Skip to main content

lean_ctx_patterns/
npm.rs

1// SPDX-License-Identifier: Apache-2.0
2
3macro_rules! static_regex {
4    ($pattern:expr_2021) => {{
5        static RE: std::sync::OnceLock<regex::Regex> = std::sync::OnceLock::new();
6        RE.get_or_init(|| {
7            regex::Regex::new($pattern).expect(concat!("BUG: invalid static regex: ", $pattern))
8        })
9    }};
10}
11
12fn added_re() -> &'static regex::Regex {
13    static_regex!(r"added (\d+) packages?")
14}
15fn time_re() -> &'static regex::Regex {
16    static_regex!(r"in (\d+\.?\d*\s*[ms]+)")
17}
18fn pkg_re() -> &'static regex::Regex {
19    static_regex!(r"\+ (\S+)@(\S+)")
20}
21fn vuln_re() -> &'static regex::Regex {
22    static_regex!(r"(\d+)\s+(critical|high|moderate|low)")
23}
24fn outdated_re() -> &'static regex::Regex {
25    static_regex!(r"^(\S+)\s+(\S+)\s+(\S+)\s+(\S+)")
26}
27
28pub fn compress(command: &str, output: &str) -> Option<String> {
29    if command.contains("install") || command.contains("add") || command.contains("ci") {
30        return Some(compress_install(output));
31    }
32    if command.contains("run") {
33        return Some(compress_run(output));
34    }
35    if command.contains("test") {
36        return Some(compress_test(output));
37    }
38    if command.contains("audit") {
39        return Some(compress_audit(output));
40    }
41    if command.contains("outdated") {
42        return Some(compress_outdated(output));
43    }
44    if command.contains("list") || command.contains("ls") {
45        return Some(compress_list(output));
46    }
47    None
48}
49
50fn compress_install(output: &str) -> String {
51    let mut packages = Vec::new();
52    let mut dep_count = 0u32;
53    let mut time = String::new();
54
55    for line in output.lines() {
56        if let Some(caps) = pkg_re().captures(line) {
57            packages.push(format!("{}@{}", &caps[1], &caps[2]));
58        }
59        if let Some(caps) = added_re().captures(line) {
60            dep_count = caps[1].parse().unwrap_or(0);
61        }
62        if let Some(caps) = time_re().captures(line) {
63            time = caps[1].to_string();
64        }
65    }
66
67    let pkg_str = if packages.is_empty() {
68        String::new()
69    } else {
70        format!("+{}", packages.join(", +"))
71    };
72
73    let dep_str = if dep_count > 0 {
74        format!(" ({dep_count} deps")
75    } else {
76        " (".to_string()
77    };
78
79    let time_str = if time.is_empty() {
80        ")".to_string()
81    } else {
82        format!(", {time})")
83    };
84
85    let summary = if pkg_str.is_empty() && dep_count > 0 {
86        format!(
87            "ok ({dep_count} deps{}",
88            if time.is_empty() {
89                ")".to_string()
90            } else {
91                format!(", {time})")
92            }
93        )
94    } else {
95        format!("{pkg_str}{dep_str}{time_str}")
96    };
97    with_install_advisories(summary, output)
98}
99
100/// Security findings an install reports must reach the agent (#1894): the
101/// `N vulnerabilities (…)` summary and the remediation command that follows.
102/// "0 vulnerabilities" is noise and stays dropped.
103pub(super) fn install_advisories(output: &str) -> Vec<String> {
104    let mut kept: Vec<String> = Vec::new();
105    for line in output.lines() {
106        let t = line.trim();
107        let lower = t.to_ascii_lowercase();
108        let is_vuln_summary = lower.contains("vulnerabilit")
109            && t.split_whitespace()
110                .any(|w| w.parse::<u32>().is_ok_and(|n| n > 0));
111        let is_fix_hint = lower.contains("audit fix") || lower.contains("audit --fix");
112        if (is_vuln_summary || is_fix_hint) && !kept.iter().any(|k| k == t) {
113            kept.push(t.to_string());
114        }
115    }
116    kept
117}
118
119pub(super) fn with_install_advisories(summary: String, output: &str) -> String {
120    let advisories = install_advisories(output);
121    if advisories.is_empty() {
122        summary
123    } else {
124        format!("{summary}\n{}", advisories.join("\n"))
125    }
126}
127
128fn compress_run(output: &str) -> String {
129    let lines: Vec<&str> = output
130        .lines()
131        .filter(|l| {
132            let t = l.trim();
133            !t.is_empty()
134                && !t.starts_with('>')
135                && !t.starts_with("npm warn")
136                && !t.contains("npm fund")
137                && !t.contains("looking for funding")
138        })
139        .collect();
140
141    if lines.len() <= 15 {
142        return lines.join("\n");
143    }
144
145    let last = lines.len().saturating_sub(10);
146    format!("...({} lines)\n{}", lines.len(), lines[last..].join("\n"))
147}
148
149fn compress_test(output: &str) -> String {
150    let jest_re = static_regex!(
151        r"Tests:\s+(?:(\d+)\s+failed,?\s*)?(?:(\d+)\s+skipped,?\s*)?(?:(\d+)\s+passed,?\s*)?(\d+)\s+total"
152    );
153    let vitest_re = static_regex!(
154        r"Test Files\s+(?:(\d+)\s+failed\s*\|?\s*)?(?:(\d+)\s+passed\s*\|?\s*)?(\d+)\s+total"
155    );
156    let mocha_re = static_regex!(r"(\d+)\s+passing.*\n\s*(?:(\d+)\s+failing)?");
157    let test_line_re = static_regex!(r"^\s*(✓|✗|✘|×|PASS|FAIL|ok|not ok)\s");
158
159    for line in output.lines() {
160        if let Some(caps) = jest_re.captures(line) {
161            let failed: u32 = caps
162                .get(1)
163                .and_then(|m| m.as_str().parse().ok())
164                .unwrap_or(0);
165            let skipped: u32 = caps
166                .get(2)
167                .and_then(|m| m.as_str().parse().ok())
168                .unwrap_or(0);
169            let passed: u32 = caps
170                .get(3)
171                .and_then(|m| m.as_str().parse().ok())
172                .unwrap_or(0);
173            let total: u32 = caps
174                .get(4)
175                .and_then(|m| m.as_str().parse().ok())
176                .unwrap_or(0);
177            return format!("tests: {passed} pass, {failed} fail, {skipped} skip ({total} total)");
178        }
179        if let Some(caps) = vitest_re.captures(line) {
180            let failed: u32 = caps
181                .get(1)
182                .and_then(|m| m.as_str().parse().ok())
183                .unwrap_or(0);
184            let passed: u32 = caps
185                .get(2)
186                .and_then(|m| m.as_str().parse().ok())
187                .unwrap_or(0);
188            let total: u32 = caps
189                .get(3)
190                .and_then(|m| m.as_str().parse().ok())
191                .unwrap_or(0);
192            return format!("tests: {passed} pass, {failed} fail ({total} total)");
193        }
194    }
195
196    if let Some(caps) = mocha_re.captures(output) {
197        let passed: u32 = caps
198            .get(1)
199            .and_then(|m| m.as_str().parse().ok())
200            .unwrap_or(0);
201        let failed: u32 = caps
202            .get(2)
203            .and_then(|m| m.as_str().parse().ok())
204            .unwrap_or(0);
205        return format!("tests: {passed} pass, {failed} fail");
206    }
207
208    let mut passed = 0u32;
209    let mut failed = 0u32;
210    for line in output.lines() {
211        let trimmed = line.trim();
212        if test_line_re.is_match(trimmed) {
213            let low = trimmed.to_lowercase();
214            if low.starts_with("✓") || low.starts_with("pass") || low.starts_with("ok ") {
215                passed += 1;
216            } else {
217                failed += 1;
218            }
219        }
220    }
221
222    if passed > 0 || failed > 0 {
223        return format!("tests: {passed} pass, {failed} fail");
224    }
225
226    compact_output(output, 10)
227}
228
229fn compress_audit(output: &str) -> String {
230    let mut severities = std::collections::HashMap::new();
231    let mut total_vulns = 0u32;
232    let mut detail_lines: Vec<String> = Vec::new();
233
234    for line in output.lines() {
235        if let Some(caps) = vuln_re().captures(line) {
236            let count: u32 = caps[1].parse().unwrap_or(0);
237            let severity = caps[2].to_string();
238            *severities.entry(severity).or_insert(0u32) += count;
239            total_vulns += count;
240        }
241
242        let lower = line.to_ascii_lowercase();
243        let is_detail = lower.contains("cve-")
244            || lower.contains("severity")
245            || lower.contains("fix available")
246            || lower.contains("package")
247            || lower.contains("depends on vulnerable")
248            || lower.contains("vulnerability")
249            || lower.contains("moderate")
250            || lower.contains("high")
251            || lower.contains("critical");
252        if is_detail && detail_lines.len() < 30 {
253            detail_lines.push(line.to_string());
254        }
255    }
256
257    if total_vulns == 0 {
258        if output.to_lowercase().contains("no vulnerabilities") || output.trim().is_empty() {
259            return "ok (0 vulnerabilities)".to_string();
260        }
261        return compact_output(output, 5);
262    }
263
264    let mut parts = Vec::new();
265    for sev in &["critical", "high", "moderate", "low"] {
266        if let Some(count) = severities.get(*sev) {
267            parts.push(format!("{count} {sev}"));
268        }
269    }
270
271    let summary = format!("{total_vulns} vulnerabilities: {}", parts.join(", "));
272    if detail_lines.is_empty() {
273        return summary;
274    }
275
276    format!("{summary}\n{}", detail_lines.join("\n"))
277}
278
279fn compress_outdated(output: &str) -> String {
280    let lines: Vec<&str> = output.lines().collect();
281    if lines.len() <= 1 {
282        return "all up-to-date".to_string();
283    }
284
285    let mut packages = Vec::new();
286    for line in &lines[1..] {
287        if let Some(caps) = outdated_re().captures(line) {
288            let name = &caps[1];
289            let current = &caps[2];
290            let wanted = &caps[3];
291            let latest = &caps[4];
292            packages.push(format!("{name}: {current} → {latest} (wanted: {wanted})"));
293        }
294    }
295
296    if packages.is_empty() {
297        return "all up-to-date".to_string();
298    }
299    format!("{} outdated:\n{}", packages.len(), packages.join("\n"))
300}
301
302fn compress_list(output: &str) -> String {
303    let lines: Vec<&str> = output.lines().collect();
304    if lines.len() <= 5 {
305        return output.to_string();
306    }
307
308    let top_level: Vec<&str> = lines
309        .iter()
310        .filter(|l| {
311            l.starts_with("├──")
312                || l.starts_with("└──")
313                || l.starts_with("+--")
314                || l.starts_with("`--")
315        })
316        .copied()
317        .collect();
318
319    if top_level.is_empty() {
320        return compact_output(output, 10);
321    }
322
323    let cleaned: Vec<String> = top_level
324        .iter()
325        .map(|l| {
326            l.replace("├──", "")
327                .replace("└──", "")
328                .replace("+--", "")
329                .replace("`--", "")
330                .trim()
331                .to_string()
332        })
333        .collect();
334
335    format!("{} packages:\n{}", cleaned.len(), cleaned.join("\n"))
336}
337
338fn compact_output(text: &str, max: usize) -> String {
339    let lines: Vec<&str> = text.lines().filter(|l| !l.trim().is_empty()).collect();
340    if lines.len() <= max {
341        return lines.join("\n");
342    }
343    format!(
344        "{}\n{}",
345        lines[..max].join("\n"),
346        super::elision_marker(lines.len() - max)
347    )
348}
349
350#[cfg(test)]
351mod tests {
352    use super::compress;
353
354    #[test]
355    fn install_keeps_vulnerability_summary_and_fix_hint() {
356        let out = "added 847 packages, and audited 848 packages in 12s\n\n3 vulnerabilities (1 moderate, 2 high)\n\nTo address all issues, run:\n  npm audit fix\n";
357        assert_eq!(
358            compress("npm install", out).unwrap(),
359            "ok (847 deps, 12s)\n3 vulnerabilities (1 moderate, 2 high)\nnpm audit fix"
360        );
361    }
362
363    #[test]
364    fn install_drops_zero_vulnerabilities() {
365        let out = "added 2 packages, and audited 3 packages in 1s\n\nfound 0 vulnerabilities\n";
366        assert_eq!(compress("npm ci", out).unwrap(), "ok (2 deps, 1s)");
367    }
368}