Skip to main content

fallow_cli/
audit.rs

1use std::collections::hash_map::DefaultHasher;
2use std::hash::{Hash, Hasher};
3use std::io::{IsTerminal, Write};
4use std::path::{Path, PathBuf};
5use std::process::{Command, ExitCode};
6use std::time::{Duration, Instant, SystemTime};
7
8use colored::Colorize;
9use fallow_config::{AuditConfig, AuditGate, OutputFormat};
10use fallow_core::git_env::clear_ambient_git_env;
11use rustc_hash::FxHashSet;
12use xxhash_rust::xxh3::xxh3_64;
13
14use crate::check::{CheckOptions, CheckResult, IssueFilters, TraceOptions};
15use crate::dupes::{DupesMode, DupesOptions, DupesResult};
16use crate::error::emit_error;
17use crate::health::{HealthOptions, HealthResult, SortBy};
18use crate::report;
19use crate::report::plural;
20
21// ── Types ────────────────────────────────────────────────────────
22
23const AUDIT_BASE_SNAPSHOT_CACHE_VERSION: u8 = 2;
24const MAX_AUDIT_BASE_SNAPSHOT_CACHE_SIZE: usize = 16 * 1024 * 1024;
25
26/// Verdict for the audit command.
27#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize)]
28#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
29#[serde(rename_all = "snake_case")]
30pub enum AuditVerdict {
31    /// No issues in changed files.
32    Pass,
33    /// Issues found, but all are warn-severity.
34    Warn,
35    /// Error-severity issues found in changed files.
36    Fail,
37}
38
39/// Per-category summary counts for the audit result.
40#[derive(Debug, Clone, serde::Serialize)]
41#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
42pub struct AuditSummary {
43    pub dead_code_issues: usize,
44    pub dead_code_has_errors: bool,
45    pub complexity_findings: usize,
46    pub max_cyclomatic: Option<u16>,
47    pub duplication_clone_groups: usize,
48}
49
50/// New-vs-inherited issue counts for audit.
51#[derive(Debug, Default, Clone, serde::Serialize)]
52#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
53pub struct AuditAttribution {
54    pub gate: AuditGate,
55    pub dead_code_introduced: usize,
56    pub dead_code_inherited: usize,
57    pub complexity_introduced: usize,
58    pub complexity_inherited: usize,
59    pub duplication_introduced: usize,
60    pub duplication_inherited: usize,
61}
62
63/// Full audit result containing verdict, summary, and sub-results.
64pub struct AuditResult {
65    pub verdict: AuditVerdict,
66    pub summary: AuditSummary,
67    pub attribution: AuditAttribution,
68    base_snapshot: Option<AuditKeySnapshot>,
69    pub base_snapshot_skipped: bool,
70    pub changed_files_count: usize,
71    /// Absolute paths of the files this run re-analyzed. Threaded into the
72    /// Fallow Impact per-finding attribution so the frontier diff knows which
73    /// files were authoritative this run.
74    pub changed_files: Vec<PathBuf>,
75    pub base_ref: String,
76    pub head_sha: Option<String>,
77    pub output: OutputFormat,
78    pub performance: bool,
79    pub check: Option<CheckResult>,
80    pub dupes: Option<DupesResult>,
81    pub health: Option<HealthResult>,
82    pub elapsed: Duration,
83}
84
85pub struct AuditOptions<'a> {
86    pub root: &'a std::path::Path,
87    pub config_path: &'a Option<std::path::PathBuf>,
88    pub output: OutputFormat,
89    pub no_cache: bool,
90    pub threads: usize,
91    pub quiet: bool,
92    pub changed_since: Option<&'a str>,
93    pub production: bool,
94    pub production_dead_code: Option<bool>,
95    pub production_health: Option<bool>,
96    pub production_dupes: Option<bool>,
97    pub workspace: Option<&'a [String]>,
98    pub changed_workspaces: Option<&'a str>,
99    pub explain: bool,
100    pub explain_skipped: bool,
101    pub performance: bool,
102    pub group_by: Option<crate::GroupBy>,
103    /// Baseline file for dead-code analysis (as produced by `fallow dead-code --save-baseline`).
104    pub dead_code_baseline: Option<&'a std::path::Path>,
105    /// Baseline file for health analysis (as produced by `fallow health --save-baseline`).
106    pub health_baseline: Option<&'a std::path::Path>,
107    /// Baseline file for duplication analysis (as produced by `fallow dupes --save-baseline`).
108    pub dupes_baseline: Option<&'a std::path::Path>,
109    /// Maximum CRAP score threshold (overrides `health.maxCrap` from config).
110    /// Functions meeting or exceeding this score cause audit to fail.
111    pub max_crap: Option<f64>,
112    /// Istanbul coverage input for accurate CRAP scoring in the health sub-pass.
113    pub coverage: Option<&'a std::path::Path>,
114    /// Prefix to strip from Istanbul source paths before rebasing to `root`.
115    pub coverage_root: Option<&'a std::path::Path>,
116    pub gate: AuditGate,
117    /// Report unused exports in entry files (forwarded to the dead-code sub-pass).
118    pub include_entry_exports: bool,
119    /// Paid runtime-coverage sidecar input (V8 directory, V8 JSON, or
120    /// Istanbul coverage map). Forwarded into the embedded health pass so
121    /// audit surfaces the `hot-path-touched` verdict alongside dead-code
122    /// and complexity findings without requiring a second `fallow health`
123    /// invocation in CI.
124    pub runtime_coverage: Option<&'a std::path::Path>,
125    /// Threshold for hot-path classification, forwarded to the sidecar.
126    pub min_invocations_hot: u64,
127    // `diff_file` was removed from this struct: audit now sources the
128    // parsed diff index from the process-wide cache in
129    // `crate::report::ci::diff_filter::shared_diff_index()`, populated
130    // by `main()`. The cache covers `--diff-file PATH`, `--diff-file -`,
131    // `--diff-stdin`, and the `$FALLOW_DIFF_FILE` env var.
132}
133
134// ── Auto-detect base branch ──────────────────────────────────────
135
136/// Try to determine the default branch for the repository.
137/// Priority: `git symbolic-ref refs/remotes/origin/HEAD` → `main` → `master`.
138/// Returns `None` if none of these exist.
139fn auto_detect_base_branch(root: &std::path::Path) -> Option<String> {
140    // Try symbolic-ref first (works when origin HEAD is set)
141    let mut symbolic_ref = std::process::Command::new("git");
142    symbolic_ref
143        .args(["symbolic-ref", "refs/remotes/origin/HEAD"])
144        .current_dir(root);
145    clear_ambient_git_env(&mut symbolic_ref);
146    if let Ok(output) = symbolic_ref.output()
147        && output.status.success()
148    {
149        let full_ref = String::from_utf8_lossy(&output.stdout).trim().to_string();
150        if let Some(branch) = full_ref.strip_prefix("refs/remotes/origin/") {
151            return Some(branch.to_string());
152        }
153    }
154
155    // Try main
156    let mut verify_main = std::process::Command::new("git");
157    verify_main
158        .args(["rev-parse", "--verify", "main"])
159        .current_dir(root);
160    clear_ambient_git_env(&mut verify_main);
161    if let Ok(output) = verify_main.output()
162        && output.status.success()
163    {
164        return Some("main".to_string());
165    }
166
167    // Try master
168    let mut verify_master = std::process::Command::new("git");
169    verify_master
170        .args(["rev-parse", "--verify", "master"])
171        .current_dir(root);
172    clear_ambient_git_env(&mut verify_master);
173    if let Ok(output) = verify_master.output()
174        && output.status.success()
175    {
176        return Some("master".to_string());
177    }
178
179    None
180}
181
182/// Get the short SHA of HEAD for the scope display line.
183fn get_head_sha(root: &std::path::Path) -> Option<String> {
184    let mut command = std::process::Command::new("git");
185    command
186        .args(["rev-parse", "--short", "HEAD"])
187        .current_dir(root);
188    clear_ambient_git_env(&mut command);
189    let output = command.output().ok()?;
190    if output.status.success() {
191        Some(String::from_utf8_lossy(&output.stdout).trim().to_string())
192    } else {
193        None
194    }
195}
196
197// ── Verdict computation ──────────────────────────────────────────
198
199fn compute_verdict(
200    check: Option<&CheckResult>,
201    dupes: Option<&DupesResult>,
202    health: Option<&HealthResult>,
203) -> AuditVerdict {
204    let mut has_errors = false;
205    let mut has_warnings = false;
206
207    // Dead code: use rules severity
208    if let Some(result) = check {
209        if crate::check::has_error_severity_issues(
210            &result.results,
211            &result.config.rules,
212            Some(&result.config),
213        ) {
214            has_errors = true;
215        } else if result.results.total_issues() > 0 {
216            has_warnings = true;
217        }
218    }
219
220    // Complexity: findings that exceeded configured thresholds are always errors.
221    // Health rules don't have a warn-severity concept — any finding above the
222    // threshold is a quality gate failure, matching `fallow health` exit code semantics.
223    if let Some(result) = health
224        && !result.report.findings.is_empty()
225    {
226        has_errors = true;
227    }
228
229    // Duplication: clone groups are warnings (unless threshold exceeded)
230    if let Some(result) = dupes
231        && !result.report.clone_groups.is_empty()
232    {
233        if result.threshold > 0.0 && result.report.stats.duplication_percentage > result.threshold {
234            has_errors = true;
235        } else {
236            has_warnings = true;
237        }
238    }
239
240    if has_errors {
241        AuditVerdict::Fail
242    } else if has_warnings {
243        AuditVerdict::Warn
244    } else {
245        AuditVerdict::Pass
246    }
247}
248
249fn build_summary(
250    check: Option<&CheckResult>,
251    dupes: Option<&DupesResult>,
252    health: Option<&HealthResult>,
253) -> AuditSummary {
254    let dead_code_issues = check.map_or(0, |r| r.results.total_issues());
255    let dead_code_has_errors = check.is_some_and(|r| {
256        crate::check::has_error_severity_issues(&r.results, &r.config.rules, Some(&r.config))
257    });
258    let complexity_findings = health.map_or(0, |r| r.report.findings.len());
259    let max_cyclomatic = health.and_then(|r| r.report.findings.iter().map(|f| f.cyclomatic).max());
260    let duplication_clone_groups = dupes.map_or(0, |r| r.report.clone_groups.len());
261
262    AuditSummary {
263        dead_code_issues,
264        dead_code_has_errors,
265        complexity_findings,
266        max_cyclomatic,
267        duplication_clone_groups,
268    }
269}
270
271fn compute_audit_attribution(
272    check: Option<&CheckResult>,
273    dupes: Option<&DupesResult>,
274    health: Option<&HealthResult>,
275    base: Option<&AuditKeySnapshot>,
276    gate: AuditGate,
277) -> AuditAttribution {
278    let dead_code = check
279        .map(|r| {
280            count_introduced(
281                &dead_code_keys(&r.results, &r.config.root),
282                base.map(|b| &b.dead_code),
283            )
284        })
285        .unwrap_or_default();
286    let complexity = health
287        .map(|r| {
288            count_introduced(
289                &health_keys(&r.report, &r.config.root),
290                base.map(|b| &b.health),
291            )
292        })
293        .unwrap_or_default();
294    let duplication = dupes
295        .map(|r| {
296            count_introduced(
297                &dupes_keys(&r.report, &r.config.root),
298                base.map(|b| &b.dupes),
299            )
300        })
301        .unwrap_or_default();
302
303    AuditAttribution {
304        gate,
305        dead_code_introduced: dead_code.0,
306        dead_code_inherited: dead_code.1,
307        complexity_introduced: complexity.0,
308        complexity_inherited: complexity.1,
309        duplication_introduced: duplication.0,
310        duplication_inherited: duplication.1,
311    }
312}
313
314fn compute_introduced_verdict(
315    check: Option<&CheckResult>,
316    dupes: Option<&DupesResult>,
317    health: Option<&HealthResult>,
318    base: Option<&AuditKeySnapshot>,
319) -> AuditVerdict {
320    let mut has_errors = false;
321    let mut has_warnings = false;
322
323    if let Some(result) = check {
324        let base_keys = base.map(|b| &b.dead_code);
325        let mut introduced = result.results.clone();
326        retain_introduced_dead_code(&mut introduced, &result.config.root, base_keys);
327        if crate::check::has_error_severity_issues(
328            &introduced,
329            &result.config.rules,
330            Some(&result.config),
331        ) {
332            has_errors = true;
333        } else if introduced.total_issues() > 0 {
334            has_warnings = true;
335        }
336    }
337
338    if let Some(result) = health {
339        let base_keys = base.map(|b| &b.health);
340        let introduced = result
341            .report
342            .findings
343            .iter()
344            .filter(|finding| {
345                !base_keys.is_some_and(|keys| {
346                    keys.contains(&health_finding_key(finding, &result.config.root))
347                })
348            })
349            .count();
350        if introduced > 0 {
351            has_errors = true;
352        }
353    }
354
355    if let Some(result) = dupes {
356        let base_keys = base.map(|b| &b.dupes);
357        let introduced = result
358            .report
359            .clone_groups
360            .iter()
361            .filter(|group| {
362                !base_keys
363                    .is_some_and(|keys| keys.contains(&dupe_group_key(group, &result.config.root)))
364            })
365            .count();
366        if introduced > 0 {
367            if result.threshold > 0.0
368                && result.report.stats.duplication_percentage > result.threshold
369            {
370                has_errors = true;
371            } else {
372                has_warnings = true;
373            }
374        }
375    }
376
377    if has_errors {
378        AuditVerdict::Fail
379    } else if has_warnings {
380        AuditVerdict::Warn
381    } else {
382        AuditVerdict::Pass
383    }
384}
385
386struct AuditKeySnapshot {
387    dead_code: FxHashSet<String>,
388    health: FxHashSet<String>,
389    dupes: FxHashSet<String>,
390}
391
392struct AuditBaseSnapshotCacheKey {
393    hash: u64,
394    base_sha: String,
395}
396
397#[derive(bitcode::Encode, bitcode::Decode)]
398struct CachedAuditKeySnapshot {
399    version: u8,
400    cli_version: String,
401    key_hash: u64,
402    base_sha: String,
403    dead_code: Vec<String>,
404    health: Vec<String>,
405    dupes: Vec<String>,
406}
407
408fn count_introduced(keys: &FxHashSet<String>, base: Option<&FxHashSet<String>>) -> (usize, usize) {
409    let Some(base) = base else {
410        return (0, 0);
411    };
412    keys.iter().fold((0, 0), |(introduced, inherited), key| {
413        if base.contains(key) {
414            (introduced, inherited + 1)
415        } else {
416            (introduced + 1, inherited)
417        }
418    })
419}
420
421fn sorted_keys(keys: &FxHashSet<String>) -> Vec<String> {
422    let mut keys: Vec<String> = keys.iter().cloned().collect();
423    keys.sort_unstable();
424    keys
425}
426
427fn snapshot_from_cached(cached: CachedAuditKeySnapshot) -> AuditKeySnapshot {
428    AuditKeySnapshot {
429        dead_code: cached.dead_code.into_iter().collect(),
430        health: cached.health.into_iter().collect(),
431        dupes: cached.dupes.into_iter().collect(),
432    }
433}
434
435fn cached_from_snapshot(
436    key: &AuditBaseSnapshotCacheKey,
437    snapshot: &AuditKeySnapshot,
438) -> CachedAuditKeySnapshot {
439    CachedAuditKeySnapshot {
440        version: AUDIT_BASE_SNAPSHOT_CACHE_VERSION,
441        cli_version: env!("CARGO_PKG_VERSION").to_string(),
442        key_hash: key.hash,
443        base_sha: key.base_sha.clone(),
444        dead_code: sorted_keys(&snapshot.dead_code),
445        health: sorted_keys(&snapshot.health),
446        dupes: sorted_keys(&snapshot.dupes),
447    }
448}
449
450fn audit_base_snapshot_cache_dir(root: &Path) -> PathBuf {
451    root.join(".fallow")
452        .join("cache")
453        .join(format!("audit-base-v{AUDIT_BASE_SNAPSHOT_CACHE_VERSION}"))
454}
455
456fn audit_base_snapshot_cache_file(root: &Path, key: &AuditBaseSnapshotCacheKey) -> PathBuf {
457    audit_base_snapshot_cache_dir(root).join(format!("{:016x}.bin", key.hash))
458}
459
460fn ensure_audit_base_snapshot_cache_dir(dir: &Path) -> Result<(), std::io::Error> {
461    std::fs::create_dir_all(dir)?;
462    let gitignore = dir.join(".gitignore");
463    if std::fs::read_to_string(&gitignore).ok().as_deref() != Some("*\n") {
464        std::fs::write(gitignore, "*\n")?;
465    }
466    Ok(())
467}
468
469fn load_cached_base_snapshot(
470    opts: &AuditOptions<'_>,
471    key: &AuditBaseSnapshotCacheKey,
472) -> Option<AuditKeySnapshot> {
473    let path = audit_base_snapshot_cache_file(opts.root, key);
474    let data = std::fs::read(path).ok()?;
475    if data.len() > MAX_AUDIT_BASE_SNAPSHOT_CACHE_SIZE {
476        return None;
477    }
478    let cached: CachedAuditKeySnapshot = bitcode::decode(&data).ok()?;
479    if cached.version != AUDIT_BASE_SNAPSHOT_CACHE_VERSION
480        || cached.cli_version != env!("CARGO_PKG_VERSION")
481        || cached.key_hash != key.hash
482        || cached.base_sha != key.base_sha
483    {
484        return None;
485    }
486    Some(snapshot_from_cached(cached))
487}
488
489fn save_cached_base_snapshot(
490    opts: &AuditOptions<'_>,
491    key: &AuditBaseSnapshotCacheKey,
492    snapshot: &AuditKeySnapshot,
493) {
494    let dir = audit_base_snapshot_cache_dir(opts.root);
495    if ensure_audit_base_snapshot_cache_dir(&dir).is_err() {
496        return;
497    }
498    let data = bitcode::encode(&cached_from_snapshot(key, snapshot));
499    let Ok(mut tmp) = tempfile::NamedTempFile::new_in(&dir) else {
500        return;
501    };
502    if tmp.write_all(&data).is_err() {
503        return;
504    }
505    let _ = tmp.persist(audit_base_snapshot_cache_file(opts.root, key));
506}
507
508fn git_rev_parse(root: &Path, rev: &str) -> Option<String> {
509    let mut command = Command::new("git");
510    command.args(["rev-parse", rev]).current_dir(root);
511    clear_ambient_git_env(&mut command);
512    let output = command.output().ok()?;
513    if !output.status.success() {
514        return None;
515    }
516    Some(String::from_utf8_lossy(&output.stdout).trim().to_string())
517}
518
519/// If fallow's process inherited any ambient git repo-state env vars (typical
520/// when invoked from a `pre-commit` / `pre-push` hook or a tool wrapping git),
521/// surface the most likely culprit so a user hitting an unexpected worktree
522/// failure can short-circuit the diagnosis. Returns `None` otherwise.
523fn ambient_git_env_hint() -> Option<String> {
524    use fallow_core::git_env::AMBIENT_GIT_ENV_VARS;
525    for var in AMBIENT_GIT_ENV_VARS {
526        if let Ok(value) = std::env::var(var)
527            && !value.is_empty()
528        {
529            return Some(format!(
530                "{var}={value} is set in the environment; if fallow is being \
531invoked from a git hook this can interfere with worktree operations. Re-run \
532with `env -u {var} fallow audit` to confirm."
533            ));
534        }
535    }
536    None
537}
538
539fn normalized_changed_files(root: &Path, changed_files: &FxHashSet<PathBuf>) -> Vec<String> {
540    let git_root = git_toplevel(root);
541    let mut files: Vec<String> = changed_files
542        .iter()
543        .map(|path| {
544            git_root
545                .as_ref()
546                .and_then(|root| path.strip_prefix(root).ok())
547                .unwrap_or(path)
548                .to_string_lossy()
549                .replace('\\', "/")
550        })
551        .collect();
552    files.sort_unstable();
553    files
554}
555
556fn config_file_fingerprint(opts: &AuditOptions<'_>) -> Result<serde_json::Value, ExitCode> {
557    let loaded = if let Some(path) = opts.config_path {
558        let config = fallow_config::FallowConfig::load(path).map_err(|e| {
559            emit_error(
560                &format!("failed to load config '{}': {e}", path.display()),
561                2,
562                opts.output,
563            )
564        })?;
565        Some((config, path.clone()))
566    } else {
567        fallow_config::FallowConfig::find_and_load(opts.root)
568            .map_err(|e| emit_error(&e, 2, opts.output))?
569    };
570
571    let Some((config, path)) = loaded else {
572        return Ok(serde_json::json!({
573            "path": null,
574            "resolved_hash": null,
575        }));
576    };
577    let bytes = serde_json::to_vec(&config).map_err(|e| {
578        emit_error(
579            &format!("failed to serialize resolved config for audit cache key: {e}"),
580            2,
581            opts.output,
582        )
583    })?;
584    Ok(serde_json::json!({
585        "path": path.to_string_lossy(),
586        "resolved_hash": format!("{:016x}", xxh3_64(&bytes)),
587    }))
588}
589
590fn coverage_file_fingerprint(path: &Path, project_root: &Path) -> serde_json::Value {
591    let resolved = crate::health::scoring::resolve_relative_to_root(path, Some(project_root));
592    let file_path = if resolved.is_dir() {
593        resolved.join("coverage-final.json")
594    } else {
595        resolved
596    };
597    match std::fs::read(&file_path) {
598        Ok(bytes) => serde_json::json!({
599            "path": path.to_string_lossy(),
600            "resolved_path": file_path.to_string_lossy(),
601            "content_hash": format!("{:016x}", xxh3_64(&bytes)),
602            "len": bytes.len(),
603        }),
604        Err(err) => serde_json::json!({
605            "path": path.to_string_lossy(),
606            "resolved_path": file_path.to_string_lossy(),
607            "error": err.kind().to_string(),
608        }),
609    }
610}
611
612fn audit_base_snapshot_cache_key(
613    opts: &AuditOptions<'_>,
614    base_ref: &str,
615    changed_files: &FxHashSet<PathBuf>,
616) -> Result<Option<AuditBaseSnapshotCacheKey>, ExitCode> {
617    if opts.no_cache {
618        return Ok(None);
619    }
620    let Some(base_sha) = git_rev_parse(opts.root, base_ref) else {
621        return Ok(None);
622    };
623    let config_file = config_file_fingerprint(opts)?;
624    let coverage_file = opts
625        .coverage
626        .map(|p| coverage_file_fingerprint(p, opts.root));
627    let payload = serde_json::json!({
628        "cache_version": AUDIT_BASE_SNAPSHOT_CACHE_VERSION,
629        "cli_version": env!("CARGO_PKG_VERSION"),
630        "base_sha": base_sha,
631        "config_file": config_file,
632        "changed_files": normalized_changed_files(opts.root, changed_files),
633        "production": opts.production,
634        "production_dead_code": opts.production_dead_code,
635        "production_health": opts.production_health,
636        "production_dupes": opts.production_dupes,
637        "workspace": opts.workspace,
638        "changed_workspaces": opts.changed_workspaces,
639        "group_by": opts.group_by.map(|g| format!("{g:?}")),
640        "include_entry_exports": opts.include_entry_exports,
641        "max_crap": opts.max_crap,
642        "coverage": coverage_file,
643        "coverage_root": opts.coverage_root.map(|p| p.to_string_lossy().to_string()),
644        "dead_code_baseline": opts.dead_code_baseline.map(|p| p.to_string_lossy().to_string()),
645        "health_baseline": opts.health_baseline.map(|p| p.to_string_lossy().to_string()),
646        "dupes_baseline": opts.dupes_baseline.map(|p| p.to_string_lossy().to_string()),
647    });
648    let bytes = serde_json::to_vec(&payload).map_err(|e| {
649        emit_error(
650            &format!("failed to build audit cache key: {e}"),
651            2,
652            opts.output,
653        )
654    })?;
655    Ok(Some(AuditBaseSnapshotCacheKey {
656        hash: xxh3_64(&bytes),
657        base_sha,
658    }))
659}
660
661fn compute_base_snapshot(
662    opts: &AuditOptions<'_>,
663    base_ref: &str,
664    changed_files: &FxHashSet<PathBuf>,
665    base_sha: Option<&str>,
666) -> Result<AuditKeySnapshot, ExitCode> {
667    let Some(worktree) = BaseWorktree::create(opts.root, base_ref, base_sha) else {
668        use std::fmt::Write as _;
669        let mut message =
670            format!("could not create a temporary worktree for base ref '{base_ref}'");
671        if let Some(hint) = ambient_git_env_hint() {
672            let _ = write!(message, "\n  hint: {hint}");
673        }
674        return Err(emit_error(&message, 2, opts.output));
675    };
676    let base_root = base_analysis_root(opts.root, worktree.path());
677    let current_config_path = opts
678        .config_path
679        .clone()
680        .or_else(|| fallow_config::FallowConfig::find_config_path(opts.root));
681    let base_opts = AuditOptions {
682        root: &base_root,
683        config_path: &current_config_path,
684        output: opts.output,
685        no_cache: opts.no_cache,
686        threads: opts.threads,
687        quiet: true,
688        changed_since: None,
689        production: opts.production,
690        production_dead_code: opts.production_dead_code,
691        production_health: opts.production_health,
692        production_dupes: opts.production_dupes,
693        workspace: opts.workspace,
694        changed_workspaces: None,
695        explain: false,
696        explain_skipped: false,
697        performance: false,
698        group_by: opts.group_by,
699        dead_code_baseline: None,
700        health_baseline: None,
701        dupes_baseline: None,
702        max_crap: opts.max_crap,
703        coverage: opts.coverage,
704        coverage_root: opts.coverage_root,
705        gate: AuditGate::All,
706        include_entry_exports: opts.include_entry_exports,
707        // Base-snapshot pass intentionally does NOT spawn the sidecar
708        // again or apply hot-path filtering: hot-path-touched is a
709        // PR-vs-HEAD signal, and the recursive base run is HEAD's
710        // baseline, so it has nothing to compare against. Suppressing
711        // here also avoids a duplicate license check + sidecar download
712        // cost on every audit run.
713        runtime_coverage: None,
714        min_invocations_hot: opts.min_invocations_hot,
715    };
716
717    let base_changed_files = remap_focus_files(changed_files, opts.root, &base_root);
718    let check_production = opts.production_dead_code.unwrap_or(opts.production);
719    let health_production = opts.production_health.unwrap_or(opts.production);
720    let share_dead_code_parse_with_health = check_production == health_production;
721
722    // Base-snapshot check and dupes share no mutable state. Running them
723    // concurrently keeps the expensive duplication pass overlapped with
724    // dead-code analysis; health then consumes check's retained parse when the
725    // production modes match, mirroring the HEAD-side audit pipeline.
726    let (check_res, dupes_res) = rayon::join(
727        || run_audit_check(&base_opts, None, share_dead_code_parse_with_health),
728        || run_audit_dupes(&base_opts, None, base_changed_files.as_ref(), None),
729    );
730    let mut check = check_res?;
731    let dupes = dupes_res?;
732    let shared_parse = if share_dead_code_parse_with_health {
733        check.as_mut().and_then(|r| r.shared_parse.take())
734    } else {
735        None
736    };
737    let health = run_audit_health(&base_opts, None, shared_parse)?;
738    if let Some(ref mut check) = check {
739        check.shared_parse = None;
740    }
741
742    Ok(AuditKeySnapshot {
743        dead_code: check.as_ref().map_or_else(FxHashSet::default, |r| {
744            dead_code_keys(&r.results, &r.config.root)
745        }),
746        health: health.as_ref().map_or_else(FxHashSet::default, |r| {
747            health_keys(&r.report, &r.config.root)
748        }),
749        dupes: dupes.as_ref().map_or_else(FxHashSet::default, |r| {
750            dupes_keys(&r.report, &r.config.root)
751        }),
752    })
753}
754
755fn base_analysis_root(current_root: &Path, base_worktree_root: &Path) -> PathBuf {
756    let Some(git_root) = git_toplevel(current_root) else {
757        return base_worktree_root.to_path_buf();
758    };
759    // `dunce::canonicalize` strips Windows `\\?\` verbatim prefix so this
760    // current_root matches `git_root` (also dunce-canonicalised above) when
761    // `strip_prefix` walks the component graph.
762    let current_root =
763        dunce::canonicalize(current_root).unwrap_or_else(|_| current_root.to_path_buf());
764    match current_root.strip_prefix(&git_root) {
765        Ok(relative) => base_worktree_root.join(relative),
766        Err(err) => {
767            tracing::warn!(
768                current_root = %current_root.display(),
769                git_root = %git_root.display(),
770                error = %err,
771                "Could not remap audit base root into the base worktree; falling back to worktree root"
772            );
773            base_worktree_root.to_path_buf()
774        }
775    }
776}
777
778fn current_keys_as_base_keys(
779    check: Option<&CheckResult>,
780    dupes: Option<&DupesResult>,
781    health: Option<&HealthResult>,
782) -> AuditKeySnapshot {
783    AuditKeySnapshot {
784        dead_code: check.as_ref().map_or_else(FxHashSet::default, |r| {
785            dead_code_keys(&r.results, &r.config.root)
786        }),
787        health: health.as_ref().map_or_else(FxHashSet::default, |r| {
788            health_keys(&r.report, &r.config.root)
789        }),
790        dupes: dupes.as_ref().map_or_else(FxHashSet::default, |r| {
791            dupes_keys(&r.report, &r.config.root)
792        }),
793    }
794}
795
796fn can_reuse_current_as_base(
797    opts: &AuditOptions<'_>,
798    base_ref: &str,
799    changed_files: &FxHashSet<PathBuf>,
800) -> bool {
801    let Some(git_root) = git_toplevel(opts.root) else {
802        return false;
803    };
804    // `try_get_changed_files` joins the canonical git toplevel onto each
805    // relative diff entry, so changed-file paths land canonical even when
806    // `opts.root` itself was passed un-canonical (typical in tests). Match
807    // against both forms so the cache-artifact check works in either case.
808    let cache_dir = opts.root.join(".fallow");
809    // `dunce::canonicalize` strips Windows `\\?\` verbatim prefix so the
810    // `starts_with` checks below compare against a shape that matches the
811    // changed_files paths (which also flow through dunce-canonicalised
812    // `resolve_git_toplevel`). On POSIX dunce is identical to std.
813    let canonical_cache_dir = dunce::canonicalize(&cache_dir).ok();
814    changed_files.iter().all(|path| {
815        if is_fallow_cache_artifact(path, &cache_dir, canonical_cache_dir.as_deref()) {
816            return true;
817        }
818        if !is_analysis_input(path) {
819            return is_non_behavioral_doc(path);
820        }
821        let Ok(current) = std::fs::read_to_string(path) else {
822            return false;
823        };
824        let Some(relative) = path.strip_prefix(&git_root).ok() else {
825            return false;
826        };
827        let Some(base) = git_show_file(opts.root, base_ref, relative) else {
828            return false;
829        };
830        if current == base {
831            return true;
832        }
833        js_ts_tokens_equivalent(path, &current, &base)
834    })
835}
836
837// `cache_dir` is the project-local cache root (`<opts.root>/.fallow`).
838// Anything under it is a fallow internal artifact (token cache, parse cache,
839// gitignore stubs) with no semantic effect on analysis, so a "changed" entry
840// inside it must not block the audit-gate base-snapshot fast path. We accept
841// both the as-given and the canonicalized cache_dir because changed-file
842// paths from `try_get_changed_files` are joined onto the canonical git
843// toplevel while `opts.root` may be un-canonical in tests.
844fn is_fallow_cache_artifact(
845    path: &Path,
846    cache_dir: &Path,
847    canonical_cache_dir: Option<&Path>,
848) -> bool {
849    path.starts_with(cache_dir)
850        || canonical_cache_dir.is_some_and(|canonical| path.starts_with(canonical))
851}
852
853fn git_toplevel(root: &Path) -> Option<PathBuf> {
854    let mut command = Command::new("git");
855    command
856        .args(["rev-parse", "--show-toplevel"])
857        .current_dir(root);
858    clear_ambient_git_env(&mut command);
859    let output = command.output().ok()?;
860    if !output.status.success() {
861        return None;
862    }
863    let path = PathBuf::from(String::from_utf8_lossy(&output.stdout).trim());
864    // Mirror `fallow_core::changed_files::resolve_git_toplevel`: use
865    // `dunce::canonicalize` to strip Windows `\\?\` verbatim prefix so this
866    // canonical form matches the shape `opts.root` and finding paths use
867    // downstream. `std::fs::canonicalize` would diverge on Windows.
868    Some(dunce::canonicalize(&path).unwrap_or(path))
869}
870
871fn git_show_file(root: &Path, base_ref: &str, relative: &Path) -> Option<String> {
872    let spec = format!(
873        "{}:{}",
874        base_ref,
875        relative.to_string_lossy().replace('\\', "/")
876    );
877    let mut command = Command::new("git");
878    command
879        .args(["show", "--end-of-options", &spec])
880        .current_dir(root);
881    clear_ambient_git_env(&mut command);
882    let output = command.output().ok()?;
883    output
884        .status
885        .success()
886        .then(|| String::from_utf8_lossy(&output.stdout).into_owned())
887}
888
889fn is_analysis_input(path: &Path) -> bool {
890    matches!(
891        path.extension().and_then(|ext| ext.to_str()),
892        Some(
893            "js" | "jsx"
894                | "ts"
895                | "tsx"
896                | "mjs"
897                | "mts"
898                | "cjs"
899                | "cts"
900                | "vue"
901                | "svelte"
902                | "astro"
903                | "mdx"
904                | "css"
905                | "scss"
906        )
907    )
908}
909
910fn is_non_behavioral_doc(path: &Path) -> bool {
911    matches!(
912        path.extension().and_then(|ext| ext.to_str()),
913        Some("md" | "markdown" | "txt" | "rst" | "adoc")
914    )
915}
916
917fn js_ts_tokens_equivalent(path: &Path, current: &str, base: &str) -> bool {
918    if current.contains("fallow-ignore") || base.contains("fallow-ignore") {
919        return false;
920    }
921    if !matches!(
922        path.extension().and_then(|ext| ext.to_str()),
923        Some("js" | "jsx" | "ts" | "tsx" | "mjs" | "mts" | "cjs" | "cts")
924    ) {
925        return false;
926    }
927    let current_tokens = fallow_core::duplicates::tokenize::tokenize_file(path, current, false);
928    let base_tokens = fallow_core::duplicates::tokenize::tokenize_file(path, base, false);
929    current_tokens
930        .tokens
931        .iter()
932        .map(|token| &token.kind)
933        .eq(base_tokens.tokens.iter().map(|token| &token.kind))
934}
935
936// Remap focused-file paths from the current working tree into the base
937// worktree, used so the duplication detector can scope clone-group
938// extraction at base to the same files we focus on at HEAD.
939//
940// Path matching at base must align with `discover_files`, which walks
941// `config.root` un-canonicalized and emits paths under that exact prefix.
942// Canonicalizing here would silently shift the prefix on systems where the
943// tempdir path traverses a symlink (`/tmp` → `/private/tmp`, `/var` →
944// `/private/var` on macOS); the focus set would then miss every discovered
945// file at base and disable the optimization. Use the prefixes as-is.
946//
947// `opts.root` is already canonical (from `validate_root`), and
948// `changed_files` was joined onto the canonical git toplevel, so
949// `strip_prefix(from_root)` succeeds for paths inside `opts.root`. Files
950// outside `opts.root` (e.g., a sibling workspace touched in the same
951// commit) are skipped rather than collapsing the whole set, so the focus
952// optimization stays active for the in-scope subset.
953fn remap_focus_files(
954    files: &FxHashSet<PathBuf>,
955    from_root: &Path,
956    to_root: &Path,
957) -> Option<FxHashSet<PathBuf>> {
958    let mut remapped = FxHashSet::default();
959    for file in files {
960        if let Ok(relative) = file.strip_prefix(from_root) {
961            remapped.insert(to_root.join(relative));
962        }
963    }
964    if remapped.is_empty() {
965        return None;
966    }
967    Some(remapped)
968}
969
970struct BaseWorktree {
971    repo_root: PathBuf,
972    path: PathBuf,
973    persistent: bool,
974}
975
976impl BaseWorktree {
977    fn create(repo_root: &Path, base_ref: &str, base_sha: Option<&str>) -> Option<Self> {
978        sweep_orphan_audit_worktrees(repo_root);
979        if let Some(base_sha) = base_sha
980            && let Some(worktree) = Self::reuse_or_create(repo_root, base_sha)
981        {
982            return Some(worktree);
983        }
984        let path = std::env::temp_dir().join(format!(
985            "fallow-audit-base-{}-{}",
986            std::process::id(),
987            std::time::SystemTime::now()
988                .duration_since(std::time::UNIX_EPOCH)
989                .ok()?
990                .as_nanos()
991        ));
992        let mut guard = WorktreeCleanupGuard::new(repo_root, &path);
993        let mut command = Command::new("git");
994        command
995            .args([
996                "worktree",
997                "add",
998                "--detach",
999                "--quiet",
1000                guard.path().to_str()?,
1001                base_ref,
1002            ])
1003            .current_dir(repo_root);
1004        clear_ambient_git_env(&mut command);
1005        let output = crate::signal::scoped_child::output(&mut command).ok()?;
1006        if !output.status.success() {
1007            return None;
1008        }
1009        guard.defuse();
1010        drop(guard);
1011        let worktree = Self {
1012            repo_root: repo_root.to_path_buf(),
1013            path,
1014            persistent: false,
1015        };
1016        materialize_base_dependency_context(repo_root, worktree.path());
1017        Some(worktree)
1018    }
1019
1020    fn reuse_or_create(repo_root: &Path, base_sha: &str) -> Option<Self> {
1021        let path = reusable_audit_worktree_path(repo_root, base_sha);
1022        // Serialise concurrent audits against the same base_sha. On contention,
1023        // fall through to the non-reusable PID-named path so the loser does not
1024        // block; matrix CI then gets at most one slow rebuild rather than racing
1025        // git worktree add against the same directory. The lock is released
1026        // automatically when `_lock` drops.
1027        let _lock = ReusableWorktreeLock::try_acquire(&path)?;
1028
1029        if reusable_audit_worktree_is_ready(repo_root, &path, base_sha) {
1030            let worktree = Self {
1031                repo_root: repo_root.to_path_buf(),
1032                path,
1033                persistent: true,
1034            };
1035            materialize_base_dependency_context(repo_root, worktree.path());
1036            // Update the staleness signal so the age-based GC sweep does
1037            // not nuke a frequently-reused cache.
1038            touch_last_used(worktree.path());
1039            return Some(worktree);
1040        }
1041
1042        remove_audit_worktree(repo_root, &path);
1043        let _ = std::fs::remove_dir_all(&path);
1044        let mut guard = WorktreeCleanupGuard::new(repo_root, &path);
1045        let mut command = Command::new("git");
1046        command
1047            .args([
1048                "worktree",
1049                "add",
1050                "--detach",
1051                "--quiet",
1052                guard.path().to_string_lossy().as_ref(),
1053                base_sha,
1054            ])
1055            .current_dir(repo_root);
1056        clear_ambient_git_env(&mut command);
1057        let output = crate::signal::scoped_child::output(&mut command).ok()?;
1058        if !output.status.success() {
1059            return None;
1060        }
1061        guard.defuse();
1062        drop(guard);
1063
1064        let worktree = Self {
1065            repo_root: repo_root.to_path_buf(),
1066            path,
1067            persistent: true,
1068        };
1069        materialize_base_dependency_context(repo_root, worktree.path());
1070        // Stamp the sidecar at fresh-create time so the cache's age is
1071        // measured from "first existence" rather than "first reuse". The
1072        // sweep's sidecar-absent branch (`touch + skip`) is still
1073        // load-bearing for pre-upgrade caches created before this
1074        // feature shipped.
1075        touch_last_used(worktree.path());
1076        Some(worktree)
1077    }
1078
1079    fn path(&self) -> &Path {
1080        &self.path
1081    }
1082}
1083
1084/// RAII cleanup guard for a freshly-created git worktree directory.
1085///
1086/// Armed before the `git worktree add` subprocess runs. If the holder returns
1087/// early (`?`) between subprocess success and the `BaseWorktree` struct binding,
1088/// `Drop` rolls back BOTH git's `.git/worktrees/<name>` registration AND the
1089/// on-disk directory. The owner calls `defuse()` once `BaseWorktree` is bound
1090/// and takes over cleanup via its own `Drop`.
1091///
1092/// With `panic = "abort"` on the release profile, this does not provide
1093/// panic-recovery cleanup (no unwind runs), but it is still load-bearing for
1094/// every early-return path between subprocess success and struct construction.
1095struct WorktreeCleanupGuard<'a> {
1096    repo_root: PathBuf,
1097    path: &'a Path,
1098    armed: bool,
1099}
1100
1101impl<'a> WorktreeCleanupGuard<'a> {
1102    fn new(repo_root: &Path, path: &'a Path) -> Self {
1103        Self {
1104            repo_root: repo_root.to_path_buf(),
1105            path,
1106            armed: true,
1107        }
1108    }
1109
1110    fn path(&self) -> &Path {
1111        self.path
1112    }
1113
1114    /// Disarm in place. Idempotent; calling twice is harmless. Drop becomes a
1115    /// no-op after this returns.
1116    fn defuse(&mut self) {
1117        self.armed = false;
1118    }
1119}
1120
1121impl Drop for WorktreeCleanupGuard<'_> {
1122    fn drop(&mut self) {
1123        if self.armed {
1124            remove_audit_worktree(&self.repo_root, self.path);
1125            let _ = std::fs::remove_dir_all(self.path);
1126        }
1127    }
1128}
1129
1130/// Kernel-level advisory lock around the reusable-cache `reuse_or_create`
1131/// critical section, backed by `std::fs::File::try_lock` (stable since Rust
1132/// 1.89), which wraps `flock(2)` on Unix and `LockFileEx` on Windows.
1133/// Concurrent acquirers either fall through (`None`) or observe a
1134/// freshly-prepared cache after the holder releases.
1135struct ReusableWorktreeLock {
1136    // Drop on `File` calls the kernel's unlock automatically; we never call
1137    // `unlock_exclusive` explicitly.
1138    _file: std::fs::File,
1139}
1140
1141impl ReusableWorktreeLock {
1142    fn try_acquire(reusable_path: &Path) -> Option<Self> {
1143        let lock_path = reusable_worktree_lock_path(reusable_path);
1144        // We never read the lock file's bytes, only its kernel-level lock
1145        // state, so set `truncate(false)` explicitly. Combining `O_TRUNC` with
1146        // `flock(2)` produced flaky `WouldBlock` returns under concurrent
1147        // acquire/release on macOS APFS during local tests.
1148        let file = std::fs::OpenOptions::new()
1149            .create(true)
1150            .truncate(false)
1151            .write(true)
1152            .open(&lock_path)
1153            .ok()?;
1154        match file.try_lock() {
1155            Ok(()) => Some(Self { _file: file }),
1156            Err(std::fs::TryLockError::WouldBlock) => {
1157                tracing::debug!(
1158                    path = %lock_path.display(),
1159                    "reusable audit worktree lock contended; falling back to non-reusable worktree",
1160                );
1161                None
1162            }
1163            Err(std::fs::TryLockError::Error(err)) => {
1164                tracing::debug!(
1165                    path = %lock_path.display(),
1166                    error = %err,
1167                    "could not acquire reusable audit worktree lock; falling back to non-reusable worktree",
1168                );
1169                None
1170            }
1171        }
1172    }
1173}
1174
1175fn reusable_worktree_lock_path(reusable_path: &Path) -> PathBuf {
1176    let mut name = reusable_path
1177        .file_name()
1178        .map(std::ffi::OsString::from)
1179        .unwrap_or_default();
1180    name.push(".lock");
1181    reusable_path
1182        .parent()
1183        .map_or_else(|| PathBuf::from(&name), |parent| parent.join(&name))
1184}
1185
1186/// Default GC threshold for persistent reusable base-snapshot caches.
1187const DEFAULT_AUDIT_CACHE_MAX_AGE_DAYS: u32 = 30;
1188
1189/// Env var that overrides `audit.cacheMaxAgeDays` from the config.
1190const AUDIT_CACHE_MAX_AGE_ENV: &str = "FALLOW_AUDIT_CACHE_MAX_AGE_DAYS";
1191
1192/// Sidecar filename suffix used to track last-use of a reusable worktree.
1193const REUSABLE_LAST_USED_SUFFIX: &str = ".last-used";
1194
1195/// Sidecar path for the "last used" timestamp of a reusable cache entry.
1196///
1197/// Lives next to the cache directory (NOT inside it) so the sidecar is
1198/// untouched by `git worktree add/remove` on the cache directory itself.
1199fn reusable_worktree_last_used_path(reusable_path: &Path) -> PathBuf {
1200    let mut name = reusable_path
1201        .file_name()
1202        .map(std::ffi::OsString::from)
1203        .unwrap_or_default();
1204    name.push(REUSABLE_LAST_USED_SUFFIX);
1205    reusable_path
1206        .parent()
1207        .map_or_else(|| PathBuf::from(&name), |parent| parent.join(&name))
1208}
1209
1210/// Stamp the sidecar `.last-used` file's mtime to now.
1211///
1212/// Called on every cache-hit reuse (and from the pre-upgrade-grace branch
1213/// of the GC sweep) so the staleness signal stays current even when the
1214/// cache directory itself is not mutated. Failures are surfaced at
1215/// `warn!` so a persistent ENOSPC / read-only-tmp condition is visible at
1216/// default `RUST_LOG=warn`; the caller does not abort the audit.
1217fn touch_last_used(reusable_path: &Path) {
1218    let last_used = reusable_worktree_last_used_path(reusable_path);
1219    let result = std::fs::OpenOptions::new()
1220        .create(true)
1221        .truncate(false)
1222        .write(true)
1223        .open(&last_used)
1224        .and_then(|file| file.set_modified(SystemTime::now()));
1225    if let Err(err) = result {
1226        tracing::warn!(
1227            path = %last_used.display(),
1228            error = %err,
1229            "failed to touch reusable audit worktree sidecar; staleness signal may not update",
1230        );
1231    }
1232}
1233
1234/// Resolve the GC threshold for persistent reusable caches.
1235///
1236/// Precedence: `FALLOW_AUDIT_CACHE_MAX_AGE_DAYS` env var > `audit.cacheMaxAgeDays`
1237/// config field > 30-day default. `0` from either source disables the sweep
1238/// entirely (returns `None`). Invalid env values (non-integer) silently fall
1239/// back to config / default; audits do not fail on a typo in a runner env var.
1240fn resolve_cache_max_age(opts: &AuditOptions<'_>) -> Option<Duration> {
1241    if let Ok(raw) = std::env::var(AUDIT_CACHE_MAX_AGE_ENV) {
1242        if let Ok(days) = raw.trim().parse::<u32>() {
1243            return days_to_duration(days);
1244        }
1245        tracing::debug!(
1246            value = %raw,
1247            "FALLOW_AUDIT_CACHE_MAX_AGE_DAYS is not a valid u32; falling back to config/default",
1248        );
1249    }
1250    if let Some(days) = load_audit_config(opts).and_then(|c| c.cache_max_age_days) {
1251        return days_to_duration(days);
1252    }
1253    days_to_duration(DEFAULT_AUDIT_CACHE_MAX_AGE_DAYS)
1254}
1255
1256fn days_to_duration(days: u32) -> Option<Duration> {
1257    if days == 0 {
1258        return None;
1259    }
1260    Some(Duration::from_secs(u64::from(days) * 86_400))
1261}
1262
1263/// Load `AuditConfig` from `opts.config_path` (or auto-discover from
1264/// `opts.root`) for GC-threshold resolution only. Errors silently fall
1265/// back to `None`; the caller defaults to a 30-day window.
1266fn load_audit_config(opts: &AuditOptions<'_>) -> Option<AuditConfig> {
1267    if let Some(path) = opts.config_path {
1268        return fallow_config::FallowConfig::load(path)
1269            .ok()
1270            .map(|config| config.audit);
1271    }
1272    fallow_config::FallowConfig::find_and_load(opts.root)
1273        .ok()
1274        .flatten()
1275        .map(|(config, _path)| config.audit)
1276}
1277
1278/// Remove persistent reusable base-snapshot worktree caches whose sidecar
1279/// `.last-used` file is older than `max_age`.
1280///
1281/// Concurrency: each candidate is gated by [`ReusableWorktreeLock`] before
1282/// removal, so an in-flight `fallow audit` mid-rebuild against the same
1283/// cache entry will not be disturbed (the sweep skips on contention).
1284///
1285/// Pre-upgrade caches lacking a sidecar are NOT removed: instead the sweep
1286/// seeds a fresh sidecar so the next invocation can age them from real
1287/// last-use. Without this grace, the dir's own mtime (= creation date on
1288/// POSIX) would wipe every legitimately-warm pre-upgrade cache on the
1289/// first run after upgrade.
1290///
1291/// The `.lock` sidecar file is intentionally NOT deleted on removal: a
1292/// racing acquirer of an unlinked-but-still-flocked inode plus a sibling
1293/// `open(O_CREAT)` at the same path would produce two processes each
1294/// holding a kernel flock on different inodes. Lock files are tens of
1295/// bytes; leaking them is harmless.
1296fn sweep_old_reusable_caches(repo_root: &Path, max_age: Duration, quiet: bool) {
1297    let Some(worktrees) = list_audit_worktrees(repo_root) else {
1298        return;
1299    };
1300    let now = SystemTime::now();
1301    let mut removed: u32 = 0;
1302    for path in worktrees {
1303        if !is_reusable_audit_worktree_path(&path) {
1304            continue;
1305        }
1306        let sidecar = reusable_worktree_last_used_path(&path);
1307        let sidecar_mtime = std::fs::metadata(&sidecar)
1308            .ok()
1309            .and_then(|m| m.modified().ok());
1310        let Some(mtime) = sidecar_mtime else {
1311            touch_last_used(&path);
1312            continue;
1313        };
1314        let Ok(age) = now.duration_since(mtime) else {
1315            continue;
1316        };
1317        if age < max_age {
1318            continue;
1319        }
1320        let Some(_lock) = ReusableWorktreeLock::try_acquire(&path) else {
1321            continue;
1322        };
1323        remove_audit_worktree(repo_root, &path);
1324        let dir_removed = match std::fs::remove_dir_all(&path) {
1325            Ok(()) => true,
1326            Err(err) if err.kind() == std::io::ErrorKind::NotFound => true,
1327            Err(err) => {
1328                tracing::warn!(
1329                    path = %path.display(),
1330                    error = %err,
1331                    "failed to remove stale reusable audit worktree directory; entry may leak",
1332                );
1333                false
1334            }
1335        };
1336        let _ = std::fs::remove_file(&sidecar);
1337        if dir_removed {
1338            removed += 1;
1339        }
1340    }
1341    if removed == 0 {
1342        return;
1343    }
1344    let mut command = Command::new("git");
1345    command
1346        .args(["worktree", "prune", "--expire=now"])
1347        .current_dir(repo_root);
1348    clear_ambient_git_env(&mut command);
1349    let _ = command.output();
1350    tracing::info!(
1351        count = removed,
1352        "reclaimed stale audit base-snapshot caches",
1353    );
1354    if !quiet {
1355        let s = plural(removed as usize);
1356        let _ = writeln!(
1357            std::io::stderr(),
1358            "fallow: reclaimed {removed} stale base-snapshot cache{s}",
1359        );
1360    }
1361}
1362
1363fn reusable_audit_worktree_path(repo_root: &Path, base_sha: &str) -> PathBuf {
1364    let repo_root = git_toplevel(repo_root).unwrap_or_else(|| repo_root.to_path_buf());
1365    // `dunce::canonicalize` keeps the hash deterministic across Windows
1366    // callers that pass verbatim-vs-non-verbatim shapes for the same repo.
1367    let repo_root = dunce::canonicalize(&repo_root).unwrap_or(repo_root);
1368    let repo_hash = xxh3_64(repo_root.to_string_lossy().as_bytes());
1369    let sha_prefix = base_sha.get(..16).unwrap_or(base_sha);
1370    std::env::temp_dir().join(format!(
1371        "fallow-audit-base-cache-{repo_hash:016x}-{sha_prefix}"
1372    ))
1373}
1374
1375fn reusable_audit_worktree_is_ready(repo_root: &Path, path: &Path, base_sha: &str) -> bool {
1376    if !path.exists() || !audit_worktree_is_registered(repo_root, path) {
1377        return false;
1378    }
1379    git_rev_parse(path, "HEAD").is_some_and(|head| head == base_sha)
1380}
1381
1382fn audit_worktree_is_registered(repo_root: &Path, path: &Path) -> bool {
1383    let Some(worktrees) = list_audit_worktrees(repo_root) else {
1384        return false;
1385    };
1386    worktrees.iter().any(|worktree| paths_equal(worktree, path))
1387}
1388
1389fn paths_equal(left: &Path, right: &Path) -> bool {
1390    if left == right {
1391        return true;
1392    }
1393    // `dunce::canonicalize` strips Windows `\\?\` verbatim prefix so two
1394    // paths that differ only in prefix shape compare equal.
1395    match (dunce::canonicalize(left), dunce::canonicalize(right)) {
1396        (Ok(left), Ok(right)) => left == right,
1397        _ => false,
1398    }
1399}
1400
1401/// Directories the audit base worktree shares with the host checkout.
1402///
1403/// `node_modules` is the original case: bare `git worktree add` lacks the
1404/// installed dependencies. `.nuxt` / `.astro` extend the same idea to
1405/// meta-framework `prepare` / `sync` outputs that the project gitignores;
1406/// without them the base pass cannot resolve tsconfig `references` chains
1407/// pointing into the generated tsconfigs and falls back to resolver-less
1408/// resolution. The trade-off matches `node_modules`: the symlinked dir is
1409/// HEAD-shaped, not base-shaped, but the alias resolution accuracy recovered
1410/// far outweighs the residual drift.
1411///
1412/// The meta-framework entries must stay aligned with the set recognized by
1413/// `missing_meta_framework_prerequisites` in `fallow_core`'s plugin registry.
1414/// Adding a framework's prepare-dir warning there without extending this list
1415/// silently reintroduces the broken-tsconfig-chain bug on the base pass for
1416/// that framework.
1417const MATERIALIZED_CONTEXT_DIRS: &[&str] = &["node_modules", ".nuxt", ".astro"];
1418
1419fn materialize_base_dependency_context(repo_root: &Path, worktree_path: &Path) {
1420    for &name in MATERIALIZED_CONTEXT_DIRS {
1421        let source = repo_root.join(name);
1422        if !source.is_dir() {
1423            continue;
1424        }
1425
1426        let destination = worktree_path.join(name);
1427        if destination.is_dir() {
1428            continue;
1429        }
1430        if let Ok(metadata) = std::fs::symlink_metadata(&destination) {
1431            if !metadata.file_type().is_symlink() {
1432                continue;
1433            }
1434            let _ = std::fs::remove_file(&destination);
1435        }
1436
1437        let _ = symlink_dependency_dir(&source, &destination);
1438    }
1439}
1440
1441#[cfg(unix)]
1442fn symlink_dependency_dir(source: &Path, destination: &Path) -> std::io::Result<()> {
1443    std::os::unix::fs::symlink(source, destination)
1444}
1445
1446#[cfg(windows)]
1447fn symlink_dependency_dir(source: &Path, destination: &Path) -> std::io::Result<()> {
1448    std::os::windows::fs::symlink_dir(source, destination)
1449}
1450
1451fn remove_audit_worktree(repo_root: &Path, path: &Path) {
1452    let mut command = Command::new("git");
1453    command
1454        .args([
1455            "worktree",
1456            "remove",
1457            "--force",
1458            path.to_string_lossy().as_ref(),
1459        ])
1460        .current_dir(repo_root);
1461    clear_ambient_git_env(&mut command);
1462    match crate::signal::scoped_child::output(&mut command) {
1463        Ok(output) => {
1464            // Only warn when an observable leak survives: the on-disk path still
1465            // exists after a non-zero `git worktree remove --force`. A missing
1466            // registration with no surviving directory is the partial-create
1467            // cleanup case and not noteworthy.
1468            if !output.status.success() && path.exists() {
1469                let stderr = String::from_utf8_lossy(&output.stderr);
1470                tracing::warn!(
1471                    path = %path.display(),
1472                    stderr = %stderr.trim(),
1473                    "git worktree remove failed; the directory remains and may leak",
1474                );
1475            }
1476        }
1477        Err(err) => {
1478            tracing::warn!(
1479                path = %path.display(),
1480                error = %err,
1481                "git worktree remove subprocess failed to spawn",
1482            );
1483        }
1484    }
1485}
1486
1487fn sweep_orphan_audit_worktrees(repo_root: &Path) {
1488    let Some(worktrees) = list_audit_worktrees(repo_root) else {
1489        return;
1490    };
1491    let mut removed_any = false;
1492    for path in worktrees {
1493        if !is_fallow_audit_worktree_path(&path)
1494            || is_reusable_audit_worktree_path(&path)
1495            || audit_worktree_process_is_alive(&path)
1496        {
1497            continue;
1498        }
1499        remove_audit_worktree(repo_root, &path);
1500        let _ = std::fs::remove_dir_all(&path);
1501        removed_any = true;
1502    }
1503    if removed_any {
1504        let mut command = Command::new("git");
1505        command
1506            .args(["worktree", "prune", "--expire=now"])
1507            .current_dir(repo_root);
1508        clear_ambient_git_env(&mut command);
1509        let _ = command.output();
1510    }
1511}
1512
1513fn list_audit_worktrees(repo_root: &Path) -> Option<Vec<PathBuf>> {
1514    let mut command = Command::new("git");
1515    command
1516        .args(["worktree", "list", "--porcelain"])
1517        .current_dir(repo_root);
1518    clear_ambient_git_env(&mut command);
1519    let output = command.output().ok()?;
1520    if !output.status.success() {
1521        return None;
1522    }
1523    Some(parse_worktree_list(&String::from_utf8_lossy(
1524        &output.stdout,
1525    )))
1526}
1527
1528fn parse_worktree_list(output: &str) -> Vec<PathBuf> {
1529    output
1530        .lines()
1531        .filter_map(|line| line.strip_prefix("worktree "))
1532        .map(PathBuf::from)
1533        .filter(|path| is_fallow_audit_worktree_path(path))
1534        .collect()
1535}
1536
1537fn is_fallow_audit_worktree_path(path: &Path) -> bool {
1538    let Some(name) = path.file_name().and_then(|name| name.to_str()) else {
1539        return false;
1540    };
1541    name.starts_with("fallow-audit-base-") && path_is_inside_temp_dir(path)
1542}
1543
1544fn is_reusable_audit_worktree_path(path: &Path) -> bool {
1545    path.file_name()
1546        .and_then(|name| name.to_str())
1547        .is_some_and(|name| name.starts_with("fallow-audit-base-cache-"))
1548}
1549
1550fn path_is_inside_temp_dir(path: &Path) -> bool {
1551    let temp = std::env::temp_dir();
1552    // `dunce::simplified` strips Windows `\\?\` verbatim prefix WITHOUT any
1553    // filesystem I/O, so this handles both verbatim and non-verbatim inputs
1554    // (synthetic test paths, real canonical paths from std OR dunce) without
1555    // requiring the path to actually exist on disk. The earlier
1556    // `dunce::canonicalize` attempt failed for the synthetic test paths in
1557    // `audit_worktree_helpers_filter_to_fallow_temp_prefix` because the
1558    // worktree dirs are constructed in-memory and never written.
1559    let simple_path = dunce::simplified(path);
1560    let simple_temp = dunce::simplified(&temp);
1561    if simple_path.starts_with(simple_temp) {
1562        return true;
1563    }
1564    // Fallback for symlinked temp dirs: canonicalize via std::fs (POSIX
1565    // resolves the symlink target; on Windows this also matches when path
1566    // canonicalises to something under temp).
1567    let Ok(canonical_temp) = std::fs::canonicalize(&temp) else {
1568        return false;
1569    };
1570    let simple_canonical_temp = dunce::simplified(&canonical_temp);
1571    simple_path.starts_with(simple_canonical_temp)
1572        || std::fs::canonicalize(path).is_ok_and(|canonical_path| {
1573            dunce::simplified(&canonical_path).starts_with(simple_canonical_temp)
1574        })
1575}
1576
1577fn audit_worktree_process_is_alive(path: &Path) -> bool {
1578    let Some(pid) = path
1579        .file_name()
1580        .and_then(|name| name.to_str())
1581        .and_then(audit_worktree_pid)
1582    else {
1583        return false;
1584    };
1585    process_is_alive(pid)
1586}
1587
1588fn audit_worktree_pid(name: &str) -> Option<u32> {
1589    name.strip_prefix("fallow-audit-base-")?
1590        .split('-')
1591        .next()?
1592        .parse()
1593        .ok()
1594}
1595
1596#[cfg(unix)]
1597pub fn process_is_alive(pid: u32) -> bool {
1598    Command::new("kill")
1599        .args(["-0", &pid.to_string()])
1600        .output()
1601        .is_ok_and(|output| output.status.success())
1602}
1603
1604#[cfg(windows)]
1605pub fn process_is_alive(pid: u32) -> bool {
1606    windows_process::is_alive(pid)
1607}
1608
1609#[cfg(not(any(unix, windows)))]
1610pub fn process_is_alive(_pid: u32) -> bool {
1611    // Conservative default on unknown platforms: treat every PID as alive so the
1612    // orphan sweep never removes anything we can't prove is dead.
1613    true
1614}
1615
1616#[cfg(windows)]
1617#[allow(
1618    unsafe_code,
1619    reason = "Win32 process-query API (OpenProcess / WaitForSingleObject / CloseHandle / GetLastError) requires unsafe FFI"
1620)]
1621mod windows_process {
1622    use windows_sys::Win32::Foundation::{
1623        CloseHandle, ERROR_ACCESS_DENIED, ERROR_INVALID_PARAMETER, GetLastError, HANDLE,
1624        WAIT_OBJECT_0,
1625    };
1626    use windows_sys::Win32::System::Threading::{
1627        OpenProcess, PROCESS_QUERY_LIMITED_INFORMATION, WaitForSingleObject,
1628    };
1629
1630    /// RAII wrapper that calls `CloseHandle` on drop, mirroring `std::mem::drop`
1631    /// semantics for kernel handles. Used so every exit path through
1632    /// `is_alive` releases the handle without manual cleanup.
1633    struct ProcessHandle(HANDLE);
1634
1635    impl Drop for ProcessHandle {
1636        fn drop(&mut self) {
1637            // SAFETY: `self.0` is a non-null handle obtained from a successful
1638            // `OpenProcess` call. We have unique ownership (the value is only
1639            // ever created inside `is_alive`), so this is the sole consumer.
1640            unsafe {
1641                CloseHandle(self.0);
1642            }
1643        }
1644    }
1645
1646    /// Cross-platform PID liveness check for Windows.
1647    ///
1648    /// Mirrors `kill -0 $pid` semantics: returns `true` when the process is
1649    /// running OR when we cannot prove it dead (e.g., `ERROR_ACCESS_DENIED` on
1650    /// processes owned by another session). Returns `false` only when the PID
1651    /// definitively does not exist (`ERROR_INVALID_PARAMETER`) or the wait
1652    /// reports the process has exited.
1653    pub(super) fn is_alive(pid: u32) -> bool {
1654        // SAFETY: `OpenProcess` accepts any `u32` PID; it either returns a
1655        // non-null handle we own, or null on failure with `GetLastError`
1656        // describing why. No memory is borrowed across the FFI boundary.
1657        let raw = unsafe { OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, 0, pid) };
1658        if raw.is_null() {
1659            // SAFETY: `GetLastError` reads thread-local storage set by the
1660            // failing `OpenProcess` call. It has no preconditions.
1661            let err = unsafe { GetLastError() };
1662            // The named `ERROR_ACCESS_DENIED` arm and the `_` arm map to the
1663            // same conservative default; the named arm is kept solely to
1664            // document the protected-process / cross-session case. Collapsing
1665            // would lose that documentation.
1666            #[expect(
1667                clippy::match_same_arms,
1668                reason = "named arm documents the cross-session protected-process case; collapsing loses that intent"
1669            )]
1670            return match err {
1671                // PID never existed or has already been fully reaped.
1672                ERROR_INVALID_PARAMETER => false,
1673                // Process exists but is owned by another session / under
1674                // protected access. Conservative default: treat as alive so we
1675                // never sweep a worktree owned by a live process we can't see.
1676                ERROR_ACCESS_DENIED => true,
1677                // Anything else (transient, unknown): conservative default.
1678                _ => true,
1679            };
1680        }
1681        let handle = ProcessHandle(raw);
1682        // `WaitForSingleObject(handle, 0)` returns `WAIT_OBJECT_0` (0) when the
1683        // process has exited and its handle is signalled, `WAIT_TIMEOUT` (0x102)
1684        // when the process is still running, and `WAIT_FAILED` (0xFFFF_FFFF) on
1685        // unexpected errors. We compare against `WAIT_OBJECT_0` specifically so
1686        // every other return value (including `WAIT_FAILED`) follows the
1687        // conservative default: treat as alive when we cannot prove the
1688        // process is dead.
1689        //
1690        // This is preferred over `GetExitCodeProcess + STILL_ACTIVE` because
1691        // `STILL_ACTIVE` (259) is a valid u32 exit code: a process that
1692        // legitimately exits with 259 would otherwise be misreported as alive.
1693        //
1694        // SAFETY: `handle.0` is non-null (checked above) and owned by the
1695        // `ProcessHandle` RAII wrapper.
1696        let wait_result = unsafe { WaitForSingleObject(handle.0, 0) };
1697        wait_result != WAIT_OBJECT_0
1698    }
1699}
1700
1701impl Drop for BaseWorktree {
1702    fn drop(&mut self) {
1703        if self.persistent {
1704            return;
1705        }
1706        remove_audit_worktree(&self.repo_root, &self.path);
1707        let _ = std::fs::remove_dir_all(&self.path);
1708    }
1709}
1710
1711fn relative_key_path(path: &Path, root: &Path) -> String {
1712    // `dunce::simplified` strips the Windows `\\?\` verbatim prefix when present
1713    // without touching the filesystem, so a path that came back from
1714    // `std::fs::canonicalize` (verbatim form on Windows) compares equal to a
1715    // path that did not (e.g., the BASE worktree path built via
1716    // `std::env::temp_dir().join(...)`). On POSIX `dunce::simplified` is a
1717    // no-op. Without this, audit's BASE-vs-HEAD finding-key intersection on
1718    // Windows produced 0 matches because `config.root` and `finding.path`
1719    // disagreed on the prefix shape, so every BASE key landed as a full
1720    // absolute path while HEAD keys landed as relative; the intersection
1721    // was empty and every pre-existing issue surfaced as "introduced".
1722    let simple_path = dunce::simplified(path);
1723    let simple_root = dunce::simplified(root);
1724    simple_path
1725        .strip_prefix(simple_root)
1726        .unwrap_or(simple_path)
1727        .to_string_lossy()
1728        .replace('\\', "/")
1729}
1730
1731fn dependency_location_key(location: &fallow_core::results::DependencyLocation) -> &'static str {
1732    match location {
1733        fallow_core::results::DependencyLocation::Dependencies => "unused-dependency",
1734        fallow_core::results::DependencyLocation::DevDependencies => "unused-dev-dependency",
1735        fallow_core::results::DependencyLocation::OptionalDependencies => {
1736            "unused-optional-dependency"
1737        }
1738    }
1739}
1740
1741fn unused_dependency_key(item: &fallow_core::results::UnusedDependency, root: &Path) -> String {
1742    format!(
1743        "{}:{}:{}",
1744        dependency_location_key(&item.location),
1745        relative_key_path(&item.path, root),
1746        item.package_name
1747    )
1748}
1749
1750fn unlisted_dependency_key(item: &fallow_core::results::UnlistedDependency, root: &Path) -> String {
1751    let mut sites = item
1752        .imported_from
1753        .iter()
1754        .map(|site| {
1755            format!(
1756                "{}:{}:{}",
1757                relative_key_path(&site.path, root),
1758                site.line,
1759                site.col
1760            )
1761        })
1762        .collect::<Vec<_>>();
1763    sites.sort();
1764    sites.dedup();
1765    format!(
1766        "unlisted-dependency:{}:{}",
1767        item.package_name,
1768        sites.join("|")
1769    )
1770}
1771
1772fn unused_member_key(
1773    rule_id: &str,
1774    item: &fallow_core::results::UnusedMember,
1775    root: &Path,
1776) -> String {
1777    format!(
1778        "{}:{}:{}:{}",
1779        rule_id,
1780        relative_key_path(&item.path, root),
1781        item.parent_name,
1782        item.member_name
1783    )
1784}
1785
1786fn unused_catalog_entry_key(
1787    item: &fallow_core::results::UnusedCatalogEntry,
1788    root: &Path,
1789) -> String {
1790    format!(
1791        "unused-catalog-entry:{}:{}:{}:{}",
1792        relative_key_path(&item.path, root),
1793        item.line,
1794        item.catalog_name,
1795        item.entry_name
1796    )
1797}
1798
1799fn empty_catalog_group_key(item: &fallow_core::results::EmptyCatalogGroup, root: &Path) -> String {
1800    format!(
1801        "empty-catalog-group:{}:{}:{}",
1802        relative_key_path(&item.path, root),
1803        item.line,
1804        item.catalog_name
1805    )
1806}
1807
1808#[expect(
1809    clippy::too_many_lines,
1810    reason = "one key-builder block per issue type keeps the audit-attribution key shape local and easy to audit; the count grows linearly with new issue types"
1811)]
1812fn dead_code_keys(
1813    results: &fallow_core::results::AnalysisResults,
1814    root: &Path,
1815) -> FxHashSet<String> {
1816    let mut keys = FxHashSet::default();
1817    for item in &results.unused_files {
1818        keys.insert(format!(
1819            "unused-file:{}",
1820            relative_key_path(&item.file.path, root)
1821        ));
1822    }
1823    for item in &results.unused_exports {
1824        keys.insert(format!(
1825            "unused-export:{}:{}",
1826            relative_key_path(&item.export.path, root),
1827            item.export.export_name
1828        ));
1829    }
1830    for item in &results.unused_types {
1831        keys.insert(format!(
1832            "unused-type:{}:{}",
1833            relative_key_path(&item.export.path, root),
1834            item.export.export_name
1835        ));
1836    }
1837    for item in &results.private_type_leaks {
1838        keys.insert(format!(
1839            "private-type-leak:{}:{}:{}",
1840            relative_key_path(&item.leak.path, root),
1841            item.leak.export_name,
1842            item.leak.type_name
1843        ));
1844    }
1845    for item in results
1846        .unused_dependencies
1847        .iter()
1848        .map(|f| &f.dep)
1849        .chain(results.unused_dev_dependencies.iter().map(|f| &f.dep))
1850        .chain(results.unused_optional_dependencies.iter().map(|f| &f.dep))
1851    {
1852        keys.insert(unused_dependency_key(item, root));
1853    }
1854    for item in &results.unused_enum_members {
1855        keys.insert(unused_member_key("unused-enum-member", &item.member, root));
1856    }
1857    for item in &results.unused_class_members {
1858        keys.insert(unused_member_key("unused-class-member", &item.member, root));
1859    }
1860    for item in &results.unresolved_imports {
1861        keys.insert(format!(
1862            "unresolved-import:{}:{}",
1863            relative_key_path(&item.import.path, root),
1864            item.import.specifier
1865        ));
1866    }
1867    for item in results.unlisted_dependencies.iter().map(|f| &f.dep) {
1868        keys.insert(unlisted_dependency_key(item, root));
1869    }
1870    for item in &results.duplicate_exports {
1871        let mut locations: Vec<String> = item
1872            .export
1873            .locations
1874            .iter()
1875            .map(|loc| relative_key_path(&loc.path, root))
1876            .collect();
1877        locations.sort();
1878        locations.dedup();
1879        keys.insert(format!(
1880            "duplicate-export:{}:{}",
1881            item.export.export_name,
1882            locations.join("|")
1883        ));
1884    }
1885    for item in &results.type_only_dependencies {
1886        keys.insert(format!(
1887            "type-only-dependency:{}:{}",
1888            relative_key_path(&item.dep.path, root),
1889            item.dep.package_name
1890        ));
1891    }
1892    for item in &results.test_only_dependencies {
1893        keys.insert(format!(
1894            "test-only-dependency:{}:{}",
1895            relative_key_path(&item.dep.path, root),
1896            item.dep.package_name
1897        ));
1898    }
1899    for item in &results.circular_dependencies {
1900        let mut files: Vec<String> = item
1901            .cycle
1902            .files
1903            .iter()
1904            .map(|path| relative_key_path(path, root))
1905            .collect();
1906        files.sort();
1907        keys.insert(format!("circular-dependency:{}", files.join("|")));
1908    }
1909    for item in &results.re_export_cycles {
1910        // Prefix the audit-gate key with the kind discriminator so self-loops
1911        // cannot keyspace-collide with future single-file multi-node shapes
1912        // (panel catch #7; same rationale as `baseline.rs::re_export_cycle_key`).
1913        let kind = match item.cycle.kind {
1914            fallow_core::results::ReExportCycleKind::MultiNode => "multi-node",
1915            fallow_core::results::ReExportCycleKind::SelfLoop => "self-loop",
1916        };
1917        let mut files: Vec<String> = item
1918            .cycle
1919            .files
1920            .iter()
1921            .map(|path| relative_key_path(path, root))
1922            .collect();
1923        files.sort();
1924        keys.insert(format!("re-export-cycle:{kind}:{}", files.join("|")));
1925    }
1926    for item in &results.boundary_violations {
1927        keys.insert(format!(
1928            "boundary-violation:{}:{}:{}",
1929            relative_key_path(&item.violation.from_path, root),
1930            relative_key_path(&item.violation.to_path, root),
1931            item.violation.import_specifier
1932        ));
1933    }
1934    for item in &results.stale_suppressions {
1935        keys.insert(format!(
1936            "stale-suppression:{}:{}",
1937            relative_key_path(&item.path, root),
1938            item.description()
1939        ));
1940    }
1941    for item in &results.unresolved_catalog_references {
1942        keys.insert(format!(
1943            "unresolved-catalog-reference:{}:{}:{}:{}",
1944            relative_key_path(&item.reference.path, root),
1945            item.reference.line,
1946            item.reference.catalog_name,
1947            item.reference.entry_name
1948        ));
1949    }
1950    for item in &results.unused_catalog_entries {
1951        keys.insert(unused_catalog_entry_key(&item.entry, root));
1952    }
1953    for item in &results.empty_catalog_groups {
1954        keys.insert(empty_catalog_group_key(&item.group, root));
1955    }
1956    for item in &results.unused_dependency_overrides {
1957        keys.insert(format!(
1958            "unused-dependency-override:{}:{}:{}",
1959            relative_key_path(&item.entry.path, root),
1960            item.entry.line,
1961            item.entry.raw_key
1962        ));
1963    }
1964    for item in &results.misconfigured_dependency_overrides {
1965        keys.insert(format!(
1966            "misconfigured-dependency-override:{}:{}:{}",
1967            relative_key_path(&item.entry.path, root),
1968            item.entry.line,
1969            item.entry.raw_key
1970        ));
1971    }
1972    keys
1973}
1974
1975#[expect(
1976    clippy::too_many_lines,
1977    reason = "one retain block per issue type keeps the gate-filter local and grep-friendly; the count grows linearly with new issue types and parallels dead_code_keys"
1978)]
1979fn retain_introduced_dead_code(
1980    results: &mut fallow_core::results::AnalysisResults,
1981    root: &Path,
1982    base: Option<&FxHashSet<String>>,
1983) {
1984    let Some(base) = base else {
1985        return;
1986    };
1987    results.unused_files.retain(|item| {
1988        !base.contains(&format!(
1989            "unused-file:{}",
1990            relative_key_path(&item.file.path, root)
1991        ))
1992    });
1993    results.unused_exports.retain(|item| {
1994        !base.contains(&format!(
1995            "unused-export:{}:{}",
1996            relative_key_path(&item.export.path, root),
1997            item.export.export_name
1998        ))
1999    });
2000    results.unused_types.retain(|item| {
2001        !base.contains(&format!(
2002            "unused-type:{}:{}",
2003            relative_key_path(&item.export.path, root),
2004            item.export.export_name
2005        ))
2006    });
2007    // The verdict path only needs correct issue counts and severities. For the
2008    // less common categories, rebuild the full key set and retain by membership.
2009    let introduced = dead_code_keys(results, root)
2010        .into_iter()
2011        .filter(|key| !base.contains(key))
2012        .collect::<FxHashSet<_>>();
2013    let keep = |key: String| introduced.contains(&key);
2014    results.private_type_leaks.retain(|item| {
2015        keep(format!(
2016            "private-type-leak:{}:{}:{}",
2017            relative_key_path(&item.leak.path, root),
2018            item.leak.export_name,
2019            item.leak.type_name
2020        ))
2021    });
2022    results
2023        .unused_dependencies
2024        .retain(|item| keep(unused_dependency_key(&item.dep, root)));
2025    results
2026        .unused_dev_dependencies
2027        .retain(|item| keep(unused_dependency_key(&item.dep, root)));
2028    results
2029        .unused_optional_dependencies
2030        .retain(|item| keep(unused_dependency_key(&item.dep, root)));
2031    results
2032        .unused_enum_members
2033        .retain(|item| keep(unused_member_key("unused-enum-member", &item.member, root)));
2034    results
2035        .unused_class_members
2036        .retain(|item| keep(unused_member_key("unused-class-member", &item.member, root)));
2037    results.unresolved_imports.retain(|item| {
2038        keep(format!(
2039            "unresolved-import:{}:{}",
2040            relative_key_path(&item.import.path, root),
2041            item.import.specifier
2042        ))
2043    });
2044    results
2045        .unlisted_dependencies
2046        .retain(|item| keep(unlisted_dependency_key(&item.dep, root)));
2047    results.duplicate_exports.retain(|item| {
2048        let mut locations: Vec<String> = item
2049            .export
2050            .locations
2051            .iter()
2052            .map(|loc| relative_key_path(&loc.path, root))
2053            .collect();
2054        locations.sort();
2055        locations.dedup();
2056        keep(format!(
2057            "duplicate-export:{}:{}",
2058            item.export.export_name,
2059            locations.join("|")
2060        ))
2061    });
2062    results.type_only_dependencies.retain(|item| {
2063        keep(format!(
2064            "type-only-dependency:{}:{}",
2065            relative_key_path(&item.dep.path, root),
2066            item.dep.package_name
2067        ))
2068    });
2069    results.test_only_dependencies.retain(|item| {
2070        keep(format!(
2071            "test-only-dependency:{}:{}",
2072            relative_key_path(&item.dep.path, root),
2073            item.dep.package_name
2074        ))
2075    });
2076    results.circular_dependencies.retain(|item| {
2077        let mut files: Vec<String> = item
2078            .cycle
2079            .files
2080            .iter()
2081            .map(|path| relative_key_path(path, root))
2082            .collect();
2083        files.sort();
2084        keep(format!("circular-dependency:{}", files.join("|")))
2085    });
2086    results.re_export_cycles.retain(|item| {
2087        let kind = match item.cycle.kind {
2088            fallow_core::results::ReExportCycleKind::MultiNode => "multi-node",
2089            fallow_core::results::ReExportCycleKind::SelfLoop => "self-loop",
2090        };
2091        let mut files: Vec<String> = item
2092            .cycle
2093            .files
2094            .iter()
2095            .map(|path| relative_key_path(path, root))
2096            .collect();
2097        files.sort();
2098        keep(format!("re-export-cycle:{kind}:{}", files.join("|")))
2099    });
2100    results.boundary_violations.retain(|item| {
2101        keep(format!(
2102            "boundary-violation:{}:{}:{}",
2103            relative_key_path(&item.violation.from_path, root),
2104            relative_key_path(&item.violation.to_path, root),
2105            item.violation.import_specifier
2106        ))
2107    });
2108    results.stale_suppressions.retain(|item| {
2109        keep(format!(
2110            "stale-suppression:{}:{}",
2111            relative_key_path(&item.path, root),
2112            item.description()
2113        ))
2114    });
2115    results.unresolved_catalog_references.retain(|item| {
2116        keep(format!(
2117            "unresolved-catalog-reference:{}:{}:{}:{}",
2118            relative_key_path(&item.reference.path, root),
2119            item.reference.line,
2120            item.reference.catalog_name,
2121            item.reference.entry_name
2122        ))
2123    });
2124    results
2125        .unused_catalog_entries
2126        .retain(|item| keep(unused_catalog_entry_key(&item.entry, root)));
2127    results
2128        .empty_catalog_groups
2129        .retain(|item| keep(empty_catalog_group_key(&item.group, root)));
2130    results.unused_dependency_overrides.retain(|item| {
2131        keep(format!(
2132            "unused-dependency-override:{}:{}:{}",
2133            relative_key_path(&item.entry.path, root),
2134            item.entry.line,
2135            item.entry.raw_key
2136        ))
2137    });
2138    results.misconfigured_dependency_overrides.retain(|item| {
2139        keep(format!(
2140            "misconfigured-dependency-override:{}:{}:{}",
2141            relative_key_path(&item.entry.path, root),
2142            item.entry.line,
2143            item.entry.raw_key
2144        ))
2145    });
2146}
2147
2148fn issue_was_introduced(key: &str, base: &FxHashSet<String>) -> bool {
2149    !base.contains(key)
2150}
2151
2152fn annotate_issue_array<I>(json: &mut serde_json::Value, key: &str, introduced: I)
2153where
2154    I: IntoIterator<Item = bool>,
2155{
2156    let Some(items) = json.get_mut(key).and_then(serde_json::Value::as_array_mut) else {
2157        return;
2158    };
2159    for (item, introduced) in items.iter_mut().zip(introduced) {
2160        if let serde_json::Value::Object(map) = item {
2161            map.insert("introduced".to_string(), serde_json::json!(introduced));
2162        }
2163    }
2164}
2165
2166#[expect(
2167    clippy::too_many_lines,
2168    reason = "keeps audit attribution keys adjacent to the JSON arrays they annotate"
2169)]
2170fn annotate_dead_code_json(
2171    json: &mut serde_json::Value,
2172    results: &fallow_core::results::AnalysisResults,
2173    root: &Path,
2174    base: &FxHashSet<String>,
2175) {
2176    annotate_issue_array(
2177        json,
2178        "unused_files",
2179        results.unused_files.iter().map(|item| {
2180            issue_was_introduced(
2181                &format!("unused-file:{}", relative_key_path(&item.file.path, root)),
2182                base,
2183            )
2184        }),
2185    );
2186    annotate_issue_array(
2187        json,
2188        "unused_exports",
2189        results.unused_exports.iter().map(|item| {
2190            issue_was_introduced(
2191                &format!(
2192                    "unused-export:{}:{}",
2193                    relative_key_path(&item.export.path, root),
2194                    item.export.export_name
2195                ),
2196                base,
2197            )
2198        }),
2199    );
2200    annotate_issue_array(
2201        json,
2202        "unused_types",
2203        results.unused_types.iter().map(|item| {
2204            issue_was_introduced(
2205                &format!(
2206                    "unused-type:{}:{}",
2207                    relative_key_path(&item.export.path, root),
2208                    item.export.export_name
2209                ),
2210                base,
2211            )
2212        }),
2213    );
2214    annotate_issue_array(
2215        json,
2216        "private_type_leaks",
2217        results.private_type_leaks.iter().map(|item| {
2218            issue_was_introduced(
2219                &format!(
2220                    "private-type-leak:{}:{}:{}",
2221                    relative_key_path(&item.leak.path, root),
2222                    item.leak.export_name,
2223                    item.leak.type_name
2224                ),
2225                base,
2226            )
2227        }),
2228    );
2229    annotate_issue_array(
2230        json,
2231        "unused_dependencies",
2232        results
2233            .unused_dependencies
2234            .iter()
2235            .map(|item| issue_was_introduced(&unused_dependency_key(&item.dep, root), base)),
2236    );
2237    annotate_issue_array(
2238        json,
2239        "unused_dev_dependencies",
2240        results
2241            .unused_dev_dependencies
2242            .iter()
2243            .map(|item| issue_was_introduced(&unused_dependency_key(&item.dep, root), base)),
2244    );
2245    annotate_issue_array(
2246        json,
2247        "unused_optional_dependencies",
2248        results
2249            .unused_optional_dependencies
2250            .iter()
2251            .map(|item| issue_was_introduced(&unused_dependency_key(&item.dep, root), base)),
2252    );
2253    annotate_issue_array(
2254        json,
2255        "unused_enum_members",
2256        results.unused_enum_members.iter().map(|item| {
2257            issue_was_introduced(
2258                &unused_member_key("unused-enum-member", &item.member, root),
2259                base,
2260            )
2261        }),
2262    );
2263    annotate_issue_array(
2264        json,
2265        "unused_class_members",
2266        results.unused_class_members.iter().map(|item| {
2267            issue_was_introduced(
2268                &unused_member_key("unused-class-member", &item.member, root),
2269                base,
2270            )
2271        }),
2272    );
2273    annotate_issue_array(
2274        json,
2275        "unresolved_imports",
2276        results.unresolved_imports.iter().map(|item| {
2277            issue_was_introduced(
2278                &format!(
2279                    "unresolved-import:{}:{}",
2280                    relative_key_path(&item.import.path, root),
2281                    item.import.specifier
2282                ),
2283                base,
2284            )
2285        }),
2286    );
2287    annotate_issue_array(
2288        json,
2289        "unlisted_dependencies",
2290        results
2291            .unlisted_dependencies
2292            .iter()
2293            .map(|item| issue_was_introduced(&unlisted_dependency_key(&item.dep, root), base)),
2294    );
2295    annotate_issue_array(
2296        json,
2297        "duplicate_exports",
2298        results.duplicate_exports.iter().map(|item| {
2299            let mut locations: Vec<String> = item
2300                .export
2301                .locations
2302                .iter()
2303                .map(|loc| relative_key_path(&loc.path, root))
2304                .collect();
2305            locations.sort();
2306            locations.dedup();
2307            issue_was_introduced(
2308                &format!(
2309                    "duplicate-export:{}:{}",
2310                    item.export.export_name,
2311                    locations.join("|")
2312                ),
2313                base,
2314            )
2315        }),
2316    );
2317    annotate_issue_array(
2318        json,
2319        "type_only_dependencies",
2320        results.type_only_dependencies.iter().map(|item| {
2321            issue_was_introduced(
2322                &format!(
2323                    "type-only-dependency:{}:{}",
2324                    relative_key_path(&item.dep.path, root),
2325                    item.dep.package_name
2326                ),
2327                base,
2328            )
2329        }),
2330    );
2331    annotate_issue_array(
2332        json,
2333        "test_only_dependencies",
2334        results.test_only_dependencies.iter().map(|item| {
2335            issue_was_introduced(
2336                &format!(
2337                    "test-only-dependency:{}:{}",
2338                    relative_key_path(&item.dep.path, root),
2339                    item.dep.package_name
2340                ),
2341                base,
2342            )
2343        }),
2344    );
2345    annotate_issue_array(
2346        json,
2347        "circular_dependencies",
2348        results.circular_dependencies.iter().map(|item| {
2349            let mut files: Vec<String> = item
2350                .cycle
2351                .files
2352                .iter()
2353                .map(|path| relative_key_path(path, root))
2354                .collect();
2355            files.sort();
2356            issue_was_introduced(&format!("circular-dependency:{}", files.join("|")), base)
2357        }),
2358    );
2359    annotate_issue_array(
2360        json,
2361        "re_export_cycles",
2362        results.re_export_cycles.iter().map(|item| {
2363            let kind = match item.cycle.kind {
2364                fallow_core::results::ReExportCycleKind::MultiNode => "multi-node",
2365                fallow_core::results::ReExportCycleKind::SelfLoop => "self-loop",
2366            };
2367            let mut files: Vec<String> = item
2368                .cycle
2369                .files
2370                .iter()
2371                .map(|path| relative_key_path(path, root))
2372                .collect();
2373            files.sort();
2374            issue_was_introduced(&format!("re-export-cycle:{kind}:{}", files.join("|")), base)
2375        }),
2376    );
2377    annotate_issue_array(
2378        json,
2379        "boundary_violations",
2380        results.boundary_violations.iter().map(|item| {
2381            issue_was_introduced(
2382                &format!(
2383                    "boundary-violation:{}:{}:{}",
2384                    relative_key_path(&item.violation.from_path, root),
2385                    relative_key_path(&item.violation.to_path, root),
2386                    item.violation.import_specifier
2387                ),
2388                base,
2389            )
2390        }),
2391    );
2392    annotate_issue_array(
2393        json,
2394        "stale_suppressions",
2395        results.stale_suppressions.iter().map(|item| {
2396            issue_was_introduced(
2397                &format!(
2398                    "stale-suppression:{}:{}",
2399                    relative_key_path(&item.path, root),
2400                    item.description()
2401                ),
2402                base,
2403            )
2404        }),
2405    );
2406    annotate_issue_array(
2407        json,
2408        "unresolved_catalog_references",
2409        results.unresolved_catalog_references.iter().map(|item| {
2410            issue_was_introduced(
2411                &format!(
2412                    "unresolved-catalog-reference:{}:{}:{}:{}",
2413                    relative_key_path(&item.reference.path, root),
2414                    item.reference.line,
2415                    item.reference.catalog_name,
2416                    item.reference.entry_name
2417                ),
2418                base,
2419            )
2420        }),
2421    );
2422    annotate_issue_array(
2423        json,
2424        "unused_catalog_entries",
2425        results
2426            .unused_catalog_entries
2427            .iter()
2428            .map(|item| issue_was_introduced(&unused_catalog_entry_key(&item.entry, root), base)),
2429    );
2430    annotate_issue_array(
2431        json,
2432        "empty_catalog_groups",
2433        results
2434            .empty_catalog_groups
2435            .iter()
2436            .map(|item| issue_was_introduced(&empty_catalog_group_key(&item.group, root), base)),
2437    );
2438    annotate_issue_array(
2439        json,
2440        "unused_dependency_overrides",
2441        results.unused_dependency_overrides.iter().map(|item| {
2442            issue_was_introduced(
2443                &format!(
2444                    "unused-dependency-override:{}:{}:{}",
2445                    relative_key_path(&item.entry.path, root),
2446                    item.entry.line,
2447                    item.entry.raw_key
2448                ),
2449                base,
2450            )
2451        }),
2452    );
2453    annotate_issue_array(
2454        json,
2455        "misconfigured_dependency_overrides",
2456        results
2457            .misconfigured_dependency_overrides
2458            .iter()
2459            .map(|item| {
2460                issue_was_introduced(
2461                    &format!(
2462                        "misconfigured-dependency-override:{}:{}:{}",
2463                        relative_key_path(&item.entry.path, root),
2464                        item.entry.line,
2465                        item.entry.raw_key
2466                    ),
2467                    base,
2468                )
2469            }),
2470    );
2471}
2472
2473fn annotate_health_json(
2474    json: &mut serde_json::Value,
2475    report: &crate::health_types::HealthReport,
2476    root: &Path,
2477    base: &FxHashSet<String>,
2478) {
2479    let Some(items) = json
2480        .get_mut("findings")
2481        .and_then(serde_json::Value::as_array_mut)
2482    else {
2483        return;
2484    };
2485    for (item, finding) in items.iter_mut().zip(&report.findings) {
2486        if let serde_json::Value::Object(map) = item {
2487            map.insert(
2488                "introduced".to_string(),
2489                serde_json::json!(issue_was_introduced(
2490                    &health_finding_key(finding, root),
2491                    base
2492                )),
2493            );
2494        }
2495    }
2496}
2497
2498fn annotate_dupes_json(
2499    json: &mut serde_json::Value,
2500    report: &fallow_core::duplicates::DuplicationReport,
2501    root: &Path,
2502    base: &FxHashSet<String>,
2503) {
2504    let Some(items) = json
2505        .get_mut("clone_groups")
2506        .and_then(serde_json::Value::as_array_mut)
2507    else {
2508        return;
2509    };
2510    for (item, group) in items.iter_mut().zip(&report.clone_groups) {
2511        if let serde_json::Value::Object(map) = item {
2512            map.insert(
2513                "introduced".to_string(),
2514                serde_json::json!(issue_was_introduced(&dupe_group_key(group, root), base)),
2515            );
2516        }
2517    }
2518}
2519
2520fn health_keys(report: &crate::health_types::HealthReport, root: &Path) -> FxHashSet<String> {
2521    report
2522        .findings
2523        .iter()
2524        .map(|finding| health_finding_key(finding, root))
2525        .collect()
2526}
2527
2528fn health_finding_key(finding: &crate::health_types::ComplexityViolation, root: &Path) -> String {
2529    format!(
2530        "complexity:{}:{}:{:?}",
2531        relative_key_path(&finding.path, root),
2532        finding.name,
2533        finding.exceeded
2534    )
2535}
2536
2537fn dupes_keys(
2538    report: &fallow_core::duplicates::DuplicationReport,
2539    root: &Path,
2540) -> FxHashSet<String> {
2541    report
2542        .clone_groups
2543        .iter()
2544        .map(|group| dupe_group_key(group, root))
2545        .collect()
2546}
2547
2548fn dupe_group_key(group: &fallow_core::duplicates::CloneGroup, root: &Path) -> String {
2549    let mut files: Vec<String> = group
2550        .instances
2551        .iter()
2552        .map(|instance| relative_key_path(&instance.file, root))
2553        .collect();
2554    files.sort();
2555    files.dedup();
2556    let mut hasher = DefaultHasher::new();
2557    for instance in &group.instances {
2558        instance.fragment.hash(&mut hasher);
2559    }
2560    format!(
2561        "dupe:{}:{}:{}:{:x}",
2562        files.join("|"),
2563        group.token_count,
2564        group.line_count,
2565        hasher.finish()
2566    )
2567}
2568
2569// ── Execute ──────────────────────────────────────────────────────
2570
2571/// Bundle of HEAD-side analysis results returned from [`run_audit_head_analyses`].
2572///
2573/// Lets the call site move all three results out of the parallel branch in one
2574/// shot, instead of threading three tuple slots through `rayon::join`.
2575struct HeadAnalyses {
2576    check: Option<CheckResult>,
2577    dupes: Option<DupesResult>,
2578    health: Option<HealthResult>,
2579}
2580
2581/// Run the three HEAD-side analyses with intra-pipeline sharing intact:
2582/// check first (so its parsed modules are available), then dupes (which can
2583/// reuse check's discovered file list when production settings match), then
2584/// health (which can reuse check's parsed modules when production settings
2585/// match). Designed to be called from inside `rayon::join` alongside
2586/// [`compute_base_snapshot`], which operates on an isolated worktree.
2587fn run_audit_head_analyses(
2588    opts: &AuditOptions<'_>,
2589    changed_since: Option<&str>,
2590    changed_files: &FxHashSet<PathBuf>,
2591) -> Result<HeadAnalyses, ExitCode> {
2592    let check_production = opts.production_dead_code.unwrap_or(opts.production);
2593    let health_production = opts.production_health.unwrap_or(opts.production);
2594    let dupes_production = opts.production_dupes.unwrap_or(opts.production);
2595    let share_dead_code_parse_with_health = check_production == health_production;
2596    let share_dead_code_files_with_dupes =
2597        share_dead_code_parse_with_health && check_production == dupes_production;
2598
2599    let mut check = run_audit_check(opts, changed_since, share_dead_code_parse_with_health)?;
2600    let dupes_files = if share_dead_code_files_with_dupes {
2601        check
2602            .as_ref()
2603            .and_then(|r| r.shared_parse.as_ref().map(|sp| sp.files.clone()))
2604    } else {
2605        None
2606    };
2607    let dupes = run_audit_dupes(opts, changed_since, Some(changed_files), dupes_files)?;
2608    let shared_parse = if share_dead_code_parse_with_health {
2609        check.as_mut().and_then(|r| r.shared_parse.take())
2610    } else {
2611        None
2612    };
2613    let health = run_audit_health(opts, changed_since, shared_parse)?;
2614    Ok(HeadAnalyses {
2615        check,
2616        dupes,
2617        health,
2618    })
2619}
2620
2621/// Run the audit pipeline: resolve base ref, run analyses, compute verdict.
2622pub fn execute_audit(opts: &AuditOptions<'_>) -> Result<AuditResult, ExitCode> {
2623    let start = Instant::now();
2624
2625    let base_ref = resolve_base_ref(opts)?;
2626
2627    // Age-based GC of persistent reusable base-snapshot caches. Runs on
2628    // every invocation (not gated on whether this audit needs a real
2629    // base snapshot) so disk-reclaim happens even when this run is fully
2630    // cache-warm. Skipped entirely when the user sets
2631    // `FALLOW_AUDIT_CACHE_MAX_AGE_DAYS=0` or `audit.cacheMaxAgeDays = 0`.
2632    if let Some(max_age) = resolve_cache_max_age(opts) {
2633        sweep_old_reusable_caches(opts.root, max_age, opts.quiet);
2634    }
2635
2636    // Get changed files (hard error if it fails, unlike combined mode)
2637    let Some(changed_files) = crate::check::get_changed_files(opts.root, &base_ref) else {
2638        return Err(emit_error(
2639            &format!(
2640                "could not determine changed files for base ref '{base_ref}'. Verify the ref exists in this git repository"
2641            ),
2642            2,
2643            opts.output,
2644        ));
2645    };
2646    let changed_files_count = changed_files.len();
2647
2648    if changed_files.is_empty() {
2649        return Ok(empty_audit_result(base_ref, opts, start.elapsed()));
2650    }
2651
2652    let changed_since = Some(base_ref.as_str());
2653
2654    // The HEAD analyses (check + dupes + health) operate on the working tree;
2655    // the base snapshot operates on an isolated git worktree checked out at
2656    // `base_ref` (reused by SHA when possible). They share no mutable state, so
2657    // we can run them concurrently via `rayon::join`, halving wall-clock time
2658    // on `--gate new-only` (the default). Inside each branch we keep the
2659    // existing share-the-parse optimization between dead-code and health, since
2660    // check finishes before either of its dependants run.
2661    let needs_real_base_snapshot = matches!(opts.gate, AuditGate::NewOnly)
2662        && !can_reuse_current_as_base(opts, &base_ref, &changed_files);
2663    let base_cache_key = if needs_real_base_snapshot {
2664        audit_base_snapshot_cache_key(opts, &base_ref, &changed_files)?
2665    } else {
2666        None
2667    };
2668    let cached_base_snapshot = base_cache_key
2669        .as_ref()
2670        .and_then(|key| load_cached_base_snapshot(opts, key));
2671
2672    let (head_res, base_res) = if needs_real_base_snapshot && cached_base_snapshot.is_none() {
2673        let base_sha = base_cache_key.as_ref().map(|key| key.base_sha.as_str());
2674        let (h, b) = rayon::join(
2675            || run_audit_head_analyses(opts, changed_since, &changed_files),
2676            || compute_base_snapshot(opts, &base_ref, &changed_files, base_sha),
2677        );
2678        (h, Some(b))
2679    } else {
2680        (
2681            run_audit_head_analyses(opts, changed_since, &changed_files),
2682            None,
2683        )
2684    };
2685
2686    let head = head_res?;
2687    let mut check_result = head.check;
2688    let dupes_result = head.dupes;
2689    let health_result = head.health;
2690
2691    let (base_snapshot, base_snapshot_skipped) = if matches!(opts.gate, AuditGate::NewOnly) {
2692        if let Some(snapshot) = cached_base_snapshot {
2693            (Some(snapshot), false)
2694        } else if let Some(base_res) = base_res {
2695            let snapshot = base_res?;
2696            if let Some(ref key) = base_cache_key {
2697                save_cached_base_snapshot(opts, key, &snapshot);
2698            }
2699            (Some(snapshot), false)
2700        } else {
2701            (
2702                Some(current_keys_as_base_keys(
2703                    check_result.as_ref(),
2704                    dupes_result.as_ref(),
2705                    health_result.as_ref(),
2706                )),
2707                true,
2708            )
2709        }
2710    } else {
2711        (None, false)
2712    };
2713    // Drop shared parse data (no longer needed after base snapshot completed).
2714    if let Some(ref mut check) = check_result {
2715        check.shared_parse = None;
2716    }
2717    let attribution = compute_audit_attribution(
2718        check_result.as_ref(),
2719        dupes_result.as_ref(),
2720        health_result.as_ref(),
2721        base_snapshot.as_ref(),
2722        opts.gate,
2723    );
2724    let verdict = if matches!(opts.gate, AuditGate::NewOnly) {
2725        compute_introduced_verdict(
2726            check_result.as_ref(),
2727            dupes_result.as_ref(),
2728            health_result.as_ref(),
2729            base_snapshot.as_ref(),
2730        )
2731    } else {
2732        compute_verdict(
2733            check_result.as_ref(),
2734            dupes_result.as_ref(),
2735            health_result.as_ref(),
2736        )
2737    };
2738    let summary = build_summary(
2739        check_result.as_ref(),
2740        dupes_result.as_ref(),
2741        health_result.as_ref(),
2742    );
2743
2744    Ok(AuditResult {
2745        verdict,
2746        summary,
2747        attribution,
2748        base_snapshot,
2749        base_snapshot_skipped,
2750        changed_files_count,
2751        changed_files: changed_files.into_iter().collect(),
2752        base_ref,
2753        head_sha: get_head_sha(opts.root),
2754        output: opts.output,
2755        performance: opts.performance,
2756        check: check_result,
2757        dupes: dupes_result,
2758        health: health_result,
2759        elapsed: start.elapsed(),
2760    })
2761}
2762
2763/// Resolve the base ref: explicit --changed-since / --base, or auto-detect.
2764fn resolve_base_ref(opts: &AuditOptions<'_>) -> Result<String, ExitCode> {
2765    if let Some(ref_str) = opts.changed_since {
2766        return Ok(ref_str.to_string());
2767    }
2768    let Some(branch) = auto_detect_base_branch(opts.root) else {
2769        return Err(emit_error(
2770            "could not detect base branch. Use --base <ref> to specify the comparison target (e.g., --base main)",
2771            2,
2772            opts.output,
2773        ));
2774    };
2775    // Validate auto-detected branch name (explicit --changed-since is validated in main.rs)
2776    if let Err(e) = crate::validate::validate_git_ref(&branch) {
2777        return Err(emit_error(
2778            &format!("auto-detected base branch '{branch}' is not a valid git ref: {e}"),
2779            2,
2780            opts.output,
2781        ));
2782    }
2783    Ok(branch)
2784}
2785
2786/// Build an empty pass result when no files have changed.
2787fn empty_audit_result(base_ref: String, opts: &AuditOptions<'_>, elapsed: Duration) -> AuditResult {
2788    AuditResult {
2789        verdict: AuditVerdict::Pass,
2790        summary: AuditSummary {
2791            dead_code_issues: 0,
2792            dead_code_has_errors: false,
2793            complexity_findings: 0,
2794            max_cyclomatic: None,
2795            duplication_clone_groups: 0,
2796        },
2797        attribution: AuditAttribution {
2798            gate: opts.gate,
2799            ..AuditAttribution::default()
2800        },
2801        base_snapshot: None,
2802        base_snapshot_skipped: false,
2803        changed_files_count: 0,
2804        changed_files: Vec::new(),
2805        base_ref,
2806        head_sha: get_head_sha(opts.root),
2807        output: opts.output,
2808        performance: opts.performance,
2809        check: None,
2810        dupes: None,
2811        health: None,
2812        elapsed,
2813    }
2814}
2815
2816/// Run dead code analysis for the audit pipeline.
2817fn run_audit_check<'a>(
2818    opts: &'a AuditOptions<'a>,
2819    changed_since: Option<&'a str>,
2820    retain_modules_for_health: bool,
2821) -> Result<Option<CheckResult>, ExitCode> {
2822    let filters = IssueFilters::default();
2823    let trace_opts = TraceOptions {
2824        trace_export: None,
2825        trace_file: None,
2826        trace_dependency: None,
2827        performance: opts.performance,
2828    };
2829    match crate::check::execute_check(&CheckOptions {
2830        root: opts.root,
2831        config_path: opts.config_path,
2832        output: opts.output,
2833        no_cache: opts.no_cache,
2834        threads: opts.threads,
2835        quiet: opts.quiet,
2836        fail_on_issues: false,
2837        filters: &filters,
2838        changed_since,
2839        diff_index: None,
2840        use_shared_diff_index: true,
2841        baseline: opts.dead_code_baseline,
2842        save_baseline: None,
2843        sarif_file: None,
2844        production: opts.production_dead_code.unwrap_or(opts.production),
2845        production_override: opts.production_dead_code,
2846        workspace: opts.workspace,
2847        changed_workspaces: opts.changed_workspaces,
2848        group_by: opts.group_by,
2849        include_dupes: false,
2850        trace_opts: &trace_opts,
2851        explain: opts.explain,
2852        top: None,
2853        file: &[],
2854        include_entry_exports: opts.include_entry_exports,
2855        summary: false,
2856        regression_opts: crate::regression::RegressionOpts {
2857            fail_on_regression: false,
2858            tolerance: crate::regression::Tolerance::Absolute(0),
2859            regression_baseline_file: None,
2860            save_target: crate::regression::SaveRegressionTarget::None,
2861            scoped: true,
2862            quiet: opts.quiet,
2863            output: opts.output,
2864        },
2865        retain_modules_for_health,
2866        defer_performance: false,
2867    }) {
2868        Ok(r) => Ok(Some(r)),
2869        Err(code) => Err(code),
2870    }
2871}
2872
2873/// Run duplication analysis for the audit pipeline.
2874///
2875/// Reads duplication settings from the project config file so that user
2876/// options like `ignoreImports`, `crossLanguage`, and `skipLocal` are
2877/// respected (same as combined mode).
2878fn run_audit_dupes<'a>(
2879    opts: &'a AuditOptions<'a>,
2880    changed_since: Option<&'a str>,
2881    changed_files: Option<&'a FxHashSet<PathBuf>>,
2882    pre_discovered: Option<Vec<fallow_types::discover::DiscoveredFile>>,
2883) -> Result<Option<DupesResult>, ExitCode> {
2884    let dupes_cfg = match crate::load_config_for_analysis(
2885        opts.root,
2886        opts.config_path,
2887        opts.output,
2888        opts.no_cache,
2889        opts.threads,
2890        opts.production_dupes
2891            .or_else(|| opts.production.then_some(true)),
2892        opts.quiet,
2893        fallow_config::ProductionAnalysis::Dupes,
2894    ) {
2895        Ok(c) => c.duplicates,
2896        Err(code) => return Err(code),
2897    };
2898    let dupes_opts = DupesOptions {
2899        root: opts.root,
2900        config_path: opts.config_path,
2901        output: opts.output,
2902        no_cache: opts.no_cache,
2903        threads: opts.threads,
2904        quiet: opts.quiet,
2905        // The audit pipeline has already merged config + global flags into
2906        // `dupes_cfg`; pass them as explicit overrides so `build_dupes_config`
2907        // doesn't re-merge with stale toml values.
2908        mode: Some(DupesMode::from(dupes_cfg.mode)),
2909        min_tokens: Some(dupes_cfg.min_tokens),
2910        min_lines: Some(dupes_cfg.min_lines),
2911        min_occurrences: Some(dupes_cfg.min_occurrences),
2912        threshold: Some(dupes_cfg.threshold),
2913        skip_local: dupes_cfg.skip_local,
2914        cross_language: dupes_cfg.cross_language,
2915        ignore_imports: dupes_cfg.ignore_imports,
2916        top: None,
2917        baseline_path: opts.dupes_baseline,
2918        save_baseline_path: None,
2919        production: opts.production_dupes.unwrap_or(opts.production),
2920        production_override: opts.production_dupes,
2921        trace: None,
2922        changed_since,
2923        diff_index: None,
2924        use_shared_diff_index: true,
2925        changed_files,
2926        workspace: opts.workspace,
2927        changed_workspaces: opts.changed_workspaces,
2928        explain: opts.explain,
2929        explain_skipped: opts.explain_skipped,
2930        summary: false,
2931        group_by: opts.group_by,
2932        // Audit emits its own performance breakdown via the audit JSON / human
2933        // formatter; the standalone dupes panel would be redundant noise here.
2934        performance: false,
2935    };
2936    let dupes_run = if let Some(files) = pre_discovered {
2937        crate::dupes::execute_dupes_with_files(&dupes_opts, files)
2938    } else {
2939        crate::dupes::execute_dupes(&dupes_opts)
2940    };
2941    match dupes_run {
2942        Ok(r) => Ok(Some(r)),
2943        Err(code) => Err(code),
2944    }
2945}
2946
2947/// Run complexity analysis for the audit pipeline (findings only, no scores/hotspots/targets).
2948fn run_audit_health<'a>(
2949    opts: &'a AuditOptions<'a>,
2950    changed_since: Option<&'a str>,
2951    shared_parse: Option<crate::health::SharedParseData>,
2952) -> Result<Option<HealthResult>, ExitCode> {
2953    // Build runtime-coverage sidecar options when --runtime-coverage was
2954    // supplied. License JWT loading + 7/30/hard-fail grace evaluation
2955    // happen inside prepare_options; an exit here means the user is past
2956    // the hard-fail line and audit cannot proceed.
2957    let runtime_coverage = match opts.runtime_coverage {
2958        Some(path) => match crate::health::coverage::prepare_options(
2959            path,
2960            opts.min_invocations_hot,
2961            None,
2962            None,
2963            opts.output,
2964        ) {
2965            Ok(options) => Some(options),
2966            Err(code) => return Err(code),
2967        },
2968        None => None,
2969    };
2970
2971    let health_opts = HealthOptions {
2972        root: opts.root,
2973        config_path: opts.config_path,
2974        output: opts.output,
2975        no_cache: opts.no_cache,
2976        threads: opts.threads,
2977        quiet: opts.quiet,
2978        max_cyclomatic: None,
2979        max_cognitive: None,
2980        max_crap: opts.max_crap,
2981        top: None,
2982        sort: SortBy::Cyclomatic,
2983        production: opts.production_health.unwrap_or(opts.production),
2984        production_override: opts.production_health,
2985        changed_since,
2986        diff_index: None,
2987        use_shared_diff_index: true,
2988        workspace: opts.workspace,
2989        changed_workspaces: opts.changed_workspaces,
2990        baseline: opts.health_baseline,
2991        save_baseline: None,
2992        complexity: true,
2993        file_scores: false,
2994        coverage_gaps: false,
2995        config_activates_coverage_gaps: false,
2996        hotspots: false,
2997        ownership: false,
2998        ownership_emails: None,
2999        targets: false,
3000        force_full: false,
3001        score_only_output: false,
3002        enforce_coverage_gap_gate: false,
3003        effort: None,
3004        score: false,
3005        min_score: None,
3006        since: None,
3007        min_commits: None,
3008        explain: opts.explain,
3009        summary: false,
3010        save_snapshot: None,
3011        trend: false,
3012        group_by: opts.group_by,
3013        coverage: opts.coverage,
3014        coverage_root: opts.coverage_root,
3015        performance: opts.performance,
3016        min_severity: None,
3017        report_only: false,
3018        runtime_coverage,
3019    };
3020    let health_run = if let Some(shared) = shared_parse {
3021        crate::health::execute_health_with_shared_parse(&health_opts, shared)
3022    } else {
3023        crate::health::execute_health(&health_opts)
3024    };
3025    match health_run {
3026        Ok(r) => Ok(Some(r)),
3027        Err(code) => Err(code),
3028    }
3029}
3030
3031// ── Print ────────────────────────────────────────────────────────
3032
3033/// Print audit results and return the appropriate exit code.
3034#[must_use]
3035pub fn print_audit_result(result: &AuditResult, quiet: bool, explain: bool) -> ExitCode {
3036    let output = result.output;
3037
3038    let format_exit = match output {
3039        OutputFormat::Json => print_audit_json(result),
3040        OutputFormat::Human | OutputFormat::Compact | OutputFormat::Markdown => {
3041            print_audit_human(result, quiet, explain, output);
3042            ExitCode::SUCCESS
3043        }
3044        OutputFormat::Sarif => print_audit_sarif(result),
3045        OutputFormat::CodeClimate => print_audit_codeclimate(result),
3046        OutputFormat::PrCommentGithub => {
3047            let value = build_audit_codeclimate(result);
3048            report::ci::pr_comment::print_pr_comment(
3049                "audit",
3050                report::ci::pr_comment::Provider::Github,
3051                &value,
3052            )
3053        }
3054        OutputFormat::PrCommentGitlab => {
3055            let value = build_audit_codeclimate(result);
3056            report::ci::pr_comment::print_pr_comment(
3057                "audit",
3058                report::ci::pr_comment::Provider::Gitlab,
3059                &value,
3060            )
3061        }
3062        OutputFormat::ReviewGithub => {
3063            let value = build_audit_codeclimate(result);
3064            report::ci::review::print_review_envelope(
3065                "audit",
3066                report::ci::pr_comment::Provider::Github,
3067                &value,
3068            )
3069        }
3070        OutputFormat::ReviewGitlab => {
3071            let value = build_audit_codeclimate(result);
3072            report::ci::review::print_review_envelope(
3073                "audit",
3074                report::ci::pr_comment::Provider::Gitlab,
3075                &value,
3076            )
3077        }
3078        OutputFormat::Badge => {
3079            eprintln!("Error: badge format is not supported for the audit command");
3080            return ExitCode::from(2);
3081        }
3082    };
3083
3084    if format_exit != ExitCode::SUCCESS {
3085        return format_exit;
3086    }
3087
3088    match result.verdict {
3089        AuditVerdict::Fail => ExitCode::from(1),
3090        AuditVerdict::Pass | AuditVerdict::Warn => ExitCode::SUCCESS,
3091    }
3092}
3093
3094// ── Human format ─────────────────────────────────────────────────
3095
3096fn print_audit_human(result: &AuditResult, quiet: bool, explain: bool, output: OutputFormat) {
3097    let show_headers = matches!(output, OutputFormat::Human) && !quiet;
3098
3099    // Scope line (stderr)
3100    if !quiet {
3101        let scope = format_scope_line(result);
3102        eprintln!();
3103        eprintln!("{scope}");
3104    }
3105
3106    let has_check_issues = result.summary.dead_code_issues > 0;
3107    let has_health_findings = result.summary.complexity_findings > 0;
3108    let has_dupe_groups = result.summary.duplication_clone_groups > 0;
3109    let has_any_findings = has_check_issues || has_health_findings || has_dupe_groups;
3110
3111    // On fail/warn with findings: show detail sections (reuse existing renderers)
3112    if has_any_findings {
3113        if show_headers && std::io::stdout().is_terminal() {
3114            println!(
3115                "{}",
3116                "Tip: run `fallow explain <issue label>`; spaces and hyphens both work, e.g. `fallow explain unused files`."
3117                    .dimmed()
3118            );
3119            println!();
3120        }
3121
3122        // Vital signs summary line (stdout) — only when verdict is pass/warn
3123        if result.verdict != AuditVerdict::Fail && !quiet {
3124            print_audit_vital_signs(result);
3125        }
3126
3127        if has_check_issues && let Some(ref check) = result.check {
3128            if show_headers {
3129                eprintln!();
3130                eprintln!("── Dead Code ──────────────────────────────────────");
3131            }
3132            crate::check::print_check_result(
3133                check,
3134                crate::check::PrintCheckOptions {
3135                    quiet,
3136                    explain,
3137                    regression_json: false,
3138                    group_by: None,
3139                    top: None,
3140                    summary: false,
3141                    summary_heading: true,
3142                    show_explain_tip: false,
3143                },
3144            );
3145        }
3146
3147        if has_dupe_groups && let Some(ref dupes) = result.dupes {
3148            if show_headers {
3149                eprintln!();
3150                eprintln!("── Duplication ────────────────────────────────────");
3151            }
3152            crate::dupes::print_dupes_result(dupes, quiet, explain, false, true, false);
3153        }
3154
3155        if has_health_findings && let Some(ref health) = result.health {
3156            if show_headers {
3157                eprintln!();
3158                eprintln!("── Complexity ─────────────────────────────────────");
3159            }
3160            // `fallow audit` does not surface the health score / trend block
3161            // (no orientation header), so let the standalone health renderer
3162            // emit it inline like `fallow health`.
3163            crate::health::print_health_result(
3164                health, quiet, explain, None, None, false, false, true, false, false,
3165            );
3166        }
3167    }
3168
3169    if !has_dupe_groups && let Some(ref dupes) = result.dupes {
3170        crate::dupes::print_default_ignore_note(dupes, quiet);
3171        crate::dupes::print_min_occurrences_note(dupes, quiet);
3172    }
3173
3174    // Status line (stderr) — always last
3175    if !quiet {
3176        print_audit_status_line(result);
3177    }
3178}
3179
3180/// Format the scope context line.
3181fn format_scope_line(result: &AuditResult) -> String {
3182    let sha_suffix = result
3183        .head_sha
3184        .as_ref()
3185        .map_or(String::new(), |sha| format!(" ({sha}..HEAD)"));
3186    format!(
3187        "Audit scope: {} changed file{} vs {}{}",
3188        result.changed_files_count,
3189        plural(result.changed_files_count),
3190        result.base_ref,
3191        sha_suffix
3192    )
3193}
3194
3195/// Print a dimmed vital-signs line summarizing warn-only findings.
3196fn print_audit_vital_signs(result: &AuditResult) {
3197    let mut parts = Vec::new();
3198    parts.push(format!("dead code {}", result.summary.dead_code_issues));
3199    if let Some(max) = result.summary.max_cyclomatic {
3200        parts.push(format!(
3201            "complexity {} (warn, max cyclomatic: {max})",
3202            result.summary.complexity_findings
3203        ));
3204    } else {
3205        parts.push(format!("complexity {}", result.summary.complexity_findings));
3206    }
3207    parts.push(format!(
3208        "duplication {}",
3209        result.summary.duplication_clone_groups
3210    ));
3211
3212    let line = parts.join(" \u{00b7} ");
3213    println!(
3214        "{} {} {}",
3215        "\u{25a0}".dimmed(),
3216        "Metrics:".dimmed(),
3217        line.dimmed()
3218    );
3219}
3220
3221/// Build summary parts for the status line (shared between warn and fail).
3222fn build_status_parts(summary: &AuditSummary) -> Vec<String> {
3223    let mut parts = Vec::new();
3224    if summary.dead_code_issues > 0 {
3225        let n = summary.dead_code_issues;
3226        parts.push(format!("dead code: {n} issue{}", plural(n)));
3227    }
3228    if summary.complexity_findings > 0 {
3229        let n = summary.complexity_findings;
3230        parts.push(format!("complexity: {n} finding{}", plural(n)));
3231    }
3232    if summary.duplication_clone_groups > 0 {
3233        let n = summary.duplication_clone_groups;
3234        parts.push(format!("duplication: {n} clone group{}", plural(n)));
3235    }
3236    parts
3237}
3238
3239/// Print the final status line on stderr.
3240fn print_audit_status_line(result: &AuditResult) {
3241    let elapsed_str = format!("{:.2}s", result.elapsed.as_secs_f64());
3242    let n = result.changed_files_count;
3243    let files_str = format!("{n} changed file{}", plural(n));
3244
3245    match result.verdict {
3246        AuditVerdict::Pass => {
3247            eprintln!(
3248                "{}",
3249                format!("\u{2713} No issues in {files_str} ({elapsed_str})")
3250                    .green()
3251                    .bold()
3252            );
3253        }
3254        AuditVerdict::Warn => {
3255            let summary = build_status_parts(&result.summary).join(" \u{00b7} ");
3256            eprintln!(
3257                "{}",
3258                format!("\u{2713} {summary} (warn) \u{00b7} {files_str} ({elapsed_str})")
3259                    .green()
3260                    .bold()
3261            );
3262        }
3263        AuditVerdict::Fail => {
3264            let summary = build_status_parts(&result.summary).join(" \u{00b7} ");
3265            eprintln!(
3266                "{}",
3267                format!("\u{2717} {summary} \u{00b7} {files_str} ({elapsed_str})")
3268                    .red()
3269                    .bold()
3270            );
3271        }
3272    }
3273
3274    if !matches!(result.attribution.gate, AuditGate::All) {
3275        let inherited = result.attribution.dead_code_inherited
3276            + result.attribution.complexity_inherited
3277            + result.attribution.duplication_inherited;
3278        if inherited > 0 {
3279            eprintln!(
3280                "  {}",
3281                format!(
3282                    "audit gate excluded {inherited} inherited finding{} (run with --gate all to enforce)",
3283                    plural(inherited)
3284                )
3285                .dimmed()
3286            );
3287        }
3288    }
3289    if result.performance {
3290        eprintln!(
3291            "  {}",
3292            format!("base_snapshot_skipped: {}", result.base_snapshot_skipped).dimmed()
3293        );
3294    }
3295}
3296
3297// ── JSON format ──────────────────────────────────────────────────
3298
3299#[expect(
3300    clippy::cast_possible_truncation,
3301    reason = "elapsed milliseconds won't exceed u64::MAX"
3302)]
3303fn print_audit_json(result: &AuditResult) -> ExitCode {
3304    let mut obj = serde_json::Map::new();
3305    obj.insert(
3306        "schema_version".into(),
3307        serde_json::Value::Number(crate::report::SCHEMA_VERSION.into()),
3308    );
3309    obj.insert(
3310        "version".into(),
3311        serde_json::Value::String(env!("CARGO_PKG_VERSION").to_string()),
3312    );
3313    obj.insert(
3314        "command".into(),
3315        serde_json::Value::String("audit".to_string()),
3316    );
3317    obj.insert(
3318        "verdict".into(),
3319        serde_json::to_value(result.verdict).unwrap_or(serde_json::Value::Null),
3320    );
3321    obj.insert(
3322        "changed_files_count".into(),
3323        serde_json::Value::Number(result.changed_files_count.into()),
3324    );
3325    obj.insert(
3326        "base_ref".into(),
3327        serde_json::Value::String(result.base_ref.clone()),
3328    );
3329    if let Some(ref sha) = result.head_sha {
3330        obj.insert("head_sha".into(), serde_json::Value::String(sha.clone()));
3331    }
3332    obj.insert(
3333        "elapsed_ms".into(),
3334        serde_json::Value::Number(serde_json::Number::from(result.elapsed.as_millis() as u64)),
3335    );
3336    if result.performance {
3337        obj.insert(
3338            "base_snapshot_skipped".into(),
3339            serde_json::Value::Bool(result.base_snapshot_skipped),
3340        );
3341    }
3342
3343    // Summary
3344    if let Ok(summary_val) = serde_json::to_value(&result.summary) {
3345        obj.insert("summary".into(), summary_val);
3346    }
3347    if let Ok(attribution_val) = serde_json::to_value(&result.attribution) {
3348        obj.insert("attribution".into(), attribution_val);
3349    }
3350
3351    // Full sub-results
3352    if let Some(ref check) = result.check {
3353        match report::build_json_with_config_fixable(
3354            &check.results,
3355            &check.config.root,
3356            check.elapsed,
3357            check.config_fixable,
3358        ) {
3359            Ok(mut json) => {
3360                if let Some(ref base) = result.base_snapshot {
3361                    annotate_dead_code_json(
3362                        &mut json,
3363                        &check.results,
3364                        &check.config.root,
3365                        &base.dead_code,
3366                    );
3367                }
3368                obj.insert("dead_code".into(), json);
3369            }
3370            Err(e) => {
3371                return emit_error(
3372                    &format!("JSON serialization error: {e}"),
3373                    2,
3374                    OutputFormat::Json,
3375                );
3376            }
3377        }
3378    }
3379
3380    if let Some(ref dupes) = result.dupes {
3381        let payload = crate::output_dupes::DupesReportPayload::from_report(&dupes.report);
3382        match serde_json::to_value(&payload) {
3383            Ok(mut json) => {
3384                let root_prefix = format!("{}/", dupes.config.root.display());
3385                report::strip_root_prefix(&mut json, &root_prefix);
3386                if let Some(ref base) = result.base_snapshot {
3387                    annotate_dupes_json(&mut json, &dupes.report, &dupes.config.root, &base.dupes);
3388                }
3389                obj.insert("duplication".into(), json);
3390            }
3391            Err(e) => {
3392                return emit_error(
3393                    &format!("JSON serialization error: {e}"),
3394                    2,
3395                    OutputFormat::Json,
3396                );
3397            }
3398        }
3399    }
3400
3401    if let Some(ref health) = result.health {
3402        match serde_json::to_value(&health.report) {
3403            Ok(mut json) => {
3404                let root_prefix = format!("{}/", health.config.root.display());
3405                report::strip_root_prefix(&mut json, &root_prefix);
3406                if let Some(ref base) = result.base_snapshot {
3407                    annotate_health_json(
3408                        &mut json,
3409                        &health.report,
3410                        &health.config.root,
3411                        &base.health,
3412                    );
3413                }
3414                obj.insert("complexity".into(), json);
3415            }
3416            Err(e) => {
3417                return emit_error(
3418                    &format!("JSON serialization error: {e}"),
3419                    2,
3420                    OutputFormat::Json,
3421                );
3422            }
3423        }
3424    }
3425
3426    let mut output = serde_json::Value::Object(obj);
3427    report::harmonize_multi_kind_suppress_line_actions(&mut output);
3428    report::emit_json(&output, "audit")
3429}
3430
3431// ── SARIF format ─────────────────────────────────────────────────
3432
3433fn print_audit_sarif(result: &AuditResult) -> ExitCode {
3434    let mut all_runs = Vec::new();
3435
3436    if let Some(ref check) = result.check {
3437        let sarif = report::build_sarif(&check.results, &check.config.root, &check.config.rules);
3438        if let Some(runs) = sarif.get("runs").and_then(|r| r.as_array()) {
3439            all_runs.extend(runs.iter().cloned());
3440        }
3441    }
3442
3443    if let Some(ref dupes) = result.dupes
3444        && !dupes.report.clone_groups.is_empty()
3445    {
3446        let run = serde_json::json!({
3447            "tool": {
3448                "driver": {
3449                    "name": "fallow",
3450                    "version": env!("CARGO_PKG_VERSION"),
3451                    "informationUri": "https://github.com/fallow-rs/fallow",
3452                }
3453            },
3454            "automationDetails": { "id": "fallow/audit/dupes" },
3455            "results": dupes.report.clone_groups.iter().enumerate().map(|(i, g)| {
3456                serde_json::json!({
3457                    "ruleId": "fallow/code-duplication",
3458                    "level": "warning",
3459                    "message": { "text": format!("Clone group {} ({} lines, {} instances)", i + 1, g.line_count, g.instances.len()) },
3460                })
3461            }).collect::<Vec<_>>()
3462        });
3463        all_runs.push(run);
3464    }
3465
3466    if let Some(ref health) = result.health {
3467        let sarif = report::build_health_sarif(&health.report, &health.config.root);
3468        if let Some(runs) = sarif.get("runs").and_then(|r| r.as_array()) {
3469            all_runs.extend(runs.iter().cloned());
3470        }
3471    }
3472
3473    let combined = serde_json::json!({
3474        "$schema": "https://json.schemastore.org/sarif-2.1.0.json",
3475        "version": "2.1.0",
3476        "runs": all_runs,
3477    });
3478
3479    report::emit_json(&combined, "SARIF audit")
3480}
3481
3482// ── CodeClimate format ───────────────────────────────────────────
3483
3484fn print_audit_codeclimate(result: &AuditResult) -> ExitCode {
3485    let value = build_audit_codeclimate(result);
3486    report::emit_json(&value, "CodeClimate audit")
3487}
3488
3489fn build_audit_codeclimate(result: &AuditResult) -> serde_json::Value {
3490    let mut all_issues: Vec<crate::output_envelope::CodeClimateIssue> = Vec::new();
3491
3492    if let Some(ref check) = result.check {
3493        all_issues.extend(report::build_codeclimate(
3494            &check.results,
3495            &check.config.root,
3496            &check.config.rules,
3497        ));
3498    }
3499
3500    if let Some(ref dupes) = result.dupes {
3501        all_issues.extend(report::build_duplication_codeclimate(
3502            &dupes.report,
3503            &dupes.config.root,
3504        ));
3505    }
3506
3507    if let Some(ref health) = result.health {
3508        all_issues.extend(report::build_health_codeclimate(
3509            &health.report,
3510            &health.config.root,
3511        ));
3512    }
3513
3514    serde_json::to_value(&all_issues).expect("CodeClimateIssue serializes infallibly")
3515}
3516
3517// ── Entry point ──────────────────────────────────────────────────
3518
3519/// Run the full audit command: execute analyses, print results, return exit code.
3520/// Run audit, optionally tagged with a gate marker (e.g. `"pre-commit"`) so
3521/// Fallow Impact can record a containment event when the gate blocks then
3522/// clears. The marker only affects the local Impact store; it never changes
3523/// the verdict, exit code, or output.
3524pub fn run_audit(opts: &AuditOptions<'_>, gate_marker: Option<&str>) -> ExitCode {
3525    if let Err(e) = crate::health::scoring::validate_coverage_root_absolute(opts.coverage_root) {
3526        return emit_error(&e, 2, opts.output);
3527    }
3528    // Resolve the coverage input path to absolute UP FRONT, against the user's
3529    // original `--root`. The base-snapshot recursion in `compute_base_snapshot`
3530    // swaps `--root` to a temp worktree directory, so a relative path that
3531    // worked at the entry would re-resolve against the worktree (which doesn't
3532    // contain the coverage file) on the recursive pass. Resolving once at the
3533    // top means downstream `resolve_relative_to_root` calls become no-ops on
3534    // an already-absolute path, regardless of which `--root` is in effect.
3535    let coverage_resolved = opts
3536        .coverage
3537        .map(|p| crate::health::scoring::resolve_relative_to_root(p, Some(opts.root)));
3538    // Absolutize runtime_coverage at the public entry for the same
3539    // reason coverage is absolutized: `compute_base_snapshot` swaps
3540    // `opts.root` to a temp worktree directory, and any relative path
3541    // would re-resolve against that worktree on the recursive base
3542    // pass. The diff source is resolved separately by `main()` into
3543    // the process-wide shared-diff cache before audit even runs, so
3544    // it does not need entry-point absolutization here.
3545    let runtime_coverage_resolved = opts
3546        .runtime_coverage
3547        .map(|p| crate::health::scoring::resolve_relative_to_root(p, Some(opts.root)));
3548    let resolved_opts = AuditOptions {
3549        coverage: coverage_resolved.as_deref(),
3550        runtime_coverage: runtime_coverage_resolved.as_deref(),
3551        ..*opts
3552    };
3553    match execute_audit(&resolved_opts) {
3554        Ok(result) => {
3555            // Best-effort: record this run into the local Impact store. No-op
3556            // when Impact tracking is disabled; never affects exit/output.
3557            // Build the per-finding attribution input from the typed results so
3558            // impact can credit genuinely-resolved findings. `check.results`
3559            // also carries the present-suppression snapshot used to tell a fix
3560            // from a `fallow-ignore`.
3561            let mut findings = result
3562                .check
3563                .as_ref()
3564                .map(|c| crate::impact::collect_dead_code_findings(&c.results))
3565                .unwrap_or_default();
3566            if let Some(health) = result.health.as_ref() {
3567                findings.extend(crate::impact::collect_complexity_findings(&health.report));
3568            }
3569            let clones = result
3570                .dupes
3571                .as_ref()
3572                .map(|d| crate::impact::collect_clone_findings(&d.report))
3573                .unwrap_or_default();
3574            let empty_supps: Vec<fallow_core::results::ActiveSuppression> = Vec::new();
3575            let suppressions = result.check.as_ref().map_or(empty_supps.as_slice(), |c| {
3576                c.results.active_suppressions.as_slice()
3577            });
3578            let attribution = crate::impact::AttributionInput {
3579                root: opts.root,
3580                scope: crate::impact::Scope::ChangedFiles(&result.changed_files),
3581                findings,
3582                clones,
3583                suppressions,
3584            };
3585            crate::impact::record_audit_run(
3586                opts.root,
3587                &result.summary,
3588                result.verdict,
3589                gate_marker.is_some(),
3590                result.head_sha.as_deref(),
3591                env!("CARGO_PKG_VERSION"),
3592                &crate::vital_signs::chrono_timestamp(),
3593                Some(&attribution),
3594            );
3595            print_audit_result(&result, opts.quiet, opts.explain)
3596        }
3597        Err(code) => code,
3598    }
3599}
3600
3601#[cfg(test)]
3602mod tests {
3603    use super::*;
3604    use std::{fs, process::Command};
3605
3606    fn git(dir: &std::path::Path, args: &[&str]) {
3607        let output = Command::new("git")
3608            .args(args)
3609            .current_dir(dir)
3610            .env_remove("GIT_DIR")
3611            .env_remove("GIT_WORK_TREE")
3612            .env("GIT_CONFIG_GLOBAL", "/dev/null")
3613            .env("GIT_CONFIG_SYSTEM", "/dev/null")
3614            .env("GIT_AUTHOR_NAME", "test")
3615            .env("GIT_AUTHOR_EMAIL", "test@test.com")
3616            .env("GIT_COMMITTER_NAME", "test")
3617            .env("GIT_COMMITTER_EMAIL", "test@test.com")
3618            .output()
3619            .expect("git command failed");
3620        assert!(
3621            output.status.success(),
3622            "git {:?} failed\nstdout:\n{}\nstderr:\n{}",
3623            args,
3624            String::from_utf8_lossy(&output.stdout),
3625            String::from_utf8_lossy(&output.stderr)
3626        );
3627    }
3628
3629    #[test]
3630    fn audit_worktree_helpers_filter_to_fallow_temp_prefix() {
3631        let temp = std::env::temp_dir();
3632        let audit_path = temp.join("fallow-audit-base-123-456");
3633        let reusable_path = temp.join("fallow-audit-base-cache-abcd-1234");
3634        let canonical_audit_path = temp
3635            .canonicalize()
3636            .unwrap_or_else(|_| temp.clone())
3637            .join("fallow-audit-base-456-789");
3638        let unrelated_temp = temp.join("other-worktree");
3639        let output = format!(
3640            "worktree /repo\nHEAD abc\n\nworktree {}\nHEAD def\n\nworktree {}\nHEAD ghi\n\nworktree {}\nHEAD jkl\n",
3641            audit_path.display(),
3642            unrelated_temp.display(),
3643            reusable_path.display()
3644        );
3645
3646        assert_eq!(
3647            parse_worktree_list(&output),
3648            vec![audit_path, reusable_path.clone()]
3649        );
3650        assert!(is_fallow_audit_worktree_path(&canonical_audit_path));
3651        assert!(is_reusable_audit_worktree_path(&reusable_path));
3652        assert_eq!(audit_worktree_pid("fallow-audit-base-123-456"), Some(123));
3653        assert_eq!(
3654            audit_worktree_pid("fallow-audit-base-cache-abcd-1234"),
3655            None
3656        );
3657        assert_eq!(audit_worktree_pid("not-fallow-audit-base-123"), None);
3658    }
3659
3660    /// Initialize a throwaway git repo with a single commit and return its root.
3661    /// Used by the worktree-lifecycle tests below as a parent repo that can host
3662    /// `git worktree add` invocations.
3663    fn init_throwaway_repo(parent: &std::path::Path, name: &str) -> PathBuf {
3664        let root = parent.join(name);
3665        fs::create_dir_all(&root).expect("repo root should be created");
3666        fs::write(root.join("README.md"), "seed\n").expect("seed file should be written");
3667        git(&root, &["init", "-b", "main"]);
3668        git(&root, &["add", "."]);
3669        git(
3670            &root,
3671            &["-c", "commit.gpgsign=false", "commit", "-m", "initial"],
3672        );
3673        root
3674    }
3675
3676    fn worktree_is_registered_with_git(repo_root: &std::path::Path, worktree_path: &Path) -> bool {
3677        list_audit_worktrees(repo_root)
3678            .is_some_and(|paths| paths.iter().any(|p| paths_equal(p, worktree_path)))
3679    }
3680
3681    #[test]
3682    fn worktree_cleanup_guard_runs_on_drop() {
3683        let tmp = tempfile::TempDir::new().expect("temp dir should be created");
3684        let repo = init_throwaway_repo(tmp.path(), "repo");
3685        let worktree_path = tmp.path().join("fallow-audit-base-1234-5678");
3686
3687        // Register a real worktree with git so the guard's `git worktree remove`
3688        // has something concrete to roll back.
3689        git(
3690            &repo,
3691            &[
3692                "worktree",
3693                "add",
3694                "--detach",
3695                "--quiet",
3696                worktree_path.to_str().expect("path is utf-8"),
3697                "HEAD",
3698            ],
3699        );
3700        assert!(worktree_path.is_dir());
3701        assert!(worktree_is_registered_with_git(&repo, &worktree_path));
3702
3703        {
3704            let _guard = WorktreeCleanupGuard::new(&repo, &worktree_path);
3705            // Guard drops at end of scope without `defuse()`.
3706        }
3707
3708        assert!(
3709            !worktree_path.exists(),
3710            "guard Drop should remove the worktree directory",
3711        );
3712        assert!(
3713            !worktree_is_registered_with_git(&repo, &worktree_path),
3714            "guard Drop should remove the git worktree registration",
3715        );
3716    }
3717
3718    #[test]
3719    fn worktree_cleanup_guard_defused_skips_drop() {
3720        let tmp = tempfile::TempDir::new().expect("temp dir should be created");
3721        let repo = init_throwaway_repo(tmp.path(), "repo");
3722        let worktree_path = tmp.path().join("fallow-audit-base-1234-5679");
3723
3724        git(
3725            &repo,
3726            &[
3727                "worktree",
3728                "add",
3729                "--detach",
3730                "--quiet",
3731                worktree_path.to_str().expect("path is utf-8"),
3732                "HEAD",
3733            ],
3734        );
3735        assert!(worktree_path.is_dir());
3736
3737        {
3738            let mut guard = WorktreeCleanupGuard::new(&repo, &worktree_path);
3739            guard.defuse();
3740            // Idempotent: a second defuse must not panic.
3741            guard.defuse();
3742        }
3743
3744        assert!(
3745            worktree_path.is_dir(),
3746            "defused guard must not remove the worktree on drop",
3747        );
3748        assert!(
3749            worktree_is_registered_with_git(&repo, &worktree_path),
3750            "defused guard must not unregister the worktree from git",
3751        );
3752
3753        // Clean up manually so the tempdir teardown does not race git's lock files.
3754        remove_audit_worktree(&repo, &worktree_path);
3755        let _ = fs::remove_dir_all(&worktree_path);
3756    }
3757
3758    #[test]
3759    fn audit_orphan_sweep_removes_dead_pid_worktree() {
3760        // Use a PID well above all platforms' typical and maximum ranges:
3761        //   - Linux:  pid_max defaults to 32 768, max cap 4 194 304 (2^22)
3762        //   - macOS:  kern.maxproc defaults to 99 998
3763        //   - Windows: PIDs are multiples of 4; 99 999 999 mod 4 == 3, so it
3764        //     cannot be a valid Windows PID either.
3765        // 99 999 999 exceeds all three.
3766        const DEAD_PID: u32 = 99_999_999;
3767        assert!(!process_is_alive(DEAD_PID));
3768
3769        let tmp = tempfile::TempDir::new().expect("temp dir should be created");
3770        let repo = init_throwaway_repo(tmp.path(), "repo");
3771
3772        // The sweep only considers worktrees whose parent is the system temp dir.
3773        // Mirror that here so the test exercises the real filter path.
3774        let worktree_path = std::env::temp_dir().join(format!(
3775            "fallow-audit-base-{}-{}",
3776            DEAD_PID,
3777            std::time::SystemTime::now()
3778                .duration_since(std::time::UNIX_EPOCH)
3779                .expect("clock should be after epoch")
3780                .as_nanos()
3781        ));
3782        git(
3783            &repo,
3784            &[
3785                "worktree",
3786                "add",
3787                "--detach",
3788                "--quiet",
3789                worktree_path.to_str().expect("path is utf-8"),
3790                "HEAD",
3791            ],
3792        );
3793        assert!(worktree_path.is_dir());
3794        assert!(worktree_is_registered_with_git(&repo, &worktree_path));
3795
3796        sweep_orphan_audit_worktrees(&repo);
3797
3798        assert!(
3799            !worktree_path.exists(),
3800            "sweep should remove worktree owned by a dead PID",
3801        );
3802        assert!(
3803            !worktree_is_registered_with_git(&repo, &worktree_path),
3804            "sweep should unregister worktree owned by a dead PID",
3805        );
3806    }
3807
3808    #[test]
3809    fn audit_orphan_sweep_keeps_live_pid_worktree() {
3810        let live_pid = std::process::id();
3811        assert!(process_is_alive(live_pid));
3812
3813        let tmp = tempfile::TempDir::new().expect("temp dir should be created");
3814        let repo = init_throwaway_repo(tmp.path(), "repo");
3815
3816        let worktree_path = std::env::temp_dir().join(format!(
3817            "fallow-audit-base-{}-{}",
3818            live_pid,
3819            std::time::SystemTime::now()
3820                .duration_since(std::time::UNIX_EPOCH)
3821                .expect("clock should be after epoch")
3822                .as_nanos()
3823        ));
3824        git(
3825            &repo,
3826            &[
3827                "worktree",
3828                "add",
3829                "--detach",
3830                "--quiet",
3831                worktree_path.to_str().expect("path is utf-8"),
3832                "HEAD",
3833            ],
3834        );
3835
3836        sweep_orphan_audit_worktrees(&repo);
3837
3838        assert!(
3839            worktree_path.is_dir(),
3840            "sweep must not remove worktree owned by a live PID",
3841        );
3842        assert!(
3843            worktree_is_registered_with_git(&repo, &worktree_path),
3844            "sweep must not unregister worktree owned by a live PID",
3845        );
3846
3847        // Tear down the live-PID worktree so it does not leak across tests.
3848        remove_audit_worktree(&repo, &worktree_path);
3849        let _ = fs::remove_dir_all(&worktree_path);
3850    }
3851
3852    /// Build a reusable-shaped worktree path inside the system tempdir
3853    /// (so `is_reusable_audit_worktree_path` and `path_is_inside_temp_dir`
3854    /// both match), uniquified by nanos so parallel tests do not collide.
3855    fn make_reusable_path(label: &str) -> PathBuf {
3856        let nanos = std::time::SystemTime::now()
3857            .duration_since(std::time::UNIX_EPOCH)
3858            .expect("clock should be after epoch")
3859            .as_nanos();
3860        std::env::temp_dir().join(format!("fallow-audit-base-cache-{label}-{nanos:032x}"))
3861    }
3862
3863    /// Register a worktree with the parent repo at `path` checked out at HEAD.
3864    /// Mirrors what `BaseWorktree::reuse_or_create` does for the fresh-create
3865    /// path so the GC sweep tests can build real cache entries.
3866    fn register_reusable_worktree(repo: &Path, path: &Path) {
3867        git(
3868            repo,
3869            &[
3870                "worktree",
3871                "add",
3872                "--detach",
3873                "--quiet",
3874                path.to_str().expect("path is utf-8"),
3875                "HEAD",
3876            ],
3877        );
3878    }
3879
3880    fn write_sidecar_with_age(path: &Path, age: Duration) {
3881        let sidecar = reusable_worktree_last_used_path(path);
3882        let file = std::fs::OpenOptions::new()
3883            .create(true)
3884            .truncate(false)
3885            .write(true)
3886            .open(&sidecar)
3887            .expect("sidecar should open");
3888        let when = SystemTime::now()
3889            .checked_sub(age)
3890            .expect("backdated time should fit in SystemTime");
3891        file.set_modified(when)
3892            .expect("set_modified should succeed");
3893    }
3894
3895    /// Tear down a reusable worktree (git registration + dir + sidecar + lock)
3896    /// regardless of which of those the test created. Idempotent.
3897    fn cleanup_reusable_worktree(repo: &Path, path: &Path) {
3898        remove_audit_worktree(repo, path);
3899        let _ = fs::remove_dir_all(path);
3900        let _ = fs::remove_file(reusable_worktree_last_used_path(path));
3901        let _ = fs::remove_file(reusable_worktree_lock_path(path));
3902    }
3903
3904    #[test]
3905    fn reusable_cache_gc_removes_old_entry_with_backdated_sidecar() {
3906        let tmp = tempfile::TempDir::new().expect("temp dir should be created");
3907        let repo = init_throwaway_repo(tmp.path(), "repo-gc-remove");
3908        let worktree_path = make_reusable_path("gc-remove");
3909        register_reusable_worktree(&repo, &worktree_path);
3910        write_sidecar_with_age(&worktree_path, Duration::from_hours(31 * 24));
3911
3912        sweep_old_reusable_caches(&repo, Duration::from_hours(30 * 24), true);
3913
3914        assert!(
3915            !worktree_path.exists(),
3916            "sweep should remove worktree dir whose sidecar is older than the threshold",
3917        );
3918        assert!(
3919            !worktree_is_registered_with_git(&repo, &worktree_path),
3920            "sweep should unregister the worktree from git",
3921        );
3922        assert!(
3923            !reusable_worktree_last_used_path(&worktree_path).exists(),
3924            "sweep should remove the sidecar `.last-used` file alongside the worktree",
3925        );
3926        // Lock file may or may not exist; it is created only when
3927        // `try_acquire` is called. We do NOT assert on it here.
3928        cleanup_reusable_worktree(&repo, &worktree_path);
3929    }
3930
3931    #[test]
3932    fn reusable_cache_gc_keeps_fresh_entry() {
3933        let tmp = tempfile::TempDir::new().expect("temp dir should be created");
3934        let repo = init_throwaway_repo(tmp.path(), "repo-gc-keep");
3935        let worktree_path = make_reusable_path("gc-keep");
3936        register_reusable_worktree(&repo, &worktree_path);
3937        write_sidecar_with_age(&worktree_path, Duration::from_mins(1));
3938
3939        sweep_old_reusable_caches(&repo, Duration::from_hours(30 * 24), true);
3940
3941        assert!(
3942            worktree_path.is_dir(),
3943            "sweep must not remove a worktree whose sidecar is fresher than the threshold",
3944        );
3945        assert!(
3946            worktree_is_registered_with_git(&repo, &worktree_path),
3947            "sweep must not unregister a fresh worktree",
3948        );
3949        cleanup_reusable_worktree(&repo, &worktree_path);
3950    }
3951
3952    #[test]
3953    fn reusable_cache_gc_skips_locked_entry() {
3954        let tmp = tempfile::TempDir::new().expect("temp dir should be created");
3955        let repo = init_throwaway_repo(tmp.path(), "repo-gc-locked");
3956        let worktree_path = make_reusable_path("gc-locked");
3957        register_reusable_worktree(&repo, &worktree_path);
3958        write_sidecar_with_age(&worktree_path, Duration::from_hours(31 * 24));
3959
3960        // Hold the lock from this thread so the sweep's `try_acquire`
3961        // observes contention and skips the entry. Drop after the sweep.
3962        let lock = ReusableWorktreeLock::try_acquire(&worktree_path)
3963            .expect("test should acquire the lock first");
3964
3965        sweep_old_reusable_caches(&repo, Duration::from_hours(30 * 24), true);
3966
3967        assert!(
3968            worktree_path.is_dir(),
3969            "sweep must skip a locked entry even when its sidecar is stale",
3970        );
3971        assert!(
3972            worktree_is_registered_with_git(&repo, &worktree_path),
3973            "sweep must not unregister a locked entry",
3974        );
3975        drop(lock);
3976        cleanup_reusable_worktree(&repo, &worktree_path);
3977    }
3978
3979    #[test]
3980    fn reusable_cache_gc_grace_when_sidecar_absent() {
3981        let tmp = tempfile::TempDir::new().expect("temp dir should be created");
3982        let repo = init_throwaway_repo(tmp.path(), "repo-gc-grace");
3983        let worktree_path = make_reusable_path("gc-grace");
3984        register_reusable_worktree(&repo, &worktree_path);
3985        // No sidecar written. Backdate the dir's own mtime so that "fall back
3986        // to dir mtime" would falsely trigger removal; the grace path must
3987        // NOT consult dir mtime.
3988        // (Skipping dir mtime backdate is fine: the implementation never
3989        // reads it, so the assertion is structural: sidecar absent => keep.)
3990        let sidecar = reusable_worktree_last_used_path(&worktree_path);
3991        assert!(
3992            !sidecar.exists(),
3993            "test pre-condition: sidecar should not exist",
3994        );
3995
3996        sweep_old_reusable_caches(&repo, Duration::from_hours(30 * 24), true);
3997
3998        assert!(
3999            worktree_path.is_dir(),
4000            "pre-upgrade grace: sidecar-absent entries must NOT be removed on first encounter",
4001        );
4002        assert!(
4003            sidecar.exists(),
4004            "pre-upgrade grace: sidecar must be seeded so the next run can age from real last-used",
4005        );
4006        let mtime = std::fs::metadata(&sidecar)
4007            .and_then(|m| m.modified())
4008            .expect("seeded sidecar should have a readable mtime");
4009        let age = SystemTime::now()
4010            .duration_since(mtime)
4011            .unwrap_or(Duration::ZERO);
4012        assert!(
4013            age < Duration::from_mins(1),
4014            "seeded sidecar mtime should be near `now()`, got age {age:?}",
4015        );
4016        cleanup_reusable_worktree(&repo, &worktree_path);
4017    }
4018
4019    #[test]
4020    fn reusable_cache_gc_preserves_lock_file_after_removal() {
4021        // Lock-file lifecycle invariant: the sweep MUST NOT delete the
4022        // `.lock` file. If it did, a sibling acquirer holding a kernel
4023        // flock on the now-unlinked inode could race with a later
4024        // `open(O_CREAT)` that produces a fresh inode at the same path,
4025        // letting two processes hold "the lock" concurrently on
4026        // different inodes.
4027        let tmp = tempfile::TempDir::new().expect("temp dir should be created");
4028        let repo = init_throwaway_repo(tmp.path(), "repo-gc-lockfile");
4029        let worktree_path = make_reusable_path("gc-lockfile");
4030        register_reusable_worktree(&repo, &worktree_path);
4031        write_sidecar_with_age(&worktree_path, Duration::from_hours(31 * 24));
4032        // Create the lock file by attempting (and immediately dropping) a lock.
4033        // This mirrors the file shape `ReusableWorktreeLock::try_acquire`
4034        // leaves behind under normal usage.
4035        let lock_path = reusable_worktree_lock_path(&worktree_path);
4036        drop(
4037            ReusableWorktreeLock::try_acquire(&worktree_path)
4038                .expect("test should acquire the lock"),
4039        );
4040        assert!(
4041            lock_path.exists(),
4042            "test pre-condition: lock file should exist before sweep",
4043        );
4044
4045        sweep_old_reusable_caches(&repo, Duration::from_hours(30 * 24), true);
4046
4047        assert!(
4048            !worktree_path.exists(),
4049            "sweep should still remove the worktree directory",
4050        );
4051        assert!(
4052            lock_path.exists(),
4053            "sweep MUST NOT delete the `.lock` file (lock-lifecycle invariant)",
4054        );
4055        let _ = fs::remove_file(&lock_path);
4056        cleanup_reusable_worktree(&repo, &worktree_path);
4057    }
4058
4059    #[test]
4060    fn reuse_or_create_stamps_sidecar_on_fresh_create_and_age_threshold_applies() {
4061        // Documented contract on `cache_max_age_days`: "Maximum age (in days
4062        // since last reuse or fresh create)". This test pins both halves:
4063        // (a) a fresh `reuse_or_create` writes the sidecar with a near-now
4064        //     mtime, AND
4065        // (b) backdating that sidecar past the threshold causes the next
4066        //     sweep to actually remove the entry. Without (a), one-off
4067        //     base SHAs would persist through the first sweep regardless
4068        //     of age, contradicting the contract.
4069        let tmp = tempfile::TempDir::new().expect("temp dir should be created");
4070        let repo = init_throwaway_repo(tmp.path(), "repo-fresh-create-stamp");
4071        let base_sha = git_rev_parse(&repo, "HEAD").expect("HEAD should resolve");
4072
4073        let worktree = BaseWorktree::reuse_or_create(&repo, &base_sha)
4074            .expect("fresh reuse_or_create should succeed on a clean repo");
4075        let cache_path = worktree.path().to_path_buf();
4076        let sidecar = reusable_worktree_last_used_path(&cache_path);
4077
4078        assert!(
4079            sidecar.exists(),
4080            "fresh-create must write the sidecar so age is measured from now",
4081        );
4082        let initial_age = std::fs::metadata(&sidecar)
4083            .and_then(|m| m.modified())
4084            .ok()
4085            .and_then(|mtime| SystemTime::now().duration_since(mtime).ok())
4086            .expect("sidecar mtime should be readable and not in the future");
4087        assert!(
4088            initial_age < Duration::from_mins(1),
4089            "fresh-create sidecar mtime should be near now(), got age {initial_age:?}",
4090        );
4091
4092        // Drop the worktree handle so the persistent cache survives but we
4093        // can mutate the sidecar.
4094        drop(worktree);
4095
4096        // Backdate the sidecar past the threshold; sweep must now remove it.
4097        write_sidecar_with_age(&cache_path, Duration::from_hours(31 * 24));
4098        sweep_old_reusable_caches(&repo, Duration::from_hours(30 * 24), true);
4099
4100        assert!(
4101            !cache_path.exists(),
4102            "after backdating, sweep must remove the fresh-created cache",
4103        );
4104        assert!(
4105            !sidecar.exists(),
4106            "sweep should remove the sidecar alongside the cache dir",
4107        );
4108        cleanup_reusable_worktree(&repo, &cache_path);
4109    }
4110
4111    #[test]
4112    fn days_to_duration_zero_disables() {
4113        assert!(days_to_duration(0).is_none());
4114        assert_eq!(days_to_duration(1), Some(Duration::from_hours(24)));
4115        assert_eq!(days_to_duration(30), Some(Duration::from_hours(30 * 24)));
4116    }
4117
4118    #[test]
4119    fn reusable_worktree_last_used_path_lives_next_to_cache_dir() {
4120        let cache_dir = std::env::temp_dir().join("fallow-audit-base-cache-abcd-1234");
4121        let sidecar = reusable_worktree_last_used_path(&cache_dir);
4122        assert_eq!(sidecar.parent(), cache_dir.parent());
4123        assert_eq!(
4124            sidecar.file_name().and_then(|s| s.to_str()),
4125            Some("fallow-audit-base-cache-abcd-1234.last-used"),
4126        );
4127    }
4128
4129    #[test]
4130    fn touch_last_used_creates_sidecar_if_missing() {
4131        let tmp = tempfile::TempDir::new().expect("temp dir should be created");
4132        let cache_dir = tmp.path().join("fallow-audit-base-cache-touchtest-0000");
4133        fs::create_dir(&cache_dir).expect("cache dir should be created");
4134        let sidecar = reusable_worktree_last_used_path(&cache_dir);
4135        assert!(!sidecar.exists(), "sidecar should not exist before touch");
4136
4137        touch_last_used(&cache_dir);
4138
4139        assert!(sidecar.exists(), "touch should create the sidecar");
4140        let mtime = fs::metadata(&sidecar)
4141            .and_then(|m| m.modified())
4142            .expect("sidecar should have an mtime");
4143        let age = SystemTime::now()
4144            .duration_since(mtime)
4145            .unwrap_or(Duration::ZERO);
4146        assert!(
4147            age < Duration::from_mins(1),
4148            "touched sidecar should be near `now()`",
4149        );
4150    }
4151
4152    #[test]
4153    fn reusable_worktree_lock_excludes_concurrent_acquires() {
4154        let tmp = tempfile::TempDir::new().expect("temp dir should be created");
4155        // Use a stable reusable-path-shaped value inside the tempdir so the
4156        // lock file lives somewhere we can clean up automatically.
4157        let reusable = tmp.path().join("fallow-audit-base-cache-deadbeef-0000");
4158        let lock_path = reusable_worktree_lock_path(&reusable);
4159
4160        let first = ReusableWorktreeLock::try_acquire(&reusable)
4161            .expect("first acquire on a fresh path should succeed");
4162        assert!(
4163            ReusableWorktreeLock::try_acquire(&reusable).is_none(),
4164            "second acquire must fail while the first is held",
4165        );
4166        // Don't assert that a same-process re-acquire-after-drop succeeds:
4167        // macOS flock(2) can keep the lock visible to other open file
4168        // descriptions in the same process for a brief window after close,
4169        // and this test would flake under parallel `cargo test` execution.
4170        // The cross-process release path is exercised by every real `fallow
4171        // audit` invocation; the in-process exclusion above is the actual
4172        // invariant we need to guarantee here.
4173        drop(first);
4174        // The lock file inode persists after the holder drops; only the
4175        // kernel lock state is released. Anchor that so future maintainers
4176        // don't conflate "release" with "delete".
4177        assert!(
4178            lock_path.exists(),
4179            "lock file must persist after drop (only the kernel lock is released)",
4180        );
4181    }
4182
4183    #[test]
4184    fn base_analysis_root_preserves_repo_subdirectory_roots() {
4185        let tmp = tempfile::TempDir::new().expect("temp dir should be created");
4186        let repo = tmp.path().join("repo");
4187        let app_root = repo.join("apps/mobile");
4188        let base_worktree = tmp.path().join("base-worktree");
4189        fs::create_dir_all(&app_root).expect("app root should be created");
4190        fs::create_dir_all(&base_worktree).expect("base worktree should be created");
4191        git(&repo, &["init", "-b", "main"]);
4192
4193        assert_eq!(
4194            base_analysis_root(&app_root, &base_worktree),
4195            base_worktree.join("apps/mobile")
4196        );
4197    }
4198
4199    #[test]
4200    fn audit_base_worktree_reuses_current_node_modules_context() {
4201        let tmp = tempfile::TempDir::new().expect("temp dir should be created");
4202        let root = tmp.path();
4203        fs::create_dir_all(root.join("src")).expect("src dir should be created");
4204        fs::write(root.join(".gitignore"), "node_modules\n.fallow\n")
4205            .expect("gitignore should be written");
4206        fs::write(
4207            root.join("package.json"),
4208            r#"{"name":"audit-rn-alias","main":"src/index.ts","dependencies":{"@react-native/typescript-config":"1.0.0"}}"#,
4209        )
4210        .expect("package.json should be written");
4211        fs::write(
4212            root.join("tsconfig.json"),
4213            r#"{"extends":"./node_modules/@react-native/typescript-config/tsconfig.json","compilerOptions":{"baseUrl":".","paths":{"@/*":["src/*"]}},"include":["src"]}"#,
4214        )
4215        .expect("tsconfig should be written");
4216        fs::write(
4217            root.join("src/index.ts"),
4218            "import { used } from '@/feature';\nconsole.log(used);\n",
4219        )
4220        .expect("index should be written");
4221        fs::write(root.join("src/feature.ts"), "export const used = 1;\n")
4222            .expect("feature should be written");
4223
4224        git(root, &["init", "-b", "main"]);
4225        git(root, &["add", "."]);
4226        git(
4227            root,
4228            &["-c", "commit.gpgsign=false", "commit", "-m", "initial"],
4229        );
4230
4231        let rn_config = root.join("node_modules/@react-native/typescript-config");
4232        fs::create_dir_all(&rn_config).expect("node_modules config dir should be created");
4233        fs::write(
4234            rn_config.join("tsconfig.json"),
4235            r#"{"compilerOptions":{"jsx":"react-native","moduleResolution":"bundler"}}"#,
4236        )
4237        .expect("node_modules tsconfig should be written");
4238
4239        let worktree =
4240            BaseWorktree::create(root, "HEAD", None).expect("base worktree should be created");
4241        assert!(
4242            worktree.path().join("node_modules").is_dir(),
4243            "base worktree should reuse ignored node_modules from the current checkout"
4244        );
4245        assert!(
4246            worktree
4247                .path()
4248                .join("node_modules/@react-native/typescript-config/tsconfig.json")
4249                .is_file(),
4250            "base worktree should preserve tsconfig extends targets installed in node_modules"
4251        );
4252    }
4253
4254    /// Confirms `materialize_base_dependency_context` symlinks the Nuxt
4255    /// `.nuxt/` generated dir from the host checkout into the audit base
4256    /// worktree. Without this, root `tsconfig.json` `references` entries
4257    /// pointing into `.nuxt/tsconfig.app.json` break in the base pass and
4258    /// emit "Nuxt project missing .nuxt/tsconfig.json" plus "Broken tsconfig
4259    /// chain" warnings. The function is exercised directly here rather than
4260    /// through `BaseWorktree::create` to avoid the `git worktree add`
4261    /// concurrency-flakiness the worktree-level integration tests already
4262    /// exhibit.
4263    #[test]
4264    fn materialize_base_dependency_context_symlinks_nuxt_generated_dir() {
4265        let host = tempfile::TempDir::new().expect("host tempdir should be created");
4266        let worktree = tempfile::TempDir::new().expect("worktree tempdir should be created");
4267
4268        let dot_nuxt = host.path().join(".nuxt");
4269        fs::create_dir_all(&dot_nuxt).expect(".nuxt dir should be created");
4270        fs::write(dot_nuxt.join("tsconfig.json"), r#"{"compilerOptions":{}}"#)
4271            .expect(".nuxt/tsconfig.json should be written");
4272        fs::write(
4273            dot_nuxt.join("tsconfig.app.json"),
4274            r#"{"compilerOptions":{}}"#,
4275        )
4276        .expect(".nuxt/tsconfig.app.json should be written");
4277
4278        materialize_base_dependency_context(host.path(), worktree.path());
4279
4280        let mirrored = worktree.path().join(".nuxt");
4281        assert!(
4282            mirrored.is_dir(),
4283            "base worktree should reuse the ignored .nuxt dir from the host checkout"
4284        );
4285        let link_meta = fs::symlink_metadata(&mirrored)
4286            .expect(".nuxt entry should exist as a symlink in the worktree");
4287        assert!(
4288            link_meta.file_type().is_symlink(),
4289            "base worktree's .nuxt should be a symlink to the host checkout"
4290        );
4291        assert!(
4292            mirrored.join("tsconfig.json").is_file(),
4293            "base worktree should expose .nuxt/tsconfig.json so the Nuxt meta-framework \
4294             prerequisite check stays quiet"
4295        );
4296        assert!(
4297            mirrored.join("tsconfig.app.json").is_file(),
4298            "base worktree should expose .nuxt/tsconfig.app.json so root tsconfig references \
4299             resolve without falling back to resolver-less resolution"
4300        );
4301    }
4302
4303    /// Confirms the same symlink treatment for Astro's `.astro/` generated
4304    /// types directory, which is gitignored by default and would otherwise
4305    /// trip the "Astro project missing .astro/" prerequisite check on the
4306    /// base pass.
4307    #[test]
4308    fn materialize_base_dependency_context_symlinks_astro_generated_dir() {
4309        let host = tempfile::TempDir::new().expect("host tempdir should be created");
4310        let worktree = tempfile::TempDir::new().expect("worktree tempdir should be created");
4311
4312        let dot_astro = host.path().join(".astro");
4313        fs::create_dir_all(&dot_astro).expect(".astro dir should be created");
4314        fs::write(dot_astro.join("types.d.ts"), "// generated types\n")
4315            .expect(".astro/types.d.ts should be written");
4316
4317        materialize_base_dependency_context(host.path(), worktree.path());
4318
4319        let mirrored = worktree.path().join(".astro");
4320        assert!(
4321            mirrored.is_dir(),
4322            "base worktree should reuse the ignored .astro dir from the host checkout"
4323        );
4324        assert!(
4325            mirrored.join("types.d.ts").is_file(),
4326            "base worktree should expose generated Astro types so the Astro meta-framework \
4327             prerequisite check stays quiet"
4328        );
4329    }
4330
4331    /// Confirms the symlink step is a no-op when the host checkout has no
4332    /// meta-framework output. We must not fabricate a dangling `.nuxt`
4333    /// symlink: the Nuxt prerequisite check would then pass on the base pass
4334    /// while the actual `.nuxt/tsconfig.json` still doesn't exist, hiding a
4335    /// real "run `nuxt prepare`" warning on the HEAD pass behind a
4336    /// process-wide dedupe key.
4337    #[test]
4338    fn materialize_base_dependency_context_skips_when_host_lacks_meta_framework_dir() {
4339        let host = tempfile::TempDir::new().expect("host tempdir should be created");
4340        let worktree = tempfile::TempDir::new().expect("worktree tempdir should be created");
4341
4342        materialize_base_dependency_context(host.path(), worktree.path());
4343
4344        assert!(
4345            !worktree.path().join(".nuxt").exists(),
4346            "base worktree should not fabricate a .nuxt symlink when the host has no .nuxt dir"
4347        );
4348        assert!(
4349            !worktree.path().join(".astro").exists(),
4350            "base worktree should not fabricate a .astro symlink when the host has no .astro dir"
4351        );
4352        assert!(
4353            !worktree.path().join("node_modules").exists(),
4354            "base worktree should not fabricate a node_modules symlink when the host has none"
4355        );
4356    }
4357
4358    /// Confirms each entry in `MATERIALIZED_CONTEXT_DIRS` is independent: a
4359    /// missing host `.nuxt/` must not prevent `node_modules` from being
4360    /// symlinked when only one of the two is present on the host.
4361    #[test]
4362    fn materialize_base_dependency_context_handles_each_dir_independently() {
4363        let host = tempfile::TempDir::new().expect("host tempdir should be created");
4364        let worktree = tempfile::TempDir::new().expect("worktree tempdir should be created");
4365
4366        fs::create_dir_all(host.path().join("node_modules"))
4367            .expect("host node_modules should be created");
4368
4369        materialize_base_dependency_context(host.path(), worktree.path());
4370
4371        assert!(
4372            worktree.path().join("node_modules").is_dir(),
4373            "node_modules should still be symlinked even when host has no .nuxt or .astro"
4374        );
4375        assert!(
4376            !worktree.path().join(".nuxt").exists(),
4377            "missing host .nuxt should leave the worktree slot empty"
4378        );
4379    }
4380
4381    /// Confirms a real (non-symlink) generated dir already present in the base
4382    /// worktree is preserved, not clobbered by a host symlink. A base commit
4383    /// that genuinely tracks `.nuxt/` is base-shaped and authoritative; the
4384    /// host-symlink shortcut only fills the gap when the worktree slot is
4385    /// empty (or a stale dangling symlink), so the `destination.is_dir()`
4386    /// early-continue must keep the worktree's own contents.
4387    #[test]
4388    fn materialize_base_dependency_context_preserves_real_worktree_dir() {
4389        let host = tempfile::TempDir::new().expect("host tempdir should be created");
4390        let worktree = tempfile::TempDir::new().expect("worktree tempdir should be created");
4391
4392        let host_nuxt = host.path().join(".nuxt");
4393        fs::create_dir_all(&host_nuxt).expect("host .nuxt dir should be created");
4394        fs::write(host_nuxt.join("tsconfig.json"), r#"{"_source":"host"}"#)
4395            .expect("host .nuxt/tsconfig.json should be written");
4396
4397        let worktree_nuxt = worktree.path().join(".nuxt");
4398        fs::create_dir_all(&worktree_nuxt).expect("worktree .nuxt dir should be created");
4399        fs::write(worktree_nuxt.join("tsconfig.json"), r#"{"_source":"base"}"#)
4400            .expect("worktree .nuxt/tsconfig.json should be written");
4401
4402        materialize_base_dependency_context(host.path(), worktree.path());
4403
4404        let link_meta = fs::symlink_metadata(&worktree_nuxt)
4405            .expect(".nuxt entry should still exist in the worktree");
4406        assert!(
4407            !link_meta.file_type().is_symlink(),
4408            "a real base-tracked .nuxt dir must not be replaced by a host symlink"
4409        );
4410        let contents =
4411            fs::read_to_string(worktree_nuxt.join("tsconfig.json")).expect("tsconfig should read");
4412        assert!(
4413            contents.contains("base"),
4414            "base worktree's own .nuxt contents must survive, not be overwritten by the host's"
4415        );
4416    }
4417
4418    #[test]
4419    fn audit_reusable_base_worktree_refreshes_current_node_modules_context() {
4420        let tmp = tempfile::TempDir::new().expect("temp dir should be created");
4421        let root = tmp.path();
4422        fs::write(root.join(".gitignore"), "node_modules\n.fallow\n")
4423            .expect("gitignore should be written");
4424        fs::write(root.join("package.json"), r#"{"name":"audit-reusable"}"#)
4425            .expect("package.json should be written");
4426
4427        git(root, &["init", "-b", "main"]);
4428        git(root, &["add", "."]);
4429        git(
4430            root,
4431            &["-c", "commit.gpgsign=false", "commit", "-m", "initial"],
4432        );
4433
4434        let rn_config = root.join("node_modules/@react-native/typescript-config");
4435        fs::create_dir_all(&rn_config).expect("node_modules config dir should be created");
4436        fs::write(rn_config.join("tsconfig.json"), "{}")
4437            .expect("node_modules tsconfig should be written");
4438
4439        let base_sha = git_rev_parse(root, "HEAD").expect("HEAD should resolve");
4440        let first = BaseWorktree::create(root, "HEAD", Some(&base_sha))
4441            .expect("persistent base worktree should be created");
4442        let worktree_path = first.path().to_path_buf();
4443        assert!(
4444            worktree_path.join("node_modules").is_dir(),
4445            "initial persistent worktree should receive node_modules context"
4446        );
4447        remove_node_modules_context(&worktree_path);
4448        assert!(
4449            !worktree_path.join("node_modules").exists(),
4450            "test setup should remove the dependency context from the reusable worktree"
4451        );
4452        drop(first);
4453
4454        let reused = BaseWorktree::create(root, "HEAD", Some(&base_sha))
4455            .expect("ready persistent base worktree should be reused");
4456        assert_eq!(reused.path(), worktree_path.as_path());
4457        assert!(
4458            reused.path().join("node_modules").is_dir(),
4459            "ready persistent worktree should refresh missing node_modules context"
4460        );
4461
4462        remove_audit_worktree(root, reused.path());
4463        let _ = fs::remove_dir_all(reused.path());
4464    }
4465
4466    fn remove_node_modules_context(worktree_path: &Path) {
4467        let path = worktree_path.join("node_modules");
4468        let Ok(metadata) = fs::symlink_metadata(&path) else {
4469            return;
4470        };
4471        if metadata.file_type().is_symlink() {
4472            #[cfg(unix)]
4473            let _ = fs::remove_file(path);
4474            #[cfg(windows)]
4475            let _ = fs::remove_dir(&path).or_else(|_| fs::remove_file(&path));
4476        } else {
4477            let _ = fs::remove_dir_all(path);
4478        }
4479    }
4480
4481    #[test]
4482    fn audit_base_snapshot_cache_payload_roundtrips_sets() {
4483        let key = AuditBaseSnapshotCacheKey {
4484            hash: 42,
4485            base_sha: "abc123".to_string(),
4486        };
4487        let snapshot = AuditKeySnapshot {
4488            dead_code: ["dead:a".to_string(), "dead:b".to_string()]
4489                .into_iter()
4490                .collect(),
4491            health: std::iter::once("health:a".to_string()).collect(),
4492            dupes: ["dupe:a".to_string(), "dupe:b".to_string()]
4493                .into_iter()
4494                .collect(),
4495        };
4496
4497        let cached = cached_from_snapshot(&key, &snapshot);
4498        assert_eq!(cached.version, AUDIT_BASE_SNAPSHOT_CACHE_VERSION);
4499        assert_eq!(cached.key_hash, key.hash);
4500        assert_eq!(cached.base_sha, key.base_sha);
4501        assert_eq!(cached.dead_code, vec!["dead:a", "dead:b"]);
4502
4503        let decoded = snapshot_from_cached(cached);
4504        assert_eq!(decoded.dead_code, snapshot.dead_code);
4505        assert_eq!(decoded.health, snapshot.health);
4506        assert_eq!(decoded.dupes, snapshot.dupes);
4507    }
4508
4509    #[test]
4510    fn audit_base_snapshot_cache_key_includes_extended_config() {
4511        let tmp = tempfile::TempDir::new().expect("temp dir should be created");
4512        let root = tmp.path();
4513        fs::write(
4514            root.join(".fallowrc.json"),
4515            r#"{"extends":"base.json","entry":["src/index.ts"]}"#,
4516        )
4517        .expect("config should be written");
4518        fs::write(
4519            root.join("base.json"),
4520            r#"{"rules":{"unused-exports":"off"}}"#,
4521        )
4522        .expect("base config should be written");
4523
4524        let config_path = None;
4525        let opts = AuditOptions {
4526            root,
4527            config_path: &config_path,
4528            output: OutputFormat::Json,
4529            no_cache: false,
4530            threads: 1,
4531            quiet: true,
4532            changed_since: Some("HEAD"),
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        };
4554
4555        let first = config_file_fingerprint(&opts).expect("fingerprint should be computed");
4556        fs::write(
4557            root.join("base.json"),
4558            r#"{"rules":{"unused-exports":"error"}}"#,
4559        )
4560        .expect("base config should be updated");
4561        let second = config_file_fingerprint(&opts).expect("fingerprint should be recomputed");
4562
4563        assert_ne!(
4564            first["resolved_hash"], second["resolved_hash"],
4565            "extended config changes must invalidate cached base snapshots"
4566        );
4567    }
4568
4569    #[test]
4570    fn audit_gate_all_skips_base_snapshot() {
4571        let tmp = tempfile::TempDir::new().expect("temp dir should be created");
4572        let root = tmp.path();
4573        fs::create_dir_all(root.join("src")).expect("src dir should be created");
4574        fs::write(
4575            root.join("package.json"),
4576            r#"{"name":"audit-gate-all","main":"src/index.ts"}"#,
4577        )
4578        .expect("package.json should be written");
4579        fs::write(root.join("src/index.ts"), "export const legacy = 1;\n")
4580            .expect("index should be written");
4581
4582        git(root, &["init", "-b", "main"]);
4583        git(root, &["add", "."]);
4584        git(
4585            root,
4586            &["-c", "commit.gpgsign=false", "commit", "-m", "initial"],
4587        );
4588        fs::write(
4589            root.join("src/index.ts"),
4590            "export const legacy = 1;\nexport const changed = 2;\n",
4591        )
4592        .expect("changed module should be written");
4593
4594        let config_path = None;
4595        let opts = AuditOptions {
4596            root,
4597            config_path: &config_path,
4598            output: OutputFormat::Json,
4599            no_cache: true,
4600            threads: 1,
4601            quiet: true,
4602            changed_since: Some("HEAD"),
4603            production: false,
4604            production_dead_code: None,
4605            production_health: None,
4606            production_dupes: None,
4607            workspace: None,
4608            changed_workspaces: None,
4609            explain: false,
4610            explain_skipped: false,
4611            performance: false,
4612            group_by: None,
4613            dead_code_baseline: None,
4614            health_baseline: None,
4615            dupes_baseline: None,
4616            max_crap: None,
4617            coverage: None,
4618            coverage_root: None,
4619            gate: AuditGate::All,
4620            include_entry_exports: false,
4621            runtime_coverage: None,
4622            min_invocations_hot: 100,
4623        };
4624
4625        let result = execute_audit(&opts).expect("audit should execute");
4626        assert!(result.base_snapshot.is_none());
4627        assert_eq!(result.attribution.gate, AuditGate::All);
4628        assert_eq!(result.attribution.dead_code_introduced, 0);
4629        assert_eq!(result.attribution.dead_code_inherited, 0);
4630    }
4631
4632    #[test]
4633    fn audit_gate_new_only_skips_base_snapshot_for_docs_only_diff() {
4634        let tmp = tempfile::TempDir::new().expect("temp dir should be created");
4635        let root = tmp.path();
4636        fs::create_dir_all(root.join("src")).expect("src dir should be created");
4637        fs::write(
4638            root.join("package.json"),
4639            r#"{"name":"audit-docs-only","main":"src/index.ts"}"#,
4640        )
4641        .expect("package.json should be written");
4642        fs::write(
4643            root.join(".fallowrc.json"),
4644            r#"{"duplicates":{"minTokens":5,"minLines":2,"mode":"strict"}}"#,
4645        )
4646        .expect("config should be written");
4647        let duplicated = "export function same(input: number): number {\n  const doubled = input * 2;\n  const shifted = doubled + 1;\n  return shifted;\n}\n";
4648        fs::write(root.join("src/index.ts"), duplicated).expect("index should be written");
4649        fs::write(root.join("src/copy.ts"), duplicated).expect("copy should be written");
4650        fs::write(root.join("README.md"), "before\n").expect("readme should be written");
4651
4652        git(root, &["init", "-b", "main"]);
4653        git(root, &["add", "."]);
4654        git(
4655            root,
4656            &["-c", "commit.gpgsign=false", "commit", "-m", "initial"],
4657        );
4658        fs::write(root.join("README.md"), "after\n").expect("readme should be modified");
4659        fs::create_dir_all(root.join(".fallow/cache/dupes-tokens-v2"))
4660            .expect("cache dir should be created");
4661        fs::write(
4662            root.join(".fallow/cache/dupes-tokens-v2/cache.bin"),
4663            b"cache",
4664        )
4665        .expect("cache artifact should be written");
4666
4667        let before_worktrees = audit_worktree_names(root);
4668
4669        let config_path = None;
4670        let opts = AuditOptions {
4671            root,
4672            config_path: &config_path,
4673            output: OutputFormat::Json,
4674            no_cache: true,
4675            threads: 1,
4676            quiet: true,
4677            changed_since: Some("HEAD"),
4678            production: false,
4679            production_dead_code: None,
4680            production_health: None,
4681            production_dupes: None,
4682            workspace: None,
4683            changed_workspaces: None,
4684            explain: false,
4685            explain_skipped: false,
4686            performance: true,
4687            group_by: None,
4688            dead_code_baseline: None,
4689            health_baseline: None,
4690            dupes_baseline: None,
4691            max_crap: None,
4692            coverage: None,
4693            coverage_root: None,
4694            gate: AuditGate::NewOnly,
4695            include_entry_exports: false,
4696            runtime_coverage: None,
4697            min_invocations_hot: 100,
4698        };
4699
4700        let result = execute_audit(&opts).expect("audit should execute");
4701        assert_eq!(result.verdict, AuditVerdict::Pass);
4702        assert_eq!(result.changed_files_count, 2);
4703        assert!(result.base_snapshot_skipped);
4704        assert!(result.base_snapshot.is_some());
4705
4706        let after_worktrees = audit_worktree_names(root);
4707        assert_eq!(
4708            before_worktrees, after_worktrees,
4709            "base snapshot skip must not create a temporary base worktree"
4710        );
4711    }
4712
4713    fn audit_worktree_names(repo_root: &Path) -> Vec<String> {
4714        let mut names: Vec<String> = list_audit_worktrees(repo_root)
4715            .unwrap_or_default()
4716            .into_iter()
4717            .filter_map(|path| {
4718                path.file_name()
4719                    .and_then(|name| name.to_str())
4720                    .map(str::to_owned)
4721            })
4722            .collect();
4723        names.sort();
4724        names
4725    }
4726
4727    #[test]
4728    fn audit_reuses_dead_code_parse_for_health_when_production_matches() {
4729        let tmp = tempfile::TempDir::new().expect("temp dir should be created");
4730        let root = tmp.path();
4731        fs::create_dir_all(root.join("src")).expect("src dir should be created");
4732        fs::write(
4733            root.join("package.json"),
4734            r#"{"name":"audit-shared-parse","main":"src/index.ts"}"#,
4735        )
4736        .expect("package.json should be written");
4737        fs::write(
4738            root.join("src/index.ts"),
4739            "import { used } from './used';\nused();\n",
4740        )
4741        .expect("index should be written");
4742        fs::write(
4743            root.join("src/used.ts"),
4744            "export function used() {\n  return 1;\n}\n",
4745        )
4746        .expect("used module should be written");
4747
4748        git(root, &["init", "-b", "main"]);
4749        git(root, &["add", "."]);
4750        git(
4751            root,
4752            &["-c", "commit.gpgsign=false", "commit", "-m", "initial"],
4753        );
4754        fs::write(
4755            root.join("src/used.ts"),
4756            "export function used() {\n  return 1;\n}\nexport function changed() {\n  return 2;\n}\n",
4757        )
4758        .expect("changed module should be written");
4759
4760        let config_path = None;
4761        let opts = AuditOptions {
4762            root,
4763            config_path: &config_path,
4764            output: OutputFormat::Json,
4765            no_cache: true,
4766            threads: 1,
4767            quiet: true,
4768            changed_since: Some("HEAD"),
4769            production: false,
4770            production_dead_code: None,
4771            production_health: None,
4772            production_dupes: None,
4773            workspace: None,
4774            changed_workspaces: None,
4775            explain: false,
4776            explain_skipped: false,
4777            performance: true,
4778            group_by: None,
4779            dead_code_baseline: None,
4780            health_baseline: None,
4781            dupes_baseline: None,
4782            max_crap: None,
4783            coverage: None,
4784            coverage_root: None,
4785            gate: AuditGate::NewOnly,
4786            include_entry_exports: false,
4787            runtime_coverage: None,
4788            min_invocations_hot: 100,
4789        };
4790
4791        let result = execute_audit(&opts).expect("audit should execute");
4792        let health = result.health.expect("health should run for changed files");
4793        let timings = health.timings.expect("performance timings should be kept");
4794        assert!(timings.discover_ms.abs() < f64::EPSILON);
4795        assert!(timings.parse_ms.abs() < f64::EPSILON);
4796        // Same production settings, so dupes should also have piggy-backed on
4797        // the dead-code file list (no separate verifiable signal in DupesResult,
4798        // but the run must still produce a non-None result).
4799        assert!(
4800            result.dupes.is_some(),
4801            "dupes should run when changed files exist"
4802        );
4803    }
4804
4805    #[test]
4806    fn audit_dupes_falls_back_to_own_discovery_when_health_off() {
4807        // When health and dupes have different production settings, dupes must
4808        // not borrow files from dead-code (the file sets can differ). The two
4809        // execution paths should still produce a result.
4810        let tmp = tempfile::TempDir::new().expect("temp dir should be created");
4811        let root = tmp.path();
4812        fs::create_dir_all(root.join("src")).expect("src dir should be created");
4813        fs::write(
4814            root.join("package.json"),
4815            r#"{"name":"audit-dupes-fallback","main":"src/index.ts"}"#,
4816        )
4817        .expect("package.json should be written");
4818        fs::write(
4819            root.join("src/index.ts"),
4820            "import { used } from './used';\nused();\n",
4821        )
4822        .expect("index should be written");
4823        fs::write(
4824            root.join("src/used.ts"),
4825            "export function used() {\n  return 1;\n}\n",
4826        )
4827        .expect("used module should be written");
4828
4829        git(root, &["init", "-b", "main"]);
4830        git(root, &["add", "."]);
4831        git(
4832            root,
4833            &["-c", "commit.gpgsign=false", "commit", "-m", "initial"],
4834        );
4835        fs::write(
4836            root.join("src/used.ts"),
4837            "export function used() {\n  return 1;\n}\nexport function changed() {\n  return 2;\n}\n",
4838        )
4839        .expect("changed module should be written");
4840
4841        let config_path = None;
4842        let opts = AuditOptions {
4843            root,
4844            config_path: &config_path,
4845            output: OutputFormat::Json,
4846            no_cache: true,
4847            threads: 1,
4848            quiet: true,
4849            changed_since: Some("HEAD"),
4850            production: false,
4851            production_dead_code: Some(true),
4852            production_health: Some(false),
4853            production_dupes: Some(false),
4854            workspace: None,
4855            changed_workspaces: None,
4856            explain: false,
4857            explain_skipped: false,
4858            performance: true,
4859            group_by: None,
4860            dead_code_baseline: None,
4861            health_baseline: None,
4862            dupes_baseline: None,
4863            max_crap: None,
4864            coverage: None,
4865            coverage_root: None,
4866            gate: AuditGate::NewOnly,
4867            include_entry_exports: false,
4868            runtime_coverage: None,
4869            min_invocations_hot: 100,
4870        };
4871
4872        let result = execute_audit(&opts).expect("audit should execute");
4873        assert!(result.dupes.is_some(), "dupes should still run");
4874    }
4875
4876    #[cfg(unix)]
4877    #[test]
4878    fn remap_focus_files_does_not_canonicalize_through_symlinks() {
4879        // Function-level contract: `remap_focus_files` must NOT canonicalize
4880        // `to_root`. The base worktree path comes from `std::env::temp_dir()`
4881        // un-canonicalized, and `discover_files` walks the worktree using that
4882        // exact prefix; resolving symlinks here would silently shift the prefix
4883        // on systems where the tempdir traverses one (`/tmp` -> `/private/tmp`,
4884        // `/var` -> `/private/var` on macOS) and miss every discovered file at
4885        // base. Pin the contract via a synthetic `from_root` and a real
4886        // symlinked `to_root`; the matching end-to-end behavior is covered by
4887        // `audit_gate_new_only_inherits_pre_existing_duplicates_in_focused_files`.
4888        let tmp = tempfile::TempDir::new().expect("temp dir");
4889        let real = tmp.path().join("real");
4890        let link = tmp.path().join("link");
4891        fs::create_dir_all(&real).expect("real dir");
4892        std::os::unix::fs::symlink(&real, &link).expect("symlink");
4893        // Sanity: `link` and `link.canonicalize()` differ. If the OS canonicalized
4894        // them to the same path, the test premise doesn't hold and the assertion
4895        // below is meaningless.
4896        let canonical = link.canonicalize().expect("canonicalize symlink");
4897        assert_ne!(link, canonical, "symlink should not equal its target");
4898
4899        let from_root = PathBuf::from("/repo");
4900        let mut focus = FxHashSet::default();
4901        focus.insert(from_root.join("src/foo.ts"));
4902
4903        let remapped = remap_focus_files(&focus, &from_root, &link)
4904            .expect("remap should succeed for in-prefix files");
4905
4906        let expected = link.join("src/foo.ts");
4907        assert!(
4908            remapped.contains(&expected),
4909            "remapped paths must keep the un-canonical to_root prefix; got {remapped:?}, expected entry {expected:?}"
4910        );
4911    }
4912
4913    #[test]
4914    fn remap_focus_files_skips_paths_outside_from_root() {
4915        // A file outside `from_root` (e.g., a sibling workspace touched in the
4916        // same diff) must not collapse the entire focus set. The optimization
4917        // should stay active for the in-scope subset.
4918        let from_root = PathBuf::from("/repo/apps/web");
4919        let to_root = PathBuf::from("/wt/apps/web");
4920        let mut focus = FxHashSet::default();
4921        focus.insert(PathBuf::from("/repo/apps/web/src/in.ts"));
4922        focus.insert(PathBuf::from("/repo/services/api/src/out.ts"));
4923
4924        let remapped =
4925            remap_focus_files(&focus, &from_root, &to_root).expect("partial map should succeed");
4926
4927        assert_eq!(remapped.len(), 1);
4928        assert!(remapped.contains(&PathBuf::from("/wt/apps/web/src/in.ts")));
4929    }
4930
4931    #[test]
4932    fn remap_focus_files_returns_none_when_no_paths_map() {
4933        let from_root = PathBuf::from("/repo/apps/web");
4934        let to_root = PathBuf::from("/wt/apps/web");
4935        let mut focus = FxHashSet::default();
4936        focus.insert(PathBuf::from("/elsewhere/foo.ts"));
4937
4938        let remapped = remap_focus_files(&focus, &from_root, &to_root);
4939        assert!(
4940            remapped.is_none(),
4941            "remap should return None when no paths can be mapped, falling caller back to full corpus"
4942        );
4943    }
4944
4945    #[test]
4946    fn audit_gate_new_only_inherits_pre_existing_duplicates_in_focused_files() {
4947        // Regression test for the dupe-focus optimization: when changed files
4948        // contain duplicates that ALSO existed at base (HEAD~1), the audit gate
4949        // must classify them as `inherited`, not `introduced`. The original
4950        // implementation canonicalized `to_root` in `remap_focus_files`, which
4951        // on macOS shifted the prefix from `/var/folders/...` to
4952        // `/private/var/folders/...`. `discover_files` in the base worktree
4953        // walked the un-canonical path, so set membership at base missed every
4954        // remapped focus path. `find_duplicates_touching_files` returned 0
4955        // groups at base, base_keys was empty, and every current finding
4956        // misclassified as `introduced`.
4957        let tmp = tempfile::TempDir::new().expect("temp dir should be created");
4958        // Mirror production: `validate_root` canonicalizes user-supplied roots
4959        // before they reach `execute_audit`. This test exercises the *base
4960        // worktree* side of the bug, where the worktree path comes from
4961        // `std::env::temp_dir()` and is canonical-vs-un-canonical INDEPENDENT
4962        // of what `opts.root` looks like. On macOS, `std::env::temp_dir()`
4963        // returns `/var/folders/...` and `canonicalize` resolves it to
4964        // `/private/var/folders/...`, so a buggy remap loses every focus path
4965        // even when `opts.root` is already canonical.
4966        let root_buf = tmp
4967            .path()
4968            .canonicalize()
4969            .expect("temp root should canonicalize");
4970        let root = root_buf.as_path();
4971        fs::create_dir_all(root.join("src")).expect("src dir should be created");
4972        fs::write(
4973            root.join("package.json"),
4974            r#"{"name":"audit-newonly-inherit","main":"src/changed.ts"}"#,
4975        )
4976        .expect("package.json should be written");
4977        fs::write(
4978            root.join(".fallowrc.json"),
4979            r#"{"duplicates":{"minTokens":10,"minLines":3,"mode":"strict"}}"#,
4980        )
4981        .expect("config should be written");
4982
4983        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";
4984        fs::write(root.join("src/changed.ts"), dup_block).expect("changed should be written");
4985        fs::write(root.join("src/peer.ts"), dup_block).expect("peer should be written");
4986
4987        git(root, &["init", "-b", "main"]);
4988        git(root, &["add", "."]);
4989        git(
4990            root,
4991            &["-c", "commit.gpgsign=false", "commit", "-m", "initial"],
4992        );
4993        // Append a comment-only line so the file is "changed" without altering
4994        // the duplicated token sequence.
4995        fs::write(
4996            root.join("src/changed.ts"),
4997            format!("{dup_block}// touched\n"),
4998        )
4999        .expect("changed file should be modified");
5000        git(root, &["add", "."]);
5001        git(
5002            root,
5003            &["-c", "commit.gpgsign=false", "commit", "-m", "touch"],
5004        );
5005
5006        let config_path = None;
5007        let opts = AuditOptions {
5008            root,
5009            config_path: &config_path,
5010            output: OutputFormat::Json,
5011            no_cache: true,
5012            threads: 1,
5013            quiet: true,
5014            changed_since: Some("HEAD~1"),
5015            production: false,
5016            production_dead_code: None,
5017            production_health: None,
5018            production_dupes: None,
5019            workspace: None,
5020            changed_workspaces: None,
5021            explain: false,
5022            explain_skipped: false,
5023            performance: false,
5024            group_by: None,
5025            dead_code_baseline: None,
5026            health_baseline: None,
5027            dupes_baseline: None,
5028            max_crap: None,
5029            coverage: None,
5030            coverage_root: None,
5031            gate: AuditGate::NewOnly,
5032            include_entry_exports: false,
5033            runtime_coverage: None,
5034            min_invocations_hot: 100,
5035        };
5036
5037        let result = execute_audit(&opts).expect("audit should execute");
5038        assert!(
5039            result.base_snapshot_skipped,
5040            "comment-only JS/TS diffs should reuse current keys as the base snapshot"
5041        );
5042        let dupes_report = &result.dupes.as_ref().expect("dupes should run").report;
5043        assert!(
5044            !dupes_report.clone_groups.is_empty(),
5045            "current run should detect the pre-existing duplicate"
5046        );
5047        assert_eq!(
5048            result.attribution.duplication_introduced, 0,
5049            "pre-existing duplicate must not be classified as introduced; \
5050             attribution = {:?}",
5051            result.attribution
5052        );
5053        assert!(
5054            result.attribution.duplication_inherited > 0,
5055            "pre-existing duplicate must be classified as inherited; \
5056             attribution = {:?}",
5057            result.attribution
5058        );
5059    }
5060
5061    #[test]
5062    fn audit_base_preserves_tsconfig_paths_when_extends_is_in_untracked_node_modules() {
5063        let tmp = tempfile::TempDir::new().expect("temp dir should be created");
5064        let root = tmp.path();
5065        fs::create_dir_all(root.join("src/screens")).expect("src dir should be created");
5066        fs::create_dir_all(root.join("node_modules/@react-native/typescript-config"))
5067            .expect("node_modules config dir should be created");
5068        fs::write(root.join(".gitignore"), "node_modules/\n").expect("gitignore should be written");
5069        fs::write(
5070            root.join("package.json"),
5071            r#"{
5072                "name": "audit-react-native-tsconfig-base",
5073                "private": true,
5074                "main": "src/App.tsx",
5075                "dependencies": {
5076                    "react-native": "0.80.0"
5077                }
5078            }"#,
5079        )
5080        .expect("package.json should be written");
5081        fs::write(
5082            root.join("tsconfig.json"),
5083            r#"{
5084                "extends": "./node_modules/@react-native/typescript-config/tsconfig.json",
5085                "compilerOptions": {
5086                    "baseUrl": ".",
5087                    "paths": {
5088                        "@/*": ["src/*"]
5089                    }
5090                },
5091                "include": ["src/**/*"]
5092            }"#,
5093        )
5094        .expect("tsconfig should be written");
5095        fs::write(
5096            root.join("node_modules/@react-native/typescript-config/tsconfig.json"),
5097            r#"{"compilerOptions":{"strict":true,"jsx":"react-jsx"}}"#,
5098        )
5099        .expect("react native tsconfig should be written");
5100        fs::write(
5101            root.join("src/App.tsx"),
5102            r#"import { homeTitle } from "@/screens/Home";
5103
5104export function App() {
5105  return homeTitle;
5106}
5107"#,
5108        )
5109        .expect("app should be written");
5110        fs::write(
5111            root.join("src/screens/Home.ts"),
5112            r#"export const homeTitle = "home";
5113"#,
5114        )
5115        .expect("home should be written");
5116
5117        git(root, &["init", "-b", "main"]);
5118        git(root, &["add", "."]);
5119        git(
5120            root,
5121            &["-c", "commit.gpgsign=false", "commit", "-m", "initial"],
5122        );
5123        fs::write(
5124            root.join("src/App.tsx"),
5125            r#"import { homeTitle } from "@/screens/Home";
5126
5127export function App() {
5128  return homeTitle.toUpperCase();
5129}
5130"#,
5131        )
5132        .expect("app should be modified");
5133
5134        let config_path = None;
5135        let opts = AuditOptions {
5136            root,
5137            config_path: &config_path,
5138            output: OutputFormat::Json,
5139            no_cache: true,
5140            threads: 1,
5141            quiet: true,
5142            changed_since: Some("HEAD"),
5143            production: false,
5144            production_dead_code: None,
5145            production_health: None,
5146            production_dupes: None,
5147            workspace: None,
5148            changed_workspaces: None,
5149            explain: false,
5150            explain_skipped: false,
5151            performance: false,
5152            group_by: None,
5153            dead_code_baseline: None,
5154            health_baseline: None,
5155            dupes_baseline: None,
5156            max_crap: None,
5157            coverage: None,
5158            coverage_root: None,
5159            gate: AuditGate::NewOnly,
5160            include_entry_exports: false,
5161            runtime_coverage: None,
5162            min_invocations_hot: 100,
5163        };
5164
5165        let result = execute_audit(&opts).expect("audit should execute");
5166        assert!(
5167            !result.base_snapshot_skipped,
5168            "source diffs should run a real base snapshot"
5169        );
5170        let base = result
5171            .base_snapshot
5172            .as_ref()
5173            .expect("base snapshot should run");
5174        assert!(
5175            !base
5176                .dead_code
5177                .contains("unresolved-import:src/App.tsx:@/screens/Home"),
5178            "base audit must keep local @/* tsconfig aliases when extends points into ignored node_modules: {:?}",
5179            base.dead_code
5180        );
5181        assert!(
5182            !base.dead_code.contains("unused-file:src/screens/Home.ts"),
5183            "alias target should stay reachable in the base worktree: {:?}",
5184            base.dead_code
5185        );
5186        let check = result.check.as_ref().expect("dead-code audit should run");
5187        assert!(
5188            check.results.unresolved_imports.is_empty(),
5189            "HEAD audit should also resolve @/* aliases: {:?}",
5190            check.results.unresolved_imports
5191        );
5192    }
5193
5194    #[test]
5195    fn audit_base_preserves_subdirectory_root_resolution() {
5196        let tmp = tempfile::TempDir::new().expect("temp dir should be created");
5197        let repo = tmp.path().join("repo");
5198        let root = repo.join("apps/mobile");
5199        fs::create_dir_all(root.join("src/screens")).expect("src dir should be created");
5200        fs::create_dir_all(root.join("node_modules/@react-native/typescript-config"))
5201            .expect("node_modules config dir should be created");
5202        fs::write(repo.join(".gitignore"), "apps/mobile/node_modules/\n")
5203            .expect("gitignore should be written");
5204        fs::write(
5205            root.join("package.json"),
5206            r#"{
5207                "name": "audit-subdir-react-native-tsconfig-base",
5208                "private": true,
5209                "main": "src/App.tsx",
5210                "dependencies": {
5211                    "react-native": "0.80.0"
5212                }
5213            }"#,
5214        )
5215        .expect("package.json should be written");
5216        fs::write(
5217            root.join("tsconfig.json"),
5218            r#"{
5219                "extends": "./node_modules/@react-native/typescript-config/tsconfig.json",
5220                "compilerOptions": {
5221                    "baseUrl": ".",
5222                    "paths": {
5223                        "@/*": ["src/*"]
5224                    }
5225                },
5226                "include": ["src/**/*"]
5227            }"#,
5228        )
5229        .expect("tsconfig should be written");
5230        fs::write(
5231            root.join("node_modules/@react-native/typescript-config/tsconfig.json"),
5232            r#"{"compilerOptions":{"strict":true,"jsx":"react-jsx"}}"#,
5233        )
5234        .expect("react native tsconfig should be written");
5235        fs::write(
5236            root.join("src/App.tsx"),
5237            r#"import { homeTitle } from "@/screens/Home";
5238
5239export function App() {
5240  return homeTitle;
5241}
5242"#,
5243        )
5244        .expect("app should be written");
5245        fs::write(
5246            root.join("src/screens/Home.ts"),
5247            r#"export const homeTitle = "home";
5248"#,
5249        )
5250        .expect("home should be written");
5251
5252        git(&repo, &["init", "-b", "main"]);
5253        git(&repo, &["add", "."]);
5254        git(
5255            &repo,
5256            &["-c", "commit.gpgsign=false", "commit", "-m", "initial"],
5257        );
5258        fs::write(
5259            root.join("src/App.tsx"),
5260            r#"import { homeTitle } from "@/screens/Home";
5261
5262export function App() {
5263  return homeTitle.toUpperCase();
5264}
5265"#,
5266        )
5267        .expect("app should be modified");
5268
5269        let config_path = None;
5270        let opts = AuditOptions {
5271            root: &root,
5272            config_path: &config_path,
5273            output: OutputFormat::Json,
5274            no_cache: true,
5275            threads: 1,
5276            quiet: true,
5277            changed_since: Some("HEAD"),
5278            production: false,
5279            production_dead_code: None,
5280            production_health: None,
5281            production_dupes: None,
5282            workspace: None,
5283            changed_workspaces: None,
5284            explain: false,
5285            explain_skipped: false,
5286            performance: false,
5287            group_by: None,
5288            dead_code_baseline: None,
5289            health_baseline: None,
5290            dupes_baseline: None,
5291            max_crap: None,
5292            coverage: None,
5293            coverage_root: None,
5294            gate: AuditGate::NewOnly,
5295            include_entry_exports: false,
5296            runtime_coverage: None,
5297            min_invocations_hot: 100,
5298        };
5299
5300        let result = execute_audit(&opts).expect("audit should execute");
5301        assert!(
5302            !result.base_snapshot_skipped,
5303            "source diffs should run a real base snapshot"
5304        );
5305        let base = result
5306            .base_snapshot
5307            .as_ref()
5308            .expect("base snapshot should run");
5309        assert!(
5310            !base
5311                .dead_code
5312                .contains("unresolved-import:src/App.tsx:@/screens/Home"),
5313            "base audit should analyze from the app subdirectory, not the repo root: {:?}",
5314            base.dead_code
5315        );
5316        assert!(
5317            !base.dead_code.contains("unused-file:src/screens/Home.ts"),
5318            "subdirectory base audit should keep alias targets reachable: {:?}",
5319            base.dead_code
5320        );
5321    }
5322
5323    #[test]
5324    fn audit_base_uses_new_explicit_config_without_hard_failure() {
5325        let tmp = tempfile::TempDir::new().expect("temp dir should be created");
5326        let root = tmp.path();
5327        fs::create_dir_all(root.join("src")).expect("src dir should be created");
5328        fs::write(
5329            root.join("package.json"),
5330            r#"{"name":"audit-new-config","main":"src/index.ts"}"#,
5331        )
5332        .expect("package.json should be written");
5333        fs::write(root.join("src/index.ts"), "export const used = 1;\n")
5334            .expect("index should be written");
5335
5336        git(root, &["init", "-b", "main"]);
5337        git(root, &["add", "."]);
5338        git(
5339            root,
5340            &["-c", "commit.gpgsign=false", "commit", "-m", "initial"],
5341        );
5342
5343        let explicit_config = root.join(".fallowrc.json");
5344        fs::write(&explicit_config, r#"{"rules":{"unused-files":"error"}}"#)
5345            .expect("new config should be written");
5346        fs::write(root.join("src/index.ts"), "export const used = 2;\n")
5347            .expect("index should be modified");
5348
5349        let config_path = Some(explicit_config);
5350        let opts = AuditOptions {
5351            root,
5352            config_path: &config_path,
5353            output: OutputFormat::Json,
5354            no_cache: true,
5355            threads: 1,
5356            quiet: true,
5357            changed_since: Some("HEAD"),
5358            production: false,
5359            production_dead_code: None,
5360            production_health: None,
5361            production_dupes: None,
5362            workspace: None,
5363            changed_workspaces: None,
5364            explain: false,
5365            explain_skipped: false,
5366            performance: false,
5367            group_by: None,
5368            dead_code_baseline: None,
5369            health_baseline: None,
5370            dupes_baseline: None,
5371            max_crap: None,
5372            coverage: None,
5373            coverage_root: None,
5374            gate: AuditGate::NewOnly,
5375            include_entry_exports: false,
5376            runtime_coverage: None,
5377            min_invocations_hot: 100,
5378        };
5379
5380        let result = execute_audit(&opts).expect("audit should execute with a new explicit config");
5381        assert!(
5382            result.base_snapshot.is_some(),
5383            "base snapshot should use the current explicit config even when the base commit lacks it"
5384        );
5385    }
5386
5387    #[test]
5388    fn audit_base_uses_current_discovered_config_for_attribution() {
5389        let tmp = tempfile::TempDir::new().expect("temp dir should be created");
5390        let root = tmp.path();
5391        fs::create_dir_all(root.join("src")).expect("src dir should be created");
5392        fs::write(
5393            root.join("package.json"),
5394            r#"{"name":"audit-current-config","main":"src/index.ts","dependencies":{"left-pad":"1.3.0"}}"#,
5395        )
5396        .expect("package.json should be written");
5397        fs::write(
5398            root.join(".fallowrc.json"),
5399            r#"{"rules":{"unused-dependencies":"off"}}"#,
5400        )
5401        .expect("base config should be written");
5402        fs::write(root.join("src/index.ts"), "export const used = 1;\n")
5403            .expect("index should be written");
5404
5405        git(root, &["init", "-b", "main"]);
5406        git(root, &["add", "."]);
5407        git(
5408            root,
5409            &["-c", "commit.gpgsign=false", "commit", "-m", "initial"],
5410        );
5411
5412        fs::write(
5413            root.join(".fallowrc.json"),
5414            r#"{"rules":{"unused-dependencies":"error"}}"#,
5415        )
5416        .expect("current config should be written");
5417        fs::write(
5418            root.join("package.json"),
5419            r#"{"name":"audit-current-config","main":"src/index.ts","dependencies":{"left-pad":"1.3.1"}}"#,
5420        )
5421        .expect("package.json should be touched");
5422
5423        let config_path = None;
5424        let opts = AuditOptions {
5425            root,
5426            config_path: &config_path,
5427            output: OutputFormat::Json,
5428            no_cache: true,
5429            threads: 1,
5430            quiet: true,
5431            changed_since: Some("HEAD"),
5432            production: false,
5433            production_dead_code: None,
5434            production_health: None,
5435            production_dupes: None,
5436            workspace: None,
5437            changed_workspaces: None,
5438            explain: false,
5439            explain_skipped: false,
5440            performance: false,
5441            group_by: None,
5442            dead_code_baseline: None,
5443            health_baseline: None,
5444            dupes_baseline: None,
5445            max_crap: None,
5446            coverage: None,
5447            coverage_root: None,
5448            gate: AuditGate::NewOnly,
5449            include_entry_exports: false,
5450            runtime_coverage: None,
5451            min_invocations_hot: 100,
5452        };
5453
5454        let result = execute_audit(&opts).expect("audit should execute");
5455        assert_eq!(
5456            result.attribution.dead_code_introduced, 0,
5457            "enabling a rule should not make pre-existing changed-file findings look introduced: {:?}",
5458            result.attribution
5459        );
5460        assert!(
5461            result.attribution.dead_code_inherited > 0,
5462            "pre-existing changed-file findings should be classified as inherited: {:?}",
5463            result.attribution
5464        );
5465    }
5466
5467    #[test]
5468    fn audit_base_current_config_attribution_survives_cache_hit() {
5469        let tmp = tempfile::TempDir::new().expect("temp dir should be created");
5470        let root = tmp.path();
5471        fs::create_dir_all(root.join("src")).expect("src dir should be created");
5472        fs::write(
5473            root.join("package.json"),
5474            r#"{"name":"audit-current-config-cache","main":"src/index.ts","dependencies":{"left-pad":"1.3.0"}}"#,
5475        )
5476        .expect("package.json should be written");
5477        fs::write(
5478            root.join(".fallowrc.json"),
5479            r#"{"rules":{"unused-dependencies":"off"}}"#,
5480        )
5481        .expect("base config should be written");
5482        fs::write(root.join("src/index.ts"), "export const used = 1;\n")
5483            .expect("index should be written");
5484
5485        git(root, &["init", "-b", "main"]);
5486        git(root, &["add", "."]);
5487        git(
5488            root,
5489            &["-c", "commit.gpgsign=false", "commit", "-m", "initial"],
5490        );
5491
5492        fs::write(
5493            root.join(".fallowrc.json"),
5494            r#"{"rules":{"unused-dependencies":"error"}}"#,
5495        )
5496        .expect("current config should be written");
5497        fs::write(
5498            root.join("package.json"),
5499            r#"{"name":"audit-current-config-cache","main":"src/index.ts","dependencies":{"left-pad":"1.3.1"}}"#,
5500        )
5501        .expect("package.json should be touched");
5502
5503        let config_path = None;
5504        let opts = AuditOptions {
5505            root,
5506            config_path: &config_path,
5507            output: OutputFormat::Json,
5508            no_cache: false,
5509            threads: 1,
5510            quiet: true,
5511            changed_since: Some("HEAD"),
5512            production: false,
5513            production_dead_code: None,
5514            production_health: None,
5515            production_dupes: None,
5516            workspace: None,
5517            changed_workspaces: None,
5518            explain: false,
5519            explain_skipped: false,
5520            performance: false,
5521            group_by: None,
5522            dead_code_baseline: None,
5523            health_baseline: None,
5524            dupes_baseline: None,
5525            max_crap: None,
5526            coverage: None,
5527            coverage_root: None,
5528            gate: AuditGate::NewOnly,
5529            include_entry_exports: false,
5530            runtime_coverage: None,
5531            min_invocations_hot: 100,
5532        };
5533
5534        let first = execute_audit(&opts).expect("first audit should execute");
5535        assert_eq!(
5536            first.attribution.dead_code_introduced, 0,
5537            "first audit should classify pre-existing findings as inherited: {:?}",
5538            first.attribution
5539        );
5540
5541        let changed_files =
5542            crate::check::get_changed_files(root, "HEAD").expect("changed files should resolve");
5543        let key = audit_base_snapshot_cache_key(&opts, "HEAD", &changed_files)
5544            .expect("cache key should compute")
5545            .expect("cache key should exist");
5546        assert!(
5547            load_cached_base_snapshot(&opts, &key).is_some(),
5548            "first audit should store a reusable base snapshot"
5549        );
5550
5551        let second = execute_audit(&opts).expect("second audit should execute");
5552        assert_eq!(
5553            second.attribution.dead_code_introduced, 0,
5554            "cache hit should keep current-config attribution stable: {:?}",
5555            second.attribution
5556        );
5557        assert!(
5558            second.attribution.dead_code_inherited > 0,
5559            "cache hit should preserve inherited base findings: {:?}",
5560            second.attribution
5561        );
5562    }
5563
5564    #[test]
5565    fn audit_dupes_only_materializes_groups_touching_changed_files() {
5566        let tmp = tempfile::TempDir::new().expect("temp dir should be created");
5567        let root_path = tmp
5568            .path()
5569            .canonicalize()
5570            .expect("temp root should canonicalize");
5571        let root = root_path.as_path();
5572        fs::create_dir_all(root.join("src")).expect("src dir should be created");
5573        fs::write(
5574            root.join("package.json"),
5575            r#"{"name":"audit-dupes-focus","main":"src/changed.ts"}"#,
5576        )
5577        .expect("package.json should be written");
5578        fs::write(
5579            root.join(".fallowrc.json"),
5580            r#"{"duplicates":{"minTokens":5,"minLines":2,"mode":"strict"}}"#,
5581        )
5582        .expect("config should be written");
5583
5584        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";
5585        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";
5586        fs::write(root.join("src/changed.ts"), focused_code).expect("changed should be written");
5587        fs::write(root.join("src/focused-copy.ts"), focused_code)
5588            .expect("focused copy should be written");
5589        fs::write(root.join("src/untouched-a.ts"), untouched_code)
5590            .expect("untouched a should be written");
5591        fs::write(root.join("src/untouched-b.ts"), untouched_code)
5592            .expect("untouched b should be written");
5593
5594        git(root, &["init", "-b", "main"]);
5595        git(root, &["add", "."]);
5596        git(
5597            root,
5598            &["-c", "commit.gpgsign=false", "commit", "-m", "initial"],
5599        );
5600        fs::write(
5601            root.join("src/changed.ts"),
5602            format!("{focused_code}export const changedMarker = true;\n"),
5603        )
5604        .expect("changed file should be modified");
5605
5606        let config_path = None;
5607        let opts = AuditOptions {
5608            root,
5609            config_path: &config_path,
5610            output: OutputFormat::Json,
5611            no_cache: true,
5612            threads: 1,
5613            quiet: true,
5614            changed_since: Some("HEAD"),
5615            production: false,
5616            production_dead_code: None,
5617            production_health: None,
5618            production_dupes: None,
5619            workspace: None,
5620            changed_workspaces: None,
5621            explain: false,
5622            explain_skipped: false,
5623            performance: false,
5624            group_by: None,
5625            dead_code_baseline: None,
5626            health_baseline: None,
5627            dupes_baseline: None,
5628            max_crap: None,
5629            coverage: None,
5630            coverage_root: None,
5631            gate: AuditGate::All,
5632            include_entry_exports: false,
5633            runtime_coverage: None,
5634            min_invocations_hot: 100,
5635        };
5636
5637        let result = execute_audit(&opts).expect("audit should execute");
5638        let dupes = result.dupes.expect("dupes should run");
5639        let changed_path = root.join("src/changed.ts");
5640
5641        assert!(
5642            !dupes.report.clone_groups.is_empty(),
5643            "changed file should still match unchanged duplicate code"
5644        );
5645        assert!(dupes.report.clone_groups.iter().all(|group| {
5646            group
5647                .instances
5648                .iter()
5649                .any(|instance| instance.file == changed_path)
5650        }));
5651    }
5652}