Skip to main content

fallow_api/diff_risk/
mod.rs

1//! Diff-risk rules of `fallow audit`.
2//!
3//! The rules compare the base and head text of each changed file. They report
4//! a fact about the change, so every finding is introduced. The pass reads the
5//! base text through one `git cat-file --batch` process at the merge base of
6//! the base ref and `HEAD`.
7//!
8//! - `weakened-tests`: a new skip or focus marker, fewer test cases across the
9//!   changed test files, fewer assertions in a test file, or a less strict
10//!   coverage threshold.
11//! - `added-suppression`: a new lint, type-check, coverage or Fallow
12//!   suppression comment.
13//! - `test-only-branch`: a new test-environment branch in a non-test source
14//!   file.
15//! - `diverged-clone-sibling`: one copy of a base clone group was edited out
16//!   of the group, and no sibling changed. Needs the base snapshot.
17//! - `added-dependency`: a new `package.json` dependency. Off by default.
18
19pub mod clones;
20pub mod text;
21
22use std::path::{Path, PathBuf};
23use std::process::Command;
24
25use fallow_config::{ResolvedConfig, RulesConfig, Severity};
26use fallow_engine::changed_files::RenamedFile;
27use fallow_engine::module_graph::relative_key_path;
28use fallow_output::{
29    DiffRiskAction, DiffRiskActionType, DiffRiskFinding, DiffRiskLocation, DiffRiskReport,
30    DiffRiskRule, DiffRiskSeverity, WeakenedTestsKind,
31};
32use fallow_types::duplicates::CloneGroup;
33use fallow_types::suppress::{IssueKind, Suppression, SuppressionTarget};
34use rustc_hash::{FxHashMap, FxHashSet};
35
36use crate::audit_run::{BaseFileReader, BaseRead};
37use clones::{BaseCloneGroup, CloneSpan, CloneTexts};
38
39/// Extensions of files that hold JavaScript-style comments, where an added
40/// suppression or a test-only branch is meaningful.
41const SOURCE_EXTENSIONS: &[&str] = &[
42    "js", "jsx", "ts", "tsx", "mjs", "cjs", "mts", "cts", "vue", "svelte", "astro",
43];
44/// Extensions where a suppression comment is meaningful. Adds styles and
45/// markup to [`SOURCE_EXTENSIONS`].
46const STYLE_EXTENSIONS: &[&str] = &["css", "scss", "sass", "less", "html"];
47
48/// Where the rule severities come from.
49#[derive(Clone, Copy)]
50pub enum DiffRiskRules<'a> {
51    /// The resolved config, with per-path `overrides`.
52    Config(&'a ResolvedConfig),
53    /// One rules table for every path.
54    Rules(&'a RulesConfig),
55}
56
57impl DiffRiskRules<'_> {
58    fn for_path(self, abs: &Path) -> RulesConfig {
59        match self {
60            Self::Config(config) => config.resolve_rules_for_path(abs),
61            Self::Rules(rules) => rules.clone(),
62        }
63    }
64}
65
66/// Inputs of one diff-risk pass.
67pub struct DiffRiskInput<'a> {
68    /// Head analysis root. Finding paths are relative to it.
69    pub root: &'a Path,
70    /// Resolved base ref of the audit.
71    pub base_ref: &'a str,
72    /// Absolute paths of the changed files.
73    pub changed_files: &'a FxHashSet<PathBuf>,
74    /// Renames between base and head.
75    pub renames: &'a [RenamedFile],
76    /// Rule severities.
77    pub rules: DiffRiskRules<'a>,
78    /// Clone groups of the base snapshot. `None` turns off
79    /// `diverged-clone-sibling`: without a base pass no copy can be compared.
80    pub base_clone_groups: Option<&'a [BaseCloneGroup]>,
81    /// Instance locations of every head clone group.
82    pub head_clone_groups: Vec<Vec<CloneSpan>>,
83}
84
85/// The instance locations of a clone group, relative to `root`.
86#[must_use]
87pub fn clone_spans(group: &CloneGroup, root: &Path) -> Vec<CloneSpan> {
88    group
89        .instances
90        .iter()
91        .map(|instance| CloneSpan {
92            path: relative_key_path(&instance.file, root),
93            start_line: u32::try_from(instance.start_line).unwrap_or(u32::MAX),
94            end_line: u32::try_from(instance.end_line).unwrap_or(u32::MAX),
95        })
96        .collect()
97}
98
99/// The base and head text of one changed file.
100struct FileTexts {
101    /// Head path relative to the analysis root.
102    rel: String,
103    /// Absolute head path.
104    abs: PathBuf,
105    /// Base text; `None` when the file is new.
106    base: Option<String>,
107    /// Head text; `None` when the change deleted the file.
108    head: Option<String>,
109}
110
111/// A finding before the severity and suppression pass.
112struct RawFinding {
113    rule: DiffRiskRule,
114    kind: Option<WeakenedTestsKind>,
115    path: String,
116    line: Option<u32>,
117    message: String,
118    evidence: String,
119    related: Vec<DiffRiskLocation>,
120}
121
122/// Run every diff-risk rule over the changed files.
123///
124/// Best-effort: a file that cannot be read is skipped, and a base-reader
125/// error stops the scan of the remaining files, so a broken pipe never reads
126/// as an empty base file.
127#[must_use]
128pub fn detect(input: &DiffRiskInput<'_>) -> DiffRiskReport {
129    let Ok(git_root) = fallow_engine::changed_files::resolve_git_toplevel(input.root) else {
130        return DiffRiskReport::default();
131    };
132    let Some(reader) = BaseFileReader::spawn(input.root) else {
133        return DiffRiskReport::default();
134    };
135    let mut base = BaseSource {
136        git_root: dunce::canonicalize(&git_root).unwrap_or(git_root),
137        reader,
138        commit: merge_base(input.root, input.base_ref),
139    };
140    let files = read_files(input, &mut base);
141
142    let mut raw = Vec::new();
143    for file in &files {
144        let rules = input.rules.for_path(&file.abs);
145        detect_file(input.root, file, &rules, &mut raw);
146    }
147    detect_removed_tests(input.root, &files, &mut raw);
148    if let Some(base_groups) = input.base_clone_groups {
149        detect_diverged_clones(input, &mut base, base_groups, &files, &mut raw);
150    }
151
152    let findings = finalize(input, &files, raw);
153    DiffRiskReport { findings }
154}
155
156/// Base-side text access: one `git cat-file --batch` reader at the merge
157/// base.
158struct BaseSource {
159    git_root: PathBuf,
160    reader: BaseFileReader,
161    commit: String,
162}
163
164impl BaseSource {
165    /// Read the base text of an absolute path. A path outside the repository
166    /// reads as an error.
167    fn read(&mut self, abs: &Path) -> BaseRead {
168        match abs.strip_prefix(&self.git_root) {
169            Ok(relative) => self.reader.read(&self.commit, relative),
170            Err(_) => BaseRead::Error,
171        }
172    }
173}
174
175/// The merge base of `base_ref` and `HEAD`, or `base_ref` when git cannot
176/// resolve one. The changed-file list uses the merge base too, so a base
177/// branch that moved on does not read as a change of this branch.
178pub(crate) fn merge_base(root: &Path, base_ref: &str) -> String {
179    let mut command = Command::new("git");
180    command
181        .args(["merge-base", "--end-of-options", base_ref, "HEAD"])
182        .current_dir(root)
183        .stderr(std::process::Stdio::null());
184    fallow_engine::changed_files::clear_ambient_git_env(&mut command);
185    command
186        .output()
187        .ok()
188        .filter(|output| output.status.success())
189        .map(|output| String::from_utf8_lossy(&output.stdout).trim().to_owned())
190        .filter(|sha| !sha.is_empty())
191        .unwrap_or_else(|| base_ref.to_owned())
192}
193
194/// Whether a diff-risk rule can read this file. The rules read discovered
195/// source files (every clone group member is one), test files, test-runner
196/// and coverage configs, and package manifests. Other changed files, such as
197/// a lockfile or an image, are not read.
198fn is_rule_relevant(root: &Path, rel: &str) -> bool {
199    fallow_engine::discover::SOURCE_EXTENSIONS.contains(&extension(rel))
200        || takes_suppression_comments(rel)
201        || is_js_test_code(root, rel)
202        || text::is_coverage_config_path(rel)
203        || crate::dependency_deltas::is_manifest_path(rel)
204}
205
206fn read_files(input: &DiffRiskInput<'_>, source: &mut BaseSource) -> Vec<FileTexts> {
207    let rename_sources: FxHashSet<&Path> = input
208        .renames
209        .iter()
210        .map(|rename| rename.from.as_path())
211        .collect();
212    let rename_targets: FxHashMap<&Path, &Path> = input
213        .renames
214        .iter()
215        .map(|rename| (rename.to.as_path(), rename.from.as_path()))
216        .collect();
217    let mut paths: Vec<&PathBuf> = input.changed_files.iter().collect();
218    paths.sort();
219
220    let mut files = Vec::new();
221    for abs in paths {
222        // The old path of a rename is not a deletion: its text lives on at
223        // the new path, which reads its base text from the old path.
224        if rename_sources.contains(abs.as_path()) {
225            continue;
226        }
227        if !abs.starts_with(input.root) {
228            continue;
229        }
230        let base_abs = rename_targets
231            .get(abs.as_path())
232            .copied()
233            .unwrap_or(abs.as_path());
234        if !base_abs.starts_with(&source.git_root) {
235            continue;
236        }
237        let rel = relative_key_path(abs, input.root);
238        if !is_rule_relevant(input.root, &rel) {
239            continue;
240        }
241        let head = match std::fs::read(abs) {
242            Ok(bytes) => Some(String::from_utf8_lossy(&bytes).into_owned()),
243            Err(error) if error.kind() == std::io::ErrorKind::NotFound => None,
244            Err(_) => continue,
245        };
246        let base = match source.read(base_abs) {
247            BaseRead::Content(base) => Some(base),
248            BaseRead::Missing => None,
249            BaseRead::Error => break,
250        };
251        if base.is_none() && head.is_none() {
252            continue;
253        }
254        files.push(FileTexts {
255            rel,
256            abs: abs.clone(),
257            base,
258            head,
259        });
260    }
261    files
262}
263
264fn extension(rel: &str) -> &str {
265    let name = rel.rsplit('/').next().unwrap_or(rel);
266    name.rsplit_once('.').map_or("", |(_, ext)| ext)
267}
268
269fn is_source_file(rel: &str) -> bool {
270    SOURCE_EXTENSIONS.contains(&extension(rel))
271}
272
273fn takes_suppression_comments(rel: &str) -> bool {
274    is_source_file(rel) || STYLE_EXTENSIONS.contains(&extension(rel))
275}
276
277/// A JavaScript-family test file, where the test rules apply. A test file in
278/// another language, such as a Rust integration test, can hold JavaScript
279/// test text in a string, so it is not read.
280fn is_js_test_code(root: &Path, rel: &str) -> bool {
281    is_source_file(rel) && fallow_engine::test_paths::is_test_code_path_str(root, rel)
282}
283
284/// A test file, or a type test of the `tsd` and Vitest conventions
285/// (`test-d/`, `*.test-d.ts`).
286fn is_type_or_test_file(root: &Path, rel: &str) -> bool {
287    fallow_engine::test_paths::is_test_path_str(root, rel)
288        || rel.split('/').any(|segment| segment == "test-d")
289        || rel.contains(".test-d.")
290}
291
292/// Directory names of test fixtures and generated code. A tool writes these
293/// files, or a test reads them as data, so their suppression comments are
294/// not a choice of the change author.
295const FIXTURE_OR_GENERATED_DIRS: &[&str] = &[
296    "fixture",
297    "fixtures",
298    "__fixtures__",
299    "__snapshots__",
300    "generated",
301    "__generated__",
302];
303
304fn is_fixture_or_generated(rel: &str) -> bool {
305    let mut dirs = rel.split('/').rev().skip(1);
306    dirs.any(|dir| FIXTURE_OR_GENERATED_DIRS.contains(&dir))
307}
308
309/// File-name markers of story files (`button.stories.tsx`).
310const STORY_FILE_MARKERS: &[&str] = &[".stories.", ".story."];
311
312/// Test or story code. Each copy there shows one case or one state, so the
313/// copies differ on purpose.
314fn is_example_copy(root: &Path, rel: &str) -> bool {
315    if fallow_engine::test_paths::is_test_path_str(root, rel) {
316        return true;
317    }
318    let name = rel.rsplit('/').next().unwrap_or(rel).to_ascii_lowercase();
319    STORY_FILE_MARKERS
320        .iter()
321        .any(|marker| name.contains(marker))
322}
323
324/// A tool config such as `vite.config.ts`. A test-environment check is
325/// normal there.
326fn is_tool_config(rel: &str) -> bool {
327    let name = rel.rsplit('/').next().unwrap_or(rel);
328    // An rc file is a dot file whose first name part ends in `rc`, such as
329    // `.eslintrc.js`. `src.ts` and `arc.ts` are not rc files.
330    let dot_rc = name.strip_prefix('.').is_some_and(|rest| {
331        rest.split_once('.')
332            .is_some_and(|(stem, _ext)| stem.ends_with("rc"))
333    });
334    name.contains(".config.") || name.contains(".conf.") || dot_rc
335}
336
337fn detect_file(root: &Path, file: &FileTexts, rules: &RulesConfig, raw: &mut Vec<RawFinding>) {
338    let base = file.base.as_deref().unwrap_or("");
339    let Some(head) = file.head.as_deref() else {
340        return;
341    };
342    let rel = file.rel.as_str();
343    let is_test = is_js_test_code(root, rel);
344
345    if rules.weakened_tests != Severity::Off {
346        if is_test {
347            for hit in text::skip_only_hits(base, head) {
348                raw.push(RawFinding {
349                    rule: DiffRiskRule::WeakenedTests,
350                    kind: Some(WeakenedTestsKind::SkipOnly),
351                    path: rel.to_owned(),
352                    line: Some(hit.line),
353                    message: format!(
354                        "The change adds `{}`, so other tests or this test do not run",
355                        hit.evidence
356                    ),
357                    evidence: hit.evidence,
358                    related: Vec::new(),
359                });
360            }
361        }
362        if text::is_coverage_config_path(rel) {
363            for hit in text::weakened_threshold_hits(base, head) {
364                raw.push(RawFinding {
365                    rule: DiffRiskRule::WeakenedTests,
366                    kind: Some(WeakenedTestsKind::CoverageThreshold),
367                    path: rel.to_owned(),
368                    line: Some(hit.line),
369                    message: format!(
370                        "The change makes a threshold less strict ({})",
371                        hit.evidence
372                    ),
373                    evidence: hit.evidence,
374                    related: Vec::new(),
375                });
376            }
377        }
378    }
379
380    if rules.added_suppression != Severity::Off
381        && takes_suppression_comments(rel)
382        && !is_fixture_or_generated(rel)
383    {
384        let type_test = is_type_or_test_file(root, rel);
385        for hit in text::added_suppression_hits(base, head) {
386            // In a test, `@ts-expect-error` asserts that a type error exists.
387            if type_test && hit.evidence == "@ts-expect-error" {
388                continue;
389            }
390            raw.push(RawFinding {
391                rule: DiffRiskRule::AddedSuppression,
392                kind: None,
393                path: rel.to_owned(),
394                line: Some(hit.line),
395                message: format!("The change adds a `{}` suppression comment", hit.evidence),
396                evidence: hit.evidence,
397                related: Vec::new(),
398            });
399        }
400    }
401
402    if rules.test_only_branch != Severity::Off
403        && is_source_file(rel)
404        && !fallow_engine::test_paths::is_test_path_str(root, rel)
405        && !is_tool_config(rel)
406    {
407        for hit in text::test_only_branch_hits(base, head) {
408            raw.push(RawFinding {
409                rule: DiffRiskRule::TestOnlyBranch,
410                kind: None,
411                path: rel.to_owned(),
412                line: Some(hit.line),
413                message: format!(
414                    "The change adds a `{}` check to source code, so tests and production can take different paths",
415                    hit.evidence
416                ),
417                evidence: hit.evidence,
418                related: Vec::new(),
419            });
420        }
421    }
422
423    if rules.added_dependency != Severity::Off && crate::dependency_deltas::is_manifest_path(rel) {
424        let deltas = crate::dependency_deltas::manifest_dependency_deltas(base, head);
425        for added in deltas.added {
426            let evidence = format!("{}@{} ({})", added.name, added.to, added.section);
427            raw.push(RawFinding {
428                rule: DiffRiskRule::AddedDependency,
429                kind: None,
430                path: rel.to_owned(),
431                line: (added.line > 0).then_some(added.line),
432                message: format!("The change adds the dependency `{}`", added.name),
433                evidence,
434                related: Vec::new(),
435            });
436        }
437    }
438}
439
440/// The names that the change removed from the project.
441///
442/// - Identifiers that a changed non-test source file held in base and no
443///   longer holds in head, such as a deleted function or a dropped call.
444///   Identifiers of a deleted file count only when no changed non-test source
445///   file still holds them in head, and names that the deleted file imports
446///   never count.
447/// - The file stem of each deleted file without test cases, and each of its
448///   directories that is gone at head, such as a deleted fixture directory.
449///
450/// Reserved words and common globals never count.
451struct RemovedNames {
452    identifiers: FxHashSet<String>,
453    segments: FxHashSet<String>,
454}
455
456/// The shortest name that can explain a test removal. Shorter names, such as
457/// `id` or `get`, are too common to tie a test to the removed code.
458const MIN_REMOVED_NAME_LEN: usize = 4;
459
460/// JS and TS reserved words and common globals. Each source file holds them,
461/// so they do not tie a test to the removed code.
462const NOT_REMOVED_NAMES: &[&str] = &[
463    "async",
464    "await",
465    "break",
466    "case",
467    "catch",
468    "class",
469    "console",
470    "const",
471    "continue",
472    "default",
473    "delete",
474    "else",
475    "enum",
476    "export",
477    "exports",
478    "extends",
479    "false",
480    "finally",
481    "from",
482    "function",
483    "import",
484    "instanceof",
485    "interface",
486    "module",
487    "null",
488    "require",
489    "return",
490    "static",
491    "super",
492    "switch",
493    "this",
494    "throw",
495    "true",
496    "type",
497    "typeof",
498    "undefined",
499    "void",
500    "while",
501    "with",
502    "yield",
503];
504
505/// Whether a token can name removed code.
506fn is_removed_name_candidate(name: &str) -> bool {
507    name.len() >= MIN_REMOVED_NAME_LEN && !NOT_REMOVED_NAMES.contains(&name)
508}
509
510/// The names that the import statements of a file bind. An imported name is
511/// declared in another file, so its use in a deleted file does not show that
512/// the name left the project.
513fn imported_names(src: &str) -> FxHashSet<String> {
514    let mut statements = String::new();
515    let mut in_import = false;
516    for line in src.lines() {
517        let trimmed = line.trim_start();
518        if !in_import && trimmed.starts_with("import ") {
519            in_import = true;
520        }
521        if !in_import {
522            continue;
523        }
524        statements.push_str(line);
525        statements.push('\n');
526        if trimmed.ends_with(';') || trimmed.contains("from ") || trimmed.ends_with("from") {
527            in_import = false;
528        }
529    }
530    text::code_identifiers(&statements)
531}
532
533impl RemovedNames {
534    fn collect(root: &Path, files: &[FileTexts]) -> Self {
535        let mut identifiers = FxHashSet::default();
536        let mut deleted_identifiers = FxHashSet::default();
537        let mut segments = FxHashSet::default();
538        let mut head_union = FxHashSet::default();
539        for file in files {
540            // A deleted file with test cases does not explain its own removal.
541            if file.head.is_none()
542                && file
543                    .base
544                    .as_deref()
545                    .is_some_and(|base| text::test_case_count(base) == 0)
546            {
547                segments.extend(deleted_path_segments(root, &file.rel));
548            }
549            if !is_source_file(&file.rel)
550                || fallow_engine::test_paths::is_test_path_str(root, &file.rel)
551            {
552                continue;
553            }
554            if let Some(head) = file.head.as_deref() {
555                head_union.extend(text::code_identifiers(head));
556            }
557            let Some(base) = file.base.as_deref() else {
558                continue;
559            };
560            match file.head.as_deref() {
561                Some(head) => {
562                    let head = text::code_identifiers(head);
563                    identifiers.extend(
564                        text::code_identifiers(base)
565                            .into_iter()
566                            .filter(|name| is_removed_name_candidate(name) && !head.contains(name)),
567                    );
568                }
569                None => {
570                    let imported = imported_names(base);
571                    deleted_identifiers.extend(text::code_identifiers(base).into_iter().filter(
572                        |name| is_removed_name_candidate(name) && !imported.contains(name),
573                    ));
574                }
575            }
576        }
577        // A name of a deleted file that a changed source file still holds did
578        // not leave the project, for example a function that moved from the
579        // deleted file.
580        identifiers.extend(
581            deleted_identifiers
582                .into_iter()
583                .filter(|name| !head_union.contains(name)),
584        );
585        Self {
586            identifiers,
587            segments,
588        }
589    }
590
591    /// Whether the lines that a test file lost name removed code that the
592    /// head test no longer names. Then the tests left together with the code
593    /// they covered. A path segment matches only a whole token of a line, so
594    /// a deleted `core` directory does not match `score`.
595    fn explain(&self, base: &str, head: &str) -> bool {
596        let removed: Vec<&str> = text::added_lines(head, base)
597            .into_iter()
598            .map(|(_, line)| line)
599            .collect();
600        if removed.is_empty() {
601            return false;
602        }
603        let head_identifiers = text::code_identifiers(head);
604        let names_removed_identifier = removed.iter().any(|line| {
605            text::code_identifiers(line)
606                .iter()
607                .any(|name| self.identifiers.contains(name) && !head_identifiers.contains(name))
608        });
609        names_removed_identifier
610            || self.segments.iter().any(|segment| {
611                !head.contains(segment.as_str())
612                    && removed
613                        .iter()
614                        .any(|line| path_tokens(line).any(|token| token == segment))
615            })
616    }
617}
618
619/// The path segments that a deleted file takes out of the project: its file
620/// stem, and each directory that is gone at head. A directory that still
621/// exists, such as `src/components`, did not leave the project, so a test that
622/// imports another file from it is not explained.
623fn deleted_path_segments(root: &Path, rel: &str) -> Vec<String> {
624    let stem_path = rel.rsplit_once('.').map_or(rel, |(stem, _)| stem);
625    let mut found = Vec::new();
626    let mut prefix_len = 0;
627    let parts: Vec<&str> = stem_path.split('/').collect();
628    let last = parts.len().saturating_sub(1);
629    for (index, part) in parts.iter().enumerate() {
630        prefix_len += part.len();
631        let is_stem = index == last;
632        let gone = is_stem || !root.join(&rel[..prefix_len]).exists();
633        if gone && part.len() >= MIN_REMOVED_NAME_LEN {
634            found.push((*part).to_owned());
635        }
636        prefix_len += 1;
637    }
638    found
639}
640
641/// The tokens of a line split on path and string delimiters.
642fn path_tokens(line: &str) -> impl Iterator<Item = &str> {
643    line.split(['/', '.', '\'', '"', '`'])
644}
645
646/// `removed-tests` and `assertion-drop`. Both compare per file, and both
647/// report only when the total over every changed test file also dropped, so
648/// tests or assertions that moved to another changed file do not report. A
649/// drop does not report when the lost test lines name code that the change
650/// removed: the tests went together with that code. An assertion drop reports
651/// only when assertions are at least half of the code lines that the file
652/// lost. Otherwise the change rewrote the test, for example it moved setup
653/// checks into a shared helper.
654fn detect_removed_tests(root: &Path, files: &[FileTexts], raw: &mut Vec<RawFinding>) {
655    struct Counts<'a> {
656        rel: &'a str,
657        base_cases: usize,
658        head_cases: usize,
659        base_asserts: usize,
660        head_asserts: usize,
661        explained: bool,
662        assertions_led: bool,
663    }
664    let removed_names = RemovedNames::collect(root, files);
665    let counts: Vec<Counts<'_>> = files
666        .iter()
667        .filter(|file| is_js_test_code(root, &file.rel))
668        .map(|file| {
669            let base = file.base.as_deref().unwrap_or("");
670            let head = file.head.as_deref().unwrap_or("");
671            Counts {
672                rel: &file.rel,
673                base_cases: text::test_case_count(base),
674                head_cases: text::test_case_count(head),
675                base_asserts: text::assertion_count(base),
676                head_asserts: text::assertion_count(head),
677                explained: removed_names.explain(base, head),
678                assertions_led: {
679                    let (asserts, code) = text::removed_assertion_lines(base, head);
680                    asserts * 2 >= code
681                },
682            }
683        })
684        .collect();
685    let total = |pick: fn(&Counts<'_>) -> usize| counts.iter().map(pick).sum::<usize>();
686    let cases_dropped = total(|c| c.head_cases) < total(|c| c.base_cases);
687    let asserts_dropped = total(|c| c.head_asserts) < total(|c| c.base_asserts);
688    for count in counts.iter().filter(|count| !count.explained) {
689        if cases_dropped && count.head_cases < count.base_cases {
690            let evidence = format!("test cases: {} -> {}", count.base_cases, count.head_cases);
691            raw.push(RawFinding {
692                rule: DiffRiskRule::WeakenedTests,
693                kind: Some(WeakenedTestsKind::RemovedTests),
694                path: count.rel.to_owned(),
695                line: None,
696                message: format!("The change removes test cases ({evidence})"),
697                evidence,
698                related: Vec::new(),
699            });
700        } else if asserts_dropped
701            && count.assertions_led
702            && count.head_cases >= count.base_cases
703            && count.head_asserts < count.base_asserts
704        {
705            let evidence = format!(
706                "assertions: {} -> {}",
707                count.base_asserts, count.head_asserts
708            );
709            raw.push(RawFinding {
710                rule: DiffRiskRule::WeakenedTests,
711                kind: Some(WeakenedTestsKind::AssertionDrop),
712                path: count.rel.to_owned(),
713                line: None,
714                message: format!("The change removes assertions ({evidence})"),
715                evidence,
716                related: Vec::new(),
717            });
718        }
719    }
720}
721
722fn detect_diverged_clones(
723    input: &DiffRiskInput<'_>,
724    source: &mut BaseSource,
725    base_groups: &[BaseCloneGroup],
726    files: &[FileTexts],
727    raw: &mut Vec<RawFinding>,
728) {
729    let changed: FxHashSet<String> = files.iter().map(|file| file.rel.clone()).collect();
730    let renames: FxHashMap<String, String> = input
731        .renames
732        .iter()
733        .map(|rename| {
734            (
735                relative_key_path(&rename.from, input.root),
736                relative_key_path(&rename.to, input.root),
737            )
738        })
739        .collect();
740    let head_by_rel: FxHashMap<&str, Option<&str>> = files
741        .iter()
742        .map(|file| (file.rel.as_str(), file.head.as_deref()))
743        .collect();
744    let root = input.root;
745    let mut base_text = |rel: &str| match source.read(&root.join(rel)) {
746        BaseRead::Content(text) => Some(text),
747        BaseRead::Missing | BaseRead::Error => None,
748    };
749    let mut head_text = |rel: &str| match head_by_rel.get(rel) {
750        Some(head) => head.map(str::to_owned),
751        None => std::fs::read_to_string(root.join(rel)).ok(),
752    };
753    let found = clones::diverged_clone_siblings(
754        base_groups,
755        &input.head_clone_groups,
756        &changed,
757        &renames,
758        &mut CloneTexts {
759            base: &mut base_text,
760            head: &mut head_text,
761        },
762    );
763    for diverged in found {
764        // Test and story copies differ case by case on purpose, so an edit to
765        // one of them is not a missed sibling.
766        if is_example_copy(root, &diverged.path) {
767            continue;
768        }
769        let rules = input.rules.for_path(&root.join(&diverged.path));
770        if rules.diverged_clone_sibling == Severity::Off {
771            continue;
772        }
773        let related: Vec<DiffRiskLocation> = diverged
774            .siblings
775            .iter()
776            .map(|sibling| DiffRiskLocation {
777                path: sibling.path.clone(),
778                line: sibling.start_line,
779            })
780            .collect();
781        let evidence = related
782            .iter()
783            .map(|location| format!("{}:{}", location.path, location.line))
784            .collect::<Vec<_>>()
785            .join(", ");
786        raw.push(RawFinding {
787            rule: DiffRiskRule::DivergedCloneSibling,
788            kind: None,
789            path: diverged.path,
790            line: diverged.line,
791            message: format!(
792                "The change edits one copy of duplicated code, and its {} unchanged copies no longer match",
793                related.len()
794            ),
795            evidence: format!("unchanged copies: {evidence}"),
796            related,
797        });
798    }
799}
800
801const fn issue_kind(rule: DiffRiskRule) -> IssueKind {
802    match rule {
803        DiffRiskRule::WeakenedTests => IssueKind::WeakenedTests,
804        DiffRiskRule::AddedSuppression => IssueKind::AddedSuppression,
805        DiffRiskRule::TestOnlyBranch => IssueKind::TestOnlyBranch,
806        DiffRiskRule::DivergedCloneSibling => IssueKind::DivergedCloneSibling,
807        DiffRiskRule::AddedDependency => IssueKind::AddedDependency,
808    }
809}
810
811const fn rule_severity(rules: &RulesConfig, rule: DiffRiskRule) -> Severity {
812    rules.severity_for_kind(issue_kind(rule))
813}
814
815/// Whether a head suppression covers the finding. An `added-suppression`
816/// finding honors only a suppression that names its rule: a new blanket
817/// `fallow-ignore` comment must not hide itself.
818fn is_suppressed(suppressions: &[Suppression], rule: DiffRiskRule, line: Option<u32>) -> bool {
819    let kind = issue_kind(rule);
820    suppressions.iter().any(|suppression| {
821        let names_rule = matches!(
822            &suppression.target,
823            Some(SuppressionTarget::Issue(target)) if *target == kind
824        );
825        if rule == DiffRiskRule::AddedSuppression && !names_rule {
826            return false;
827        }
828        let line = line.unwrap_or(0);
829        if line == 0 {
830            suppression.line == 0 && suppression.matches_issue_kind(0, kind)
831        } else {
832            suppression.matches_issue_kind(line, kind)
833        }
834    })
835}
836
837fn finalize(
838    input: &DiffRiskInput<'_>,
839    files: &[FileTexts],
840    raw: Vec<RawFinding>,
841) -> Vec<DiffRiskFinding> {
842    let mut suppressions: FxHashMap<&str, Vec<Suppression>> = FxHashMap::default();
843    let head_exists: FxHashMap<&str, bool> = files
844        .iter()
845        .map(|file| (file.rel.as_str(), file.head.is_some()))
846        .collect();
847    for file in files {
848        if let Some(head) = file.head.as_deref() {
849            suppressions.insert(
850                file.rel.as_str(),
851                fallow_engine::source::suppressions_from_text(head),
852            );
853        }
854    }
855    let mut findings: Vec<DiffRiskFinding> = raw
856        .into_iter()
857        .filter_map(|finding| {
858            let rules = input.rules.for_path(&input.root.join(&finding.path));
859            let severity = match rule_severity(&rules, finding.rule) {
860                Severity::Off => return None,
861                Severity::Warn => DiffRiskSeverity::Warn,
862                Severity::Error => DiffRiskSeverity::Error,
863            };
864            if suppressions
865                .get(finding.path.as_str())
866                .is_some_and(|list| is_suppressed(list, finding.rule, finding.line))
867            {
868                return None;
869            }
870            let exists = head_exists
871                .get(finding.path.as_str())
872                .copied()
873                .unwrap_or(true);
874            let actions = suppression_actions(finding.rule, &finding.path, finding.line, exists);
875            Some(DiffRiskFinding {
876                rule: finding.rule,
877                kind: finding.kind,
878                path: finding.path,
879                line: finding.line,
880                message: finding.message,
881                evidence: finding.evidence,
882                severity,
883                introduced: true,
884                related: finding.related,
885                actions,
886            })
887        })
888        .collect();
889    findings.sort_by(|a, b| {
890        a.path
891            .cmp(&b.path)
892            .then(a.line.cmp(&b.line))
893            .then(a.rule.cmp(&b.rule))
894            .then(a.kind.cmp(&b.kind))
895            .then(a.evidence.cmp(&b.evidence))
896    });
897    findings
898}
899
900/// The comment markers of a file type, `None` when the file cannot hold a
901/// suppression comment.
902fn comment_markers(rel: &str) -> Option<(&'static str, &'static str)> {
903    match extension(rel) {
904        "css" | "scss" | "sass" | "less" => Some(("/* ", " */")),
905        "html" => Some(("<!-- ", " -->")),
906        _ if is_source_file(rel) => Some(("// ", "")),
907        _ => None,
908    }
909}
910
911fn suppression_actions(
912    rule: DiffRiskRule,
913    path: &str,
914    line: Option<u32>,
915    head_exists: bool,
916) -> Vec<DiffRiskAction> {
917    let code = rule.code();
918    let markers = comment_markers(path).filter(|_| head_exists);
919    let Some((open, close)) = markers else {
920        return vec![DiffRiskAction {
921            kind: DiffRiskActionType::SuppressInConfig,
922            auto_fixable: false,
923            description: format!(
924                "Set `{code}` to `off` for `{path}` in an `overrides` entry of the Fallow config"
925            ),
926            comment: None,
927        }];
928    };
929    let mut actions = Vec::new();
930    if line.is_some() {
931        actions.push(DiffRiskAction {
932            kind: DiffRiskActionType::SuppressLine,
933            auto_fixable: false,
934            description: format!("Suppress this `{code}` finding with a comment above the line"),
935            comment: Some(format!("{open}fallow-ignore-next-line {code}{close}")),
936        });
937    }
938    actions.push(DiffRiskAction {
939        kind: DiffRiskActionType::SuppressFile,
940        auto_fixable: false,
941        description: format!("Suppress `{code}` for the whole file"),
942        comment: Some(format!("{open}fallow-ignore-file {code}{close}")),
943    });
944    actions
945}
946
947#[cfg(test)]
948mod tests {
949    use super::*;
950
951    fn suppressions(src: &str) -> Vec<Suppression> {
952        fallow_engine::source::suppressions_from_text(src)
953    }
954
955    fn file(rel: &str, base: &str, head: &str) -> FileTexts {
956        FileTexts {
957            rel: rel.to_owned(),
958            abs: PathBuf::from("/project").join(rel),
959            base: Some(base.to_owned()),
960            head: Some(head.to_owned()),
961        }
962    }
963
964    fn rules_of(raw: &[RawFinding]) -> Vec<(DiffRiskRule, &str)> {
965        raw.iter()
966            .map(|finding| (finding.rule, finding.evidence.as_str()))
967            .collect()
968    }
969
970    #[test]
971    fn only_files_a_rule_reads_are_relevant() {
972        let root = Path::new("/project");
973        for rel in [
974            "src/a.ts",
975            "src/a.test.ts",
976            "src/page.mdx",
977            "src/query.graphql",
978            "styles/app.css",
979            "package.json",
980            "packages/ui/package.json",
981            "vitest.config.ts",
982            ".nycrc",
983            ".fallowrc.json",
984        ] {
985            assert!(is_rule_relevant(root, rel), "{rel} must be read");
986        }
987        for rel in [
988            "pnpm-lock.yaml",
989            "package-lock.json",
990            "yarn.lock",
991            "assets/logo.png",
992            "fonts/inter.woff2",
993            "README.md",
994        ] {
995            assert!(!is_rule_relevant(root, rel), "{rel} must not be read");
996        }
997    }
998
999    fn removed_test_findings(files: &[FileTexts]) -> Vec<(String, String)> {
1000        removed_test_findings_in(Path::new("/project"), files)
1001    }
1002
1003    fn removed_test_findings_in(root: &Path, files: &[FileTexts]) -> Vec<(String, String)> {
1004        let mut raw = Vec::new();
1005        detect_removed_tests(root, files, &mut raw);
1006        let mut found: Vec<(String, String)> = raw
1007            .into_iter()
1008            .map(|finding| (finding.path, finding.evidence))
1009            .collect();
1010        found.sort_unstable();
1011        found
1012    }
1013
1014    fn pairs(list: &[(&str, &str)]) -> Vec<(String, String)> {
1015        list.iter()
1016            .map(|(a, b)| ((*a).to_owned(), (*b).to_owned()))
1017            .collect()
1018    }
1019
1020    const ERRORS_BASE: &str = "export const ERR_A = 'a';\nexport const ERR_GONE = 'gone';\n";
1021    const ERRORS_HEAD: &str = "export const ERR_A = 'a';\n";
1022    const ERRORS_TEST_BASE: &str = "test('ERR_A', () => {\n  expect(ERR_A).toBe('a');\n});\ntest('ERR_GONE', () => {\n  expect(ERR_GONE).toBe('gone');\n});\n";
1023    const ERRORS_TEST_HEAD: &str = "test('ERR_A', () => {\n  expect(ERR_A).toBe('a');\n});\n";
1024
1025    #[test]
1026    fn tests_removed_with_the_source_name_they_cover_are_not_reported() {
1027        let files = [
1028            file("src/errors.ts", ERRORS_BASE, ERRORS_HEAD),
1029            file("src/errors.test.ts", ERRORS_TEST_BASE, ERRORS_TEST_HEAD),
1030        ];
1031        assert!(removed_test_findings(&files).is_empty());
1032    }
1033
1034    #[test]
1035    fn tests_removed_while_their_source_name_stays_are_reported() {
1036        let files = [
1037            file("src/errors.ts", ERRORS_BASE, ERRORS_BASE),
1038            file("src/errors.test.ts", ERRORS_TEST_BASE, ERRORS_TEST_HEAD),
1039        ];
1040        assert_eq!(
1041            removed_test_findings(&files),
1042            pairs(&[("src/errors.test.ts", "test cases: 2 -> 1")])
1043        );
1044    }
1045
1046    #[test]
1047    fn assertions_removed_with_the_source_name_they_cover_are_not_reported() {
1048        let base = "test('errors', () => {\n  expect(ERR_A).toBe('a');\n  expect(ERR_GONE).toBe('gone');\n});\n";
1049        let head = "test('errors', () => {\n  expect(ERR_A).toBe('a');\n});\n";
1050        let files = [
1051            file("src/errors.ts", ERRORS_BASE, ERRORS_HEAD),
1052            file("src/errors.test.ts", base, head),
1053        ];
1054        assert!(removed_test_findings(&files).is_empty());
1055        let files = [
1056            file("src/errors.ts", ERRORS_BASE, ERRORS_BASE),
1057            file("src/errors.test.ts", base, head),
1058        ];
1059        assert_eq!(
1060            removed_test_findings(&files),
1061            pairs(&[("src/errors.test.ts", "assertions: 2 -> 1")])
1062        );
1063    }
1064
1065    #[test]
1066    fn tests_removed_with_a_deleted_fixture_directory_are_not_reported() {
1067        let base = "test('a', () => run('basic'));\ntest('legacy', () => run('legacy-types'));\n";
1068        let head = "test('a', () => run('basic'));\n";
1069        let files = [
1070            file("test/package.spec.js", base, head),
1071            FileTexts {
1072                rel: "test/fixtures/legacy-types/src/index.ts".to_owned(),
1073                abs: PathBuf::from("/project/test/fixtures/legacy-types/src/index.ts"),
1074                base: Some("export const a = 1;\n".to_owned()),
1075                head: None,
1076            },
1077        ];
1078        assert!(removed_test_findings(&files).is_empty());
1079    }
1080
1081    #[test]
1082    fn assertions_that_leave_with_rewritten_setup_code_are_not_reported() {
1083        let base = "test('creates', async () => {\n  const response = await client.post('/api').send({ query: CREATE });\n  expect(response.body.errors).toBeUndefined();\n  const id = response.body.data.create.id;\n  expect(await read(id)).toEqual({ id });\n});\n";
1084        let head = "test('creates', async () => {\n  const { id } = await createRecord();\n  expect(await read(id)).toEqual({ id });\n});\n";
1085        let files = [file("test/create.spec.ts", base, head)];
1086        assert!(removed_test_findings(&files).is_empty());
1087        let head = "test('creates', async () => {\n  const response = await client.post('/api').send({ query: CREATE });\n  const id = response.body.data.create.id;\n});\n";
1088        let files = [file("test/create.spec.ts", base, head)];
1089        assert_eq!(
1090            removed_test_findings(&files),
1091            pairs(&[("test/create.spec.ts", "assertions: 2 -> 0")])
1092        );
1093    }
1094
1095    #[test]
1096    fn tests_of_a_call_that_one_source_file_dropped_are_not_reported() {
1097        let hook_base = "export const useCommands = () => {\n  const enabled = useIsFeatureEnabled('CORE');\n  return enabled ? commands : [];\n};\n";
1098        let hook_head = "export const useCommands = () => commands;\n";
1099        let other = "export const usePage = () => useIsFeatureEnabled('PAGE');\n";
1100        let test_base = "jest.mock('./flags', () => ({ useIsFeatureEnabled: () => flag() }));\nit('shows commands', () => {\n  expect(useCommands()).toEqual(commands);\n});\nit('hides commands with the flag off', () => {\n  flag.mockReturnValue(false);\n  expect(useCommands()).toEqual([]);\n});\n";
1101        let test_head =
1102            "it('shows commands', () => {\n  expect(useCommands()).toEqual(commands);\n});\n";
1103        let files = [
1104            file("src/useCommands.ts", hook_base, hook_head),
1105            file("src/usePage.ts", other, other),
1106            file("src/useCommands.test.ts", test_base, test_head),
1107        ];
1108        assert!(removed_test_findings(&files).is_empty());
1109    }
1110
1111    #[test]
1112    fn words_in_comments_and_test_titles_do_not_explain_a_removal() {
1113        let source_base =
1114            "// Hide the items when the page is disabled.\nexport const items = (all) => all;\n";
1115        let source_head = "export const items = (all) => all;\n";
1116        let test_base = "it('keeps items', () => {\n  expect(items([1])).toEqual([1]);\n});\nit('keeps items when the page is disabled', () => {\n  expect(items([])).toEqual([]);\n});\n";
1117        let test_head = "it('keeps items', () => {\n  expect(items([1])).toEqual([1]);\n});\n";
1118        let files = [
1119            file("src/items.ts", source_base, source_head),
1120            file("src/items.test.ts", test_base, test_head),
1121        ];
1122        assert_eq!(
1123            removed_test_findings(&files),
1124            pairs(&[("src/items.test.ts", "test cases: 2 -> 1")])
1125        );
1126    }
1127
1128    #[test]
1129    fn a_deleted_test_file_does_not_explain_its_own_removal() {
1130        let files = [FileTexts {
1131            rel: "src/zero.test.ts".to_owned(),
1132            abs: PathBuf::from("/project/src/zero.test.ts"),
1133            base: Some("import { price } from './zero';\nit('keeps zero', () => {\n  expect(price(0)).toBe(0);\n});\n".to_owned()),
1134            head: None,
1135        }];
1136        assert_eq!(
1137            removed_test_findings(&files),
1138            pairs(&[("src/zero.test.ts", "test cases: 1 -> 0")])
1139        );
1140    }
1141
1142    fn deleted(rel: &str, base: &str) -> FileTexts {
1143        FileTexts {
1144            rel: rel.to_owned(),
1145            abs: PathBuf::from("/project").join(rel),
1146            base: Some(base.to_owned()),
1147            head: None,
1148        }
1149    }
1150
1151    #[test]
1152    fn an_unrelated_deleted_source_file_does_not_explain_a_deleted_test_file() {
1153        let files = [
1154            deleted(
1155                "src/legacy.ts",
1156                "import { a } from './a';\nimport { price } from './price';\nexport const legacy = price(a);\n",
1157            ),
1158            deleted(
1159                "src/price.test.ts",
1160                "import { price } from './price';\nit('keeps zero', () => {\n  expect(price(0)).toBe(0);\n});\n",
1161            ),
1162        ];
1163        assert_eq!(
1164            removed_test_findings(&files),
1165            pairs(&[("src/price.test.ts", "test cases: 1 -> 0")])
1166        );
1167    }
1168
1169    #[test]
1170    fn a_name_that_a_changed_file_still_holds_does_not_explain_a_removal() {
1171        let files = [
1172            deleted("src/legacy.ts", "export const formatPrice = (n) => n;\n"),
1173            file(
1174                "src/format.ts",
1175                "export const format = (n) => n;\n",
1176                "export const formatPrice = (n) => n;\n",
1177            ),
1178            file(
1179                "src/format.test.ts",
1180                "it('formats', () => {\n  expect(formatPrice(1)).toBe(1);\n});\n",
1181                "",
1182            ),
1183        ];
1184        assert_eq!(
1185            removed_test_findings(&files),
1186            pairs(&[("src/format.test.ts", "test cases: 1 -> 0")])
1187        );
1188    }
1189
1190    #[test]
1191    fn a_deleted_directory_name_matches_whole_path_segments_only() {
1192        let root = tempfile::tempdir().expect("tempdir");
1193        let base =
1194            "it('a', () => run());\nit('scores', () => {\n  expect(score(1)).toBe(1);\n});\n";
1195        let head = "it('a', () => run());\n";
1196        let files = [
1197            file("src/score.test.ts", base, head),
1198            deleted("src/core/x.ts", "export const x = 1;\n"),
1199        ];
1200        assert_eq!(
1201            removed_test_findings_in(root.path(), &files),
1202            pairs(&[("src/score.test.ts", "test cases: 2 -> 1")])
1203        );
1204        let base =
1205            "import { a } from '../core/a';\nit('a', () => run(a));\nit('b', () => run(b));\n";
1206        let head = "it('a', () => run(a));\n";
1207        let files = [
1208            file("src/run.test.ts", base, head),
1209            deleted("src/core/x.ts", "export const x = 1;\n"),
1210        ];
1211        assert!(removed_test_findings_in(root.path(), &files).is_empty());
1212    }
1213
1214    #[test]
1215    fn a_directory_that_still_exists_does_not_explain_a_removal() {
1216        let root = tempfile::tempdir().expect("tempdir");
1217        std::fs::create_dir_all(root.path().join("src/components")).expect("components dir");
1218        let files = [
1219            deleted(
1220                "src/components/Legacy.tsx",
1221                "export const Legacy = () => null;\n",
1222            ),
1223            deleted(
1224                "src/__tests__/button.test.tsx",
1225                "import { Button } from '../components/Button';\nit('renders', () => {\n  expect(Button()).toBeTruthy();\n});\n",
1226            ),
1227        ];
1228        assert_eq!(
1229            removed_test_findings_in(root.path(), &files),
1230            pairs(&[("src/__tests__/button.test.tsx", "test cases: 1 -> 0")])
1231        );
1232    }
1233
1234    #[test]
1235    fn edited_story_copy_is_not_a_diverged_clone() {
1236        for rel in [
1237            "apps/storybook/stories/select.stories.tsx",
1238            "src/button.story.tsx",
1239        ] {
1240            assert!(is_example_copy(Path::new("/project"), rel), "{rel}");
1241        }
1242        assert!(!is_example_copy(Path::new("/project"), "src/store.ts"));
1243    }
1244
1245    #[test]
1246    fn tool_config_names_match_config_and_dot_rc_files_only() {
1247        for name in [
1248            "vite.config.ts",
1249            "pkg/karma.conf.js",
1250            ".eslintrc.js",
1251            "pkg/.mocharc.cjs",
1252        ] {
1253            assert!(is_tool_config(name), "{name}");
1254        }
1255        for name in ["src.ts", "src/arc.ts", "lib/orc.js", "src/index.ts"] {
1256            assert!(!is_tool_config(name), "{name}");
1257        }
1258    }
1259
1260    #[test]
1261    fn suppressions_in_fixture_and_generated_files_are_not_reported() {
1262        let head = "// @ts-nocheck\nexport const a = 1;\n";
1263        let mut raw = Vec::new();
1264        let root = Path::new("/project");
1265        let rules = RulesConfig::default();
1266        for rel in [
1267            "src/__tests__/upstream/fixture/generated/schema.ts",
1268            "test/fixtures/app/src/index.ts",
1269            "src/__generated__/graphql.ts",
1270            "src/generated/client.ts",
1271        ] {
1272            detect_file(root, &file(rel, "", head), &rules, &mut raw);
1273        }
1274        assert!(raw.is_empty(), "{:?}", rules_of(&raw));
1275        detect_file(root, &file("src/schema.ts", "", head), &rules, &mut raw);
1276        assert_eq!(
1277            rules_of(&raw),
1278            vec![(DiffRiskRule::AddedSuppression, "@ts-nocheck")]
1279        );
1280    }
1281
1282    #[test]
1283    fn ts_expect_error_in_type_tests_is_an_assertion_not_a_suppression() {
1284        let head = "// @ts-expect-error\nconst x: string = 1;\n";
1285        let mut raw = Vec::new();
1286        let root = Path::new("/project");
1287        let rules = RulesConfig::default();
1288        detect_file(root, &file("test-d/hooks.ts", "", head), &rules, &mut raw);
1289        detect_file(root, &file("src/a.test-d.ts", "", head), &rules, &mut raw);
1290        detect_file(root, &file("src/a.test.ts", "", head), &rules, &mut raw);
1291        assert!(raw.is_empty(), "{:?}", rules_of(&raw));
1292        detect_file(root, &file("src/a.ts", "", head), &rules, &mut raw);
1293        assert_eq!(
1294            rules_of(&raw),
1295            vec![(DiffRiskRule::AddedSuppression, "@ts-expect-error")]
1296        );
1297    }
1298
1299    /// A test file in another language (a Rust integration test under
1300    /// `tests/`) can hold JavaScript test text in a string. The test rules
1301    /// read JavaScript-family test files only.
1302    #[test]
1303    fn test_rules_read_javascript_test_files_only() {
1304        let root = Path::new("/project");
1305        let rules = RulesConfig::default();
1306        let base = "it('a', f);\nit('b', f);\n";
1307        let head = "it.skip('a', f);\n";
1308        let mut raw = Vec::new();
1309        let rust = file("crates/cli/tests/integration/a_tests.rs", base, head);
1310        detect_file(root, &rust, &rules, &mut raw);
1311        detect_removed_tests(root, std::slice::from_ref(&rust), &mut raw);
1312        assert!(raw.is_empty(), "{:?}", rules_of(&raw));
1313
1314        let js = file("src/a.test.ts", base, head);
1315        detect_file(root, &js, &rules, &mut raw);
1316        detect_removed_tests(root, std::slice::from_ref(&js), &mut raw);
1317        assert_eq!(
1318            rules_of(&raw),
1319            vec![
1320                (DiffRiskRule::WeakenedTests, "it.skip"),
1321                (DiffRiskRule::WeakenedTests, "test cases: 2 -> 1"),
1322            ]
1323        );
1324    }
1325
1326    #[test]
1327    fn named_line_suppression_hides_the_finding() {
1328        let list = suppressions("// fallow-ignore-next-line weakened-tests\nit.skip('a', f);\n");
1329        assert!(is_suppressed(&list, DiffRiskRule::WeakenedTests, Some(2)));
1330        assert!(!is_suppressed(&list, DiffRiskRule::TestOnlyBranch, Some(2)));
1331    }
1332
1333    #[test]
1334    fn file_suppression_hides_a_file_level_finding() {
1335        let list = suppressions("// fallow-ignore-file weakened-tests\n");
1336        assert!(is_suppressed(&list, DiffRiskRule::WeakenedTests, None));
1337    }
1338
1339    #[test]
1340    fn blanket_suppression_does_not_hide_an_added_suppression() {
1341        let list = suppressions("// fallow-ignore-file\nconst a = 1;\n");
1342        assert!(!is_suppressed(
1343            &list,
1344            DiffRiskRule::AddedSuppression,
1345            Some(1)
1346        ));
1347        assert!(is_suppressed(&list, DiffRiskRule::TestOnlyBranch, Some(2)));
1348    }
1349
1350    #[test]
1351    fn json_and_deleted_files_get_a_config_action() {
1352        let actions =
1353            suppression_actions(DiffRiskRule::AddedDependency, "package.json", Some(3), true);
1354        assert_eq!(actions.len(), 1);
1355        assert_eq!(actions[0].kind, DiffRiskActionType::SuppressInConfig);
1356        let actions = suppression_actions(DiffRiskRule::WeakenedTests, "a.test.ts", None, false);
1357        assert_eq!(actions[0].kind, DiffRiskActionType::SuppressInConfig);
1358    }
1359
1360    #[test]
1361    fn source_files_get_line_and_file_comments() {
1362        let actions = suppression_actions(DiffRiskRule::TestOnlyBranch, "src/a.ts", Some(3), true);
1363        let comments: Vec<_> = actions
1364            .iter()
1365            .filter_map(|a| a.comment.as_deref())
1366            .collect();
1367        assert_eq!(
1368            comments,
1369            vec![
1370                "// fallow-ignore-next-line test-only-branch",
1371                "// fallow-ignore-file test-only-branch"
1372            ]
1373        );
1374        let actions =
1375            suppression_actions(DiffRiskRule::AddedSuppression, "src/a.css", Some(3), true);
1376        assert_eq!(
1377            actions[0].comment.as_deref(),
1378            Some("/* fallow-ignore-next-line added-suppression */")
1379        );
1380    }
1381}