Skip to main content

fallow_api/diff_risk/
text.rs

1//! Line-level detectors over the base and head text of one changed file.
2//!
3//! The detectors are syntactic. They compare token counts and added lines,
4//! and they do not parse the file. A line counts as added when its trimmed
5//! text occurs more often in head than in base, so a moved line is not added.
6
7use rustc_hash::{FxHashMap, FxHashSet};
8
9/// One detector hit on a head line.
10#[derive(Debug, Clone, PartialEq, Eq)]
11pub struct LineHit {
12    /// 1-based head line.
13    pub line: u32,
14    /// The token or value change that supports the hit.
15    pub evidence: String,
16}
17
18/// Roots of a test-case call: `it(...)`, `test.skip(...)`.
19const TEST_CASE_ROOTS: &[&str] = &["it", "test", "specify", "xit", "xtest", "fit"];
20/// Roots of a suite call that can carry a skip or focus marker.
21const SUITE_ROOTS: &[&str] = &["describe", "suite", "context"];
22/// Roots that skip or focus by name alone.
23const PREFIXED_ROOTS: &[&str] = &["xit", "xtest", "xdescribe", "fit", "fdescribe"];
24/// Members that keep a call a test case (`it.each`, `test.concurrent`).
25const 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];
38/// Members of `expect` that are an assertion (`expect.soft(x)`).
39const EXPECT_ASSERTION_MEMBERS: &[&str] = &["soft", "poll"];
40
41/// Comment tokens that turn off a check. `fallow-ignore` has its own rule.
42const 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";
55/// Suppression tokens of the diff-risk rules. A `fallow-ignore` comment that
56/// names only these is the suppression the findings ask for, so it is not an
57/// added suppression itself.
58const DIFF_RISK_TOKENS: &[&str] = &[
59    "weakened-tests",
60    "added-suppression",
61    "test-only-branch",
62    "diverged-clone-sibling",
63    "added-dependency",
64];
65
66/// Threshold keys where a lower value is less strict.
67const MIN_THRESHOLD_KEYS: &[&str] = &[
68    "branches",
69    "functions",
70    "lines",
71    "statements",
72    "minScore",
73    "min-score",
74    "minCoverage",
75    "min-coverage",
76];
77/// Threshold keys where a higher value is less strict.
78const MAX_THRESHOLD_KEYS: &[&str] = &[
79    "maxCrap",
80    "max-crap",
81    "maxCyclomatic",
82    "max-cyclomatic",
83    "maxCognitive",
84    "max-cognitive",
85];
86
87/// Lines of `head` that do not occur in `base`, as `(1-based line, text)`.
88///
89/// Lines compare by trimmed text as a multiset. When head has more copies of
90/// a line than base, the last copies count as added. Blank lines never count.
91#[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    // The number of leading copies of each text that base already holds.
102    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/// The identifier tokens in the code of `src`. Comments and the content of
126/// string literals do not count, so a word in a test title or a comment is
127/// not an identifier. A token that starts with a digit is a number.
128#[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/// Added lines of a test file that add a skip or focus marker.
148#[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/// The number of test-case calls in a test file, comments excluded.
166#[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/// The number of assertion calls (`expect(`, `assert`, `should`) in a test
175/// file, comments excluded.
176#[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/// The code lines that the change removed from a file, as `(assertion lines,
191/// code lines)`. Comment and blank lines do not count.
192#[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/// Added comment lines that add a suppression.
203#[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/// Added lines of a non-test source file that branch on the test
218/// environment.
219#[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
238/// Whether a test-environment check picks an env file, such as
239/// `config({ path: NODE_ENV === 'test' ? '.env.test' : '.env' })`. That is
240/// configuration, not a code path.
241fn picks_env_file(code: &str) -> bool {
242    ["'.env", "\".env", "`.env"]
243        .iter()
244        .any(|literal| code.contains(literal))
245}
246
247/// The most lines that [`only_quiets_logs`] reads after a check.
248const MAX_GUARDED_LINES: usize = 12;
249
250/// Whether a negated test check (`!== 'test'`) guards only `console` calls.
251/// Then the tests only stay quiet, and production takes the same code path.
252fn 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    // The rest of the block holds only more `console` calls or the
277    // arguments of one: quoted text, closing parentheses or commas.
278    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/// Threshold values in a coverage or quality config that the change made
293/// less strict. Occurrences of one key pair up in file order.
294#[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/// Whether `rel_path` names a test-runner, coverage or Fallow config file.
323#[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
357/// The code of a line before any comment. A line that starts inside a
358/// comment has no code.
359fn 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
374/// The comment of a line, from its first comment marker.
375fn 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
383/// The byte offset of the first `//`, `/*` or `<!--` marker. A `//` right
384/// after `:` is a URL scheme, not a comment. A `//` or `/*` inside a
385/// single-line string literal is text, not a comment. The `<!--` search reads
386/// the raw line, because an apostrophe in markup text is not a quote.
387fn 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
412/// Every member chain in `code` that is called: `it.skip(` gives
413/// `["it", "skip"]`. A chain that follows a `.` is a member of another
414/// expression, so it is not a chain root.
415fn 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
481/// `x.should.equal(1)` style assertions: `.should` followed by `.` or `(`.
482fn 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
491/// The first suppression token in a comment, or `None`. A `fallow-ignore`
492/// marker that names only diff-risk rules is not a suppression here.
493fn 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
509/// Whether a `fallow-ignore-*` directive lists only diff-risk tokens. A
510/// directive without tokens suppresses every rule, so it does not qualify.
511fn 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    // The first word is the directive itself (`fallow-ignore-next-line`).
521    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
529/// The test-environment token in a line of code, or `None`.
530fn test_environment_token(code: &str) -> Option<&'static str> {
531    // The tokens count only outside string literals: a parser that compares
532    // source text with `'process.env.VITEST'` is not a test-only branch.
533    // `import.meta.vitest` is not a token: Vitest uses it to mark an in-source
534    // test block, which is test code and not a production branch.
535    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
548/// `code` with the content of each string literal on the line replaced by
549/// spaces. The quotes stay. A backslash escapes the next character. Each
550/// character becomes as many spaces as it has bytes, so byte offsets in the
551/// result are byte offsets in `code`.
552fn 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
575/// Every numeric value that follows `key` as an object key, with its 1-based
576/// line. The key must stand alone: `lines` does not match `maxLines`.
577fn 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}