1use serde::Serialize;
16
17#[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 TestWeakened,
26 ThresholdLowered,
29 SuppressionAdded,
32 SecurityCheckRemoved,
35}
36
37#[derive(Debug, Clone, Serialize)]
40#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
41pub struct WeakeningSignal {
42 pub kind: WeakeningKind,
44 pub file: String,
46 pub evidence: String,
48}
49
50#[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#[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#[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#[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#[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#[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#[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
203fn count_token(src: &str, token: &str) -> usize {
205 src.matches(token).count()
206}
207
208fn 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
218fn 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
229fn 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 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}