1use rustc_hash::{FxHashMap, FxHashSet};
8
9#[derive(Debug, Clone, PartialEq, Eq)]
11pub struct LineHit {
12 pub line: u32,
14 pub evidence: String,
16}
17
18const TEST_CASE_ROOTS: &[&str] = &["it", "test", "specify", "xit", "xtest", "fit"];
20const SUITE_ROOTS: &[&str] = &["describe", "suite", "context"];
22const PREFIXED_ROOTS: &[&str] = &["xit", "xtest", "xdescribe", "fit", "fdescribe"];
24const TEST_CASE_MEMBERS: &[&str] = &[
26 "skip",
27 "only",
28 "each",
29 "todo",
30 "concurrent",
31 "sequential",
32 "fails",
33 "failing",
34 "fixme",
35 "skipIf",
36 "runIf",
37];
38const EXPECT_ASSERTION_MEMBERS: &[&str] = &["soft", "poll"];
40
41const SUPPRESSION_TOKENS: &[&str] = &[
43 "eslint-disable",
44 "oxlint-disable",
45 "biome-ignore",
46 "deno-lint-ignore",
47 "@ts-ignore",
48 "@ts-expect-error",
49 "@ts-nocheck",
50 "istanbul ignore",
51 "c8 ignore",
52 "v8 ignore",
53];
54const FALLOW_IGNORE_TOKEN: &str = "fallow-ignore";
55const DIFF_RISK_TOKENS: &[&str] = &[
59 "weakened-tests",
60 "added-suppression",
61 "test-only-branch",
62 "diverged-clone-sibling",
63 "added-dependency",
64];
65
66const MIN_THRESHOLD_KEYS: &[&str] = &[
68 "branches",
69 "functions",
70 "lines",
71 "statements",
72 "minScore",
73 "min-score",
74 "minCoverage",
75 "min-coverage",
76];
77const MAX_THRESHOLD_KEYS: &[&str] = &[
79 "maxCrap",
80 "max-crap",
81 "maxCyclomatic",
82 "max-cyclomatic",
83 "maxCognitive",
84 "max-cognitive",
85];
86
87#[must_use]
92pub fn added_lines<'a>(base: &str, head: &'a str) -> Vec<(u32, &'a str)> {
93 let mut remaining: FxHashMap<&str, usize> = FxHashMap::default();
94 for line in base.lines() {
95 *remaining.entry(line.trim()).or_default() += 1;
96 }
97 let mut head_counts: FxHashMap<&str, usize> = FxHashMap::default();
98 for line in head.lines() {
99 *head_counts.entry(line.trim()).or_default() += 1;
100 }
101 let mut kept: FxHashMap<&str, usize> = head_counts
103 .iter()
104 .map(|(text, count)| {
105 let in_base = remaining.get(text).copied().unwrap_or(0);
106 (*text, (*count).min(in_base))
107 })
108 .collect();
109 let mut added = Vec::new();
110 for (index, line) in head.lines().enumerate() {
111 let text = line.trim();
112 if text.is_empty() {
113 continue;
114 }
115 let keep = kept.entry(text).or_default();
116 if *keep > 0 {
117 *keep -= 1;
118 continue;
119 }
120 added.push((line_number(index), line));
121 }
122 added
123}
124
125#[must_use]
129pub fn code_identifiers(src: &str) -> FxHashSet<String> {
130 let mut found = FxHashSet::default();
131 for line in src.lines() {
132 let code = blank_string_literals(code_part(line));
133 found.extend(
134 code.split(|c: char| !(c.is_ascii_alphanumeric() || c == '_' || c == '$'))
135 .filter(|token| {
136 token
137 .bytes()
138 .next()
139 .is_some_and(|byte| !byte.is_ascii_digit())
140 })
141 .map(str::to_owned),
142 );
143 }
144 found
145}
146
147#[must_use]
149pub fn skip_only_hits(base: &str, head: &str) -> Vec<LineHit> {
150 let mut hits = Vec::new();
151 for (line, text) in added_lines(base, head) {
152 for chain in call_chains(code_part(text)) {
153 if is_skip_or_focus(&chain) {
154 hits.push(LineHit {
155 line,
156 evidence: chain.join("."),
157 });
158 break;
159 }
160 }
161 }
162 hits
163}
164
165#[must_use]
167pub fn test_case_count(src: &str) -> usize {
168 src.lines()
169 .flat_map(|line| call_chains(code_part(line)))
170 .filter(|chain| is_test_case(chain))
171 .count()
172}
173
174#[must_use]
177pub fn assertion_count(src: &str) -> usize {
178 src.lines()
179 .map(|line| {
180 let code = code_part(line);
181 let chains = call_chains(code)
182 .into_iter()
183 .filter(|chain| is_assertion(chain))
184 .count();
185 chains + member_should_count(code)
186 })
187 .sum()
188}
189
190#[must_use]
193pub fn removed_assertion_lines(base: &str, head: &str) -> (usize, usize) {
194 added_lines(head, base)
195 .into_iter()
196 .filter(|(_, line)| !code_part(line).trim().is_empty())
197 .fold((0, 0), |(asserts, code), (_, line)| {
198 (asserts + usize::from(assertion_count(line) > 0), code + 1)
199 })
200}
201
202#[must_use]
204pub fn added_suppression_hits(base: &str, head: &str) -> Vec<LineHit> {
205 added_lines(base, head)
206 .into_iter()
207 .filter_map(|(line, text)| {
208 let comment = comment_part(text)?;
209 suppression_token(comment).map(|token| LineHit {
210 line,
211 evidence: token.to_owned(),
212 })
213 })
214 .collect()
215}
216
217#[must_use]
220pub fn test_only_branch_hits(base: &str, head: &str) -> Vec<LineHit> {
221 let head_lines: Vec<&str> = head.lines().collect();
222 added_lines(base, head)
223 .into_iter()
224 .filter_map(|(line, text)| {
225 let code = code_part(text);
226 let token = test_environment_token(code)?;
227 if picks_env_file(code) || only_quiets_logs(&head_lines, line) {
228 return None;
229 }
230 Some(LineHit {
231 line,
232 evidence: token.to_owned(),
233 })
234 })
235 .collect()
236}
237
238fn picks_env_file(code: &str) -> bool {
242 ["'.env", "\".env", "`.env"]
243 .iter()
244 .any(|literal| code.contains(literal))
245}
246
247const MAX_GUARDED_LINES: usize = 12;
249
250fn only_quiets_logs(lines: &[&str], line: u32) -> bool {
253 let index = usize::try_from(line).unwrap_or(0).saturating_sub(1);
254 let Some(check) = lines.get(index) else {
255 return false;
256 };
257 let check = blank_string_literals(code_part(check));
258 if !check.contains("!=") {
259 return false;
260 }
261 if check.contains("console.") {
262 return true;
263 }
264 let mut body = lines
265 .iter()
266 .skip(index + 1)
267 .take(MAX_GUARDED_LINES)
268 .map(|line| code_part(line).trim())
269 .filter(|code| !code.is_empty() && !matches!(*code, "{" | ") {" | ")"));
270 if !body
271 .next()
272 .is_some_and(|first| first.starts_with("console."))
273 {
274 return false;
275 }
276 for code in body {
279 if code.starts_with('}') {
280 return true;
281 }
282 let continues = code.starts_with("console.")
283 || code.starts_with(['\'', '"', '`', ')', ','])
284 || code.ends_with(',');
285 if !continues {
286 return false;
287 }
288 }
289 false
290}
291
292#[must_use]
295pub fn weakened_threshold_hits(base: &str, head: &str) -> Vec<LineHit> {
296 let mut hits = Vec::new();
297 for (keys, lower_is_weaker) in [(MIN_THRESHOLD_KEYS, true), (MAX_THRESHOLD_KEYS, false)] {
298 for key in keys {
299 let base_values = numeric_values_for_key(base, key);
300 let head_values = numeric_values_for_key(head, key);
301 for ((base_value, _), (head_value, head_line)) in
302 base_values.iter().zip(head_values.iter())
303 {
304 let weaker = if lower_is_weaker {
305 head_value < base_value
306 } else {
307 head_value > base_value
308 };
309 if weaker {
310 hits.push(LineHit {
311 line: *head_line,
312 evidence: format!("{key}: {base_value} -> {head_value}"),
313 });
314 }
315 }
316 }
317 }
318 hits.sort_by_key(|hit| hit.line);
319 hits
320}
321
322#[must_use]
324pub fn is_coverage_config_path(rel_path: &str) -> bool {
325 let name = rel_path.rsplit('/').next().unwrap_or(rel_path);
326 if rel_path.contains("node_modules/") {
327 return false;
328 }
329 const EXACT: &[&str] = &[
330 "package.json",
331 ".nycrc",
332 ".nycrc.json",
333 ".nycrc.yml",
334 ".nycrc.yaml",
335 ".c8rc",
336 ".c8rc.json",
337 ".fallowrc.json",
338 ".fallowrc.jsonc",
339 "fallow.toml",
340 ".fallow.toml",
341 ];
342 const PREFIXES: &[&str] = &[
343 "vitest.config.",
344 "vitest.workspace.",
345 "vite.config.",
346 "jest.config.",
347 "karma.conf.",
348 "playwright.config.",
349 ];
350 EXACT.contains(&name) || PREFIXES.iter().any(|prefix| name.starts_with(prefix))
351}
352
353fn line_number(index: usize) -> u32 {
354 u32::try_from(index + 1).unwrap_or(u32::MAX)
355}
356
357fn code_part(line: &str) -> &str {
360 let trimmed = line.trim_start();
361 if trimmed.starts_with("//")
362 || trimmed.starts_with("/*")
363 || trimmed.starts_with('*')
364 || trimmed.starts_with("<!--")
365 {
366 return "";
367 }
368 match comment_start(line) {
369 Some(start) => &line[..start],
370 None => line,
371 }
372}
373
374fn comment_part(line: &str) -> Option<&str> {
376 let trimmed = line.trim_start();
377 if trimmed.starts_with('*') || trimmed.starts_with('#') {
378 return Some(trimmed);
379 }
380 comment_start(line).map(|start| &line[start..])
381}
382
383fn comment_start(line: &str) -> Option<usize> {
388 let code = blank_string_literals(line);
389 let bytes = code.as_bytes();
390 let mut index = 0;
391 let mut script = None;
392 while index + 1 < bytes.len() {
393 let pair = (bytes[index], bytes[index + 1]);
394 let is_line_comment = pair == (b'/', b'/') && (index == 0 || bytes[index - 1] != b':');
395 if is_line_comment || pair == (b'/', b'*') {
396 script = Some(index);
397 break;
398 }
399 index += 1;
400 }
401 let markup = line.find("<!--");
402 match (script, markup) {
403 (Some(a), Some(b)) => Some(a.min(b)),
404 (a, b) => a.or(b),
405 }
406}
407
408const fn is_ident_byte(byte: u8) -> bool {
409 byte.is_ascii_alphanumeric() || byte == b'_' || byte == b'$'
410}
411
412fn call_chains(code: &str) -> Vec<Vec<&str>> {
416 let bytes = code.as_bytes();
417 let mut chains = Vec::new();
418 let mut index = 0;
419 while index < bytes.len() {
420 let starts_ident = is_ident_byte(bytes[index])
421 && !bytes[index].is_ascii_digit()
422 && (index == 0 || (!is_ident_byte(bytes[index - 1]) && bytes[index - 1] != b'.'));
423 if !starts_ident {
424 index += 1;
425 continue;
426 }
427 let mut chain = Vec::new();
428 let mut cursor = index;
429 loop {
430 let start = cursor;
431 while cursor < bytes.len() && is_ident_byte(bytes[cursor]) {
432 cursor += 1;
433 }
434 chain.push(&code[start..cursor]);
435 if cursor + 1 < bytes.len() && bytes[cursor] == b'.' && is_ident_byte(bytes[cursor + 1])
436 {
437 cursor += 1;
438 continue;
439 }
440 break;
441 }
442 let next = code[cursor..].trim_start().bytes().next();
443 if matches!(next, Some(b'(' | b'`')) {
444 chains.push(chain);
445 }
446 index = cursor.max(index + 1);
447 }
448 chains
449}
450
451fn is_skip_or_focus(chain: &[&str]) -> bool {
452 let Some(root) = chain.first() else {
453 return false;
454 };
455 if PREFIXED_ROOTS.contains(root) {
456 return true;
457 }
458 (TEST_CASE_ROOTS.contains(root) || SUITE_ROOTS.contains(root))
459 && chain[1..]
460 .iter()
461 .any(|member| matches!(*member, "skip" | "only"))
462}
463
464fn is_test_case(chain: &[&str]) -> bool {
465 chain
466 .first()
467 .is_some_and(|root| TEST_CASE_ROOTS.contains(root))
468 && chain[1..]
469 .iter()
470 .all(|member| TEST_CASE_MEMBERS.contains(member))
471}
472
473fn is_assertion(chain: &[&str]) -> bool {
474 match chain {
475 ["expect"] | ["should" | "assert", ..] => true,
476 ["expect", member] => EXPECT_ASSERTION_MEMBERS.contains(member),
477 _ => false,
478 }
479}
480
481fn member_should_count(code: &str) -> usize {
483 code.match_indices(".should")
484 .filter(|(start, token)| {
485 let after = code.as_bytes().get(start + token.len()).copied();
486 matches!(after, Some(b'.' | b'('))
487 })
488 .count()
489}
490
491fn suppression_token(comment: &str) -> Option<&'static str> {
494 let generic = SUPPRESSION_TOKENS
495 .iter()
496 .filter_map(|token| comment.find(token).map(|at| (at, *token)))
497 .min_by_key(|(at, _)| *at);
498 let fallow = comment
499 .find(FALLOW_IGNORE_TOKEN)
500 .filter(|at| !names_only_diff_risk_rules(&comment[*at..]))
501 .map(|at| (at, FALLOW_IGNORE_TOKEN));
502 [generic, fallow]
503 .into_iter()
504 .flatten()
505 .min_by_key(|(at, _)| *at)
506 .map(|(_, token)| token)
507}
508
509fn names_only_diff_risk_rules(directive: &str) -> bool {
512 let body = directive
513 .split_once("--")
514 .map_or(directive, |(tokens, _reason)| tokens);
515 let body = body
516 .trim_end()
517 .trim_end_matches("*/")
518 .trim_end_matches("-->");
519 let mut words = body.split_whitespace();
520 words.next();
522 let tokens: Vec<&str> = words
523 .flat_map(|word| word.split(','))
524 .filter(|token| !token.is_empty())
525 .collect();
526 !tokens.is_empty() && tokens.iter().all(|token| DIFF_RISK_TOKENS.contains(token))
527}
528
529fn test_environment_token(code: &str) -> Option<&'static str> {
531 let outside = blank_string_literals(code);
536 for token in ["process.env.VITEST", "process.env.JEST_WORKER_ID"] {
537 if outside.contains(token) {
538 return Some(token);
539 }
540 }
541 let compares_test = ["'test'", "\"test\"", "`test`"]
542 .iter()
543 .any(|literal| code.contains(literal))
544 && (outside.contains("==") || outside.contains("!="));
545 (outside.contains("process.env.NODE_ENV") && compares_test).then_some("process.env.NODE_ENV")
546}
547
548fn blank_string_literals(code: &str) -> String {
553 let mut out = String::with_capacity(code.len());
554 let mut quote: Option<char> = None;
555 let mut escaped = false;
556 for c in code.chars() {
557 let Some(open) = quote else {
558 if matches!(c, '\'' | '"' | '`') {
559 quote = Some(c);
560 }
561 out.push(c);
562 continue;
563 };
564 if c == open && !escaped {
565 quote = None;
566 out.push(c);
567 continue;
568 }
569 escaped = !escaped && c == '\\';
570 out.extend(std::iter::repeat_n(' ', c.len_utf8()));
571 }
572 out
573}
574
575fn numeric_values_for_key(src: &str, key: &str) -> Vec<(f64, u32)> {
578 let mut values = Vec::new();
579 let mut line = 1u32;
580 let mut scanned = 0;
581 let mut search_from = 0;
582 while let Some(relative) = src[search_from..].find(key) {
583 let start = search_from + relative;
584 let end = start + key.len();
585 search_from = end;
586 let before = src[..start].bytes().next_back();
587 let after = src.as_bytes().get(end).copied();
588 let stands_alone = !before.is_some_and(|byte| is_ident_byte(byte) || byte == b'-')
589 && !after.is_some_and(|byte| is_ident_byte(byte) || byte == b'-');
590 if !stands_alone {
591 continue;
592 }
593 let rest = src[end..]
594 .trim_start_matches(['"', '\''])
595 .trim_start_matches([' ', '\t']);
596 let Some(rest) = rest.strip_prefix([':', '=']) else {
597 continue;
598 };
599 let rest = rest.trim_start_matches([' ', '\t', '"', '\'']);
600 let number: String = rest
601 .chars()
602 .take_while(|c| c.is_ascii_digit() || *c == '.' || *c == '-')
603 .collect();
604 let Ok(value) = number.parse::<f64>() else {
605 continue;
606 };
607 line += u32::try_from(src[scanned..start].matches('\n').count()).unwrap_or(0);
608 scanned = start;
609 values.push((value, line));
610 }
611 values
612}
613
614#[cfg(test)]
615mod tests {
616 use super::*;
617
618 #[test]
619 fn explain_text_names_every_detected_suppression_token() {
620 let full = crate::explain::AUDIT_RULES
621 .iter()
622 .find(|rule| rule.id == "fallow/added-suppression")
623 .map(|rule| rule.full)
624 .unwrap_or_default();
625 for token in SUPPRESSION_TOKENS.iter().chain([&FALLOW_IGNORE_TOKEN]) {
626 assert!(
627 full.contains(&format!("`{token}`")),
628 "the added-suppression explain text must name `{token}`"
629 );
630 }
631 }
632
633 #[test]
634 fn added_lines_ignore_moved_lines() {
635 let base = "a\nb\nc\n";
636 let head = "c\na\nb\nd\n";
637 assert_eq!(added_lines(base, head), vec![(4, "d")]);
638 }
639
640 #[test]
641 fn added_skip_and_only_markers_are_hits_at_their_line() {
642 let base = "it('a', () => {});\ntest('b', () => {});\n";
643 let head = "it.skip('a', () => {});\ntest.only('b', () => {});\nxit('c', f);\n";
644 let hits = skip_only_hits(base, head);
645 let evidence: Vec<(u32, &str)> = hits
646 .iter()
647 .map(|hit| (hit.line, hit.evidence.as_str()))
648 .collect();
649 assert_eq!(evidence, vec![(1, "it.skip"), (2, "test.only"), (3, "xit")]);
650 }
651
652 #[test]
653 fn existing_or_commented_skip_markers_are_not_hits() {
654 let base = "it.skip('a', () => {});\n";
655 let head = "// it.only('x')\nit.skip('a', () => {});\nconst profit = fit2(1);\n";
656 assert!(skip_only_hits(base, head).is_empty());
657 }
658
659 #[test]
660 fn test_case_count_counts_cases_with_modifiers_only() {
661 let src = "describe('s', () => {\n it('a', f);\n it.skip('b', f);\n test.each([1])('c', f);\n test.describe('d', f);\n submit(1);\n // it('commented', f);\n});\n";
662 assert_eq!(test_case_count(src), 3);
663 }
664
665 #[test]
666 fn assertion_count_counts_expect_assert_and_should() {
667 let src = "expect(a).toBe(1);\nexpect.soft(b).toBe(2);\nassert.equal(c, 3);\nassert(d);\nx.should.equal(4);\nexpect.assertions(2);\n// expect(e)\n";
668 assert_eq!(assertion_count(src), 5);
669 }
670
671 #[test]
672 fn added_suppression_comments_are_hits() {
673 let base = "const a = 1;\n// eslint-disable-next-line no-console\nconsole.log(a);\n";
674 let head = "// @ts-ignore\nconst a = 1;\n// eslint-disable-next-line no-console\nconsole.log(a);\n/* istanbul ignore next */\nconst s = 'eslint-disable';\n";
675 let hits = added_suppression_hits(base, head);
676 let evidence: Vec<(u32, &str)> = hits
677 .iter()
678 .map(|hit| (hit.line, hit.evidence.as_str()))
679 .collect();
680 assert_eq!(evidence, vec![(1, "@ts-ignore"), (5, "istanbul ignore")]);
681 }
682
683 #[test]
684 fn fallow_ignore_that_names_only_diff_risk_rules_is_not_an_added_suppression() {
685 let head = "// fallow-ignore-next-line weakened-tests\nit.skip('a', f);\n// fallow-ignore-next-line unused-export\nexport const b = 1;\n// fallow-ignore-file\n";
686 let hits = added_suppression_hits("", head);
687 let lines: Vec<u32> = hits.iter().map(|hit| hit.line).collect();
688 assert_eq!(lines, vec![3, 5]);
689 }
690
691 #[test]
692 fn comment_markers_inside_string_literals_are_not_suppressions() {
693 let head = "out.push('/* eslint-disable */');\nout.push(\"// @ts-nocheck\");\nconst u = 'it\\'s'; // @ts-ignore\n<!-- eslint-disable -->\n";
694 let hits = added_suppression_hits("", head);
695 let evidence: Vec<(u32, &str)> = hits
696 .iter()
697 .map(|hit| (hit.line, hit.evidence.as_str()))
698 .collect();
699 assert_eq!(evidence, vec![(3, "@ts-ignore"), (4, "eslint-disable")]);
700 }
701
702 #[test]
703 fn multibyte_characters_before_a_comment_do_not_panic() {
704 let head = "const name = 'café'; // @ts-ignore\nconst ünï = 1;\n";
705 let hits = added_suppression_hits("", head);
706 assert_eq!(hits.len(), 1);
707 assert_eq!(hits[0].line, 1);
708 }
709
710 #[test]
711 fn quiet_logs_in_tests_are_not_hits() {
712 let head = "if (process.env.NODE_ENV !== 'test') {\n console.error(message);\n}\nif (\n status !== 'ok' &&\n process.env.NODE_ENV !== 'test'\n) {\n console.warn(\n `not scheduled`,\n );\n}\nif (process.env.NODE_ENV !== 'test') {\n sendMetrics();\n}\n";
713 let lines: Vec<u32> = test_only_branch_hits("", head)
714 .iter()
715 .map(|hit| hit.line)
716 .collect();
717 assert_eq!(lines, vec![12]);
718 }
719
720 #[test]
721 fn test_env_file_selection_is_not_a_hit() {
722 let head = "config({ path: process.env.NODE_ENV === 'test' ? '.env.test' : '.env' });\nconst key = process.env.NODE_ENV === 'test' ? 'test:' : '';\n";
723 let lines: Vec<u32> = test_only_branch_hits("", head)
724 .iter()
725 .map(|hit| hit.line)
726 .collect();
727 assert_eq!(lines, vec![2]);
728 }
729
730 #[test]
731 fn added_test_environment_branches_are_hits() {
732 let head = "if (process.env.NODE_ENV === 'test') return;\nif (process.env.NODE_ENV === 'production') x();\nconst w = process.env.JEST_WORKER_ID;\nif (process.env.VITEST) y();\n// process.env.VITEST\n";
733 let lines: Vec<u32> = test_only_branch_hits("", head)
734 .iter()
735 .map(|hit| hit.line)
736 .collect();
737 assert_eq!(lines, vec![1, 3, 4]);
738 }
739
740 #[test]
741 fn vitest_in_source_test_block_is_not_a_hit() {
742 let head = "export const add = (a, b) => a + b;\nif (import.meta.vitest) {\n const { it, expect } = import.meta.vitest;\n it('adds', () => expect(add(1, 2)).toBe(3));\n}\n";
743 assert!(test_only_branch_hits("", head).is_empty());
744 }
745
746 #[test]
747 fn test_environment_tokens_inside_strings_are_not_hits() {
748 let head = "return code.slice(a, b) === 'import.meta.vitest'\nconst m = `inside the \\`if (import.meta.vitest)\\` block`;\nconst s = \"process.env.VITEST\";\n";
749 assert!(test_only_branch_hits("", head).is_empty());
750 }
751
752 #[test]
753 fn lowered_min_threshold_and_raised_max_threshold_are_hits() {
754 let base = "thresholds: {\n lines: 90,\n branches: 80,\n},\nmaxCrap: 30,\n";
755 let head = "thresholds: {\n lines: 70,\n branches: 85,\n},\nmaxCrap: 45,\n";
756 let hits = weakened_threshold_hits(base, head);
757 let evidence: Vec<(u32, &str)> = hits
758 .iter()
759 .map(|hit| (hit.line, hit.evidence.as_str()))
760 .collect();
761 assert_eq!(
762 evidence,
763 vec![(2, "lines: 90 -> 70"), (5, "maxCrap: 30 -> 45")]
764 );
765 }
766
767 #[test]
768 fn json_thresholds_are_compared_by_key() {
769 let base = r#"{"jest": {"coverageThreshold": {"global": {"statements": 95}}}}"#;
770 let head = r#"{"jest": {"coverageThreshold": {"global": {"statements": 50}}}}"#;
771 let hits = weakened_threshold_hits(base, head);
772 assert_eq!(hits.len(), 1);
773 assert_eq!(hits[0].evidence, "statements: 95 -> 50");
774 }
775
776 #[test]
777 fn coverage_config_paths() {
778 assert!(is_coverage_config_path("vitest.config.ts"));
779 assert!(is_coverage_config_path("packages/a/jest.config.js"));
780 assert!(is_coverage_config_path("package.json"));
781 assert!(is_coverage_config_path(".nycrc.json"));
782 assert!(is_coverage_config_path(".fallowrc.json"));
783 assert!(!is_coverage_config_path("src/lines.ts"));
784 }
785}