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