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