Skip to main content

fallow_cli/
audit.rs

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