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