Skip to main content

fallow_cli/
audit.rs

1use std::io::Write;
2use std::path::{Path, PathBuf};
3use std::process::{Command, ExitCode};
4use std::time::{Duration, Instant};
5
6use fallow_config::{AuditGate, OutputFormat};
7use fallow_core::git_env::clear_ambient_git_env;
8use rustc_hash::FxHashSet;
9use xxhash_rust::xxh3::xxh3_64;
10
11use crate::check::{CheckOptions, CheckResult, IssueFilters, TraceOptions};
12use crate::dupes::{DupesMode, DupesOptions, DupesResult};
13use crate::error::emit_error;
14use crate::health::{HealthOptions, HealthResult, SortBy};
15
16const AUDIT_BASE_SNAPSHOT_CACHE_VERSION: u8 = 2;
17const MAX_AUDIT_BASE_SNAPSHOT_CACHE_SIZE: usize = 16 * 1024 * 1024;
18
19/// Verdict for the audit command.
20#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize)]
21#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
22#[serde(rename_all = "snake_case")]
23pub enum AuditVerdict {
24    /// No issues in changed files.
25    Pass,
26    /// Issues found, but all are warn-severity.
27    Warn,
28    /// Error-severity issues found in changed files.
29    Fail,
30}
31
32/// Per-category summary counts for the audit result.
33#[derive(Debug, Clone, serde::Serialize)]
34#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
35pub struct AuditSummary {
36    pub dead_code_issues: usize,
37    pub dead_code_has_errors: bool,
38    pub complexity_findings: usize,
39    pub max_cyclomatic: Option<u16>,
40    pub duplication_clone_groups: usize,
41}
42
43/// New-vs-inherited issue counts for audit.
44#[derive(Debug, Default, Clone, serde::Serialize)]
45#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
46pub struct AuditAttribution {
47    pub gate: AuditGate,
48    pub dead_code_introduced: usize,
49    pub dead_code_inherited: usize,
50    pub complexity_introduced: usize,
51    pub complexity_inherited: usize,
52    pub duplication_introduced: usize,
53    pub duplication_inherited: usize,
54}
55
56/// Full audit result containing verdict, summary, and sub-results.
57pub struct AuditResult {
58    pub verdict: AuditVerdict,
59    pub summary: AuditSummary,
60    pub attribution: AuditAttribution,
61    base_snapshot: Option<AuditKeySnapshot>,
62    pub base_snapshot_skipped: bool,
63    pub changed_files_count: usize,
64    /// Absolute paths of the files this run re-analyzed. Threaded into the
65    /// Fallow Impact per-finding attribution so the frontier diff knows which
66    /// files were authoritative this run.
67    pub changed_files: Vec<PathBuf>,
68    pub base_ref: String,
69    pub head_sha: Option<String>,
70    pub output: OutputFormat,
71    pub performance: bool,
72    pub check: Option<CheckResult>,
73    pub dupes: Option<DupesResult>,
74    pub health: Option<HealthResult>,
75    pub elapsed: Duration,
76}
77
78pub struct AuditOptions<'a> {
79    pub root: &'a std::path::Path,
80    pub config_path: &'a Option<std::path::PathBuf>,
81    pub cache_dir: &'a std::path::Path,
82    pub output: OutputFormat,
83    pub no_cache: bool,
84    pub threads: usize,
85    pub quiet: bool,
86    pub changed_since: Option<&'a str>,
87    pub production: bool,
88    pub production_dead_code: Option<bool>,
89    pub production_health: Option<bool>,
90    pub production_dupes: Option<bool>,
91    pub workspace: Option<&'a [String]>,
92    pub changed_workspaces: Option<&'a str>,
93    pub explain: bool,
94    pub explain_skipped: bool,
95    pub performance: bool,
96    pub group_by: Option<crate::GroupBy>,
97    /// Baseline file for dead-code analysis (as produced by `fallow dead-code --save-baseline`).
98    pub dead_code_baseline: Option<&'a std::path::Path>,
99    /// Baseline file for health analysis (as produced by `fallow health --save-baseline`).
100    pub health_baseline: Option<&'a std::path::Path>,
101    /// Baseline file for duplication analysis (as produced by `fallow dupes --save-baseline`).
102    pub dupes_baseline: Option<&'a std::path::Path>,
103    /// Maximum CRAP score threshold (overrides `health.maxCrap` from config).
104    /// Functions meeting or exceeding this score cause audit to fail.
105    pub max_crap: Option<f64>,
106    /// Istanbul coverage input for accurate CRAP scoring in the health sub-pass.
107    pub coverage: Option<&'a std::path::Path>,
108    /// Prefix to strip from Istanbul source paths before rebasing to `root`.
109    pub coverage_root: Option<&'a std::path::Path>,
110    pub gate: AuditGate,
111    /// Report unused exports in entry files (forwarded to the dead-code sub-pass).
112    pub include_entry_exports: bool,
113    /// Paid runtime-coverage sidecar input (V8 directory, V8 JSON, or
114    /// Istanbul coverage map). Forwarded into the embedded health pass so
115    /// audit surfaces the `hot-path-touched` verdict alongside dead-code
116    /// and complexity findings without requiring a second `fallow health`
117    /// invocation in CI.
118    pub runtime_coverage: Option<&'a std::path::Path>,
119    /// Threshold for hot-path classification, forwarded to the sidecar.
120    pub min_invocations_hot: u64,
121}
122
123/// Try to determine the default branch for the repository.
124/// Priority: `git symbolic-ref refs/remotes/origin/HEAD` → `main` → `master`.
125/// Returns `None` if none of these exist.
126fn auto_detect_base_branch(root: &std::path::Path) -> Option<String> {
127    let mut symbolic_ref = std::process::Command::new("git");
128    symbolic_ref
129        .args(["symbolic-ref", "refs/remotes/origin/HEAD"])
130        .current_dir(root);
131    clear_ambient_git_env(&mut symbolic_ref);
132    if let Ok(output) = symbolic_ref.output()
133        && output.status.success()
134    {
135        let full_ref = String::from_utf8_lossy(&output.stdout).trim().to_string();
136        if let Some(branch) = full_ref.strip_prefix("refs/remotes/origin/") {
137            return Some(branch.to_string());
138        }
139    }
140
141    let mut verify_main = std::process::Command::new("git");
142    verify_main
143        .args(["rev-parse", "--verify", "main"])
144        .current_dir(root);
145    clear_ambient_git_env(&mut verify_main);
146    if let Ok(output) = verify_main.output()
147        && output.status.success()
148    {
149        return Some("main".to_string());
150    }
151
152    let mut verify_master = std::process::Command::new("git");
153    verify_master
154        .args(["rev-parse", "--verify", "master"])
155        .current_dir(root);
156    clear_ambient_git_env(&mut verify_master);
157    if let Ok(output) = verify_master.output()
158        && output.status.success()
159    {
160        return Some("master".to_string());
161    }
162
163    None
164}
165
166/// Get the short SHA of HEAD for the scope display line.
167fn get_head_sha(root: &std::path::Path) -> Option<String> {
168    let mut command = std::process::Command::new("git");
169    command
170        .args(["rev-parse", "--short", "HEAD"])
171        .current_dir(root);
172    clear_ambient_git_env(&mut command);
173    let output = command.output().ok()?;
174    if output.status.success() {
175        Some(String::from_utf8_lossy(&output.stdout).trim().to_string())
176    } else {
177        None
178    }
179}
180
181fn compute_verdict(
182    check: Option<&CheckResult>,
183    dupes: Option<&DupesResult>,
184    health: Option<&HealthResult>,
185) -> AuditVerdict {
186    let mut has_errors = false;
187    let mut has_warnings = false;
188
189    if let Some(result) = check {
190        if crate::check::has_error_severity_issues(
191            &result.results,
192            &result.config.rules,
193            Some(&result.config),
194        ) {
195            has_errors = true;
196        } else if result.results.total_issues() > 0 {
197            has_warnings = true;
198        }
199    }
200
201    if let Some(result) = health
202        && !result.report.findings.is_empty()
203    {
204        has_errors = true;
205    }
206
207    if let Some(result) = dupes
208        && !result.report.clone_groups.is_empty()
209    {
210        if result.threshold > 0.0 && result.report.stats.duplication_percentage > result.threshold {
211            has_errors = true;
212        } else {
213            has_warnings = true;
214        }
215    }
216
217    if has_errors {
218        AuditVerdict::Fail
219    } else if has_warnings {
220        AuditVerdict::Warn
221    } else {
222        AuditVerdict::Pass
223    }
224}
225
226fn build_summary(
227    check: Option<&CheckResult>,
228    dupes: Option<&DupesResult>,
229    health: Option<&HealthResult>,
230) -> AuditSummary {
231    let dead_code_issues = check.map_or(0, |r| r.results.total_issues());
232    let dead_code_has_errors = check.is_some_and(|r| {
233        crate::check::has_error_severity_issues(&r.results, &r.config.rules, Some(&r.config))
234    });
235    let complexity_findings = health.map_or(0, |r| r.report.findings.len());
236    let max_cyclomatic = health.and_then(|r| r.report.findings.iter().map(|f| f.cyclomatic).max());
237    let duplication_clone_groups = dupes.map_or(0, |r| r.report.clone_groups.len());
238
239    AuditSummary {
240        dead_code_issues,
241        dead_code_has_errors,
242        complexity_findings,
243        max_cyclomatic,
244        duplication_clone_groups,
245    }
246}
247
248fn compute_audit_attribution(
249    check: Option<&CheckResult>,
250    dupes: Option<&DupesResult>,
251    health: Option<&HealthResult>,
252    base: Option<&AuditKeySnapshot>,
253    gate: AuditGate,
254) -> AuditAttribution {
255    let dead_code = check
256        .map(|r| {
257            count_introduced(
258                &dead_code_keys(&r.results, &r.config.root),
259                base.map(|b| &b.dead_code),
260            )
261        })
262        .unwrap_or_default();
263    let complexity = health
264        .map(|r| {
265            count_introduced(
266                &health_keys(&r.report, &r.config.root),
267                base.map(|b| &b.health),
268            )
269        })
270        .unwrap_or_default();
271    let duplication = dupes
272        .map(|r| {
273            count_introduced(
274                &dupes_keys(&r.report, &r.config.root),
275                base.map(|b| &b.dupes),
276            )
277        })
278        .unwrap_or_default();
279
280    AuditAttribution {
281        gate,
282        dead_code_introduced: dead_code.0,
283        dead_code_inherited: dead_code.1,
284        complexity_introduced: complexity.0,
285        complexity_inherited: complexity.1,
286        duplication_introduced: duplication.0,
287        duplication_inherited: duplication.1,
288    }
289}
290
291fn compute_introduced_verdict(
292    check: Option<&CheckResult>,
293    dupes: Option<&DupesResult>,
294    health: Option<&HealthResult>,
295    base: Option<&AuditKeySnapshot>,
296) -> AuditVerdict {
297    let mut has_errors = false;
298    let mut has_warnings = false;
299
300    if let Some(result) = check {
301        let base_keys = base.map(|b| &b.dead_code);
302        let mut introduced = result.results.clone();
303        retain_introduced_dead_code(&mut introduced, &result.config.root, base_keys);
304        if crate::check::has_error_severity_issues(
305            &introduced,
306            &result.config.rules,
307            Some(&result.config),
308        ) {
309            has_errors = true;
310        } else if introduced.total_issues() > 0 {
311            has_warnings = true;
312        }
313    }
314
315    if let Some(result) = health {
316        let base_keys = base.map(|b| &b.health);
317        let introduced = result
318            .report
319            .findings
320            .iter()
321            .filter(|finding| {
322                !base_keys.is_some_and(|keys| {
323                    keys.contains(&health_finding_key(finding, &result.config.root))
324                })
325            })
326            .count();
327        if introduced > 0 {
328            has_errors = true;
329        }
330    }
331
332    if let Some(result) = dupes {
333        let base_keys = base.map(|b| &b.dupes);
334        let introduced = result
335            .report
336            .clone_groups
337            .iter()
338            .filter(|group| {
339                !base_keys
340                    .is_some_and(|keys| keys.contains(&dupe_group_key(group, &result.config.root)))
341            })
342            .count();
343        if introduced > 0 {
344            if result.threshold > 0.0
345                && result.report.stats.duplication_percentage > result.threshold
346            {
347                has_errors = true;
348            } else {
349                has_warnings = true;
350            }
351        }
352    }
353
354    if has_errors {
355        AuditVerdict::Fail
356    } else if has_warnings {
357        AuditVerdict::Warn
358    } else {
359        AuditVerdict::Pass
360    }
361}
362
363struct AuditKeySnapshot {
364    dead_code: FxHashSet<String>,
365    health: FxHashSet<String>,
366    dupes: FxHashSet<String>,
367}
368
369struct AuditBaseSnapshotCacheKey {
370    hash: u64,
371    base_sha: String,
372}
373
374#[derive(bitcode::Encode, bitcode::Decode)]
375struct CachedAuditKeySnapshot {
376    version: u8,
377    cli_version: String,
378    key_hash: u64,
379    base_sha: String,
380    dead_code: Vec<String>,
381    health: Vec<String>,
382    dupes: Vec<String>,
383}
384
385fn count_introduced(keys: &FxHashSet<String>, base: Option<&FxHashSet<String>>) -> (usize, usize) {
386    let Some(base) = base else {
387        return (0, 0);
388    };
389    keys.iter().fold((0, 0), |(introduced, inherited), key| {
390        if base.contains(key) {
391            (introduced, inherited + 1)
392        } else {
393            (introduced + 1, inherited)
394        }
395    })
396}
397
398fn sorted_keys(keys: &FxHashSet<String>) -> Vec<String> {
399    let mut keys: Vec<String> = keys.iter().cloned().collect();
400    keys.sort_unstable();
401    keys
402}
403
404fn snapshot_from_cached(cached: CachedAuditKeySnapshot) -> AuditKeySnapshot {
405    AuditKeySnapshot {
406        dead_code: cached.dead_code.into_iter().collect(),
407        health: cached.health.into_iter().collect(),
408        dupes: cached.dupes.into_iter().collect(),
409    }
410}
411
412fn cached_from_snapshot(
413    key: &AuditBaseSnapshotCacheKey,
414    snapshot: &AuditKeySnapshot,
415) -> CachedAuditKeySnapshot {
416    CachedAuditKeySnapshot {
417        version: AUDIT_BASE_SNAPSHOT_CACHE_VERSION,
418        cli_version: env!("CARGO_PKG_VERSION").to_string(),
419        key_hash: key.hash,
420        base_sha: key.base_sha.clone(),
421        dead_code: sorted_keys(&snapshot.dead_code),
422        health: sorted_keys(&snapshot.health),
423        dupes: sorted_keys(&snapshot.dupes),
424    }
425}
426
427fn audit_base_snapshot_cache_dir(cache_dir: &Path) -> PathBuf {
428    cache_dir
429        .join("cache")
430        .join(format!("audit-base-v{AUDIT_BASE_SNAPSHOT_CACHE_VERSION}"))
431}
432
433fn audit_base_snapshot_cache_file(cache_dir: &Path, key: &AuditBaseSnapshotCacheKey) -> PathBuf {
434    audit_base_snapshot_cache_dir(cache_dir).join(format!("{:016x}.bin", key.hash))
435}
436
437fn ensure_audit_base_snapshot_cache_dir(dir: &Path) -> Result<(), std::io::Error> {
438    std::fs::create_dir_all(dir)?;
439    let gitignore = dir.join(".gitignore");
440    if std::fs::read_to_string(&gitignore).ok().as_deref() != Some("*\n") {
441        std::fs::write(gitignore, "*\n")?;
442    }
443    Ok(())
444}
445
446fn load_cached_base_snapshot(
447    opts: &AuditOptions<'_>,
448    key: &AuditBaseSnapshotCacheKey,
449) -> Option<AuditKeySnapshot> {
450    let path = audit_base_snapshot_cache_file(opts.cache_dir, key);
451    let data = std::fs::read(path).ok()?;
452    if data.len() > MAX_AUDIT_BASE_SNAPSHOT_CACHE_SIZE {
453        return None;
454    }
455    let cached: CachedAuditKeySnapshot = bitcode::decode(&data).ok()?;
456    if cached.version != AUDIT_BASE_SNAPSHOT_CACHE_VERSION
457        || cached.cli_version != env!("CARGO_PKG_VERSION")
458        || cached.key_hash != key.hash
459        || cached.base_sha != key.base_sha
460    {
461        return None;
462    }
463    Some(snapshot_from_cached(cached))
464}
465
466fn save_cached_base_snapshot(
467    opts: &AuditOptions<'_>,
468    key: &AuditBaseSnapshotCacheKey,
469    snapshot: &AuditKeySnapshot,
470) {
471    let dir = audit_base_snapshot_cache_dir(opts.cache_dir);
472    if ensure_audit_base_snapshot_cache_dir(&dir).is_err() {
473        return;
474    }
475    let data = bitcode::encode(&cached_from_snapshot(key, snapshot));
476    let Ok(mut tmp) = tempfile::NamedTempFile::new_in(&dir) else {
477        return;
478    };
479    if tmp.write_all(&data).is_err() {
480        return;
481    }
482    let _ = tmp.persist(audit_base_snapshot_cache_file(opts.cache_dir, key));
483}
484
485fn git_rev_parse(root: &Path, rev: &str) -> Option<String> {
486    let mut command = Command::new("git");
487    command.args(["rev-parse", rev]).current_dir(root);
488    clear_ambient_git_env(&mut command);
489    let output = command.output().ok()?;
490    if !output.status.success() {
491        return None;
492    }
493    Some(String::from_utf8_lossy(&output.stdout).trim().to_string())
494}
495
496/// If fallow's process inherited any ambient git repo-state env vars (typical
497/// when invoked from a `pre-commit` / `pre-push` hook or a tool wrapping git),
498/// surface the most likely culprit so a user hitting an unexpected worktree
499/// failure can short-circuit the diagnosis. Returns `None` otherwise.
500fn ambient_git_env_hint() -> Option<String> {
501    use fallow_core::git_env::AMBIENT_GIT_ENV_VARS;
502    for var in AMBIENT_GIT_ENV_VARS {
503        if let Ok(value) = std::env::var(var)
504            && !value.is_empty()
505        {
506            return Some(format!(
507                "{var}={value} is set in the environment; if fallow is being \
508invoked from a git hook this can interfere with worktree operations. Re-run \
509with `env -u {var} fallow audit` to confirm."
510            ));
511        }
512    }
513    None
514}
515
516fn normalized_changed_files(root: &Path, changed_files: &FxHashSet<PathBuf>) -> Vec<String> {
517    let git_root = git_toplevel(root);
518    let mut files: Vec<String> = changed_files
519        .iter()
520        .map(|path| {
521            git_root
522                .as_ref()
523                .and_then(|root| path.strip_prefix(root).ok())
524                .unwrap_or(path)
525                .to_string_lossy()
526                .replace('\\', "/")
527        })
528        .collect();
529    files.sort_unstable();
530    files
531}
532
533fn config_file_fingerprint(opts: &AuditOptions<'_>) -> Result<serde_json::Value, ExitCode> {
534    let loaded = if let Some(path) = opts.config_path {
535        let config = fallow_config::FallowConfig::load(path).map_err(|e| {
536            emit_error(
537                &format!("failed to load config '{}': {e}", path.display()),
538                2,
539                opts.output,
540            )
541        })?;
542        Some((config, path.clone()))
543    } else {
544        fallow_config::FallowConfig::find_and_load(opts.root)
545            .map_err(|e| emit_error(&e, 2, opts.output))?
546    };
547
548    let Some((config, path)) = loaded else {
549        return Ok(serde_json::json!({
550            "path": null,
551            "resolved_hash": null,
552        }));
553    };
554    let bytes = serde_json::to_vec(&config).map_err(|e| {
555        emit_error(
556            &format!("failed to serialize resolved config for audit cache key: {e}"),
557            2,
558            opts.output,
559        )
560    })?;
561    Ok(serde_json::json!({
562        "path": path.to_string_lossy(),
563        "resolved_hash": format!("{:016x}", xxh3_64(&bytes)),
564    }))
565}
566
567fn coverage_file_fingerprint(path: &Path, project_root: &Path) -> serde_json::Value {
568    let resolved = crate::health::scoring::resolve_relative_to_root(path, Some(project_root));
569    let file_path = if resolved.is_dir() {
570        resolved.join("coverage-final.json")
571    } else {
572        resolved
573    };
574    match std::fs::read(&file_path) {
575        Ok(bytes) => serde_json::json!({
576            "path": path.to_string_lossy(),
577            "resolved_path": file_path.to_string_lossy(),
578            "content_hash": format!("{:016x}", xxh3_64(&bytes)),
579            "len": bytes.len(),
580        }),
581        Err(err) => serde_json::json!({
582            "path": path.to_string_lossy(),
583            "resolved_path": file_path.to_string_lossy(),
584            "error": err.kind().to_string(),
585        }),
586    }
587}
588
589fn audit_base_snapshot_cache_key(
590    opts: &AuditOptions<'_>,
591    base_ref: &str,
592    changed_files: &FxHashSet<PathBuf>,
593) -> Result<Option<AuditBaseSnapshotCacheKey>, ExitCode> {
594    if opts.no_cache {
595        return Ok(None);
596    }
597    let Some(base_sha) = git_rev_parse(opts.root, base_ref) else {
598        return Ok(None);
599    };
600    let config_file = config_file_fingerprint(opts)?;
601    let coverage_file = opts
602        .coverage
603        .map(|p| coverage_file_fingerprint(p, opts.root));
604    let payload = serde_json::json!({
605        "cache_version": AUDIT_BASE_SNAPSHOT_CACHE_VERSION,
606        "cli_version": env!("CARGO_PKG_VERSION"),
607        "base_sha": base_sha,
608        "config_file": config_file,
609        "changed_files": normalized_changed_files(opts.root, changed_files),
610        "production": opts.production,
611        "production_dead_code": opts.production_dead_code,
612        "production_health": opts.production_health,
613        "production_dupes": opts.production_dupes,
614        "workspace": opts.workspace,
615        "changed_workspaces": opts.changed_workspaces,
616        "group_by": opts.group_by.map(|g| format!("{g:?}")),
617        "include_entry_exports": opts.include_entry_exports,
618        "max_crap": opts.max_crap,
619        "coverage": coverage_file,
620        "coverage_root": opts.coverage_root.map(|p| p.to_string_lossy().to_string()),
621        "dead_code_baseline": opts.dead_code_baseline.map(|p| p.to_string_lossy().to_string()),
622        "health_baseline": opts.health_baseline.map(|p| p.to_string_lossy().to_string()),
623        "dupes_baseline": opts.dupes_baseline.map(|p| p.to_string_lossy().to_string()),
624    });
625    let bytes = serde_json::to_vec(&payload).map_err(|e| {
626        emit_error(
627            &format!("failed to build audit cache key: {e}"),
628            2,
629            opts.output,
630        )
631    })?;
632    Ok(Some(AuditBaseSnapshotCacheKey {
633        hash: xxh3_64(&bytes),
634        base_sha,
635    }))
636}
637
638fn compute_base_snapshot(
639    opts: &AuditOptions<'_>,
640    base_ref: &str,
641    changed_files: &FxHashSet<PathBuf>,
642    base_sha: Option<&str>,
643) -> Result<AuditKeySnapshot, ExitCode> {
644    let Some(worktree) = BaseWorktree::create(opts.root, base_ref, base_sha) else {
645        use std::fmt::Write as _;
646        let mut message =
647            format!("could not create a temporary worktree for base ref '{base_ref}'");
648        if let Some(hint) = ambient_git_env_hint() {
649            let _ = write!(message, "\n  hint: {hint}");
650        }
651        return Err(emit_error(&message, 2, opts.output));
652    };
653    let base_root = base_analysis_root(opts.root, worktree.path());
654    let base_cache_dir = remap_cache_dir_for_base_worktree(opts.root, &base_root, opts.cache_dir);
655    let current_config_path = opts
656        .config_path
657        .clone()
658        .or_else(|| fallow_config::FallowConfig::find_config_path(opts.root));
659    let base_opts = AuditOptions {
660        root: &base_root,
661        config_path: &current_config_path,
662        cache_dir: &base_cache_dir,
663        output: opts.output,
664        no_cache: opts.no_cache,
665        threads: opts.threads,
666        quiet: true,
667        changed_since: None,
668        production: opts.production,
669        production_dead_code: opts.production_dead_code,
670        production_health: opts.production_health,
671        production_dupes: opts.production_dupes,
672        workspace: opts.workspace,
673        changed_workspaces: None,
674        explain: false,
675        explain_skipped: false,
676        performance: false,
677        group_by: opts.group_by,
678        dead_code_baseline: None,
679        health_baseline: None,
680        dupes_baseline: None,
681        max_crap: opts.max_crap,
682        coverage: opts.coverage,
683        coverage_root: opts.coverage_root,
684        gate: AuditGate::All,
685        include_entry_exports: opts.include_entry_exports,
686        runtime_coverage: None,
687        min_invocations_hot: opts.min_invocations_hot,
688    };
689
690    let base_changed_files = remap_focus_files(changed_files, opts.root, &base_root);
691    let check_production = opts.production_dead_code.unwrap_or(opts.production);
692    let health_production = opts.production_health.unwrap_or(opts.production);
693    let share_dead_code_parse_with_health = check_production == health_production;
694
695    let (check_res, dupes_res) = rayon::join(
696        || run_audit_check(&base_opts, None, share_dead_code_parse_with_health),
697        || run_audit_dupes(&base_opts, None, base_changed_files.as_ref(), None),
698    );
699    let mut check = check_res?;
700    let dupes = dupes_res?;
701    let shared_parse = if share_dead_code_parse_with_health {
702        check.as_mut().and_then(|r| r.shared_parse.take())
703    } else {
704        None
705    };
706    let health = run_audit_health(&base_opts, None, shared_parse)?;
707    if let Some(ref mut check) = check {
708        check.shared_parse = None;
709    }
710
711    Ok(AuditKeySnapshot {
712        dead_code: check.as_ref().map_or_else(FxHashSet::default, |r| {
713            dead_code_keys(&r.results, &r.config.root)
714        }),
715        health: health.as_ref().map_or_else(FxHashSet::default, |r| {
716            health_keys(&r.report, &r.config.root)
717        }),
718        dupes: dupes.as_ref().map_or_else(FxHashSet::default, |r| {
719            dupes_keys(&r.report, &r.config.root)
720        }),
721    })
722}
723
724fn base_analysis_root(current_root: &Path, base_worktree_root: &Path) -> PathBuf {
725    let Some(git_root) = git_toplevel(current_root) else {
726        return base_worktree_root.to_path_buf();
727    };
728    let current_root =
729        dunce::canonicalize(current_root).unwrap_or_else(|_| current_root.to_path_buf());
730    match current_root.strip_prefix(&git_root) {
731        Ok(relative) => base_worktree_root.join(relative),
732        Err(err) => {
733            tracing::warn!(
734                current_root = %current_root.display(),
735                git_root = %git_root.display(),
736                error = %err,
737                "Could not remap audit base root into the base worktree; falling back to worktree root"
738            );
739            base_worktree_root.to_path_buf()
740        }
741    }
742}
743
744fn current_keys_as_base_keys(
745    check: Option<&CheckResult>,
746    dupes: Option<&DupesResult>,
747    health: Option<&HealthResult>,
748) -> AuditKeySnapshot {
749    AuditKeySnapshot {
750        dead_code: check.as_ref().map_or_else(FxHashSet::default, |r| {
751            dead_code_keys(&r.results, &r.config.root)
752        }),
753        health: health.as_ref().map_or_else(FxHashSet::default, |r| {
754            health_keys(&r.report, &r.config.root)
755        }),
756        dupes: dupes.as_ref().map_or_else(FxHashSet::default, |r| {
757            dupes_keys(&r.report, &r.config.root)
758        }),
759    }
760}
761
762fn can_reuse_current_as_base(
763    opts: &AuditOptions<'_>,
764    base_ref: &str,
765    changed_files: &FxHashSet<PathBuf>,
766) -> bool {
767    let Some(git_root) = git_toplevel(opts.root) else {
768        return false;
769    };
770    let cache_dir = opts.cache_dir.to_path_buf();
771    let canonical_cache_dir = dunce::canonicalize(&cache_dir).ok();
772    changed_files.iter().all(|path| {
773        if is_fallow_cache_artifact(path, &cache_dir, canonical_cache_dir.as_deref()) {
774            return true;
775        }
776        if !is_analysis_input(path) {
777            return is_non_behavioral_doc(path);
778        }
779        let Ok(current) = std::fs::read_to_string(path) else {
780            return false;
781        };
782        let Some(relative) = path.strip_prefix(&git_root).ok() else {
783            return false;
784        };
785        let Some(base) = git_show_file(opts.root, base_ref, relative) else {
786            return false;
787        };
788        if current == base {
789            return true;
790        }
791        js_ts_tokens_equivalent(path, &current, &base)
792    })
793}
794
795fn is_fallow_cache_artifact(
796    path: &Path,
797    cache_dir: &Path,
798    canonical_cache_dir: Option<&Path>,
799) -> bool {
800    path.starts_with(cache_dir)
801        || canonical_cache_dir.is_some_and(|canonical| path.starts_with(canonical))
802}
803
804fn remap_cache_dir_for_base_worktree(
805    current_root: &Path,
806    base_worktree_root: &Path,
807    cache_dir: &Path,
808) -> PathBuf {
809    if cache_dir.is_absolute()
810        && let Ok(relative) = cache_dir.strip_prefix(current_root)
811    {
812        return base_worktree_root.join(relative);
813    }
814    cache_dir.to_path_buf()
815}
816
817fn git_toplevel(root: &Path) -> Option<PathBuf> {
818    let mut command = Command::new("git");
819    command
820        .args(["rev-parse", "--show-toplevel"])
821        .current_dir(root);
822    clear_ambient_git_env(&mut command);
823    let output = command.output().ok()?;
824    if !output.status.success() {
825        return None;
826    }
827    let path = PathBuf::from(String::from_utf8_lossy(&output.stdout).trim());
828    Some(dunce::canonicalize(&path).unwrap_or(path))
829}
830
831fn git_show_file(root: &Path, base_ref: &str, relative: &Path) -> Option<String> {
832    let spec = format!(
833        "{}:{}",
834        base_ref,
835        relative.to_string_lossy().replace('\\', "/")
836    );
837    let mut command = Command::new("git");
838    command
839        .args(["show", "--end-of-options", &spec])
840        .current_dir(root);
841    clear_ambient_git_env(&mut command);
842    let output = command.output().ok()?;
843    output
844        .status
845        .success()
846        .then(|| String::from_utf8_lossy(&output.stdout).into_owned())
847}
848
849fn is_analysis_input(path: &Path) -> bool {
850    matches!(
851        path.extension().and_then(|ext| ext.to_str()),
852        Some(
853            "js" | "jsx"
854                | "ts"
855                | "tsx"
856                | "mjs"
857                | "mts"
858                | "cjs"
859                | "cts"
860                | "vue"
861                | "svelte"
862                | "astro"
863                | "mdx"
864                | "css"
865                | "scss"
866        )
867    )
868}
869
870fn is_non_behavioral_doc(path: &Path) -> bool {
871    matches!(
872        path.extension().and_then(|ext| ext.to_str()),
873        Some("md" | "markdown" | "txt" | "rst" | "adoc")
874    )
875}
876
877fn js_ts_tokens_equivalent(path: &Path, current: &str, base: &str) -> bool {
878    if current.contains("fallow-ignore") || base.contains("fallow-ignore") {
879        return false;
880    }
881    if !matches!(
882        path.extension().and_then(|ext| ext.to_str()),
883        Some("js" | "jsx" | "ts" | "tsx" | "mjs" | "mts" | "cjs" | "cts")
884    ) {
885        return false;
886    }
887    let current_tokens = fallow_core::duplicates::tokenize::tokenize_file(path, current, false);
888    let base_tokens = fallow_core::duplicates::tokenize::tokenize_file(path, base, false);
889    current_tokens
890        .tokens
891        .iter()
892        .map(|token| &token.kind)
893        .eq(base_tokens.tokens.iter().map(|token| &token.kind))
894}
895
896fn remap_focus_files(
897    files: &FxHashSet<PathBuf>,
898    from_root: &Path,
899    to_root: &Path,
900) -> Option<FxHashSet<PathBuf>> {
901    let mut remapped = FxHashSet::default();
902    for file in files {
903        if let Ok(relative) = file.strip_prefix(from_root) {
904            remapped.insert(to_root.join(relative));
905        }
906    }
907    if remapped.is_empty() {
908        return None;
909    }
910    Some(remapped)
911}
912
913#[path = "audit_base_worktree.rs"]
914mod base_worktree;
915
916use base_worktree::{BaseWorktree, resolve_cache_max_age, sweep_old_reusable_caches};
917
918#[cfg(test)]
919use std::time::SystemTime;
920
921#[cfg(test)]
922use base_worktree::{
923    ReusableWorktreeLock, WorktreeCleanupGuard, audit_worktree_pid, days_to_duration,
924    is_fallow_audit_worktree_path, is_reusable_audit_worktree_path, list_audit_worktrees,
925    materialize_base_dependency_context, parse_worktree_list, paths_equal, process_is_alive,
926    remove_audit_worktree, reusable_worktree_last_used_path, reusable_worktree_lock_path,
927    sweep_orphan_audit_worktrees, touch_last_used,
928};
929
930#[path = "audit_keys.rs"]
931mod keys;
932
933use keys::{
934    dead_code_keys, dupe_group_key, dupes_keys, health_finding_key, health_keys,
935    retain_introduced_dead_code,
936};
937
938struct HeadAnalyses {
939    check: Option<CheckResult>,
940    dupes: Option<DupesResult>,
941    health: Option<HealthResult>,
942}
943
944/// Run the three HEAD-side analyses with intra-pipeline sharing intact:
945/// check first (so its parsed modules are available), then dupes (which can
946/// reuse check's discovered file list when production settings match), then
947/// health (which can reuse check's parsed modules when production settings
948/// match). Designed to be called from inside `rayon::join` alongside
949/// [`compute_base_snapshot`], which operates on an isolated worktree.
950fn run_audit_head_analyses(
951    opts: &AuditOptions<'_>,
952    changed_since: Option<&str>,
953    changed_files: &FxHashSet<PathBuf>,
954) -> Result<HeadAnalyses, ExitCode> {
955    let check_production = opts.production_dead_code.unwrap_or(opts.production);
956    let health_production = opts.production_health.unwrap_or(opts.production);
957    let dupes_production = opts.production_dupes.unwrap_or(opts.production);
958    let share_dead_code_parse_with_health = check_production == health_production;
959    let share_dead_code_files_with_dupes =
960        share_dead_code_parse_with_health && check_production == dupes_production;
961
962    let mut check = run_audit_check(opts, changed_since, share_dead_code_parse_with_health)?;
963    let dupes_files = if share_dead_code_files_with_dupes {
964        check
965            .as_ref()
966            .and_then(|r| r.shared_parse.as_ref().map(|sp| sp.files.clone()))
967    } else {
968        None
969    };
970    let dupes = run_audit_dupes(opts, changed_since, Some(changed_files), dupes_files)?;
971    let shared_parse = if share_dead_code_parse_with_health {
972        check.as_mut().and_then(|r| r.shared_parse.take())
973    } else {
974        None
975    };
976    let health = run_audit_health(opts, changed_since, shared_parse)?;
977    Ok(HeadAnalyses {
978        check,
979        dupes,
980        health,
981    })
982}
983
984/// Run the audit pipeline: resolve base ref, run analyses, compute verdict.
985pub fn execute_audit(opts: &AuditOptions<'_>) -> Result<AuditResult, ExitCode> {
986    let start = Instant::now();
987
988    let base_ref = resolve_base_ref(opts)?;
989
990    // Always sweep: prunable orphans (cache dir externally reaped, git admin
991    // entry left behind) are reclaimed regardless of the age threshold, so the
992    // sweep runs even when age-based GC is disabled (`max_age` is `None`).
993    sweep_old_reusable_caches(opts.root, resolve_cache_max_age(opts), opts.quiet);
994
995    let Some(changed_files) = crate::check::get_changed_files(opts.root, &base_ref) else {
996        return Err(emit_error(
997            &format!(
998                "could not determine changed files for base ref '{base_ref}'. Verify the ref exists in this git repository"
999            ),
1000            2,
1001            opts.output,
1002        ));
1003    };
1004    let changed_files_count = changed_files.len();
1005
1006    if changed_files.is_empty() {
1007        return Ok(empty_audit_result(base_ref, opts, start.elapsed()));
1008    }
1009
1010    let changed_since = Some(base_ref.as_str());
1011
1012    let needs_real_base_snapshot = matches!(opts.gate, AuditGate::NewOnly)
1013        && !can_reuse_current_as_base(opts, &base_ref, &changed_files);
1014    let base_cache_key = if needs_real_base_snapshot {
1015        audit_base_snapshot_cache_key(opts, &base_ref, &changed_files)?
1016    } else {
1017        None
1018    };
1019    let cached_base_snapshot = base_cache_key
1020        .as_ref()
1021        .and_then(|key| load_cached_base_snapshot(opts, key));
1022
1023    let (head_res, base_res) = if needs_real_base_snapshot && cached_base_snapshot.is_none() {
1024        let base_sha = base_cache_key.as_ref().map(|key| key.base_sha.as_str());
1025        let (h, b) = rayon::join(
1026            || run_audit_head_analyses(opts, changed_since, &changed_files),
1027            || compute_base_snapshot(opts, &base_ref, &changed_files, base_sha),
1028        );
1029        (h, Some(b))
1030    } else {
1031        (
1032            run_audit_head_analyses(opts, changed_since, &changed_files),
1033            None,
1034        )
1035    };
1036
1037    let head = head_res?;
1038    let mut check_result = head.check;
1039    let dupes_result = head.dupes;
1040    let health_result = head.health;
1041
1042    let (base_snapshot, base_snapshot_skipped) = if matches!(opts.gate, AuditGate::NewOnly) {
1043        if let Some(snapshot) = cached_base_snapshot {
1044            (Some(snapshot), false)
1045        } else if let Some(base_res) = base_res {
1046            let snapshot = base_res?;
1047            if let Some(ref key) = base_cache_key {
1048                save_cached_base_snapshot(opts, key, &snapshot);
1049            }
1050            (Some(snapshot), false)
1051        } else {
1052            (
1053                Some(current_keys_as_base_keys(
1054                    check_result.as_ref(),
1055                    dupes_result.as_ref(),
1056                    health_result.as_ref(),
1057                )),
1058                true,
1059            )
1060        }
1061    } else {
1062        (None, false)
1063    };
1064    if let Some(ref mut check) = check_result {
1065        check.shared_parse = None;
1066    }
1067    let attribution = compute_audit_attribution(
1068        check_result.as_ref(),
1069        dupes_result.as_ref(),
1070        health_result.as_ref(),
1071        base_snapshot.as_ref(),
1072        opts.gate,
1073    );
1074    let verdict = if matches!(opts.gate, AuditGate::NewOnly) {
1075        compute_introduced_verdict(
1076            check_result.as_ref(),
1077            dupes_result.as_ref(),
1078            health_result.as_ref(),
1079            base_snapshot.as_ref(),
1080        )
1081    } else {
1082        compute_verdict(
1083            check_result.as_ref(),
1084            dupes_result.as_ref(),
1085            health_result.as_ref(),
1086        )
1087    };
1088    let summary = build_summary(
1089        check_result.as_ref(),
1090        dupes_result.as_ref(),
1091        health_result.as_ref(),
1092    );
1093
1094    Ok(AuditResult {
1095        verdict,
1096        summary,
1097        attribution,
1098        base_snapshot,
1099        base_snapshot_skipped,
1100        changed_files_count,
1101        changed_files: changed_files.into_iter().collect(),
1102        base_ref,
1103        head_sha: get_head_sha(opts.root),
1104        output: opts.output,
1105        performance: opts.performance,
1106        check: check_result,
1107        dupes: dupes_result,
1108        health: health_result,
1109        elapsed: start.elapsed(),
1110    })
1111}
1112
1113/// Resolve the base ref: explicit --changed-since / --base, or auto-detect.
1114fn resolve_base_ref(opts: &AuditOptions<'_>) -> Result<String, ExitCode> {
1115    if let Some(ref_str) = opts.changed_since {
1116        return Ok(ref_str.to_string());
1117    }
1118    let Some(branch) = auto_detect_base_branch(opts.root) else {
1119        return Err(emit_error(
1120            "could not detect base branch. Use --base <ref> to specify the comparison target (e.g., --base main)",
1121            2,
1122            opts.output,
1123        ));
1124    };
1125    if let Err(e) = crate::validate::validate_git_ref(&branch) {
1126        return Err(emit_error(
1127            &format!("auto-detected base branch '{branch}' is not a valid git ref: {e}"),
1128            2,
1129            opts.output,
1130        ));
1131    }
1132    Ok(branch)
1133}
1134
1135/// Build an empty pass result when no files have changed.
1136fn empty_audit_result(base_ref: String, opts: &AuditOptions<'_>, elapsed: Duration) -> AuditResult {
1137    AuditResult {
1138        verdict: AuditVerdict::Pass,
1139        summary: AuditSummary {
1140            dead_code_issues: 0,
1141            dead_code_has_errors: false,
1142            complexity_findings: 0,
1143            max_cyclomatic: None,
1144            duplication_clone_groups: 0,
1145        },
1146        attribution: AuditAttribution {
1147            gate: opts.gate,
1148            ..AuditAttribution::default()
1149        },
1150        base_snapshot: None,
1151        base_snapshot_skipped: false,
1152        changed_files_count: 0,
1153        changed_files: Vec::new(),
1154        base_ref,
1155        head_sha: get_head_sha(opts.root),
1156        output: opts.output,
1157        performance: opts.performance,
1158        check: None,
1159        dupes: None,
1160        health: None,
1161        elapsed,
1162    }
1163}
1164
1165/// Run dead code analysis for the audit pipeline.
1166fn run_audit_check<'a>(
1167    opts: &'a AuditOptions<'a>,
1168    changed_since: Option<&'a str>,
1169    retain_modules_for_health: bool,
1170) -> Result<Option<CheckResult>, ExitCode> {
1171    let filters = IssueFilters::default();
1172    let trace_opts = TraceOptions {
1173        trace_export: None,
1174        trace_file: None,
1175        trace_dependency: None,
1176        performance: opts.performance,
1177    };
1178    match crate::check::execute_check(&CheckOptions {
1179        root: opts.root,
1180        config_path: opts.config_path,
1181        output: opts.output,
1182        no_cache: opts.no_cache,
1183        threads: opts.threads,
1184        quiet: opts.quiet,
1185        fail_on_issues: false,
1186        filters: &filters,
1187        changed_since,
1188        diff_index: None,
1189        use_shared_diff_index: true,
1190        baseline: opts.dead_code_baseline,
1191        save_baseline: None,
1192        sarif_file: None,
1193        production: opts.production_dead_code.unwrap_or(opts.production),
1194        production_override: opts.production_dead_code,
1195        workspace: opts.workspace,
1196        changed_workspaces: opts.changed_workspaces,
1197        group_by: opts.group_by,
1198        include_dupes: false,
1199        trace_opts: &trace_opts,
1200        explain: opts.explain,
1201        top: None,
1202        file: &[],
1203        include_entry_exports: opts.include_entry_exports,
1204        summary: false,
1205        regression_opts: crate::regression::RegressionOpts {
1206            fail_on_regression: false,
1207            tolerance: crate::regression::Tolerance::Absolute(0),
1208            regression_baseline_file: None,
1209            save_target: crate::regression::SaveRegressionTarget::None,
1210            scoped: true,
1211            quiet: opts.quiet,
1212            output: opts.output,
1213        },
1214        retain_modules_for_health,
1215        defer_performance: false,
1216    }) {
1217        Ok(r) => Ok(Some(r)),
1218        Err(code) => Err(code),
1219    }
1220}
1221
1222/// Run duplication analysis for the audit pipeline.
1223///
1224/// Reads duplication settings from the project config file so that user
1225/// options like `ignoreImports`, `crossLanguage`, and `skipLocal` are
1226/// respected (same as combined mode).
1227fn run_audit_dupes<'a>(
1228    opts: &'a AuditOptions<'a>,
1229    changed_since: Option<&'a str>,
1230    changed_files: Option<&'a FxHashSet<PathBuf>>,
1231    pre_discovered: Option<Vec<fallow_types::discover::DiscoveredFile>>,
1232) -> Result<Option<DupesResult>, ExitCode> {
1233    let dupes_cfg = match crate::load_config_for_analysis(
1234        opts.root,
1235        opts.config_path,
1236        opts.output,
1237        opts.no_cache,
1238        opts.threads,
1239        opts.production_dupes
1240            .or_else(|| opts.production.then_some(true)),
1241        opts.quiet,
1242        fallow_config::ProductionAnalysis::Dupes,
1243    ) {
1244        Ok(c) => c.duplicates,
1245        Err(code) => return Err(code),
1246    };
1247    let dupes_opts = DupesOptions {
1248        root: opts.root,
1249        config_path: opts.config_path,
1250        output: opts.output,
1251        no_cache: opts.no_cache,
1252        threads: opts.threads,
1253        quiet: opts.quiet,
1254        mode: Some(DupesMode::from(dupes_cfg.mode)),
1255        min_tokens: Some(dupes_cfg.min_tokens),
1256        min_lines: Some(dupes_cfg.min_lines),
1257        min_occurrences: Some(dupes_cfg.min_occurrences),
1258        threshold: Some(dupes_cfg.threshold),
1259        skip_local: dupes_cfg.skip_local,
1260        cross_language: dupes_cfg.cross_language,
1261        ignore_imports: dupes_cfg.ignore_imports,
1262        top: None,
1263        baseline_path: opts.dupes_baseline,
1264        save_baseline_path: None,
1265        production: opts.production_dupes.unwrap_or(opts.production),
1266        production_override: opts.production_dupes,
1267        trace: None,
1268        changed_since,
1269        diff_index: None,
1270        use_shared_diff_index: true,
1271        changed_files,
1272        workspace: opts.workspace,
1273        changed_workspaces: opts.changed_workspaces,
1274        explain: opts.explain,
1275        explain_skipped: opts.explain_skipped,
1276        summary: false,
1277        group_by: opts.group_by,
1278        performance: false,
1279    };
1280    let dupes_run = if let Some(files) = pre_discovered {
1281        crate::dupes::execute_dupes_with_files(&dupes_opts, files)
1282    } else {
1283        crate::dupes::execute_dupes(&dupes_opts)
1284    };
1285    match dupes_run {
1286        Ok(r) => Ok(Some(r)),
1287        Err(code) => Err(code),
1288    }
1289}
1290
1291/// Run complexity analysis for the audit pipeline (findings only, no scores/hotspots/targets).
1292fn run_audit_health<'a>(
1293    opts: &'a AuditOptions<'a>,
1294    changed_since: Option<&'a str>,
1295    shared_parse: Option<crate::health::SharedParseData>,
1296) -> Result<Option<HealthResult>, ExitCode> {
1297    let runtime_coverage = match opts.runtime_coverage {
1298        Some(path) => match crate::health::coverage::prepare_options(
1299            path,
1300            opts.min_invocations_hot,
1301            None,
1302            None,
1303            opts.output,
1304        ) {
1305            Ok(options) => Some(options),
1306            Err(code) => return Err(code),
1307        },
1308        None => None,
1309    };
1310
1311    let health_opts = HealthOptions {
1312        root: opts.root,
1313        config_path: opts.config_path,
1314        output: opts.output,
1315        no_cache: opts.no_cache,
1316        threads: opts.threads,
1317        quiet: opts.quiet,
1318        max_cyclomatic: None,
1319        max_cognitive: None,
1320        max_crap: opts.max_crap,
1321        top: None,
1322        sort: SortBy::Cyclomatic,
1323        production: opts.production_health.unwrap_or(opts.production),
1324        production_override: opts.production_health,
1325        changed_since,
1326        diff_index: None,
1327        use_shared_diff_index: true,
1328        workspace: opts.workspace,
1329        changed_workspaces: opts.changed_workspaces,
1330        baseline: opts.health_baseline,
1331        save_baseline: None,
1332        complexity: true,
1333        complexity_breakdown: false,
1334        file_scores: false,
1335        coverage_gaps: false,
1336        config_activates_coverage_gaps: false,
1337        hotspots: false,
1338        ownership: false,
1339        ownership_emails: None,
1340        targets: false,
1341        force_full: false,
1342        score_only_output: false,
1343        enforce_coverage_gap_gate: false,
1344        effort: None,
1345        score: false,
1346        min_score: None,
1347        since: None,
1348        min_commits: None,
1349        explain: opts.explain,
1350        summary: false,
1351        save_snapshot: None,
1352        trend: false,
1353        group_by: opts.group_by,
1354        coverage: opts.coverage,
1355        coverage_root: opts.coverage_root,
1356        performance: opts.performance,
1357        min_severity: None,
1358        report_only: false,
1359        runtime_coverage,
1360        // audit runs no hotspot/ownership pass; --churn-file is health-only.
1361        churn_file: None,
1362    };
1363    let health_run = if let Some(shared) = shared_parse {
1364        crate::health::execute_health_with_shared_parse(&health_opts, shared)
1365    } else {
1366        crate::health::execute_health(&health_opts)
1367    };
1368    match health_run {
1369        Ok(r) => Ok(Some(r)),
1370        Err(code) => Err(code),
1371    }
1372}
1373
1374#[path = "audit_output.rs"]
1375mod output;
1376
1377pub use output::print_audit_result;
1378
1379/// Run the full audit command: execute analyses, print results, return exit code.
1380/// Run audit, optionally tagged with a gate marker (e.g. `"pre-commit"`) so
1381/// Fallow Impact can record a containment event when the gate blocks then
1382/// clears. The marker only affects the local Impact store; it never changes
1383/// the verdict, exit code, or output.
1384pub fn run_audit(opts: &AuditOptions<'_>, gate_marker: Option<&str>) -> ExitCode {
1385    if let Err(e) = crate::health::scoring::validate_coverage_root_absolute(opts.coverage_root) {
1386        return emit_error(&e, 2, opts.output);
1387    }
1388    let coverage_resolved = opts
1389        .coverage
1390        .map(|p| crate::health::scoring::resolve_relative_to_root(p, Some(opts.root)));
1391    let runtime_coverage_resolved = opts
1392        .runtime_coverage
1393        .map(|p| crate::health::scoring::resolve_relative_to_root(p, Some(opts.root)));
1394    let resolved_opts = AuditOptions {
1395        coverage: coverage_resolved.as_deref(),
1396        runtime_coverage: runtime_coverage_resolved.as_deref(),
1397        ..*opts
1398    };
1399    match execute_audit(&resolved_opts) {
1400        Ok(result) => {
1401            let mut findings = result
1402                .check
1403                .as_ref()
1404                .map(|c| crate::impact::collect_dead_code_findings(&c.results))
1405                .unwrap_or_default();
1406            if let Some(health) = result.health.as_ref() {
1407                findings.extend(crate::impact::collect_complexity_findings(&health.report));
1408            }
1409            let clones = result
1410                .dupes
1411                .as_ref()
1412                .map(|d| crate::impact::collect_clone_findings(&d.report))
1413                .unwrap_or_default();
1414            let empty_supps: Vec<fallow_core::results::ActiveSuppression> = Vec::new();
1415            let suppressions = result.check.as_ref().map_or(empty_supps.as_slice(), |c| {
1416                c.results.active_suppressions.as_slice()
1417            });
1418            let attribution = crate::impact::AttributionInput {
1419                root: opts.root,
1420                scope: crate::impact::Scope::ChangedFiles(&result.changed_files),
1421                findings,
1422                clones,
1423                suppressions,
1424            };
1425            crate::impact::record_audit_run(
1426                opts.root,
1427                &result.summary,
1428                &crate::impact::AuditRunRecord {
1429                    verdict: result.verdict,
1430                    gate: gate_marker.is_some(),
1431                    git_sha: result.head_sha.as_deref(),
1432                    version: env!("CARGO_PKG_VERSION"),
1433                    timestamp: &crate::vital_signs::chrono_timestamp(),
1434                    attribution: Some(&attribution),
1435                },
1436            );
1437            print_audit_result(&result, opts.quiet, opts.explain)
1438        }
1439        Err(code) => code,
1440    }
1441}
1442
1443#[cfg(test)]
1444mod tests {
1445    use super::*;
1446    use std::{fs, process::Command};
1447
1448    fn git(dir: &std::path::Path, args: &[&str]) {
1449        let output = Command::new("git")
1450            .args(args)
1451            .current_dir(dir)
1452            .env_remove("GIT_DIR")
1453            .env_remove("GIT_WORK_TREE")
1454            .env("GIT_CONFIG_GLOBAL", "/dev/null")
1455            .env("GIT_CONFIG_SYSTEM", "/dev/null")
1456            .env("GIT_AUTHOR_NAME", "test")
1457            .env("GIT_AUTHOR_EMAIL", "test@test.com")
1458            .env("GIT_COMMITTER_NAME", "test")
1459            .env("GIT_COMMITTER_EMAIL", "test@test.com")
1460            .output()
1461            .expect("git command failed");
1462        assert!(
1463            output.status.success(),
1464            "git {:?} failed\nstdout:\n{}\nstderr:\n{}",
1465            args,
1466            String::from_utf8_lossy(&output.stdout),
1467            String::from_utf8_lossy(&output.stderr)
1468        );
1469    }
1470
1471    #[test]
1472    fn audit_worktree_helpers_filter_to_fallow_temp_prefix() {
1473        let temp = std::env::temp_dir();
1474        let audit_path = temp.join("fallow-audit-base-123-456");
1475        let reusable_path = temp.join("fallow-audit-base-cache-abcd-1234");
1476        let canonical_audit_path = temp
1477            .canonicalize()
1478            .unwrap_or_else(|_| temp.clone())
1479            .join("fallow-audit-base-456-789");
1480        let unrelated_temp = temp.join("other-worktree");
1481        let output = format!(
1482            "worktree /repo\nHEAD abc\n\nworktree {}\nHEAD def\n\nworktree {}\nHEAD ghi\n\nworktree {}\nHEAD jkl\n",
1483            audit_path.display(),
1484            unrelated_temp.display(),
1485            reusable_path.display()
1486        );
1487
1488        assert_eq!(
1489            parse_worktree_list(&output),
1490            vec![audit_path, reusable_path.clone()]
1491        );
1492        assert!(is_fallow_audit_worktree_path(&canonical_audit_path));
1493        assert!(is_reusable_audit_worktree_path(&reusable_path));
1494        assert_eq!(audit_worktree_pid("fallow-audit-base-123-456"), Some(123));
1495        assert_eq!(
1496            audit_worktree_pid("fallow-audit-base-cache-abcd-1234"),
1497            None
1498        );
1499        assert_eq!(audit_worktree_pid("not-fallow-audit-base-123"), None);
1500    }
1501
1502    /// Initialize a throwaway git repo with a single commit and return its root.
1503    /// Used by the worktree-lifecycle tests below as a parent repo that can host
1504    /// `git worktree add` invocations.
1505    fn init_throwaway_repo(parent: &std::path::Path, name: &str) -> PathBuf {
1506        let root = parent.join(name);
1507        fs::create_dir_all(&root).expect("repo root should be created");
1508        fs::write(root.join("README.md"), "seed\n").expect("seed file should be written");
1509        git(&root, &["init", "-b", "main"]);
1510        git(&root, &["add", "."]);
1511        git(
1512            &root,
1513            &["-c", "commit.gpgsign=false", "commit", "-m", "initial"],
1514        );
1515        root
1516    }
1517
1518    #[test]
1519    fn auto_detect_base_branch_prefers_origin_head() {
1520        let tmp = tempfile::TempDir::new().expect("temp dir should be created");
1521        let repo = init_throwaway_repo(tmp.path(), "repo");
1522        git(&repo, &["branch", "trunk"]);
1523        git(&repo, &["update-ref", "refs/remotes/origin/trunk", "trunk"]);
1524        git(
1525            &repo,
1526            &[
1527                "symbolic-ref",
1528                "refs/remotes/origin/HEAD",
1529                "refs/remotes/origin/trunk",
1530            ],
1531        );
1532
1533        assert_eq!(auto_detect_base_branch(&repo), Some("trunk".to_string()));
1534    }
1535
1536    #[test]
1537    fn auto_detect_base_branch_falls_back_to_main() {
1538        let tmp = tempfile::TempDir::new().expect("temp dir should be created");
1539        let repo = init_throwaway_repo(tmp.path(), "repo");
1540
1541        assert_eq!(auto_detect_base_branch(&repo), Some("main".to_string()));
1542    }
1543
1544    #[test]
1545    fn auto_detect_base_branch_falls_back_to_master() {
1546        let tmp = tempfile::TempDir::new().expect("temp dir should be created");
1547        let repo = tmp.path().join("repo");
1548        fs::create_dir_all(&repo).expect("repo root should be created");
1549        fs::write(repo.join("README.md"), "seed\n").expect("seed file should be written");
1550        git(&repo, &["init", "-b", "master"]);
1551        git(&repo, &["add", "."]);
1552        git(
1553            &repo,
1554            &["-c", "commit.gpgsign=false", "commit", "-m", "initial"],
1555        );
1556
1557        assert_eq!(auto_detect_base_branch(&repo), Some("master".to_string()));
1558    }
1559
1560    #[test]
1561    fn auto_detect_base_branch_returns_none_outside_git_repo() {
1562        let tmp = tempfile::TempDir::new().expect("temp dir should be created");
1563
1564        assert_eq!(auto_detect_base_branch(tmp.path()), None);
1565    }
1566
1567    #[test]
1568    fn get_head_sha_returns_short_head_for_git_repo() {
1569        let tmp = tempfile::TempDir::new().expect("temp dir should be created");
1570        let repo = init_throwaway_repo(tmp.path(), "repo");
1571        let output = Command::new("git")
1572            .args(["rev-parse", "--short", "HEAD"])
1573            .current_dir(&repo)
1574            .env_remove("GIT_DIR")
1575            .env_remove("GIT_WORK_TREE")
1576            .output()
1577            .expect("git rev-parse should run");
1578        assert!(output.status.success());
1579
1580        assert_eq!(
1581            get_head_sha(&repo),
1582            Some(String::from_utf8_lossy(&output.stdout).trim().to_string())
1583        );
1584    }
1585
1586    #[test]
1587    fn get_head_sha_returns_none_outside_git_repo() {
1588        let tmp = tempfile::TempDir::new().expect("temp dir should be created");
1589
1590        assert_eq!(get_head_sha(tmp.path()), None);
1591    }
1592
1593    fn worktree_is_registered_with_git(repo_root: &std::path::Path, worktree_path: &Path) -> bool {
1594        list_audit_worktrees(repo_root)
1595            .is_some_and(|paths| paths.iter().any(|p| paths_equal(p, worktree_path)))
1596    }
1597
1598    /// True when `git worktree list --porcelain` still carries an admin entry
1599    /// whose path ends with `worktree_path`'s basename. Unlike
1600    /// `worktree_is_registered_with_git`, this matches by basename against the
1601    /// raw porcelain output, so it stays correct even when the directory has
1602    /// been deleted (a prunable orphan): `paths_equal` canonicalization cannot
1603    /// match a missing path across the macOS `/var` -> `/private/var` symlink,
1604    /// but the unique nanos-suffixed basename is stable.
1605    fn worktree_admin_entry_present(repo_root: &std::path::Path, worktree_path: &Path) -> bool {
1606        let basename = worktree_path
1607            .file_name()
1608            .and_then(|n| n.to_str())
1609            .expect("reusable worktree path has a utf-8 basename");
1610        let output = Command::new("git")
1611            .args(["worktree", "list", "--porcelain"])
1612            .current_dir(repo_root)
1613            .env_remove("GIT_DIR")
1614            .env_remove("GIT_WORK_TREE")
1615            .output()
1616            .expect("git worktree list should run");
1617        String::from_utf8_lossy(&output.stdout)
1618            .lines()
1619            .filter_map(|line| line.strip_prefix("worktree "))
1620            .any(|p| p.ends_with(basename))
1621    }
1622
1623    #[test]
1624    fn worktree_cleanup_guard_runs_on_drop() {
1625        let tmp = tempfile::TempDir::new().expect("temp dir should be created");
1626        let repo = init_throwaway_repo(tmp.path(), "repo");
1627        let worktree_path = tmp.path().join("fallow-audit-base-1234-5678");
1628
1629        git(
1630            &repo,
1631            &[
1632                "worktree",
1633                "add",
1634                "--detach",
1635                "--quiet",
1636                worktree_path.to_str().expect("path is utf-8"),
1637                "HEAD",
1638            ],
1639        );
1640        assert!(worktree_path.is_dir());
1641        assert!(worktree_is_registered_with_git(&repo, &worktree_path));
1642
1643        {
1644            let _guard = WorktreeCleanupGuard::new(&repo, &worktree_path);
1645        }
1646
1647        assert!(
1648            !worktree_path.exists(),
1649            "guard Drop should remove the worktree directory",
1650        );
1651        assert!(
1652            !worktree_is_registered_with_git(&repo, &worktree_path),
1653            "guard Drop should remove the git worktree registration",
1654        );
1655    }
1656
1657    #[test]
1658    fn worktree_cleanup_guard_defused_skips_drop() {
1659        let tmp = tempfile::TempDir::new().expect("temp dir should be created");
1660        let repo = init_throwaway_repo(tmp.path(), "repo");
1661        let worktree_path = tmp.path().join("fallow-audit-base-1234-5679");
1662
1663        git(
1664            &repo,
1665            &[
1666                "worktree",
1667                "add",
1668                "--detach",
1669                "--quiet",
1670                worktree_path.to_str().expect("path is utf-8"),
1671                "HEAD",
1672            ],
1673        );
1674        assert!(worktree_path.is_dir());
1675
1676        {
1677            let mut guard = WorktreeCleanupGuard::new(&repo, &worktree_path);
1678            guard.defuse();
1679            guard.defuse();
1680        }
1681
1682        assert!(
1683            worktree_path.is_dir(),
1684            "defused guard must not remove the worktree on drop",
1685        );
1686        assert!(
1687            worktree_is_registered_with_git(&repo, &worktree_path),
1688            "defused guard must not unregister the worktree from git",
1689        );
1690
1691        remove_audit_worktree(&repo, &worktree_path);
1692        let _ = fs::remove_dir_all(&worktree_path);
1693    }
1694
1695    #[test]
1696    fn audit_orphan_sweep_removes_dead_pid_worktree() {
1697        const DEAD_PID: u32 = 99_999_999;
1698        assert!(!process_is_alive(DEAD_PID));
1699
1700        let tmp = tempfile::TempDir::new().expect("temp dir should be created");
1701        let repo = init_throwaway_repo(tmp.path(), "repo");
1702
1703        let worktree_path = std::env::temp_dir().join(format!(
1704            "fallow-audit-base-{}-{}",
1705            DEAD_PID,
1706            std::time::SystemTime::now()
1707                .duration_since(std::time::UNIX_EPOCH)
1708                .expect("clock should be after epoch")
1709                .as_nanos()
1710        ));
1711        git(
1712            &repo,
1713            &[
1714                "worktree",
1715                "add",
1716                "--detach",
1717                "--quiet",
1718                worktree_path.to_str().expect("path is utf-8"),
1719                "HEAD",
1720            ],
1721        );
1722        assert!(worktree_path.is_dir());
1723        assert!(worktree_is_registered_with_git(&repo, &worktree_path));
1724
1725        sweep_orphan_audit_worktrees(&repo);
1726
1727        assert!(
1728            !worktree_path.exists(),
1729            "sweep should remove worktree owned by a dead PID",
1730        );
1731        assert!(
1732            !worktree_is_registered_with_git(&repo, &worktree_path),
1733            "sweep should unregister worktree owned by a dead PID",
1734        );
1735    }
1736
1737    #[test]
1738    fn audit_orphan_sweep_keeps_live_pid_worktree() {
1739        let live_pid = std::process::id();
1740        assert!(process_is_alive(live_pid));
1741
1742        let tmp = tempfile::TempDir::new().expect("temp dir should be created");
1743        let repo = init_throwaway_repo(tmp.path(), "repo");
1744
1745        let worktree_path = std::env::temp_dir().join(format!(
1746            "fallow-audit-base-{}-{}",
1747            live_pid,
1748            std::time::SystemTime::now()
1749                .duration_since(std::time::UNIX_EPOCH)
1750                .expect("clock should be after epoch")
1751                .as_nanos()
1752        ));
1753        git(
1754            &repo,
1755            &[
1756                "worktree",
1757                "add",
1758                "--detach",
1759                "--quiet",
1760                worktree_path.to_str().expect("path is utf-8"),
1761                "HEAD",
1762            ],
1763        );
1764
1765        sweep_orphan_audit_worktrees(&repo);
1766
1767        assert!(
1768            worktree_path.is_dir(),
1769            "sweep must not remove worktree owned by a live PID",
1770        );
1771        assert!(
1772            worktree_is_registered_with_git(&repo, &worktree_path),
1773            "sweep must not unregister worktree owned by a live PID",
1774        );
1775
1776        remove_audit_worktree(&repo, &worktree_path);
1777        let _ = fs::remove_dir_all(&worktree_path);
1778    }
1779
1780    /// Build a reusable-shaped worktree path inside the system tempdir
1781    /// (so `is_reusable_audit_worktree_path` and `path_is_inside_temp_dir`
1782    /// both match), uniquified by nanos so parallel tests do not collide.
1783    fn make_reusable_path(label: &str) -> PathBuf {
1784        let nanos = std::time::SystemTime::now()
1785            .duration_since(std::time::UNIX_EPOCH)
1786            .expect("clock should be after epoch")
1787            .as_nanos();
1788        std::env::temp_dir().join(format!("fallow-audit-base-cache-{label}-{nanos:032x}"))
1789    }
1790
1791    /// Register a worktree with the parent repo at `path` checked out at HEAD.
1792    /// Mirrors what `BaseWorktree::reuse_or_create` does for the fresh-create
1793    /// path so the GC sweep tests can build real cache entries.
1794    fn register_reusable_worktree(repo: &Path, path: &Path) {
1795        git(
1796            repo,
1797            &[
1798                "worktree",
1799                "add",
1800                "--detach",
1801                "--quiet",
1802                path.to_str().expect("path is utf-8"),
1803                "HEAD",
1804            ],
1805        );
1806    }
1807
1808    fn write_sidecar_with_age(path: &Path, age: Duration) {
1809        let sidecar = reusable_worktree_last_used_path(path);
1810        let file = std::fs::OpenOptions::new()
1811            .create(true)
1812            .truncate(false)
1813            .write(true)
1814            .open(&sidecar)
1815            .expect("sidecar should open");
1816        let when = SystemTime::now()
1817            .checked_sub(age)
1818            .expect("backdated time should fit in SystemTime");
1819        file.set_modified(when)
1820            .expect("set_modified should succeed");
1821    }
1822
1823    /// Tear down a reusable worktree (git registration + dir + sidecar + lock)
1824    /// regardless of which of those the test created. Idempotent.
1825    fn cleanup_reusable_worktree(repo: &Path, path: &Path) {
1826        remove_audit_worktree(repo, path);
1827        let _ = fs::remove_dir_all(path);
1828        let _ = fs::remove_file(reusable_worktree_last_used_path(path));
1829        let _ = fs::remove_file(reusable_worktree_lock_path(path));
1830    }
1831
1832    #[test]
1833    fn reusable_cache_gc_removes_old_entry_with_backdated_sidecar() {
1834        let tmp = tempfile::TempDir::new().expect("temp dir should be created");
1835        let repo = init_throwaway_repo(tmp.path(), "repo-gc-remove");
1836        let worktree_path = make_reusable_path("gc-remove");
1837        register_reusable_worktree(&repo, &worktree_path);
1838        write_sidecar_with_age(&worktree_path, Duration::from_hours(31 * 24));
1839
1840        sweep_old_reusable_caches(&repo, Some(Duration::from_hours(30 * 24)), true);
1841
1842        assert!(
1843            !worktree_path.exists(),
1844            "sweep should remove worktree dir whose sidecar is older than the threshold",
1845        );
1846        assert!(
1847            !worktree_is_registered_with_git(&repo, &worktree_path),
1848            "sweep should unregister the worktree from git",
1849        );
1850        assert!(
1851            !reusable_worktree_last_used_path(&worktree_path).exists(),
1852            "sweep should remove the sidecar `.last-used` file alongside the worktree",
1853        );
1854        cleanup_reusable_worktree(&repo, &worktree_path);
1855    }
1856
1857    #[test]
1858    fn reusable_cache_gc_keeps_fresh_entry() {
1859        let tmp = tempfile::TempDir::new().expect("temp dir should be created");
1860        let repo = init_throwaway_repo(tmp.path(), "repo-gc-keep");
1861        let worktree_path = make_reusable_path("gc-keep");
1862        register_reusable_worktree(&repo, &worktree_path);
1863        write_sidecar_with_age(&worktree_path, Duration::from_mins(1));
1864
1865        sweep_old_reusable_caches(&repo, Some(Duration::from_hours(30 * 24)), true);
1866
1867        assert!(
1868            worktree_path.is_dir(),
1869            "sweep must not remove a worktree whose sidecar is fresher than the threshold",
1870        );
1871        assert!(
1872            worktree_is_registered_with_git(&repo, &worktree_path),
1873            "sweep must not unregister a fresh worktree",
1874        );
1875        cleanup_reusable_worktree(&repo, &worktree_path);
1876    }
1877
1878    #[test]
1879    fn reusable_cache_gc_skips_locked_entry() {
1880        let tmp = tempfile::TempDir::new().expect("temp dir should be created");
1881        let repo = init_throwaway_repo(tmp.path(), "repo-gc-locked");
1882        let worktree_path = make_reusable_path("gc-locked");
1883        register_reusable_worktree(&repo, &worktree_path);
1884        write_sidecar_with_age(&worktree_path, Duration::from_hours(31 * 24));
1885
1886        let lock = ReusableWorktreeLock::try_acquire(&worktree_path)
1887            .expect("test should acquire the lock first");
1888
1889        sweep_old_reusable_caches(&repo, Some(Duration::from_hours(30 * 24)), true);
1890
1891        assert!(
1892            worktree_path.is_dir(),
1893            "sweep must skip a locked entry even when its sidecar is stale",
1894        );
1895        assert!(
1896            worktree_is_registered_with_git(&repo, &worktree_path),
1897            "sweep must not unregister a locked entry",
1898        );
1899        drop(lock);
1900        cleanup_reusable_worktree(&repo, &worktree_path);
1901    }
1902
1903    #[test]
1904    fn reusable_cache_gc_grace_when_sidecar_absent() {
1905        let tmp = tempfile::TempDir::new().expect("temp dir should be created");
1906        let repo = init_throwaway_repo(tmp.path(), "repo-gc-grace");
1907        let worktree_path = make_reusable_path("gc-grace");
1908        register_reusable_worktree(&repo, &worktree_path);
1909        let sidecar = reusable_worktree_last_used_path(&worktree_path);
1910        assert!(
1911            !sidecar.exists(),
1912            "test pre-condition: sidecar should not exist",
1913        );
1914
1915        sweep_old_reusable_caches(&repo, Some(Duration::from_hours(30 * 24)), true);
1916
1917        assert!(
1918            worktree_path.is_dir(),
1919            "pre-upgrade grace: sidecar-absent entries must NOT be removed on first encounter",
1920        );
1921        assert!(
1922            sidecar.exists(),
1923            "pre-upgrade grace: sidecar must be seeded so the next run can age from real last-used",
1924        );
1925        let mtime = std::fs::metadata(&sidecar)
1926            .and_then(|m| m.modified())
1927            .expect("seeded sidecar should have a readable mtime");
1928        let age = SystemTime::now()
1929            .duration_since(mtime)
1930            .unwrap_or(Duration::ZERO);
1931        assert!(
1932            age < Duration::from_mins(1),
1933            "seeded sidecar mtime should be near `now()`, got age {age:?}",
1934        );
1935        cleanup_reusable_worktree(&repo, &worktree_path);
1936    }
1937
1938    #[test]
1939    fn reusable_cache_gc_reclaims_prunable_orphan_when_dir_missing() {
1940        let tmp = tempfile::TempDir::new().expect("temp dir should be created");
1941        let repo = init_throwaway_repo(tmp.path(), "repo-gc-orphan");
1942        let worktree_path = make_reusable_path("gc-orphan");
1943        register_reusable_worktree(&repo, &worktree_path);
1944        // Fresh sidecar: the age branch alone would KEEP this entry, so a
1945        // successful reclaim proves the dir-missing branch drove it.
1946        write_sidecar_with_age(&worktree_path, Duration::from_mins(1));
1947        let sidecar = reusable_worktree_last_used_path(&worktree_path);
1948
1949        // Simulate an external temp-reaper: delete only the worktree directory,
1950        // leaving git's admin entry and the sidecar behind.
1951        fs::remove_dir_all(&worktree_path).expect("test should remove the cache dir");
1952        assert!(
1953            !worktree_path.exists(),
1954            "test pre-condition: cache dir should be gone",
1955        );
1956        assert!(
1957            worktree_admin_entry_present(&repo, &worktree_path),
1958            "test pre-condition: git admin entry should still be registered (prunable)",
1959        );
1960        assert!(
1961            sidecar.exists(),
1962            "test pre-condition: sidecar survives a dir-only reaper",
1963        );
1964
1965        sweep_old_reusable_caches(&repo, Some(Duration::from_hours(30 * 24)), true);
1966
1967        assert!(
1968            !worktree_admin_entry_present(&repo, &worktree_path),
1969            "sweep should unregister a prunable orphan whose dir was externally removed",
1970        );
1971        assert!(
1972            !sidecar.exists(),
1973            "sweep should remove the stale sidecar for a reclaimed orphan",
1974        );
1975        cleanup_reusable_worktree(&repo, &worktree_path);
1976    }
1977
1978    #[test]
1979    fn reusable_cache_gc_reclaims_prunable_orphan_even_when_age_gc_disabled() {
1980        let tmp = tempfile::TempDir::new().expect("temp dir should be created");
1981        let repo = init_throwaway_repo(tmp.path(), "repo-gc-orphan-nogc");
1982        let worktree_path = make_reusable_path("gc-orphan-nogc");
1983        register_reusable_worktree(&repo, &worktree_path);
1984        write_sidecar_with_age(&worktree_path, Duration::from_mins(1));
1985        let sidecar = reusable_worktree_last_used_path(&worktree_path);
1986        fs::remove_dir_all(&worktree_path).expect("test should remove the cache dir");
1987        assert!(
1988            worktree_admin_entry_present(&repo, &worktree_path),
1989            "test pre-condition: git admin entry should still be registered (prunable)",
1990        );
1991        assert!(
1992            sidecar.exists(),
1993            "test pre-condition: sidecar survives a dir-only reaper",
1994        );
1995
1996        // `None` = age-based GC disabled (`cacheMaxAgeDays = 0`). Orphan reclaim
1997        // must still run so dead admin entries do not accumulate forever.
1998        sweep_old_reusable_caches(&repo, None, true);
1999
2000        assert!(
2001            !worktree_admin_entry_present(&repo, &worktree_path),
2002            "orphan reclaim must run even when age-based GC is disabled",
2003        );
2004        assert!(
2005            !sidecar.exists(),
2006            "sweep should remove the stale sidecar even when age-based GC is disabled",
2007        );
2008        cleanup_reusable_worktree(&repo, &worktree_path);
2009    }
2010
2011    #[test]
2012    fn reusable_cache_gc_preserves_lock_file_after_removal() {
2013        let tmp = tempfile::TempDir::new().expect("temp dir should be created");
2014        let repo = init_throwaway_repo(tmp.path(), "repo-gc-lockfile");
2015        let worktree_path = make_reusable_path("gc-lockfile");
2016        register_reusable_worktree(&repo, &worktree_path);
2017        write_sidecar_with_age(&worktree_path, Duration::from_hours(31 * 24));
2018        let lock_path = reusable_worktree_lock_path(&worktree_path);
2019        drop(
2020            ReusableWorktreeLock::try_acquire(&worktree_path)
2021                .expect("test should acquire the lock"),
2022        );
2023        assert!(
2024            lock_path.exists(),
2025            "test pre-condition: lock file should exist before sweep",
2026        );
2027
2028        sweep_old_reusable_caches(&repo, Some(Duration::from_hours(30 * 24)), true);
2029
2030        assert!(
2031            !worktree_path.exists(),
2032            "sweep should still remove the worktree directory",
2033        );
2034        assert!(
2035            lock_path.exists(),
2036            "sweep MUST NOT delete the `.lock` file (lock-lifecycle invariant)",
2037        );
2038        let _ = fs::remove_file(&lock_path);
2039        cleanup_reusable_worktree(&repo, &worktree_path);
2040    }
2041
2042    #[test]
2043    fn reuse_or_create_stamps_sidecar_on_fresh_create() {
2044        let tmp = tempfile::TempDir::new().expect("temp dir should be created");
2045        let repo = init_throwaway_repo(tmp.path(), "repo-fresh-create-stamp");
2046        let base_sha = git_rev_parse(&repo, "HEAD").expect("HEAD should resolve");
2047
2048        let worktree = BaseWorktree::reuse_or_create(&repo, &base_sha)
2049            .expect("fresh reuse_or_create should succeed on a clean repo");
2050        let cache_path = worktree.path().to_path_buf();
2051        let sidecar = reusable_worktree_last_used_path(&cache_path);
2052
2053        assert!(
2054            sidecar.exists(),
2055            "fresh-create must write the sidecar so age is measured from now",
2056        );
2057        let initial_age = std::fs::metadata(&sidecar)
2058            .and_then(|m| m.modified())
2059            .ok()
2060            .and_then(|mtime| SystemTime::now().duration_since(mtime).ok())
2061            .expect("sidecar mtime should be readable and not in the future");
2062        assert!(
2063            initial_age < Duration::from_mins(1),
2064            "fresh-create sidecar mtime should be near now(), got age {initial_age:?}",
2065        );
2066
2067        drop(worktree);
2068        cleanup_reusable_worktree(&repo, &cache_path);
2069    }
2070
2071    #[test]
2072    fn days_to_duration_zero_disables() {
2073        assert!(days_to_duration(0).is_none());
2074        assert_eq!(days_to_duration(1), Some(Duration::from_hours(24)));
2075        assert_eq!(days_to_duration(30), Some(Duration::from_hours(30 * 24)));
2076    }
2077
2078    #[test]
2079    fn reusable_worktree_last_used_path_lives_next_to_cache_dir() {
2080        let cache_dir = std::env::temp_dir().join("fallow-audit-base-cache-abcd-1234");
2081        let sidecar = reusable_worktree_last_used_path(&cache_dir);
2082        assert_eq!(sidecar.parent(), cache_dir.parent());
2083        assert_eq!(
2084            sidecar.file_name().and_then(|s| s.to_str()),
2085            Some("fallow-audit-base-cache-abcd-1234.last-used"),
2086        );
2087    }
2088
2089    #[test]
2090    fn touch_last_used_creates_sidecar_if_missing() {
2091        let tmp = tempfile::TempDir::new().expect("temp dir should be created");
2092        let cache_dir = tmp.path().join("fallow-audit-base-cache-touchtest-0000");
2093        fs::create_dir(&cache_dir).expect("cache dir should be created");
2094        let sidecar = reusable_worktree_last_used_path(&cache_dir);
2095        assert!(!sidecar.exists(), "sidecar should not exist before touch");
2096
2097        touch_last_used(&cache_dir);
2098
2099        assert!(sidecar.exists(), "touch should create the sidecar");
2100        let mtime = fs::metadata(&sidecar)
2101            .and_then(|m| m.modified())
2102            .expect("sidecar should have an mtime");
2103        let age = SystemTime::now()
2104            .duration_since(mtime)
2105            .unwrap_or(Duration::ZERO);
2106        assert!(
2107            age < Duration::from_mins(1),
2108            "touched sidecar should be near `now()`",
2109        );
2110    }
2111
2112    #[test]
2113    fn reusable_worktree_lock_excludes_concurrent_acquires() {
2114        let tmp = tempfile::TempDir::new().expect("temp dir should be created");
2115        let reusable = tmp.path().join("fallow-audit-base-cache-deadbeef-0000");
2116        let lock_path = reusable_worktree_lock_path(&reusable);
2117
2118        let first = ReusableWorktreeLock::try_acquire(&reusable)
2119            .expect("first acquire on a fresh path should succeed");
2120        assert!(
2121            ReusableWorktreeLock::try_acquire(&reusable).is_none(),
2122            "second acquire must fail while the first is held",
2123        );
2124        drop(first);
2125        assert!(
2126            lock_path.exists(),
2127            "lock file must persist after drop (only the kernel lock is released)",
2128        );
2129    }
2130
2131    #[test]
2132    fn base_analysis_root_preserves_repo_subdirectory_roots() {
2133        let tmp = tempfile::TempDir::new().expect("temp dir should be created");
2134        let repo = tmp.path().join("repo");
2135        let app_root = repo.join("apps/mobile");
2136        let base_worktree = tmp.path().join("base-worktree");
2137        fs::create_dir_all(&app_root).expect("app root should be created");
2138        fs::create_dir_all(&base_worktree).expect("base worktree should be created");
2139        git(&repo, &["init", "-b", "main"]);
2140
2141        assert_eq!(
2142            base_analysis_root(&app_root, &base_worktree),
2143            base_worktree.join("apps/mobile")
2144        );
2145    }
2146
2147    #[test]
2148    fn audit_base_worktree_reuses_current_node_modules_context() {
2149        let tmp = tempfile::TempDir::new().expect("temp dir should be created");
2150        let root = tmp.path();
2151        fs::create_dir_all(root.join("src")).expect("src dir should be created");
2152        fs::write(root.join(".gitignore"), "node_modules\n.fallow\n")
2153            .expect("gitignore should be written");
2154        fs::write(
2155            root.join("package.json"),
2156            r#"{"name":"audit-rn-alias","main":"src/index.ts","dependencies":{"@react-native/typescript-config":"1.0.0"}}"#,
2157        )
2158        .expect("package.json should be written");
2159        fs::write(
2160            root.join("tsconfig.json"),
2161            r#"{"extends":"./node_modules/@react-native/typescript-config/tsconfig.json","compilerOptions":{"baseUrl":".","paths":{"@/*":["src/*"]}},"include":["src"]}"#,
2162        )
2163        .expect("tsconfig should be written");
2164        fs::write(
2165            root.join("src/index.ts"),
2166            "import { used } from '@/feature';\nconsole.log(used);\n",
2167        )
2168        .expect("index should be written");
2169        fs::write(root.join("src/feature.ts"), "export const used = 1;\n")
2170            .expect("feature should be written");
2171
2172        git(root, &["init", "-b", "main"]);
2173        git(root, &["add", "."]);
2174        git(
2175            root,
2176            &["-c", "commit.gpgsign=false", "commit", "-m", "initial"],
2177        );
2178
2179        let rn_config = root.join("node_modules/@react-native/typescript-config");
2180        fs::create_dir_all(&rn_config).expect("node_modules config dir should be created");
2181        fs::write(
2182            rn_config.join("tsconfig.json"),
2183            r#"{"compilerOptions":{"jsx":"react-native","moduleResolution":"bundler"}}"#,
2184        )
2185        .expect("node_modules tsconfig should be written");
2186
2187        let worktree =
2188            BaseWorktree::create(root, "HEAD", None).expect("base worktree should be created");
2189        assert!(
2190            worktree.path().join("node_modules").is_dir(),
2191            "base worktree should reuse ignored node_modules from the current checkout"
2192        );
2193        assert!(
2194            worktree
2195                .path()
2196                .join("node_modules/@react-native/typescript-config/tsconfig.json")
2197                .is_file(),
2198            "base worktree should preserve tsconfig extends targets installed in node_modules"
2199        );
2200    }
2201
2202    /// Confirms `materialize_base_dependency_context` symlinks the Nuxt
2203    /// `.nuxt/` generated dir from the host checkout into the audit base
2204    /// worktree. Without this, root `tsconfig.json` `references` entries
2205    /// pointing into `.nuxt/tsconfig.app.json` break in the base pass and
2206    /// emit "Nuxt project missing .nuxt/tsconfig.json" plus "Broken tsconfig
2207    /// chain" warnings. The function is exercised directly here rather than
2208    /// through `BaseWorktree::create` to avoid the `git worktree add`
2209    /// concurrency-flakiness the worktree-level integration tests already
2210    /// exhibit.
2211    #[test]
2212    fn materialize_base_dependency_context_symlinks_nuxt_generated_dir() {
2213        let host = tempfile::TempDir::new().expect("host tempdir should be created");
2214        let worktree = tempfile::TempDir::new().expect("worktree tempdir should be created");
2215
2216        let dot_nuxt = host.path().join(".nuxt");
2217        fs::create_dir_all(&dot_nuxt).expect(".nuxt dir should be created");
2218        fs::write(dot_nuxt.join("tsconfig.json"), r#"{"compilerOptions":{}}"#)
2219            .expect(".nuxt/tsconfig.json should be written");
2220        fs::write(
2221            dot_nuxt.join("tsconfig.app.json"),
2222            r#"{"compilerOptions":{}}"#,
2223        )
2224        .expect(".nuxt/tsconfig.app.json should be written");
2225
2226        materialize_base_dependency_context(host.path(), worktree.path());
2227
2228        let mirrored = worktree.path().join(".nuxt");
2229        assert!(
2230            mirrored.is_dir(),
2231            "base worktree should reuse the ignored .nuxt dir from the host checkout"
2232        );
2233        let link_meta = fs::symlink_metadata(&mirrored)
2234            .expect(".nuxt entry should exist as a symlink in the worktree");
2235        assert!(
2236            link_meta.file_type().is_symlink(),
2237            "base worktree's .nuxt should be a symlink to the host checkout"
2238        );
2239        assert!(
2240            mirrored.join("tsconfig.json").is_file(),
2241            "base worktree should expose .nuxt/tsconfig.json so the Nuxt meta-framework \
2242             prerequisite check stays quiet"
2243        );
2244        assert!(
2245            mirrored.join("tsconfig.app.json").is_file(),
2246            "base worktree should expose .nuxt/tsconfig.app.json so root tsconfig references \
2247             resolve without falling back to resolver-less resolution"
2248        );
2249    }
2250
2251    /// Confirms the same symlink treatment for Astro's `.astro/` generated
2252    /// types directory, which is gitignored by default and would otherwise
2253    /// trip the "Astro project missing .astro/" prerequisite check on the
2254    /// base pass.
2255    #[test]
2256    fn materialize_base_dependency_context_symlinks_astro_generated_dir() {
2257        let host = tempfile::TempDir::new().expect("host tempdir should be created");
2258        let worktree = tempfile::TempDir::new().expect("worktree tempdir should be created");
2259
2260        let dot_astro = host.path().join(".astro");
2261        fs::create_dir_all(&dot_astro).expect(".astro dir should be created");
2262        fs::write(dot_astro.join("types.d.ts"), "// generated types\n")
2263            .expect(".astro/types.d.ts should be written");
2264
2265        materialize_base_dependency_context(host.path(), worktree.path());
2266
2267        let mirrored = worktree.path().join(".astro");
2268        assert!(
2269            mirrored.is_dir(),
2270            "base worktree should reuse the ignored .astro dir from the host checkout"
2271        );
2272        assert!(
2273            mirrored.join("types.d.ts").is_file(),
2274            "base worktree should expose generated Astro types so the Astro meta-framework \
2275             prerequisite check stays quiet"
2276        );
2277    }
2278
2279    /// Confirms the symlink step is a no-op when the host checkout has no
2280    /// meta-framework output. We must not fabricate a dangling `.nuxt`
2281    /// symlink: the Nuxt prerequisite check would then pass on the base pass
2282    /// while the actual `.nuxt/tsconfig.json` still doesn't exist, hiding a
2283    /// real "run `nuxt prepare`" warning on the HEAD pass behind a
2284    /// process-wide dedupe key.
2285    #[test]
2286    fn materialize_base_dependency_context_skips_when_host_lacks_meta_framework_dir() {
2287        let host = tempfile::TempDir::new().expect("host tempdir should be created");
2288        let worktree = tempfile::TempDir::new().expect("worktree tempdir should be created");
2289
2290        materialize_base_dependency_context(host.path(), worktree.path());
2291
2292        assert!(
2293            !worktree.path().join(".nuxt").exists(),
2294            "base worktree should not fabricate a .nuxt symlink when the host has no .nuxt dir"
2295        );
2296        assert!(
2297            !worktree.path().join(".astro").exists(),
2298            "base worktree should not fabricate a .astro symlink when the host has no .astro dir"
2299        );
2300        assert!(
2301            !worktree.path().join("node_modules").exists(),
2302            "base worktree should not fabricate a node_modules symlink when the host has none"
2303        );
2304    }
2305
2306    /// Confirms each entry in `MATERIALIZED_CONTEXT_DIRS` is independent: a
2307    /// missing host `.nuxt/` must not prevent `node_modules` from being
2308    /// symlinked when only one of the two is present on the host.
2309    #[test]
2310    fn materialize_base_dependency_context_handles_each_dir_independently() {
2311        let host = tempfile::TempDir::new().expect("host tempdir should be created");
2312        let worktree = tempfile::TempDir::new().expect("worktree tempdir should be created");
2313
2314        fs::create_dir_all(host.path().join("node_modules"))
2315            .expect("host node_modules should be created");
2316
2317        materialize_base_dependency_context(host.path(), worktree.path());
2318
2319        assert!(
2320            worktree.path().join("node_modules").is_dir(),
2321            "node_modules should still be symlinked even when host has no .nuxt or .astro"
2322        );
2323        assert!(
2324            !worktree.path().join(".nuxt").exists(),
2325            "missing host .nuxt should leave the worktree slot empty"
2326        );
2327    }
2328
2329    /// Confirms a real (non-symlink) generated dir already present in the base
2330    /// worktree is preserved, not clobbered by a host symlink. A base commit
2331    /// that genuinely tracks `.nuxt/` is base-shaped and authoritative; the
2332    /// host-symlink shortcut only fills the gap when the worktree slot is
2333    /// empty (or a stale dangling symlink), so the `destination.is_dir()`
2334    /// early-continue must keep the worktree's own contents.
2335    #[test]
2336    fn materialize_base_dependency_context_preserves_real_worktree_dir() {
2337        let host = tempfile::TempDir::new().expect("host tempdir should be created");
2338        let worktree = tempfile::TempDir::new().expect("worktree tempdir should be created");
2339
2340        let host_nuxt = host.path().join(".nuxt");
2341        fs::create_dir_all(&host_nuxt).expect("host .nuxt dir should be created");
2342        fs::write(host_nuxt.join("tsconfig.json"), r#"{"_source":"host"}"#)
2343            .expect("host .nuxt/tsconfig.json should be written");
2344
2345        let worktree_nuxt = worktree.path().join(".nuxt");
2346        fs::create_dir_all(&worktree_nuxt).expect("worktree .nuxt dir should be created");
2347        fs::write(worktree_nuxt.join("tsconfig.json"), r#"{"_source":"base"}"#)
2348            .expect("worktree .nuxt/tsconfig.json should be written");
2349
2350        materialize_base_dependency_context(host.path(), worktree.path());
2351
2352        let link_meta = fs::symlink_metadata(&worktree_nuxt)
2353            .expect(".nuxt entry should still exist in the worktree");
2354        assert!(
2355            !link_meta.file_type().is_symlink(),
2356            "a real base-tracked .nuxt dir must not be replaced by a host symlink"
2357        );
2358        let contents =
2359            fs::read_to_string(worktree_nuxt.join("tsconfig.json")).expect("tsconfig should read");
2360        assert!(
2361            contents.contains("base"),
2362            "base worktree's own .nuxt contents must survive, not be overwritten by the host's"
2363        );
2364    }
2365
2366    #[test]
2367    fn audit_reusable_base_worktree_refreshes_current_node_modules_context() {
2368        let tmp = tempfile::TempDir::new().expect("temp dir should be created");
2369        let root = tmp.path();
2370        fs::write(root.join(".gitignore"), "node_modules\n.fallow\n")
2371            .expect("gitignore should be written");
2372        fs::write(root.join("package.json"), r#"{"name":"audit-reusable"}"#)
2373            .expect("package.json should be written");
2374
2375        git(root, &["init", "-b", "main"]);
2376        git(root, &["add", "."]);
2377        git(
2378            root,
2379            &["-c", "commit.gpgsign=false", "commit", "-m", "initial"],
2380        );
2381
2382        let rn_config = root.join("node_modules/@react-native/typescript-config");
2383        fs::create_dir_all(&rn_config).expect("node_modules config dir should be created");
2384        fs::write(rn_config.join("tsconfig.json"), "{}")
2385            .expect("node_modules tsconfig should be written");
2386
2387        let base_sha = git_rev_parse(root, "HEAD").expect("HEAD should resolve");
2388        let first = BaseWorktree::create(root, "HEAD", Some(&base_sha))
2389            .expect("persistent base worktree should be created");
2390        let worktree_path = first.path().to_path_buf();
2391        assert!(
2392            worktree_path.join("node_modules").is_dir(),
2393            "initial persistent worktree should receive node_modules context"
2394        );
2395        remove_node_modules_context(&worktree_path);
2396        assert!(
2397            !worktree_path.join("node_modules").exists(),
2398            "test setup should remove the dependency context from the reusable worktree"
2399        );
2400        drop(first);
2401
2402        let reused = BaseWorktree::create(root, "HEAD", Some(&base_sha))
2403            .expect("ready persistent base worktree should be reused");
2404        assert_eq!(reused.path(), worktree_path.as_path());
2405        assert!(
2406            reused.path().join("node_modules").is_dir(),
2407            "ready persistent worktree should refresh missing node_modules context"
2408        );
2409
2410        remove_audit_worktree(root, reused.path());
2411        let _ = fs::remove_dir_all(reused.path());
2412    }
2413
2414    fn remove_node_modules_context(worktree_path: &Path) {
2415        let path = worktree_path.join("node_modules");
2416        let Ok(metadata) = fs::symlink_metadata(&path) else {
2417            return;
2418        };
2419        if metadata.file_type().is_symlink() {
2420            #[cfg(unix)]
2421            let _ = fs::remove_file(path);
2422            #[cfg(windows)]
2423            let _ = fs::remove_dir(&path).or_else(|_| fs::remove_file(&path));
2424        } else {
2425            let _ = fs::remove_dir_all(path);
2426        }
2427    }
2428
2429    #[test]
2430    fn audit_base_snapshot_cache_payload_roundtrips_sets() {
2431        let key = AuditBaseSnapshotCacheKey {
2432            hash: 42,
2433            base_sha: "abc123".to_string(),
2434        };
2435        let snapshot = AuditKeySnapshot {
2436            dead_code: ["dead:a".to_string(), "dead:b".to_string()]
2437                .into_iter()
2438                .collect(),
2439            health: std::iter::once("health:a".to_string()).collect(),
2440            dupes: ["dupe:a".to_string(), "dupe:b".to_string()]
2441                .into_iter()
2442                .collect(),
2443        };
2444
2445        let cached = cached_from_snapshot(&key, &snapshot);
2446        assert_eq!(cached.version, AUDIT_BASE_SNAPSHOT_CACHE_VERSION);
2447        assert_eq!(cached.key_hash, key.hash);
2448        assert_eq!(cached.base_sha, key.base_sha);
2449        assert_eq!(cached.dead_code, vec!["dead:a", "dead:b"]);
2450
2451        let decoded = snapshot_from_cached(cached);
2452        assert_eq!(decoded.dead_code, snapshot.dead_code);
2453        assert_eq!(decoded.health, snapshot.health);
2454        assert_eq!(decoded.dupes, snapshot.dupes);
2455    }
2456
2457    #[test]
2458    fn audit_base_snapshot_cache_dir_writes_gitignore() {
2459        let tmp = tempfile::TempDir::new().expect("temp dir should be created");
2460        let cache_root = tmp.path().join(".custom-fallow-cache");
2461        let cache_dir = audit_base_snapshot_cache_dir(&cache_root);
2462
2463        ensure_audit_base_snapshot_cache_dir(&cache_dir).expect("cache dir should be created");
2464
2465        assert_eq!(
2466            fs::read_to_string(cache_dir.join(".gitignore")).expect("gitignore should read"),
2467            "*\n"
2468        );
2469    }
2470
2471    #[test]
2472    fn audit_base_snapshot_cache_roundtrips_from_disk() {
2473        let tmp = tempfile::TempDir::new().expect("temp dir should be created");
2474        let config_path = None;
2475        let cache_root = tmp.path().join(".custom-fallow-cache");
2476        let opts = AuditOptions {
2477            root: tmp.path(),
2478            cache_dir: &cache_root,
2479            config_path: &config_path,
2480            output: OutputFormat::Json,
2481            no_cache: false,
2482            threads: 1,
2483            quiet: true,
2484            changed_since: Some("HEAD"),
2485            production: false,
2486            production_dead_code: None,
2487            production_health: None,
2488            production_dupes: None,
2489            workspace: None,
2490            changed_workspaces: None,
2491            explain: false,
2492            explain_skipped: false,
2493            performance: false,
2494            group_by: None,
2495            dead_code_baseline: None,
2496            health_baseline: None,
2497            dupes_baseline: None,
2498            max_crap: None,
2499            coverage: None,
2500            coverage_root: None,
2501            gate: AuditGate::NewOnly,
2502            include_entry_exports: false,
2503            runtime_coverage: None,
2504            min_invocations_hot: 100,
2505        };
2506        let key = AuditBaseSnapshotCacheKey {
2507            hash: 0xfeed,
2508            base_sha: "abc123".to_string(),
2509        };
2510        let snapshot = AuditKeySnapshot {
2511            dead_code: std::iter::once("dead:a".to_string()).collect(),
2512            health: std::iter::once("health:a".to_string()).collect(),
2513            dupes: std::iter::once("dupe:a".to_string()).collect(),
2514        };
2515
2516        save_cached_base_snapshot(&opts, &key, &snapshot);
2517        assert!(
2518            audit_base_snapshot_cache_file(&cache_root, &key).exists(),
2519            "snapshot should be saved below the configured cache directory"
2520        );
2521        let loaded = load_cached_base_snapshot(&opts, &key).expect("snapshot should load");
2522
2523        assert_eq!(loaded.dead_code, snapshot.dead_code);
2524        assert_eq!(loaded.health, snapshot.health);
2525        assert_eq!(loaded.dupes, snapshot.dupes);
2526    }
2527
2528    #[test]
2529    fn audit_base_snapshot_cache_rejects_mismatched_key() {
2530        let tmp = tempfile::TempDir::new().expect("temp dir should be created");
2531        let config_path = None;
2532        let cache_root = tmp.path().join(".custom-fallow-cache");
2533        let opts = AuditOptions {
2534            root: tmp.path(),
2535            cache_dir: &cache_root,
2536            config_path: &config_path,
2537            output: OutputFormat::Json,
2538            no_cache: false,
2539            threads: 1,
2540            quiet: true,
2541            changed_since: Some("HEAD"),
2542            production: false,
2543            production_dead_code: None,
2544            production_health: None,
2545            production_dupes: None,
2546            workspace: None,
2547            changed_workspaces: None,
2548            explain: false,
2549            explain_skipped: false,
2550            performance: false,
2551            group_by: None,
2552            dead_code_baseline: None,
2553            health_baseline: None,
2554            dupes_baseline: None,
2555            max_crap: None,
2556            coverage: None,
2557            coverage_root: None,
2558            gate: AuditGate::NewOnly,
2559            include_entry_exports: false,
2560            runtime_coverage: None,
2561            min_invocations_hot: 100,
2562        };
2563        let key = AuditBaseSnapshotCacheKey {
2564            hash: 0xbeef,
2565            base_sha: "head".to_string(),
2566        };
2567        let cached = CachedAuditKeySnapshot {
2568            version: AUDIT_BASE_SNAPSHOT_CACHE_VERSION,
2569            cli_version: env!("CARGO_PKG_VERSION").to_string(),
2570            key_hash: key.hash,
2571            base_sha: "other".to_string(),
2572            dead_code: vec!["dead:a".to_string()],
2573            health: vec![],
2574            dupes: vec![],
2575        };
2576        let cache_dir = audit_base_snapshot_cache_dir(&cache_root);
2577        ensure_audit_base_snapshot_cache_dir(&cache_dir).expect("cache dir should be created");
2578        fs::write(
2579            audit_base_snapshot_cache_file(&cache_root, &key),
2580            bitcode::encode(&cached),
2581        )
2582        .expect("cache file should be written");
2583
2584        assert!(load_cached_base_snapshot(&opts, &key).is_none());
2585    }
2586
2587    #[test]
2588    fn audit_base_snapshot_cache_key_includes_extended_config() {
2589        let tmp = tempfile::TempDir::new().expect("temp dir should be created");
2590        let root = tmp.path();
2591        fs::write(
2592            root.join(".fallowrc.json"),
2593            r#"{"extends":"base.json","entry":["src/index.ts"]}"#,
2594        )
2595        .expect("config should be written");
2596        fs::write(
2597            root.join("base.json"),
2598            r#"{"rules":{"unused-exports":"off"}}"#,
2599        )
2600        .expect("base config should be written");
2601
2602        let config_path = None;
2603        let cache_root = root.join(".fallow");
2604        let opts = AuditOptions {
2605            root,
2606            cache_dir: &cache_root,
2607            config_path: &config_path,
2608            output: OutputFormat::Json,
2609            no_cache: false,
2610            threads: 1,
2611            quiet: true,
2612            changed_since: Some("HEAD"),
2613            production: false,
2614            production_dead_code: None,
2615            production_health: None,
2616            production_dupes: None,
2617            workspace: None,
2618            changed_workspaces: None,
2619            explain: false,
2620            explain_skipped: false,
2621            performance: false,
2622            group_by: None,
2623            dead_code_baseline: None,
2624            health_baseline: None,
2625            dupes_baseline: None,
2626            max_crap: None,
2627            coverage: None,
2628            coverage_root: None,
2629            gate: AuditGate::NewOnly,
2630            include_entry_exports: false,
2631            runtime_coverage: None,
2632            min_invocations_hot: 100,
2633        };
2634
2635        let first = config_file_fingerprint(&opts).expect("fingerprint should be computed");
2636        fs::write(
2637            root.join("base.json"),
2638            r#"{"rules":{"unused-exports":"error"}}"#,
2639        )
2640        .expect("base config should be updated");
2641        let second = config_file_fingerprint(&opts).expect("fingerprint should be recomputed");
2642
2643        assert_ne!(
2644            first["resolved_hash"], second["resolved_hash"],
2645            "extended config changes must invalidate cached base snapshots"
2646        );
2647    }
2648
2649    #[test]
2650    fn audit_gate_all_skips_base_snapshot() {
2651        let tmp = tempfile::TempDir::new().expect("temp dir should be created");
2652        let root = tmp.path();
2653        fs::create_dir_all(root.join("src")).expect("src dir should be created");
2654        fs::write(
2655            root.join("package.json"),
2656            r#"{"name":"audit-gate-all","main":"src/index.ts"}"#,
2657        )
2658        .expect("package.json should be written");
2659        fs::write(root.join("src/index.ts"), "export const legacy = 1;\n")
2660            .expect("index should be written");
2661
2662        git(root, &["init", "-b", "main"]);
2663        git(root, &["add", "."]);
2664        git(
2665            root,
2666            &["-c", "commit.gpgsign=false", "commit", "-m", "initial"],
2667        );
2668        fs::write(
2669            root.join("src/index.ts"),
2670            "export const legacy = 1;\nexport const changed = 2;\n",
2671        )
2672        .expect("changed module should be written");
2673
2674        let config_path = None;
2675        let cache_root = root.join(".fallow");
2676        let opts = AuditOptions {
2677            root,
2678            cache_dir: &cache_root,
2679            config_path: &config_path,
2680            output: OutputFormat::Json,
2681            no_cache: true,
2682            threads: 1,
2683            quiet: true,
2684            changed_since: Some("HEAD"),
2685            production: false,
2686            production_dead_code: None,
2687            production_health: None,
2688            production_dupes: None,
2689            workspace: None,
2690            changed_workspaces: None,
2691            explain: false,
2692            explain_skipped: false,
2693            performance: false,
2694            group_by: None,
2695            dead_code_baseline: None,
2696            health_baseline: None,
2697            dupes_baseline: None,
2698            max_crap: None,
2699            coverage: None,
2700            coverage_root: None,
2701            gate: AuditGate::All,
2702            include_entry_exports: false,
2703            runtime_coverage: None,
2704            min_invocations_hot: 100,
2705        };
2706
2707        let result = execute_audit(&opts).expect("audit should execute");
2708        assert!(result.base_snapshot.is_none());
2709        assert_eq!(result.attribution.gate, AuditGate::All);
2710        assert_eq!(result.attribution.dead_code_introduced, 0);
2711        assert_eq!(result.attribution.dead_code_inherited, 0);
2712    }
2713
2714    #[test]
2715    fn audit_gate_new_only_skips_base_snapshot_for_docs_only_diff() {
2716        let tmp = tempfile::TempDir::new().expect("temp dir should be created");
2717        let root = tmp.path();
2718        fs::create_dir_all(root.join("src")).expect("src dir should be created");
2719        fs::write(
2720            root.join("package.json"),
2721            r#"{"name":"audit-docs-only","main":"src/index.ts"}"#,
2722        )
2723        .expect("package.json should be written");
2724        fs::write(
2725            root.join(".fallowrc.json"),
2726            r#"{"duplicates":{"minTokens":5,"minLines":2,"mode":"strict"}}"#,
2727        )
2728        .expect("config should be written");
2729        let duplicated = "export function same(input: number): number {\n  const doubled = input * 2;\n  const shifted = doubled + 1;\n  return shifted;\n}\n";
2730        fs::write(root.join("src/index.ts"), duplicated).expect("index should be written");
2731        fs::write(root.join("src/copy.ts"), duplicated).expect("copy should be written");
2732        fs::write(root.join("README.md"), "before\n").expect("readme should be written");
2733
2734        git(root, &["init", "-b", "main"]);
2735        git(root, &["add", "."]);
2736        git(
2737            root,
2738            &["-c", "commit.gpgsign=false", "commit", "-m", "initial"],
2739        );
2740        fs::write(root.join("README.md"), "after\n").expect("readme should be modified");
2741        fs::create_dir_all(root.join(".fallow/cache/dupes-tokens-v2"))
2742            .expect("cache dir should be created");
2743        fs::write(
2744            root.join(".fallow/cache/dupes-tokens-v2/cache.bin"),
2745            b"cache",
2746        )
2747        .expect("cache artifact should be written");
2748
2749        let before_worktrees = audit_worktree_names(root);
2750
2751        let config_path = None;
2752        let cache_root = root.join(".fallow");
2753        let opts = AuditOptions {
2754            root,
2755            cache_dir: &cache_root,
2756            config_path: &config_path,
2757            output: OutputFormat::Json,
2758            no_cache: true,
2759            threads: 1,
2760            quiet: true,
2761            changed_since: Some("HEAD"),
2762            production: false,
2763            production_dead_code: None,
2764            production_health: None,
2765            production_dupes: None,
2766            workspace: None,
2767            changed_workspaces: None,
2768            explain: false,
2769            explain_skipped: false,
2770            performance: true,
2771            group_by: None,
2772            dead_code_baseline: None,
2773            health_baseline: None,
2774            dupes_baseline: None,
2775            max_crap: None,
2776            coverage: None,
2777            coverage_root: None,
2778            gate: AuditGate::NewOnly,
2779            include_entry_exports: false,
2780            runtime_coverage: None,
2781            min_invocations_hot: 100,
2782        };
2783
2784        let result = execute_audit(&opts).expect("audit should execute");
2785        assert_eq!(result.verdict, AuditVerdict::Pass);
2786        assert_eq!(result.changed_files_count, 2);
2787        assert!(result.base_snapshot_skipped);
2788        assert!(result.base_snapshot.is_some());
2789
2790        let after_worktrees = audit_worktree_names(root);
2791        assert_eq!(
2792            before_worktrees, after_worktrees,
2793            "base snapshot skip must not create a temporary base worktree"
2794        );
2795    }
2796
2797    fn audit_worktree_names(repo_root: &Path) -> Vec<String> {
2798        let mut names: Vec<String> = list_audit_worktrees(repo_root)
2799            .unwrap_or_default()
2800            .into_iter()
2801            .filter_map(|path| {
2802                path.file_name()
2803                    .and_then(|name| name.to_str())
2804                    .map(str::to_owned)
2805            })
2806            .collect();
2807        names.sort();
2808        names
2809    }
2810
2811    #[test]
2812    fn audit_reuses_dead_code_parse_for_health_when_production_matches() {
2813        let tmp = tempfile::TempDir::new().expect("temp dir should be created");
2814        let root = tmp.path();
2815        fs::create_dir_all(root.join("src")).expect("src dir should be created");
2816        fs::write(
2817            root.join("package.json"),
2818            r#"{"name":"audit-shared-parse","main":"src/index.ts"}"#,
2819        )
2820        .expect("package.json should be written");
2821        fs::write(
2822            root.join("src/index.ts"),
2823            "import { used } from './used';\nused();\n",
2824        )
2825        .expect("index should be written");
2826        fs::write(
2827            root.join("src/used.ts"),
2828            "export function used() {\n  return 1;\n}\n",
2829        )
2830        .expect("used module should be written");
2831
2832        git(root, &["init", "-b", "main"]);
2833        git(root, &["add", "."]);
2834        git(
2835            root,
2836            &["-c", "commit.gpgsign=false", "commit", "-m", "initial"],
2837        );
2838        fs::write(
2839            root.join("src/used.ts"),
2840            "export function used() {\n  return 1;\n}\nexport function changed() {\n  return 2;\n}\n",
2841        )
2842        .expect("changed module should be written");
2843
2844        let config_path = None;
2845        let cache_root = root.join(".fallow");
2846        let opts = AuditOptions {
2847            root,
2848            cache_dir: &cache_root,
2849            config_path: &config_path,
2850            output: OutputFormat::Json,
2851            no_cache: true,
2852            threads: 1,
2853            quiet: true,
2854            changed_since: Some("HEAD"),
2855            production: false,
2856            production_dead_code: None,
2857            production_health: None,
2858            production_dupes: None,
2859            workspace: None,
2860            changed_workspaces: None,
2861            explain: false,
2862            explain_skipped: false,
2863            performance: true,
2864            group_by: None,
2865            dead_code_baseline: None,
2866            health_baseline: None,
2867            dupes_baseline: None,
2868            max_crap: None,
2869            coverage: None,
2870            coverage_root: None,
2871            gate: AuditGate::NewOnly,
2872            include_entry_exports: false,
2873            runtime_coverage: None,
2874            min_invocations_hot: 100,
2875        };
2876
2877        let result = execute_audit(&opts).expect("audit should execute");
2878        let health = result.health.expect("health should run for changed files");
2879        let timings = health.timings.expect("performance timings should be kept");
2880        assert!(timings.discover_ms.abs() < f64::EPSILON);
2881        assert!(timings.parse_ms.abs() < f64::EPSILON);
2882        assert!(
2883            result.dupes.is_some(),
2884            "dupes should run when changed files exist"
2885        );
2886    }
2887
2888    #[test]
2889    fn audit_dupes_falls_back_to_own_discovery_when_health_off() {
2890        let tmp = tempfile::TempDir::new().expect("temp dir should be created");
2891        let root = tmp.path();
2892        fs::create_dir_all(root.join("src")).expect("src dir should be created");
2893        fs::write(
2894            root.join("package.json"),
2895            r#"{"name":"audit-dupes-fallback","main":"src/index.ts"}"#,
2896        )
2897        .expect("package.json should be written");
2898        fs::write(
2899            root.join("src/index.ts"),
2900            "import { used } from './used';\nused();\n",
2901        )
2902        .expect("index should be written");
2903        fs::write(
2904            root.join("src/used.ts"),
2905            "export function used() {\n  return 1;\n}\n",
2906        )
2907        .expect("used module should be written");
2908
2909        git(root, &["init", "-b", "main"]);
2910        git(root, &["add", "."]);
2911        git(
2912            root,
2913            &["-c", "commit.gpgsign=false", "commit", "-m", "initial"],
2914        );
2915        fs::write(
2916            root.join("src/used.ts"),
2917            "export function used() {\n  return 1;\n}\nexport function changed() {\n  return 2;\n}\n",
2918        )
2919        .expect("changed module should be written");
2920
2921        let config_path = None;
2922        let cache_root = root.join(".fallow");
2923        let opts = AuditOptions {
2924            root,
2925            cache_dir: &cache_root,
2926            config_path: &config_path,
2927            output: OutputFormat::Json,
2928            no_cache: true,
2929            threads: 1,
2930            quiet: true,
2931            changed_since: Some("HEAD"),
2932            production: false,
2933            production_dead_code: Some(true),
2934            production_health: Some(false),
2935            production_dupes: Some(false),
2936            workspace: None,
2937            changed_workspaces: None,
2938            explain: false,
2939            explain_skipped: false,
2940            performance: true,
2941            group_by: None,
2942            dead_code_baseline: None,
2943            health_baseline: None,
2944            dupes_baseline: None,
2945            max_crap: None,
2946            coverage: None,
2947            coverage_root: None,
2948            gate: AuditGate::NewOnly,
2949            include_entry_exports: false,
2950            runtime_coverage: None,
2951            min_invocations_hot: 100,
2952        };
2953
2954        let result = execute_audit(&opts).expect("audit should execute");
2955        assert!(result.dupes.is_some(), "dupes should still run");
2956    }
2957
2958    #[cfg(unix)]
2959    #[test]
2960    fn remap_focus_files_does_not_canonicalize_through_symlinks() {
2961        let tmp = tempfile::TempDir::new().expect("temp dir");
2962        let real = tmp.path().join("real");
2963        let link = tmp.path().join("link");
2964        fs::create_dir_all(&real).expect("real dir");
2965        std::os::unix::fs::symlink(&real, &link).expect("symlink");
2966        let canonical = link.canonicalize().expect("canonicalize symlink");
2967        assert_ne!(link, canonical, "symlink should not equal its target");
2968
2969        let from_root = PathBuf::from("/repo");
2970        let mut focus = FxHashSet::default();
2971        focus.insert(from_root.join("src/foo.ts"));
2972
2973        let remapped = remap_focus_files(&focus, &from_root, &link)
2974            .expect("remap should succeed for in-prefix files");
2975
2976        let expected = link.join("src/foo.ts");
2977        assert!(
2978            remapped.contains(&expected),
2979            "remapped paths must keep the un-canonical to_root prefix; got {remapped:?}, expected entry {expected:?}"
2980        );
2981    }
2982
2983    #[test]
2984    fn remap_focus_files_skips_paths_outside_from_root() {
2985        let from_root = PathBuf::from("/repo/apps/web");
2986        let to_root = PathBuf::from("/wt/apps/web");
2987        let mut focus = FxHashSet::default();
2988        focus.insert(PathBuf::from("/repo/apps/web/src/in.ts"));
2989        focus.insert(PathBuf::from("/repo/services/api/src/out.ts"));
2990
2991        let remapped =
2992            remap_focus_files(&focus, &from_root, &to_root).expect("partial map should succeed");
2993
2994        assert_eq!(remapped.len(), 1);
2995        assert!(remapped.contains(&PathBuf::from("/wt/apps/web/src/in.ts")));
2996    }
2997
2998    #[test]
2999    fn remap_focus_files_returns_none_when_no_paths_map() {
3000        let from_root = PathBuf::from("/repo/apps/web");
3001        let to_root = PathBuf::from("/wt/apps/web");
3002        let mut focus = FxHashSet::default();
3003        focus.insert(PathBuf::from("/elsewhere/foo.ts"));
3004
3005        let remapped = remap_focus_files(&focus, &from_root, &to_root);
3006        assert!(
3007            remapped.is_none(),
3008            "remap should return None when no paths can be mapped, falling caller back to full corpus"
3009        );
3010    }
3011
3012    #[test]
3013    fn remap_cache_dir_moves_project_local_cache_to_base_worktree() {
3014        let tmp = tempfile::TempDir::new().expect("temp dir should be created");
3015        let current_root = tmp.path().join("repo");
3016        let base_root = tmp.path().join("fallow-base");
3017        let cache_dir = current_root.join(".cache").join("fallow");
3018
3019        let remapped = remap_cache_dir_for_base_worktree(&current_root, &base_root, &cache_dir);
3020
3021        assert_eq!(remapped, base_root.join(".cache").join("fallow"));
3022    }
3023
3024    #[test]
3025    fn remap_cache_dir_keeps_external_absolute_cache_shared() {
3026        let tmp = tempfile::TempDir::new().expect("temp dir should be created");
3027        let current_root = tmp.path().join("repo");
3028        let base_root = tmp.path().join("fallow-base");
3029        let cache_dir = tmp.path().join("shared").join("fallow-cache");
3030
3031        let remapped = remap_cache_dir_for_base_worktree(&current_root, &base_root, &cache_dir);
3032
3033        assert_eq!(remapped, cache_dir);
3034    }
3035
3036    #[test]
3037    fn audit_gate_new_only_inherits_pre_existing_duplicates_in_focused_files() {
3038        let tmp = tempfile::TempDir::new().expect("temp dir should be created");
3039        let root_buf = tmp
3040            .path()
3041            .canonicalize()
3042            .expect("temp root should canonicalize");
3043        let root = root_buf.as_path();
3044        fs::create_dir_all(root.join("src")).expect("src dir should be created");
3045        fs::write(
3046            root.join("package.json"),
3047            r#"{"name":"audit-newonly-inherit","main":"src/changed.ts"}"#,
3048        )
3049        .expect("package.json should be written");
3050        fs::write(
3051            root.join(".fallowrc.json"),
3052            r#"{"duplicates":{"minTokens":10,"minLines":3,"mode":"strict"}}"#,
3053        )
3054        .expect("config should be written");
3055
3056        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";
3057        fs::write(root.join("src/changed.ts"), dup_block).expect("changed should be written");
3058        fs::write(root.join("src/peer.ts"), dup_block).expect("peer should be written");
3059
3060        git(root, &["init", "-b", "main"]);
3061        git(root, &["add", "."]);
3062        git(
3063            root,
3064            &["-c", "commit.gpgsign=false", "commit", "-m", "initial"],
3065        );
3066        fs::write(
3067            root.join("src/changed.ts"),
3068            format!("{dup_block}// touched\n"),
3069        )
3070        .expect("changed file should be modified");
3071        git(root, &["add", "."]);
3072        git(
3073            root,
3074            &["-c", "commit.gpgsign=false", "commit", "-m", "touch"],
3075        );
3076
3077        let config_path = None;
3078        let cache_root = root.join(".fallow");
3079        let opts = AuditOptions {
3080            root,
3081            cache_dir: &cache_root,
3082            config_path: &config_path,
3083            output: OutputFormat::Json,
3084            no_cache: true,
3085            threads: 1,
3086            quiet: true,
3087            changed_since: Some("HEAD~1"),
3088            production: false,
3089            production_dead_code: None,
3090            production_health: None,
3091            production_dupes: None,
3092            workspace: None,
3093            changed_workspaces: None,
3094            explain: false,
3095            explain_skipped: false,
3096            performance: false,
3097            group_by: None,
3098            dead_code_baseline: None,
3099            health_baseline: None,
3100            dupes_baseline: None,
3101            max_crap: None,
3102            coverage: None,
3103            coverage_root: None,
3104            gate: AuditGate::NewOnly,
3105            include_entry_exports: false,
3106            runtime_coverage: None,
3107            min_invocations_hot: 100,
3108        };
3109
3110        let result = execute_audit(&opts).expect("audit should execute");
3111        assert!(
3112            result.base_snapshot_skipped,
3113            "comment-only JS/TS diffs should reuse current keys as the base snapshot"
3114        );
3115        let dupes_report = &result.dupes.as_ref().expect("dupes should run").report;
3116        assert!(
3117            !dupes_report.clone_groups.is_empty(),
3118            "current run should detect the pre-existing duplicate"
3119        );
3120        assert_eq!(
3121            result.attribution.duplication_introduced, 0,
3122            "pre-existing duplicate must not be classified as introduced; \
3123             attribution = {:?}",
3124            result.attribution
3125        );
3126        assert!(
3127            result.attribution.duplication_inherited > 0,
3128            "pre-existing duplicate must be classified as inherited; \
3129             attribution = {:?}",
3130            result.attribution
3131        );
3132    }
3133
3134    #[test]
3135    fn audit_base_preserves_tsconfig_paths_when_extends_is_in_untracked_node_modules() {
3136        let tmp = tempfile::TempDir::new().expect("temp dir should be created");
3137        let root = tmp.path();
3138        fs::create_dir_all(root.join("src/screens")).expect("src dir should be created");
3139        fs::create_dir_all(root.join("node_modules/@react-native/typescript-config"))
3140            .expect("node_modules config dir should be created");
3141        fs::write(root.join(".gitignore"), "node_modules/\n").expect("gitignore should be written");
3142        fs::write(
3143            root.join("package.json"),
3144            r#"{
3145                "name": "audit-react-native-tsconfig-base",
3146                "private": true,
3147                "main": "src/App.tsx",
3148                "dependencies": {
3149                    "react-native": "0.80.0"
3150                }
3151            }"#,
3152        )
3153        .expect("package.json should be written");
3154        fs::write(
3155            root.join("tsconfig.json"),
3156            r#"{
3157                "extends": "./node_modules/@react-native/typescript-config/tsconfig.json",
3158                "compilerOptions": {
3159                    "baseUrl": ".",
3160                    "paths": {
3161                        "@/*": ["src/*"]
3162                    }
3163                },
3164                "include": ["src/**/*"]
3165            }"#,
3166        )
3167        .expect("tsconfig should be written");
3168        fs::write(
3169            root.join("node_modules/@react-native/typescript-config/tsconfig.json"),
3170            r#"{"compilerOptions":{"strict":true,"jsx":"react-jsx"}}"#,
3171        )
3172        .expect("react native tsconfig should be written");
3173        fs::write(
3174            root.join("src/App.tsx"),
3175            r#"import { homeTitle } from "@/screens/Home";
3176
3177export function App() {
3178  return homeTitle;
3179}
3180"#,
3181        )
3182        .expect("app should be written");
3183        fs::write(
3184            root.join("src/screens/Home.ts"),
3185            r#"export const homeTitle = "home";
3186"#,
3187        )
3188        .expect("home should be written");
3189
3190        git(root, &["init", "-b", "main"]);
3191        git(root, &["add", "."]);
3192        git(
3193            root,
3194            &["-c", "commit.gpgsign=false", "commit", "-m", "initial"],
3195        );
3196        fs::write(
3197            root.join("src/App.tsx"),
3198            r#"import { homeTitle } from "@/screens/Home";
3199
3200export function App() {
3201  return homeTitle.toUpperCase();
3202}
3203"#,
3204        )
3205        .expect("app should be modified");
3206
3207        let config_path = None;
3208        let cache_root = root.join(".fallow");
3209        let opts = AuditOptions {
3210            root,
3211            cache_dir: &cache_root,
3212            config_path: &config_path,
3213            output: OutputFormat::Json,
3214            no_cache: true,
3215            threads: 1,
3216            quiet: true,
3217            changed_since: Some("HEAD"),
3218            production: false,
3219            production_dead_code: None,
3220            production_health: None,
3221            production_dupes: None,
3222            workspace: None,
3223            changed_workspaces: None,
3224            explain: false,
3225            explain_skipped: false,
3226            performance: false,
3227            group_by: None,
3228            dead_code_baseline: None,
3229            health_baseline: None,
3230            dupes_baseline: None,
3231            max_crap: None,
3232            coverage: None,
3233            coverage_root: None,
3234            gate: AuditGate::NewOnly,
3235            include_entry_exports: false,
3236            runtime_coverage: None,
3237            min_invocations_hot: 100,
3238        };
3239
3240        let result = execute_audit(&opts).expect("audit should execute");
3241        assert!(
3242            !result.base_snapshot_skipped,
3243            "source diffs should run a real base snapshot"
3244        );
3245        let base = result
3246            .base_snapshot
3247            .as_ref()
3248            .expect("base snapshot should run");
3249        assert!(
3250            !base
3251                .dead_code
3252                .contains("unresolved-import:src/App.tsx:@/screens/Home"),
3253            "base audit must keep local @/* tsconfig aliases when extends points into ignored node_modules: {:?}",
3254            base.dead_code
3255        );
3256        assert!(
3257            !base.dead_code.contains("unused-file:src/screens/Home.ts"),
3258            "alias target should stay reachable in the base worktree: {:?}",
3259            base.dead_code
3260        );
3261        let check = result.check.as_ref().expect("dead-code audit should run");
3262        assert!(
3263            check.results.unresolved_imports.is_empty(),
3264            "HEAD audit should also resolve @/* aliases: {:?}",
3265            check.results.unresolved_imports
3266        );
3267    }
3268
3269    #[test]
3270    fn audit_base_preserves_subdirectory_root_resolution() {
3271        let tmp = tempfile::TempDir::new().expect("temp dir should be created");
3272        let repo = tmp.path().join("repo");
3273        let root = repo.join("apps/mobile");
3274        fs::create_dir_all(root.join("src/screens")).expect("src dir should be created");
3275        fs::create_dir_all(root.join("node_modules/@react-native/typescript-config"))
3276            .expect("node_modules config dir should be created");
3277        fs::write(repo.join(".gitignore"), "apps/mobile/node_modules/\n")
3278            .expect("gitignore should be written");
3279        fs::write(
3280            root.join("package.json"),
3281            r#"{
3282                "name": "audit-subdir-react-native-tsconfig-base",
3283                "private": true,
3284                "main": "src/App.tsx",
3285                "dependencies": {
3286                    "react-native": "0.80.0"
3287                }
3288            }"#,
3289        )
3290        .expect("package.json should be written");
3291        fs::write(
3292            root.join("tsconfig.json"),
3293            r#"{
3294                "extends": "./node_modules/@react-native/typescript-config/tsconfig.json",
3295                "compilerOptions": {
3296                    "baseUrl": ".",
3297                    "paths": {
3298                        "@/*": ["src/*"]
3299                    }
3300                },
3301                "include": ["src/**/*"]
3302            }"#,
3303        )
3304        .expect("tsconfig should be written");
3305        fs::write(
3306            root.join("node_modules/@react-native/typescript-config/tsconfig.json"),
3307            r#"{"compilerOptions":{"strict":true,"jsx":"react-jsx"}}"#,
3308        )
3309        .expect("react native tsconfig should be written");
3310        fs::write(
3311            root.join("src/App.tsx"),
3312            r#"import { homeTitle } from "@/screens/Home";
3313
3314export function App() {
3315  return homeTitle;
3316}
3317"#,
3318        )
3319        .expect("app should be written");
3320        fs::write(
3321            root.join("src/screens/Home.ts"),
3322            r#"export const homeTitle = "home";
3323"#,
3324        )
3325        .expect("home should be written");
3326
3327        git(&repo, &["init", "-b", "main"]);
3328        git(&repo, &["add", "."]);
3329        git(
3330            &repo,
3331            &["-c", "commit.gpgsign=false", "commit", "-m", "initial"],
3332        );
3333        fs::write(
3334            root.join("src/App.tsx"),
3335            r#"import { homeTitle } from "@/screens/Home";
3336
3337export function App() {
3338  return homeTitle.toUpperCase();
3339}
3340"#,
3341        )
3342        .expect("app should be modified");
3343
3344        let config_path = None;
3345        let cache_root = root.join(".fallow");
3346        let opts = AuditOptions {
3347            root: &root,
3348            cache_dir: &cache_root,
3349            config_path: &config_path,
3350            output: OutputFormat::Json,
3351            no_cache: true,
3352            threads: 1,
3353            quiet: true,
3354            changed_since: Some("HEAD"),
3355            production: false,
3356            production_dead_code: None,
3357            production_health: None,
3358            production_dupes: None,
3359            workspace: None,
3360            changed_workspaces: None,
3361            explain: false,
3362            explain_skipped: false,
3363            performance: false,
3364            group_by: None,
3365            dead_code_baseline: None,
3366            health_baseline: None,
3367            dupes_baseline: None,
3368            max_crap: None,
3369            coverage: None,
3370            coverage_root: None,
3371            gate: AuditGate::NewOnly,
3372            include_entry_exports: false,
3373            runtime_coverage: None,
3374            min_invocations_hot: 100,
3375        };
3376
3377        let result = execute_audit(&opts).expect("audit should execute");
3378        assert!(
3379            !result.base_snapshot_skipped,
3380            "source diffs should run a real base snapshot"
3381        );
3382        let base = result
3383            .base_snapshot
3384            .as_ref()
3385            .expect("base snapshot should run");
3386        assert!(
3387            !base
3388                .dead_code
3389                .contains("unresolved-import:src/App.tsx:@/screens/Home"),
3390            "base audit should analyze from the app subdirectory, not the repo root: {:?}",
3391            base.dead_code
3392        );
3393        assert!(
3394            !base.dead_code.contains("unused-file:src/screens/Home.ts"),
3395            "subdirectory base audit should keep alias targets reachable: {:?}",
3396            base.dead_code
3397        );
3398    }
3399
3400    #[test]
3401    fn audit_base_uses_new_explicit_config_without_hard_failure() {
3402        let tmp = tempfile::TempDir::new().expect("temp dir should be created");
3403        let root = tmp.path();
3404        fs::create_dir_all(root.join("src")).expect("src dir should be created");
3405        fs::write(
3406            root.join("package.json"),
3407            r#"{"name":"audit-new-config","main":"src/index.ts"}"#,
3408        )
3409        .expect("package.json should be written");
3410        fs::write(root.join("src/index.ts"), "export const used = 1;\n")
3411            .expect("index should be written");
3412
3413        git(root, &["init", "-b", "main"]);
3414        git(root, &["add", "."]);
3415        git(
3416            root,
3417            &["-c", "commit.gpgsign=false", "commit", "-m", "initial"],
3418        );
3419
3420        let explicit_config = root.join(".fallowrc.json");
3421        fs::write(&explicit_config, r#"{"rules":{"unused-files":"error"}}"#)
3422            .expect("new config should be written");
3423        fs::write(root.join("src/index.ts"), "export const used = 2;\n")
3424            .expect("index should be modified");
3425
3426        let config_path = Some(explicit_config);
3427        let cache_root = root.join(".fallow");
3428        let opts = AuditOptions {
3429            root,
3430            cache_dir: &cache_root,
3431            config_path: &config_path,
3432            output: OutputFormat::Json,
3433            no_cache: true,
3434            threads: 1,
3435            quiet: true,
3436            changed_since: Some("HEAD"),
3437            production: false,
3438            production_dead_code: None,
3439            production_health: None,
3440            production_dupes: None,
3441            workspace: None,
3442            changed_workspaces: None,
3443            explain: false,
3444            explain_skipped: false,
3445            performance: false,
3446            group_by: None,
3447            dead_code_baseline: None,
3448            health_baseline: None,
3449            dupes_baseline: None,
3450            max_crap: None,
3451            coverage: None,
3452            coverage_root: None,
3453            gate: AuditGate::NewOnly,
3454            include_entry_exports: false,
3455            runtime_coverage: None,
3456            min_invocations_hot: 100,
3457        };
3458
3459        let result = execute_audit(&opts).expect("audit should execute with a new explicit config");
3460        assert!(
3461            result.base_snapshot.is_some(),
3462            "base snapshot should use the current explicit config even when the base commit lacks it"
3463        );
3464    }
3465
3466    #[test]
3467    fn audit_base_uses_current_discovered_config_for_attribution() {
3468        let tmp = tempfile::TempDir::new().expect("temp dir should be created");
3469        let root = tmp.path();
3470        fs::create_dir_all(root.join("src")).expect("src dir should be created");
3471        fs::write(
3472            root.join("package.json"),
3473            r#"{"name":"audit-current-config","main":"src/index.ts","dependencies":{"left-pad":"1.3.0"}}"#,
3474        )
3475        .expect("package.json should be written");
3476        fs::write(
3477            root.join(".fallowrc.json"),
3478            r#"{"rules":{"unused-dependencies":"off"}}"#,
3479        )
3480        .expect("base config should be written");
3481        fs::write(root.join("src/index.ts"), "export const used = 1;\n")
3482            .expect("index should be written");
3483
3484        git(root, &["init", "-b", "main"]);
3485        git(root, &["add", "."]);
3486        git(
3487            root,
3488            &["-c", "commit.gpgsign=false", "commit", "-m", "initial"],
3489        );
3490
3491        fs::write(
3492            root.join(".fallowrc.json"),
3493            r#"{"rules":{"unused-dependencies":"error"}}"#,
3494        )
3495        .expect("current config should be written");
3496        fs::write(
3497            root.join("package.json"),
3498            r#"{"name":"audit-current-config","main":"src/index.ts","dependencies":{"left-pad":"1.3.1"}}"#,
3499        )
3500        .expect("package.json should be touched");
3501
3502        let config_path = None;
3503        let cache_root = root.join(".fallow");
3504        let opts = AuditOptions {
3505            root,
3506            cache_dir: &cache_root,
3507            config_path: &config_path,
3508            output: OutputFormat::Json,
3509            no_cache: true,
3510            threads: 1,
3511            quiet: true,
3512            changed_since: Some("HEAD"),
3513            production: false,
3514            production_dead_code: None,
3515            production_health: None,
3516            production_dupes: None,
3517            workspace: None,
3518            changed_workspaces: None,
3519            explain: false,
3520            explain_skipped: false,
3521            performance: false,
3522            group_by: None,
3523            dead_code_baseline: None,
3524            health_baseline: None,
3525            dupes_baseline: None,
3526            max_crap: None,
3527            coverage: None,
3528            coverage_root: None,
3529            gate: AuditGate::NewOnly,
3530            include_entry_exports: false,
3531            runtime_coverage: None,
3532            min_invocations_hot: 100,
3533        };
3534
3535        let result = execute_audit(&opts).expect("audit should execute");
3536        assert_eq!(
3537            result.attribution.dead_code_introduced, 0,
3538            "enabling a rule should not make pre-existing changed-file findings look introduced: {:?}",
3539            result.attribution
3540        );
3541        assert!(
3542            result.attribution.dead_code_inherited > 0,
3543            "pre-existing changed-file findings should be classified as inherited: {:?}",
3544            result.attribution
3545        );
3546    }
3547
3548    #[test]
3549    fn audit_base_current_config_attribution_survives_cache_hit() {
3550        let tmp = tempfile::TempDir::new().expect("temp dir should be created");
3551        let root = tmp.path();
3552        fs::create_dir_all(root.join("src")).expect("src dir should be created");
3553        fs::write(
3554            root.join("package.json"),
3555            r#"{"name":"audit-current-config-cache","main":"src/index.ts","dependencies":{"left-pad":"1.3.0"}}"#,
3556        )
3557        .expect("package.json should be written");
3558        fs::write(
3559            root.join(".fallowrc.json"),
3560            r#"{"rules":{"unused-dependencies":"off"}}"#,
3561        )
3562        .expect("base config should be written");
3563        fs::write(root.join("src/index.ts"), "export const used = 1;\n")
3564            .expect("index should be written");
3565
3566        git(root, &["init", "-b", "main"]);
3567        git(root, &["add", "."]);
3568        git(
3569            root,
3570            &["-c", "commit.gpgsign=false", "commit", "-m", "initial"],
3571        );
3572
3573        fs::write(
3574            root.join(".fallowrc.json"),
3575            r#"{"rules":{"unused-dependencies":"error"}}"#,
3576        )
3577        .expect("current config should be written");
3578        fs::write(
3579            root.join("package.json"),
3580            r#"{"name":"audit-current-config-cache","main":"src/index.ts","dependencies":{"left-pad":"1.3.1"}}"#,
3581        )
3582        .expect("package.json should be touched");
3583
3584        let config_path = None;
3585        let cache_root = root.join(".fallow");
3586        let opts = AuditOptions {
3587            root,
3588            cache_dir: &cache_root,
3589            config_path: &config_path,
3590            output: OutputFormat::Json,
3591            no_cache: false,
3592            threads: 1,
3593            quiet: true,
3594            changed_since: Some("HEAD"),
3595            production: false,
3596            production_dead_code: None,
3597            production_health: None,
3598            production_dupes: None,
3599            workspace: None,
3600            changed_workspaces: None,
3601            explain: false,
3602            explain_skipped: false,
3603            performance: false,
3604            group_by: None,
3605            dead_code_baseline: None,
3606            health_baseline: None,
3607            dupes_baseline: None,
3608            max_crap: None,
3609            coverage: None,
3610            coverage_root: None,
3611            gate: AuditGate::NewOnly,
3612            include_entry_exports: false,
3613            runtime_coverage: None,
3614            min_invocations_hot: 100,
3615        };
3616
3617        let first = execute_audit(&opts).expect("first audit should execute");
3618        assert_eq!(
3619            first.attribution.dead_code_introduced, 0,
3620            "first audit should classify pre-existing findings as inherited: {:?}",
3621            first.attribution
3622        );
3623
3624        let changed_files =
3625            crate::check::get_changed_files(root, "HEAD").expect("changed files should resolve");
3626        let key = audit_base_snapshot_cache_key(&opts, "HEAD", &changed_files)
3627            .expect("cache key should compute")
3628            .expect("cache key should exist");
3629        assert!(
3630            load_cached_base_snapshot(&opts, &key).is_some(),
3631            "first audit should store a reusable base snapshot"
3632        );
3633
3634        let second = execute_audit(&opts).expect("second audit should execute");
3635        assert_eq!(
3636            second.attribution.dead_code_introduced, 0,
3637            "cache hit should keep current-config attribution stable: {:?}",
3638            second.attribution
3639        );
3640        assert!(
3641            second.attribution.dead_code_inherited > 0,
3642            "cache hit should preserve inherited base findings: {:?}",
3643            second.attribution
3644        );
3645    }
3646
3647    #[test]
3648    fn audit_dupes_only_materializes_groups_touching_changed_files() {
3649        let tmp = tempfile::TempDir::new().expect("temp dir should be created");
3650        let root_path = tmp
3651            .path()
3652            .canonicalize()
3653            .expect("temp root should canonicalize");
3654        let root = root_path.as_path();
3655        fs::create_dir_all(root.join("src")).expect("src dir should be created");
3656        fs::write(
3657            root.join("package.json"),
3658            r#"{"name":"audit-dupes-focus","main":"src/changed.ts"}"#,
3659        )
3660        .expect("package.json should be written");
3661        fs::write(
3662            root.join(".fallowrc.json"),
3663            r#"{"duplicates":{"minTokens":5,"minLines":2,"mode":"strict"}}"#,
3664        )
3665        .expect("config should be written");
3666
3667        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";
3668        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";
3669        fs::write(root.join("src/changed.ts"), focused_code).expect("changed should be written");
3670        fs::write(root.join("src/focused-copy.ts"), focused_code)
3671            .expect("focused copy should be written");
3672        fs::write(root.join("src/untouched-a.ts"), untouched_code)
3673            .expect("untouched a should be written");
3674        fs::write(root.join("src/untouched-b.ts"), untouched_code)
3675            .expect("untouched b should be written");
3676
3677        git(root, &["init", "-b", "main"]);
3678        git(root, &["add", "."]);
3679        git(
3680            root,
3681            &["-c", "commit.gpgsign=false", "commit", "-m", "initial"],
3682        );
3683        fs::write(
3684            root.join("src/changed.ts"),
3685            format!("{focused_code}export const changedMarker = true;\n"),
3686        )
3687        .expect("changed file should be modified");
3688
3689        let config_path = None;
3690        let cache_root = root.join(".fallow");
3691        let opts = AuditOptions {
3692            root,
3693            cache_dir: &cache_root,
3694            config_path: &config_path,
3695            output: OutputFormat::Json,
3696            no_cache: true,
3697            threads: 1,
3698            quiet: true,
3699            changed_since: Some("HEAD"),
3700            production: false,
3701            production_dead_code: None,
3702            production_health: None,
3703            production_dupes: None,
3704            workspace: None,
3705            changed_workspaces: None,
3706            explain: false,
3707            explain_skipped: false,
3708            performance: false,
3709            group_by: None,
3710            dead_code_baseline: None,
3711            health_baseline: None,
3712            dupes_baseline: None,
3713            max_crap: None,
3714            coverage: None,
3715            coverage_root: None,
3716            gate: AuditGate::All,
3717            include_entry_exports: false,
3718            runtime_coverage: None,
3719            min_invocations_hot: 100,
3720        };
3721
3722        let result = execute_audit(&opts).expect("audit should execute");
3723        let dupes = result.dupes.expect("dupes should run");
3724        let changed_path = root.join("src/changed.ts");
3725
3726        assert!(
3727            !dupes.report.clone_groups.is_empty(),
3728            "changed file should still match unchanged duplicate code"
3729        );
3730        assert!(dupes.report.clone_groups.iter().all(|group| {
3731            group
3732                .instances
3733                .iter()
3734                .any(|instance| instance.file == changed_path)
3735        }));
3736    }
3737}