Skip to main content

fallow_cli/
audit_weakening.rs

1//! Weakening-signal pass (6.F), PROMOTED TO A HEADLINE per v6.
2//!
3//! A diff-scoped base-vs-head pass over the changed files that flags the
4//! AI-era failure modes a green check hides: tests removed or skipped, coverage
5//! / thresholds lowered, suppressions added, security checks/steps removed.
6//!
7//! Always ADVISORY, reviewer-private, NEVER gates, NEVER auto-posted. It rides
8//! the brief envelope (exit-0 by construction) as a headline section.
9//!
10//! Honest scope (ADR-001, syntactic): these are line-shape heuristics over the
11//! base-vs-head text of changed files, NOT a semantic test-coverage proof. A
12//! signal is an attention pointer ("the diff weakened a guardrail here"), framed
13//! so a reviewer decides.
14
15pub use fallow_output::{WeakeningKind, WeakeningSignal};
16
17/// Detect skipped-test additions: an `it.skip` / `xit` / `describe.skip` /
18/// `.only` token present in head but not base. `.only` narrows the run to a
19/// subset, silently excluding siblings, so it counts as a weakening too.
20#[must_use]
21pub fn detect_test_weakening(base: &str, head: &str) -> Vec<String> {
22    const SKIP_TOKENS: &[&str] = &[
23        "it.skip",
24        "test.skip",
25        "describe.skip",
26        "xit(",
27        "xdescribe(",
28        "it.only",
29        "test.only",
30        "describe.only",
31    ];
32    let mut signals = Vec::new();
33    for token in SKIP_TOKENS {
34        let head_count = count_token(head, token);
35        let base_count = count_token(base, token);
36        if head_count > base_count {
37            signals.push((*token).to_string());
38        }
39    }
40    signals
41}
42
43/// Detect net-removed tests: a `it(` / `test(` / `describe(` callsite count that
44/// dropped between base and head (a test deleted).
45#[must_use]
46pub fn detect_removed_tests(base: &str, head: &str) -> Vec<String> {
47    const TEST_TOKENS: &[&str] = &["it(", "test(", "describe("];
48    let mut signals = Vec::new();
49    for token in TEST_TOKENS {
50        let base_count = count_token(base, token);
51        let head_count = count_token(head, token);
52        if base_count > head_count {
53            signals.push(format!("{token} removed ({base_count} -> {head_count})"));
54        }
55    }
56    signals
57}
58
59/// Detect added suppressions: a `fallow-ignore` / `eslint-disable` / `@ts-ignore`
60/// / `@ts-expect-error` count that increased between base and head. Only counts
61/// occurrences on a COMMENT line (a line containing `//`, `#`, `/*`, `*`, or
62/// `<!--`), since a real suppression directive always lives in a comment. This
63/// keeps the token list's own definition (e.g. the string array in this file)
64/// from self-flagging, the only false-positive class the real-world smoke hit.
65#[must_use]
66pub fn detect_added_suppressions(base: &str, head: &str) -> Vec<String> {
67    const SUPPRESS_TOKENS: &[&str] = &[
68        "fallow-ignore",
69        "eslint-disable",
70        "@ts-ignore",
71        "@ts-expect-error",
72        "biome-ignore",
73        "prettier-ignore",
74    ];
75    let mut signals = Vec::new();
76    for token in SUPPRESS_TOKENS {
77        let base_count = count_token_in_comments(base, token);
78        let head_count = count_token_in_comments(head, token);
79        if head_count > base_count {
80            signals.push(format!("{token} added ({base_count} -> {head_count})"));
81        }
82    }
83    signals
84}
85
86/// Detect lowered numeric thresholds: a config key whose numeric value decreased
87/// between base and head. Scans common coverage/threshold key names; a key
88/// present in both with a strictly smaller head value is a weakening.
89#[must_use]
90pub fn detect_lowered_thresholds(base: &str, head: &str) -> Vec<String> {
91    const THRESHOLD_KEYS: &[&str] = &[
92        "branches",
93        "functions",
94        "lines",
95        "statements",
96        "minScore",
97        "min-score",
98        "maxCrap",
99        "max-crap",
100        "minCoverage",
101        "min-coverage",
102        "coverageThreshold",
103        "threshold",
104    ];
105    let mut signals = Vec::new();
106    for key in THRESHOLD_KEYS {
107        let (Some(base_val), Some(head_val)) = (
108            first_numeric_for_key(base, key),
109            first_numeric_for_key(head, key),
110        ) else {
111            continue;
112        };
113        if head_val < base_val {
114            signals.push(format!("{key}: {base_val} -> {head_val}"));
115        }
116    }
117    signals
118}
119
120/// Detect removed security CI steps: a line invoking a security scanner / audit
121/// step that was present in base but is gone in head. Scoped to CI files by the
122/// caller; here we count net-removed scanner-invoking lines.
123#[must_use]
124pub fn detect_removed_security_steps(base: &str, head: &str) -> Vec<String> {
125    const SECURITY_TOKENS: &[&str] = &[
126        "npm audit",
127        "yarn audit",
128        "pnpm audit",
129        "fallow security",
130        "codeql",
131        "snyk",
132        "trivy",
133        "semgrep",
134        "gitleaks",
135        "dependency-review",
136        "osv-scanner",
137    ];
138    let mut signals = Vec::new();
139    for token in SECURITY_TOKENS {
140        let base_count = count_token(base, token);
141        let head_count = count_token(head, token);
142        if base_count > head_count {
143            signals.push(format!("{token} step removed"));
144        }
145    }
146    signals
147}
148
149/// Whether a path looks like a CI workflow file (where security-step removal is
150/// meaningful).
151#[must_use]
152pub fn is_ci_file(rel_path: &str) -> bool {
153    rel_path.contains(".github/workflows/")
154        || rel_path.ends_with(".gitlab-ci.yml")
155        || rel_path.ends_with(".gitlab-ci.yaml")
156}
157
158/// Whether a path looks like a test file (where test removal/skip is meaningful).
159#[must_use]
160pub fn is_test_file(rel_path: &str) -> bool {
161    let lower = rel_path.to_ascii_lowercase();
162    lower.contains(".test.")
163        || lower.contains(".spec.")
164        || lower.contains("__tests__/")
165        || lower.contains("/tests/")
166        || lower.contains("/test/")
167        || lower.contains(".cy.")
168}
169
170/// Count occurrences of `token` in `src`.
171fn count_token(src: &str, token: &str) -> usize {
172    src.matches(token).count()
173}
174
175/// Count occurrences of `token` only on lines that look like a comment (a real
176/// suppression directive is always commented). Sums per-line matches so multiple
177/// suppressions on one line still count.
178fn count_token_in_comments(src: &str, token: &str) -> usize {
179    src.lines()
180        .filter(|line| line_is_comment(line))
181        .map(|line| line.matches(token).count())
182        .sum()
183}
184
185/// Whether a line contains a comment marker (`//`, `#`, `/*`, a leading `*`
186/// continuation, or `<!--`). Conservative: any of these makes the line eligible.
187fn line_is_comment(line: &str) -> bool {
188    let trimmed = line.trim_start();
189    line.contains("//")
190        || line.contains("/*")
191        || line.contains("<!--")
192        || trimmed.starts_with('#')
193        || trimmed.starts_with('*')
194}
195
196/// Find the first numeric value following `"<key>":` or `<key>:` or `<key> =`
197/// anywhere in `src`. Returns the parsed `f64`, or `None` when the key is absent
198/// or the value is not numeric. Tolerant of JSON/JSONC/TOML/YAML shapes, both
199/// inline (`{ "branches": 90 }`) and per-line. Matches the key at a word
200/// boundary (preceded by a quote, whitespace, `{`, or `,`) so `branches` does
201/// not match a longer key that merely ends in `branches`.
202fn first_numeric_for_key(src: &str, key: &str) -> Option<f64> {
203    let mut search_from = 0;
204    while let Some(rel) = src[search_from..].find(key) {
205        let start = search_from + rel;
206        search_from = start + key.len();
207        // Word-boundary check on the byte before the key.
208        let preceded_ok = start == 0
209            || src[..start]
210                .chars()
211                .next_back()
212                .is_some_and(|c| matches!(c, '"' | '\'' | ' ' | '\t' | '{' | ',' | '\n'));
213        if !preceded_ok {
214            continue;
215        }
216        let after = src[search_from..]
217            .trim_start_matches(['"', '\''])
218            .trim_start()
219            .trim_start_matches([':', '='])
220            .trim_start()
221            .trim_start_matches(['"', '\'']);
222        let num: String = after
223            .chars()
224            .take_while(|c| c.is_ascii_digit() || *c == '.' || *c == '-')
225            .collect();
226        if let Ok(value) = num.parse::<f64>() {
227            return Some(value);
228        }
229    }
230    None
231}
232
233#[cfg(test)]
234mod tests {
235    use super::*;
236
237    #[test]
238    fn injected_it_skip_is_flagged() {
239        let base = "it('works', () => { expect(x).toBe(1); });";
240        let head = "it.skip('works', () => { expect(x).toBe(1); });";
241        let signals = detect_test_weakening(base, head);
242        assert!(
243            signals.iter().any(|s| s == "it.skip"),
244            "it.skip must be flagged: {signals:?}"
245        );
246    }
247
248    #[test]
249    fn only_narrowing_is_flagged() {
250        let base = "describe('a', () => {});";
251        let head = "describe.only('a', () => {});";
252        let signals = detect_test_weakening(base, head);
253        assert!(signals.iter().any(|s| s == "describe.only"));
254    }
255
256    #[test]
257    fn unchanged_tests_produce_no_signal() {
258        let src = "it('a', () => {}); it('b', () => {});";
259        assert!(detect_test_weakening(src, src).is_empty());
260        assert!(detect_removed_tests(src, src).is_empty());
261    }
262
263    #[test]
264    fn removed_test_is_flagged() {
265        let base = "it('a', () => {}); it('b', () => {});";
266        let head = "it('a', () => {});";
267        let signals = detect_removed_tests(base, head);
268        assert_eq!(signals.len(), 1);
269        assert!(signals[0].contains("it("));
270    }
271
272    #[test]
273    fn added_suppression_is_flagged() {
274        let base = "const x = 1;";
275        let head = "// eslint-disable-next-line\nconst x = 1;";
276        let signals = detect_added_suppressions(base, head);
277        assert!(signals.iter().any(|s| s.starts_with("eslint-disable")));
278    }
279
280    #[test]
281    fn lowered_threshold_is_flagged() {
282        let base = r#"{ "branches": 90, "lines": 85 }"#;
283        let head = r#"{ "branches": 70, "lines": 85 }"#;
284        let signals = detect_lowered_thresholds(base, head);
285        assert_eq!(signals, vec!["branches: 90 -> 70".to_string()]);
286    }
287
288    #[test]
289    fn lowered_min_score_is_flagged() {
290        let base = "minScore: 80";
291        let head = "minScore: 50";
292        let signals = detect_lowered_thresholds(base, head);
293        assert_eq!(signals, vec!["minScore: 80 -> 50".to_string()]);
294    }
295
296    #[test]
297    fn raised_threshold_is_not_flagged() {
298        let base = r#"{ "branches": 70 }"#;
299        let head = r#"{ "branches": 90 }"#;
300        assert!(detect_lowered_thresholds(base, head).is_empty());
301    }
302
303    #[test]
304    fn removed_security_step_is_flagged() {
305        let base = "      - run: npm audit --audit-level=high\n      - run: build";
306        let head = "      - run: build";
307        let signals = detect_removed_security_steps(base, head);
308        assert!(signals.iter().any(|s| s.contains("npm audit")));
309    }
310
311    #[test]
312    fn file_classifiers() {
313        assert!(is_ci_file(".github/workflows/ci.yml"));
314        assert!(is_ci_file(".gitlab-ci.yml"));
315        assert!(!is_ci_file("src/app.ts"));
316        assert!(is_test_file("src/app.test.ts"));
317        assert!(is_test_file("__tests__/app.ts"));
318        assert!(!is_test_file("src/app.ts"));
319    }
320}