Skip to main content

fallow_cli/
audit.rs

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