Skip to main content

fallow_api/audit_run/
mod.rs

1//! The changed-code audit.
2//!
3//! `fallow audit`, the MCP `audit` tool and [`crate::run_audit`] all run an
4//! audit through [`run`], so they give the same introduced and inherited split
5//! and the same verdict. A surface supplies an [`AuditBackend`]: it runs the
6//! three analyses (dead code, duplication, health) and creates the base
7//! checkout. This module owns the rest:
8//!
9//! - the base ref: explicit, then `FALLOW_AUDIT_BASE`, then auto-detection
10//!   ([`resolve_audit_base`]),
11//! - the check that lets the head run stand in for the base
12//!   ([`can_reuse_current_as_base`]),
13//! - rename detection, and the base focus set of changed files plus pre-rename
14//!   paths,
15//! - the base snapshot: checkout, analysis root, focus remap, dependency scope,
16//!   and keys,
17//! - the rename remap of base keys, the degraded type-aware comparison, the
18//!   clone-group demotion, the verdict, and the introduced flags on the head
19//!   findings.
20
21mod base_files;
22mod base_ref;
23mod outcome;
24mod scope;
25mod snapshot;
26#[cfg(test)]
27mod tests;
28
29use std::path::{Path, PathBuf};
30
31use fallow_config::{AuditGate, AuditWorsened, ResolvedConfig, RulesConfig};
32use fallow_engine::changed_files::RenamedFile;
33use fallow_engine::repo_refs::{BaseAnalysisRoot, resolve_base_analysis_root};
34use fallow_output::HealthReport;
35use fallow_types::duplicates::CloneGroup;
36use fallow_types::results::AnalysisResults;
37use rustc_hash::FxHashSet;
38
39pub use base_files::{BaseFileReader, BaseRead, can_reuse_current_as_base, export_names_at_base};
40pub use base_ref::{
41    AuditBaseError, AuditBaseOrigin, parse_audit_base_override, resolve_audit_base,
42};
43pub use outcome::{
44    DupeDemotionDiffSource, SharedDiff, compare, demote_preexisting_dupe_introductions, outcome,
45    styling_finding_gates, styling_rule_severity,
46};
47pub use scope::{
48    BaseCoverageInputs, base_coverage_inputs, base_focus_files, remap_focus_files, renamed_files,
49    scope_dependency_findings,
50};
51pub use snapshot::{
52    AuditKeySnapshot, branching_keys, type_aware_attribution_degrade_reason,
53    type_aware_degrade_warning, type_aware_gap_signature,
54};
55
56use crate::audit_keys::AuditComparison;
57use crate::{AuditAttribution, AuditSummary, AuditVerdict};
58
59/// The dead-code analysis of one audit side, as the audit reads it.
60pub struct DeadCodeView<'a> {
61    /// Findings after change scoping.
62    pub results: &'a AnalysisResults,
63    /// Config that decides the effective severity of each finding.
64    pub config: &'a ResolvedConfig,
65    /// Root that key paths are relative to.
66    pub root: &'a Path,
67    /// Type-aware metadata of the pass, when it ran type-aware analysis.
68    pub type_aware: Option<&'a fallow_types::envelope::TypeAwareMeta>,
69    /// Dead-code keys before type-aware refinement, when the surface captured
70    /// them.
71    pub syntactic_keys: Option<&'a FxHashSet<String>>,
72    /// Exports-aware public-export keys, when the surface computed them.
73    pub public_api: Option<&'a FxHashSet<String>>,
74}
75
76/// The duplication analysis of one audit side, as the audit reads it.
77pub struct DuplicationView<'a> {
78    /// Clone groups in output order.
79    pub clone_groups: Vec<&'a CloneGroup>,
80    /// Root that key paths are relative to.
81    pub root: &'a Path,
82    /// Duplicated share of the analyzed code, in percent.
83    pub duplication_percentage: f64,
84    /// Duplication percentage above which an introduced group fails the
85    /// audit; `0.0` turns the threshold off.
86    pub threshold: f64,
87}
88
89/// The health analysis of one audit side, as the audit reads it.
90pub struct HealthView<'a> {
91    /// Health report with complexity and styling findings.
92    pub report: &'a HealthReport,
93    /// Root that key paths are relative to.
94    pub root: &'a Path,
95    /// Rules that decide whether a styling finding gates the verdict.
96    pub rules: &'a RulesConfig,
97    /// Branching totals per file, when the surface computed them.
98    pub branching: Option<&'a fallow_engine::health::BranchingByFile>,
99}
100
101/// The analyses of one audit side. An analysis the surface did not run is
102/// `None`.
103#[derive(Default)]
104pub struct AuditAnalysesView<'a> {
105    /// Dead-code analysis.
106    pub dead_code: Option<DeadCodeView<'a>>,
107    /// Duplication analysis.
108    pub duplication: Option<DuplicationView<'a>>,
109    /// Health analysis.
110    pub health: Option<HealthView<'a>>,
111}
112
113/// The analyses a surface ran for one audit side.
114pub trait AuditAnalyses {
115    /// A read view of the analyses.
116    fn view(&self) -> AuditAnalysesView<'_>;
117    /// The dead-code findings, for the dependency scope and the introduced
118    /// flags.
119    fn dead_code_results_mut(&mut self) -> Option<&mut AnalysisResults>;
120    /// The health report, for the introduced flags.
121    fn health_report_mut(&mut self) -> Option<&mut HealthReport>;
122    /// Record the warning of a degraded type-aware comparison on the
123    /// type-aware metadata of the run.
124    fn record_type_aware_warning(&mut self, warning: &str);
125}
126
127/// A checkout of the base commit.
128pub trait BaseCheckout {
129    /// Root of the checkout.
130    fn path(&self) -> &Path;
131}
132
133impl BaseCheckout for fallow_engine::repo_refs::TemporaryBaseWorktree {
134    fn path(&self) -> &Path {
135        Self::path(self)
136    }
137}
138
139/// How one surface runs the analyses of an audit.
140pub trait AuditBackend: Sync {
141    /// The analyses of one side.
142    type Analyses: AuditAnalyses + Send;
143    /// A checkout of the base commit. The base pass keeps it until the base
144    /// analyses complete.
145    type Checkout: BaseCheckout;
146    /// Key of a cached base snapshot.
147    type CacheKey: Sync;
148    /// The error of the surface.
149    type Error: Send;
150
151    /// Called once when the run has changed files, before any base work.
152    fn prepare(&self) {}
153
154    /// Run the head analyses, scoped to `changed_files`.
155    ///
156    /// # Errors
157    ///
158    /// Returns the surface error when an analysis fails.
159    fn run_head(&self, changed_files: &FxHashSet<PathBuf>) -> Result<Self::Analyses, Self::Error>;
160
161    /// Create a checkout of `base_ref`. `base_sha` is the full SHA when a
162    /// cache key resolved it.
163    ///
164    /// # Errors
165    ///
166    /// Returns the surface error when the checkout cannot be created.
167    fn create_base_checkout(
168        &self,
169        base_ref: &str,
170        base_sha: Option<&str>,
171    ) -> Result<Self::Checkout, Self::Error>;
172
173    /// Run the base analyses in `base_root`. With `focus`, scope the findings
174    /// to those files; without it, leave them unscoped.
175    ///
176    /// # Errors
177    ///
178    /// Returns the surface error when an analysis fails.
179    fn run_base(
180        &self,
181        base_root: &Path,
182        focus: Option<&FxHashSet<PathBuf>>,
183    ) -> Result<Self::Analyses, Self::Error>;
184
185    /// The cache key of the base snapshot for `base_ref` and `focus`, or
186    /// `None` when the surface keeps no cache.
187    ///
188    /// # Errors
189    ///
190    /// Returns the surface error when the key inputs cannot be read.
191    fn base_cache_key(
192        &self,
193        _base_ref: &str,
194        _focus: &FxHashSet<PathBuf>,
195    ) -> Result<Option<Self::CacheKey>, Self::Error> {
196        Ok(None)
197    }
198
199    /// The full base SHA that `key` records.
200    fn cached_base_sha<'k>(&self, _key: &'k Self::CacheKey) -> Option<&'k str> {
201        None
202    }
203
204    /// A cached base snapshot for `key`.
205    fn load_cached_base(&self, _key: &Self::CacheKey) -> Option<AuditKeySnapshot> {
206        None
207    }
208
209    /// Store a fresh base snapshot under `key`.
210    fn save_cached_base(&self, _key: &Self::CacheKey, _snapshot: &AuditKeySnapshot) {}
211
212    /// The opt-in shared diff of the run, when one is active.
213    fn shared_diff(&self) -> Option<SharedDiff<'_>> {
214        None
215    }
216}
217
218/// Inputs of one audit run.
219pub struct AuditRunInput<'a> {
220    /// Head analysis root.
221    pub root: &'a Path,
222    /// Gate mode. `new-only` compares with the base snapshot.
223    pub gate: AuditGate,
224    /// How a worsened complexity finding affects the verdict.
225    pub worsened: AuditWorsened,
226    /// Resolved base ref.
227    pub base_ref: &'a str,
228    /// Cache directory of the run. A changed file inside it never blocks the
229    /// reuse of the head run as the base snapshot.
230    pub cache_dir: Option<&'a Path>,
231    /// Changed files of the run, after any narrowing of the surface.
232    pub changed_files: FxHashSet<PathBuf>,
233    /// Commits whose findings are inherited in addition to the base, see
234    /// [`fallow_engine::repo_refs::merged_default_branch_refs`].
235    pub inherited_base_refs: &'a [String],
236}
237
238/// The base snapshot that attribution compares with.
239#[derive(Debug, Default)]
240pub struct AuditBase {
241    /// The snapshot; `None` under `--gate all`.
242    pub snapshot: Option<AuditKeySnapshot>,
243    /// `true` when the head keys stand in for the base (no finding can have
244    /// changed), so every finding is inherited.
245    pub skipped: bool,
246    /// Snapshots of the inherited bases, with head paths. Attribution absorbs
247    /// them into `snapshot` after the rename remap of the primary base.
248    pub inherited: Vec<AuditKeySnapshot>,
249}
250
251/// Inputs of [`attribute`].
252pub struct AuditAttributionInput<'a> {
253    /// Head analysis root.
254    pub root: &'a Path,
255    /// Gate mode.
256    pub gate: AuditGate,
257    /// How a worsened complexity finding affects the verdict.
258    pub worsened: AuditWorsened,
259    /// Resolved base ref, for the demotion diff.
260    pub base_ref: &'a str,
261    /// The base snapshot.
262    pub base: AuditBase,
263    /// Renames between base and head.
264    pub renames: &'a [RenamedFile],
265    /// The opt-in shared diff of the run.
266    pub shared_diff: Option<SharedDiff<'a>>,
267}
268
269/// Attribution result of one audit run.
270#[derive(Debug)]
271pub struct AuditOutcome {
272    /// Overall verdict.
273    pub verdict: AuditVerdict,
274    /// Per-domain counts.
275    pub summary: AuditSummary,
276    /// Introduced and inherited counts, and the gate.
277    pub attribution: AuditAttribution,
278    /// The classification of every head finding.
279    pub comparison: AuditComparison,
280    /// The base snapshot after the rename remap.
281    pub base_snapshot: Option<AuditKeySnapshot>,
282    /// `true` when the head keys stood in for the base.
283    pub base_snapshot_skipped: bool,
284    /// Which diff decided the clone-group demotion; `None` when it did not
285    /// run.
286    pub dupe_demotion_diff_source: Option<DupeDemotionDiffSource>,
287    /// The warning of a degraded type-aware comparison, already recorded on
288    /// the head analyses.
289    pub type_aware_degrade_warning: Option<String>,
290    /// Diff-risk findings. Every one is introduced by the change. Empty until
291    /// [`run`] computes them, and in [`attribute`] alone.
292    pub diff_risk: fallow_output::DiffRiskReport,
293}
294
295impl AuditOutcome {
296    /// Store the diff-risk findings and fold their severity into the verdict:
297    /// an `error` finding fails the audit, a `warn` finding turns a pass into
298    /// a warn.
299    pub fn apply_diff_risk(&mut self, report: fallow_output::DiffRiskReport) {
300        if report.has_errors() {
301            self.verdict = AuditVerdict::Fail;
302        } else if !report.is_empty() && self.verdict == AuditVerdict::Pass {
303            self.verdict = AuditVerdict::Warn;
304        }
305        self.diff_risk = report;
306    }
307}
308
309/// The base snapshot of the typed audit output. When the head keys stood in
310/// for the base, the head keys are the base snapshot, so this keeps them.
311pub(crate) fn programmatic_base_snapshot(
312    outcome: &AuditOutcome,
313) -> Option<crate::AuditProgrammaticKeySnapshot> {
314    outcome
315        .base_snapshot
316        .as_ref()
317        .map(AuditKeySnapshot::to_programmatic)
318}
319
320/// One completed audit run.
321pub struct AuditRun<A> {
322    /// Head analyses, with introduced flags on dead-code and health findings
323    /// when a base snapshot exists.
324    pub analyses: A,
325    /// Changed files of the run.
326    pub changed_files: FxHashSet<PathBuf>,
327    /// Attribution result.
328    pub outcome: AuditOutcome,
329}
330
331/// Run one audit. Returns `None` when the run has no changed files.
332///
333/// # Errors
334///
335/// Returns the error of the backend when an analysis or the base checkout
336/// fails.
337pub fn run<B: AuditBackend>(
338    backend: &B,
339    input: AuditRunInput<'_>,
340) -> Result<Option<AuditRun<B::Analyses>>, B::Error> {
341    let AuditRunInput {
342        root,
343        gate,
344        worsened,
345        base_ref,
346        cache_dir,
347        changed_files,
348        inherited_base_refs,
349    } = input;
350    if changed_files.is_empty() {
351        return Ok(None);
352    }
353    backend.prepare();
354
355    let needs_real_base = matches!(gate, AuditGate::NewOnly)
356        && !can_reuse_current_as_base(root, cache_dir, base_ref, &changed_files);
357    // The diff-risk pass also needs the renames under `--gate all`, so a moved
358    // test file does not read as removed tests.
359    let renames = if needs_real_base || !matches!(gate, AuditGate::NewOnly) {
360        renamed_files(root, base_ref)
361    } else {
362        Vec::new()
363    };
364    let focus = base_focus_files(&changed_files, &renames);
365    let cache_key = if needs_real_base {
366        backend.base_cache_key(base_ref, &focus)?
367    } else {
368        None
369    };
370    let cached = cache_key
371        .as_ref()
372        .and_then(|key| backend.load_cached_base(key));
373
374    let (head, fresh_base) = if needs_real_base && cached.is_none() {
375        let base_sha = cache_key
376            .as_ref()
377            .and_then(|key| backend.cached_base_sha(key));
378        let (head, base) = rayon::join(
379            || backend.run_head(&changed_files),
380            || base_snapshot(backend, root, base_ref, &focus, base_sha),
381        );
382        (head, Some(base))
383    } else {
384        (backend.run_head(&changed_files), None)
385    };
386    let mut analyses = head?;
387    scope_dependency_findings_of(&mut analyses, &changed_files);
388
389    let base = if !matches!(gate, AuditGate::NewOnly) {
390        AuditBase::default()
391    } else if let Some(snapshot) = cached {
392        AuditBase {
393            snapshot: Some(snapshot),
394            skipped: false,
395            inherited: inherited_snapshots(backend, root, inherited_base_refs, &focus),
396        }
397    } else if let Some(fresh) = fresh_base {
398        let snapshot = fresh?;
399        if let Some(key) = cache_key.as_ref() {
400            backend.save_cached_base(key, &snapshot);
401        }
402        AuditBase {
403            snapshot: Some(snapshot),
404            skipped: false,
405            inherited: inherited_snapshots(backend, root, inherited_base_refs, &focus),
406        }
407    } else {
408        AuditBase {
409            snapshot: Some(AuditKeySnapshot::from_view(&analyses.view())),
410            skipped: true,
411            inherited: Vec::new(),
412        }
413    };
414
415    let mut outcome = attribute(
416        &mut analyses,
417        AuditAttributionInput {
418            root,
419            gate,
420            worsened,
421            base_ref,
422            base,
423            renames: &renames,
424            shared_diff: backend.shared_diff(),
425        },
426    );
427    let diff_risk = diff_risk_of(
428        &analyses,
429        &outcome,
430        root,
431        base_ref,
432        &changed_files,
433        &renames,
434    );
435    outcome.apply_diff_risk(diff_risk);
436    Ok(Some(AuditRun {
437        analyses,
438        changed_files,
439        outcome,
440    }))
441}
442
443/// Compare head analyses with a base snapshot, decide the verdict, and set
444/// the introduced flags on the dead-code and health findings.
445///
446/// Under the `new-only` gate, a complexity finding that exceeds only CRAP from
447/// estimated coverage gets the gate severity `warn` first, so it does not fail
448/// the verdict. Under every gate, a complexity finding in a test file gets the
449/// gate severity `warn` and the gate exemption `test-file`.
450pub fn attribute<A: AuditAnalyses>(
451    analyses: &mut A,
452    input: AuditAttributionInput<'_>,
453) -> AuditOutcome {
454    let AuditAttributionInput {
455        root,
456        gate,
457        worsened,
458        base_ref,
459        base,
460        renames,
461        shared_diff,
462    } = input;
463    let AuditBase {
464        snapshot: mut base_snapshot,
465        skipped,
466        inherited,
467    } = base;
468    if matches!(gate, AuditGate::NewOnly)
469        && let Some(report) = analyses.health_report_mut()
470    {
471        crate::audit_complexity::demote_estimated_crap_gates(&mut report.findings);
472    }
473    if let Some(report) = analyses.health_report_mut() {
474        crate::audit_complexity::exempt_test_file_gates(&mut report.findings, root);
475    }
476    // The head keys that stand in for a skipped base are head paths already.
477    if !skipped && let Some(snapshot) = base_snapshot.as_mut() {
478        snapshot.remap_for_renames(renames, root);
479        for inherited in inherited {
480            snapshot.absorb_inherited(inherited);
481        }
482        if let Some(dead_code) = analyses.view().dead_code.as_ref() {
483            snapshot.inherit_unprovable_dead_code(dead_code);
484        }
485    }
486    let degrade_reason = {
487        let view = analyses.view();
488        type_aware_attribution_degrade_reason(
489            base_snapshot.as_ref(),
490            view.dead_code
491                .as_ref()
492                .and_then(|dead_code| dead_code.type_aware),
493        )
494    };
495    let type_aware_degrade_warning = degrade_reason.map(type_aware_degrade_warning);
496    if let Some(warning) = type_aware_degrade_warning.as_deref() {
497        analyses.record_type_aware_warning(warning);
498    }
499
500    let (comparison, dupe_demotion_diff_source, (attribution, verdict, summary)) = {
501        let view = analyses.view();
502        let mut comparison = compare(&view, base_snapshot.as_ref(), degrade_reason.is_some());
503        if worsened == AuditWorsened::Off {
504            comparison.clear_worsened();
505        }
506        let source = demote_preexisting_dupe_introductions(
507            &mut comparison,
508            &view,
509            root,
510            base_ref,
511            shared_diff,
512        );
513        let result = outcome(gate, worsened, &view, &comparison, base_snapshot.is_some());
514        (comparison, source, result)
515    };
516
517    if base_snapshot.is_some() {
518        if let Some(results) = analyses.dead_code_results_mut() {
519            comparison.dead_code.annotate_results(results);
520        }
521        if let Some(report) = analyses.health_report_mut() {
522            for ((finding, introduced), class) in report
523                .findings
524                .iter_mut()
525                .zip(comparison.health.introduced())
526                .zip(&comparison.health_classes)
527            {
528                finding.introduced = Some(introduced);
529                finding.attribution = Some(class.attribution());
530                finding.worsened_metrics.clone_from(&class.worsened_metrics);
531            }
532        }
533    }
534
535    AuditOutcome {
536        verdict,
537        summary,
538        attribution,
539        comparison,
540        base_snapshot,
541        base_snapshot_skipped: skipped,
542        dupe_demotion_diff_source,
543        type_aware_degrade_warning,
544        diff_risk: fallow_output::DiffRiskReport::default(),
545    }
546}
547
548/// Run the diff-risk rules over the changed files of one audit.
549fn diff_risk_of<A: AuditAnalyses>(
550    analyses: &A,
551    outcome: &AuditOutcome,
552    root: &Path,
553    base_ref: &str,
554    changed_files: &FxHashSet<PathBuf>,
555    renames: &[RenamedFile],
556) -> fallow_output::DiffRiskReport {
557    let view = analyses.view();
558    let default_rules = RulesConfig::default();
559    let rules = if let Some(dead_code) = view.dead_code.as_ref() {
560        crate::diff_risk::DiffRiskRules::Config(dead_code.config)
561    } else if let Some(health) = view.health.as_ref() {
562        crate::diff_risk::DiffRiskRules::Rules(health.rules)
563    } else {
564        crate::diff_risk::DiffRiskRules::Rules(&default_rules)
565    };
566    let head_clone_groups = view
567        .duplication
568        .as_ref()
569        .map(|duplication| {
570            duplication
571                .clone_groups
572                .iter()
573                .map(|group| crate::diff_risk::clone_spans(group, duplication.root))
574                .collect()
575        })
576        .unwrap_or_default();
577    // A skipped base pass reuses the head keys, so no clone copy can differ.
578    let base_clone_groups = if outcome.base_snapshot_skipped || view.duplication.is_none() {
579        None
580    } else {
581        outcome
582            .base_snapshot
583            .as_ref()
584            .map(|snapshot| snapshot.dupe_groups.as_slice())
585    };
586    crate::diff_risk::detect(&crate::diff_risk::DiffRiskInput {
587        root,
588        base_ref,
589        changed_files,
590        renames,
591        rules,
592        base_clone_groups,
593        head_clone_groups,
594    })
595}
596
597/// Take the snapshot of each inherited base, with its keys on head paths.
598///
599/// An inherited base only lowers the introduced count, so a base that fails
600/// (no worktree, a failed analysis) is left out. The audit then keeps the
601/// stricter comparison with the primary base.
602fn inherited_snapshots<B: AuditBackend>(
603    backend: &B,
604    root: &Path,
605    refs: &[String],
606    focus: &FxHashSet<PathBuf>,
607) -> Vec<AuditKeySnapshot> {
608    refs.iter()
609        .filter_map(|inherited_ref| inherited_snapshot(backend, root, inherited_ref, focus).ok())
610        .collect()
611}
612
613fn inherited_snapshot<B: AuditBackend>(
614    backend: &B,
615    root: &Path,
616    inherited_ref: &str,
617    focus: &FxHashSet<PathBuf>,
618) -> Result<AuditKeySnapshot, B::Error> {
619    let cache_key = backend.base_cache_key(inherited_ref, focus)?;
620    if let Some(cached) = cache_key
621        .as_ref()
622        .and_then(|key| backend.load_cached_base(key))
623    {
624        return Ok(remapped(cached, root, inherited_ref));
625    }
626    let base_sha = cache_key
627        .as_ref()
628        .and_then(|key| backend.cached_base_sha(key));
629    let snapshot = base_snapshot(backend, root, inherited_ref, focus, base_sha)?;
630    if let Some(key) = cache_key.as_ref() {
631        backend.save_cached_base(key, &snapshot);
632    }
633    Ok(remapped(snapshot, root, inherited_ref))
634}
635
636fn remapped(mut snapshot: AuditKeySnapshot, root: &Path, base_ref: &str) -> AuditKeySnapshot {
637    snapshot.remap_for_renames(&renamed_files(root, base_ref), root);
638    snapshot
639}
640
641/// Analyze the base checkout and take its attribution keys.
642fn base_snapshot<B: AuditBackend>(
643    backend: &B,
644    root: &Path,
645    base_ref: &str,
646    focus: &FxHashSet<PathBuf>,
647    base_sha: Option<&str>,
648) -> Result<AuditKeySnapshot, B::Error> {
649    let checkout = backend.create_base_checkout(base_ref, base_sha)?;
650    let base_root = match resolve_base_analysis_root(root, checkout.path()) {
651        // The canonical spelling: an analysis session canonicalizes its root,
652        // so a focus set spelled through a symbolic link (the macOS temporary
653        // directory) would match no finding and empty the base snapshot.
654        BaseAnalysisRoot::Present(base_root) => {
655            dunce::canonicalize(&base_root).unwrap_or(base_root)
656        }
657        // A root that the base commit does not contain (a package added on
658        // the branch) has an empty base snapshot, so every finding under it
659        // is introduced.
660        BaseAnalysisRoot::NewInHead(_) => return Ok(AuditKeySnapshot::default()),
661    };
662    let base_focus = remap_focus_files(focus, root, &base_root);
663    let mut base = backend.run_base(&base_root, base_focus.as_ref())?;
664    if let Some(focus) = base_focus.as_ref() {
665        scope_dependency_findings_of(&mut base, focus);
666    }
667    let snapshot = AuditKeySnapshot::from_view(&base.view());
668    drop(checkout);
669    Ok(snapshot)
670}
671
672/// Apply [`scope_dependency_findings`] to the dead-code findings of one side.
673fn scope_dependency_findings_of<A: AuditAnalyses>(
674    analyses: &mut A,
675    changed_files: &FxHashSet<PathBuf>,
676) {
677    let Some(root) = analyses
678        .view()
679        .dead_code
680        .map(|dead_code| dead_code.root.to_path_buf())
681    else {
682        return;
683    };
684    if let Some(results) = analyses.dead_code_results_mut() {
685        scope_dependency_findings(results, &root, changed_files);
686    }
687}