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