Skip to main content

fallow_api/audit_run/
scope.rs

1//! Which files and findings one audit run covers.
2
3use std::path::{Path, PathBuf};
4
5use fallow_engine::changed_files::RenamedFile;
6use fallow_types::results::AnalysisResults;
7use rustc_hash::FxHashSet;
8
9/// Keep a dependency-level finding only when its manifest is in `changed_files`.
10///
11/// A dependency finding (unused, type-only, test-only or misplaced
12/// dependency, unused catalog entry) is anchored to the package manifest or
13/// the catalog file that declares it. `--changed-since` keeps these findings
14/// whatever changed, because whether a dependency is used is a fact about the
15/// whole graph. An audit reviews a changeset, so it reports them only when the
16/// changeset touched the file that declares them. The rule covers the root
17/// manifest and each workspace package manifest. A relative anchor (a catalog
18/// file) is relative to `root`, the root of the analysis.
19#[expect(
20    clippy::implicit_hasher,
21    reason = "fallow standardizes on FxHashSet across the workspace"
22)]
23pub fn scope_dependency_findings(
24    results: &mut AnalysisResults,
25    root: &Path,
26    changed_files: &FxHashSet<PathBuf>,
27) {
28    let changed: FxHashSet<PathBuf> = changed_files
29        .iter()
30        .map(|path| dunce::simplified(path).to_path_buf())
31        .collect();
32    // Git reports changed files under the canonical top level, while a root
33    // can be spelled through a symbolic link, so the canonical form of an
34    // anchor is tried as well.
35    let declared_in_change = |path: &Path| {
36        let anchored = if path.is_absolute() {
37            path.to_path_buf()
38        } else {
39            root.join(path)
40        };
41        changed.contains(dunce::simplified(&anchored))
42            || dunce::canonicalize(&anchored).is_ok_and(|canonical| changed.contains(&canonical))
43    };
44    results
45        .unused_dependencies
46        .retain(|finding| declared_in_change(&finding.dep.path));
47    results
48        .unused_dev_dependencies
49        .retain(|finding| declared_in_change(&finding.dep.path));
50    results
51        .unused_optional_dependencies
52        .retain(|finding| declared_in_change(&finding.dep.path));
53    results
54        .type_only_dependencies
55        .retain(|finding| declared_in_change(&finding.dep.path));
56    results
57        .test_only_dependencies
58        .retain(|finding| declared_in_change(&finding.dep.path));
59    results
60        .dev_dependencies_in_production
61        .retain(|finding| declared_in_change(&finding.dep.path));
62    results
63        .unused_catalog_entries
64        .retain(|finding| declared_in_change(&finding.entry.path));
65}
66
67/// Detect base..head renames for rename-aware attribution.
68///
69/// Best effort: when git fails, the audit uses plain path-keyed attribution,
70/// which reports findings that moved with a file as introduced.
71#[must_use]
72pub fn renamed_files(root: &Path, base_ref: &str) -> Vec<RenamedFile> {
73    fallow_engine::changed_files::try_get_renamed_files(root, base_ref).unwrap_or_default()
74}
75
76/// The files the base pass covers: the changed files plus the pre-rename path
77/// of each rename, so base findings on moved files are in the base snapshot and
78/// the rename remap can move them onto their head paths.
79#[must_use]
80#[expect(
81    clippy::implicit_hasher,
82    reason = "fallow standardizes on FxHashSet across the workspace"
83)]
84pub fn base_focus_files(
85    changed_files: &FxHashSet<PathBuf>,
86    renames: &[RenamedFile],
87) -> FxHashSet<PathBuf> {
88    changed_files
89        .iter()
90        .cloned()
91        .chain(renames.iter().map(|rename| rename.from.clone()))
92        .collect()
93}
94
95/// Express `files` (absolute paths under `from_root`) under `to_root`.
96///
97/// Returns `None` when no path maps. The caller then leaves the base results
98/// unfiltered: a filter with an empty set would remove every base finding and
99/// make every inherited head finding look introduced.
100///
101/// The focus set comes from `git rev-parse --show-toplevel`, whose spelling
102/// can differ from the canonical root of the caller (Windows 8.3 components,
103/// drive-letter case, verbatim `\\?\` prefixes), so the simplified and the
104/// canonical forms are both tried before a path is given up.
105#[must_use]
106#[expect(
107    clippy::implicit_hasher,
108    reason = "fallow standardizes on FxHashSet across the workspace"
109)]
110pub fn remap_focus_files(
111    files: &FxHashSet<PathBuf>,
112    from_root: &Path,
113    to_root: &Path,
114) -> Option<FxHashSet<PathBuf>> {
115    let simple_from = dunce::simplified(from_root).to_path_buf();
116    let canonical_from = dunce::canonicalize(from_root).unwrap_or_else(|_| simple_from.clone());
117    let mut remapped = FxHashSet::default();
118    for file in files {
119        let simple_file = dunce::simplified(file);
120        let relative = simple_file
121            .strip_prefix(&simple_from)
122            .or_else(|_| simple_file.strip_prefix(&canonical_from))
123            .map(Path::to_path_buf)
124            .ok()
125            .or_else(|| {
126                let canonical_file = dunce::canonicalize(file).ok()?;
127                canonical_file
128                    .strip_prefix(&canonical_from)
129                    .map(Path::to_path_buf)
130                    .ok()
131            });
132        if let Some(relative) = relative {
133            remapped.insert(to_root.join(relative));
134        }
135    }
136    if remapped.is_empty() {
137        return None;
138    }
139    Some(remapped)
140}
141
142/// Istanbul coverage inputs of the base pass.
143#[derive(Debug, Clone, Default, PartialEq, Eq)]
144pub struct BaseCoverageInputs {
145    /// Coverage map path, resolved against the head root.
146    pub coverage: Option<PathBuf>,
147    /// Prefix to strip from recorded paths before they rebase onto the base
148    /// worktree.
149    pub coverage_root: Option<PathBuf>,
150}
151
152/// Coverage inputs for the base-worktree pass.
153///
154/// The Istanbul map records head-checkout paths, while the base pass analyzes
155/// a temporary worktree. Without a rebase no coverage entry matches a base
156/// file, base CRAP falls back to the reachability estimate, and unchanged
157/// functions flip to `introduced` (#2347). Without an explicit map, the head
158/// pass auto-detects `coverage/coverage-final.json` against the head root,
159/// which the base worktree never has, so the same detection runs here. When no
160/// explicit `coverage_root` exists, the canonical head root becomes the strip
161/// prefix, so every entry rebases onto the base worktree. An explicit
162/// `coverage_root` stays as it is: the base pass rebases it onto its own root.
163#[must_use]
164pub fn base_coverage_inputs(
165    root: &Path,
166    coverage: Option<&Path>,
167    coverage_root: Option<&Path>,
168) -> BaseCoverageInputs {
169    let canonical_root = dunce::canonicalize(root).unwrap_or_else(|_| root.to_path_buf());
170    let coverage = coverage.map_or_else(
171        || fallow_engine::health::scoring::auto_detect_coverage(&canonical_root),
172        |coverage| {
173            Some(fallow_engine::health::scoring::resolve_relative_to_root(
174                coverage,
175                Some(&canonical_root),
176            ))
177        },
178    );
179    let coverage_root = match (&coverage, coverage_root) {
180        (_, Some(explicit)) => Some(explicit.to_path_buf()),
181        (Some(_), None) => Some(canonical_root),
182        (None, None) => None,
183    };
184    BaseCoverageInputs {
185        coverage,
186        coverage_root,
187    }
188}