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