Skip to main content

fallow_cli/
audit.rs

1use std::io::Write;
2use std::path::{Path, PathBuf};
3use std::process::{Command, ExitCode};
4use std::time::{Duration, Instant};
5
6use fallow_config::{AuditGate, OutputFormat};
7use fallow_core::git_env::clear_ambient_git_env;
8use rustc_hash::{FxHashMap, FxHashSet};
9use xxhash_rust::xxh3::xxh3_64;
10
11use crate::base_worktree::{
12    BaseWorktree, git_rev_parse, git_toplevel, resolve_cache_max_age, sweep_old_reusable_caches,
13};
14use crate::check::{CheckOptions, CheckResult, IssueFilters, TraceOptions};
15use crate::dupes::{DupesMode, DupesOptions, DupesResult};
16use crate::error::emit_error;
17use crate::health::{HealthOptions, HealthResult, SortBy};
18
19const AUDIT_BASE_SNAPSHOT_CACHE_VERSION: u8 = 3;
20const MAX_AUDIT_BASE_SNAPSHOT_CACHE_SIZE: usize = 16 * 1024 * 1024;
21
22/// Verdict for the audit command.
23#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize)]
24#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
25#[serde(rename_all = "snake_case")]
26pub enum AuditVerdict {
27    /// No issues in changed files.
28    Pass,
29    /// Issues found, but all are warn-severity.
30    Warn,
31    /// Error-severity issues found in changed files.
32    Fail,
33}
34
35/// Per-category summary counts for the audit result.
36#[derive(Debug, Clone, serde::Serialize)]
37#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
38pub struct AuditSummary {
39    pub dead_code_issues: usize,
40    pub dead_code_has_errors: bool,
41    pub complexity_findings: usize,
42    pub max_cyclomatic: Option<u16>,
43    pub duplication_clone_groups: usize,
44}
45
46/// New-vs-inherited issue counts for audit.
47#[derive(Debug, Default, Clone, serde::Serialize)]
48#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
49pub struct AuditAttribution {
50    pub gate: AuditGate,
51    pub dead_code_introduced: usize,
52    pub dead_code_inherited: usize,
53    pub complexity_introduced: usize,
54    pub complexity_inherited: usize,
55    pub duplication_introduced: usize,
56    pub duplication_inherited: usize,
57}
58
59/// Full audit result containing verdict, summary, and sub-results.
60pub struct AuditResult {
61    pub verdict: AuditVerdict,
62    pub summary: AuditSummary,
63    pub attribution: AuditAttribution,
64    /// Key snapshot of the base ref for new-vs-inherited attribution. `None`
65    /// when the base pass was skipped (`--gate all`) or unavailable. Exposed at
66    /// crate scope so test fixtures in sibling modules can construct an
67    /// `AuditResult` with `base_snapshot: None`.
68    pub base_snapshot: Option<AuditKeySnapshot>,
69    pub base_snapshot_skipped: bool,
70    pub changed_files_count: usize,
71    /// Absolute paths of the files this run re-analyzed. Threaded into the
72    /// Fallow Impact per-finding attribution so the frontier diff knows which
73    /// files were authoritative this run.
74    pub changed_files: Vec<PathBuf>,
75    pub base_ref: String,
76    /// Human-readable provenance of `base_ref` for the scope line, e.g.
77    /// `merge-base with origin/main`. `None` for an explicit `--base` (the ref
78    /// the user typed is already self-describing). Not serialized; the JSON
79    /// envelope carries the resolved `base_ref` directly.
80    pub base_description: Option<String>,
81    pub head_sha: Option<String>,
82    pub output: OutputFormat,
83    pub performance: bool,
84    pub check: Option<CheckResult>,
85    pub dupes: Option<DupesResult>,
86    pub health: Option<HealthResult>,
87    pub elapsed: Duration,
88    /// Review-brief data, populated only on the brief path. The deltas are
89    /// computed from the head sets vs the base snapshot; weakening + routing are
90    /// computed from git over the changed files. `None` off the brief path.
91    pub review_deltas: Option<crate::audit_brief::ReviewDeltas>,
92    pub weakening_signals: Vec<weakening::WeakeningSignal>,
93    pub routing: Option<routing::RoutingFacts>,
94    /// Decision surface (the apex): the ranked, capped, signal_id-anchored set
95    /// of consequential structural decisions, each framed as a judgment question.
96    /// Populated only on the brief path; `None` otherwise.
97    pub decision_surface: Option<crate::audit_decision_surface::DecisionSurface>,
98    /// Deterministic graph-snapshot hash: a stable hash of the relevant HEAD
99    /// graph + diff state (the six key sets plus the resolved base ref + head
100    /// sha). Pinned into the walkthrough guide digest so a stale agent JSON
101    /// (whose echoed hash != this) is REFUSED on reentry. The verifier is the
102    /// graph: a mutated tree changes a key set, changes this hash, refuses the
103    /// stale payload. Populated only on the brief path; `None` otherwise.
104    pub graph_snapshot_hash: Option<String>,
105    /// Per-hunk change anchors derived from the diff: one stable, content-
106    /// addressed id per changed region. Emitted in the walkthrough guide so an
107    /// agent can anchor a trade-off about a changed region with no graph finding
108    /// (and have it post-validated). Also folded into `graph_snapshot_hash` so a
109    /// moved region refuses a stale payload. Populated only on the brief path.
110    pub change_anchors: Vec<crate::audit_walkthrough::ChangeAnchor>,
111}
112
113pub struct AuditOptions<'a> {
114    pub root: &'a std::path::Path,
115    pub config_path: &'a Option<std::path::PathBuf>,
116    pub cache_dir: &'a std::path::Path,
117    pub output: OutputFormat,
118    pub no_cache: bool,
119    pub threads: usize,
120    pub quiet: bool,
121    pub changed_since: Option<&'a str>,
122    pub production: bool,
123    pub production_dead_code: Option<bool>,
124    pub production_health: Option<bool>,
125    pub production_dupes: Option<bool>,
126    pub workspace: Option<&'a [String]>,
127    pub changed_workspaces: Option<&'a str>,
128    pub explain: bool,
129    pub explain_skipped: bool,
130    pub performance: bool,
131    pub group_by: Option<crate::GroupBy>,
132    /// Baseline file for dead-code analysis (as produced by `fallow dead-code --save-baseline`).
133    pub dead_code_baseline: Option<&'a std::path::Path>,
134    /// Baseline file for health analysis (as produced by `fallow health --save-baseline`).
135    pub health_baseline: Option<&'a std::path::Path>,
136    /// Baseline file for duplication analysis (as produced by `fallow dupes --save-baseline`).
137    pub dupes_baseline: Option<&'a std::path::Path>,
138    /// Maximum CRAP score threshold (overrides `health.maxCrap` from config).
139    /// Functions meeting or exceeding this score cause audit to fail.
140    pub max_crap: Option<f64>,
141    /// Istanbul coverage input for accurate CRAP scoring in the health sub-pass.
142    pub coverage: Option<&'a std::path::Path>,
143    /// Prefix to strip from Istanbul source paths before rebasing to `root`.
144    pub coverage_root: Option<&'a std::path::Path>,
145    pub gate: AuditGate,
146    /// Report unused exports in entry files (forwarded to the dead-code sub-pass).
147    pub include_entry_exports: bool,
148    /// Paid runtime-coverage sidecar input (V8 directory, V8 JSON, or
149    /// Istanbul coverage map). Forwarded into the embedded health pass so
150    /// audit surfaces the `hot-path-touched` verdict alongside dead-code
151    /// and complexity findings without requiring a second `fallow health`
152    /// invocation in CI.
153    pub runtime_coverage: Option<&'a std::path::Path>,
154    /// Threshold for hot-path classification, forwarded to the sidecar.
155    pub min_invocations_hot: u64,
156    /// Render the deterministic, always-exit-0 review brief (`fallow audit
157    /// --brief` / `fallow review`) instead of the gating audit report. The
158    /// audit analysis still runs and the verdict is still computed and carried
159    /// informationally; it just never drives the exit code on this path.
160    pub brief: bool,
161    /// Decision-surface cap (the working-memory limit). Default 4; clamped to
162    /// [3, 5] (4 plus or minus 1) by the extractor. Only consulted on the brief
163    /// path.
164    pub max_decisions: usize,
165    /// Emit the agent-contract walkthrough GUIDE (digest + schema + graph-
166    /// snapshot pin) instead of the brief body. Implies `brief`. Always exit 0.
167    pub walkthrough_guide: bool,
168    /// Path to an agent's judgment JSON to POST-VALIDATE against the live
169    /// graph. Implies `brief`. Always exit 0. `None` off the walkthrough path.
170    pub walkthrough_file: Option<&'a std::path::Path>,
171    /// Expand the de-prioritized units in the human focus map ("show me what
172    /// you de-prioritized"). The `deprioritized` list is ALWAYS in the JSON
173    /// regardless; this only re-expands the human render (collapse-by-default).
174    /// Only consulted on the brief path.
175    pub show_deprioritized: bool,
176}
177
178/// A base ref resolved by auto-detection: the git ref to diff against plus a
179/// human-readable provenance string for the scope line.
180struct DetectedBase {
181    /// The ref the audit diffs against: a `git merge-base` SHA (the fork
182    /// point), a remote-tracking ref, or a local branch name.
183    git_ref: String,
184    /// How the ref was resolved, e.g. `merge-base with origin/main`. Shown on
185    /// the human audit scope line so the comparison target is checkable.
186    description: String,
187}
188
189/// Run `git <args>` in `root` with ambient git env cleared and return trimmed
190/// stdout, or `None` on non-zero exit / empty output.
191fn git_stdout(root: &std::path::Path, args: &[&str]) -> Option<String> {
192    let mut command = std::process::Command::new("git");
193    command.args(args).current_dir(root);
194    clear_ambient_git_env(&mut command);
195    let output = command.output().ok()?;
196    if !output.status.success() {
197        return None;
198    }
199    let trimmed = String::from_utf8_lossy(&output.stdout).trim().to_string();
200    if trimmed.is_empty() {
201        None
202    } else {
203        Some(trimmed)
204    }
205}
206
207/// Whether `git_ref` resolves to a commit in this repository.
208fn git_ref_exists(root: &std::path::Path, git_ref: &str) -> bool {
209    git_stdout(root, &["rev-parse", "--verify", "--quiet", git_ref]).is_some()
210}
211
212/// The current branch's configured upstream (`@{upstream}`), e.g. `origin/main`,
213/// or `None` when no tracking branch is set (detached HEAD, fresh worktree).
214fn git_upstream_ref(root: &std::path::Path) -> Option<String> {
215    git_stdout(
216        root,
217        &[
218            "rev-parse",
219            "--abbrev-ref",
220            "--symbolic-full-name",
221            "@{upstream}",
222        ],
223    )
224}
225
226/// The merge-base (fork point) SHA of `a` and `b`, or `None` when there is no
227/// common ancestor (shallow clone, unrelated history).
228fn git_merge_base(root: &std::path::Path, a: &str, b: &str) -> Option<String> {
229    git_stdout(root, &["merge-base", a, b])
230}
231
232/// The remote default branch as a remote-tracking ref (`origin/<branch>`).
233/// Priority: `origin/HEAD` symbolic ref, then `origin/main`, then
234/// `origin/master`. Returns `None` when there is no `origin` remote at all.
235fn detect_remote_default_ref(root: &std::path::Path) -> Option<String> {
236    if let Some(full_ref) = git_stdout(root, &["symbolic-ref", "refs/remotes/origin/HEAD"])
237        && let Some(branch) = full_ref.strip_prefix("refs/remotes/origin/")
238    {
239        return Some(format!("origin/{branch}"));
240    }
241    for candidate in ["origin/main", "origin/master"] {
242        if git_ref_exists(root, candidate) {
243            return Some(candidate.to_string());
244        }
245    }
246    None
247}
248
249/// Auto-detect the base ref for `fallow audit` when no `--base` / env override
250/// is set.
251///
252/// The base is the `git merge-base` (fork point) against the branch's upstream
253/// or the remote default, mirroring the `fallow hooks install --target git`
254/// pre-commit hook (issue #242). Resolving to the merge-base SHA, rather than a
255/// bare branch name, fixes the long-standing bug where the default branch was
256/// discovered via `origin/HEAD` but returned as the bare name `main` (issue
257/// #1168): git resolves a bare `main` to the LOCAL `refs/heads/main`, which is
258/// stale on worktree checkouts cut from `origin/main`, so the audit diffed
259/// every branch against an ancient base and false-failed the gate.
260///
261/// Resolution order:
262/// 1. `@{upstream}` merge-base, so a branch forked off a non-default
263///    integration branch compares against where it actually forked.
264/// 2. Remote default (`origin/HEAD` -> `origin/main` -> `origin/master`)
265///    merge-base. The remote-tracking ref refreshes on fetch, unlike a
266///    long-stale local branch; the merge-base is also immune to an unfetched
267///    `origin/main` in the false-fail direction.
268/// 3. Local `main` / `master` when there is no `origin` remote, preserving the
269///    historical behavior for air-gapped / local-only repos.
270fn auto_detect_base_ref(root: &std::path::Path) -> Option<DetectedBase> {
271    if let Some(upstream) = git_upstream_ref(root) {
272        if let Some(sha) = git_merge_base(root, &upstream, "HEAD") {
273            return Some(DetectedBase {
274                git_ref: sha,
275                description: format!("merge-base with {upstream}"),
276            });
277        }
278        // No common ancestor (shallow clone / unrelated history): fall back to
279        // the upstream tip rather than failing the detection outright.
280        return Some(DetectedBase {
281            description: format!("{upstream} (tip)"),
282            git_ref: upstream,
283        });
284    }
285
286    if let Some(remote_ref) = detect_remote_default_ref(root) {
287        if let Some(sha) = git_merge_base(root, &remote_ref, "HEAD") {
288            return Some(DetectedBase {
289                git_ref: sha,
290                description: format!("merge-base with {remote_ref}"),
291            });
292        }
293        return Some(DetectedBase {
294            description: format!("{remote_ref} (tip)"),
295            git_ref: remote_ref,
296        });
297    }
298
299    for candidate in ["main", "master"] {
300        if git_ref_exists(root, candidate) {
301            return Some(DetectedBase {
302                git_ref: candidate.to_string(),
303                description: format!("local {candidate}"),
304            });
305        }
306    }
307
308    None
309}
310
311/// Get the short SHA of HEAD for the scope display line.
312fn get_head_sha(root: &std::path::Path) -> Option<String> {
313    let mut command = std::process::Command::new("git");
314    command
315        .args(["rev-parse", "--short", "HEAD"])
316        .current_dir(root);
317    clear_ambient_git_env(&mut command);
318    let output = command.output().ok()?;
319    if output.status.success() {
320        Some(String::from_utf8_lossy(&output.stdout).trim().to_string())
321    } else {
322        None
323    }
324}
325
326fn compute_verdict(
327    check: Option<&CheckResult>,
328    dupes: Option<&DupesResult>,
329    health: Option<&HealthResult>,
330) -> AuditVerdict {
331    let mut has_errors = false;
332    let mut has_warnings = false;
333
334    if let Some(result) = check {
335        if crate::check::has_error_severity_issues(
336            &result.results,
337            &result.config.rules,
338            Some(&result.config),
339        ) {
340            has_errors = true;
341        } else if result.results.total_issues() > 0 {
342            has_warnings = true;
343        }
344    }
345
346    if let Some(result) = health
347        && !result.report.findings.is_empty()
348    {
349        has_errors = true;
350    }
351
352    if let Some(result) = dupes
353        && !result.report.clone_groups.is_empty()
354    {
355        if result.threshold > 0.0 && result.report.stats.duplication_percentage > result.threshold {
356            has_errors = true;
357        } else {
358            has_warnings = true;
359        }
360    }
361
362    if has_errors {
363        AuditVerdict::Fail
364    } else if has_warnings {
365        AuditVerdict::Warn
366    } else {
367        AuditVerdict::Pass
368    }
369}
370
371fn build_summary(
372    check: Option<&CheckResult>,
373    dupes: Option<&DupesResult>,
374    health: Option<&HealthResult>,
375) -> AuditSummary {
376    let dead_code_issues = check.map_or(0, |r| r.results.total_issues());
377    let dead_code_has_errors = check.is_some_and(|r| {
378        crate::check::has_error_severity_issues(&r.results, &r.config.rules, Some(&r.config))
379    });
380    let complexity_findings = health.map_or(0, |r| r.report.findings.len());
381    let max_cyclomatic = health.and_then(|r| r.report.findings.iter().map(|f| f.cyclomatic).max());
382    let duplication_clone_groups = dupes.map_or(0, |r| r.report.clone_groups.len());
383
384    AuditSummary {
385        dead_code_issues,
386        dead_code_has_errors,
387        complexity_findings,
388        max_cyclomatic,
389        duplication_clone_groups,
390    }
391}
392
393fn compute_audit_attribution(
394    check: Option<&CheckResult>,
395    dupes: Option<&DupesResult>,
396    health: Option<&HealthResult>,
397    base: Option<&AuditKeySnapshot>,
398    gate: AuditGate,
399) -> AuditAttribution {
400    let dead_code = check
401        .map(|r| {
402            count_introduced(
403                &dead_code_keys(&r.results, &r.config.root),
404                base.map(|b| &b.dead_code),
405            )
406        })
407        .unwrap_or_default();
408    let complexity = health
409        .map(|r| {
410            count_introduced(
411                &health_keys(&r.report, &r.config.root),
412                base.map(|b| &b.health),
413            )
414        })
415        .unwrap_or_default();
416    let duplication = dupes
417        .map(|r| {
418            count_introduced(
419                &dupes_keys(&r.report, &r.config.root),
420                base.map(|b| &b.dupes),
421            )
422        })
423        .unwrap_or_default();
424
425    AuditAttribution {
426        gate,
427        dead_code_introduced: dead_code.0,
428        dead_code_inherited: dead_code.1,
429        complexity_introduced: complexity.0,
430        complexity_inherited: complexity.1,
431        duplication_introduced: duplication.0,
432        duplication_inherited: duplication.1,
433    }
434}
435
436fn compute_introduced_verdict(
437    check: Option<&CheckResult>,
438    dupes: Option<&DupesResult>,
439    health: Option<&HealthResult>,
440    base: Option<&AuditKeySnapshot>,
441) -> AuditVerdict {
442    let mut has_errors = false;
443    let mut has_warnings = false;
444
445    if let Some(result) = check {
446        let (errors, warnings) = introduced_check_verdict(result, base);
447        has_errors |= errors;
448        has_warnings |= warnings;
449    }
450    if let Some(result) = health {
451        has_errors |= introduced_health_has_errors(result, base);
452    }
453    if let Some(result) = dupes {
454        let (errors, warnings) = introduced_dupes_verdict(result, base);
455        has_errors |= errors;
456        has_warnings |= warnings;
457    }
458
459    if has_errors {
460        AuditVerdict::Fail
461    } else if has_warnings {
462        AuditVerdict::Warn
463    } else {
464        AuditVerdict::Pass
465    }
466}
467
468/// Error/warning contribution from dead-code issues introduced since the base.
469fn introduced_check_verdict(result: &CheckResult, base: Option<&AuditKeySnapshot>) -> (bool, bool) {
470    let base_keys = base.map(|b| &b.dead_code);
471    let mut introduced = result.results.clone();
472    retain_introduced_dead_code(&mut introduced, &result.config.root, base_keys);
473    if crate::check::has_error_severity_issues(
474        &introduced,
475        &result.config.rules,
476        Some(&result.config),
477    ) {
478        (true, false)
479    } else if introduced.total_issues() > 0 {
480        (false, true)
481    } else {
482        (false, false)
483    }
484}
485
486/// True when complexity findings were introduced since the base (always an error).
487fn introduced_health_has_errors(result: &HealthResult, base: Option<&AuditKeySnapshot>) -> bool {
488    let base_keys = base.map(|b| &b.health);
489    result.report.findings.iter().any(|finding| {
490        !base_keys
491            .is_some_and(|keys| keys.contains(&health_finding_key(finding, &result.config.root)))
492    })
493}
494
495/// Error/warning contribution from clone groups introduced since the base.
496fn introduced_dupes_verdict(result: &DupesResult, base: Option<&AuditKeySnapshot>) -> (bool, bool) {
497    let base_keys = base.map(|b| &b.dupes);
498    let introduced = result
499        .report
500        .clone_groups
501        .iter()
502        .filter(|group| {
503            !base_keys
504                .is_some_and(|keys| keys.contains(&dupe_group_key(group, &result.config.root)))
505        })
506        .count();
507    if introduced == 0 {
508        return (false, false);
509    }
510    if result.threshold > 0.0 && result.report.stats.duplication_percentage > result.threshold {
511        (true, false)
512    } else {
513        (false, true)
514    }
515}
516
517pub struct AuditKeySnapshot {
518    dead_code: FxHashSet<String>,
519    health: FxHashSet<String>,
520    dupes: FxHashSet<String>,
521    /// Review-brief delta substrate (populated only on the brief path; empty
522    /// otherwise). Cross-zone boundary EDGE keys (`<from_zone>->-<to_zone>`),
523    /// one per distinct zone pair (R2 first-edge-only framing).
524    boundary_edges: FxHashSet<String>,
525    /// Canonical circular-dependency keys (rotation-independent file set).
526    cycles: FxHashSet<String>,
527    /// Exports-aware public-export keys (`<rel_path>::<name>`), the surface
528    /// reachable through `package.json` `exports` + re-export reachability.
529    public_api: FxHashSet<String>,
530}
531
532struct AuditBaseSnapshotCacheKey {
533    hash: u64,
534    base_sha: String,
535}
536
537#[derive(bitcode::Encode, bitcode::Decode)]
538struct CachedAuditKeySnapshot {
539    version: u8,
540    cli_version: String,
541    key_hash: u64,
542    base_sha: String,
543    dead_code: Vec<String>,
544    health: Vec<String>,
545    dupes: Vec<String>,
546    boundary_edges: Vec<String>,
547    cycles: Vec<String>,
548    public_api: Vec<String>,
549}
550
551fn count_introduced(keys: &FxHashSet<String>, base: Option<&FxHashSet<String>>) -> (usize, usize) {
552    let Some(base) = base else {
553        return (0, 0);
554    };
555    keys.iter().fold((0, 0), |(introduced, inherited), key| {
556        if base.contains(key) {
557            (introduced, inherited + 1)
558        } else {
559            (introduced + 1, inherited)
560        }
561    })
562}
563
564fn sorted_keys(keys: &FxHashSet<String>) -> Vec<String> {
565    let mut keys: Vec<String> = keys.iter().cloned().collect();
566    keys.sort_unstable();
567    keys
568}
569
570fn snapshot_from_cached(cached: CachedAuditKeySnapshot) -> AuditKeySnapshot {
571    AuditKeySnapshot {
572        dead_code: cached.dead_code.into_iter().collect(),
573        health: cached.health.into_iter().collect(),
574        dupes: cached.dupes.into_iter().collect(),
575        boundary_edges: cached.boundary_edges.into_iter().collect(),
576        cycles: cached.cycles.into_iter().collect(),
577        public_api: cached.public_api.into_iter().collect(),
578    }
579}
580
581fn cached_from_snapshot(
582    key: &AuditBaseSnapshotCacheKey,
583    snapshot: &AuditKeySnapshot,
584) -> CachedAuditKeySnapshot {
585    CachedAuditKeySnapshot {
586        version: AUDIT_BASE_SNAPSHOT_CACHE_VERSION,
587        cli_version: env!("CARGO_PKG_VERSION").to_string(),
588        key_hash: key.hash,
589        base_sha: key.base_sha.clone(),
590        dead_code: sorted_keys(&snapshot.dead_code),
591        health: sorted_keys(&snapshot.health),
592        dupes: sorted_keys(&snapshot.dupes),
593        boundary_edges: sorted_keys(&snapshot.boundary_edges),
594        cycles: sorted_keys(&snapshot.cycles),
595        public_api: sorted_keys(&snapshot.public_api),
596    }
597}
598
599fn audit_base_snapshot_cache_dir(cache_dir: &Path) -> PathBuf {
600    cache_dir
601        .join("cache")
602        .join(format!("audit-base-v{AUDIT_BASE_SNAPSHOT_CACHE_VERSION}"))
603}
604
605fn audit_base_snapshot_cache_file(cache_dir: &Path, key: &AuditBaseSnapshotCacheKey) -> PathBuf {
606    audit_base_snapshot_cache_dir(cache_dir).join(format!("{:016x}.bin", key.hash))
607}
608
609fn ensure_audit_base_snapshot_cache_dir(dir: &Path) -> Result<(), std::io::Error> {
610    std::fs::create_dir_all(dir)?;
611    let gitignore = dir.join(".gitignore");
612    if std::fs::read_to_string(&gitignore).ok().as_deref() != Some("*\n") {
613        std::fs::write(gitignore, "*\n")?;
614    }
615    Ok(())
616}
617
618fn load_cached_base_snapshot(
619    opts: &AuditOptions<'_>,
620    key: &AuditBaseSnapshotCacheKey,
621) -> Option<AuditKeySnapshot> {
622    let path = audit_base_snapshot_cache_file(opts.cache_dir, key);
623    let data = std::fs::read(path).ok()?;
624    if data.len() > MAX_AUDIT_BASE_SNAPSHOT_CACHE_SIZE {
625        return None;
626    }
627    let cached: CachedAuditKeySnapshot = bitcode::decode(&data).ok()?;
628    if cached.version != AUDIT_BASE_SNAPSHOT_CACHE_VERSION
629        || cached.cli_version != env!("CARGO_PKG_VERSION")
630        || cached.key_hash != key.hash
631        || cached.base_sha != key.base_sha
632    {
633        return None;
634    }
635    Some(snapshot_from_cached(cached))
636}
637
638fn save_cached_base_snapshot(
639    opts: &AuditOptions<'_>,
640    key: &AuditBaseSnapshotCacheKey,
641    snapshot: &AuditKeySnapshot,
642) {
643    let dir = audit_base_snapshot_cache_dir(opts.cache_dir);
644    if ensure_audit_base_snapshot_cache_dir(&dir).is_err() {
645        return;
646    }
647    let data = bitcode::encode(&cached_from_snapshot(key, snapshot));
648    let Ok(mut tmp) = tempfile::NamedTempFile::new_in(&dir) else {
649        return;
650    };
651    if tmp.write_all(&data).is_err() {
652        return;
653    }
654    let _ = tmp.persist(audit_base_snapshot_cache_file(opts.cache_dir, key));
655}
656
657/// If fallow's process inherited any ambient git repo-state env vars (typical
658/// when invoked from a `pre-commit` / `pre-push` hook or a tool wrapping git),
659/// surface the most likely culprit so a user hitting an unexpected worktree
660/// failure can short-circuit the diagnosis. Returns `None` otherwise.
661fn ambient_git_env_hint() -> Option<String> {
662    use fallow_core::git_env::AMBIENT_GIT_ENV_VARS;
663    for var in AMBIENT_GIT_ENV_VARS {
664        if let Ok(value) = std::env::var(var)
665            && !value.is_empty()
666        {
667            return Some(format!(
668                "{var}={value} is set in the environment; if fallow is being \
669invoked from a git hook this can interfere with worktree operations. Re-run \
670with `env -u {var} fallow audit` to confirm."
671            ));
672        }
673    }
674    None
675}
676
677fn normalized_changed_files(root: &Path, changed_files: &FxHashSet<PathBuf>) -> Vec<String> {
678    let git_root = git_toplevel(root);
679    let mut files: Vec<String> = changed_files
680        .iter()
681        .map(|path| {
682            git_root
683                .as_ref()
684                .and_then(|root| path.strip_prefix(root).ok())
685                .unwrap_or(path)
686                .to_string_lossy()
687                .replace('\\', "/")
688        })
689        .collect();
690    files.sort_unstable();
691    files
692}
693
694fn config_file_fingerprint(opts: &AuditOptions<'_>) -> Result<serde_json::Value, ExitCode> {
695    let loaded = if let Some(path) = opts.config_path {
696        let config = fallow_config::FallowConfig::load(path).map_err(|e| {
697            emit_error(
698                &format!("failed to load config '{}': {e}", path.display()),
699                2,
700                opts.output,
701            )
702        })?;
703        Some((config, path.clone()))
704    } else {
705        fallow_config::FallowConfig::find_and_load(opts.root)
706            .map_err(|e| emit_error(&e, 2, opts.output))?
707    };
708
709    let Some((config, path)) = loaded else {
710        return Ok(serde_json::json!({
711            "path": null,
712            "resolved_hash": null,
713        }));
714    };
715    let bytes = serde_json::to_vec(&config).map_err(|e| {
716        emit_error(
717            &format!("failed to serialize resolved config for audit cache key: {e}"),
718            2,
719            opts.output,
720        )
721    })?;
722    Ok(serde_json::json!({
723        "path": path.to_string_lossy(),
724        "resolved_hash": format!("{:016x}", xxh3_64(&bytes)),
725    }))
726}
727
728fn coverage_file_fingerprint(path: &Path, project_root: &Path) -> serde_json::Value {
729    let resolved = crate::health::scoring::resolve_relative_to_root(path, Some(project_root));
730    let file_path = if resolved.is_dir() {
731        resolved.join("coverage-final.json")
732    } else {
733        resolved
734    };
735    match std::fs::read(&file_path) {
736        Ok(bytes) => serde_json::json!({
737            "path": path.to_string_lossy(),
738            "resolved_path": file_path.to_string_lossy(),
739            "content_hash": format!("{:016x}", xxh3_64(&bytes)),
740            "len": bytes.len(),
741        }),
742        Err(err) => serde_json::json!({
743            "path": path.to_string_lossy(),
744            "resolved_path": file_path.to_string_lossy(),
745            "error": err.kind().to_string(),
746        }),
747    }
748}
749
750fn audit_base_snapshot_cache_key(
751    opts: &AuditOptions<'_>,
752    base_ref: &str,
753    changed_files: &FxHashSet<PathBuf>,
754) -> Result<Option<AuditBaseSnapshotCacheKey>, ExitCode> {
755    if opts.no_cache {
756        return Ok(None);
757    }
758    let Some(base_sha) = git_rev_parse(opts.root, base_ref) else {
759        return Ok(None);
760    };
761    let config_file = config_file_fingerprint(opts)?;
762    let coverage_file = opts
763        .coverage
764        .map(|p| coverage_file_fingerprint(p, opts.root));
765    let payload = serde_json::json!({
766        "cache_version": AUDIT_BASE_SNAPSHOT_CACHE_VERSION,
767        "cli_version": env!("CARGO_PKG_VERSION"),
768        "base_sha": base_sha,
769        "config_file": config_file,
770        "changed_files": normalized_changed_files(opts.root, changed_files),
771        "production": opts.production,
772        "production_dead_code": opts.production_dead_code,
773        "production_health": opts.production_health,
774        "production_dupes": opts.production_dupes,
775        "workspace": opts.workspace,
776        "changed_workspaces": opts.changed_workspaces,
777        "group_by": opts.group_by.map(|g| format!("{g:?}")),
778        "include_entry_exports": opts.include_entry_exports,
779        "max_crap": opts.max_crap,
780        "coverage": coverage_file,
781        "coverage_root": opts.coverage_root.map(|p| p.to_string_lossy().to_string()),
782        "dead_code_baseline": opts.dead_code_baseline.map(|p| p.to_string_lossy().to_string()),
783        "health_baseline": opts.health_baseline.map(|p| p.to_string_lossy().to_string()),
784        "dupes_baseline": opts.dupes_baseline.map(|p| p.to_string_lossy().to_string()),
785    });
786    let bytes = serde_json::to_vec(&payload).map_err(|e| {
787        emit_error(
788            &format!("failed to build audit cache key: {e}"),
789            2,
790            opts.output,
791        )
792    })?;
793    Ok(Some(AuditBaseSnapshotCacheKey {
794        hash: xxh3_64(&bytes),
795        base_sha,
796    }))
797}
798
799fn compute_base_snapshot(
800    opts: &AuditOptions<'_>,
801    base_ref: &str,
802    changed_files: &FxHashSet<PathBuf>,
803    base_sha: Option<&str>,
804) -> Result<AuditKeySnapshot, ExitCode> {
805    let Some(worktree) = BaseWorktree::create(opts.root, base_ref, base_sha) else {
806        use std::fmt::Write as _;
807        let mut message =
808            format!("could not create a temporary worktree for base ref '{base_ref}'");
809        if let Some(hint) = ambient_git_env_hint() {
810            let _ = write!(message, "\n  hint: {hint}");
811        }
812        return Err(emit_error(&message, 2, opts.output));
813    };
814    let base_root = base_analysis_root(opts.root, worktree.path());
815    let base_cache_dir = remap_cache_dir_for_base_worktree(opts.root, &base_root, opts.cache_dir);
816    let current_config_path = opts
817        .config_path
818        .clone()
819        .or_else(|| fallow_config::FallowConfig::find_config_path(opts.root));
820    let base_opts =
821        build_base_audit_options(opts, &base_root, &current_config_path, &base_cache_dir);
822
823    let base_changed_files = remap_focus_files(changed_files, opts.root, &base_root);
824    let check_production = opts.production_dead_code.unwrap_or(opts.production);
825    let health_production = opts.production_health.unwrap_or(opts.production);
826    let share_dead_code_parse_with_health = check_production == health_production;
827
828    let (check_res, dupes_res) = rayon::join(
829        || run_audit_check(&base_opts, None, share_dead_code_parse_with_health),
830        || run_audit_dupes(&base_opts, None, base_changed_files.as_ref(), None),
831    );
832    let mut check = check_res?;
833    let dupes = dupes_res?;
834    // Compute the exports-aware public-export set against the BASE graph while it
835    // is still retained on the check result, BEFORE health consumes it. The
836    // public_api delta is brief-only, so this only runs on the brief path.
837    let base_public_api = if opts.brief {
838        public_api_keys_from_check(check.as_ref(), &base_root)
839    } else {
840        FxHashSet::default()
841    };
842    let shared_parse = if share_dead_code_parse_with_health {
843        check.as_mut().and_then(|r| r.shared_parse.take())
844    } else {
845        None
846    };
847    let health = run_audit_health(&base_opts, None, shared_parse)?;
848    if let Some(ref mut check) = check {
849        check.shared_parse = None;
850    }
851
852    Ok(snapshot_from_results(
853        check.as_ref(),
854        dupes.as_ref(),
855        health.as_ref(),
856        base_public_api,
857    ))
858}
859
860/// Build an `AuditKeySnapshot` of dead-code/health/dupes keys from analysis
861/// results. `public_api` is the exports-aware public-export key set, computed by
862/// the caller from the retained graph BEFORE it is dropped (empty off the brief
863/// path). Boundary-edge and cycle delta keys are derived directly from the
864/// dead-code results, so they are always available.
865fn snapshot_from_results(
866    check: Option<&CheckResult>,
867    dupes: Option<&DupesResult>,
868    health: Option<&HealthResult>,
869    public_api: FxHashSet<String>,
870) -> AuditKeySnapshot {
871    let (boundary_edges, cycles) = check.map_or_else(
872        || (FxHashSet::default(), FxHashSet::default()),
873        |r| {
874            (
875                review_deltas::boundary_edge_keys(&r.results.boundary_violations),
876                review_deltas::cycle_keys(&r.results.circular_dependencies, &r.config.root),
877            )
878        },
879    );
880    AuditKeySnapshot {
881        dead_code: check.map_or_else(FxHashSet::default, |r| {
882            dead_code_keys(&r.results, &r.config.root)
883        }),
884        health: health.map_or_else(FxHashSet::default, |r| {
885            health_keys(&r.report, &r.config.root)
886        }),
887        dupes: dupes.map_or_else(FxHashSet::default, |r| {
888            dupes_keys(&r.report, &r.config.root)
889        }),
890        boundary_edges,
891        cycles,
892        public_api,
893    }
894}
895
896/// Compute the exports-aware public-export key set from a check result's retained
897/// graph. Returns an empty set when the graph was not retained (off the brief
898/// path) so non-brief base snapshots stay cheap. Loads the root `package.json`
899/// and discovers workspaces so the exports-aware entry resolution (R4) can run.
900fn public_api_keys_from_check(check: Option<&CheckResult>, root: &Path) -> FxHashSet<String> {
901    let Some(check) = check else {
902        return FxHashSet::default();
903    };
904    let Some(graph) = check
905        .shared_parse
906        .as_ref()
907        .and_then(|sp| sp.analysis_output.as_ref())
908        .and_then(|out| out.graph.as_ref())
909    else {
910        return FxHashSet::default();
911    };
912    let root_pkg = fallow_config::PackageJson::load(&check.config.root.join("package.json")).ok();
913    let workspaces = fallow_config::discover_workspaces(&check.config.root);
914    review_deltas::public_export_keys_for(
915        graph,
916        &check.config,
917        root_pkg.as_ref(),
918        &workspaces,
919        root,
920    )
921}
922
923/// Build the `AuditOptions` for the isolated base-worktree analysis pass.
924#[expect(
925    clippy::ref_option,
926    reason = "AuditOptions.config_path is &Option<PathBuf>; the borrow is stored into the returned struct"
927)]
928fn build_base_audit_options<'a>(
929    opts: &AuditOptions<'a>,
930    base_root: &'a Path,
931    current_config_path: &'a Option<PathBuf>,
932    base_cache_dir: &'a Path,
933) -> AuditOptions<'a> {
934    AuditOptions {
935        root: base_root,
936        config_path: current_config_path,
937        cache_dir: base_cache_dir,
938        output: opts.output,
939        no_cache: opts.no_cache,
940        threads: opts.threads,
941        quiet: true,
942        changed_since: None,
943        production: opts.production,
944        production_dead_code: opts.production_dead_code,
945        production_health: opts.production_health,
946        production_dupes: opts.production_dupes,
947        workspace: opts.workspace,
948        changed_workspaces: None,
949        explain: false,
950        explain_skipped: false,
951        performance: false,
952        group_by: opts.group_by,
953        dead_code_baseline: None,
954        health_baseline: None,
955        dupes_baseline: None,
956        max_crap: opts.max_crap,
957        coverage: opts.coverage,
958        coverage_root: opts.coverage_root,
959        gate: AuditGate::All,
960        include_entry_exports: opts.include_entry_exports,
961        runtime_coverage: None,
962        min_invocations_hot: opts.min_invocations_hot,
963        brief: false,
964        max_decisions: 4,
965        walkthrough_guide: false,
966        walkthrough_file: None,
967        show_deprioritized: false,
968    }
969}
970
971fn base_analysis_root(current_root: &Path, base_worktree_root: &Path) -> PathBuf {
972    let Some(git_root) = git_toplevel(current_root) else {
973        return base_worktree_root.to_path_buf();
974    };
975    let current_root =
976        dunce::canonicalize(current_root).unwrap_or_else(|_| current_root.to_path_buf());
977    match current_root.strip_prefix(&git_root) {
978        Ok(relative) => base_worktree_root.join(relative),
979        Err(err) => {
980            tracing::warn!(
981                current_root = %current_root.display(),
982                git_root = %git_root.display(),
983                error = %err,
984                "Could not remap audit base root into the base worktree; falling back to worktree root"
985            );
986            base_worktree_root.to_path_buf()
987        }
988    }
989}
990
991fn current_keys_as_base_keys(
992    check: Option<&CheckResult>,
993    dupes: Option<&DupesResult>,
994    health: Option<&HealthResult>,
995) -> AuditKeySnapshot {
996    // Reuse path (no behavioral change vs base): head IS base, so the delta
997    // sets are the head's own keys, which makes every head-minus-base delta
998    // empty. `public_api_keys` is the head set already computed on the brief
999    // path; the boundary/cycle keys come from the head results.
1000    let public_api = check
1001        .and_then(|r| r.public_api_keys.clone())
1002        .unwrap_or_default();
1003    snapshot_from_results(check, dupes, health, public_api)
1004}
1005
1006fn can_reuse_current_as_base(
1007    opts: &AuditOptions<'_>,
1008    base_ref: &str,
1009    changed_files: &FxHashSet<PathBuf>,
1010) -> bool {
1011    let Some(git_root) = git_toplevel(opts.root) else {
1012        return false;
1013    };
1014    let cache_dir = opts.cache_dir.to_path_buf();
1015    let canonical_cache_dir = dunce::canonicalize(&cache_dir).ok();
1016    // Spawn the batched base-file reader lazily: a changeset of only cache
1017    // artifacts or docs never touches git, so it spawns zero processes.
1018    let mut reader: Option<BaseFileReader> = None;
1019    for path in changed_files {
1020        if is_fallow_cache_artifact(path, &cache_dir, canonical_cache_dir.as_deref()) {
1021            continue;
1022        }
1023        if !is_analysis_input(path) {
1024            if is_non_behavioral_doc(path) {
1025                continue;
1026            }
1027            return false;
1028        }
1029        let Ok(current) = std::fs::read_to_string(path) else {
1030            return false;
1031        };
1032        let Ok(relative) = path.strip_prefix(&git_root) else {
1033            return false;
1034        };
1035        let reader = match reader.as_mut() {
1036            Some(reader) => reader,
1037            None => {
1038                let Some(spawned) = BaseFileReader::spawn(opts.root) else {
1039                    return false;
1040                };
1041                reader.insert(spawned)
1042            }
1043        };
1044        let Some(base) = reader.read(base_ref, relative) else {
1045            return false;
1046        };
1047        if current == base {
1048            continue;
1049        }
1050        if !js_ts_tokens_equivalent(path, &current, &base) {
1051            return false;
1052        }
1053    }
1054    true
1055}
1056
1057/// A long-lived `git cat-file --batch` child process used to read the base
1058/// version of changed files without spawning one `git show` per file.
1059///
1060/// Requests and responses are strictly lockstep (one request line, one
1061/// response) to avoid pipe-buffer deadlock. Per-file comparison semantics are
1062/// byte-identical to the previous `git show` path: a missing object yields
1063/// `None` (treated as not reusable), and content is read with lossy UTF-8
1064/// conversion to match `String::from_utf8_lossy`.
1065///
1066/// The child is owned through a [`ScopedChild`](crate::signal::ScopedChild) so
1067/// an interrupt (SIGINT/SIGTERM) during a large reuse loop kills the long-lived
1068/// `cat-file` process via the signal registry instead of orphaning it.
1069struct BaseFileReader {
1070    /// The registered `cat-file --batch` child. Wrapped in `Option` so `Drop`
1071    /// can `take()` it and call the consuming `ScopedChild::wait` after closing
1072    /// stdin, reaping the child and deregistering its PID.
1073    child: Option<crate::signal::ScopedChild>,
1074    /// Wrapped in `Option` so `Drop` can `take()` and drop it explicitly,
1075    /// closing the pipe before the blocking wait (which would otherwise block).
1076    stdin: Option<std::process::ChildStdin>,
1077    stdout: std::io::BufReader<std::process::ChildStdout>,
1078}
1079
1080impl BaseFileReader {
1081    /// Spawn a single `git cat-file --batch` process rooted at `root`.
1082    ///
1083    /// Returns `None` on spawn failure or if the child's stdio pipes are
1084    /// unavailable; the caller then degrades to "not reusable" (returns
1085    /// `false`), mirroring the previous per-file `git show` failure behavior.
1086    fn spawn(root: &Path) -> Option<Self> {
1087        let mut command = Command::new("git");
1088        command
1089            .args(["cat-file", "--batch"])
1090            .current_dir(root)
1091            .stdin(std::process::Stdio::piped())
1092            .stdout(std::process::Stdio::piped())
1093            .stderr(std::process::Stdio::null());
1094        clear_ambient_git_env(&mut command);
1095        let mut child = crate::signal::ScopedChild::spawn(&mut command).ok()?;
1096        let stdin = child.take_stdin()?;
1097        let stdout = child.take_stdout()?;
1098        Some(Self {
1099            child: Some(child),
1100            stdin: Some(stdin),
1101            stdout: std::io::BufReader::new(stdout),
1102        })
1103    }
1104
1105    /// Read the base version of `relative` at `base_ref`.
1106    ///
1107    /// Writes one `<base_ref>:<path>` request line (forward-slash separators)
1108    /// and reads exactly one response in lockstep. Returns `None` if the object
1109    /// is missing (the ` missing` header path), on any parse or IO error, or if
1110    /// the path contains a newline (which would corrupt the request stream).
1111    fn read(&mut self, base_ref: &str, relative: &Path) -> Option<String> {
1112        use std::io::{BufRead, Read};
1113
1114        let relative = relative.to_string_lossy().replace('\\', "/");
1115        // A newline in the path cannot be expressed as a single batch request
1116        // line; treat it as not reusable rather than writing a corrupt request.
1117        if relative.contains('\n') {
1118            return None;
1119        }
1120
1121        let stdin = self.stdin.as_mut()?;
1122        writeln!(stdin, "{base_ref}:{relative}").ok()?;
1123        stdin.flush().ok()?;
1124
1125        let mut header = String::new();
1126        if self.stdout.read_line(&mut header).ok()? == 0 {
1127            return None;
1128        }
1129        // `git cat-file --batch` reports a missing object as `<spec> missing\n`.
1130        if header.trim_end().ends_with(" missing") {
1131            return None;
1132        }
1133        // Otherwise the header is `<oid> <type> <size>\n`; parse the size.
1134        let size: usize = header.trim_end().rsplit(' ').next()?.parse().ok()?;
1135        let mut buf = vec![0u8; size];
1136        self.stdout.read_exact(&mut buf).ok()?;
1137        // Consume the single trailing newline that follows the object content.
1138        // An off-by-one here corrupts every subsequent read in the batch.
1139        let mut newline = [0u8; 1];
1140        self.stdout.read_exact(&mut newline).ok()?;
1141
1142        Some(String::from_utf8_lossy(&buf).into_owned())
1143    }
1144}
1145
1146impl Drop for BaseFileReader {
1147    fn drop(&mut self) {
1148        // Close stdin so the child sees EOF and exits, then reap it through the
1149        // ScopedChild's blocking `wait` (which also deregisters the PID from the
1150        // signal registry). Dropping the `ChildStdin` closes the pipe; doing
1151        // this before the wait prevents it from blocking.
1152        self.stdin.take();
1153        if let Some(child) = self.child.take() {
1154            let _ = child.wait();
1155        }
1156    }
1157}
1158
1159fn is_fallow_cache_artifact(
1160    path: &Path,
1161    cache_dir: &Path,
1162    canonical_cache_dir: Option<&Path>,
1163) -> bool {
1164    path.starts_with(cache_dir)
1165        || canonical_cache_dir.is_some_and(|canonical| path.starts_with(canonical))
1166}
1167
1168fn remap_cache_dir_for_base_worktree(
1169    current_root: &Path,
1170    base_worktree_root: &Path,
1171    cache_dir: &Path,
1172) -> PathBuf {
1173    if cache_dir.is_absolute()
1174        && let Ok(relative) = cache_dir.strip_prefix(current_root)
1175    {
1176        return base_worktree_root.join(relative);
1177    }
1178    cache_dir.to_path_buf()
1179}
1180
1181fn is_analysis_input(path: &Path) -> bool {
1182    matches!(
1183        path.extension().and_then(|ext| ext.to_str()),
1184        Some(
1185            "js" | "jsx"
1186                | "ts"
1187                | "tsx"
1188                | "mjs"
1189                | "mts"
1190                | "cjs"
1191                | "cts"
1192                | "vue"
1193                | "svelte"
1194                | "astro"
1195                | "mdx"
1196                | "css"
1197                | "scss"
1198        )
1199    )
1200}
1201
1202fn is_non_behavioral_doc(path: &Path) -> bool {
1203    matches!(
1204        path.extension().and_then(|ext| ext.to_str()),
1205        Some("md" | "markdown" | "txt" | "rst" | "adoc")
1206    )
1207}
1208
1209fn js_ts_tokens_equivalent(path: &Path, current: &str, base: &str) -> bool {
1210    if current.contains("fallow-ignore") || base.contains("fallow-ignore") {
1211        return false;
1212    }
1213    if !matches!(
1214        path.extension().and_then(|ext| ext.to_str()),
1215        Some("js" | "jsx" | "ts" | "tsx" | "mjs" | "mts" | "cjs" | "cts")
1216    ) {
1217        return false;
1218    }
1219    let current_tokens = fallow_core::duplicates::tokenize::tokenize_file(path, current, false);
1220    let base_tokens = fallow_core::duplicates::tokenize::tokenize_file(path, base, false);
1221    current_tokens
1222        .tokens
1223        .iter()
1224        .map(|token| &token.kind)
1225        .eq(base_tokens.tokens.iter().map(|token| &token.kind))
1226}
1227
1228fn remap_focus_files(
1229    files: &FxHashSet<PathBuf>,
1230    from_root: &Path,
1231    to_root: &Path,
1232) -> Option<FxHashSet<PathBuf>> {
1233    let mut remapped = FxHashSet::default();
1234    for file in files {
1235        if let Ok(relative) = file.strip_prefix(from_root) {
1236            remapped.insert(to_root.join(relative));
1237        }
1238    }
1239    if remapped.is_empty() {
1240        return None;
1241    }
1242    Some(remapped)
1243}
1244
1245#[cfg(test)]
1246use std::time::SystemTime;
1247
1248#[cfg(test)]
1249use crate::base_worktree::{
1250    ReusableWorktreeLock, WorktreeCleanupGuard, audit_worktree_pid, days_to_duration,
1251    is_fallow_audit_worktree_path, is_reusable_audit_worktree_path, list_audit_worktrees,
1252    materialize_base_dependency_context, parse_worktree_list, paths_equal, process_is_alive,
1253    remove_audit_worktree, reusable_worktree_last_used_path, reusable_worktree_lock_path,
1254    sweep_orphan_audit_worktrees, touch_last_used,
1255};
1256
1257#[path = "audit_keys.rs"]
1258pub mod keys;
1259
1260#[path = "audit_review_deltas.rs"]
1261pub mod review_deltas;
1262
1263#[path = "audit_weakening.rs"]
1264pub mod weakening;
1265
1266#[path = "audit_routing.rs"]
1267pub mod routing;
1268
1269use keys::{
1270    dead_code_keys, dupe_group_key, dupes_keys, health_finding_key, health_keys,
1271    retain_introduced_dead_code,
1272};
1273
1274struct HeadAnalyses {
1275    check: Option<CheckResult>,
1276    dupes: Option<DupesResult>,
1277    health: Option<HealthResult>,
1278}
1279
1280/// HEAD analyses result paired with an optional freshly computed base snapshot
1281/// (present only when a real base worktree was run in parallel).
1282type HeadAndBaseResult = (
1283    Result<HeadAnalyses, ExitCode>,
1284    Option<Result<AuditKeySnapshot, ExitCode>>,
1285);
1286
1287/// Run the HEAD analyses, optionally alongside a fresh base snapshot via
1288/// `rayon::join` when `run_base` is set. Mirrors the previous inline branch.
1289fn run_audit_head_and_base(
1290    opts: &AuditOptions<'_>,
1291    changed_since: Option<&str>,
1292    changed_files: &FxHashSet<PathBuf>,
1293    base_ref: &str,
1294    base_cache_key: Option<&AuditBaseSnapshotCacheKey>,
1295    run_base: bool,
1296) -> HeadAndBaseResult {
1297    if run_base {
1298        let base_sha = base_cache_key.map(|key| key.base_sha.as_str());
1299        let (h, b) = rayon::join(
1300            || run_audit_head_analyses(opts, changed_since, changed_files),
1301            || compute_base_snapshot(opts, base_ref, changed_files, base_sha),
1302        );
1303        (h, Some(b))
1304    } else {
1305        (
1306            run_audit_head_analyses(opts, changed_since, changed_files),
1307            None,
1308        )
1309    }
1310}
1311
1312struct AuditResultParts {
1313    verdict: AuditVerdict,
1314    summary: AuditSummary,
1315    attribution: AuditAttribution,
1316    base_snapshot: Option<AuditKeySnapshot>,
1317    base_snapshot_skipped: bool,
1318    changed_files_count: usize,
1319    changed_files: FxHashSet<PathBuf>,
1320    base_ref: String,
1321    base_description: Option<String>,
1322    head_sha: Option<String>,
1323    output: OutputFormat,
1324    performance: bool,
1325    check: Option<CheckResult>,
1326    dupes: Option<DupesResult>,
1327    health: Option<HealthResult>,
1328    elapsed: Duration,
1329    review_deltas: Option<crate::audit_brief::ReviewDeltas>,
1330    weakening_signals: Vec<weakening::WeakeningSignal>,
1331    routing: Option<routing::RoutingFacts>,
1332    decision_surface: Option<crate::audit_decision_surface::DecisionSurface>,
1333    graph_snapshot_hash: Option<String>,
1334    change_anchors: Vec<crate::audit_walkthrough::ChangeAnchor>,
1335}
1336
1337/// Run the three HEAD-side analyses with intra-pipeline sharing intact:
1338/// check first (so its parsed modules are available), then dupes (which can
1339/// reuse check's discovered file list when production settings match), then
1340/// health (which can reuse check's parsed modules when production settings
1341/// match). Designed to be called from inside `rayon::join` alongside
1342/// [`compute_base_snapshot`], which operates on an isolated worktree.
1343fn run_audit_head_analyses(
1344    opts: &AuditOptions<'_>,
1345    changed_since: Option<&str>,
1346    changed_files: &FxHashSet<PathBuf>,
1347) -> Result<HeadAnalyses, ExitCode> {
1348    let check_production = opts.production_dead_code.unwrap_or(opts.production);
1349    let health_production = opts.production_health.unwrap_or(opts.production);
1350    let dupes_production = opts.production_dupes.unwrap_or(opts.production);
1351    let share_dead_code_parse_with_health = check_production == health_production;
1352    let share_dead_code_files_with_dupes =
1353        share_dead_code_parse_with_health && check_production == dupes_production;
1354
1355    let mut check = run_audit_check(opts, changed_since, share_dead_code_parse_with_health)?;
1356    let dupes_files = if share_dead_code_files_with_dupes {
1357        check
1358            .as_ref()
1359            .and_then(|r| r.shared_parse.as_ref().map(|sp| sp.files.clone()))
1360    } else {
1361        None
1362    };
1363    let dupes = run_audit_dupes(opts, changed_since, Some(changed_files), dupes_files)?;
1364    // Compute the impact closure AND the exports-aware public-export key
1365    // set for the review brief BEFORE health consumes the shared parse (which
1366    // owns the retained graph). Both are stored on the check result so they
1367    // survive the graph drop.
1368    if opts.brief
1369        && let Some(ref mut check) = check
1370    {
1371        check.impact_closure = compute_brief_impact_closure(opts.root, check, changed_files);
1372        check.public_api_keys = Some(public_api_keys_from_check(Some(check), opts.root));
1373        check.partition_order = compute_brief_partition_order(opts.root, check, changed_files);
1374        check.focus_facts = compute_brief_focus_facts(opts.root, check, changed_files);
1375        check.export_lines = compute_brief_export_lines(opts.root, check, changed_files);
1376        check.internal_consumers =
1377            compute_brief_internal_consumers(opts.root, check, changed_files);
1378    }
1379    let shared_parse = if share_dead_code_parse_with_health {
1380        check.as_mut().and_then(|r| r.shared_parse.take())
1381    } else {
1382        None
1383    };
1384    let health = run_audit_health(opts, changed_since, shared_parse)?;
1385    Ok(HeadAnalyses {
1386        check,
1387        dupes,
1388        health,
1389    })
1390}
1391
1392/// Compute the impact closure for the review brief from the check result's
1393/// retained graph against the changed-file set.
1394///
1395/// Maps each changed absolute path to its graph `FileId`, walks `reverse_deps` +
1396/// re-export chains to the transitive affected set, and partitions into
1397/// `{ in_diff, affected_not_shown, coordination_gap }`. Returns `None` when the
1398/// graph was not retained (off the brief path) or no changed file maps to a
1399/// known module (e.g. every changed file is non-source / outside the graph).
1400fn compute_brief_impact_closure(
1401    root: &std::path::Path,
1402    check: &CheckResult,
1403    changed_files: &FxHashSet<PathBuf>,
1404) -> Option<fallow_core::graph::ImpactClosurePaths> {
1405    let graph = check
1406        .shared_parse
1407        .as_ref()
1408        .and_then(|sp| sp.analysis_output.as_ref())
1409        .and_then(|out| out.graph.as_ref())?;
1410
1411    // Build an absolute-path -> FileId index from the graph's modules, normalizing
1412    // separators so the git-diff changed set (which can carry either separator on
1413    // Windows) matches the canonical module paths.
1414    let path_to_id: FxHashMap<String, fallow_types::discover::FileId> = graph
1415        .modules
1416        .iter()
1417        .map(|m| (m.path.to_string_lossy().replace('\\', "/"), m.file_id))
1418        .collect();
1419
1420    let changed_ids: Vec<fallow_types::discover::FileId> = changed_files
1421        .iter()
1422        .filter_map(|p| {
1423            let key = p.to_string_lossy().replace('\\', "/");
1424            path_to_id.get(&key).copied()
1425        })
1426        .collect();
1427
1428    if changed_ids.is_empty() {
1429        return None;
1430    }
1431
1432    let closure = graph.impact_closure(&changed_ids);
1433    Some(graph.closure_with_paths(&closure, root))
1434}
1435
1436/// Compute the partition + order for the review brief's stage 2 from the
1437/// check result's retained graph against the changed-file set.
1438///
1439/// Maps each changed absolute path to its graph `FileId`, groups the changed
1440/// files into by-module units, and computes a dependency-sensible review order
1441/// over those units. Returns `None` when the graph was not retained (off the
1442/// brief path) or no changed file maps to a known module.
1443fn compute_brief_partition_order(
1444    root: &std::path::Path,
1445    check: &CheckResult,
1446    changed_files: &FxHashSet<PathBuf>,
1447) -> Option<fallow_core::graph::PartitionOrderPaths> {
1448    let graph = check
1449        .shared_parse
1450        .as_ref()
1451        .and_then(|sp| sp.analysis_output.as_ref())
1452        .and_then(|out| out.graph.as_ref())?;
1453
1454    let path_to_id: FxHashMap<String, fallow_types::discover::FileId> = graph
1455        .modules
1456        .iter()
1457        .map(|m| (m.path.to_string_lossy().replace('\\', "/"), m.file_id))
1458        .collect();
1459
1460    let changed_ids: Vec<fallow_types::discover::FileId> = changed_files
1461        .iter()
1462        .filter_map(|p| {
1463            let key = p.to_string_lossy().replace('\\', "/");
1464            path_to_id.get(&key).copied()
1465        })
1466        .collect();
1467
1468    if changed_ids.is_empty() {
1469        return None;
1470    }
1471
1472    let partition = graph.partition_order(&changed_ids);
1473    Some(graph.partition_order_with_paths(&partition, root))
1474}
1475
1476/// Precompute the per-changed-file `rel_path -> [(export-name, 1-based line)]` map
1477/// for the decision surface, from the retained graph's export spans + each file's
1478/// line offsets, BEFORE health drops the graph. Lets a coordination / public-API
1479/// decision anchor to the exact export line. `None` when the graph is not retained.
1480fn compute_brief_export_lines(
1481    root: &std::path::Path,
1482    check: &CheckResult,
1483    changed_files: &FxHashSet<PathBuf>,
1484) -> Option<FxHashMap<String, Vec<(String, u32)>>> {
1485    let graph = check
1486        .shared_parse
1487        .as_ref()
1488        .and_then(|sp| sp.analysis_output.as_ref())
1489        .and_then(|out| out.graph.as_ref())?;
1490
1491    let changed_norm: FxHashSet<String> = changed_files
1492        .iter()
1493        .map(|p| p.to_string_lossy().replace('\\', "/"))
1494        .collect();
1495
1496    let mut map: FxHashMap<String, Vec<(String, u32)>> = FxHashMap::default();
1497    for module in &graph.modules {
1498        let abs = module.path.to_string_lossy().replace('\\', "/");
1499        if !changed_norm.contains(&abs) || module.exports.is_empty() {
1500            continue;
1501        }
1502        let Ok(content) = std::fs::read_to_string(&module.path) else {
1503            continue;
1504        };
1505        let offsets = fallow_types::extract::compute_line_offsets(&content);
1506        let exports: Vec<(String, u32)> = module
1507            .exports
1508            .iter()
1509            .map(|export| {
1510                let (line, _) =
1511                    fallow_types::extract::byte_offset_to_line_col(&offsets, export.span.start);
1512                (export.name.to_string(), line)
1513            })
1514            .collect();
1515        map.insert(keys::relative_key_path(&module.path, root), exports);
1516    }
1517    Some(map)
1518}
1519
1520/// Precompute the per-anchor honest consumer count for the decision surface:
1521/// `rel_path -> count of distinct in-repo modules OUTSIDE the diff that directly
1522/// import the anchor file`, from the retained graph's reverse-deps BEFORE health
1523/// drops the graph (mirroring [`compute_brief_export_lines`]). This is the honest
1524/// per-decision DISPLAY number ("N in-repo modules already depend on this"),
1525/// distinct from the project-wide `affected_not_shown` ranking proxy. Importers
1526/// that are themselves part of the diff are excluded (they are the change, not a
1527/// pre-existing dependent). `None` when the graph is not retained.
1528fn compute_brief_internal_consumers(
1529    root: &std::path::Path,
1530    check: &CheckResult,
1531    changed_files: &FxHashSet<PathBuf>,
1532) -> Option<FxHashMap<String, u64>> {
1533    let graph = check
1534        .shared_parse
1535        .as_ref()
1536        .and_then(|sp| sp.analysis_output.as_ref())
1537        .and_then(|out| out.graph.as_ref())?;
1538
1539    let changed_norm: FxHashSet<String> = changed_files
1540        .iter()
1541        .map(|p| p.to_string_lossy().replace('\\', "/"))
1542        .collect();
1543    let id_to_norm: FxHashMap<fallow_types::discover::FileId, String> = graph
1544        .modules
1545        .iter()
1546        .map(|m| (m.file_id, m.path.to_string_lossy().replace('\\', "/")))
1547        .collect();
1548
1549    let mut map: FxHashMap<String, u64> = FxHashMap::default();
1550    for module in &graph.modules {
1551        let abs = module.path.to_string_lossy().replace('\\', "/");
1552        if !changed_norm.contains(&abs) {
1553            continue;
1554        }
1555        let count = graph
1556            .importers_of(module.file_id)
1557            .iter()
1558            .filter(|imp| {
1559                id_to_norm
1560                    .get(imp)
1561                    .is_none_or(|p| !changed_norm.contains(p))
1562            })
1563            .count() as u64;
1564        map.insert(keys::relative_key_path(&module.path, root), count);
1565    }
1566    Some(map)
1567}
1568
1569/// Compute the per-file focus graph facts (fan-in/out + the dynamic-dispatch /
1570/// re-export-indirection confidence-flag signals) for the review brief's stage 4
1571/// weighted focus map, from the check result's retained graph against the
1572/// changed-file set.
1573///
1574/// Maps each changed absolute path to its graph `FileId`, computes the per-file
1575/// blast + confidence signals, and path-resolves them. Returns `None` when the
1576/// graph was not retained (off the brief path) or no changed file maps to a known
1577/// module.
1578fn compute_brief_focus_facts(
1579    root: &std::path::Path,
1580    check: &CheckResult,
1581    changed_files: &FxHashSet<PathBuf>,
1582) -> Option<Vec<fallow_core::graph::FocusFileFactsPaths>> {
1583    let graph = check
1584        .shared_parse
1585        .as_ref()
1586        .and_then(|sp| sp.analysis_output.as_ref())
1587        .and_then(|out| out.graph.as_ref())?;
1588
1589    let path_to_id: FxHashMap<String, fallow_types::discover::FileId> = graph
1590        .modules
1591        .iter()
1592        .map(|m| (m.path.to_string_lossy().replace('\\', "/"), m.file_id))
1593        .collect();
1594
1595    let changed_ids: Vec<fallow_types::discover::FileId> = changed_files
1596        .iter()
1597        .filter_map(|p| {
1598            let key = p.to_string_lossy().replace('\\', "/");
1599            path_to_id.get(&key).copied()
1600        })
1601        .collect();
1602
1603    if changed_ids.is_empty() {
1604        return None;
1605    }
1606
1607    let facts = graph.focus_file_facts(&changed_ids);
1608    Some(graph.focus_facts_with_paths(&facts, root))
1609}
1610
1611/// Run the audit pipeline: resolve base ref, run analyses, compute verdict.
1612pub fn execute_audit(opts: &AuditOptions<'_>) -> Result<AuditResult, ExitCode> {
1613    let start = Instant::now();
1614
1615    let (base_ref, base_description) = resolve_base_ref(opts)?;
1616
1617    // Always sweep: prunable orphans (cache dir externally reaped, git admin
1618    // entry left behind) are reclaimed regardless of the age threshold, so the
1619    // sweep runs even when age-based GC is disabled (`max_age` is `None`).
1620    sweep_old_reusable_caches(
1621        opts.root,
1622        resolve_cache_max_age(opts.root, opts.config_path.as_ref()),
1623        opts.quiet,
1624    );
1625
1626    let Some(changed_files) = crate::check::get_changed_files(opts.root, &base_ref) else {
1627        return Err(emit_error(
1628            &format!(
1629                "could not determine changed files for base ref '{base_ref}'. Verify the ref exists in this git repository"
1630            ),
1631            2,
1632            opts.output,
1633        ));
1634    };
1635    let changed_files_count = changed_files.len();
1636
1637    if changed_files.is_empty() {
1638        return Ok(empty_audit_result(
1639            base_ref,
1640            base_description,
1641            opts,
1642            start.elapsed(),
1643        ));
1644    }
1645
1646    let changed_since = Some(base_ref.as_str());
1647
1648    let needs_real_base_snapshot = matches!(opts.gate, AuditGate::NewOnly)
1649        && !can_reuse_current_as_base(opts, &base_ref, &changed_files);
1650    let base_cache_key = if needs_real_base_snapshot {
1651        audit_base_snapshot_cache_key(opts, &base_ref, &changed_files)?
1652    } else {
1653        None
1654    };
1655    let cached_base_snapshot = base_cache_key
1656        .as_ref()
1657        .and_then(|key| load_cached_base_snapshot(opts, key));
1658
1659    let (head_res, base_res) = run_audit_head_and_base(
1660        opts,
1661        changed_since,
1662        &changed_files,
1663        &base_ref,
1664        base_cache_key.as_ref(),
1665        needs_real_base_snapshot && cached_base_snapshot.is_none(),
1666    );
1667
1668    assemble_audit_result(AuditAssemblyInput {
1669        opts,
1670        head_res,
1671        base_res,
1672        cached_base_snapshot,
1673        base_cache_key,
1674        changed_files,
1675        changed_files_count,
1676        base_ref,
1677        base_description,
1678        start,
1679    })
1680}
1681
1682/// Inputs threaded from the audit prelude into [`assemble_audit_result`].
1683struct AuditAssemblyInput<'a> {
1684    opts: &'a AuditOptions<'a>,
1685    head_res: Result<HeadAnalyses, ExitCode>,
1686    base_res: Option<Result<AuditKeySnapshot, ExitCode>>,
1687    cached_base_snapshot: Option<AuditKeySnapshot>,
1688    base_cache_key: Option<AuditBaseSnapshotCacheKey>,
1689    changed_files: FxHashSet<PathBuf>,
1690    changed_files_count: usize,
1691    base_ref: String,
1692    base_description: Option<String>,
1693    start: Instant,
1694}
1695
1696/// Resolve the base snapshot, compute attribution/verdict/summary, and build the
1697/// final `AuditResult` from the HEAD-side analyses.
1698fn assemble_audit_result(input: AuditAssemblyInput<'_>) -> Result<AuditResult, ExitCode> {
1699    let opts = input.opts;
1700    let head = input.head_res?;
1701    let mut check_result = head.check;
1702    let dupes_result = head.dupes;
1703    let health_result = head.health;
1704
1705    let (base_snapshot, base_snapshot_skipped) = resolve_base_snapshot(
1706        opts,
1707        input.cached_base_snapshot,
1708        input.base_res,
1709        input.base_cache_key.as_ref(),
1710        CurrentAnalysisRefs {
1711            check: check_result.as_ref(),
1712            dupes: dupes_result.as_ref(),
1713            health: health_result.as_ref(),
1714        },
1715    )?;
1716    if let Some(ref mut check) = check_result {
1717        check.shared_parse = None;
1718    }
1719    let (attribution, verdict, summary) = compute_audit_outcome(
1720        opts.gate,
1721        check_result.as_ref(),
1722        dupes_result.as_ref(),
1723        health_result.as_ref(),
1724        base_snapshot.as_ref(),
1725    );
1726
1727    // Review-brief data: deltas (head-vs-base sets), weakening (base-vs-head
1728    // diff scan over the changed files), and ownership routing. Brief-path only.
1729    let (review_deltas, weakening_signals, routing) = if opts.brief {
1730        compute_brief_e3_data(
1731            opts,
1732            check_result.as_ref(),
1733            base_snapshot.as_ref(),
1734            &input.changed_files,
1735            &input.base_ref,
1736        )
1737    } else {
1738        (None, Vec::new(), None)
1739    };
1740
1741    // Decision surface (the apex): classify the SOLID-3 candidates from the
1742    // deltas + coordination gaps, anchor each `signal_id`, rank, cap, and pair
1743    // the routed expert. Brief-path only.
1744    let decision_surface = if opts.brief {
1745        Some(compute_decision_surface(
1746            opts,
1747            check_result.as_ref(),
1748            review_deltas.as_ref(),
1749            routing.as_ref(),
1750        ))
1751    } else {
1752        None
1753    };
1754
1755    // Per-hunk change anchors (the region-level anchor set): derived from the
1756    // SAME diff source the run used (an opt-in `--diff-stdin`/`--diff-file` diff,
1757    // else the committed `base...HEAD`), so emission and validation anchor to one
1758    // changed set. Brief-path only.
1759    let change_anchors = if opts.brief {
1760        compute_change_anchors(opts.root, &input.base_ref)
1761    } else {
1762        Vec::new()
1763    };
1764
1765    // Graph-snapshot hash (the staleness pin): a deterministic hash of the
1766    // HEAD-side key sets plus the resolved base ref + head sha + the change-anchor
1767    // id set. Brief-path only. The verifier is the graph: a mutated tree changes a
1768    // key set OR a changed region, changes this hash, and refuses a stale agent
1769    // walkthrough on reentry (so a cited change_anchor that moved is refused too).
1770    let head_sha = get_head_sha(opts.root);
1771    let graph_snapshot_hash = if opts.brief {
1772        Some(compute_graph_snapshot_hash(
1773            check_result.as_ref(),
1774            dupes_result.as_ref(),
1775            health_result.as_ref(),
1776            &input.base_ref,
1777            head_sha.as_deref(),
1778            &change_anchors,
1779        ))
1780    } else {
1781        None
1782    };
1783
1784    Ok(build_audit_result(AuditResultParts {
1785        verdict,
1786        summary,
1787        attribution,
1788        base_snapshot,
1789        base_snapshot_skipped,
1790        changed_files_count: input.changed_files_count,
1791        changed_files: input.changed_files,
1792        base_ref: input.base_ref,
1793        base_description: input.base_description,
1794        head_sha,
1795        output: opts.output,
1796        performance: opts.performance,
1797        check: check_result,
1798        dupes: dupes_result,
1799        health: health_result,
1800        elapsed: input.start.elapsed(),
1801        review_deltas,
1802        weakening_signals,
1803        routing,
1804        decision_surface,
1805        graph_snapshot_hash,
1806        change_anchors,
1807    }))
1808}
1809
1810/// Compute the deterministic graph-snapshot hash from the HEAD-side analysis
1811/// results plus the resolved base ref + head sha. Reuses [`snapshot_from_results`]
1812/// for the six key sets (dead_code / health / dupes / boundary_edges / cycles /
1813/// public_api), each sorted, then folds in the base ref and head sha so the same
1814/// tree compared against the same base always yields the same hash.
1815///
1816/// The verifier is the graph: any structural change (a new finding, a new edge,
1817/// a new export) shifts a key set and changes this hash, so a stale agent
1818/// walkthrough whose echoed hash no longer matches is REFUSED on reentry.
1819fn compute_graph_snapshot_hash(
1820    check: Option<&CheckResult>,
1821    dupes: Option<&DupesResult>,
1822    health: Option<&HealthResult>,
1823    base_ref: &str,
1824    head_sha: Option<&str>,
1825    change_anchors: &[crate::audit_walkthrough::ChangeAnchor],
1826) -> String {
1827    // The HEAD public-export set was computed on the brief path and retained on
1828    // the check result (`public_api_keys`); reuse it so the hash is exports-aware
1829    // without re-walking the graph.
1830    let public_api = check
1831        .and_then(|c| c.public_api_keys.clone())
1832        .unwrap_or_default();
1833    let snapshot = snapshot_from_results(check, dupes, health, public_api);
1834    let mut bytes: Vec<u8> = Vec::new();
1835    // Sorted key sets, each length-prefixed, so the byte stream is unambiguous.
1836    for set in [
1837        &snapshot.dead_code,
1838        &snapshot.health,
1839        &snapshot.dupes,
1840        &snapshot.boundary_edges,
1841        &snapshot.cycles,
1842        &snapshot.public_api,
1843    ] {
1844        for key in sorted_keys(set) {
1845            bytes.extend_from_slice(key.as_bytes());
1846            bytes.push(0);
1847        }
1848        bytes.push(1);
1849    }
1850    // Seventh key set: the SORTED change-anchor id set, so a moved/added/removed
1851    // changed region shifts this hash and a cited change_anchor that moved is
1852    // refused as stale (the finding key sets are line-independent and would not
1853    // otherwise cover the region-level anchors).
1854    let mut anchor_ids: Vec<&str> = change_anchors
1855        .iter()
1856        .map(|a| a.change_anchor.as_str())
1857        .collect();
1858    anchor_ids.sort_unstable();
1859    for id in anchor_ids {
1860        bytes.extend_from_slice(id.as_bytes());
1861        bytes.push(0);
1862    }
1863    bytes.push(1);
1864    bytes.extend_from_slice(base_ref.as_bytes());
1865    bytes.push(0);
1866    bytes.extend_from_slice(head_sha.unwrap_or("").as_bytes());
1867    format!("graph:{:016x}", xxh3_64(&bytes))
1868}
1869
1870/// Derive the per-hunk change anchors for this run from the SAME diff source the
1871/// run used: the opt-in shared diff (`--diff-stdin` / `--diff-file`) when present,
1872/// else the committed merge-base diff `base_ref...HEAD`. Using one source for both
1873/// the guide emission and the `--walkthrough-file` validation keeps the emitted
1874/// anchor set equal to the set validated on reentry (a committed-vs-staged
1875/// mismatch would otherwise reject every staged region's anchor). A diff that
1876/// cannot be computed yields an empty set (no anchors, never a panic).
1877fn compute_change_anchors(
1878    root: &std::path::Path,
1879    base_ref: &str,
1880) -> Vec<crate::audit_walkthrough::ChangeAnchor> {
1881    if let Some(raw) = crate::report::ci::diff_filter::shared_diff_raw() {
1882        return crate::audit_walkthrough::parse_change_anchors(raw);
1883    }
1884    fallow_core::changed_files::try_get_changed_diff(root, base_ref)
1885        .map(|diff| crate::audit_walkthrough::parse_change_anchors(&diff))
1886        .unwrap_or_default()
1887}
1888
1889/// Compute the decision surface from the assembled brief inputs: gather the
1890/// boundary anchors (one representative per introduced zone-pair), the
1891/// coordination gaps, and the impact-closure blast magnitude, then run the
1892/// extractor. The cap is taken from the audit options (clamped to [3, 5] by the
1893/// extractor). Returns an empty surface when no check result is available.
1894fn compute_decision_surface(
1895    opts: &AuditOptions<'_>,
1896    check: Option<&CheckResult>,
1897    review_deltas: Option<&crate::audit_brief::ReviewDeltas>,
1898    routing: Option<&routing::RoutingFacts>,
1899) -> crate::audit_decision_surface::DecisionSurface {
1900    use crate::audit_decision_surface::{
1901        BoundaryAnchor, CoordinationAnchor, DecisionInputs, extract_decision_surface,
1902    };
1903
1904    let (Some(check), Some(deltas)) = (check, review_deltas) else {
1905        return crate::audit_decision_surface::DecisionSurface::default();
1906    };
1907    let root = &check.config.root;
1908
1909    // One representative boundary anchor per introduced zone-pair: pick the first
1910    // violation whose zone-pair key matches an introduced edge, for the file +
1911    // line suppression/routing anchor.
1912    let mut boundary_anchors: Vec<BoundaryAnchor> = Vec::new();
1913    let mut seen_pairs: FxHashSet<String> = FxHashSet::default();
1914    for finding in &check.results.boundary_violations {
1915        let key = review_deltas::boundary_edge_key(finding);
1916        if !deltas.boundary_introduced.contains(&key) || !seen_pairs.insert(key.clone()) {
1917            continue;
1918        }
1919        boundary_anchors.push(BoundaryAnchor {
1920            zone_pair_key: key,
1921            from_file: keys::relative_key_path(&finding.violation.from_path, root),
1922            from_zone: finding.violation.from_zone.clone(),
1923            to_zone: finding.violation.to_zone.clone(),
1924            line: finding.violation.line,
1925        });
1926    }
1927
1928    // Coordination gaps projected to the public-API/contract decision shape.
1929    // Aggregate per changed file: ONE contract decision per changed file (R1
1930    // batch-consolidate), counting its distinct non-diff consumers as the blast.
1931    let closure = check.impact_closure.as_ref();
1932    let mut coordination: Vec<CoordinationAnchor> = closure
1933        .map(|c| aggregate_coordination_gaps(&c.coordination_gap))
1934        .unwrap_or_default();
1935    let affected_not_shown = closure.map_or(0, |c| c.affected_not_shown.len() as u64);
1936
1937    let empty_routing = routing::RoutingFacts::default();
1938    let routing = routing.unwrap_or(&empty_routing);
1939
1940    // Head-source reader for suppression checks AND for resolving a contract
1941    // symbol's declaration line: read the on-disk (head) content of an anchor file
1942    // by its root-relative path. Best-effort; an unreadable file is not suppressed.
1943    let root_owned = root.clone();
1944    let head_source = move |rel: &str| std::fs::read_to_string(root_owned.join(rel)).ok();
1945
1946    // Resolve a contract symbol's 1-based declaration line from the per-file
1947    // export-line map precomputed on the brief path (the graph is already dropped
1948    // by health here, so we cannot re-derive it now). Lets coordination /
1949    // public-API decisions deep-link to the exact export instead of the file head.
1950    let export_lines = check.export_lines.as_ref();
1951    let resolve_line = |rel: &str, symbols: &[String]| -> u32 {
1952        let Some(exports) = export_lines.and_then(|map| map.get(rel)) else {
1953            return 0;
1954        };
1955        exports
1956            .iter()
1957            .find(|(name, _)| symbols.iter().any(|s| name == s))
1958            .or_else(|| exports.first())
1959            .map_or(0, |(_, line)| *line)
1960    };
1961    for anchor in &mut coordination {
1962        anchor.line = resolve_line(&anchor.changed_file, &anchor.consumed_symbols);
1963    }
1964    let public_api_anchor_line = deltas.public_api_added.first().map_or(0, |key| {
1965        let mut parts = key.splitn(2, "::");
1966        let path = parts.next().unwrap_or_default();
1967        let name = parts.next().unwrap_or_default();
1968        resolve_line(path, &[name.to_string()])
1969    });
1970
1971    // Rename resolver: a head (post-rename) root-relative path -> its pre-rename
1972    // path, from the diff's rename pairs. Best-effort (empty without a shared diff
1973    // or renames); lets each decision carry a rename-durable `previous_signal_id`.
1974    let rename_old_path = |rel: &str| -> Option<String> {
1975        crate::report::ci::diff_filter::shared_diff_index()
1976            .and_then(|idx| idx.old_path_for(rel))
1977            .map(str::to_string)
1978    };
1979
1980    // Honest per-anchor consumer count, looked up from the map precomputed before
1981    // the graph drop. `0` for an anchor with no recorded importers (a new file).
1982    let internal_consumers_map = check.internal_consumers.as_ref();
1983    let internal_consumers = |rel: &str| -> u64 {
1984        internal_consumers_map
1985            .and_then(|map| map.get(rel))
1986            .copied()
1987            .unwrap_or(0)
1988    };
1989
1990    extract_decision_surface(&DecisionInputs {
1991        deltas,
1992        boundary_anchors: &boundary_anchors,
1993        coordination: &coordination,
1994        public_api_anchor_line,
1995        affected_not_shown,
1996        routing,
1997        head_source: &head_source,
1998        rename_old_path: &rename_old_path,
1999        internal_consumers: &internal_consumers,
2000        cap: opts.max_decisions,
2001    })
2002}
2003
2004/// Aggregate per-(changed, consumer) coordination gaps into ONE contract anchor
2005/// per changed file (R1 batch-consolidate), with the distinct-consumer count as
2006/// the blast and the union of consumed symbols as the contract. Sorted by changed
2007/// file for deterministic output.
2008fn aggregate_coordination_gaps(
2009    gaps: &[fallow_core::graph::CoordinationGapPaths],
2010) -> Vec<crate::audit_decision_surface::CoordinationAnchor> {
2011    use crate::audit_decision_surface::CoordinationAnchor;
2012    let mut by_file: FxHashMap<String, (u64, FxHashSet<String>)> = FxHashMap::default();
2013    for gap in gaps {
2014        let entry = by_file
2015            .entry(gap.changed_file.clone())
2016            .or_insert_with(|| (0, FxHashSet::default()));
2017        entry.0 += 1;
2018        for symbol in &gap.consumed_symbols {
2019            entry.1.insert(symbol.clone());
2020        }
2021    }
2022    let mut anchors: Vec<CoordinationAnchor> = by_file
2023        .into_iter()
2024        .map(|(changed_file, (consumer_count, symbols))| {
2025            let mut consumed_symbols: Vec<String> = symbols.into_iter().collect();
2026            consumed_symbols.sort_unstable();
2027            CoordinationAnchor {
2028                changed_file,
2029                consumed_symbols,
2030                consumer_count,
2031                line: 0,
2032            }
2033        })
2034        .collect();
2035    anchors.sort_by(|a, b| a.changed_file.cmp(&b.changed_file));
2036    anchors
2037}
2038
2039/// Compute the review-brief data: the diff-aware deltas (head sets vs base
2040/// snapshot), the weakening-signal pass (base-vs-head diff over the changed
2041/// files), and ownership routing. Pure-ish: weakening + routing shell out to git
2042/// (via [`BaseFileReader`] / churn), so this runs only on the brief path.
2043fn compute_brief_e3_data(
2044    opts: &AuditOptions<'_>,
2045    check: Option<&CheckResult>,
2046    base_snapshot: Option<&AuditKeySnapshot>,
2047    changed_files: &FxHashSet<PathBuf>,
2048    base_ref: &str,
2049) -> (
2050    Option<crate::audit_brief::ReviewDeltas>,
2051    Vec<weakening::WeakeningSignal>,
2052    Option<routing::RoutingFacts>,
2053) {
2054    let deltas = check.map(|check| {
2055        let head_boundary = review_deltas::boundary_edge_keys(&check.results.boundary_violations);
2056        let head_cycles =
2057            review_deltas::cycle_keys(&check.results.circular_dependencies, &check.config.root);
2058        let head_public_api = check.public_api_keys.clone().unwrap_or_default();
2059        let empty = FxHashSet::default();
2060        let (base_boundary, base_cycles, base_public_api) = base_snapshot
2061            .map_or((&empty, &empty, &empty), |b| {
2062                (&b.boundary_edges, &b.cycles, &b.public_api)
2063            });
2064        crate::audit_brief::build_review_deltas(
2065            &head_boundary,
2066            base_boundary,
2067            &head_cycles,
2068            base_cycles,
2069            &head_public_api,
2070            base_public_api,
2071        )
2072    });
2073
2074    let weakening_signals = compute_weakening_signals(opts.root, base_ref, changed_files);
2075
2076    let routing =
2077        check.map(|check| routing::compute_routing(opts.root, &check.config, changed_files));
2078
2079    (deltas, weakening_signals, routing)
2080}
2081
2082/// Run the weakening-signal pass over the changed files: read each file's base
2083/// content via [`BaseFileReader`], diff it against the on-disk head content, and
2084/// emit a [`weakening::WeakeningSignal`] per detected weakening. Best-effort: a
2085/// file whose base or head cannot be read is skipped silently.
2086fn compute_weakening_signals(
2087    root: &Path,
2088    base_ref: &str,
2089    changed_files: &FxHashSet<PathBuf>,
2090) -> Vec<weakening::WeakeningSignal> {
2091    use weakening::{WeakeningKind, WeakeningSignal};
2092
2093    let Some(git_root) = git_toplevel(root) else {
2094        return Vec::new();
2095    };
2096    let Some(mut reader) = BaseFileReader::spawn(root) else {
2097        return Vec::new();
2098    };
2099
2100    let mut signals: Vec<WeakeningSignal> = Vec::new();
2101    // Sort the changed files for deterministic signal ordering.
2102    let mut files: Vec<&PathBuf> = changed_files.iter().collect();
2103    files.sort();
2104
2105    for abs in files {
2106        let Ok(relative) = abs.strip_prefix(&git_root) else {
2107            continue;
2108        };
2109        let rel_str = relative.to_string_lossy().replace('\\', "/");
2110        let head = std::fs::read_to_string(abs).unwrap_or_default();
2111        let base = reader.read(base_ref, relative).unwrap_or_default();
2112        // A net-new file (no base) or a non-source file still gets the scan; the
2113        // detectors are no-ops on irrelevant content.
2114
2115        if weakening::is_test_file(&rel_str) {
2116            for token in weakening::detect_test_weakening(&base, &head) {
2117                signals.push(WeakeningSignal {
2118                    kind: WeakeningKind::TestWeakened,
2119                    file: rel_str.clone(),
2120                    evidence: format!("{token} added"),
2121                });
2122            }
2123            for ev in weakening::detect_removed_tests(&base, &head) {
2124                signals.push(WeakeningSignal {
2125                    kind: WeakeningKind::TestWeakened,
2126                    file: rel_str.clone(),
2127                    evidence: ev,
2128                });
2129            }
2130        }
2131        for ev in weakening::detect_added_suppressions(&base, &head) {
2132            signals.push(WeakeningSignal {
2133                kind: WeakeningKind::SuppressionAdded,
2134                file: rel_str.clone(),
2135                evidence: ev,
2136            });
2137        }
2138        for ev in weakening::detect_lowered_thresholds(&base, &head) {
2139            signals.push(WeakeningSignal {
2140                kind: WeakeningKind::ThresholdLowered,
2141                file: rel_str.clone(),
2142                evidence: ev,
2143            });
2144        }
2145        if weakening::is_ci_file(&rel_str) {
2146            for ev in weakening::detect_removed_security_steps(&base, &head) {
2147                signals.push(WeakeningSignal {
2148                    kind: WeakeningKind::SecurityCheckRemoved,
2149                    file: rel_str.clone(),
2150                    evidence: ev,
2151                });
2152            }
2153        }
2154    }
2155    signals
2156}
2157
2158/// Attribution, verdict, and summary computed together from the HEAD analyses and
2159/// base snapshot. Also records the final result count for telemetry.
2160fn compute_audit_outcome(
2161    gate: AuditGate,
2162    check: Option<&CheckResult>,
2163    dupes: Option<&DupesResult>,
2164    health: Option<&HealthResult>,
2165    base: Option<&AuditKeySnapshot>,
2166) -> (AuditAttribution, AuditVerdict, AuditSummary) {
2167    let attribution = compute_audit_attribution(check, dupes, health, base, gate);
2168    let verdict = compute_audit_verdict(gate, check, dupes, health, base);
2169    let summary = build_summary(check, dupes, health);
2170    crate::telemetry::note_final_result_count(
2171        summary.dead_code_issues + summary.complexity_findings + summary.duplication_clone_groups,
2172    );
2173    (attribution, verdict, summary)
2174}
2175
2176/// Resolve the base key snapshot for the `new`-only gate: prefer the cache, then a
2177/// freshly computed base worktree (persisting it), else fall back to current keys
2178/// (marking the snapshot skipped). Returns `(None, false)` outside `new`-only mode.
2179/// The current-run analysis result references threaded together so the base
2180/// snapshot resolver can fall back to the current keys without a six-deep
2181/// argument list. Bundled refs of the optional check / dupes / health results.
2182#[derive(Clone, Copy)]
2183struct CurrentAnalysisRefs<'a> {
2184    check: Option<&'a CheckResult>,
2185    dupes: Option<&'a DupesResult>,
2186    health: Option<&'a HealthResult>,
2187}
2188
2189fn resolve_base_snapshot(
2190    opts: &AuditOptions<'_>,
2191    cached_base_snapshot: Option<AuditKeySnapshot>,
2192    base_res: Option<Result<AuditKeySnapshot, ExitCode>>,
2193    base_cache_key: Option<&AuditBaseSnapshotCacheKey>,
2194    current: CurrentAnalysisRefs<'_>,
2195) -> Result<(Option<AuditKeySnapshot>, bool), ExitCode> {
2196    if !matches!(opts.gate, AuditGate::NewOnly) {
2197        return Ok((None, false));
2198    }
2199    if let Some(snapshot) = cached_base_snapshot {
2200        return Ok((Some(snapshot), false));
2201    }
2202    if let Some(base_res) = base_res {
2203        let snapshot = base_res?;
2204        if let Some(key) = base_cache_key {
2205            save_cached_base_snapshot(opts, key, &snapshot);
2206        }
2207        return Ok((Some(snapshot), false));
2208    }
2209    let CurrentAnalysisRefs {
2210        check,
2211        dupes,
2212        health,
2213    } = current;
2214    Ok((Some(current_keys_as_base_keys(check, dupes, health)), true))
2215}
2216
2217/// Pick the audit verdict: the introduced-only gate in `new`-only mode, otherwise
2218/// the full backlog verdict.
2219fn compute_audit_verdict(
2220    gate: AuditGate,
2221    check: Option<&CheckResult>,
2222    dupes: Option<&DupesResult>,
2223    health: Option<&HealthResult>,
2224    base: Option<&AuditKeySnapshot>,
2225) -> AuditVerdict {
2226    if matches!(gate, AuditGate::NewOnly) {
2227        compute_introduced_verdict(check, dupes, health, base)
2228    } else {
2229        compute_verdict(check, dupes, health)
2230    }
2231}
2232
2233fn build_audit_result(parts: AuditResultParts) -> AuditResult {
2234    AuditResult {
2235        verdict: parts.verdict,
2236        summary: parts.summary,
2237        attribution: parts.attribution,
2238        base_snapshot: parts.base_snapshot,
2239        base_snapshot_skipped: parts.base_snapshot_skipped,
2240        changed_files_count: parts.changed_files_count,
2241        changed_files: parts.changed_files.into_iter().collect(),
2242        base_ref: parts.base_ref,
2243        base_description: parts.base_description,
2244        head_sha: parts.head_sha,
2245        output: parts.output,
2246        performance: parts.performance,
2247        check: parts.check,
2248        dupes: parts.dupes,
2249        health: parts.health,
2250        elapsed: parts.elapsed,
2251        review_deltas: parts.review_deltas,
2252        weakening_signals: parts.weakening_signals,
2253        routing: parts.routing,
2254        decision_surface: parts.decision_surface,
2255        graph_snapshot_hash: parts.graph_snapshot_hash,
2256        change_anchors: parts.change_anchors,
2257    }
2258}
2259
2260/// Parse a raw `FALLOW_AUDIT_BASE` value: trim, treat empty / whitespace-only as
2261/// unset. Pure helper so the trimming logic is testable without mutating env.
2262fn parse_audit_base_override(raw: Option<String>) -> Option<String> {
2263    let trimmed = raw?.trim().to_string();
2264    if trimmed.is_empty() {
2265        None
2266    } else {
2267        Some(trimmed)
2268    }
2269}
2270
2271/// The `FALLOW_AUDIT_BASE` override (trimmed), or `None` when unset / empty.
2272/// Lets a downstream consumer pin the base without editing the generated agent
2273/// gate script (issue #1168), e.g. `FALLOW_AUDIT_BASE=upstream/main` on a fork.
2274fn audit_base_env_override() -> Option<String> {
2275    parse_audit_base_override(std::env::var("FALLOW_AUDIT_BASE").ok())
2276}
2277
2278/// Resolve the base ref and an optional human-readable provenance for the scope
2279/// line. Precedence: explicit `--changed-since` / `--base` flag, then the
2280/// `FALLOW_AUDIT_BASE` env override, then auto-detection.
2281fn resolve_base_ref(opts: &AuditOptions<'_>) -> Result<(String, Option<String>), ExitCode> {
2282    if let Some(ref_str) = opts.changed_since {
2283        return Ok((ref_str.to_string(), None));
2284    }
2285    if let Some(env_ref) = audit_base_env_override() {
2286        if let Err(e) = crate::validate::validate_git_ref(&env_ref) {
2287            return Err(emit_error(
2288                &format!("FALLOW_AUDIT_BASE='{env_ref}' is not a valid git ref: {e}"),
2289                2,
2290                opts.output,
2291            ));
2292        }
2293        let description = format!("FALLOW_AUDIT_BASE={env_ref}");
2294        return Ok((env_ref, Some(description)));
2295    }
2296    let Some(detected) = auto_detect_base_ref(opts.root) else {
2297        return Err(emit_error(
2298            "could not detect base branch. Use --base <ref> to specify the comparison target (e.g., --base main)",
2299            2,
2300            opts.output,
2301        ));
2302    };
2303    if let Err(e) = crate::validate::validate_git_ref(&detected.git_ref) {
2304        return Err(emit_error(
2305            &format!(
2306                "auto-detected base ref '{}' is not a valid git ref: {e}",
2307                detected.git_ref
2308            ),
2309            2,
2310            opts.output,
2311        ));
2312    }
2313    Ok((detected.git_ref, Some(detected.description)))
2314}
2315
2316/// Build an empty pass result when no files have changed.
2317fn empty_audit_result(
2318    base_ref: String,
2319    base_description: Option<String>,
2320    opts: &AuditOptions<'_>,
2321    elapsed: Duration,
2322) -> AuditResult {
2323    crate::telemetry::note_final_result_count(0);
2324
2325    let head_sha = get_head_sha(opts.root);
2326    // An empty changeset is a valid graph state: pin a hash on the brief path so
2327    // the walkthrough guide still carries a stable snapshot pin (no findings, so
2328    // the hash folds only the base ref + head sha).
2329    let graph_snapshot_hash = if opts.brief {
2330        // An empty changeset has no changed regions, so no change anchors.
2331        Some(compute_graph_snapshot_hash(
2332            None,
2333            None,
2334            None,
2335            &base_ref,
2336            head_sha.as_deref(),
2337            &[],
2338        ))
2339    } else {
2340        None
2341    };
2342
2343    AuditResult {
2344        verdict: AuditVerdict::Pass,
2345        summary: AuditSummary {
2346            dead_code_issues: 0,
2347            dead_code_has_errors: false,
2348            complexity_findings: 0,
2349            max_cyclomatic: None,
2350            duplication_clone_groups: 0,
2351        },
2352        attribution: AuditAttribution {
2353            gate: opts.gate,
2354            ..AuditAttribution::default()
2355        },
2356        base_snapshot: None,
2357        base_snapshot_skipped: false,
2358        changed_files_count: 0,
2359        changed_files: Vec::new(),
2360        base_ref,
2361        base_description,
2362        head_sha,
2363        output: opts.output,
2364        performance: opts.performance,
2365        check: None,
2366        dupes: None,
2367        health: None,
2368        elapsed,
2369        review_deltas: None,
2370        weakening_signals: Vec::new(),
2371        routing: None,
2372        decision_surface: None,
2373        graph_snapshot_hash,
2374        change_anchors: Vec::new(),
2375    }
2376}
2377
2378/// Run dead code analysis for the audit pipeline.
2379fn run_audit_check<'a>(
2380    opts: &'a AuditOptions<'a>,
2381    changed_since: Option<&'a str>,
2382    retain_modules_for_health: bool,
2383) -> Result<Option<CheckResult>, ExitCode> {
2384    let filters = IssueFilters::default();
2385    // The review brief needs the module graph for the impact closure, which
2386    // rides the retained-modules path. Force retention on the brief path even
2387    // when health does not share the dead-code parse (mismatched production
2388    // modes), so the graph is available before health consumes the shared parse.
2389    let retain_modules_for_health = retain_modules_for_health || opts.brief;
2390    let trace_opts = TraceOptions {
2391        trace_export: None,
2392        trace_file: None,
2393        trace_dependency: None,
2394        impact_closure: None,
2395        performance: opts.performance,
2396    };
2397    match crate::check::execute_check(&CheckOptions {
2398        root: opts.root,
2399        config_path: opts.config_path,
2400        output: opts.output,
2401        no_cache: opts.no_cache,
2402        threads: opts.threads,
2403        quiet: opts.quiet,
2404        fail_on_issues: false,
2405        filters: &filters,
2406        changed_since,
2407        diff_index: None,
2408        use_shared_diff_index: true,
2409        baseline: opts.dead_code_baseline,
2410        save_baseline: None,
2411        sarif_file: None,
2412        production: opts.production_dead_code.unwrap_or(opts.production),
2413        production_override: opts.production_dead_code,
2414        workspace: opts.workspace,
2415        changed_workspaces: opts.changed_workspaces,
2416        group_by: opts.group_by,
2417        include_dupes: false,
2418        trace_opts: &trace_opts,
2419        explain: opts.explain,
2420        top: None,
2421        file: &[],
2422        include_entry_exports: opts.include_entry_exports,
2423        summary: false,
2424        regression_opts: crate::regression::RegressionOpts {
2425            fail_on_regression: false,
2426            tolerance: crate::regression::Tolerance::Absolute(0),
2427            regression_baseline_file: None,
2428            save_target: crate::regression::SaveRegressionTarget::None,
2429            scoped: true,
2430            quiet: opts.quiet,
2431            output: opts.output,
2432        },
2433        retain_modules_for_health,
2434        defer_performance: false,
2435    }) {
2436        Ok(r) => Ok(Some(r)),
2437        Err(code) => Err(code),
2438    }
2439}
2440
2441/// Run duplication analysis for the audit pipeline.
2442///
2443/// Reads duplication settings from the project config file so that user
2444/// options like `ignoreImports`, `crossLanguage`, and `skipLocal` are
2445/// respected (same as combined mode).
2446fn run_audit_dupes<'a>(
2447    opts: &'a AuditOptions<'a>,
2448    changed_since: Option<&'a str>,
2449    changed_files: Option<&'a FxHashSet<PathBuf>>,
2450    pre_discovered: Option<Vec<fallow_types::discover::DiscoveredFile>>,
2451) -> Result<Option<DupesResult>, ExitCode> {
2452    let dupes_cfg = match crate::load_config_for_analysis(
2453        opts.root,
2454        opts.config_path,
2455        crate::ConfigLoadOptions {
2456            output: opts.output,
2457            no_cache: opts.no_cache,
2458            threads: opts.threads,
2459            production_override: opts
2460                .production_dupes
2461                .or_else(|| opts.production.then_some(true)),
2462            quiet: opts.quiet,
2463        },
2464        fallow_config::ProductionAnalysis::Dupes,
2465    ) {
2466        Ok(c) => c.duplicates,
2467        Err(code) => return Err(code),
2468    };
2469    let dupes_opts = build_audit_dupes_options(opts, changed_since, changed_files, &dupes_cfg);
2470    let dupes_run = if let Some(files) = pre_discovered {
2471        crate::dupes::execute_dupes_with_files(&dupes_opts, files)
2472    } else {
2473        crate::dupes::execute_dupes(&dupes_opts)
2474    };
2475    match dupes_run {
2476        Ok(r) => Ok(Some(r)),
2477        Err(code) => Err(code),
2478    }
2479}
2480
2481/// Build the `DupesOptions` for an audit run from project config + audit options.
2482fn build_audit_dupes_options<'a>(
2483    opts: &'a AuditOptions<'a>,
2484    changed_since: Option<&'a str>,
2485    changed_files: Option<&'a FxHashSet<PathBuf>>,
2486    dupes_cfg: &fallow_config::DuplicatesConfig,
2487) -> DupesOptions<'a> {
2488    DupesOptions {
2489        root: opts.root,
2490        config_path: opts.config_path,
2491        output: opts.output,
2492        no_cache: opts.no_cache,
2493        threads: opts.threads,
2494        quiet: opts.quiet,
2495        mode: Some(DupesMode::from(dupes_cfg.mode)),
2496        min_tokens: Some(dupes_cfg.min_tokens),
2497        min_lines: Some(dupes_cfg.min_lines),
2498        min_occurrences: Some(dupes_cfg.min_occurrences),
2499        threshold: Some(dupes_cfg.threshold),
2500        skip_local: dupes_cfg.skip_local,
2501        cross_language: dupes_cfg.cross_language,
2502        ignore_imports: Some(dupes_cfg.ignore_imports),
2503        top: None,
2504        baseline_path: opts.dupes_baseline,
2505        save_baseline_path: None,
2506        production: opts.production_dupes.unwrap_or(opts.production),
2507        production_override: opts.production_dupes,
2508        trace: None,
2509        changed_since,
2510        diff_index: None,
2511        use_shared_diff_index: true,
2512        changed_files,
2513        workspace: opts.workspace,
2514        changed_workspaces: opts.changed_workspaces,
2515        explain: opts.explain,
2516        explain_skipped: opts.explain_skipped,
2517        summary: false,
2518        group_by: opts.group_by,
2519        performance: false,
2520    }
2521}
2522
2523/// Run complexity analysis for the audit pipeline (findings only, no scores/hotspots/targets).
2524fn run_audit_health<'a>(
2525    opts: &'a AuditOptions<'a>,
2526    changed_since: Option<&'a str>,
2527    shared_parse: Option<crate::health::SharedParseData>,
2528) -> Result<Option<HealthResult>, ExitCode> {
2529    let runtime_coverage = match opts.runtime_coverage {
2530        Some(path) => match crate::health::coverage::prepare_options(
2531            path,
2532            opts.min_invocations_hot,
2533            None,
2534            None,
2535            opts.output,
2536        ) {
2537            Ok(options) => Some(options),
2538            Err(code) => return Err(code),
2539        },
2540        None => None,
2541    };
2542
2543    let health_opts = build_audit_health_options(opts, changed_since, runtime_coverage);
2544    let health_run = if let Some(shared) = shared_parse {
2545        crate::health::execute_health_with_shared_parse(&health_opts, shared)
2546    } else {
2547        crate::health::execute_health(&health_opts)
2548    };
2549    match health_run {
2550        Ok(r) => Ok(Some(r)),
2551        Err(code) => Err(code),
2552    }
2553}
2554
2555/// Build the findings-only `HealthOptions` for an audit run (no scores, hotspots,
2556/// ownership, or targets; `--churn-file` is health-only).
2557fn build_audit_health_options<'a>(
2558    opts: &'a AuditOptions<'a>,
2559    changed_since: Option<&'a str>,
2560    runtime_coverage: Option<crate::health::RuntimeCoverageOptions>,
2561) -> HealthOptions<'a> {
2562    HealthOptions {
2563        root: opts.root,
2564        config_path: opts.config_path,
2565        output: opts.output,
2566        no_cache: opts.no_cache,
2567        threads: opts.threads,
2568        quiet: opts.quiet,
2569        max_cyclomatic: None,
2570        max_cognitive: None,
2571        max_crap: opts.max_crap,
2572        top: None,
2573        sort: SortBy::Cyclomatic,
2574        production: opts.production_health.unwrap_or(opts.production),
2575        production_override: opts.production_health,
2576        changed_since,
2577        diff_index: None,
2578        use_shared_diff_index: true,
2579        workspace: opts.workspace,
2580        changed_workspaces: opts.changed_workspaces,
2581        baseline: opts.health_baseline,
2582        save_baseline: None,
2583        complexity: true,
2584        complexity_breakdown: false,
2585        file_scores: false,
2586        coverage_gaps: false,
2587        config_activates_coverage_gaps: false,
2588        hotspots: false,
2589        ownership: false,
2590        ownership_emails: None,
2591        targets: false,
2592        css: false,
2593        force_full: false,
2594        score_only_output: false,
2595        enforce_coverage_gap_gate: false,
2596        effort: None,
2597        score: false,
2598        min_score: None,
2599        since: None,
2600        min_commits: None,
2601        explain: opts.explain,
2602        summary: false,
2603        save_snapshot: None,
2604        trend: false,
2605        group_by: opts.group_by,
2606        coverage: opts.coverage,
2607        coverage_root: opts.coverage_root,
2608        performance: opts.performance,
2609        min_severity: None,
2610        report_only: false,
2611        runtime_coverage,
2612        churn_file: None,
2613    }
2614}
2615
2616#[path = "audit_output.rs"]
2617mod output;
2618
2619pub use output::{
2620    insert_audit_dead_code_json, insert_audit_duplication_json, insert_audit_health_json,
2621    insert_audit_json_header, print_audit_findings, print_audit_result,
2622};
2623
2624/// Run the full audit command: execute analyses, print results, return exit code.
2625/// Run audit, optionally tagged with a gate marker (e.g. `"pre-commit"`) so
2626/// Fallow Impact can record a containment event when the gate blocks then
2627/// clears. The marker only affects the local Impact store; it never changes
2628/// the verdict, exit code, or output.
2629pub fn run_audit(opts: &AuditOptions<'_>, gate_marker: Option<&str>) -> ExitCode {
2630    if let Err(e) = crate::health::scoring::validate_coverage_root_absolute(opts.coverage_root) {
2631        return emit_error(&e, 2, opts.output);
2632    }
2633    let coverage_resolved = opts
2634        .coverage
2635        .map(|p| crate::health::scoring::resolve_relative_to_root(p, Some(opts.root)));
2636    let runtime_coverage_resolved = opts
2637        .runtime_coverage
2638        .map(|p| crate::health::scoring::resolve_relative_to_root(p, Some(opts.root)));
2639    let resolved_opts = AuditOptions {
2640        coverage: coverage_resolved.as_deref(),
2641        runtime_coverage: runtime_coverage_resolved.as_deref(),
2642        ..*opts
2643    };
2644    match execute_audit(&resolved_opts) {
2645        Ok(result) => {
2646            let mut findings = result
2647                .check
2648                .as_ref()
2649                .map(|c| crate::impact::collect_dead_code_findings(&c.results))
2650                .unwrap_or_default();
2651            if let Some(health) = result.health.as_ref() {
2652                findings.extend(crate::impact::collect_complexity_findings(&health.report));
2653            }
2654            let clones = result
2655                .dupes
2656                .as_ref()
2657                .map(|d| crate::impact::collect_clone_findings(&d.report))
2658                .unwrap_or_default();
2659            let empty_supps: Vec<fallow_core::results::ActiveSuppression> = Vec::new();
2660            let suppressions = result.check.as_ref().map_or(empty_supps.as_slice(), |c| {
2661                c.results.active_suppressions.as_slice()
2662            });
2663            let attribution = crate::impact::AttributionInput {
2664                root: opts.root,
2665                scope: crate::impact::Scope::ChangedFiles(&result.changed_files),
2666                findings,
2667                clones,
2668                suppressions,
2669            };
2670            crate::impact::record_audit_run(
2671                opts.root,
2672                &result.summary,
2673                &crate::impact::AuditRunRecord {
2674                    verdict: result.verdict,
2675                    gate: gate_marker.is_some(),
2676                    git_sha: result.head_sha.as_deref(),
2677                    version: env!("CARGO_PKG_VERSION"),
2678                    timestamp: &crate::vital_signs::chrono_timestamp(),
2679                    attribution: Some(&attribution),
2680                },
2681            );
2682            // Walkthrough surfaces take priority over the brief body when
2683            // requested. Both always exit 0.
2684            if opts.walkthrough_guide {
2685                crate::audit_brief::print_walkthrough_guide_result(&result)
2686            } else if let Some(path) = opts.walkthrough_file {
2687                crate::audit_brief::print_walkthrough_file_result(&result, path)
2688            } else if opts.brief {
2689                // Exit-0 seam: the brief renders the same analysis but never
2690                // gates on the verdict. `print_brief_result` always returns
2691                // SUCCESS.
2692                crate::audit_brief::print_brief_result(
2693                    &result,
2694                    opts.quiet,
2695                    opts.explain,
2696                    opts.show_deprioritized,
2697                )
2698            } else {
2699                print_audit_result(&result, opts.quiet, opts.explain)
2700            }
2701        }
2702        Err(code) => code,
2703    }
2704}
2705
2706/// Run the standalone `fallow decision-surface` command: the separable, cheap
2707/// apex. Executes the SAME changed-code analysis the review brief runs (it is
2708/// the brief path, NOT the full project pipeline), then emits ONLY the decision
2709/// surface envelope. Always exit 0 (the surface is advisory, never a gate).
2710///
2711/// The MCP `decision_surface` tool wraps this command. It is callable without the
2712/// full pipeline because it reuses `execute_audit` in brief mode (changed-code
2713/// scope), not bare `fallow`.
2714#[must_use]
2715pub fn run_decision_surface(opts: &AuditOptions<'_>) -> ExitCode {
2716    // Force brief mode: the decision surface is only computed on the brief path.
2717    let brief_opts = AuditOptions {
2718        brief: true,
2719        ..*opts
2720    };
2721    match execute_audit(&brief_opts) {
2722        Ok(result) => crate::audit_brief::print_decision_surface_result(&result, opts.quiet),
2723        Err(code) => code,
2724    }
2725}
2726
2727#[cfg(test)]
2728mod tests {
2729    use super::*;
2730    use std::{fs, process::Command};
2731
2732    fn git(dir: &std::path::Path, args: &[&str]) {
2733        let output = Command::new("git")
2734            .args(args)
2735            .current_dir(dir)
2736            .env_remove("GIT_DIR")
2737            .env_remove("GIT_WORK_TREE")
2738            .env("GIT_CONFIG_GLOBAL", "/dev/null")
2739            .env("GIT_CONFIG_SYSTEM", "/dev/null")
2740            .env("GIT_AUTHOR_NAME", "test")
2741            .env("GIT_AUTHOR_EMAIL", "test@test.com")
2742            .env("GIT_COMMITTER_NAME", "test")
2743            .env("GIT_COMMITTER_EMAIL", "test@test.com")
2744            .output()
2745            .expect("git command failed");
2746        assert!(
2747            output.status.success(),
2748            "git {:?} failed\nstdout:\n{}\nstderr:\n{}",
2749            args,
2750            String::from_utf8_lossy(&output.stdout),
2751            String::from_utf8_lossy(&output.stderr)
2752        );
2753    }
2754
2755    #[test]
2756    fn audit_worktree_helpers_filter_to_fallow_temp_prefix() {
2757        let temp = std::env::temp_dir();
2758        let audit_path = temp.join("fallow-audit-base-123-456");
2759        let reusable_path = temp.join("fallow-audit-base-cache-abcd-1234");
2760        let canonical_audit_path = temp
2761            .canonicalize()
2762            .unwrap_or_else(|_| temp.clone())
2763            .join("fallow-audit-base-456-789");
2764        let unrelated_temp = temp.join("other-worktree");
2765        let output = format!(
2766            "worktree /repo\nHEAD abc\n\nworktree {}\nHEAD def\n\nworktree {}\nHEAD ghi\n\nworktree {}\nHEAD jkl\n",
2767            audit_path.display(),
2768            unrelated_temp.display(),
2769            reusable_path.display()
2770        );
2771
2772        assert_eq!(
2773            parse_worktree_list(&output),
2774            vec![audit_path, reusable_path.clone()]
2775        );
2776        assert!(is_fallow_audit_worktree_path(&canonical_audit_path));
2777        assert!(is_reusable_audit_worktree_path(&reusable_path));
2778        assert_eq!(audit_worktree_pid("fallow-audit-base-123-456"), Some(123));
2779        assert_eq!(
2780            audit_worktree_pid("fallow-audit-base-cache-abcd-1234"),
2781            None
2782        );
2783        assert_eq!(audit_worktree_pid("not-fallow-audit-base-123"), None);
2784    }
2785
2786    /// Initialize a throwaway git repo with a single commit and return its root.
2787    /// Used by the worktree-lifecycle tests below as a parent repo that can host
2788    /// `git worktree add` invocations.
2789    fn init_throwaway_repo(parent: &std::path::Path, name: &str) -> PathBuf {
2790        let root = parent.join(name);
2791        fs::create_dir_all(&root).expect("repo root should be created");
2792        fs::write(root.join("README.md"), "seed\n").expect("seed file should be written");
2793        git(&root, &["init", "-b", "main"]);
2794        git(&root, &["add", "."]);
2795        git(
2796            &root,
2797            &["-c", "commit.gpgsign=false", "commit", "-m", "initial"],
2798        );
2799        root
2800    }
2801
2802    /// Add a tracked file and commit it; return the new HEAD SHA.
2803    fn commit_file(repo: &std::path::Path, name: &str, body: &str) -> String {
2804        fs::write(repo.join(name), body).expect("file should be written");
2805        git(repo, &["add", "."]);
2806        git(repo, &["-c", "commit.gpgsign=false", "commit", "-m", name]);
2807        git_rev_parse(repo, "HEAD").expect("HEAD should resolve")
2808    }
2809
2810    #[test]
2811    fn auto_detect_base_ref_resolves_origin_default_to_merge_base() {
2812        let tmp = tempfile::TempDir::new().expect("temp dir should be created");
2813        let repo = init_throwaway_repo(tmp.path(), "repo");
2814        let head = git_rev_parse(&repo, "HEAD").expect("HEAD should resolve");
2815        git(&repo, &["branch", "trunk"]);
2816        git(&repo, &["update-ref", "refs/remotes/origin/trunk", "trunk"]);
2817        git(
2818            &repo,
2819            &[
2820                "symbolic-ref",
2821                "refs/remotes/origin/HEAD",
2822                "refs/remotes/origin/trunk",
2823            ],
2824        );
2825
2826        let detected = auto_detect_base_ref(&repo).expect("base should be detected");
2827        // trunk == HEAD, so the merge-base is HEAD's own SHA (the bare branch
2828        // name `trunk` is no longer returned: it would resolve to a local ref).
2829        assert_eq!(detected.git_ref, head);
2830        assert_eq!(detected.description, "merge-base with origin/trunk");
2831    }
2832
2833    /// Regression for issue #1168: a worktree checkout whose local `main` is
2834    /// stale relative to a fresh `origin/main`. The base must be the fork point
2835    /// (merge-base with `origin/main`), NOT the stale local-`main` commit that
2836    /// the old bare-name resolution diffed against.
2837    #[test]
2838    fn auto_detect_base_ref_ignores_stale_local_main() {
2839        let tmp = tempfile::TempDir::new().expect("temp dir should be created");
2840        let repo = init_throwaway_repo(tmp.path(), "repo");
2841        let stale = git_rev_parse(&repo, "HEAD").expect("HEAD should resolve");
2842
2843        // origin/main starts at the first commit, then a teammate advances it.
2844        git(&repo, &["update-ref", "refs/remotes/origin/main", "main"]);
2845        git(
2846            &repo,
2847            &[
2848                "symbolic-ref",
2849                "refs/remotes/origin/HEAD",
2850                "refs/remotes/origin/main",
2851            ],
2852        );
2853        let fork_point = commit_file(&repo, "teammate.txt", "merged work\n");
2854        git(&repo, &["update-ref", "refs/remotes/origin/main", "main"]);
2855
2856        // Cut a feature branch from the fresh origin tip using the raw SHA (no
2857        // upstream tracking), then leave local `main` behind at the stale commit.
2858        git(&repo, &["checkout", "-b", "feature", &fork_point]);
2859        commit_file(&repo, "feature.txt", "my change\n");
2860        git(&repo, &["branch", "-f", "main", &stale]);
2861
2862        let detected = auto_detect_base_ref(&repo).expect("base should be detected");
2863        assert_eq!(
2864            detected.git_ref, fork_point,
2865            "base must be the fork point (origin/main), not stale local main"
2866        );
2867        assert_ne!(
2868            detected.git_ref, stale,
2869            "must not diff against stale local main"
2870        );
2871        assert_eq!(detected.description, "merge-base with origin/main");
2872    }
2873
2874    #[test]
2875    fn auto_detect_base_ref_prefers_configured_upstream() {
2876        let tmp = tempfile::TempDir::new().expect("temp dir should be created");
2877        let repo = init_throwaway_repo(tmp.path(), "repo");
2878        let fork_point = git_rev_parse(&repo, "HEAD").expect("HEAD should resolve");
2879        // Configure `origin` so refs/remotes/origin/* are recognized as tracking
2880        // refs and `--set-upstream-to` is accepted.
2881        git(&repo, &["remote", "add", "origin", &repo.to_string_lossy()]);
2882        git(&repo, &["update-ref", "refs/remotes/origin/main", "main"]);
2883
2884        git(&repo, &["checkout", "-b", "feature"]);
2885        git(
2886            &repo,
2887            &["branch", "--set-upstream-to=origin/main", "feature"],
2888        );
2889        commit_file(&repo, "feature.txt", "my change\n");
2890
2891        let detected = auto_detect_base_ref(&repo).expect("base should be detected");
2892        assert_eq!(detected.git_ref, fork_point);
2893        assert_eq!(detected.description, "merge-base with origin/main");
2894    }
2895
2896    #[test]
2897    fn auto_detect_base_ref_falls_back_to_local_main_without_remote() {
2898        let tmp = tempfile::TempDir::new().expect("temp dir should be created");
2899        let repo = init_throwaway_repo(tmp.path(), "repo");
2900
2901        let detected = auto_detect_base_ref(&repo).expect("base should be detected");
2902        assert_eq!(detected.git_ref, "main");
2903        assert_eq!(detected.description, "local main");
2904    }
2905
2906    #[test]
2907    fn auto_detect_base_ref_falls_back_to_local_master_without_remote() {
2908        let tmp = tempfile::TempDir::new().expect("temp dir should be created");
2909        let repo = tmp.path().join("repo");
2910        fs::create_dir_all(&repo).expect("repo root should be created");
2911        fs::write(repo.join("README.md"), "seed\n").expect("seed file should be written");
2912        git(&repo, &["init", "-b", "master"]);
2913        git(&repo, &["add", "."]);
2914        git(
2915            &repo,
2916            &["-c", "commit.gpgsign=false", "commit", "-m", "initial"],
2917        );
2918
2919        let detected = auto_detect_base_ref(&repo).expect("base should be detected");
2920        assert_eq!(detected.git_ref, "master");
2921        assert_eq!(detected.description, "local master");
2922    }
2923
2924    #[test]
2925    fn auto_detect_base_ref_returns_none_outside_git_repo() {
2926        let tmp = tempfile::TempDir::new().expect("temp dir should be created");
2927
2928        assert!(auto_detect_base_ref(tmp.path()).is_none());
2929    }
2930
2931    #[test]
2932    fn parse_audit_base_override_trims_and_rejects_empty() {
2933        assert_eq!(parse_audit_base_override(None), None);
2934        assert_eq!(parse_audit_base_override(Some(String::new())), None);
2935        assert_eq!(parse_audit_base_override(Some("   ".to_string())), None);
2936        assert_eq!(
2937            parse_audit_base_override(Some("  origin/main  ".to_string())),
2938            Some("origin/main".to_string())
2939        );
2940    }
2941
2942    /// When the remote default shares no history with HEAD (the merge-base
2943    /// failure case a shallow clone also hits), auto-detect falls back to the
2944    /// remote-tracking ref tip rather than failing detection.
2945    #[test]
2946    fn auto_detect_base_ref_falls_back_to_remote_tip_without_common_ancestor() {
2947        let tmp = tempfile::TempDir::new().expect("temp dir should be created");
2948        let repo = init_throwaway_repo(tmp.path(), "repo");
2949        // Build an unrelated-history commit and point origin/main at it, so
2950        // merge-base(origin/main, HEAD) has no common ancestor.
2951        git(&repo, &["checkout", "--orphan", "unrelated"]);
2952        commit_file(&repo, "unrelated.txt", "no shared history\n");
2953        let unrelated = git_rev_parse(&repo, "HEAD").expect("HEAD should resolve");
2954        git(
2955            &repo,
2956            &["update-ref", "refs/remotes/origin/main", &unrelated],
2957        );
2958        git(
2959            &repo,
2960            &[
2961                "symbolic-ref",
2962                "refs/remotes/origin/HEAD",
2963                "refs/remotes/origin/main",
2964            ],
2965        );
2966        git(&repo, &["checkout", "main"]);
2967
2968        let detected = auto_detect_base_ref(&repo).expect("base should be detected");
2969        assert_eq!(detected.git_ref, "origin/main");
2970        assert_eq!(detected.description, "origin/main (tip)");
2971    }
2972
2973    #[test]
2974    fn get_head_sha_returns_short_head_for_git_repo() {
2975        let tmp = tempfile::TempDir::new().expect("temp dir should be created");
2976        let repo = init_throwaway_repo(tmp.path(), "repo");
2977        let output = Command::new("git")
2978            .args(["rev-parse", "--short", "HEAD"])
2979            .current_dir(&repo)
2980            .env_remove("GIT_DIR")
2981            .env_remove("GIT_WORK_TREE")
2982            .output()
2983            .expect("git rev-parse should run");
2984        assert!(output.status.success());
2985
2986        assert_eq!(
2987            get_head_sha(&repo),
2988            Some(String::from_utf8_lossy(&output.stdout).trim().to_string())
2989        );
2990    }
2991
2992    #[test]
2993    fn get_head_sha_returns_none_outside_git_repo() {
2994        let tmp = tempfile::TempDir::new().expect("temp dir should be created");
2995
2996        assert_eq!(get_head_sha(tmp.path()), None);
2997    }
2998
2999    fn worktree_is_registered_with_git(repo_root: &std::path::Path, worktree_path: &Path) -> bool {
3000        list_audit_worktrees(repo_root)
3001            .is_some_and(|paths| paths.iter().any(|p| paths_equal(p, worktree_path)))
3002    }
3003
3004    /// True when `git worktree list --porcelain` still carries an admin entry
3005    /// whose path ends with `worktree_path`'s basename. Unlike
3006    /// `worktree_is_registered_with_git`, this matches by basename against the
3007    /// raw porcelain output, so it stays correct even when the directory has
3008    /// been deleted (a prunable orphan): `paths_equal` canonicalization cannot
3009    /// match a missing path across the macOS `/var` -> `/private/var` symlink,
3010    /// but the unique nanos-suffixed basename is stable.
3011    fn worktree_admin_entry_present(repo_root: &std::path::Path, worktree_path: &Path) -> bool {
3012        let basename = worktree_path
3013            .file_name()
3014            .and_then(|n| n.to_str())
3015            .expect("reusable worktree path has a utf-8 basename");
3016        let output = Command::new("git")
3017            .args(["worktree", "list", "--porcelain"])
3018            .current_dir(repo_root)
3019            .env_remove("GIT_DIR")
3020            .env_remove("GIT_WORK_TREE")
3021            .output()
3022            .expect("git worktree list should run");
3023        String::from_utf8_lossy(&output.stdout)
3024            .lines()
3025            .filter_map(|line| line.strip_prefix("worktree "))
3026            .any(|p| p.ends_with(basename))
3027    }
3028
3029    #[test]
3030    fn worktree_cleanup_guard_runs_on_drop() {
3031        let tmp = tempfile::TempDir::new().expect("temp dir should be created");
3032        let repo = init_throwaway_repo(tmp.path(), "repo");
3033        let worktree_path = tmp.path().join("fallow-audit-base-1234-5678");
3034
3035        git(
3036            &repo,
3037            &[
3038                "worktree",
3039                "add",
3040                "--detach",
3041                "--quiet",
3042                worktree_path.to_str().expect("path is utf-8"),
3043                "HEAD",
3044            ],
3045        );
3046        assert!(worktree_path.is_dir());
3047        assert!(worktree_is_registered_with_git(&repo, &worktree_path));
3048
3049        {
3050            let _guard = WorktreeCleanupGuard::new(&repo, &worktree_path);
3051        }
3052
3053        assert!(
3054            !worktree_path.exists(),
3055            "guard Drop should remove the worktree directory",
3056        );
3057        assert!(
3058            !worktree_is_registered_with_git(&repo, &worktree_path),
3059            "guard Drop should remove the git worktree registration",
3060        );
3061    }
3062
3063    #[test]
3064    fn worktree_cleanup_guard_defused_skips_drop() {
3065        let tmp = tempfile::TempDir::new().expect("temp dir should be created");
3066        let repo = init_throwaway_repo(tmp.path(), "repo");
3067        let worktree_path = tmp.path().join("fallow-audit-base-1234-5679");
3068
3069        git(
3070            &repo,
3071            &[
3072                "worktree",
3073                "add",
3074                "--detach",
3075                "--quiet",
3076                worktree_path.to_str().expect("path is utf-8"),
3077                "HEAD",
3078            ],
3079        );
3080        assert!(worktree_path.is_dir());
3081
3082        {
3083            let mut guard = WorktreeCleanupGuard::new(&repo, &worktree_path);
3084            guard.defuse();
3085            guard.defuse();
3086        }
3087
3088        assert!(
3089            worktree_path.is_dir(),
3090            "defused guard must not remove the worktree on drop",
3091        );
3092        assert!(
3093            worktree_is_registered_with_git(&repo, &worktree_path),
3094            "defused guard must not unregister the worktree from git",
3095        );
3096
3097        remove_audit_worktree(&repo, &worktree_path);
3098        let _ = fs::remove_dir_all(&worktree_path);
3099    }
3100
3101    #[test]
3102    fn audit_orphan_sweep_removes_dead_pid_worktree() {
3103        const DEAD_PID: u32 = 99_999_999;
3104        assert!(!process_is_alive(DEAD_PID));
3105
3106        let tmp = tempfile::TempDir::new().expect("temp dir should be created");
3107        let repo = init_throwaway_repo(tmp.path(), "repo");
3108
3109        let worktree_path = std::env::temp_dir().join(format!(
3110            "fallow-audit-base-{}-{}",
3111            DEAD_PID,
3112            std::time::SystemTime::now()
3113                .duration_since(std::time::UNIX_EPOCH)
3114                .expect("clock should be after epoch")
3115                .as_nanos()
3116        ));
3117        git(
3118            &repo,
3119            &[
3120                "worktree",
3121                "add",
3122                "--detach",
3123                "--quiet",
3124                worktree_path.to_str().expect("path is utf-8"),
3125                "HEAD",
3126            ],
3127        );
3128        assert!(worktree_path.is_dir());
3129        assert!(worktree_is_registered_with_git(&repo, &worktree_path));
3130
3131        sweep_orphan_audit_worktrees(&repo);
3132
3133        assert!(
3134            !worktree_path.exists(),
3135            "sweep should remove worktree owned by a dead PID",
3136        );
3137        assert!(
3138            !worktree_is_registered_with_git(&repo, &worktree_path),
3139            "sweep should unregister worktree owned by a dead PID",
3140        );
3141    }
3142
3143    #[test]
3144    fn audit_orphan_sweep_keeps_live_pid_worktree() {
3145        let live_pid = std::process::id();
3146        assert!(process_is_alive(live_pid));
3147
3148        let tmp = tempfile::TempDir::new().expect("temp dir should be created");
3149        let repo = init_throwaway_repo(tmp.path(), "repo");
3150
3151        let worktree_path = std::env::temp_dir().join(format!(
3152            "fallow-audit-base-{}-{}",
3153            live_pid,
3154            std::time::SystemTime::now()
3155                .duration_since(std::time::UNIX_EPOCH)
3156                .expect("clock should be after epoch")
3157                .as_nanos()
3158        ));
3159        git(
3160            &repo,
3161            &[
3162                "worktree",
3163                "add",
3164                "--detach",
3165                "--quiet",
3166                worktree_path.to_str().expect("path is utf-8"),
3167                "HEAD",
3168            ],
3169        );
3170
3171        sweep_orphan_audit_worktrees(&repo);
3172
3173        assert!(
3174            worktree_path.is_dir(),
3175            "sweep must not remove worktree owned by a live PID",
3176        );
3177        assert!(
3178            worktree_is_registered_with_git(&repo, &worktree_path),
3179            "sweep must not unregister worktree owned by a live PID",
3180        );
3181
3182        remove_audit_worktree(&repo, &worktree_path);
3183        let _ = fs::remove_dir_all(&worktree_path);
3184    }
3185
3186    /// Build a reusable-shaped worktree path inside the system tempdir
3187    /// (so `is_reusable_audit_worktree_path` and `path_is_inside_temp_dir`
3188    /// both match), uniquified by nanos so parallel tests do not collide.
3189    fn make_reusable_path(label: &str) -> PathBuf {
3190        let nanos = std::time::SystemTime::now()
3191            .duration_since(std::time::UNIX_EPOCH)
3192            .expect("clock should be after epoch")
3193            .as_nanos();
3194        std::env::temp_dir().join(format!("fallow-audit-base-cache-{label}-{nanos:032x}"))
3195    }
3196
3197    /// Register a worktree with the parent repo at `path` checked out at HEAD.
3198    /// Mirrors what `BaseWorktree::reuse_or_create` does for the fresh-create
3199    /// path so the GC sweep tests can build real cache entries.
3200    fn register_reusable_worktree(repo: &Path, path: &Path) {
3201        git(
3202            repo,
3203            &[
3204                "worktree",
3205                "add",
3206                "--detach",
3207                "--quiet",
3208                path.to_str().expect("path is utf-8"),
3209                "HEAD",
3210            ],
3211        );
3212    }
3213
3214    fn write_sidecar_with_age(path: &Path, age: Duration) {
3215        let sidecar = reusable_worktree_last_used_path(path);
3216        let file = std::fs::OpenOptions::new()
3217            .create(true)
3218            .truncate(false)
3219            .write(true)
3220            .open(&sidecar)
3221            .expect("sidecar should open");
3222        let when = SystemTime::now()
3223            .checked_sub(age)
3224            .expect("backdated time should fit in SystemTime");
3225        file.set_modified(when)
3226            .expect("set_modified should succeed");
3227    }
3228
3229    /// Tear down a reusable worktree (git registration + dir + sidecar + lock)
3230    /// regardless of which of those the test created. Idempotent.
3231    fn cleanup_reusable_worktree(repo: &Path, path: &Path) {
3232        remove_audit_worktree(repo, path);
3233        let _ = fs::remove_dir_all(path);
3234        let _ = fs::remove_file(reusable_worktree_last_used_path(path));
3235        let _ = fs::remove_file(reusable_worktree_lock_path(path));
3236    }
3237
3238    #[test]
3239    fn reusable_cache_gc_removes_old_entry_with_backdated_sidecar() {
3240        let tmp = tempfile::TempDir::new().expect("temp dir should be created");
3241        let repo = init_throwaway_repo(tmp.path(), "repo-gc-remove");
3242        let worktree_path = make_reusable_path("gc-remove");
3243        register_reusable_worktree(&repo, &worktree_path);
3244        write_sidecar_with_age(&worktree_path, Duration::from_hours(31 * 24));
3245
3246        sweep_old_reusable_caches(&repo, Some(Duration::from_hours(30 * 24)), true);
3247
3248        assert!(
3249            !worktree_path.exists(),
3250            "sweep should remove worktree dir whose sidecar is older than the threshold",
3251        );
3252        assert!(
3253            !worktree_is_registered_with_git(&repo, &worktree_path),
3254            "sweep should unregister the worktree from git",
3255        );
3256        assert!(
3257            !reusable_worktree_last_used_path(&worktree_path).exists(),
3258            "sweep should remove the sidecar `.last-used` file alongside the worktree",
3259        );
3260        cleanup_reusable_worktree(&repo, &worktree_path);
3261    }
3262
3263    #[test]
3264    fn reusable_cache_gc_keeps_fresh_entry() {
3265        let tmp = tempfile::TempDir::new().expect("temp dir should be created");
3266        let repo = init_throwaway_repo(tmp.path(), "repo-gc-keep");
3267        let worktree_path = make_reusable_path("gc-keep");
3268        register_reusable_worktree(&repo, &worktree_path);
3269        write_sidecar_with_age(&worktree_path, Duration::from_mins(1));
3270
3271        sweep_old_reusable_caches(&repo, Some(Duration::from_hours(30 * 24)), true);
3272
3273        assert!(
3274            worktree_path.is_dir(),
3275            "sweep must not remove a worktree whose sidecar is fresher than the threshold",
3276        );
3277        assert!(
3278            worktree_is_registered_with_git(&repo, &worktree_path),
3279            "sweep must not unregister a fresh worktree",
3280        );
3281        cleanup_reusable_worktree(&repo, &worktree_path);
3282    }
3283
3284    #[test]
3285    fn reusable_cache_gc_skips_locked_entry() {
3286        let tmp = tempfile::TempDir::new().expect("temp dir should be created");
3287        let repo = init_throwaway_repo(tmp.path(), "repo-gc-locked");
3288        let worktree_path = make_reusable_path("gc-locked");
3289        register_reusable_worktree(&repo, &worktree_path);
3290        write_sidecar_with_age(&worktree_path, Duration::from_hours(31 * 24));
3291
3292        let lock = ReusableWorktreeLock::try_acquire(&worktree_path)
3293            .expect("test should acquire the lock first");
3294
3295        sweep_old_reusable_caches(&repo, Some(Duration::from_hours(30 * 24)), true);
3296
3297        assert!(
3298            worktree_path.is_dir(),
3299            "sweep must skip a locked entry even when its sidecar is stale",
3300        );
3301        assert!(
3302            worktree_is_registered_with_git(&repo, &worktree_path),
3303            "sweep must not unregister a locked entry",
3304        );
3305        drop(lock);
3306        cleanup_reusable_worktree(&repo, &worktree_path);
3307    }
3308
3309    #[test]
3310    fn reusable_cache_gc_grace_when_sidecar_absent() {
3311        let tmp = tempfile::TempDir::new().expect("temp dir should be created");
3312        let repo = init_throwaway_repo(tmp.path(), "repo-gc-grace");
3313        let worktree_path = make_reusable_path("gc-grace");
3314        register_reusable_worktree(&repo, &worktree_path);
3315        let sidecar = reusable_worktree_last_used_path(&worktree_path);
3316        assert!(
3317            !sidecar.exists(),
3318            "test pre-condition: sidecar should not exist",
3319        );
3320
3321        sweep_old_reusable_caches(&repo, Some(Duration::from_hours(30 * 24)), true);
3322
3323        assert!(
3324            worktree_path.is_dir(),
3325            "pre-upgrade grace: sidecar-absent entries must NOT be removed on first encounter",
3326        );
3327        assert!(
3328            sidecar.exists(),
3329            "pre-upgrade grace: sidecar must be seeded so the next run can age from real last-used",
3330        );
3331        let mtime = std::fs::metadata(&sidecar)
3332            .and_then(|m| m.modified())
3333            .expect("seeded sidecar should have a readable mtime");
3334        let age = SystemTime::now()
3335            .duration_since(mtime)
3336            .unwrap_or(Duration::ZERO);
3337        assert!(
3338            age < Duration::from_mins(1),
3339            "seeded sidecar mtime should be near `now()`, got age {age:?}",
3340        );
3341        cleanup_reusable_worktree(&repo, &worktree_path);
3342    }
3343
3344    #[test]
3345    fn reusable_cache_gc_reclaims_prunable_orphan_when_dir_missing() {
3346        let tmp = tempfile::TempDir::new().expect("temp dir should be created");
3347        let repo = init_throwaway_repo(tmp.path(), "repo-gc-orphan");
3348        let worktree_path = make_reusable_path("gc-orphan");
3349        register_reusable_worktree(&repo, &worktree_path);
3350        // Fresh sidecar: the age branch alone would KEEP this entry, so a
3351        // successful reclaim proves the dir-missing branch drove it.
3352        write_sidecar_with_age(&worktree_path, Duration::from_mins(1));
3353        let sidecar = reusable_worktree_last_used_path(&worktree_path);
3354
3355        // Simulate an external temp-reaper: delete only the worktree directory,
3356        // leaving git's admin entry and the sidecar behind.
3357        fs::remove_dir_all(&worktree_path).expect("test should remove the cache dir");
3358        assert!(
3359            !worktree_path.exists(),
3360            "test pre-condition: cache dir should be gone",
3361        );
3362        assert!(
3363            worktree_admin_entry_present(&repo, &worktree_path),
3364            "test pre-condition: git admin entry should still be registered (prunable)",
3365        );
3366        assert!(
3367            sidecar.exists(),
3368            "test pre-condition: sidecar survives a dir-only reaper",
3369        );
3370
3371        sweep_old_reusable_caches(&repo, Some(Duration::from_hours(30 * 24)), true);
3372
3373        assert!(
3374            !worktree_admin_entry_present(&repo, &worktree_path),
3375            "sweep should unregister a prunable orphan whose dir was externally removed",
3376        );
3377        assert!(
3378            !sidecar.exists(),
3379            "sweep should remove the stale sidecar for a reclaimed orphan",
3380        );
3381        cleanup_reusable_worktree(&repo, &worktree_path);
3382    }
3383
3384    #[test]
3385    fn reusable_cache_gc_reclaims_prunable_orphan_even_when_age_gc_disabled() {
3386        let tmp = tempfile::TempDir::new().expect("temp dir should be created");
3387        let repo = init_throwaway_repo(tmp.path(), "repo-gc-orphan-nogc");
3388        let worktree_path = make_reusable_path("gc-orphan-nogc");
3389        register_reusable_worktree(&repo, &worktree_path);
3390        write_sidecar_with_age(&worktree_path, Duration::from_mins(1));
3391        let sidecar = reusable_worktree_last_used_path(&worktree_path);
3392        fs::remove_dir_all(&worktree_path).expect("test should remove the cache dir");
3393        assert!(
3394            worktree_admin_entry_present(&repo, &worktree_path),
3395            "test pre-condition: git admin entry should still be registered (prunable)",
3396        );
3397        assert!(
3398            sidecar.exists(),
3399            "test pre-condition: sidecar survives a dir-only reaper",
3400        );
3401
3402        // `None` = age-based GC disabled (`cacheMaxAgeDays = 0`). Orphan reclaim
3403        // must still run so dead admin entries do not accumulate forever.
3404        sweep_old_reusable_caches(&repo, None, true);
3405
3406        assert!(
3407            !worktree_admin_entry_present(&repo, &worktree_path),
3408            "orphan reclaim must run even when age-based GC is disabled",
3409        );
3410        assert!(
3411            !sidecar.exists(),
3412            "sweep should remove the stale sidecar even when age-based GC is disabled",
3413        );
3414        cleanup_reusable_worktree(&repo, &worktree_path);
3415    }
3416
3417    #[test]
3418    fn reusable_cache_gc_preserves_lock_file_after_removal() {
3419        let tmp = tempfile::TempDir::new().expect("temp dir should be created");
3420        let repo = init_throwaway_repo(tmp.path(), "repo-gc-lockfile");
3421        let worktree_path = make_reusable_path("gc-lockfile");
3422        register_reusable_worktree(&repo, &worktree_path);
3423        write_sidecar_with_age(&worktree_path, Duration::from_hours(31 * 24));
3424        let lock_path = reusable_worktree_lock_path(&worktree_path);
3425        drop(
3426            ReusableWorktreeLock::try_acquire(&worktree_path)
3427                .expect("test should acquire the lock"),
3428        );
3429        assert!(
3430            lock_path.exists(),
3431            "test pre-condition: lock file should exist before sweep",
3432        );
3433
3434        sweep_old_reusable_caches(&repo, Some(Duration::from_hours(30 * 24)), true);
3435
3436        assert!(
3437            !worktree_path.exists(),
3438            "sweep should still remove the worktree directory",
3439        );
3440        assert!(
3441            lock_path.exists(),
3442            "sweep MUST NOT delete the `.lock` file (lock-lifecycle invariant)",
3443        );
3444        let _ = fs::remove_file(&lock_path);
3445        cleanup_reusable_worktree(&repo, &worktree_path);
3446    }
3447
3448    #[test]
3449    fn reuse_or_create_stamps_sidecar_on_fresh_create() {
3450        let tmp = tempfile::TempDir::new().expect("temp dir should be created");
3451        let repo = init_throwaway_repo(tmp.path(), "repo-fresh-create-stamp");
3452        let base_sha = git_rev_parse(&repo, "HEAD").expect("HEAD should resolve");
3453
3454        let worktree = BaseWorktree::reuse_or_create(&repo, &base_sha)
3455            .expect("fresh reuse_or_create should succeed on a clean repo");
3456        let cache_path = worktree.path().to_path_buf();
3457        let sidecar = reusable_worktree_last_used_path(&cache_path);
3458
3459        assert!(
3460            sidecar.exists(),
3461            "fresh-create must write the sidecar so age is measured from now",
3462        );
3463        let initial_age = std::fs::metadata(&sidecar)
3464            .and_then(|m| m.modified())
3465            .ok()
3466            .and_then(|mtime| SystemTime::now().duration_since(mtime).ok())
3467            .expect("sidecar mtime should be readable and not in the future");
3468        assert!(
3469            initial_age < Duration::from_mins(1),
3470            "fresh-create sidecar mtime should be near now(), got age {initial_age:?}",
3471        );
3472
3473        drop(worktree);
3474        cleanup_reusable_worktree(&repo, &cache_path);
3475    }
3476
3477    #[test]
3478    fn days_to_duration_zero_disables() {
3479        assert!(days_to_duration(0).is_none());
3480        assert_eq!(days_to_duration(1), Some(Duration::from_hours(24)));
3481        assert_eq!(days_to_duration(30), Some(Duration::from_hours(30 * 24)));
3482    }
3483
3484    #[test]
3485    fn reusable_worktree_last_used_path_lives_next_to_cache_dir() {
3486        let cache_dir = std::env::temp_dir().join("fallow-audit-base-cache-abcd-1234");
3487        let sidecar = reusable_worktree_last_used_path(&cache_dir);
3488        assert_eq!(sidecar.parent(), cache_dir.parent());
3489        assert_eq!(
3490            sidecar.file_name().and_then(|s| s.to_str()),
3491            Some("fallow-audit-base-cache-abcd-1234.last-used"),
3492        );
3493    }
3494
3495    #[test]
3496    fn touch_last_used_creates_sidecar_if_missing() {
3497        let tmp = tempfile::TempDir::new().expect("temp dir should be created");
3498        let cache_dir = tmp.path().join("fallow-audit-base-cache-touchtest-0000");
3499        fs::create_dir(&cache_dir).expect("cache dir should be created");
3500        let sidecar = reusable_worktree_last_used_path(&cache_dir);
3501        assert!(!sidecar.exists(), "sidecar should not exist before touch");
3502
3503        touch_last_used(&cache_dir);
3504
3505        assert!(sidecar.exists(), "touch should create the sidecar");
3506        let mtime = fs::metadata(&sidecar)
3507            .and_then(|m| m.modified())
3508            .expect("sidecar should have an mtime");
3509        let age = SystemTime::now()
3510            .duration_since(mtime)
3511            .unwrap_or(Duration::ZERO);
3512        assert!(
3513            age < Duration::from_mins(1),
3514            "touched sidecar should be near `now()`",
3515        );
3516    }
3517
3518    #[test]
3519    fn reusable_worktree_lock_excludes_concurrent_acquires() {
3520        let tmp = tempfile::TempDir::new().expect("temp dir should be created");
3521        let reusable = tmp.path().join("fallow-audit-base-cache-deadbeef-0000");
3522        let lock_path = reusable_worktree_lock_path(&reusable);
3523
3524        let first = ReusableWorktreeLock::try_acquire(&reusable)
3525            .expect("first acquire on a fresh path should succeed");
3526        assert!(
3527            ReusableWorktreeLock::try_acquire(&reusable).is_none(),
3528            "second acquire must fail while the first is held",
3529        );
3530        drop(first);
3531        assert!(
3532            lock_path.exists(),
3533            "lock file must persist after drop (only the kernel lock is released)",
3534        );
3535    }
3536
3537    #[test]
3538    fn base_analysis_root_preserves_repo_subdirectory_roots() {
3539        let tmp = tempfile::TempDir::new().expect("temp dir should be created");
3540        let repo = tmp.path().join("repo");
3541        let app_root = repo.join("apps/mobile");
3542        let base_worktree = tmp.path().join("base-worktree");
3543        fs::create_dir_all(&app_root).expect("app root should be created");
3544        fs::create_dir_all(&base_worktree).expect("base worktree should be created");
3545        git(&repo, &["init", "-b", "main"]);
3546
3547        assert_eq!(
3548            base_analysis_root(&app_root, &base_worktree),
3549            base_worktree.join("apps/mobile")
3550        );
3551    }
3552
3553    #[test]
3554    fn audit_base_worktree_reuses_current_node_modules_context() {
3555        let tmp = tempfile::TempDir::new().expect("temp dir should be created");
3556        let root = tmp.path();
3557        fs::create_dir_all(root.join("src")).expect("src dir should be created");
3558        fs::write(root.join(".gitignore"), "node_modules\n.fallow\n")
3559            .expect("gitignore should be written");
3560        fs::write(
3561            root.join("package.json"),
3562            r#"{"name":"audit-rn-alias","main":"src/index.ts","dependencies":{"@react-native/typescript-config":"1.0.0"}}"#,
3563        )
3564        .expect("package.json should be written");
3565        fs::write(
3566            root.join("tsconfig.json"),
3567            r#"{"extends":"./node_modules/@react-native/typescript-config/tsconfig.json","compilerOptions":{"baseUrl":".","paths":{"@/*":["src/*"]}},"include":["src"]}"#,
3568        )
3569        .expect("tsconfig should be written");
3570        fs::write(
3571            root.join("src/index.ts"),
3572            "import { used } from '@/feature';\nconsole.log(used);\n",
3573        )
3574        .expect("index should be written");
3575        fs::write(root.join("src/feature.ts"), "export const used = 1;\n")
3576            .expect("feature should be written");
3577
3578        git(root, &["init", "-b", "main"]);
3579        git(root, &["add", "."]);
3580        git(
3581            root,
3582            &["-c", "commit.gpgsign=false", "commit", "-m", "initial"],
3583        );
3584
3585        let rn_config = root.join("node_modules/@react-native/typescript-config");
3586        fs::create_dir_all(&rn_config).expect("node_modules config dir should be created");
3587        fs::write(
3588            rn_config.join("tsconfig.json"),
3589            r#"{"compilerOptions":{"jsx":"react-native","moduleResolution":"bundler"}}"#,
3590        )
3591        .expect("node_modules tsconfig should be written");
3592
3593        let worktree =
3594            BaseWorktree::create(root, "HEAD", None).expect("base worktree should be created");
3595        assert!(
3596            worktree.path().join("node_modules").is_dir(),
3597            "base worktree should reuse ignored node_modules from the current checkout"
3598        );
3599        assert!(
3600            worktree
3601                .path()
3602                .join("node_modules/@react-native/typescript-config/tsconfig.json")
3603                .is_file(),
3604            "base worktree should preserve tsconfig extends targets installed in node_modules"
3605        );
3606    }
3607
3608    /// Confirms `materialize_base_dependency_context` symlinks the Nuxt
3609    /// `.nuxt/` generated dir from the host checkout into the audit base
3610    /// worktree. Without this, root `tsconfig.json` `references` entries
3611    /// pointing into `.nuxt/tsconfig.app.json` break in the base pass and
3612    /// emit "Nuxt project missing .nuxt/tsconfig.json" plus "Broken tsconfig
3613    /// chain" warnings. The function is exercised directly here rather than
3614    /// through `BaseWorktree::create` to avoid the `git worktree add`
3615    /// concurrency-flakiness the worktree-level integration tests already
3616    /// exhibit.
3617    #[test]
3618    fn materialize_base_dependency_context_symlinks_nuxt_generated_dir() {
3619        let host = tempfile::TempDir::new().expect("host tempdir should be created");
3620        let worktree = tempfile::TempDir::new().expect("worktree tempdir should be created");
3621
3622        let dot_nuxt = host.path().join(".nuxt");
3623        fs::create_dir_all(&dot_nuxt).expect(".nuxt dir should be created");
3624        fs::write(dot_nuxt.join("tsconfig.json"), r#"{"compilerOptions":{}}"#)
3625            .expect(".nuxt/tsconfig.json should be written");
3626        fs::write(
3627            dot_nuxt.join("tsconfig.app.json"),
3628            r#"{"compilerOptions":{}}"#,
3629        )
3630        .expect(".nuxt/tsconfig.app.json should be written");
3631
3632        materialize_base_dependency_context(host.path(), worktree.path());
3633
3634        let mirrored = worktree.path().join(".nuxt");
3635        assert!(
3636            mirrored.is_dir(),
3637            "base worktree should reuse the ignored .nuxt dir from the host checkout"
3638        );
3639        let link_meta = fs::symlink_metadata(&mirrored)
3640            .expect(".nuxt entry should exist as a symlink in the worktree");
3641        assert!(
3642            link_meta.file_type().is_symlink(),
3643            "base worktree's .nuxt should be a symlink to the host checkout"
3644        );
3645        assert!(
3646            mirrored.join("tsconfig.json").is_file(),
3647            "base worktree should expose .nuxt/tsconfig.json so the Nuxt meta-framework \
3648             prerequisite check stays quiet"
3649        );
3650        assert!(
3651            mirrored.join("tsconfig.app.json").is_file(),
3652            "base worktree should expose .nuxt/tsconfig.app.json so root tsconfig references \
3653             resolve without falling back to resolver-less resolution"
3654        );
3655    }
3656
3657    /// Confirms the same symlink treatment for Astro's `.astro/` generated
3658    /// types directory, which is gitignored by default and would otherwise
3659    /// trip the "Astro project missing .astro/" prerequisite check on the
3660    /// base pass.
3661    #[test]
3662    fn materialize_base_dependency_context_symlinks_astro_generated_dir() {
3663        let host = tempfile::TempDir::new().expect("host tempdir should be created");
3664        let worktree = tempfile::TempDir::new().expect("worktree tempdir should be created");
3665
3666        let dot_astro = host.path().join(".astro");
3667        fs::create_dir_all(&dot_astro).expect(".astro dir should be created");
3668        fs::write(dot_astro.join("types.d.ts"), "// generated types\n")
3669            .expect(".astro/types.d.ts should be written");
3670
3671        materialize_base_dependency_context(host.path(), worktree.path());
3672
3673        let mirrored = worktree.path().join(".astro");
3674        assert!(
3675            mirrored.is_dir(),
3676            "base worktree should reuse the ignored .astro dir from the host checkout"
3677        );
3678        assert!(
3679            mirrored.join("types.d.ts").is_file(),
3680            "base worktree should expose generated Astro types so the Astro meta-framework \
3681             prerequisite check stays quiet"
3682        );
3683    }
3684
3685    /// Confirms the symlink step is a no-op when the host checkout has no
3686    /// meta-framework output. We must not fabricate a dangling `.nuxt`
3687    /// symlink: the Nuxt prerequisite check would then pass on the base pass
3688    /// while the actual `.nuxt/tsconfig.json` still doesn't exist, hiding a
3689    /// real "run `nuxt prepare`" warning on the HEAD pass behind a
3690    /// process-wide dedupe key.
3691    #[test]
3692    fn materialize_base_dependency_context_skips_when_host_lacks_meta_framework_dir() {
3693        let host = tempfile::TempDir::new().expect("host tempdir should be created");
3694        let worktree = tempfile::TempDir::new().expect("worktree tempdir should be created");
3695
3696        materialize_base_dependency_context(host.path(), worktree.path());
3697
3698        assert!(
3699            !worktree.path().join(".nuxt").exists(),
3700            "base worktree should not fabricate a .nuxt symlink when the host has no .nuxt dir"
3701        );
3702        assert!(
3703            !worktree.path().join(".astro").exists(),
3704            "base worktree should not fabricate a .astro symlink when the host has no .astro dir"
3705        );
3706        assert!(
3707            !worktree.path().join("node_modules").exists(),
3708            "base worktree should not fabricate a node_modules symlink when the host has none"
3709        );
3710    }
3711
3712    /// Confirms each entry in `MATERIALIZED_CONTEXT_DIRS` is independent: a
3713    /// missing host `.nuxt/` must not prevent `node_modules` from being
3714    /// symlinked when only one of the two is present on the host.
3715    #[test]
3716    fn materialize_base_dependency_context_handles_each_dir_independently() {
3717        let host = tempfile::TempDir::new().expect("host tempdir should be created");
3718        let worktree = tempfile::TempDir::new().expect("worktree tempdir should be created");
3719
3720        fs::create_dir_all(host.path().join("node_modules"))
3721            .expect("host node_modules should be created");
3722
3723        materialize_base_dependency_context(host.path(), worktree.path());
3724
3725        assert!(
3726            worktree.path().join("node_modules").is_dir(),
3727            "node_modules should still be symlinked even when host has no .nuxt or .astro"
3728        );
3729        assert!(
3730            !worktree.path().join(".nuxt").exists(),
3731            "missing host .nuxt should leave the worktree slot empty"
3732        );
3733    }
3734
3735    /// Confirms a real (non-symlink) generated dir already present in the base
3736    /// worktree is preserved, not clobbered by a host symlink. A base commit
3737    /// that genuinely tracks `.nuxt/` is base-shaped and authoritative; the
3738    /// host-symlink shortcut only fills the gap when the worktree slot is
3739    /// empty (or a stale dangling symlink), so the `destination.is_dir()`
3740    /// early-continue must keep the worktree's own contents.
3741    #[test]
3742    fn materialize_base_dependency_context_preserves_real_worktree_dir() {
3743        let host = tempfile::TempDir::new().expect("host tempdir should be created");
3744        let worktree = tempfile::TempDir::new().expect("worktree tempdir should be created");
3745
3746        let host_nuxt = host.path().join(".nuxt");
3747        fs::create_dir_all(&host_nuxt).expect("host .nuxt dir should be created");
3748        fs::write(host_nuxt.join("tsconfig.json"), r#"{"_source":"host"}"#)
3749            .expect("host .nuxt/tsconfig.json should be written");
3750
3751        let worktree_nuxt = worktree.path().join(".nuxt");
3752        fs::create_dir_all(&worktree_nuxt).expect("worktree .nuxt dir should be created");
3753        fs::write(worktree_nuxt.join("tsconfig.json"), r#"{"_source":"base"}"#)
3754            .expect("worktree .nuxt/tsconfig.json should be written");
3755
3756        materialize_base_dependency_context(host.path(), worktree.path());
3757
3758        let link_meta = fs::symlink_metadata(&worktree_nuxt)
3759            .expect(".nuxt entry should still exist in the worktree");
3760        assert!(
3761            !link_meta.file_type().is_symlink(),
3762            "a real base-tracked .nuxt dir must not be replaced by a host symlink"
3763        );
3764        let contents =
3765            fs::read_to_string(worktree_nuxt.join("tsconfig.json")).expect("tsconfig should read");
3766        assert!(
3767            contents.contains("base"),
3768            "base worktree's own .nuxt contents must survive, not be overwritten by the host's"
3769        );
3770    }
3771
3772    #[test]
3773    fn audit_reusable_base_worktree_refreshes_current_node_modules_context() {
3774        let tmp = tempfile::TempDir::new().expect("temp dir should be created");
3775        let root = tmp.path();
3776        fs::write(root.join(".gitignore"), "node_modules\n.fallow\n")
3777            .expect("gitignore should be written");
3778        fs::write(root.join("package.json"), r#"{"name":"audit-reusable"}"#)
3779            .expect("package.json should be written");
3780
3781        git(root, &["init", "-b", "main"]);
3782        git(root, &["add", "."]);
3783        git(
3784            root,
3785            &["-c", "commit.gpgsign=false", "commit", "-m", "initial"],
3786        );
3787
3788        let rn_config = root.join("node_modules/@react-native/typescript-config");
3789        fs::create_dir_all(&rn_config).expect("node_modules config dir should be created");
3790        fs::write(rn_config.join("tsconfig.json"), "{}")
3791            .expect("node_modules tsconfig should be written");
3792
3793        let base_sha = git_rev_parse(root, "HEAD").expect("HEAD should resolve");
3794        let first = BaseWorktree::reuse_or_create(root, &base_sha)
3795            .expect("persistent base worktree should be created");
3796        let worktree_path = first.path().to_path_buf();
3797        assert!(
3798            worktree_path.join("node_modules").is_dir(),
3799            "initial persistent worktree should receive node_modules context"
3800        );
3801        remove_node_modules_context(&worktree_path);
3802        assert!(
3803            !worktree_path.join("node_modules").exists(),
3804            "test setup should remove the dependency context from the reusable worktree"
3805        );
3806        drop(first);
3807
3808        let reused = BaseWorktree::reuse_or_create(root, &base_sha)
3809            .expect("ready persistent base worktree should be reused");
3810        assert_eq!(reused.path(), worktree_path.as_path());
3811        assert!(
3812            reused.path().join("node_modules").is_dir(),
3813            "ready persistent worktree should refresh missing node_modules context"
3814        );
3815
3816        cleanup_reusable_worktree(root, reused.path());
3817    }
3818
3819    fn remove_node_modules_context(worktree_path: &Path) {
3820        let path = worktree_path.join("node_modules");
3821        let Ok(metadata) = fs::symlink_metadata(&path) else {
3822            return;
3823        };
3824        if metadata.file_type().is_symlink() {
3825            #[cfg(unix)]
3826            let _ = fs::remove_file(path);
3827            #[cfg(windows)]
3828            let _ = fs::remove_dir(&path).or_else(|_| fs::remove_file(&path));
3829        } else {
3830            let _ = fs::remove_dir_all(path);
3831        }
3832    }
3833
3834    #[test]
3835    fn audit_base_snapshot_cache_payload_roundtrips_sets() {
3836        let key = AuditBaseSnapshotCacheKey {
3837            hash: 42,
3838            base_sha: "abc123".to_string(),
3839        };
3840        let snapshot = AuditKeySnapshot {
3841            dead_code: ["dead:a".to_string(), "dead:b".to_string()]
3842                .into_iter()
3843                .collect(),
3844            health: std::iter::once("health:a".to_string()).collect(),
3845            dupes: ["dupe:a".to_string(), "dupe:b".to_string()]
3846                .into_iter()
3847                .collect(),
3848            boundary_edges: std::iter::once("ui->-db".to_string()).collect(),
3849            cycles: std::iter::once("a.ts|b.ts".to_string()).collect(),
3850            public_api: std::iter::once("src/index.ts::foo".to_string()).collect(),
3851        };
3852
3853        let cached = cached_from_snapshot(&key, &snapshot);
3854        assert_eq!(cached.version, AUDIT_BASE_SNAPSHOT_CACHE_VERSION);
3855        assert_eq!(cached.key_hash, key.hash);
3856        assert_eq!(cached.base_sha, key.base_sha);
3857        assert_eq!(cached.dead_code, vec!["dead:a", "dead:b"]);
3858
3859        let decoded = snapshot_from_cached(cached);
3860        assert_eq!(decoded.dead_code, snapshot.dead_code);
3861        assert_eq!(decoded.health, snapshot.health);
3862        assert_eq!(decoded.dupes, snapshot.dupes);
3863        assert_eq!(decoded.boundary_edges, snapshot.boundary_edges);
3864        assert_eq!(decoded.cycles, snapshot.cycles);
3865        assert_eq!(decoded.public_api, snapshot.public_api);
3866    }
3867
3868    #[test]
3869    fn audit_base_snapshot_cache_dir_writes_gitignore() {
3870        let tmp = tempfile::TempDir::new().expect("temp dir should be created");
3871        let cache_root = tmp.path().join(".custom-fallow-cache");
3872        let cache_dir = audit_base_snapshot_cache_dir(&cache_root);
3873
3874        ensure_audit_base_snapshot_cache_dir(&cache_dir).expect("cache dir should be created");
3875
3876        assert_eq!(
3877            fs::read_to_string(cache_dir.join(".gitignore")).expect("gitignore should read"),
3878            "*\n"
3879        );
3880    }
3881
3882    #[test]
3883    fn audit_base_snapshot_cache_roundtrips_from_disk() {
3884        let tmp = tempfile::TempDir::new().expect("temp dir should be created");
3885        let config_path = None;
3886        let cache_root = tmp.path().join(".custom-fallow-cache");
3887        let opts = AuditOptions {
3888            root: tmp.path(),
3889            cache_dir: &cache_root,
3890            config_path: &config_path,
3891            output: OutputFormat::Json,
3892            no_cache: false,
3893            threads: 1,
3894            quiet: true,
3895            changed_since: Some("HEAD"),
3896            production: false,
3897            production_dead_code: None,
3898            production_health: None,
3899            production_dupes: None,
3900            workspace: None,
3901            changed_workspaces: None,
3902            explain: false,
3903            explain_skipped: false,
3904            performance: false,
3905            group_by: None,
3906            dead_code_baseline: None,
3907            health_baseline: None,
3908            dupes_baseline: None,
3909            max_crap: None,
3910            coverage: None,
3911            coverage_root: None,
3912            gate: AuditGate::NewOnly,
3913            include_entry_exports: false,
3914            runtime_coverage: None,
3915            min_invocations_hot: 100,
3916            brief: false,
3917            max_decisions: 4,
3918            walkthrough_guide: false,
3919            walkthrough_file: None,
3920            show_deprioritized: false,
3921        };
3922        let key = AuditBaseSnapshotCacheKey {
3923            hash: 0xfeed,
3924            base_sha: "abc123".to_string(),
3925        };
3926        let snapshot = AuditKeySnapshot {
3927            dead_code: std::iter::once("dead:a".to_string()).collect(),
3928            health: std::iter::once("health:a".to_string()).collect(),
3929            dupes: std::iter::once("dupe:a".to_string()).collect(),
3930            boundary_edges: FxHashSet::default(),
3931            cycles: FxHashSet::default(),
3932            public_api: FxHashSet::default(),
3933        };
3934
3935        save_cached_base_snapshot(&opts, &key, &snapshot);
3936        assert!(
3937            audit_base_snapshot_cache_file(&cache_root, &key).exists(),
3938            "snapshot should be saved below the configured cache directory"
3939        );
3940        let loaded = load_cached_base_snapshot(&opts, &key).expect("snapshot should load");
3941
3942        assert_eq!(loaded.dead_code, snapshot.dead_code);
3943        assert_eq!(loaded.health, snapshot.health);
3944        assert_eq!(loaded.dupes, snapshot.dupes);
3945    }
3946
3947    #[test]
3948    fn audit_base_snapshot_cache_rejects_mismatched_key() {
3949        let tmp = tempfile::TempDir::new().expect("temp dir should be created");
3950        let config_path = None;
3951        let cache_root = tmp.path().join(".custom-fallow-cache");
3952        let opts = AuditOptions {
3953            root: tmp.path(),
3954            cache_dir: &cache_root,
3955            config_path: &config_path,
3956            output: OutputFormat::Json,
3957            no_cache: false,
3958            threads: 1,
3959            quiet: true,
3960            changed_since: Some("HEAD"),
3961            production: false,
3962            production_dead_code: None,
3963            production_health: None,
3964            production_dupes: None,
3965            workspace: None,
3966            changed_workspaces: None,
3967            explain: false,
3968            explain_skipped: false,
3969            performance: false,
3970            group_by: None,
3971            dead_code_baseline: None,
3972            health_baseline: None,
3973            dupes_baseline: None,
3974            max_crap: None,
3975            coverage: None,
3976            coverage_root: None,
3977            gate: AuditGate::NewOnly,
3978            include_entry_exports: false,
3979            runtime_coverage: None,
3980            min_invocations_hot: 100,
3981            brief: false,
3982            max_decisions: 4,
3983            walkthrough_guide: false,
3984            walkthrough_file: None,
3985            show_deprioritized: false,
3986        };
3987        let key = AuditBaseSnapshotCacheKey {
3988            hash: 0xbeef,
3989            base_sha: "head".to_string(),
3990        };
3991        let cached = CachedAuditKeySnapshot {
3992            version: AUDIT_BASE_SNAPSHOT_CACHE_VERSION,
3993            cli_version: env!("CARGO_PKG_VERSION").to_string(),
3994            key_hash: key.hash,
3995            base_sha: "other".to_string(),
3996            dead_code: vec!["dead:a".to_string()],
3997            health: vec![],
3998            dupes: vec![],
3999            boundary_edges: vec![],
4000            cycles: vec![],
4001            public_api: vec![],
4002        };
4003        let cache_dir = audit_base_snapshot_cache_dir(&cache_root);
4004        ensure_audit_base_snapshot_cache_dir(&cache_dir).expect("cache dir should be created");
4005        fs::write(
4006            audit_base_snapshot_cache_file(&cache_root, &key),
4007            bitcode::encode(&cached),
4008        )
4009        .expect("cache file should be written");
4010
4011        assert!(load_cached_base_snapshot(&opts, &key).is_none());
4012    }
4013
4014    #[test]
4015    fn audit_base_snapshot_cache_key_includes_extended_config() {
4016        let tmp = tempfile::TempDir::new().expect("temp dir should be created");
4017        let root = tmp.path();
4018        fs::write(
4019            root.join(".fallowrc.json"),
4020            r#"{"extends":"base.json","entry":["src/index.ts"]}"#,
4021        )
4022        .expect("config should be written");
4023        fs::write(
4024            root.join("base.json"),
4025            r#"{"rules":{"unused-exports":"off"}}"#,
4026        )
4027        .expect("base config should be written");
4028
4029        let config_path = None;
4030        let cache_root = root.join(".fallow");
4031        let opts = AuditOptions {
4032            root,
4033            cache_dir: &cache_root,
4034            config_path: &config_path,
4035            output: OutputFormat::Json,
4036            no_cache: false,
4037            threads: 1,
4038            quiet: true,
4039            changed_since: Some("HEAD"),
4040            production: false,
4041            production_dead_code: None,
4042            production_health: None,
4043            production_dupes: None,
4044            workspace: None,
4045            changed_workspaces: None,
4046            explain: false,
4047            explain_skipped: false,
4048            performance: false,
4049            group_by: None,
4050            dead_code_baseline: None,
4051            health_baseline: None,
4052            dupes_baseline: None,
4053            max_crap: None,
4054            coverage: None,
4055            coverage_root: None,
4056            gate: AuditGate::NewOnly,
4057            include_entry_exports: false,
4058            runtime_coverage: None,
4059            min_invocations_hot: 100,
4060            brief: false,
4061            max_decisions: 4,
4062            walkthrough_guide: false,
4063            walkthrough_file: None,
4064            show_deprioritized: false,
4065        };
4066
4067        let first = config_file_fingerprint(&opts).expect("fingerprint should be computed");
4068        fs::write(
4069            root.join("base.json"),
4070            r#"{"rules":{"unused-exports":"error"}}"#,
4071        )
4072        .expect("base config should be updated");
4073        let second = config_file_fingerprint(&opts).expect("fingerprint should be recomputed");
4074
4075        assert_ne!(
4076            first["resolved_hash"], second["resolved_hash"],
4077            "extended config changes must invalidate cached base snapshots"
4078        );
4079    }
4080
4081    #[test]
4082    fn audit_gate_all_skips_base_snapshot() {
4083        let tmp = tempfile::TempDir::new().expect("temp dir should be created");
4084        let root = tmp.path();
4085        fs::create_dir_all(root.join("src")).expect("src dir should be created");
4086        fs::write(
4087            root.join("package.json"),
4088            r#"{"name":"audit-gate-all","main":"src/index.ts"}"#,
4089        )
4090        .expect("package.json should be written");
4091        fs::write(root.join("src/index.ts"), "export const legacy = 1;\n")
4092            .expect("index should be written");
4093
4094        git(root, &["init", "-b", "main"]);
4095        git(root, &["add", "."]);
4096        git(
4097            root,
4098            &["-c", "commit.gpgsign=false", "commit", "-m", "initial"],
4099        );
4100        fs::write(
4101            root.join("src/index.ts"),
4102            "export const legacy = 1;\nexport const changed = 2;\n",
4103        )
4104        .expect("changed module should be written");
4105
4106        let config_path = None;
4107        let cache_root = root.join(".fallow");
4108        let opts = AuditOptions {
4109            root,
4110            cache_dir: &cache_root,
4111            config_path: &config_path,
4112            output: OutputFormat::Json,
4113            no_cache: true,
4114            threads: 1,
4115            quiet: true,
4116            changed_since: Some("HEAD"),
4117            production: false,
4118            production_dead_code: None,
4119            production_health: None,
4120            production_dupes: None,
4121            workspace: None,
4122            changed_workspaces: None,
4123            explain: false,
4124            explain_skipped: false,
4125            performance: false,
4126            group_by: None,
4127            dead_code_baseline: None,
4128            health_baseline: None,
4129            dupes_baseline: None,
4130            max_crap: None,
4131            coverage: None,
4132            coverage_root: None,
4133            gate: AuditGate::All,
4134            include_entry_exports: false,
4135            runtime_coverage: None,
4136            min_invocations_hot: 100,
4137            brief: false,
4138            max_decisions: 4,
4139            walkthrough_guide: false,
4140            walkthrough_file: None,
4141            show_deprioritized: false,
4142        };
4143
4144        let result = execute_audit(&opts).expect("audit should execute");
4145        assert!(result.base_snapshot.is_none());
4146        assert_eq!(result.attribution.gate, AuditGate::All);
4147        assert_eq!(result.attribution.dead_code_introduced, 0);
4148        assert_eq!(result.attribution.dead_code_inherited, 0);
4149    }
4150
4151    #[test]
4152    fn audit_gate_new_only_skips_base_snapshot_for_docs_only_diff() {
4153        let tmp = tempfile::TempDir::new().expect("temp dir should be created");
4154        let root = tmp.path();
4155        fs::create_dir_all(root.join("src")).expect("src dir should be created");
4156        fs::write(
4157            root.join("package.json"),
4158            r#"{"name":"audit-docs-only","main":"src/index.ts"}"#,
4159        )
4160        .expect("package.json should be written");
4161        fs::write(
4162            root.join(".fallowrc.json"),
4163            r#"{"duplicates":{"minTokens":5,"minLines":2,"mode":"strict"}}"#,
4164        )
4165        .expect("config should be written");
4166        let duplicated = "export function same(input: number): number {\n  const doubled = input * 2;\n  const shifted = doubled + 1;\n  return shifted;\n}\n";
4167        fs::write(root.join("src/index.ts"), duplicated).expect("index should be written");
4168        fs::write(root.join("src/copy.ts"), duplicated).expect("copy should be written");
4169        fs::write(root.join("README.md"), "before\n").expect("readme should be written");
4170
4171        git(root, &["init", "-b", "main"]);
4172        git(root, &["add", "."]);
4173        git(
4174            root,
4175            &["-c", "commit.gpgsign=false", "commit", "-m", "initial"],
4176        );
4177        fs::write(root.join("README.md"), "after\n").expect("readme should be modified");
4178        fs::create_dir_all(root.join(".fallow/cache/dupes-tokens-v2"))
4179            .expect("cache dir should be created");
4180        fs::write(
4181            root.join(".fallow/cache/dupes-tokens-v2/cache.bin"),
4182            b"cache",
4183        )
4184        .expect("cache artifact should be written");
4185
4186        let before_worktrees = audit_worktree_names(root);
4187
4188        let config_path = None;
4189        let cache_root = root.join(".fallow");
4190        let opts = AuditOptions {
4191            root,
4192            cache_dir: &cache_root,
4193            config_path: &config_path,
4194            output: OutputFormat::Json,
4195            no_cache: true,
4196            threads: 1,
4197            quiet: true,
4198            changed_since: Some("HEAD"),
4199            production: false,
4200            production_dead_code: None,
4201            production_health: None,
4202            production_dupes: None,
4203            workspace: None,
4204            changed_workspaces: None,
4205            explain: false,
4206            explain_skipped: false,
4207            performance: true,
4208            group_by: None,
4209            dead_code_baseline: None,
4210            health_baseline: None,
4211            dupes_baseline: None,
4212            max_crap: None,
4213            coverage: None,
4214            coverage_root: None,
4215            gate: AuditGate::NewOnly,
4216            include_entry_exports: false,
4217            runtime_coverage: None,
4218            min_invocations_hot: 100,
4219            brief: false,
4220            max_decisions: 4,
4221            walkthrough_guide: false,
4222            walkthrough_file: None,
4223            show_deprioritized: false,
4224        };
4225
4226        let result = execute_audit(&opts).expect("audit should execute");
4227        assert_eq!(result.verdict, AuditVerdict::Pass);
4228        assert_eq!(result.changed_files_count, 2);
4229        assert!(result.base_snapshot_skipped);
4230        assert!(result.base_snapshot.is_some());
4231
4232        let after_worktrees = audit_worktree_names(root);
4233        assert_eq!(
4234            before_worktrees, after_worktrees,
4235            "base snapshot skip must not create a temporary base worktree"
4236        );
4237    }
4238
4239    fn audit_worktree_names(repo_root: &Path) -> Vec<String> {
4240        let mut names: Vec<String> = list_audit_worktrees(repo_root)
4241            .unwrap_or_default()
4242            .into_iter()
4243            .filter_map(|path| {
4244                path.file_name()
4245                    .and_then(|name| name.to_str())
4246                    .map(str::to_owned)
4247            })
4248            .collect();
4249        names.sort();
4250        names
4251    }
4252
4253    #[test]
4254    fn audit_reuses_dead_code_parse_for_health_when_production_matches() {
4255        let tmp = tempfile::TempDir::new().expect("temp dir should be created");
4256        let root = tmp.path();
4257        fs::create_dir_all(root.join("src")).expect("src dir should be created");
4258        fs::write(
4259            root.join("package.json"),
4260            r#"{"name":"audit-shared-parse","main":"src/index.ts"}"#,
4261        )
4262        .expect("package.json should be written");
4263        fs::write(
4264            root.join("src/index.ts"),
4265            "import { used } from './used';\nused();\n",
4266        )
4267        .expect("index should be written");
4268        fs::write(
4269            root.join("src/used.ts"),
4270            "export function used() {\n  return 1;\n}\n",
4271        )
4272        .expect("used module should be written");
4273
4274        git(root, &["init", "-b", "main"]);
4275        git(root, &["add", "."]);
4276        git(
4277            root,
4278            &["-c", "commit.gpgsign=false", "commit", "-m", "initial"],
4279        );
4280        fs::write(
4281            root.join("src/used.ts"),
4282            "export function used() {\n  return 1;\n}\nexport function changed() {\n  return 2;\n}\n",
4283        )
4284        .expect("changed module should be written");
4285
4286        let config_path = None;
4287        let cache_root = root.join(".fallow");
4288        let opts = AuditOptions {
4289            root,
4290            cache_dir: &cache_root,
4291            config_path: &config_path,
4292            output: OutputFormat::Json,
4293            no_cache: true,
4294            threads: 1,
4295            quiet: true,
4296            changed_since: Some("HEAD"),
4297            production: false,
4298            production_dead_code: None,
4299            production_health: None,
4300            production_dupes: None,
4301            workspace: None,
4302            changed_workspaces: None,
4303            explain: false,
4304            explain_skipped: false,
4305            performance: true,
4306            group_by: None,
4307            dead_code_baseline: None,
4308            health_baseline: None,
4309            dupes_baseline: None,
4310            max_crap: None,
4311            coverage: None,
4312            coverage_root: None,
4313            gate: AuditGate::NewOnly,
4314            include_entry_exports: false,
4315            runtime_coverage: None,
4316            min_invocations_hot: 100,
4317            brief: false,
4318            max_decisions: 4,
4319            walkthrough_guide: false,
4320            walkthrough_file: None,
4321            show_deprioritized: false,
4322        };
4323
4324        let result = execute_audit(&opts).expect("audit should execute");
4325        let health = result.health.expect("health should run for changed files");
4326        let timings = health.timings.expect("performance timings should be kept");
4327        assert!(timings.discover_ms.abs() < f64::EPSILON);
4328        assert!(timings.parse_ms.abs() < f64::EPSILON);
4329        assert!(
4330            result.dupes.is_some(),
4331            "dupes should run when changed files exist"
4332        );
4333    }
4334
4335    #[test]
4336    fn audit_dupes_falls_back_to_own_discovery_when_health_off() {
4337        let tmp = tempfile::TempDir::new().expect("temp dir should be created");
4338        let root = tmp.path();
4339        fs::create_dir_all(root.join("src")).expect("src dir should be created");
4340        fs::write(
4341            root.join("package.json"),
4342            r#"{"name":"audit-dupes-fallback","main":"src/index.ts"}"#,
4343        )
4344        .expect("package.json should be written");
4345        fs::write(
4346            root.join("src/index.ts"),
4347            "import { used } from './used';\nused();\n",
4348        )
4349        .expect("index should be written");
4350        fs::write(
4351            root.join("src/used.ts"),
4352            "export function used() {\n  return 1;\n}\n",
4353        )
4354        .expect("used module should be written");
4355
4356        git(root, &["init", "-b", "main"]);
4357        git(root, &["add", "."]);
4358        git(
4359            root,
4360            &["-c", "commit.gpgsign=false", "commit", "-m", "initial"],
4361        );
4362        fs::write(
4363            root.join("src/used.ts"),
4364            "export function used() {\n  return 1;\n}\nexport function changed() {\n  return 2;\n}\n",
4365        )
4366        .expect("changed module should be written");
4367
4368        let config_path = None;
4369        let cache_root = root.join(".fallow");
4370        let opts = AuditOptions {
4371            root,
4372            cache_dir: &cache_root,
4373            config_path: &config_path,
4374            output: OutputFormat::Json,
4375            no_cache: true,
4376            threads: 1,
4377            quiet: true,
4378            changed_since: Some("HEAD"),
4379            production: false,
4380            production_dead_code: Some(true),
4381            production_health: Some(false),
4382            production_dupes: Some(false),
4383            workspace: None,
4384            changed_workspaces: None,
4385            explain: false,
4386            explain_skipped: false,
4387            performance: true,
4388            group_by: None,
4389            dead_code_baseline: None,
4390            health_baseline: None,
4391            dupes_baseline: None,
4392            max_crap: None,
4393            coverage: None,
4394            coverage_root: None,
4395            gate: AuditGate::NewOnly,
4396            include_entry_exports: false,
4397            runtime_coverage: None,
4398            min_invocations_hot: 100,
4399            brief: false,
4400            max_decisions: 4,
4401            walkthrough_guide: false,
4402            walkthrough_file: None,
4403            show_deprioritized: false,
4404        };
4405
4406        let result = execute_audit(&opts).expect("audit should execute");
4407        assert!(result.dupes.is_some(), "dupes should still run");
4408    }
4409
4410    #[cfg(unix)]
4411    #[test]
4412    fn remap_focus_files_does_not_canonicalize_through_symlinks() {
4413        let tmp = tempfile::TempDir::new().expect("temp dir");
4414        let real = tmp.path().join("real");
4415        let link = tmp.path().join("link");
4416        fs::create_dir_all(&real).expect("real dir");
4417        std::os::unix::fs::symlink(&real, &link).expect("symlink");
4418        let canonical = link.canonicalize().expect("canonicalize symlink");
4419        assert_ne!(link, canonical, "symlink should not equal its target");
4420
4421        let from_root = PathBuf::from("/repo");
4422        let mut focus = FxHashSet::default();
4423        focus.insert(from_root.join("src/foo.ts"));
4424
4425        let remapped = remap_focus_files(&focus, &from_root, &link)
4426            .expect("remap should succeed for in-prefix files");
4427
4428        let expected = link.join("src/foo.ts");
4429        assert!(
4430            remapped.contains(&expected),
4431            "remapped paths must keep the un-canonical to_root prefix; got {remapped:?}, expected entry {expected:?}"
4432        );
4433    }
4434
4435    #[test]
4436    fn remap_focus_files_skips_paths_outside_from_root() {
4437        let from_root = PathBuf::from("/repo/apps/web");
4438        let to_root = PathBuf::from("/wt/apps/web");
4439        let mut focus = FxHashSet::default();
4440        focus.insert(PathBuf::from("/repo/apps/web/src/in.ts"));
4441        focus.insert(PathBuf::from("/repo/services/api/src/out.ts"));
4442
4443        let remapped =
4444            remap_focus_files(&focus, &from_root, &to_root).expect("partial map should succeed");
4445
4446        assert_eq!(remapped.len(), 1);
4447        assert!(remapped.contains(&PathBuf::from("/wt/apps/web/src/in.ts")));
4448    }
4449
4450    #[test]
4451    fn remap_focus_files_returns_none_when_no_paths_map() {
4452        let from_root = PathBuf::from("/repo/apps/web");
4453        let to_root = PathBuf::from("/wt/apps/web");
4454        let mut focus = FxHashSet::default();
4455        focus.insert(PathBuf::from("/elsewhere/foo.ts"));
4456
4457        let remapped = remap_focus_files(&focus, &from_root, &to_root);
4458        assert!(
4459            remapped.is_none(),
4460            "remap should return None when no paths can be mapped, falling caller back to full corpus"
4461        );
4462    }
4463
4464    #[test]
4465    fn remap_cache_dir_moves_project_local_cache_to_base_worktree() {
4466        let tmp = tempfile::TempDir::new().expect("temp dir should be created");
4467        let current_root = tmp.path().join("repo");
4468        let base_root = tmp.path().join("fallow-base");
4469        let cache_dir = current_root.join(".cache").join("fallow");
4470
4471        let remapped = remap_cache_dir_for_base_worktree(&current_root, &base_root, &cache_dir);
4472
4473        assert_eq!(remapped, base_root.join(".cache").join("fallow"));
4474    }
4475
4476    #[test]
4477    fn remap_cache_dir_keeps_external_absolute_cache_shared() {
4478        let tmp = tempfile::TempDir::new().expect("temp dir should be created");
4479        let current_root = tmp.path().join("repo");
4480        let base_root = tmp.path().join("fallow-base");
4481        let cache_dir = tmp.path().join("shared").join("fallow-cache");
4482
4483        let remapped = remap_cache_dir_for_base_worktree(&current_root, &base_root, &cache_dir);
4484
4485        assert_eq!(remapped, cache_dir);
4486    }
4487
4488    #[test]
4489    fn audit_gate_new_only_inherits_pre_existing_duplicates_in_focused_files() {
4490        let tmp = tempfile::TempDir::new().expect("temp dir should be created");
4491        let root_buf = tmp
4492            .path()
4493            .canonicalize()
4494            .expect("temp root should canonicalize");
4495        let root = root_buf.as_path();
4496        fs::create_dir_all(root.join("src")).expect("src dir should be created");
4497        fs::write(
4498            root.join("package.json"),
4499            r#"{"name":"audit-newonly-inherit","main":"src/changed.ts"}"#,
4500        )
4501        .expect("package.json should be written");
4502        fs::write(
4503            root.join(".fallowrc.json"),
4504            r#"{"duplicates":{"minTokens":10,"minLines":3,"mode":"strict"}}"#,
4505        )
4506        .expect("config should be written");
4507
4508        let dup_block = "export function processItems(input: number[]): number[] {\n  const doubled = input.map((value) => value * 2);\n  const filtered = doubled.filter((value) => value > 0);\n  const summed = filtered.reduce((acc, value) => acc + value, 0);\n  const shifted = summed + 10;\n  const scaled = shifted * 3;\n  const rounded = Math.round(scaled / 7);\n  return [rounded, scaled, summed];\n}\n";
4509        fs::write(root.join("src/changed.ts"), dup_block).expect("changed should be written");
4510        fs::write(root.join("src/peer.ts"), dup_block).expect("peer should be written");
4511
4512        git(root, &["init", "-b", "main"]);
4513        git(root, &["add", "."]);
4514        git(
4515            root,
4516            &["-c", "commit.gpgsign=false", "commit", "-m", "initial"],
4517        );
4518        fs::write(
4519            root.join("src/changed.ts"),
4520            format!("{dup_block}// touched\n"),
4521        )
4522        .expect("changed file should be modified");
4523        git(root, &["add", "."]);
4524        git(
4525            root,
4526            &["-c", "commit.gpgsign=false", "commit", "-m", "touch"],
4527        );
4528
4529        let config_path = None;
4530        let cache_root = root.join(".fallow");
4531        let opts = AuditOptions {
4532            root,
4533            cache_dir: &cache_root,
4534            config_path: &config_path,
4535            output: OutputFormat::Json,
4536            no_cache: true,
4537            threads: 1,
4538            quiet: true,
4539            changed_since: Some("HEAD~1"),
4540            production: false,
4541            production_dead_code: None,
4542            production_health: None,
4543            production_dupes: None,
4544            workspace: None,
4545            changed_workspaces: None,
4546            explain: false,
4547            explain_skipped: false,
4548            performance: false,
4549            group_by: None,
4550            dead_code_baseline: None,
4551            health_baseline: None,
4552            dupes_baseline: None,
4553            max_crap: None,
4554            coverage: None,
4555            coverage_root: None,
4556            gate: AuditGate::NewOnly,
4557            include_entry_exports: false,
4558            runtime_coverage: None,
4559            min_invocations_hot: 100,
4560            brief: false,
4561            max_decisions: 4,
4562            walkthrough_guide: false,
4563            walkthrough_file: None,
4564            show_deprioritized: false,
4565        };
4566
4567        let result = execute_audit(&opts).expect("audit should execute");
4568        assert!(
4569            result.base_snapshot_skipped,
4570            "comment-only JS/TS diffs should reuse current keys as the base snapshot"
4571        );
4572        let dupes_report = &result.dupes.as_ref().expect("dupes should run").report;
4573        assert!(
4574            !dupes_report.clone_groups.is_empty(),
4575            "current run should detect the pre-existing duplicate"
4576        );
4577        assert_eq!(
4578            result.attribution.duplication_introduced, 0,
4579            "pre-existing duplicate must not be classified as introduced; \
4580             attribution = {:?}",
4581            result.attribution
4582        );
4583        assert!(
4584            result.attribution.duplication_inherited > 0,
4585            "pre-existing duplicate must be classified as inherited; \
4586             attribution = {:?}",
4587            result.attribution
4588        );
4589    }
4590
4591    #[test]
4592    #[expect(
4593        clippy::too_many_lines,
4594        reason = "test fixture; linear setup/assert, length is not a maintainability concern"
4595    )]
4596    fn audit_base_preserves_tsconfig_paths_when_extends_is_in_untracked_node_modules() {
4597        let tmp = tempfile::TempDir::new().expect("temp dir should be created");
4598        let root = tmp.path();
4599        fs::create_dir_all(root.join("src/screens")).expect("src dir should be created");
4600        fs::create_dir_all(root.join("node_modules/@react-native/typescript-config"))
4601            .expect("node_modules config dir should be created");
4602        fs::write(root.join(".gitignore"), "node_modules/\n").expect("gitignore should be written");
4603        fs::write(
4604            root.join("package.json"),
4605            r#"{
4606                "name": "audit-react-native-tsconfig-base",
4607                "private": true,
4608                "main": "src/App.tsx",
4609                "dependencies": {
4610                    "react-native": "0.80.0"
4611                }
4612            }"#,
4613        )
4614        .expect("package.json should be written");
4615        fs::write(
4616            root.join("tsconfig.json"),
4617            r#"{
4618                "extends": "./node_modules/@react-native/typescript-config/tsconfig.json",
4619                "compilerOptions": {
4620                    "baseUrl": ".",
4621                    "paths": {
4622                        "@/*": ["src/*"]
4623                    }
4624                },
4625                "include": ["src/**/*"]
4626            }"#,
4627        )
4628        .expect("tsconfig should be written");
4629        fs::write(
4630            root.join("node_modules/@react-native/typescript-config/tsconfig.json"),
4631            r#"{"compilerOptions":{"strict":true,"jsx":"react-jsx"}}"#,
4632        )
4633        .expect("react native tsconfig should be written");
4634        fs::write(
4635            root.join("src/App.tsx"),
4636            r#"import { homeTitle } from "@/screens/Home";
4637
4638export function App() {
4639  return homeTitle;
4640}
4641"#,
4642        )
4643        .expect("app should be written");
4644        fs::write(
4645            root.join("src/screens/Home.ts"),
4646            r#"export const homeTitle = "home";
4647"#,
4648        )
4649        .expect("home should be written");
4650
4651        git(root, &["init", "-b", "main"]);
4652        git(root, &["add", "."]);
4653        git(
4654            root,
4655            &["-c", "commit.gpgsign=false", "commit", "-m", "initial"],
4656        );
4657        fs::write(
4658            root.join("src/App.tsx"),
4659            r#"import { homeTitle } from "@/screens/Home";
4660
4661export function App() {
4662  return homeTitle.toUpperCase();
4663}
4664"#,
4665        )
4666        .expect("app should be modified");
4667
4668        let config_path = None;
4669        let cache_root = root.join(".fallow");
4670        let opts = AuditOptions {
4671            root,
4672            cache_dir: &cache_root,
4673            config_path: &config_path,
4674            output: OutputFormat::Json,
4675            no_cache: true,
4676            threads: 1,
4677            quiet: true,
4678            changed_since: Some("HEAD"),
4679            production: false,
4680            production_dead_code: None,
4681            production_health: None,
4682            production_dupes: None,
4683            workspace: None,
4684            changed_workspaces: None,
4685            explain: false,
4686            explain_skipped: false,
4687            performance: false,
4688            group_by: None,
4689            dead_code_baseline: None,
4690            health_baseline: None,
4691            dupes_baseline: None,
4692            max_crap: None,
4693            coverage: None,
4694            coverage_root: None,
4695            gate: AuditGate::NewOnly,
4696            include_entry_exports: false,
4697            runtime_coverage: None,
4698            min_invocations_hot: 100,
4699            brief: false,
4700            max_decisions: 4,
4701            walkthrough_guide: false,
4702            walkthrough_file: None,
4703            show_deprioritized: false,
4704        };
4705
4706        let result = execute_audit(&opts).expect("audit should execute");
4707        assert!(
4708            !result.base_snapshot_skipped,
4709            "source diffs should run a real base snapshot"
4710        );
4711        let base = result
4712            .base_snapshot
4713            .as_ref()
4714            .expect("base snapshot should run");
4715        assert!(
4716            !base
4717                .dead_code
4718                .contains("unresolved-import:src/App.tsx:@/screens/Home"),
4719            "base audit must keep local @/* tsconfig aliases when extends points into ignored node_modules: {:?}",
4720            base.dead_code
4721        );
4722        assert!(
4723            !base.dead_code.contains("unused-file:src/screens/Home.ts"),
4724            "alias target should stay reachable in the base worktree: {:?}",
4725            base.dead_code
4726        );
4727        let check = result.check.as_ref().expect("dead-code audit should run");
4728        assert!(
4729            check.results.unresolved_imports.is_empty(),
4730            "HEAD audit should also resolve @/* aliases: {:?}",
4731            check.results.unresolved_imports
4732        );
4733    }
4734
4735    #[test]
4736    #[expect(
4737        clippy::too_many_lines,
4738        reason = "test fixture; linear setup/assert, length is not a maintainability concern"
4739    )]
4740    fn audit_base_preserves_subdirectory_root_resolution() {
4741        let tmp = tempfile::TempDir::new().expect("temp dir should be created");
4742        let repo = tmp.path().join("repo");
4743        let root = repo.join("apps/mobile");
4744        fs::create_dir_all(root.join("src/screens")).expect("src dir should be created");
4745        fs::create_dir_all(root.join("node_modules/@react-native/typescript-config"))
4746            .expect("node_modules config dir should be created");
4747        fs::write(repo.join(".gitignore"), "apps/mobile/node_modules/\n")
4748            .expect("gitignore should be written");
4749        fs::write(
4750            root.join("package.json"),
4751            r#"{
4752                "name": "audit-subdir-react-native-tsconfig-base",
4753                "private": true,
4754                "main": "src/App.tsx",
4755                "dependencies": {
4756                    "react-native": "0.80.0"
4757                }
4758            }"#,
4759        )
4760        .expect("package.json should be written");
4761        fs::write(
4762            root.join("tsconfig.json"),
4763            r#"{
4764                "extends": "./node_modules/@react-native/typescript-config/tsconfig.json",
4765                "compilerOptions": {
4766                    "baseUrl": ".",
4767                    "paths": {
4768                        "@/*": ["src/*"]
4769                    }
4770                },
4771                "include": ["src/**/*"]
4772            }"#,
4773        )
4774        .expect("tsconfig should be written");
4775        fs::write(
4776            root.join("node_modules/@react-native/typescript-config/tsconfig.json"),
4777            r#"{"compilerOptions":{"strict":true,"jsx":"react-jsx"}}"#,
4778        )
4779        .expect("react native tsconfig should be written");
4780        fs::write(
4781            root.join("src/App.tsx"),
4782            r#"import { homeTitle } from "@/screens/Home";
4783
4784export function App() {
4785  return homeTitle;
4786}
4787"#,
4788        )
4789        .expect("app should be written");
4790        fs::write(
4791            root.join("src/screens/Home.ts"),
4792            r#"export const homeTitle = "home";
4793"#,
4794        )
4795        .expect("home should be written");
4796
4797        git(&repo, &["init", "-b", "main"]);
4798        git(&repo, &["add", "."]);
4799        git(
4800            &repo,
4801            &["-c", "commit.gpgsign=false", "commit", "-m", "initial"],
4802        );
4803        fs::write(
4804            root.join("src/App.tsx"),
4805            r#"import { homeTitle } from "@/screens/Home";
4806
4807export function App() {
4808  return homeTitle.toUpperCase();
4809}
4810"#,
4811        )
4812        .expect("app should be modified");
4813
4814        let config_path = None;
4815        let cache_root = root.join(".fallow");
4816        let opts = AuditOptions {
4817            root: &root,
4818            cache_dir: &cache_root,
4819            config_path: &config_path,
4820            output: OutputFormat::Json,
4821            no_cache: true,
4822            threads: 1,
4823            quiet: true,
4824            changed_since: Some("HEAD"),
4825            production: false,
4826            production_dead_code: None,
4827            production_health: None,
4828            production_dupes: None,
4829            workspace: None,
4830            changed_workspaces: None,
4831            explain: false,
4832            explain_skipped: false,
4833            performance: false,
4834            group_by: None,
4835            dead_code_baseline: None,
4836            health_baseline: None,
4837            dupes_baseline: None,
4838            max_crap: None,
4839            coverage: None,
4840            coverage_root: None,
4841            gate: AuditGate::NewOnly,
4842            include_entry_exports: false,
4843            runtime_coverage: None,
4844            min_invocations_hot: 100,
4845            brief: false,
4846            max_decisions: 4,
4847            walkthrough_guide: false,
4848            walkthrough_file: None,
4849            show_deprioritized: false,
4850        };
4851
4852        let result = execute_audit(&opts).expect("audit should execute");
4853        assert!(
4854            !result.base_snapshot_skipped,
4855            "source diffs should run a real base snapshot"
4856        );
4857        let base = result
4858            .base_snapshot
4859            .as_ref()
4860            .expect("base snapshot should run");
4861        assert!(
4862            !base
4863                .dead_code
4864                .contains("unresolved-import:src/App.tsx:@/screens/Home"),
4865            "base audit should analyze from the app subdirectory, not the repo root: {:?}",
4866            base.dead_code
4867        );
4868        assert!(
4869            !base.dead_code.contains("unused-file:src/screens/Home.ts"),
4870            "subdirectory base audit should keep alias targets reachable: {:?}",
4871            base.dead_code
4872        );
4873    }
4874
4875    #[test]
4876    fn audit_base_uses_new_explicit_config_without_hard_failure() {
4877        let tmp = tempfile::TempDir::new().expect("temp dir should be created");
4878        let root = tmp.path();
4879        fs::create_dir_all(root.join("src")).expect("src dir should be created");
4880        fs::write(
4881            root.join("package.json"),
4882            r#"{"name":"audit-new-config","main":"src/index.ts"}"#,
4883        )
4884        .expect("package.json should be written");
4885        fs::write(root.join("src/index.ts"), "export const used = 1;\n")
4886            .expect("index should be written");
4887
4888        git(root, &["init", "-b", "main"]);
4889        git(root, &["add", "."]);
4890        git(
4891            root,
4892            &["-c", "commit.gpgsign=false", "commit", "-m", "initial"],
4893        );
4894
4895        let explicit_config = root.join(".fallowrc.json");
4896        fs::write(&explicit_config, r#"{"rules":{"unused-files":"error"}}"#)
4897            .expect("new config should be written");
4898        fs::write(root.join("src/index.ts"), "export const used = 2;\n")
4899            .expect("index should be modified");
4900
4901        let config_path = Some(explicit_config);
4902        let cache_root = root.join(".fallow");
4903        let opts = AuditOptions {
4904            root,
4905            cache_dir: &cache_root,
4906            config_path: &config_path,
4907            output: OutputFormat::Json,
4908            no_cache: true,
4909            threads: 1,
4910            quiet: true,
4911            changed_since: Some("HEAD"),
4912            production: false,
4913            production_dead_code: None,
4914            production_health: None,
4915            production_dupes: None,
4916            workspace: None,
4917            changed_workspaces: None,
4918            explain: false,
4919            explain_skipped: false,
4920            performance: false,
4921            group_by: None,
4922            dead_code_baseline: None,
4923            health_baseline: None,
4924            dupes_baseline: None,
4925            max_crap: None,
4926            coverage: None,
4927            coverage_root: None,
4928            gate: AuditGate::NewOnly,
4929            include_entry_exports: false,
4930            runtime_coverage: None,
4931            min_invocations_hot: 100,
4932            brief: false,
4933            max_decisions: 4,
4934            walkthrough_guide: false,
4935            walkthrough_file: None,
4936            show_deprioritized: false,
4937        };
4938
4939        let result = execute_audit(&opts).expect("audit should execute with a new explicit config");
4940        assert!(
4941            result.base_snapshot.is_some(),
4942            "base snapshot should use the current explicit config even when the base commit lacks it"
4943        );
4944    }
4945
4946    #[test]
4947    fn audit_base_uses_current_discovered_config_for_attribution() {
4948        let tmp = tempfile::TempDir::new().expect("temp dir should be created");
4949        let root = tmp.path();
4950        fs::create_dir_all(root.join("src")).expect("src dir should be created");
4951        fs::write(
4952            root.join("package.json"),
4953            r#"{"name":"audit-current-config","main":"src/index.ts","dependencies":{"left-pad":"1.3.0"}}"#,
4954        )
4955        .expect("package.json should be written");
4956        fs::write(
4957            root.join(".fallowrc.json"),
4958            r#"{"rules":{"unused-dependencies":"off"}}"#,
4959        )
4960        .expect("base config should be written");
4961        fs::write(root.join("src/index.ts"), "export const used = 1;\n")
4962            .expect("index should be written");
4963
4964        git(root, &["init", "-b", "main"]);
4965        git(root, &["add", "."]);
4966        git(
4967            root,
4968            &["-c", "commit.gpgsign=false", "commit", "-m", "initial"],
4969        );
4970
4971        fs::write(
4972            root.join(".fallowrc.json"),
4973            r#"{"rules":{"unused-dependencies":"error"}}"#,
4974        )
4975        .expect("current config should be written");
4976        fs::write(
4977            root.join("package.json"),
4978            r#"{"name":"audit-current-config","main":"src/index.ts","dependencies":{"left-pad":"1.3.1"}}"#,
4979        )
4980        .expect("package.json should be touched");
4981
4982        let config_path = None;
4983        let cache_root = root.join(".fallow");
4984        let opts = AuditOptions {
4985            root,
4986            cache_dir: &cache_root,
4987            config_path: &config_path,
4988            output: OutputFormat::Json,
4989            no_cache: true,
4990            threads: 1,
4991            quiet: true,
4992            changed_since: Some("HEAD"),
4993            production: false,
4994            production_dead_code: None,
4995            production_health: None,
4996            production_dupes: None,
4997            workspace: None,
4998            changed_workspaces: None,
4999            explain: false,
5000            explain_skipped: false,
5001            performance: false,
5002            group_by: None,
5003            dead_code_baseline: None,
5004            health_baseline: None,
5005            dupes_baseline: None,
5006            max_crap: None,
5007            coverage: None,
5008            coverage_root: None,
5009            gate: AuditGate::NewOnly,
5010            include_entry_exports: false,
5011            runtime_coverage: None,
5012            min_invocations_hot: 100,
5013            brief: false,
5014            max_decisions: 4,
5015            walkthrough_guide: false,
5016            walkthrough_file: None,
5017            show_deprioritized: false,
5018        };
5019
5020        let result = execute_audit(&opts).expect("audit should execute");
5021        assert_eq!(
5022            result.attribution.dead_code_introduced, 0,
5023            "enabling a rule should not make pre-existing changed-file findings look introduced: {:?}",
5024            result.attribution
5025        );
5026        assert!(
5027            result.attribution.dead_code_inherited > 0,
5028            "pre-existing changed-file findings should be classified as inherited: {:?}",
5029            result.attribution
5030        );
5031    }
5032
5033    #[test]
5034    fn audit_base_current_config_attribution_survives_cache_hit() {
5035        let tmp = tempfile::TempDir::new().expect("temp dir should be created");
5036        let root = tmp.path();
5037        fs::create_dir_all(root.join("src")).expect("src dir should be created");
5038        fs::write(
5039            root.join("package.json"),
5040            r#"{"name":"audit-current-config-cache","main":"src/index.ts","dependencies":{"left-pad":"1.3.0"}}"#,
5041        )
5042        .expect("package.json should be written");
5043        fs::write(
5044            root.join(".fallowrc.json"),
5045            r#"{"rules":{"unused-dependencies":"off"}}"#,
5046        )
5047        .expect("base config should be written");
5048        fs::write(root.join("src/index.ts"), "export const used = 1;\n")
5049            .expect("index should be written");
5050
5051        git(root, &["init", "-b", "main"]);
5052        git(root, &["add", "."]);
5053        git(
5054            root,
5055            &["-c", "commit.gpgsign=false", "commit", "-m", "initial"],
5056        );
5057
5058        fs::write(
5059            root.join(".fallowrc.json"),
5060            r#"{"rules":{"unused-dependencies":"error"}}"#,
5061        )
5062        .expect("current config should be written");
5063        fs::write(
5064            root.join("package.json"),
5065            r#"{"name":"audit-current-config-cache","main":"src/index.ts","dependencies":{"left-pad":"1.3.1"}}"#,
5066        )
5067        .expect("package.json should be touched");
5068
5069        let config_path = None;
5070        let cache_root = root.join(".fallow");
5071        let opts = AuditOptions {
5072            root,
5073            cache_dir: &cache_root,
5074            config_path: &config_path,
5075            output: OutputFormat::Json,
5076            no_cache: false,
5077            threads: 1,
5078            quiet: true,
5079            changed_since: Some("HEAD"),
5080            production: false,
5081            production_dead_code: None,
5082            production_health: None,
5083            production_dupes: None,
5084            workspace: None,
5085            changed_workspaces: None,
5086            explain: false,
5087            explain_skipped: false,
5088            performance: false,
5089            group_by: None,
5090            dead_code_baseline: None,
5091            health_baseline: None,
5092            dupes_baseline: None,
5093            max_crap: None,
5094            coverage: None,
5095            coverage_root: None,
5096            gate: AuditGate::NewOnly,
5097            include_entry_exports: false,
5098            runtime_coverage: None,
5099            min_invocations_hot: 100,
5100            brief: false,
5101            max_decisions: 4,
5102            walkthrough_guide: false,
5103            walkthrough_file: None,
5104            show_deprioritized: false,
5105        };
5106
5107        let first = execute_audit(&opts).expect("first audit should execute");
5108        assert_eq!(
5109            first.attribution.dead_code_introduced, 0,
5110            "first audit should classify pre-existing findings as inherited: {:?}",
5111            first.attribution
5112        );
5113
5114        let changed_files =
5115            crate::check::get_changed_files(root, "HEAD").expect("changed files should resolve");
5116        let key = audit_base_snapshot_cache_key(&opts, "HEAD", &changed_files)
5117            .expect("cache key should compute")
5118            .expect("cache key should exist");
5119        assert!(
5120            load_cached_base_snapshot(&opts, &key).is_some(),
5121            "first audit should store a reusable base snapshot"
5122        );
5123
5124        let second = execute_audit(&opts).expect("second audit should execute");
5125        assert_eq!(
5126            second.attribution.dead_code_introduced, 0,
5127            "cache hit should keep current-config attribution stable: {:?}",
5128            second.attribution
5129        );
5130        assert!(
5131            second.attribution.dead_code_inherited > 0,
5132            "cache hit should preserve inherited base findings: {:?}",
5133            second.attribution
5134        );
5135    }
5136
5137    #[test]
5138    fn audit_dupes_only_materializes_groups_touching_changed_files() {
5139        let tmp = tempfile::TempDir::new().expect("temp dir should be created");
5140        let root_path = tmp
5141            .path()
5142            .canonicalize()
5143            .expect("temp root should canonicalize");
5144        let root = root_path.as_path();
5145        fs::create_dir_all(root.join("src")).expect("src dir should be created");
5146        fs::write(
5147            root.join("package.json"),
5148            r#"{"name":"audit-dupes-focus","main":"src/changed.ts"}"#,
5149        )
5150        .expect("package.json should be written");
5151        fs::write(
5152            root.join(".fallowrc.json"),
5153            r#"{"duplicates":{"minTokens":5,"minLines":2,"mode":"strict"}}"#,
5154        )
5155        .expect("config should be written");
5156
5157        let focused_code = "export function focused(input: number): number {\n  const doubled = input * 2;\n  const shifted = doubled + 10;\n  return shifted / 2;\n}\n";
5158        let untouched_code = "export function untouched(input: string): string {\n  const lowered = input.toLowerCase();\n  const padded = lowered.padStart(10, \"x\");\n  return padded.slice(0, 8);\n}\n";
5159        fs::write(root.join("src/changed.ts"), focused_code).expect("changed should be written");
5160        fs::write(root.join("src/focused-copy.ts"), focused_code)
5161            .expect("focused copy should be written");
5162        fs::write(root.join("src/untouched-a.ts"), untouched_code)
5163            .expect("untouched a should be written");
5164        fs::write(root.join("src/untouched-b.ts"), untouched_code)
5165            .expect("untouched b should be written");
5166
5167        git(root, &["init", "-b", "main"]);
5168        git(root, &["add", "."]);
5169        git(
5170            root,
5171            &["-c", "commit.gpgsign=false", "commit", "-m", "initial"],
5172        );
5173        fs::write(
5174            root.join("src/changed.ts"),
5175            format!("{focused_code}export const changedMarker = true;\n"),
5176        )
5177        .expect("changed file should be modified");
5178
5179        let config_path = None;
5180        let cache_root = root.join(".fallow");
5181        let opts = AuditOptions {
5182            root,
5183            cache_dir: &cache_root,
5184            config_path: &config_path,
5185            output: OutputFormat::Json,
5186            no_cache: true,
5187            threads: 1,
5188            quiet: true,
5189            changed_since: Some("HEAD"),
5190            production: false,
5191            production_dead_code: None,
5192            production_health: None,
5193            production_dupes: None,
5194            workspace: None,
5195            changed_workspaces: None,
5196            explain: false,
5197            explain_skipped: false,
5198            performance: false,
5199            group_by: None,
5200            dead_code_baseline: None,
5201            health_baseline: None,
5202            dupes_baseline: None,
5203            max_crap: None,
5204            coverage: None,
5205            coverage_root: None,
5206            gate: AuditGate::All,
5207            include_entry_exports: false,
5208            runtime_coverage: None,
5209            min_invocations_hot: 100,
5210            brief: false,
5211            max_decisions: 4,
5212            walkthrough_guide: false,
5213            walkthrough_file: None,
5214            show_deprioritized: false,
5215        };
5216
5217        let result = execute_audit(&opts).expect("audit should execute");
5218        let dupes = result.dupes.expect("dupes should run");
5219        let changed_path = root.join("src/changed.ts");
5220
5221        assert!(
5222            !dupes.report.clone_groups.is_empty(),
5223            "changed file should still match unchanged duplicate code"
5224        );
5225        assert!(dupes.report.clone_groups.iter().all(|group| {
5226            group
5227                .instances
5228                .iter()
5229                .any(|instance| instance.file == changed_path)
5230        }));
5231    }
5232
5233    // ── Unit tests for js_ts_tokens_equivalent, is_analysis_input, is_non_behavioral_doc ──
5234
5235    #[test]
5236    fn tokens_equivalent_whitespace_only() {
5237        // Reformatting (indentation, blank lines) must not change token identity.
5238        let a = "export const x = 1;\nexport const y = 2;\n";
5239        let b = "export const x = 1;\n\n\nexport const y = 2;\n";
5240        assert!(
5241            js_ts_tokens_equivalent(Path::new("a.ts"), a, b),
5242            "whitespace-only change must be treated as equivalent"
5243        );
5244    }
5245
5246    #[test]
5247    fn tokens_equivalent_comment_only_change() {
5248        // Comments do not produce tokens; adding or removing a comment should be
5249        // treated as equivalent by the tokenizer.
5250        let a = "export const x = 1;\n";
5251        let b = "// note\nexport const x = 1;\n";
5252        assert!(
5253            js_ts_tokens_equivalent(Path::new("a.ts"), a, b),
5254            "comment-only change must be treated as equivalent (comments emit no tokens)"
5255        );
5256    }
5257
5258    #[test]
5259    fn tokens_equivalent_identifier_rename_is_not_equivalent() {
5260        // Identifier carries its text payload; a rename must not be reusable.
5261        let a = "export const a = 1;\n";
5262        let b = "export const b = 1;\n";
5263        assert!(
5264            !js_ts_tokens_equivalent(Path::new("a.ts"), a, b),
5265            "identifier rename must be treated as non-equivalent"
5266        );
5267    }
5268
5269    #[test]
5270    fn tokens_equivalent_string_literal_change_is_not_equivalent() {
5271        // StringLiteral carries its text payload; a changed import path must not be reusable.
5272        let a = r#"import x from "./a";"#;
5273        let b = r#"import x from "./b";"#;
5274        assert!(
5275            !js_ts_tokens_equivalent(Path::new("a.ts"), a, b),
5276            "string-literal change must be treated as non-equivalent"
5277        );
5278    }
5279
5280    #[test]
5281    fn tokens_equivalent_fallow_ignore_marker_forces_false() {
5282        // The guard fires before tokenization; even identical content containing the
5283        // marker must return false so suppression changes are never skipped.
5284        let code = "// fallow-ignore-next-line unused-exports\nexport const x = 1;\n";
5285        assert!(
5286            !js_ts_tokens_equivalent(Path::new("a.ts"), code, code),
5287            "fallow-ignore marker in either side must force false"
5288        );
5289    }
5290
5291    #[test]
5292    fn tokens_equivalent_non_js_extension_is_false() {
5293        // The extension check fires before tokenization; CSS content cannot be reused.
5294        let a = ".foo { color: red; }\n";
5295        let b = ".foo {\n  color: red;\n}\n";
5296        assert!(
5297            !js_ts_tokens_equivalent(Path::new("styles.css"), a, b),
5298            "non-JS/TS extension must always return false"
5299        );
5300    }
5301
5302    /// KNOWN SOUNDNESS GAP: `TokenKind::TemplateLiteral` carries no payload
5303    /// (see `crates/core/src/duplicates/token_types.rs`), so a change to the
5304    /// content of a template literal is invisible to the tokenizer and is
5305    /// treated as equivalent. This is safe for most template strings but
5306    /// unsound for dynamic `import(\`...\`)` patterns where the quasi prefix
5307    /// feeds module-resolution pattern edges. This test pins the current
5308    /// behavior. A follow-up fix should give `TemplateLiteral` a payload to
5309    /// close the gap.
5310    #[test]
5311    fn tokens_equivalent_template_literal_content_change_is_equivalent_known_gap() {
5312        let a = "const p = import(`./pages/${x}`);\n";
5313        let b = "const p = import(`./views/${x}`);\n";
5314        // KNOWN GAP: changing the quasi string of a template literal is NOT
5315        // detected as a behavioral change because TokenKind::TemplateLiteral
5316        // has no payload. Expected: true (equivalent), which is incorrect for
5317        // dynamic-import prefixes but documents the current reality.
5318        assert!(
5319            js_ts_tokens_equivalent(Path::new("a.ts"), a, b),
5320            "template-literal content change is CURRENTLY treated as equivalent (known gap)"
5321        );
5322    }
5323
5324    /// Companion to the template-literal gap test: a regex-literal content
5325    /// change is also invisible to the tokenizer.
5326    #[test]
5327    fn tokens_equivalent_regex_literal_content_change_is_equivalent_known_gap() {
5328        let a = "const re = /^foo/;\n";
5329        let b = "const re = /^bar/;\n";
5330        // KNOWN GAP: TokenKind::RegExpLiteral has no payload.
5331        assert!(
5332            js_ts_tokens_equivalent(Path::new("a.ts"), a, b),
5333            "regex-literal content change is CURRENTLY treated as equivalent (known gap)"
5334        );
5335    }
5336
5337    #[test]
5338    fn analysis_input_and_doc_classification() {
5339        // Analysis inputs: JS/TS variants and component formats are behavioral.
5340        assert!(is_analysis_input(Path::new("src/app.ts")));
5341        assert!(is_analysis_input(Path::new("src/app.tsx")));
5342        assert!(is_analysis_input(Path::new("src/app.js")));
5343        assert!(is_analysis_input(Path::new("src/app.jsx")));
5344        assert!(is_analysis_input(Path::new("src/app.mts")));
5345        assert!(is_analysis_input(Path::new("src/app.vue")));
5346        assert!(is_analysis_input(Path::new("src/styles.css")));
5347
5348        // Non-analysis inputs.
5349        assert!(!is_analysis_input(Path::new("README.md")));
5350        assert!(!is_analysis_input(Path::new("package.json")));
5351        assert!(!is_analysis_input(Path::new("image.png")));
5352
5353        // Non-behavioral docs.
5354        assert!(is_non_behavioral_doc(Path::new("README.md")));
5355        assert!(is_non_behavioral_doc(Path::new("CHANGELOG.txt")));
5356        assert!(is_non_behavioral_doc(Path::new("docs/guide.rst")));
5357        assert!(is_non_behavioral_doc(Path::new("docs/guide.adoc")));
5358
5359        // .json is neither an analysis input nor a non-behavioral doc, so the
5360        // predicate treats it as behavioral (can_reuse returns false for it).
5361        assert!(!is_analysis_input(Path::new("package.json")));
5362        assert!(!is_non_behavioral_doc(Path::new("package.json")));
5363    }
5364}