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    crate::telemetry::note_final_result_count(
1094        summary.dead_code_issues + summary.complexity_findings + summary.duplication_clone_groups,
1095    );
1096
1097    Ok(AuditResult {
1098        verdict,
1099        summary,
1100        attribution,
1101        base_snapshot,
1102        base_snapshot_skipped,
1103        changed_files_count,
1104        changed_files: changed_files.into_iter().collect(),
1105        base_ref,
1106        head_sha: get_head_sha(opts.root),
1107        output: opts.output,
1108        performance: opts.performance,
1109        check: check_result,
1110        dupes: dupes_result,
1111        health: health_result,
1112        elapsed: start.elapsed(),
1113    })
1114}
1115
1116/// Resolve the base ref: explicit --changed-since / --base, or auto-detect.
1117fn resolve_base_ref(opts: &AuditOptions<'_>) -> Result<String, ExitCode> {
1118    if let Some(ref_str) = opts.changed_since {
1119        return Ok(ref_str.to_string());
1120    }
1121    let Some(branch) = auto_detect_base_branch(opts.root) else {
1122        return Err(emit_error(
1123            "could not detect base branch. Use --base <ref> to specify the comparison target (e.g., --base main)",
1124            2,
1125            opts.output,
1126        ));
1127    };
1128    if let Err(e) = crate::validate::validate_git_ref(&branch) {
1129        return Err(emit_error(
1130            &format!("auto-detected base branch '{branch}' is not a valid git ref: {e}"),
1131            2,
1132            opts.output,
1133        ));
1134    }
1135    Ok(branch)
1136}
1137
1138/// Build an empty pass result when no files have changed.
1139fn empty_audit_result(base_ref: String, opts: &AuditOptions<'_>, elapsed: Duration) -> AuditResult {
1140    crate::telemetry::note_final_result_count(0);
1141
1142    AuditResult {
1143        verdict: AuditVerdict::Pass,
1144        summary: AuditSummary {
1145            dead_code_issues: 0,
1146            dead_code_has_errors: false,
1147            complexity_findings: 0,
1148            max_cyclomatic: None,
1149            duplication_clone_groups: 0,
1150        },
1151        attribution: AuditAttribution {
1152            gate: opts.gate,
1153            ..AuditAttribution::default()
1154        },
1155        base_snapshot: None,
1156        base_snapshot_skipped: false,
1157        changed_files_count: 0,
1158        changed_files: Vec::new(),
1159        base_ref,
1160        head_sha: get_head_sha(opts.root),
1161        output: opts.output,
1162        performance: opts.performance,
1163        check: None,
1164        dupes: None,
1165        health: None,
1166        elapsed,
1167    }
1168}
1169
1170/// Run dead code analysis for the audit pipeline.
1171fn run_audit_check<'a>(
1172    opts: &'a AuditOptions<'a>,
1173    changed_since: Option<&'a str>,
1174    retain_modules_for_health: bool,
1175) -> Result<Option<CheckResult>, ExitCode> {
1176    let filters = IssueFilters::default();
1177    let trace_opts = TraceOptions {
1178        trace_export: None,
1179        trace_file: None,
1180        trace_dependency: None,
1181        performance: opts.performance,
1182    };
1183    match crate::check::execute_check(&CheckOptions {
1184        root: opts.root,
1185        config_path: opts.config_path,
1186        output: opts.output,
1187        no_cache: opts.no_cache,
1188        threads: opts.threads,
1189        quiet: opts.quiet,
1190        fail_on_issues: false,
1191        filters: &filters,
1192        changed_since,
1193        diff_index: None,
1194        use_shared_diff_index: true,
1195        baseline: opts.dead_code_baseline,
1196        save_baseline: None,
1197        sarif_file: None,
1198        production: opts.production_dead_code.unwrap_or(opts.production),
1199        production_override: opts.production_dead_code,
1200        workspace: opts.workspace,
1201        changed_workspaces: opts.changed_workspaces,
1202        group_by: opts.group_by,
1203        include_dupes: false,
1204        trace_opts: &trace_opts,
1205        explain: opts.explain,
1206        top: None,
1207        file: &[],
1208        include_entry_exports: opts.include_entry_exports,
1209        summary: false,
1210        regression_opts: crate::regression::RegressionOpts {
1211            fail_on_regression: false,
1212            tolerance: crate::regression::Tolerance::Absolute(0),
1213            regression_baseline_file: None,
1214            save_target: crate::regression::SaveRegressionTarget::None,
1215            scoped: true,
1216            quiet: opts.quiet,
1217            output: opts.output,
1218        },
1219        retain_modules_for_health,
1220        defer_performance: false,
1221    }) {
1222        Ok(r) => Ok(Some(r)),
1223        Err(code) => Err(code),
1224    }
1225}
1226
1227/// Run duplication analysis for the audit pipeline.
1228///
1229/// Reads duplication settings from the project config file so that user
1230/// options like `ignoreImports`, `crossLanguage`, and `skipLocal` are
1231/// respected (same as combined mode).
1232fn run_audit_dupes<'a>(
1233    opts: &'a AuditOptions<'a>,
1234    changed_since: Option<&'a str>,
1235    changed_files: Option<&'a FxHashSet<PathBuf>>,
1236    pre_discovered: Option<Vec<fallow_types::discover::DiscoveredFile>>,
1237) -> Result<Option<DupesResult>, ExitCode> {
1238    let dupes_cfg = match crate::load_config_for_analysis(
1239        opts.root,
1240        opts.config_path,
1241        opts.output,
1242        opts.no_cache,
1243        opts.threads,
1244        opts.production_dupes
1245            .or_else(|| opts.production.then_some(true)),
1246        opts.quiet,
1247        fallow_config::ProductionAnalysis::Dupes,
1248    ) {
1249        Ok(c) => c.duplicates,
1250        Err(code) => return Err(code),
1251    };
1252    let dupes_opts = DupesOptions {
1253        root: opts.root,
1254        config_path: opts.config_path,
1255        output: opts.output,
1256        no_cache: opts.no_cache,
1257        threads: opts.threads,
1258        quiet: opts.quiet,
1259        mode: Some(DupesMode::from(dupes_cfg.mode)),
1260        min_tokens: Some(dupes_cfg.min_tokens),
1261        min_lines: Some(dupes_cfg.min_lines),
1262        min_occurrences: Some(dupes_cfg.min_occurrences),
1263        threshold: Some(dupes_cfg.threshold),
1264        skip_local: dupes_cfg.skip_local,
1265        cross_language: dupes_cfg.cross_language,
1266        ignore_imports: dupes_cfg.ignore_imports,
1267        top: None,
1268        baseline_path: opts.dupes_baseline,
1269        save_baseline_path: None,
1270        production: opts.production_dupes.unwrap_or(opts.production),
1271        production_override: opts.production_dupes,
1272        trace: None,
1273        changed_since,
1274        diff_index: None,
1275        use_shared_diff_index: true,
1276        changed_files,
1277        workspace: opts.workspace,
1278        changed_workspaces: opts.changed_workspaces,
1279        explain: opts.explain,
1280        explain_skipped: opts.explain_skipped,
1281        summary: false,
1282        group_by: opts.group_by,
1283        performance: false,
1284    };
1285    let dupes_run = if let Some(files) = pre_discovered {
1286        crate::dupes::execute_dupes_with_files(&dupes_opts, files)
1287    } else {
1288        crate::dupes::execute_dupes(&dupes_opts)
1289    };
1290    match dupes_run {
1291        Ok(r) => Ok(Some(r)),
1292        Err(code) => Err(code),
1293    }
1294}
1295
1296/// Run complexity analysis for the audit pipeline (findings only, no scores/hotspots/targets).
1297fn run_audit_health<'a>(
1298    opts: &'a AuditOptions<'a>,
1299    changed_since: Option<&'a str>,
1300    shared_parse: Option<crate::health::SharedParseData>,
1301) -> Result<Option<HealthResult>, ExitCode> {
1302    let runtime_coverage = match opts.runtime_coverage {
1303        Some(path) => match crate::health::coverage::prepare_options(
1304            path,
1305            opts.min_invocations_hot,
1306            None,
1307            None,
1308            opts.output,
1309        ) {
1310            Ok(options) => Some(options),
1311            Err(code) => return Err(code),
1312        },
1313        None => None,
1314    };
1315
1316    let health_opts = HealthOptions {
1317        root: opts.root,
1318        config_path: opts.config_path,
1319        output: opts.output,
1320        no_cache: opts.no_cache,
1321        threads: opts.threads,
1322        quiet: opts.quiet,
1323        max_cyclomatic: None,
1324        max_cognitive: None,
1325        max_crap: opts.max_crap,
1326        top: None,
1327        sort: SortBy::Cyclomatic,
1328        production: opts.production_health.unwrap_or(opts.production),
1329        production_override: opts.production_health,
1330        changed_since,
1331        diff_index: None,
1332        use_shared_diff_index: true,
1333        workspace: opts.workspace,
1334        changed_workspaces: opts.changed_workspaces,
1335        baseline: opts.health_baseline,
1336        save_baseline: None,
1337        complexity: true,
1338        complexity_breakdown: false,
1339        file_scores: false,
1340        coverage_gaps: false,
1341        config_activates_coverage_gaps: false,
1342        hotspots: false,
1343        ownership: false,
1344        ownership_emails: None,
1345        targets: false,
1346        force_full: false,
1347        score_only_output: false,
1348        enforce_coverage_gap_gate: false,
1349        effort: None,
1350        score: false,
1351        min_score: None,
1352        since: None,
1353        min_commits: None,
1354        explain: opts.explain,
1355        summary: false,
1356        save_snapshot: None,
1357        trend: false,
1358        group_by: opts.group_by,
1359        coverage: opts.coverage,
1360        coverage_root: opts.coverage_root,
1361        performance: opts.performance,
1362        min_severity: None,
1363        report_only: false,
1364        runtime_coverage,
1365        // audit runs no hotspot/ownership pass; --churn-file is health-only.
1366        churn_file: None,
1367    };
1368    let health_run = if let Some(shared) = shared_parse {
1369        crate::health::execute_health_with_shared_parse(&health_opts, shared)
1370    } else {
1371        crate::health::execute_health(&health_opts)
1372    };
1373    match health_run {
1374        Ok(r) => Ok(Some(r)),
1375        Err(code) => Err(code),
1376    }
1377}
1378
1379#[path = "audit_output.rs"]
1380mod output;
1381
1382pub use output::print_audit_result;
1383
1384/// Run the full audit command: execute analyses, print results, return exit code.
1385/// Run audit, optionally tagged with a gate marker (e.g. `"pre-commit"`) so
1386/// Fallow Impact can record a containment event when the gate blocks then
1387/// clears. The marker only affects the local Impact store; it never changes
1388/// the verdict, exit code, or output.
1389pub fn run_audit(opts: &AuditOptions<'_>, gate_marker: Option<&str>) -> ExitCode {
1390    if let Err(e) = crate::health::scoring::validate_coverage_root_absolute(opts.coverage_root) {
1391        return emit_error(&e, 2, opts.output);
1392    }
1393    let coverage_resolved = opts
1394        .coverage
1395        .map(|p| crate::health::scoring::resolve_relative_to_root(p, Some(opts.root)));
1396    let runtime_coverage_resolved = opts
1397        .runtime_coverage
1398        .map(|p| crate::health::scoring::resolve_relative_to_root(p, Some(opts.root)));
1399    let resolved_opts = AuditOptions {
1400        coverage: coverage_resolved.as_deref(),
1401        runtime_coverage: runtime_coverage_resolved.as_deref(),
1402        ..*opts
1403    };
1404    match execute_audit(&resolved_opts) {
1405        Ok(result) => {
1406            let mut findings = result
1407                .check
1408                .as_ref()
1409                .map(|c| crate::impact::collect_dead_code_findings(&c.results))
1410                .unwrap_or_default();
1411            if let Some(health) = result.health.as_ref() {
1412                findings.extend(crate::impact::collect_complexity_findings(&health.report));
1413            }
1414            let clones = result
1415                .dupes
1416                .as_ref()
1417                .map(|d| crate::impact::collect_clone_findings(&d.report))
1418                .unwrap_or_default();
1419            let empty_supps: Vec<fallow_core::results::ActiveSuppression> = Vec::new();
1420            let suppressions = result.check.as_ref().map_or(empty_supps.as_slice(), |c| {
1421                c.results.active_suppressions.as_slice()
1422            });
1423            let attribution = crate::impact::AttributionInput {
1424                root: opts.root,
1425                scope: crate::impact::Scope::ChangedFiles(&result.changed_files),
1426                findings,
1427                clones,
1428                suppressions,
1429            };
1430            crate::impact::record_audit_run(
1431                opts.root,
1432                &result.summary,
1433                &crate::impact::AuditRunRecord {
1434                    verdict: result.verdict,
1435                    gate: gate_marker.is_some(),
1436                    git_sha: result.head_sha.as_deref(),
1437                    version: env!("CARGO_PKG_VERSION"),
1438                    timestamp: &crate::vital_signs::chrono_timestamp(),
1439                    attribution: Some(&attribution),
1440                },
1441            );
1442            print_audit_result(&result, opts.quiet, opts.explain)
1443        }
1444        Err(code) => code,
1445    }
1446}
1447
1448#[cfg(test)]
1449mod tests {
1450    use super::*;
1451    use std::{fs, process::Command};
1452
1453    fn git(dir: &std::path::Path, args: &[&str]) {
1454        let output = Command::new("git")
1455            .args(args)
1456            .current_dir(dir)
1457            .env_remove("GIT_DIR")
1458            .env_remove("GIT_WORK_TREE")
1459            .env("GIT_CONFIG_GLOBAL", "/dev/null")
1460            .env("GIT_CONFIG_SYSTEM", "/dev/null")
1461            .env("GIT_AUTHOR_NAME", "test")
1462            .env("GIT_AUTHOR_EMAIL", "test@test.com")
1463            .env("GIT_COMMITTER_NAME", "test")
1464            .env("GIT_COMMITTER_EMAIL", "test@test.com")
1465            .output()
1466            .expect("git command failed");
1467        assert!(
1468            output.status.success(),
1469            "git {:?} failed\nstdout:\n{}\nstderr:\n{}",
1470            args,
1471            String::from_utf8_lossy(&output.stdout),
1472            String::from_utf8_lossy(&output.stderr)
1473        );
1474    }
1475
1476    #[test]
1477    fn audit_worktree_helpers_filter_to_fallow_temp_prefix() {
1478        let temp = std::env::temp_dir();
1479        let audit_path = temp.join("fallow-audit-base-123-456");
1480        let reusable_path = temp.join("fallow-audit-base-cache-abcd-1234");
1481        let canonical_audit_path = temp
1482            .canonicalize()
1483            .unwrap_or_else(|_| temp.clone())
1484            .join("fallow-audit-base-456-789");
1485        let unrelated_temp = temp.join("other-worktree");
1486        let output = format!(
1487            "worktree /repo\nHEAD abc\n\nworktree {}\nHEAD def\n\nworktree {}\nHEAD ghi\n\nworktree {}\nHEAD jkl\n",
1488            audit_path.display(),
1489            unrelated_temp.display(),
1490            reusable_path.display()
1491        );
1492
1493        assert_eq!(
1494            parse_worktree_list(&output),
1495            vec![audit_path, reusable_path.clone()]
1496        );
1497        assert!(is_fallow_audit_worktree_path(&canonical_audit_path));
1498        assert!(is_reusable_audit_worktree_path(&reusable_path));
1499        assert_eq!(audit_worktree_pid("fallow-audit-base-123-456"), Some(123));
1500        assert_eq!(
1501            audit_worktree_pid("fallow-audit-base-cache-abcd-1234"),
1502            None
1503        );
1504        assert_eq!(audit_worktree_pid("not-fallow-audit-base-123"), None);
1505    }
1506
1507    /// Initialize a throwaway git repo with a single commit and return its root.
1508    /// Used by the worktree-lifecycle tests below as a parent repo that can host
1509    /// `git worktree add` invocations.
1510    fn init_throwaway_repo(parent: &std::path::Path, name: &str) -> PathBuf {
1511        let root = parent.join(name);
1512        fs::create_dir_all(&root).expect("repo root should be created");
1513        fs::write(root.join("README.md"), "seed\n").expect("seed file should be written");
1514        git(&root, &["init", "-b", "main"]);
1515        git(&root, &["add", "."]);
1516        git(
1517            &root,
1518            &["-c", "commit.gpgsign=false", "commit", "-m", "initial"],
1519        );
1520        root
1521    }
1522
1523    #[test]
1524    fn auto_detect_base_branch_prefers_origin_head() {
1525        let tmp = tempfile::TempDir::new().expect("temp dir should be created");
1526        let repo = init_throwaway_repo(tmp.path(), "repo");
1527        git(&repo, &["branch", "trunk"]);
1528        git(&repo, &["update-ref", "refs/remotes/origin/trunk", "trunk"]);
1529        git(
1530            &repo,
1531            &[
1532                "symbolic-ref",
1533                "refs/remotes/origin/HEAD",
1534                "refs/remotes/origin/trunk",
1535            ],
1536        );
1537
1538        assert_eq!(auto_detect_base_branch(&repo), Some("trunk".to_string()));
1539    }
1540
1541    #[test]
1542    fn auto_detect_base_branch_falls_back_to_main() {
1543        let tmp = tempfile::TempDir::new().expect("temp dir should be created");
1544        let repo = init_throwaway_repo(tmp.path(), "repo");
1545
1546        assert_eq!(auto_detect_base_branch(&repo), Some("main".to_string()));
1547    }
1548
1549    #[test]
1550    fn auto_detect_base_branch_falls_back_to_master() {
1551        let tmp = tempfile::TempDir::new().expect("temp dir should be created");
1552        let repo = tmp.path().join("repo");
1553        fs::create_dir_all(&repo).expect("repo root should be created");
1554        fs::write(repo.join("README.md"), "seed\n").expect("seed file should be written");
1555        git(&repo, &["init", "-b", "master"]);
1556        git(&repo, &["add", "."]);
1557        git(
1558            &repo,
1559            &["-c", "commit.gpgsign=false", "commit", "-m", "initial"],
1560        );
1561
1562        assert_eq!(auto_detect_base_branch(&repo), Some("master".to_string()));
1563    }
1564
1565    #[test]
1566    fn auto_detect_base_branch_returns_none_outside_git_repo() {
1567        let tmp = tempfile::TempDir::new().expect("temp dir should be created");
1568
1569        assert_eq!(auto_detect_base_branch(tmp.path()), None);
1570    }
1571
1572    #[test]
1573    fn get_head_sha_returns_short_head_for_git_repo() {
1574        let tmp = tempfile::TempDir::new().expect("temp dir should be created");
1575        let repo = init_throwaway_repo(tmp.path(), "repo");
1576        let output = Command::new("git")
1577            .args(["rev-parse", "--short", "HEAD"])
1578            .current_dir(&repo)
1579            .env_remove("GIT_DIR")
1580            .env_remove("GIT_WORK_TREE")
1581            .output()
1582            .expect("git rev-parse should run");
1583        assert!(output.status.success());
1584
1585        assert_eq!(
1586            get_head_sha(&repo),
1587            Some(String::from_utf8_lossy(&output.stdout).trim().to_string())
1588        );
1589    }
1590
1591    #[test]
1592    fn get_head_sha_returns_none_outside_git_repo() {
1593        let tmp = tempfile::TempDir::new().expect("temp dir should be created");
1594
1595        assert_eq!(get_head_sha(tmp.path()), None);
1596    }
1597
1598    fn worktree_is_registered_with_git(repo_root: &std::path::Path, worktree_path: &Path) -> bool {
1599        list_audit_worktrees(repo_root)
1600            .is_some_and(|paths| paths.iter().any(|p| paths_equal(p, worktree_path)))
1601    }
1602
1603    /// True when `git worktree list --porcelain` still carries an admin entry
1604    /// whose path ends with `worktree_path`'s basename. Unlike
1605    /// `worktree_is_registered_with_git`, this matches by basename against the
1606    /// raw porcelain output, so it stays correct even when the directory has
1607    /// been deleted (a prunable orphan): `paths_equal` canonicalization cannot
1608    /// match a missing path across the macOS `/var` -> `/private/var` symlink,
1609    /// but the unique nanos-suffixed basename is stable.
1610    fn worktree_admin_entry_present(repo_root: &std::path::Path, worktree_path: &Path) -> bool {
1611        let basename = worktree_path
1612            .file_name()
1613            .and_then(|n| n.to_str())
1614            .expect("reusable worktree path has a utf-8 basename");
1615        let output = Command::new("git")
1616            .args(["worktree", "list", "--porcelain"])
1617            .current_dir(repo_root)
1618            .env_remove("GIT_DIR")
1619            .env_remove("GIT_WORK_TREE")
1620            .output()
1621            .expect("git worktree list should run");
1622        String::from_utf8_lossy(&output.stdout)
1623            .lines()
1624            .filter_map(|line| line.strip_prefix("worktree "))
1625            .any(|p| p.ends_with(basename))
1626    }
1627
1628    #[test]
1629    fn worktree_cleanup_guard_runs_on_drop() {
1630        let tmp = tempfile::TempDir::new().expect("temp dir should be created");
1631        let repo = init_throwaway_repo(tmp.path(), "repo");
1632        let worktree_path = tmp.path().join("fallow-audit-base-1234-5678");
1633
1634        git(
1635            &repo,
1636            &[
1637                "worktree",
1638                "add",
1639                "--detach",
1640                "--quiet",
1641                worktree_path.to_str().expect("path is utf-8"),
1642                "HEAD",
1643            ],
1644        );
1645        assert!(worktree_path.is_dir());
1646        assert!(worktree_is_registered_with_git(&repo, &worktree_path));
1647
1648        {
1649            let _guard = WorktreeCleanupGuard::new(&repo, &worktree_path);
1650        }
1651
1652        assert!(
1653            !worktree_path.exists(),
1654            "guard Drop should remove the worktree directory",
1655        );
1656        assert!(
1657            !worktree_is_registered_with_git(&repo, &worktree_path),
1658            "guard Drop should remove the git worktree registration",
1659        );
1660    }
1661
1662    #[test]
1663    fn worktree_cleanup_guard_defused_skips_drop() {
1664        let tmp = tempfile::TempDir::new().expect("temp dir should be created");
1665        let repo = init_throwaway_repo(tmp.path(), "repo");
1666        let worktree_path = tmp.path().join("fallow-audit-base-1234-5679");
1667
1668        git(
1669            &repo,
1670            &[
1671                "worktree",
1672                "add",
1673                "--detach",
1674                "--quiet",
1675                worktree_path.to_str().expect("path is utf-8"),
1676                "HEAD",
1677            ],
1678        );
1679        assert!(worktree_path.is_dir());
1680
1681        {
1682            let mut guard = WorktreeCleanupGuard::new(&repo, &worktree_path);
1683            guard.defuse();
1684            guard.defuse();
1685        }
1686
1687        assert!(
1688            worktree_path.is_dir(),
1689            "defused guard must not remove the worktree on drop",
1690        );
1691        assert!(
1692            worktree_is_registered_with_git(&repo, &worktree_path),
1693            "defused guard must not unregister the worktree from git",
1694        );
1695
1696        remove_audit_worktree(&repo, &worktree_path);
1697        let _ = fs::remove_dir_all(&worktree_path);
1698    }
1699
1700    #[test]
1701    fn audit_orphan_sweep_removes_dead_pid_worktree() {
1702        const DEAD_PID: u32 = 99_999_999;
1703        assert!(!process_is_alive(DEAD_PID));
1704
1705        let tmp = tempfile::TempDir::new().expect("temp dir should be created");
1706        let repo = init_throwaway_repo(tmp.path(), "repo");
1707
1708        let worktree_path = std::env::temp_dir().join(format!(
1709            "fallow-audit-base-{}-{}",
1710            DEAD_PID,
1711            std::time::SystemTime::now()
1712                .duration_since(std::time::UNIX_EPOCH)
1713                .expect("clock should be after epoch")
1714                .as_nanos()
1715        ));
1716        git(
1717            &repo,
1718            &[
1719                "worktree",
1720                "add",
1721                "--detach",
1722                "--quiet",
1723                worktree_path.to_str().expect("path is utf-8"),
1724                "HEAD",
1725            ],
1726        );
1727        assert!(worktree_path.is_dir());
1728        assert!(worktree_is_registered_with_git(&repo, &worktree_path));
1729
1730        sweep_orphan_audit_worktrees(&repo);
1731
1732        assert!(
1733            !worktree_path.exists(),
1734            "sweep should remove worktree owned by a dead PID",
1735        );
1736        assert!(
1737            !worktree_is_registered_with_git(&repo, &worktree_path),
1738            "sweep should unregister worktree owned by a dead PID",
1739        );
1740    }
1741
1742    #[test]
1743    fn audit_orphan_sweep_keeps_live_pid_worktree() {
1744        let live_pid = std::process::id();
1745        assert!(process_is_alive(live_pid));
1746
1747        let tmp = tempfile::TempDir::new().expect("temp dir should be created");
1748        let repo = init_throwaway_repo(tmp.path(), "repo");
1749
1750        let worktree_path = std::env::temp_dir().join(format!(
1751            "fallow-audit-base-{}-{}",
1752            live_pid,
1753            std::time::SystemTime::now()
1754                .duration_since(std::time::UNIX_EPOCH)
1755                .expect("clock should be after epoch")
1756                .as_nanos()
1757        ));
1758        git(
1759            &repo,
1760            &[
1761                "worktree",
1762                "add",
1763                "--detach",
1764                "--quiet",
1765                worktree_path.to_str().expect("path is utf-8"),
1766                "HEAD",
1767            ],
1768        );
1769
1770        sweep_orphan_audit_worktrees(&repo);
1771
1772        assert!(
1773            worktree_path.is_dir(),
1774            "sweep must not remove worktree owned by a live PID",
1775        );
1776        assert!(
1777            worktree_is_registered_with_git(&repo, &worktree_path),
1778            "sweep must not unregister worktree owned by a live PID",
1779        );
1780
1781        remove_audit_worktree(&repo, &worktree_path);
1782        let _ = fs::remove_dir_all(&worktree_path);
1783    }
1784
1785    /// Build a reusable-shaped worktree path inside the system tempdir
1786    /// (so `is_reusable_audit_worktree_path` and `path_is_inside_temp_dir`
1787    /// both match), uniquified by nanos so parallel tests do not collide.
1788    fn make_reusable_path(label: &str) -> PathBuf {
1789        let nanos = std::time::SystemTime::now()
1790            .duration_since(std::time::UNIX_EPOCH)
1791            .expect("clock should be after epoch")
1792            .as_nanos();
1793        std::env::temp_dir().join(format!("fallow-audit-base-cache-{label}-{nanos:032x}"))
1794    }
1795
1796    /// Register a worktree with the parent repo at `path` checked out at HEAD.
1797    /// Mirrors what `BaseWorktree::reuse_or_create` does for the fresh-create
1798    /// path so the GC sweep tests can build real cache entries.
1799    fn register_reusable_worktree(repo: &Path, path: &Path) {
1800        git(
1801            repo,
1802            &[
1803                "worktree",
1804                "add",
1805                "--detach",
1806                "--quiet",
1807                path.to_str().expect("path is utf-8"),
1808                "HEAD",
1809            ],
1810        );
1811    }
1812
1813    fn write_sidecar_with_age(path: &Path, age: Duration) {
1814        let sidecar = reusable_worktree_last_used_path(path);
1815        let file = std::fs::OpenOptions::new()
1816            .create(true)
1817            .truncate(false)
1818            .write(true)
1819            .open(&sidecar)
1820            .expect("sidecar should open");
1821        let when = SystemTime::now()
1822            .checked_sub(age)
1823            .expect("backdated time should fit in SystemTime");
1824        file.set_modified(when)
1825            .expect("set_modified should succeed");
1826    }
1827
1828    /// Tear down a reusable worktree (git registration + dir + sidecar + lock)
1829    /// regardless of which of those the test created. Idempotent.
1830    fn cleanup_reusable_worktree(repo: &Path, path: &Path) {
1831        remove_audit_worktree(repo, path);
1832        let _ = fs::remove_dir_all(path);
1833        let _ = fs::remove_file(reusable_worktree_last_used_path(path));
1834        let _ = fs::remove_file(reusable_worktree_lock_path(path));
1835    }
1836
1837    #[test]
1838    fn reusable_cache_gc_removes_old_entry_with_backdated_sidecar() {
1839        let tmp = tempfile::TempDir::new().expect("temp dir should be created");
1840        let repo = init_throwaway_repo(tmp.path(), "repo-gc-remove");
1841        let worktree_path = make_reusable_path("gc-remove");
1842        register_reusable_worktree(&repo, &worktree_path);
1843        write_sidecar_with_age(&worktree_path, Duration::from_hours(31 * 24));
1844
1845        sweep_old_reusable_caches(&repo, Some(Duration::from_hours(30 * 24)), true);
1846
1847        assert!(
1848            !worktree_path.exists(),
1849            "sweep should remove worktree dir whose sidecar is older than the threshold",
1850        );
1851        assert!(
1852            !worktree_is_registered_with_git(&repo, &worktree_path),
1853            "sweep should unregister the worktree from git",
1854        );
1855        assert!(
1856            !reusable_worktree_last_used_path(&worktree_path).exists(),
1857            "sweep should remove the sidecar `.last-used` file alongside the worktree",
1858        );
1859        cleanup_reusable_worktree(&repo, &worktree_path);
1860    }
1861
1862    #[test]
1863    fn reusable_cache_gc_keeps_fresh_entry() {
1864        let tmp = tempfile::TempDir::new().expect("temp dir should be created");
1865        let repo = init_throwaway_repo(tmp.path(), "repo-gc-keep");
1866        let worktree_path = make_reusable_path("gc-keep");
1867        register_reusable_worktree(&repo, &worktree_path);
1868        write_sidecar_with_age(&worktree_path, Duration::from_mins(1));
1869
1870        sweep_old_reusable_caches(&repo, Some(Duration::from_hours(30 * 24)), true);
1871
1872        assert!(
1873            worktree_path.is_dir(),
1874            "sweep must not remove a worktree whose sidecar is fresher than the threshold",
1875        );
1876        assert!(
1877            worktree_is_registered_with_git(&repo, &worktree_path),
1878            "sweep must not unregister a fresh worktree",
1879        );
1880        cleanup_reusable_worktree(&repo, &worktree_path);
1881    }
1882
1883    #[test]
1884    fn reusable_cache_gc_skips_locked_entry() {
1885        let tmp = tempfile::TempDir::new().expect("temp dir should be created");
1886        let repo = init_throwaway_repo(tmp.path(), "repo-gc-locked");
1887        let worktree_path = make_reusable_path("gc-locked");
1888        register_reusable_worktree(&repo, &worktree_path);
1889        write_sidecar_with_age(&worktree_path, Duration::from_hours(31 * 24));
1890
1891        let lock = ReusableWorktreeLock::try_acquire(&worktree_path)
1892            .expect("test should acquire the lock first");
1893
1894        sweep_old_reusable_caches(&repo, Some(Duration::from_hours(30 * 24)), true);
1895
1896        assert!(
1897            worktree_path.is_dir(),
1898            "sweep must skip a locked entry even when its sidecar is stale",
1899        );
1900        assert!(
1901            worktree_is_registered_with_git(&repo, &worktree_path),
1902            "sweep must not unregister a locked entry",
1903        );
1904        drop(lock);
1905        cleanup_reusable_worktree(&repo, &worktree_path);
1906    }
1907
1908    #[test]
1909    fn reusable_cache_gc_grace_when_sidecar_absent() {
1910        let tmp = tempfile::TempDir::new().expect("temp dir should be created");
1911        let repo = init_throwaway_repo(tmp.path(), "repo-gc-grace");
1912        let worktree_path = make_reusable_path("gc-grace");
1913        register_reusable_worktree(&repo, &worktree_path);
1914        let sidecar = reusable_worktree_last_used_path(&worktree_path);
1915        assert!(
1916            !sidecar.exists(),
1917            "test pre-condition: sidecar should not exist",
1918        );
1919
1920        sweep_old_reusable_caches(&repo, Some(Duration::from_hours(30 * 24)), true);
1921
1922        assert!(
1923            worktree_path.is_dir(),
1924            "pre-upgrade grace: sidecar-absent entries must NOT be removed on first encounter",
1925        );
1926        assert!(
1927            sidecar.exists(),
1928            "pre-upgrade grace: sidecar must be seeded so the next run can age from real last-used",
1929        );
1930        let mtime = std::fs::metadata(&sidecar)
1931            .and_then(|m| m.modified())
1932            .expect("seeded sidecar should have a readable mtime");
1933        let age = SystemTime::now()
1934            .duration_since(mtime)
1935            .unwrap_or(Duration::ZERO);
1936        assert!(
1937            age < Duration::from_mins(1),
1938            "seeded sidecar mtime should be near `now()`, got age {age:?}",
1939        );
1940        cleanup_reusable_worktree(&repo, &worktree_path);
1941    }
1942
1943    #[test]
1944    fn reusable_cache_gc_reclaims_prunable_orphan_when_dir_missing() {
1945        let tmp = tempfile::TempDir::new().expect("temp dir should be created");
1946        let repo = init_throwaway_repo(tmp.path(), "repo-gc-orphan");
1947        let worktree_path = make_reusable_path("gc-orphan");
1948        register_reusable_worktree(&repo, &worktree_path);
1949        // Fresh sidecar: the age branch alone would KEEP this entry, so a
1950        // successful reclaim proves the dir-missing branch drove it.
1951        write_sidecar_with_age(&worktree_path, Duration::from_mins(1));
1952        let sidecar = reusable_worktree_last_used_path(&worktree_path);
1953
1954        // Simulate an external temp-reaper: delete only the worktree directory,
1955        // leaving git's admin entry and the sidecar behind.
1956        fs::remove_dir_all(&worktree_path).expect("test should remove the cache dir");
1957        assert!(
1958            !worktree_path.exists(),
1959            "test pre-condition: cache dir should be gone",
1960        );
1961        assert!(
1962            worktree_admin_entry_present(&repo, &worktree_path),
1963            "test pre-condition: git admin entry should still be registered (prunable)",
1964        );
1965        assert!(
1966            sidecar.exists(),
1967            "test pre-condition: sidecar survives a dir-only reaper",
1968        );
1969
1970        sweep_old_reusable_caches(&repo, Some(Duration::from_hours(30 * 24)), true);
1971
1972        assert!(
1973            !worktree_admin_entry_present(&repo, &worktree_path),
1974            "sweep should unregister a prunable orphan whose dir was externally removed",
1975        );
1976        assert!(
1977            !sidecar.exists(),
1978            "sweep should remove the stale sidecar for a reclaimed orphan",
1979        );
1980        cleanup_reusable_worktree(&repo, &worktree_path);
1981    }
1982
1983    #[test]
1984    fn reusable_cache_gc_reclaims_prunable_orphan_even_when_age_gc_disabled() {
1985        let tmp = tempfile::TempDir::new().expect("temp dir should be created");
1986        let repo = init_throwaway_repo(tmp.path(), "repo-gc-orphan-nogc");
1987        let worktree_path = make_reusable_path("gc-orphan-nogc");
1988        register_reusable_worktree(&repo, &worktree_path);
1989        write_sidecar_with_age(&worktree_path, Duration::from_mins(1));
1990        let sidecar = reusable_worktree_last_used_path(&worktree_path);
1991        fs::remove_dir_all(&worktree_path).expect("test should remove the cache dir");
1992        assert!(
1993            worktree_admin_entry_present(&repo, &worktree_path),
1994            "test pre-condition: git admin entry should still be registered (prunable)",
1995        );
1996        assert!(
1997            sidecar.exists(),
1998            "test pre-condition: sidecar survives a dir-only reaper",
1999        );
2000
2001        // `None` = age-based GC disabled (`cacheMaxAgeDays = 0`). Orphan reclaim
2002        // must still run so dead admin entries do not accumulate forever.
2003        sweep_old_reusable_caches(&repo, None, true);
2004
2005        assert!(
2006            !worktree_admin_entry_present(&repo, &worktree_path),
2007            "orphan reclaim must run even when age-based GC is disabled",
2008        );
2009        assert!(
2010            !sidecar.exists(),
2011            "sweep should remove the stale sidecar even when age-based GC is disabled",
2012        );
2013        cleanup_reusable_worktree(&repo, &worktree_path);
2014    }
2015
2016    #[test]
2017    fn reusable_cache_gc_preserves_lock_file_after_removal() {
2018        let tmp = tempfile::TempDir::new().expect("temp dir should be created");
2019        let repo = init_throwaway_repo(tmp.path(), "repo-gc-lockfile");
2020        let worktree_path = make_reusable_path("gc-lockfile");
2021        register_reusable_worktree(&repo, &worktree_path);
2022        write_sidecar_with_age(&worktree_path, Duration::from_hours(31 * 24));
2023        let lock_path = reusable_worktree_lock_path(&worktree_path);
2024        drop(
2025            ReusableWorktreeLock::try_acquire(&worktree_path)
2026                .expect("test should acquire the lock"),
2027        );
2028        assert!(
2029            lock_path.exists(),
2030            "test pre-condition: lock file should exist before sweep",
2031        );
2032
2033        sweep_old_reusable_caches(&repo, Some(Duration::from_hours(30 * 24)), true);
2034
2035        assert!(
2036            !worktree_path.exists(),
2037            "sweep should still remove the worktree directory",
2038        );
2039        assert!(
2040            lock_path.exists(),
2041            "sweep MUST NOT delete the `.lock` file (lock-lifecycle invariant)",
2042        );
2043        let _ = fs::remove_file(&lock_path);
2044        cleanup_reusable_worktree(&repo, &worktree_path);
2045    }
2046
2047    #[test]
2048    fn reuse_or_create_stamps_sidecar_on_fresh_create() {
2049        let tmp = tempfile::TempDir::new().expect("temp dir should be created");
2050        let repo = init_throwaway_repo(tmp.path(), "repo-fresh-create-stamp");
2051        let base_sha = git_rev_parse(&repo, "HEAD").expect("HEAD should resolve");
2052
2053        let worktree = BaseWorktree::reuse_or_create(&repo, &base_sha)
2054            .expect("fresh reuse_or_create should succeed on a clean repo");
2055        let cache_path = worktree.path().to_path_buf();
2056        let sidecar = reusable_worktree_last_used_path(&cache_path);
2057
2058        assert!(
2059            sidecar.exists(),
2060            "fresh-create must write the sidecar so age is measured from now",
2061        );
2062        let initial_age = std::fs::metadata(&sidecar)
2063            .and_then(|m| m.modified())
2064            .ok()
2065            .and_then(|mtime| SystemTime::now().duration_since(mtime).ok())
2066            .expect("sidecar mtime should be readable and not in the future");
2067        assert!(
2068            initial_age < Duration::from_mins(1),
2069            "fresh-create sidecar mtime should be near now(), got age {initial_age:?}",
2070        );
2071
2072        drop(worktree);
2073        cleanup_reusable_worktree(&repo, &cache_path);
2074    }
2075
2076    #[test]
2077    fn days_to_duration_zero_disables() {
2078        assert!(days_to_duration(0).is_none());
2079        assert_eq!(days_to_duration(1), Some(Duration::from_hours(24)));
2080        assert_eq!(days_to_duration(30), Some(Duration::from_hours(30 * 24)));
2081    }
2082
2083    #[test]
2084    fn reusable_worktree_last_used_path_lives_next_to_cache_dir() {
2085        let cache_dir = std::env::temp_dir().join("fallow-audit-base-cache-abcd-1234");
2086        let sidecar = reusable_worktree_last_used_path(&cache_dir);
2087        assert_eq!(sidecar.parent(), cache_dir.parent());
2088        assert_eq!(
2089            sidecar.file_name().and_then(|s| s.to_str()),
2090            Some("fallow-audit-base-cache-abcd-1234.last-used"),
2091        );
2092    }
2093
2094    #[test]
2095    fn touch_last_used_creates_sidecar_if_missing() {
2096        let tmp = tempfile::TempDir::new().expect("temp dir should be created");
2097        let cache_dir = tmp.path().join("fallow-audit-base-cache-touchtest-0000");
2098        fs::create_dir(&cache_dir).expect("cache dir should be created");
2099        let sidecar = reusable_worktree_last_used_path(&cache_dir);
2100        assert!(!sidecar.exists(), "sidecar should not exist before touch");
2101
2102        touch_last_used(&cache_dir);
2103
2104        assert!(sidecar.exists(), "touch should create the sidecar");
2105        let mtime = fs::metadata(&sidecar)
2106            .and_then(|m| m.modified())
2107            .expect("sidecar should have an mtime");
2108        let age = SystemTime::now()
2109            .duration_since(mtime)
2110            .unwrap_or(Duration::ZERO);
2111        assert!(
2112            age < Duration::from_mins(1),
2113            "touched sidecar should be near `now()`",
2114        );
2115    }
2116
2117    #[test]
2118    fn reusable_worktree_lock_excludes_concurrent_acquires() {
2119        let tmp = tempfile::TempDir::new().expect("temp dir should be created");
2120        let reusable = tmp.path().join("fallow-audit-base-cache-deadbeef-0000");
2121        let lock_path = reusable_worktree_lock_path(&reusable);
2122
2123        let first = ReusableWorktreeLock::try_acquire(&reusable)
2124            .expect("first acquire on a fresh path should succeed");
2125        assert!(
2126            ReusableWorktreeLock::try_acquire(&reusable).is_none(),
2127            "second acquire must fail while the first is held",
2128        );
2129        drop(first);
2130        assert!(
2131            lock_path.exists(),
2132            "lock file must persist after drop (only the kernel lock is released)",
2133        );
2134    }
2135
2136    #[test]
2137    fn base_analysis_root_preserves_repo_subdirectory_roots() {
2138        let tmp = tempfile::TempDir::new().expect("temp dir should be created");
2139        let repo = tmp.path().join("repo");
2140        let app_root = repo.join("apps/mobile");
2141        let base_worktree = tmp.path().join("base-worktree");
2142        fs::create_dir_all(&app_root).expect("app root should be created");
2143        fs::create_dir_all(&base_worktree).expect("base worktree should be created");
2144        git(&repo, &["init", "-b", "main"]);
2145
2146        assert_eq!(
2147            base_analysis_root(&app_root, &base_worktree),
2148            base_worktree.join("apps/mobile")
2149        );
2150    }
2151
2152    #[test]
2153    fn audit_base_worktree_reuses_current_node_modules_context() {
2154        let tmp = tempfile::TempDir::new().expect("temp dir should be created");
2155        let root = tmp.path();
2156        fs::create_dir_all(root.join("src")).expect("src dir should be created");
2157        fs::write(root.join(".gitignore"), "node_modules\n.fallow\n")
2158            .expect("gitignore should be written");
2159        fs::write(
2160            root.join("package.json"),
2161            r#"{"name":"audit-rn-alias","main":"src/index.ts","dependencies":{"@react-native/typescript-config":"1.0.0"}}"#,
2162        )
2163        .expect("package.json should be written");
2164        fs::write(
2165            root.join("tsconfig.json"),
2166            r#"{"extends":"./node_modules/@react-native/typescript-config/tsconfig.json","compilerOptions":{"baseUrl":".","paths":{"@/*":["src/*"]}},"include":["src"]}"#,
2167        )
2168        .expect("tsconfig should be written");
2169        fs::write(
2170            root.join("src/index.ts"),
2171            "import { used } from '@/feature';\nconsole.log(used);\n",
2172        )
2173        .expect("index should be written");
2174        fs::write(root.join("src/feature.ts"), "export const used = 1;\n")
2175            .expect("feature should be written");
2176
2177        git(root, &["init", "-b", "main"]);
2178        git(root, &["add", "."]);
2179        git(
2180            root,
2181            &["-c", "commit.gpgsign=false", "commit", "-m", "initial"],
2182        );
2183
2184        let rn_config = root.join("node_modules/@react-native/typescript-config");
2185        fs::create_dir_all(&rn_config).expect("node_modules config dir should be created");
2186        fs::write(
2187            rn_config.join("tsconfig.json"),
2188            r#"{"compilerOptions":{"jsx":"react-native","moduleResolution":"bundler"}}"#,
2189        )
2190        .expect("node_modules tsconfig should be written");
2191
2192        let worktree =
2193            BaseWorktree::create(root, "HEAD", None).expect("base worktree should be created");
2194        assert!(
2195            worktree.path().join("node_modules").is_dir(),
2196            "base worktree should reuse ignored node_modules from the current checkout"
2197        );
2198        assert!(
2199            worktree
2200                .path()
2201                .join("node_modules/@react-native/typescript-config/tsconfig.json")
2202                .is_file(),
2203            "base worktree should preserve tsconfig extends targets installed in node_modules"
2204        );
2205    }
2206
2207    /// Confirms `materialize_base_dependency_context` symlinks the Nuxt
2208    /// `.nuxt/` generated dir from the host checkout into the audit base
2209    /// worktree. Without this, root `tsconfig.json` `references` entries
2210    /// pointing into `.nuxt/tsconfig.app.json` break in the base pass and
2211    /// emit "Nuxt project missing .nuxt/tsconfig.json" plus "Broken tsconfig
2212    /// chain" warnings. The function is exercised directly here rather than
2213    /// through `BaseWorktree::create` to avoid the `git worktree add`
2214    /// concurrency-flakiness the worktree-level integration tests already
2215    /// exhibit.
2216    #[test]
2217    fn materialize_base_dependency_context_symlinks_nuxt_generated_dir() {
2218        let host = tempfile::TempDir::new().expect("host tempdir should be created");
2219        let worktree = tempfile::TempDir::new().expect("worktree tempdir should be created");
2220
2221        let dot_nuxt = host.path().join(".nuxt");
2222        fs::create_dir_all(&dot_nuxt).expect(".nuxt dir should be created");
2223        fs::write(dot_nuxt.join("tsconfig.json"), r#"{"compilerOptions":{}}"#)
2224            .expect(".nuxt/tsconfig.json should be written");
2225        fs::write(
2226            dot_nuxt.join("tsconfig.app.json"),
2227            r#"{"compilerOptions":{}}"#,
2228        )
2229        .expect(".nuxt/tsconfig.app.json should be written");
2230
2231        materialize_base_dependency_context(host.path(), worktree.path());
2232
2233        let mirrored = worktree.path().join(".nuxt");
2234        assert!(
2235            mirrored.is_dir(),
2236            "base worktree should reuse the ignored .nuxt dir from the host checkout"
2237        );
2238        let link_meta = fs::symlink_metadata(&mirrored)
2239            .expect(".nuxt entry should exist as a symlink in the worktree");
2240        assert!(
2241            link_meta.file_type().is_symlink(),
2242            "base worktree's .nuxt should be a symlink to the host checkout"
2243        );
2244        assert!(
2245            mirrored.join("tsconfig.json").is_file(),
2246            "base worktree should expose .nuxt/tsconfig.json so the Nuxt meta-framework \
2247             prerequisite check stays quiet"
2248        );
2249        assert!(
2250            mirrored.join("tsconfig.app.json").is_file(),
2251            "base worktree should expose .nuxt/tsconfig.app.json so root tsconfig references \
2252             resolve without falling back to resolver-less resolution"
2253        );
2254    }
2255
2256    /// Confirms the same symlink treatment for Astro's `.astro/` generated
2257    /// types directory, which is gitignored by default and would otherwise
2258    /// trip the "Astro project missing .astro/" prerequisite check on the
2259    /// base pass.
2260    #[test]
2261    fn materialize_base_dependency_context_symlinks_astro_generated_dir() {
2262        let host = tempfile::TempDir::new().expect("host tempdir should be created");
2263        let worktree = tempfile::TempDir::new().expect("worktree tempdir should be created");
2264
2265        let dot_astro = host.path().join(".astro");
2266        fs::create_dir_all(&dot_astro).expect(".astro dir should be created");
2267        fs::write(dot_astro.join("types.d.ts"), "// generated types\n")
2268            .expect(".astro/types.d.ts should be written");
2269
2270        materialize_base_dependency_context(host.path(), worktree.path());
2271
2272        let mirrored = worktree.path().join(".astro");
2273        assert!(
2274            mirrored.is_dir(),
2275            "base worktree should reuse the ignored .astro dir from the host checkout"
2276        );
2277        assert!(
2278            mirrored.join("types.d.ts").is_file(),
2279            "base worktree should expose generated Astro types so the Astro meta-framework \
2280             prerequisite check stays quiet"
2281        );
2282    }
2283
2284    /// Confirms the symlink step is a no-op when the host checkout has no
2285    /// meta-framework output. We must not fabricate a dangling `.nuxt`
2286    /// symlink: the Nuxt prerequisite check would then pass on the base pass
2287    /// while the actual `.nuxt/tsconfig.json` still doesn't exist, hiding a
2288    /// real "run `nuxt prepare`" warning on the HEAD pass behind a
2289    /// process-wide dedupe key.
2290    #[test]
2291    fn materialize_base_dependency_context_skips_when_host_lacks_meta_framework_dir() {
2292        let host = tempfile::TempDir::new().expect("host tempdir should be created");
2293        let worktree = tempfile::TempDir::new().expect("worktree tempdir should be created");
2294
2295        materialize_base_dependency_context(host.path(), worktree.path());
2296
2297        assert!(
2298            !worktree.path().join(".nuxt").exists(),
2299            "base worktree should not fabricate a .nuxt symlink when the host has no .nuxt dir"
2300        );
2301        assert!(
2302            !worktree.path().join(".astro").exists(),
2303            "base worktree should not fabricate a .astro symlink when the host has no .astro dir"
2304        );
2305        assert!(
2306            !worktree.path().join("node_modules").exists(),
2307            "base worktree should not fabricate a node_modules symlink when the host has none"
2308        );
2309    }
2310
2311    /// Confirms each entry in `MATERIALIZED_CONTEXT_DIRS` is independent: a
2312    /// missing host `.nuxt/` must not prevent `node_modules` from being
2313    /// symlinked when only one of the two is present on the host.
2314    #[test]
2315    fn materialize_base_dependency_context_handles_each_dir_independently() {
2316        let host = tempfile::TempDir::new().expect("host tempdir should be created");
2317        let worktree = tempfile::TempDir::new().expect("worktree tempdir should be created");
2318
2319        fs::create_dir_all(host.path().join("node_modules"))
2320            .expect("host node_modules should be created");
2321
2322        materialize_base_dependency_context(host.path(), worktree.path());
2323
2324        assert!(
2325            worktree.path().join("node_modules").is_dir(),
2326            "node_modules should still be symlinked even when host has no .nuxt or .astro"
2327        );
2328        assert!(
2329            !worktree.path().join(".nuxt").exists(),
2330            "missing host .nuxt should leave the worktree slot empty"
2331        );
2332    }
2333
2334    /// Confirms a real (non-symlink) generated dir already present in the base
2335    /// worktree is preserved, not clobbered by a host symlink. A base commit
2336    /// that genuinely tracks `.nuxt/` is base-shaped and authoritative; the
2337    /// host-symlink shortcut only fills the gap when the worktree slot is
2338    /// empty (or a stale dangling symlink), so the `destination.is_dir()`
2339    /// early-continue must keep the worktree's own contents.
2340    #[test]
2341    fn materialize_base_dependency_context_preserves_real_worktree_dir() {
2342        let host = tempfile::TempDir::new().expect("host tempdir should be created");
2343        let worktree = tempfile::TempDir::new().expect("worktree tempdir should be created");
2344
2345        let host_nuxt = host.path().join(".nuxt");
2346        fs::create_dir_all(&host_nuxt).expect("host .nuxt dir should be created");
2347        fs::write(host_nuxt.join("tsconfig.json"), r#"{"_source":"host"}"#)
2348            .expect("host .nuxt/tsconfig.json should be written");
2349
2350        let worktree_nuxt = worktree.path().join(".nuxt");
2351        fs::create_dir_all(&worktree_nuxt).expect("worktree .nuxt dir should be created");
2352        fs::write(worktree_nuxt.join("tsconfig.json"), r#"{"_source":"base"}"#)
2353            .expect("worktree .nuxt/tsconfig.json should be written");
2354
2355        materialize_base_dependency_context(host.path(), worktree.path());
2356
2357        let link_meta = fs::symlink_metadata(&worktree_nuxt)
2358            .expect(".nuxt entry should still exist in the worktree");
2359        assert!(
2360            !link_meta.file_type().is_symlink(),
2361            "a real base-tracked .nuxt dir must not be replaced by a host symlink"
2362        );
2363        let contents =
2364            fs::read_to_string(worktree_nuxt.join("tsconfig.json")).expect("tsconfig should read");
2365        assert!(
2366            contents.contains("base"),
2367            "base worktree's own .nuxt contents must survive, not be overwritten by the host's"
2368        );
2369    }
2370
2371    #[test]
2372    fn audit_reusable_base_worktree_refreshes_current_node_modules_context() {
2373        let tmp = tempfile::TempDir::new().expect("temp dir should be created");
2374        let root = tmp.path();
2375        fs::write(root.join(".gitignore"), "node_modules\n.fallow\n")
2376            .expect("gitignore should be written");
2377        fs::write(root.join("package.json"), r#"{"name":"audit-reusable"}"#)
2378            .expect("package.json should be written");
2379
2380        git(root, &["init", "-b", "main"]);
2381        git(root, &["add", "."]);
2382        git(
2383            root,
2384            &["-c", "commit.gpgsign=false", "commit", "-m", "initial"],
2385        );
2386
2387        let rn_config = root.join("node_modules/@react-native/typescript-config");
2388        fs::create_dir_all(&rn_config).expect("node_modules config dir should be created");
2389        fs::write(rn_config.join("tsconfig.json"), "{}")
2390            .expect("node_modules tsconfig should be written");
2391
2392        let base_sha = git_rev_parse(root, "HEAD").expect("HEAD should resolve");
2393        let first = BaseWorktree::create(root, "HEAD", Some(&base_sha))
2394            .expect("persistent base worktree should be created");
2395        let worktree_path = first.path().to_path_buf();
2396        assert!(
2397            worktree_path.join("node_modules").is_dir(),
2398            "initial persistent worktree should receive node_modules context"
2399        );
2400        remove_node_modules_context(&worktree_path);
2401        assert!(
2402            !worktree_path.join("node_modules").exists(),
2403            "test setup should remove the dependency context from the reusable worktree"
2404        );
2405        drop(first);
2406
2407        let reused = BaseWorktree::create(root, "HEAD", Some(&base_sha))
2408            .expect("ready persistent base worktree should be reused");
2409        assert_eq!(reused.path(), worktree_path.as_path());
2410        assert!(
2411            reused.path().join("node_modules").is_dir(),
2412            "ready persistent worktree should refresh missing node_modules context"
2413        );
2414
2415        remove_audit_worktree(root, reused.path());
2416        let _ = fs::remove_dir_all(reused.path());
2417    }
2418
2419    fn remove_node_modules_context(worktree_path: &Path) {
2420        let path = worktree_path.join("node_modules");
2421        let Ok(metadata) = fs::symlink_metadata(&path) else {
2422            return;
2423        };
2424        if metadata.file_type().is_symlink() {
2425            #[cfg(unix)]
2426            let _ = fs::remove_file(path);
2427            #[cfg(windows)]
2428            let _ = fs::remove_dir(&path).or_else(|_| fs::remove_file(&path));
2429        } else {
2430            let _ = fs::remove_dir_all(path);
2431        }
2432    }
2433
2434    #[test]
2435    fn audit_base_snapshot_cache_payload_roundtrips_sets() {
2436        let key = AuditBaseSnapshotCacheKey {
2437            hash: 42,
2438            base_sha: "abc123".to_string(),
2439        };
2440        let snapshot = AuditKeySnapshot {
2441            dead_code: ["dead:a".to_string(), "dead:b".to_string()]
2442                .into_iter()
2443                .collect(),
2444            health: std::iter::once("health:a".to_string()).collect(),
2445            dupes: ["dupe:a".to_string(), "dupe:b".to_string()]
2446                .into_iter()
2447                .collect(),
2448        };
2449
2450        let cached = cached_from_snapshot(&key, &snapshot);
2451        assert_eq!(cached.version, AUDIT_BASE_SNAPSHOT_CACHE_VERSION);
2452        assert_eq!(cached.key_hash, key.hash);
2453        assert_eq!(cached.base_sha, key.base_sha);
2454        assert_eq!(cached.dead_code, vec!["dead:a", "dead:b"]);
2455
2456        let decoded = snapshot_from_cached(cached);
2457        assert_eq!(decoded.dead_code, snapshot.dead_code);
2458        assert_eq!(decoded.health, snapshot.health);
2459        assert_eq!(decoded.dupes, snapshot.dupes);
2460    }
2461
2462    #[test]
2463    fn audit_base_snapshot_cache_dir_writes_gitignore() {
2464        let tmp = tempfile::TempDir::new().expect("temp dir should be created");
2465        let cache_root = tmp.path().join(".custom-fallow-cache");
2466        let cache_dir = audit_base_snapshot_cache_dir(&cache_root);
2467
2468        ensure_audit_base_snapshot_cache_dir(&cache_dir).expect("cache dir should be created");
2469
2470        assert_eq!(
2471            fs::read_to_string(cache_dir.join(".gitignore")).expect("gitignore should read"),
2472            "*\n"
2473        );
2474    }
2475
2476    #[test]
2477    fn audit_base_snapshot_cache_roundtrips_from_disk() {
2478        let tmp = tempfile::TempDir::new().expect("temp dir should be created");
2479        let config_path = None;
2480        let cache_root = tmp.path().join(".custom-fallow-cache");
2481        let opts = AuditOptions {
2482            root: tmp.path(),
2483            cache_dir: &cache_root,
2484            config_path: &config_path,
2485            output: OutputFormat::Json,
2486            no_cache: false,
2487            threads: 1,
2488            quiet: true,
2489            changed_since: Some("HEAD"),
2490            production: false,
2491            production_dead_code: None,
2492            production_health: None,
2493            production_dupes: None,
2494            workspace: None,
2495            changed_workspaces: None,
2496            explain: false,
2497            explain_skipped: false,
2498            performance: false,
2499            group_by: None,
2500            dead_code_baseline: None,
2501            health_baseline: None,
2502            dupes_baseline: None,
2503            max_crap: None,
2504            coverage: None,
2505            coverage_root: None,
2506            gate: AuditGate::NewOnly,
2507            include_entry_exports: false,
2508            runtime_coverage: None,
2509            min_invocations_hot: 100,
2510        };
2511        let key = AuditBaseSnapshotCacheKey {
2512            hash: 0xfeed,
2513            base_sha: "abc123".to_string(),
2514        };
2515        let snapshot = AuditKeySnapshot {
2516            dead_code: std::iter::once("dead:a".to_string()).collect(),
2517            health: std::iter::once("health:a".to_string()).collect(),
2518            dupes: std::iter::once("dupe:a".to_string()).collect(),
2519        };
2520
2521        save_cached_base_snapshot(&opts, &key, &snapshot);
2522        assert!(
2523            audit_base_snapshot_cache_file(&cache_root, &key).exists(),
2524            "snapshot should be saved below the configured cache directory"
2525        );
2526        let loaded = load_cached_base_snapshot(&opts, &key).expect("snapshot should load");
2527
2528        assert_eq!(loaded.dead_code, snapshot.dead_code);
2529        assert_eq!(loaded.health, snapshot.health);
2530        assert_eq!(loaded.dupes, snapshot.dupes);
2531    }
2532
2533    #[test]
2534    fn audit_base_snapshot_cache_rejects_mismatched_key() {
2535        let tmp = tempfile::TempDir::new().expect("temp dir should be created");
2536        let config_path = None;
2537        let cache_root = tmp.path().join(".custom-fallow-cache");
2538        let opts = AuditOptions {
2539            root: tmp.path(),
2540            cache_dir: &cache_root,
2541            config_path: &config_path,
2542            output: OutputFormat::Json,
2543            no_cache: false,
2544            threads: 1,
2545            quiet: true,
2546            changed_since: Some("HEAD"),
2547            production: false,
2548            production_dead_code: None,
2549            production_health: None,
2550            production_dupes: None,
2551            workspace: None,
2552            changed_workspaces: None,
2553            explain: false,
2554            explain_skipped: false,
2555            performance: false,
2556            group_by: None,
2557            dead_code_baseline: None,
2558            health_baseline: None,
2559            dupes_baseline: None,
2560            max_crap: None,
2561            coverage: None,
2562            coverage_root: None,
2563            gate: AuditGate::NewOnly,
2564            include_entry_exports: false,
2565            runtime_coverage: None,
2566            min_invocations_hot: 100,
2567        };
2568        let key = AuditBaseSnapshotCacheKey {
2569            hash: 0xbeef,
2570            base_sha: "head".to_string(),
2571        };
2572        let cached = CachedAuditKeySnapshot {
2573            version: AUDIT_BASE_SNAPSHOT_CACHE_VERSION,
2574            cli_version: env!("CARGO_PKG_VERSION").to_string(),
2575            key_hash: key.hash,
2576            base_sha: "other".to_string(),
2577            dead_code: vec!["dead:a".to_string()],
2578            health: vec![],
2579            dupes: vec![],
2580        };
2581        let cache_dir = audit_base_snapshot_cache_dir(&cache_root);
2582        ensure_audit_base_snapshot_cache_dir(&cache_dir).expect("cache dir should be created");
2583        fs::write(
2584            audit_base_snapshot_cache_file(&cache_root, &key),
2585            bitcode::encode(&cached),
2586        )
2587        .expect("cache file should be written");
2588
2589        assert!(load_cached_base_snapshot(&opts, &key).is_none());
2590    }
2591
2592    #[test]
2593    fn audit_base_snapshot_cache_key_includes_extended_config() {
2594        let tmp = tempfile::TempDir::new().expect("temp dir should be created");
2595        let root = tmp.path();
2596        fs::write(
2597            root.join(".fallowrc.json"),
2598            r#"{"extends":"base.json","entry":["src/index.ts"]}"#,
2599        )
2600        .expect("config should be written");
2601        fs::write(
2602            root.join("base.json"),
2603            r#"{"rules":{"unused-exports":"off"}}"#,
2604        )
2605        .expect("base config should be written");
2606
2607        let config_path = None;
2608        let cache_root = root.join(".fallow");
2609        let opts = AuditOptions {
2610            root,
2611            cache_dir: &cache_root,
2612            config_path: &config_path,
2613            output: OutputFormat::Json,
2614            no_cache: false,
2615            threads: 1,
2616            quiet: true,
2617            changed_since: Some("HEAD"),
2618            production: false,
2619            production_dead_code: None,
2620            production_health: None,
2621            production_dupes: None,
2622            workspace: None,
2623            changed_workspaces: None,
2624            explain: false,
2625            explain_skipped: false,
2626            performance: false,
2627            group_by: None,
2628            dead_code_baseline: None,
2629            health_baseline: None,
2630            dupes_baseline: None,
2631            max_crap: None,
2632            coverage: None,
2633            coverage_root: None,
2634            gate: AuditGate::NewOnly,
2635            include_entry_exports: false,
2636            runtime_coverage: None,
2637            min_invocations_hot: 100,
2638        };
2639
2640        let first = config_file_fingerprint(&opts).expect("fingerprint should be computed");
2641        fs::write(
2642            root.join("base.json"),
2643            r#"{"rules":{"unused-exports":"error"}}"#,
2644        )
2645        .expect("base config should be updated");
2646        let second = config_file_fingerprint(&opts).expect("fingerprint should be recomputed");
2647
2648        assert_ne!(
2649            first["resolved_hash"], second["resolved_hash"],
2650            "extended config changes must invalidate cached base snapshots"
2651        );
2652    }
2653
2654    #[test]
2655    fn audit_gate_all_skips_base_snapshot() {
2656        let tmp = tempfile::TempDir::new().expect("temp dir should be created");
2657        let root = tmp.path();
2658        fs::create_dir_all(root.join("src")).expect("src dir should be created");
2659        fs::write(
2660            root.join("package.json"),
2661            r#"{"name":"audit-gate-all","main":"src/index.ts"}"#,
2662        )
2663        .expect("package.json should be written");
2664        fs::write(root.join("src/index.ts"), "export const legacy = 1;\n")
2665            .expect("index should be written");
2666
2667        git(root, &["init", "-b", "main"]);
2668        git(root, &["add", "."]);
2669        git(
2670            root,
2671            &["-c", "commit.gpgsign=false", "commit", "-m", "initial"],
2672        );
2673        fs::write(
2674            root.join("src/index.ts"),
2675            "export const legacy = 1;\nexport const changed = 2;\n",
2676        )
2677        .expect("changed module should be written");
2678
2679        let config_path = None;
2680        let cache_root = root.join(".fallow");
2681        let opts = AuditOptions {
2682            root,
2683            cache_dir: &cache_root,
2684            config_path: &config_path,
2685            output: OutputFormat::Json,
2686            no_cache: true,
2687            threads: 1,
2688            quiet: true,
2689            changed_since: Some("HEAD"),
2690            production: false,
2691            production_dead_code: None,
2692            production_health: None,
2693            production_dupes: None,
2694            workspace: None,
2695            changed_workspaces: None,
2696            explain: false,
2697            explain_skipped: false,
2698            performance: false,
2699            group_by: None,
2700            dead_code_baseline: None,
2701            health_baseline: None,
2702            dupes_baseline: None,
2703            max_crap: None,
2704            coverage: None,
2705            coverage_root: None,
2706            gate: AuditGate::All,
2707            include_entry_exports: false,
2708            runtime_coverage: None,
2709            min_invocations_hot: 100,
2710        };
2711
2712        let result = execute_audit(&opts).expect("audit should execute");
2713        assert!(result.base_snapshot.is_none());
2714        assert_eq!(result.attribution.gate, AuditGate::All);
2715        assert_eq!(result.attribution.dead_code_introduced, 0);
2716        assert_eq!(result.attribution.dead_code_inherited, 0);
2717    }
2718
2719    #[test]
2720    fn audit_gate_new_only_skips_base_snapshot_for_docs_only_diff() {
2721        let tmp = tempfile::TempDir::new().expect("temp dir should be created");
2722        let root = tmp.path();
2723        fs::create_dir_all(root.join("src")).expect("src dir should be created");
2724        fs::write(
2725            root.join("package.json"),
2726            r#"{"name":"audit-docs-only","main":"src/index.ts"}"#,
2727        )
2728        .expect("package.json should be written");
2729        fs::write(
2730            root.join(".fallowrc.json"),
2731            r#"{"duplicates":{"minTokens":5,"minLines":2,"mode":"strict"}}"#,
2732        )
2733        .expect("config should be written");
2734        let duplicated = "export function same(input: number): number {\n  const doubled = input * 2;\n  const shifted = doubled + 1;\n  return shifted;\n}\n";
2735        fs::write(root.join("src/index.ts"), duplicated).expect("index should be written");
2736        fs::write(root.join("src/copy.ts"), duplicated).expect("copy should be written");
2737        fs::write(root.join("README.md"), "before\n").expect("readme should be written");
2738
2739        git(root, &["init", "-b", "main"]);
2740        git(root, &["add", "."]);
2741        git(
2742            root,
2743            &["-c", "commit.gpgsign=false", "commit", "-m", "initial"],
2744        );
2745        fs::write(root.join("README.md"), "after\n").expect("readme should be modified");
2746        fs::create_dir_all(root.join(".fallow/cache/dupes-tokens-v2"))
2747            .expect("cache dir should be created");
2748        fs::write(
2749            root.join(".fallow/cache/dupes-tokens-v2/cache.bin"),
2750            b"cache",
2751        )
2752        .expect("cache artifact should be written");
2753
2754        let before_worktrees = audit_worktree_names(root);
2755
2756        let config_path = None;
2757        let cache_root = root.join(".fallow");
2758        let opts = AuditOptions {
2759            root,
2760            cache_dir: &cache_root,
2761            config_path: &config_path,
2762            output: OutputFormat::Json,
2763            no_cache: true,
2764            threads: 1,
2765            quiet: true,
2766            changed_since: Some("HEAD"),
2767            production: false,
2768            production_dead_code: None,
2769            production_health: None,
2770            production_dupes: None,
2771            workspace: None,
2772            changed_workspaces: None,
2773            explain: false,
2774            explain_skipped: false,
2775            performance: true,
2776            group_by: None,
2777            dead_code_baseline: None,
2778            health_baseline: None,
2779            dupes_baseline: None,
2780            max_crap: None,
2781            coverage: None,
2782            coverage_root: None,
2783            gate: AuditGate::NewOnly,
2784            include_entry_exports: false,
2785            runtime_coverage: None,
2786            min_invocations_hot: 100,
2787        };
2788
2789        let result = execute_audit(&opts).expect("audit should execute");
2790        assert_eq!(result.verdict, AuditVerdict::Pass);
2791        assert_eq!(result.changed_files_count, 2);
2792        assert!(result.base_snapshot_skipped);
2793        assert!(result.base_snapshot.is_some());
2794
2795        let after_worktrees = audit_worktree_names(root);
2796        assert_eq!(
2797            before_worktrees, after_worktrees,
2798            "base snapshot skip must not create a temporary base worktree"
2799        );
2800    }
2801
2802    fn audit_worktree_names(repo_root: &Path) -> Vec<String> {
2803        let mut names: Vec<String> = list_audit_worktrees(repo_root)
2804            .unwrap_or_default()
2805            .into_iter()
2806            .filter_map(|path| {
2807                path.file_name()
2808                    .and_then(|name| name.to_str())
2809                    .map(str::to_owned)
2810            })
2811            .collect();
2812        names.sort();
2813        names
2814    }
2815
2816    #[test]
2817    fn audit_reuses_dead_code_parse_for_health_when_production_matches() {
2818        let tmp = tempfile::TempDir::new().expect("temp dir should be created");
2819        let root = tmp.path();
2820        fs::create_dir_all(root.join("src")).expect("src dir should be created");
2821        fs::write(
2822            root.join("package.json"),
2823            r#"{"name":"audit-shared-parse","main":"src/index.ts"}"#,
2824        )
2825        .expect("package.json should be written");
2826        fs::write(
2827            root.join("src/index.ts"),
2828            "import { used } from './used';\nused();\n",
2829        )
2830        .expect("index should be written");
2831        fs::write(
2832            root.join("src/used.ts"),
2833            "export function used() {\n  return 1;\n}\n",
2834        )
2835        .expect("used module should be written");
2836
2837        git(root, &["init", "-b", "main"]);
2838        git(root, &["add", "."]);
2839        git(
2840            root,
2841            &["-c", "commit.gpgsign=false", "commit", "-m", "initial"],
2842        );
2843        fs::write(
2844            root.join("src/used.ts"),
2845            "export function used() {\n  return 1;\n}\nexport function changed() {\n  return 2;\n}\n",
2846        )
2847        .expect("changed module should be written");
2848
2849        let config_path = None;
2850        let cache_root = root.join(".fallow");
2851        let opts = AuditOptions {
2852            root,
2853            cache_dir: &cache_root,
2854            config_path: &config_path,
2855            output: OutputFormat::Json,
2856            no_cache: true,
2857            threads: 1,
2858            quiet: true,
2859            changed_since: Some("HEAD"),
2860            production: false,
2861            production_dead_code: None,
2862            production_health: None,
2863            production_dupes: None,
2864            workspace: None,
2865            changed_workspaces: None,
2866            explain: false,
2867            explain_skipped: false,
2868            performance: true,
2869            group_by: None,
2870            dead_code_baseline: None,
2871            health_baseline: None,
2872            dupes_baseline: None,
2873            max_crap: None,
2874            coverage: None,
2875            coverage_root: None,
2876            gate: AuditGate::NewOnly,
2877            include_entry_exports: false,
2878            runtime_coverage: None,
2879            min_invocations_hot: 100,
2880        };
2881
2882        let result = execute_audit(&opts).expect("audit should execute");
2883        let health = result.health.expect("health should run for changed files");
2884        let timings = health.timings.expect("performance timings should be kept");
2885        assert!(timings.discover_ms.abs() < f64::EPSILON);
2886        assert!(timings.parse_ms.abs() < f64::EPSILON);
2887        assert!(
2888            result.dupes.is_some(),
2889            "dupes should run when changed files exist"
2890        );
2891    }
2892
2893    #[test]
2894    fn audit_dupes_falls_back_to_own_discovery_when_health_off() {
2895        let tmp = tempfile::TempDir::new().expect("temp dir should be created");
2896        let root = tmp.path();
2897        fs::create_dir_all(root.join("src")).expect("src dir should be created");
2898        fs::write(
2899            root.join("package.json"),
2900            r#"{"name":"audit-dupes-fallback","main":"src/index.ts"}"#,
2901        )
2902        .expect("package.json should be written");
2903        fs::write(
2904            root.join("src/index.ts"),
2905            "import { used } from './used';\nused();\n",
2906        )
2907        .expect("index should be written");
2908        fs::write(
2909            root.join("src/used.ts"),
2910            "export function used() {\n  return 1;\n}\n",
2911        )
2912        .expect("used module should be written");
2913
2914        git(root, &["init", "-b", "main"]);
2915        git(root, &["add", "."]);
2916        git(
2917            root,
2918            &["-c", "commit.gpgsign=false", "commit", "-m", "initial"],
2919        );
2920        fs::write(
2921            root.join("src/used.ts"),
2922            "export function used() {\n  return 1;\n}\nexport function changed() {\n  return 2;\n}\n",
2923        )
2924        .expect("changed module should be written");
2925
2926        let config_path = None;
2927        let cache_root = root.join(".fallow");
2928        let opts = AuditOptions {
2929            root,
2930            cache_dir: &cache_root,
2931            config_path: &config_path,
2932            output: OutputFormat::Json,
2933            no_cache: true,
2934            threads: 1,
2935            quiet: true,
2936            changed_since: Some("HEAD"),
2937            production: false,
2938            production_dead_code: Some(true),
2939            production_health: Some(false),
2940            production_dupes: Some(false),
2941            workspace: None,
2942            changed_workspaces: None,
2943            explain: false,
2944            explain_skipped: false,
2945            performance: true,
2946            group_by: None,
2947            dead_code_baseline: None,
2948            health_baseline: None,
2949            dupes_baseline: None,
2950            max_crap: None,
2951            coverage: None,
2952            coverage_root: None,
2953            gate: AuditGate::NewOnly,
2954            include_entry_exports: false,
2955            runtime_coverage: None,
2956            min_invocations_hot: 100,
2957        };
2958
2959        let result = execute_audit(&opts).expect("audit should execute");
2960        assert!(result.dupes.is_some(), "dupes should still run");
2961    }
2962
2963    #[cfg(unix)]
2964    #[test]
2965    fn remap_focus_files_does_not_canonicalize_through_symlinks() {
2966        let tmp = tempfile::TempDir::new().expect("temp dir");
2967        let real = tmp.path().join("real");
2968        let link = tmp.path().join("link");
2969        fs::create_dir_all(&real).expect("real dir");
2970        std::os::unix::fs::symlink(&real, &link).expect("symlink");
2971        let canonical = link.canonicalize().expect("canonicalize symlink");
2972        assert_ne!(link, canonical, "symlink should not equal its target");
2973
2974        let from_root = PathBuf::from("/repo");
2975        let mut focus = FxHashSet::default();
2976        focus.insert(from_root.join("src/foo.ts"));
2977
2978        let remapped = remap_focus_files(&focus, &from_root, &link)
2979            .expect("remap should succeed for in-prefix files");
2980
2981        let expected = link.join("src/foo.ts");
2982        assert!(
2983            remapped.contains(&expected),
2984            "remapped paths must keep the un-canonical to_root prefix; got {remapped:?}, expected entry {expected:?}"
2985        );
2986    }
2987
2988    #[test]
2989    fn remap_focus_files_skips_paths_outside_from_root() {
2990        let from_root = PathBuf::from("/repo/apps/web");
2991        let to_root = PathBuf::from("/wt/apps/web");
2992        let mut focus = FxHashSet::default();
2993        focus.insert(PathBuf::from("/repo/apps/web/src/in.ts"));
2994        focus.insert(PathBuf::from("/repo/services/api/src/out.ts"));
2995
2996        let remapped =
2997            remap_focus_files(&focus, &from_root, &to_root).expect("partial map should succeed");
2998
2999        assert_eq!(remapped.len(), 1);
3000        assert!(remapped.contains(&PathBuf::from("/wt/apps/web/src/in.ts")));
3001    }
3002
3003    #[test]
3004    fn remap_focus_files_returns_none_when_no_paths_map() {
3005        let from_root = PathBuf::from("/repo/apps/web");
3006        let to_root = PathBuf::from("/wt/apps/web");
3007        let mut focus = FxHashSet::default();
3008        focus.insert(PathBuf::from("/elsewhere/foo.ts"));
3009
3010        let remapped = remap_focus_files(&focus, &from_root, &to_root);
3011        assert!(
3012            remapped.is_none(),
3013            "remap should return None when no paths can be mapped, falling caller back to full corpus"
3014        );
3015    }
3016
3017    #[test]
3018    fn remap_cache_dir_moves_project_local_cache_to_base_worktree() {
3019        let tmp = tempfile::TempDir::new().expect("temp dir should be created");
3020        let current_root = tmp.path().join("repo");
3021        let base_root = tmp.path().join("fallow-base");
3022        let cache_dir = current_root.join(".cache").join("fallow");
3023
3024        let remapped = remap_cache_dir_for_base_worktree(&current_root, &base_root, &cache_dir);
3025
3026        assert_eq!(remapped, base_root.join(".cache").join("fallow"));
3027    }
3028
3029    #[test]
3030    fn remap_cache_dir_keeps_external_absolute_cache_shared() {
3031        let tmp = tempfile::TempDir::new().expect("temp dir should be created");
3032        let current_root = tmp.path().join("repo");
3033        let base_root = tmp.path().join("fallow-base");
3034        let cache_dir = tmp.path().join("shared").join("fallow-cache");
3035
3036        let remapped = remap_cache_dir_for_base_worktree(&current_root, &base_root, &cache_dir);
3037
3038        assert_eq!(remapped, cache_dir);
3039    }
3040
3041    #[test]
3042    fn audit_gate_new_only_inherits_pre_existing_duplicates_in_focused_files() {
3043        let tmp = tempfile::TempDir::new().expect("temp dir should be created");
3044        let root_buf = tmp
3045            .path()
3046            .canonicalize()
3047            .expect("temp root should canonicalize");
3048        let root = root_buf.as_path();
3049        fs::create_dir_all(root.join("src")).expect("src dir should be created");
3050        fs::write(
3051            root.join("package.json"),
3052            r#"{"name":"audit-newonly-inherit","main":"src/changed.ts"}"#,
3053        )
3054        .expect("package.json should be written");
3055        fs::write(
3056            root.join(".fallowrc.json"),
3057            r#"{"duplicates":{"minTokens":10,"minLines":3,"mode":"strict"}}"#,
3058        )
3059        .expect("config should be written");
3060
3061        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";
3062        fs::write(root.join("src/changed.ts"), dup_block).expect("changed should be written");
3063        fs::write(root.join("src/peer.ts"), dup_block).expect("peer should be written");
3064
3065        git(root, &["init", "-b", "main"]);
3066        git(root, &["add", "."]);
3067        git(
3068            root,
3069            &["-c", "commit.gpgsign=false", "commit", "-m", "initial"],
3070        );
3071        fs::write(
3072            root.join("src/changed.ts"),
3073            format!("{dup_block}// touched\n"),
3074        )
3075        .expect("changed file should be modified");
3076        git(root, &["add", "."]);
3077        git(
3078            root,
3079            &["-c", "commit.gpgsign=false", "commit", "-m", "touch"],
3080        );
3081
3082        let config_path = None;
3083        let cache_root = root.join(".fallow");
3084        let opts = AuditOptions {
3085            root,
3086            cache_dir: &cache_root,
3087            config_path: &config_path,
3088            output: OutputFormat::Json,
3089            no_cache: true,
3090            threads: 1,
3091            quiet: true,
3092            changed_since: Some("HEAD~1"),
3093            production: false,
3094            production_dead_code: None,
3095            production_health: None,
3096            production_dupes: None,
3097            workspace: None,
3098            changed_workspaces: None,
3099            explain: false,
3100            explain_skipped: false,
3101            performance: false,
3102            group_by: None,
3103            dead_code_baseline: None,
3104            health_baseline: None,
3105            dupes_baseline: None,
3106            max_crap: None,
3107            coverage: None,
3108            coverage_root: None,
3109            gate: AuditGate::NewOnly,
3110            include_entry_exports: false,
3111            runtime_coverage: None,
3112            min_invocations_hot: 100,
3113        };
3114
3115        let result = execute_audit(&opts).expect("audit should execute");
3116        assert!(
3117            result.base_snapshot_skipped,
3118            "comment-only JS/TS diffs should reuse current keys as the base snapshot"
3119        );
3120        let dupes_report = &result.dupes.as_ref().expect("dupes should run").report;
3121        assert!(
3122            !dupes_report.clone_groups.is_empty(),
3123            "current run should detect the pre-existing duplicate"
3124        );
3125        assert_eq!(
3126            result.attribution.duplication_introduced, 0,
3127            "pre-existing duplicate must not be classified as introduced; \
3128             attribution = {:?}",
3129            result.attribution
3130        );
3131        assert!(
3132            result.attribution.duplication_inherited > 0,
3133            "pre-existing duplicate must be classified as inherited; \
3134             attribution = {:?}",
3135            result.attribution
3136        );
3137    }
3138
3139    #[test]
3140    fn audit_base_preserves_tsconfig_paths_when_extends_is_in_untracked_node_modules() {
3141        let tmp = tempfile::TempDir::new().expect("temp dir should be created");
3142        let root = tmp.path();
3143        fs::create_dir_all(root.join("src/screens")).expect("src dir should be created");
3144        fs::create_dir_all(root.join("node_modules/@react-native/typescript-config"))
3145            .expect("node_modules config dir should be created");
3146        fs::write(root.join(".gitignore"), "node_modules/\n").expect("gitignore should be written");
3147        fs::write(
3148            root.join("package.json"),
3149            r#"{
3150                "name": "audit-react-native-tsconfig-base",
3151                "private": true,
3152                "main": "src/App.tsx",
3153                "dependencies": {
3154                    "react-native": "0.80.0"
3155                }
3156            }"#,
3157        )
3158        .expect("package.json should be written");
3159        fs::write(
3160            root.join("tsconfig.json"),
3161            r#"{
3162                "extends": "./node_modules/@react-native/typescript-config/tsconfig.json",
3163                "compilerOptions": {
3164                    "baseUrl": ".",
3165                    "paths": {
3166                        "@/*": ["src/*"]
3167                    }
3168                },
3169                "include": ["src/**/*"]
3170            }"#,
3171        )
3172        .expect("tsconfig should be written");
3173        fs::write(
3174            root.join("node_modules/@react-native/typescript-config/tsconfig.json"),
3175            r#"{"compilerOptions":{"strict":true,"jsx":"react-jsx"}}"#,
3176        )
3177        .expect("react native tsconfig should be written");
3178        fs::write(
3179            root.join("src/App.tsx"),
3180            r#"import { homeTitle } from "@/screens/Home";
3181
3182export function App() {
3183  return homeTitle;
3184}
3185"#,
3186        )
3187        .expect("app should be written");
3188        fs::write(
3189            root.join("src/screens/Home.ts"),
3190            r#"export const homeTitle = "home";
3191"#,
3192        )
3193        .expect("home should be written");
3194
3195        git(root, &["init", "-b", "main"]);
3196        git(root, &["add", "."]);
3197        git(
3198            root,
3199            &["-c", "commit.gpgsign=false", "commit", "-m", "initial"],
3200        );
3201        fs::write(
3202            root.join("src/App.tsx"),
3203            r#"import { homeTitle } from "@/screens/Home";
3204
3205export function App() {
3206  return homeTitle.toUpperCase();
3207}
3208"#,
3209        )
3210        .expect("app should be modified");
3211
3212        let config_path = None;
3213        let cache_root = root.join(".fallow");
3214        let opts = AuditOptions {
3215            root,
3216            cache_dir: &cache_root,
3217            config_path: &config_path,
3218            output: OutputFormat::Json,
3219            no_cache: true,
3220            threads: 1,
3221            quiet: true,
3222            changed_since: Some("HEAD"),
3223            production: false,
3224            production_dead_code: None,
3225            production_health: None,
3226            production_dupes: None,
3227            workspace: None,
3228            changed_workspaces: None,
3229            explain: false,
3230            explain_skipped: false,
3231            performance: false,
3232            group_by: None,
3233            dead_code_baseline: None,
3234            health_baseline: None,
3235            dupes_baseline: None,
3236            max_crap: None,
3237            coverage: None,
3238            coverage_root: None,
3239            gate: AuditGate::NewOnly,
3240            include_entry_exports: false,
3241            runtime_coverage: None,
3242            min_invocations_hot: 100,
3243        };
3244
3245        let result = execute_audit(&opts).expect("audit should execute");
3246        assert!(
3247            !result.base_snapshot_skipped,
3248            "source diffs should run a real base snapshot"
3249        );
3250        let base = result
3251            .base_snapshot
3252            .as_ref()
3253            .expect("base snapshot should run");
3254        assert!(
3255            !base
3256                .dead_code
3257                .contains("unresolved-import:src/App.tsx:@/screens/Home"),
3258            "base audit must keep local @/* tsconfig aliases when extends points into ignored node_modules: {:?}",
3259            base.dead_code
3260        );
3261        assert!(
3262            !base.dead_code.contains("unused-file:src/screens/Home.ts"),
3263            "alias target should stay reachable in the base worktree: {:?}",
3264            base.dead_code
3265        );
3266        let check = result.check.as_ref().expect("dead-code audit should run");
3267        assert!(
3268            check.results.unresolved_imports.is_empty(),
3269            "HEAD audit should also resolve @/* aliases: {:?}",
3270            check.results.unresolved_imports
3271        );
3272    }
3273
3274    #[test]
3275    fn audit_base_preserves_subdirectory_root_resolution() {
3276        let tmp = tempfile::TempDir::new().expect("temp dir should be created");
3277        let repo = tmp.path().join("repo");
3278        let root = repo.join("apps/mobile");
3279        fs::create_dir_all(root.join("src/screens")).expect("src dir should be created");
3280        fs::create_dir_all(root.join("node_modules/@react-native/typescript-config"))
3281            .expect("node_modules config dir should be created");
3282        fs::write(repo.join(".gitignore"), "apps/mobile/node_modules/\n")
3283            .expect("gitignore should be written");
3284        fs::write(
3285            root.join("package.json"),
3286            r#"{
3287                "name": "audit-subdir-react-native-tsconfig-base",
3288                "private": true,
3289                "main": "src/App.tsx",
3290                "dependencies": {
3291                    "react-native": "0.80.0"
3292                }
3293            }"#,
3294        )
3295        .expect("package.json should be written");
3296        fs::write(
3297            root.join("tsconfig.json"),
3298            r#"{
3299                "extends": "./node_modules/@react-native/typescript-config/tsconfig.json",
3300                "compilerOptions": {
3301                    "baseUrl": ".",
3302                    "paths": {
3303                        "@/*": ["src/*"]
3304                    }
3305                },
3306                "include": ["src/**/*"]
3307            }"#,
3308        )
3309        .expect("tsconfig should be written");
3310        fs::write(
3311            root.join("node_modules/@react-native/typescript-config/tsconfig.json"),
3312            r#"{"compilerOptions":{"strict":true,"jsx":"react-jsx"}}"#,
3313        )
3314        .expect("react native tsconfig should be written");
3315        fs::write(
3316            root.join("src/App.tsx"),
3317            r#"import { homeTitle } from "@/screens/Home";
3318
3319export function App() {
3320  return homeTitle;
3321}
3322"#,
3323        )
3324        .expect("app should be written");
3325        fs::write(
3326            root.join("src/screens/Home.ts"),
3327            r#"export const homeTitle = "home";
3328"#,
3329        )
3330        .expect("home should be written");
3331
3332        git(&repo, &["init", "-b", "main"]);
3333        git(&repo, &["add", "."]);
3334        git(
3335            &repo,
3336            &["-c", "commit.gpgsign=false", "commit", "-m", "initial"],
3337        );
3338        fs::write(
3339            root.join("src/App.tsx"),
3340            r#"import { homeTitle } from "@/screens/Home";
3341
3342export function App() {
3343  return homeTitle.toUpperCase();
3344}
3345"#,
3346        )
3347        .expect("app should be modified");
3348
3349        let config_path = None;
3350        let cache_root = root.join(".fallow");
3351        let opts = AuditOptions {
3352            root: &root,
3353            cache_dir: &cache_root,
3354            config_path: &config_path,
3355            output: OutputFormat::Json,
3356            no_cache: true,
3357            threads: 1,
3358            quiet: true,
3359            changed_since: Some("HEAD"),
3360            production: false,
3361            production_dead_code: None,
3362            production_health: None,
3363            production_dupes: None,
3364            workspace: None,
3365            changed_workspaces: None,
3366            explain: false,
3367            explain_skipped: false,
3368            performance: false,
3369            group_by: None,
3370            dead_code_baseline: None,
3371            health_baseline: None,
3372            dupes_baseline: None,
3373            max_crap: None,
3374            coverage: None,
3375            coverage_root: None,
3376            gate: AuditGate::NewOnly,
3377            include_entry_exports: false,
3378            runtime_coverage: None,
3379            min_invocations_hot: 100,
3380        };
3381
3382        let result = execute_audit(&opts).expect("audit should execute");
3383        assert!(
3384            !result.base_snapshot_skipped,
3385            "source diffs should run a real base snapshot"
3386        );
3387        let base = result
3388            .base_snapshot
3389            .as_ref()
3390            .expect("base snapshot should run");
3391        assert!(
3392            !base
3393                .dead_code
3394                .contains("unresolved-import:src/App.tsx:@/screens/Home"),
3395            "base audit should analyze from the app subdirectory, not the repo root: {:?}",
3396            base.dead_code
3397        );
3398        assert!(
3399            !base.dead_code.contains("unused-file:src/screens/Home.ts"),
3400            "subdirectory base audit should keep alias targets reachable: {:?}",
3401            base.dead_code
3402        );
3403    }
3404
3405    #[test]
3406    fn audit_base_uses_new_explicit_config_without_hard_failure() {
3407        let tmp = tempfile::TempDir::new().expect("temp dir should be created");
3408        let root = tmp.path();
3409        fs::create_dir_all(root.join("src")).expect("src dir should be created");
3410        fs::write(
3411            root.join("package.json"),
3412            r#"{"name":"audit-new-config","main":"src/index.ts"}"#,
3413        )
3414        .expect("package.json should be written");
3415        fs::write(root.join("src/index.ts"), "export const used = 1;\n")
3416            .expect("index should be written");
3417
3418        git(root, &["init", "-b", "main"]);
3419        git(root, &["add", "."]);
3420        git(
3421            root,
3422            &["-c", "commit.gpgsign=false", "commit", "-m", "initial"],
3423        );
3424
3425        let explicit_config = root.join(".fallowrc.json");
3426        fs::write(&explicit_config, r#"{"rules":{"unused-files":"error"}}"#)
3427            .expect("new config should be written");
3428        fs::write(root.join("src/index.ts"), "export const used = 2;\n")
3429            .expect("index should be modified");
3430
3431        let config_path = Some(explicit_config);
3432        let cache_root = root.join(".fallow");
3433        let opts = AuditOptions {
3434            root,
3435            cache_dir: &cache_root,
3436            config_path: &config_path,
3437            output: OutputFormat::Json,
3438            no_cache: true,
3439            threads: 1,
3440            quiet: true,
3441            changed_since: Some("HEAD"),
3442            production: false,
3443            production_dead_code: None,
3444            production_health: None,
3445            production_dupes: None,
3446            workspace: None,
3447            changed_workspaces: None,
3448            explain: false,
3449            explain_skipped: false,
3450            performance: false,
3451            group_by: None,
3452            dead_code_baseline: None,
3453            health_baseline: None,
3454            dupes_baseline: None,
3455            max_crap: None,
3456            coverage: None,
3457            coverage_root: None,
3458            gate: AuditGate::NewOnly,
3459            include_entry_exports: false,
3460            runtime_coverage: None,
3461            min_invocations_hot: 100,
3462        };
3463
3464        let result = execute_audit(&opts).expect("audit should execute with a new explicit config");
3465        assert!(
3466            result.base_snapshot.is_some(),
3467            "base snapshot should use the current explicit config even when the base commit lacks it"
3468        );
3469    }
3470
3471    #[test]
3472    fn audit_base_uses_current_discovered_config_for_attribution() {
3473        let tmp = tempfile::TempDir::new().expect("temp dir should be created");
3474        let root = tmp.path();
3475        fs::create_dir_all(root.join("src")).expect("src dir should be created");
3476        fs::write(
3477            root.join("package.json"),
3478            r#"{"name":"audit-current-config","main":"src/index.ts","dependencies":{"left-pad":"1.3.0"}}"#,
3479        )
3480        .expect("package.json should be written");
3481        fs::write(
3482            root.join(".fallowrc.json"),
3483            r#"{"rules":{"unused-dependencies":"off"}}"#,
3484        )
3485        .expect("base config should be written");
3486        fs::write(root.join("src/index.ts"), "export const used = 1;\n")
3487            .expect("index should be written");
3488
3489        git(root, &["init", "-b", "main"]);
3490        git(root, &["add", "."]);
3491        git(
3492            root,
3493            &["-c", "commit.gpgsign=false", "commit", "-m", "initial"],
3494        );
3495
3496        fs::write(
3497            root.join(".fallowrc.json"),
3498            r#"{"rules":{"unused-dependencies":"error"}}"#,
3499        )
3500        .expect("current config should be written");
3501        fs::write(
3502            root.join("package.json"),
3503            r#"{"name":"audit-current-config","main":"src/index.ts","dependencies":{"left-pad":"1.3.1"}}"#,
3504        )
3505        .expect("package.json should be touched");
3506
3507        let config_path = None;
3508        let cache_root = root.join(".fallow");
3509        let opts = AuditOptions {
3510            root,
3511            cache_dir: &cache_root,
3512            config_path: &config_path,
3513            output: OutputFormat::Json,
3514            no_cache: true,
3515            threads: 1,
3516            quiet: true,
3517            changed_since: Some("HEAD"),
3518            production: false,
3519            production_dead_code: None,
3520            production_health: None,
3521            production_dupes: None,
3522            workspace: None,
3523            changed_workspaces: None,
3524            explain: false,
3525            explain_skipped: false,
3526            performance: false,
3527            group_by: None,
3528            dead_code_baseline: None,
3529            health_baseline: None,
3530            dupes_baseline: None,
3531            max_crap: None,
3532            coverage: None,
3533            coverage_root: None,
3534            gate: AuditGate::NewOnly,
3535            include_entry_exports: false,
3536            runtime_coverage: None,
3537            min_invocations_hot: 100,
3538        };
3539
3540        let result = execute_audit(&opts).expect("audit should execute");
3541        assert_eq!(
3542            result.attribution.dead_code_introduced, 0,
3543            "enabling a rule should not make pre-existing changed-file findings look introduced: {:?}",
3544            result.attribution
3545        );
3546        assert!(
3547            result.attribution.dead_code_inherited > 0,
3548            "pre-existing changed-file findings should be classified as inherited: {:?}",
3549            result.attribution
3550        );
3551    }
3552
3553    #[test]
3554    fn audit_base_current_config_attribution_survives_cache_hit() {
3555        let tmp = tempfile::TempDir::new().expect("temp dir should be created");
3556        let root = tmp.path();
3557        fs::create_dir_all(root.join("src")).expect("src dir should be created");
3558        fs::write(
3559            root.join("package.json"),
3560            r#"{"name":"audit-current-config-cache","main":"src/index.ts","dependencies":{"left-pad":"1.3.0"}}"#,
3561        )
3562        .expect("package.json should be written");
3563        fs::write(
3564            root.join(".fallowrc.json"),
3565            r#"{"rules":{"unused-dependencies":"off"}}"#,
3566        )
3567        .expect("base config should be written");
3568        fs::write(root.join("src/index.ts"), "export const used = 1;\n")
3569            .expect("index should be written");
3570
3571        git(root, &["init", "-b", "main"]);
3572        git(root, &["add", "."]);
3573        git(
3574            root,
3575            &["-c", "commit.gpgsign=false", "commit", "-m", "initial"],
3576        );
3577
3578        fs::write(
3579            root.join(".fallowrc.json"),
3580            r#"{"rules":{"unused-dependencies":"error"}}"#,
3581        )
3582        .expect("current config should be written");
3583        fs::write(
3584            root.join("package.json"),
3585            r#"{"name":"audit-current-config-cache","main":"src/index.ts","dependencies":{"left-pad":"1.3.1"}}"#,
3586        )
3587        .expect("package.json should be touched");
3588
3589        let config_path = None;
3590        let cache_root = root.join(".fallow");
3591        let opts = AuditOptions {
3592            root,
3593            cache_dir: &cache_root,
3594            config_path: &config_path,
3595            output: OutputFormat::Json,
3596            no_cache: false,
3597            threads: 1,
3598            quiet: true,
3599            changed_since: Some("HEAD"),
3600            production: false,
3601            production_dead_code: None,
3602            production_health: None,
3603            production_dupes: None,
3604            workspace: None,
3605            changed_workspaces: None,
3606            explain: false,
3607            explain_skipped: false,
3608            performance: false,
3609            group_by: None,
3610            dead_code_baseline: None,
3611            health_baseline: None,
3612            dupes_baseline: None,
3613            max_crap: None,
3614            coverage: None,
3615            coverage_root: None,
3616            gate: AuditGate::NewOnly,
3617            include_entry_exports: false,
3618            runtime_coverage: None,
3619            min_invocations_hot: 100,
3620        };
3621
3622        let first = execute_audit(&opts).expect("first audit should execute");
3623        assert_eq!(
3624            first.attribution.dead_code_introduced, 0,
3625            "first audit should classify pre-existing findings as inherited: {:?}",
3626            first.attribution
3627        );
3628
3629        let changed_files =
3630            crate::check::get_changed_files(root, "HEAD").expect("changed files should resolve");
3631        let key = audit_base_snapshot_cache_key(&opts, "HEAD", &changed_files)
3632            .expect("cache key should compute")
3633            .expect("cache key should exist");
3634        assert!(
3635            load_cached_base_snapshot(&opts, &key).is_some(),
3636            "first audit should store a reusable base snapshot"
3637        );
3638
3639        let second = execute_audit(&opts).expect("second audit should execute");
3640        assert_eq!(
3641            second.attribution.dead_code_introduced, 0,
3642            "cache hit should keep current-config attribution stable: {:?}",
3643            second.attribution
3644        );
3645        assert!(
3646            second.attribution.dead_code_inherited > 0,
3647            "cache hit should preserve inherited base findings: {:?}",
3648            second.attribution
3649        );
3650    }
3651
3652    #[test]
3653    fn audit_dupes_only_materializes_groups_touching_changed_files() {
3654        let tmp = tempfile::TempDir::new().expect("temp dir should be created");
3655        let root_path = tmp
3656            .path()
3657            .canonicalize()
3658            .expect("temp root should canonicalize");
3659        let root = root_path.as_path();
3660        fs::create_dir_all(root.join("src")).expect("src dir should be created");
3661        fs::write(
3662            root.join("package.json"),
3663            r#"{"name":"audit-dupes-focus","main":"src/changed.ts"}"#,
3664        )
3665        .expect("package.json should be written");
3666        fs::write(
3667            root.join(".fallowrc.json"),
3668            r#"{"duplicates":{"minTokens":5,"minLines":2,"mode":"strict"}}"#,
3669        )
3670        .expect("config should be written");
3671
3672        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";
3673        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";
3674        fs::write(root.join("src/changed.ts"), focused_code).expect("changed should be written");
3675        fs::write(root.join("src/focused-copy.ts"), focused_code)
3676            .expect("focused copy should be written");
3677        fs::write(root.join("src/untouched-a.ts"), untouched_code)
3678            .expect("untouched a should be written");
3679        fs::write(root.join("src/untouched-b.ts"), untouched_code)
3680            .expect("untouched b should be written");
3681
3682        git(root, &["init", "-b", "main"]);
3683        git(root, &["add", "."]);
3684        git(
3685            root,
3686            &["-c", "commit.gpgsign=false", "commit", "-m", "initial"],
3687        );
3688        fs::write(
3689            root.join("src/changed.ts"),
3690            format!("{focused_code}export const changedMarker = true;\n"),
3691        )
3692        .expect("changed file should be modified");
3693
3694        let config_path = None;
3695        let cache_root = root.join(".fallow");
3696        let opts = AuditOptions {
3697            root,
3698            cache_dir: &cache_root,
3699            config_path: &config_path,
3700            output: OutputFormat::Json,
3701            no_cache: true,
3702            threads: 1,
3703            quiet: true,
3704            changed_since: Some("HEAD"),
3705            production: false,
3706            production_dead_code: None,
3707            production_health: None,
3708            production_dupes: None,
3709            workspace: None,
3710            changed_workspaces: None,
3711            explain: false,
3712            explain_skipped: false,
3713            performance: false,
3714            group_by: None,
3715            dead_code_baseline: None,
3716            health_baseline: None,
3717            dupes_baseline: None,
3718            max_crap: None,
3719            coverage: None,
3720            coverage_root: None,
3721            gate: AuditGate::All,
3722            include_entry_exports: false,
3723            runtime_coverage: None,
3724            min_invocations_hot: 100,
3725        };
3726
3727        let result = execute_audit(&opts).expect("audit should execute");
3728        let dupes = result.dupes.expect("dupes should run");
3729        let changed_path = root.join("src/changed.ts");
3730
3731        assert!(
3732            !dupes.report.clone_groups.is_empty(),
3733            "changed file should still match unchanged duplicate code"
3734        );
3735        assert!(dupes.report.clone_groups.iter().all(|group| {
3736            group
3737                .instances
3738                .iter()
3739                .any(|instance| instance.file == changed_path)
3740        }));
3741    }
3742}