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