Skip to main content

fallow_cli/
audit.rs

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