Skip to main content

fallow_cli/
audit.rs

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