1pub use fallow_output::{WeakeningKind, WeakeningSignal};
16
17#[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#[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#[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#[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#[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#[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#[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
170fn count_token(src: &str, token: &str) -> usize {
172 src.matches(token).count()
173}
174
175fn 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
185fn 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
196fn 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 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}