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