Skip to main content

fallow_cli/
audit.rs

1use std::io::Write;
2use std::path::{Path, PathBuf};
3use std::process::{Command, ExitCode};
4use std::time::{Duration, Instant};
5
6use fallow_config::{AuditGate, OutputFormat};
7use fallow_engine::clear_ambient_git_env;
8use rustc_hash::{FxHashMap, FxHashSet};
9use xxhash_rust::xxh3::xxh3_64;
10
11pub use fallow_api::{AuditAttribution, AuditSummary, AuditVerdict};
12
13#[cfg(test)]
14use crate::base_worktree::git_rev_parse;
15use crate::base_worktree::{
16    BaseWorktree, git_toplevel, resolve_cache_max_age, sweep_old_reusable_caches,
17};
18use crate::check::{CheckOptions, CheckResult, IssueFilters, TraceOptions};
19use crate::dupes::{DupesMode, DupesOptions, DupesResult};
20use crate::error::emit_error;
21use crate::health::{HealthOptions, HealthResult};
22
23/// Full audit result containing verdict, summary, and sub-results.
24pub struct AuditResult {
25    pub verdict: AuditVerdict,
26    pub summary: AuditSummary,
27    pub attribution: AuditAttribution,
28    /// Key snapshot of the base ref for new-vs-inherited attribution. `None`
29    /// when the base pass was skipped (`--gate all`) or unavailable. Exposed at
30    /// crate scope so test fixtures in sibling modules can construct an
31    /// `AuditResult` with `base_snapshot: None`.
32    pub base_snapshot: Option<AuditKeySnapshot>,
33    pub base_snapshot_skipped: bool,
34    pub changed_files_count: usize,
35    /// Absolute paths of the files this run re-analyzed. Threaded into the
36    /// Fallow Impact per-finding attribution so the frontier diff knows which
37    /// files were authoritative this run.
38    pub changed_files: Vec<PathBuf>,
39    pub base_ref: String,
40    /// Human-readable provenance of `base_ref` for the scope line, e.g.
41    /// `merge-base with origin/main`. `None` for an explicit `--base` (the ref
42    /// the user typed is already self-describing). Not serialized; the JSON
43    /// envelope carries the resolved `base_ref` directly.
44    pub base_description: Option<String>,
45    pub head_sha: Option<String>,
46    pub output: OutputFormat,
47    pub performance: bool,
48    pub check: Option<CheckResult>,
49    pub dupes: Option<DupesResult>,
50    pub health: Option<HealthResult>,
51    pub elapsed: Duration,
52    /// Review-brief data, populated only on the brief path. The deltas are
53    /// computed from the head sets vs the base snapshot; weakening + routing are
54    /// computed from git over the changed files. `None` off the brief path.
55    pub review_deltas: Option<crate::audit_brief::ReviewDeltas>,
56    pub weakening_signals: Vec<weakening::WeakeningSignal>,
57    pub routing: Option<routing::RoutingFacts>,
58    /// Decision surface (the apex): the ranked, capped, signal_id-anchored set
59    /// of consequential structural decisions, each framed as a judgment question.
60    /// Populated only on the brief path; `None` otherwise.
61    pub decision_surface: Option<crate::audit_decision_surface::DecisionSurface>,
62    /// Deterministic graph-snapshot hash: a stable hash of the relevant HEAD
63    /// graph + diff state (the six key sets plus the resolved base ref + head
64    /// sha). Pinned into the walkthrough guide digest so a stale agent JSON
65    /// (whose echoed hash != this) is REFUSED on reentry. The verifier is the
66    /// graph: a mutated tree changes a key set, changes this hash, refuses the
67    /// stale payload. Populated only on the brief path; `None` otherwise.
68    pub graph_snapshot_hash: Option<String>,
69    /// Per-hunk change anchors derived from the diff: one stable, content-
70    /// addressed id per changed region. Emitted in the walkthrough guide so an
71    /// agent can anchor a trade-off about a changed region with no graph finding
72    /// (and have it post-validated). Also folded into `graph_snapshot_hash` so a
73    /// moved region refuses a stale payload. Populated only on the brief path.
74    pub change_anchors: Vec<crate::audit_walkthrough::ChangeAnchor>,
75}
76
77pub struct AuditOptions<'a> {
78    pub root: &'a std::path::Path,
79    pub config_path: &'a Option<std::path::PathBuf>,
80    pub cache_dir: &'a std::path::Path,
81    pub output: OutputFormat,
82    pub no_cache: bool,
83    pub threads: usize,
84    pub quiet: bool,
85    pub changed_since: Option<&'a str>,
86    pub production: bool,
87    pub production_dead_code: Option<bool>,
88    pub production_health: Option<bool>,
89    pub production_dupes: Option<bool>,
90    pub workspace: Option<&'a [String]>,
91    pub changed_workspaces: Option<&'a str>,
92    pub explain: bool,
93    pub explain_skipped: bool,
94    pub performance: bool,
95    pub group_by: Option<crate::GroupBy>,
96    /// Baseline file for dead-code analysis (as produced by `fallow dead-code --save-baseline`).
97    pub dead_code_baseline: Option<&'a std::path::Path>,
98    /// Baseline file for health analysis (as produced by `fallow health --save-baseline`).
99    pub health_baseline: Option<&'a std::path::Path>,
100    /// Baseline file for duplication analysis (as produced by `fallow dupes --save-baseline`).
101    pub dupes_baseline: Option<&'a std::path::Path>,
102    /// Maximum CRAP score threshold (overrides `health.maxCrap` from config).
103    /// Functions meeting or exceeding this score cause audit to fail.
104    pub max_crap: Option<f64>,
105    /// Istanbul coverage input for accurate CRAP scoring in the health sub-pass.
106    pub coverage: Option<&'a std::path::Path>,
107    /// Prefix to strip from Istanbul source paths before rebasing to `root`.
108    pub coverage_root: Option<&'a std::path::Path>,
109    pub gate: AuditGate,
110    /// Report unused exports in entry files (forwarded to the dead-code sub-pass).
111    pub include_entry_exports: bool,
112    /// Paid runtime-coverage sidecar input (V8 directory, V8 JSON, or
113    /// Istanbul coverage map). Forwarded into the embedded health pass so
114    /// audit surfaces the `hot-path-touched` verdict alongside dead-code
115    /// and complexity findings without requiring a second `fallow health`
116    /// invocation in CI.
117    pub runtime_coverage: Option<&'a std::path::Path>,
118    /// Threshold for hot-path classification, forwarded to the sidecar.
119    pub min_invocations_hot: u64,
120    /// Render the deterministic, always-exit-0 review brief (`fallow audit
121    /// --brief` / `fallow review`) instead of the gating audit report. The
122    /// audit analysis still runs and the verdict is still computed and carried
123    /// informationally; it just never drives the exit code on this path.
124    pub brief: bool,
125    /// Decision-surface cap (the working-memory limit). Default 4; clamped to
126    /// [3, 5] (4 plus or minus 1) by the extractor. Only consulted on the brief
127    /// path.
128    pub max_decisions: usize,
129    /// Emit the agent-contract walkthrough GUIDE (digest + schema + graph-
130    /// snapshot pin) instead of the brief body. Implies `brief`. Always exit 0.
131    pub walkthrough_guide: bool,
132    /// Render the existing walkthrough guide as a staged human or markdown tour.
133    /// Implies `brief`. Always exit 0.
134    pub walkthrough: bool,
135    /// Changed files to record as viewed in the local walkthrough state ledger
136    /// before rendering the tour. Empty off the walkthrough path.
137    pub mark_viewed: &'a [std::path::PathBuf],
138    /// Expand the Cleared panel in the human or markdown walkthrough tour.
139    pub show_cleared: bool,
140    /// Path to an agent's judgment JSON to POST-VALIDATE against the live
141    /// graph. Implies `brief`. Always exit 0. `None` off the walkthrough path.
142    pub walkthrough_file: Option<&'a std::path::Path>,
143    /// Expand the de-prioritized units in the human focus map ("show me what
144    /// you de-prioritized"). The `deprioritized` list is ALWAYS in the JSON
145    /// regardless; this only re-expands the human render (collapse-by-default).
146    /// Only consulted on the brief path.
147    pub show_deprioritized: bool,
148}
149
150#[path = "audit_base_ref.rs"]
151mod base_ref;
152#[path = "audit_cache.rs"]
153mod cache;
154
155#[cfg(test)]
156use base_ref::{auto_detect_base_ref, parse_audit_base_override};
157use base_ref::{get_head_sha, resolve_base_ref};
158#[cfg(test)]
159use cache::{
160    AUDIT_BASE_SNAPSHOT_CACHE_VERSION, CachedAuditKeySnapshot, audit_base_snapshot_cache_dir,
161    audit_base_snapshot_cache_file, cached_from_snapshot, config_file_fingerprint,
162    ensure_audit_base_snapshot_cache_dir, snapshot_from_cached,
163};
164use cache::{
165    AuditBaseSnapshotCacheKey, audit_base_snapshot_cache_key, load_cached_base_snapshot,
166    save_cached_base_snapshot, sorted_keys,
167};
168
169fn compute_verdict(
170    check: Option<&CheckResult>,
171    dupes: Option<&DupesResult>,
172    health: Option<&HealthResult>,
173) -> AuditVerdict {
174    let mut has_errors = false;
175    let mut has_warnings = false;
176
177    if let Some(result) = check {
178        if crate::check::has_error_severity_issues(
179            &result.results,
180            &result.config.rules,
181            Some(&result.config),
182        ) {
183            has_errors = true;
184        } else if result.results.total_issues() > 0 {
185            has_warnings = true;
186        }
187    }
188
189    if let Some(result) = health
190        && !result.report.findings.is_empty()
191    {
192        has_errors = true;
193    }
194
195    if let Some(result) = dupes
196        && !result.report.clone_groups.is_empty()
197    {
198        if result.threshold > 0.0 && result.report.stats.duplication_percentage > result.threshold {
199            has_errors = true;
200        } else {
201            has_warnings = true;
202        }
203    }
204
205    if has_errors {
206        AuditVerdict::Fail
207    } else if has_warnings {
208        AuditVerdict::Warn
209    } else {
210        AuditVerdict::Pass
211    }
212}
213
214fn build_summary(
215    check: Option<&CheckResult>,
216    dupes: Option<&DupesResult>,
217    health: Option<&HealthResult>,
218) -> AuditSummary {
219    let dead_code_issues = check.map_or(0, |r| r.results.total_issues());
220    let dead_code_has_errors = check.is_some_and(|r| {
221        crate::check::has_error_severity_issues(&r.results, &r.config.rules, Some(&r.config))
222    });
223    let complexity_findings = health.map_or(0, |r| r.report.findings.len());
224    let max_cyclomatic = health.and_then(|r| r.report.findings.iter().map(|f| f.cyclomatic).max());
225    let duplication_clone_groups = dupes.map_or(0, |r| r.report.clone_groups.len());
226
227    AuditSummary {
228        dead_code_issues,
229        dead_code_has_errors,
230        complexity_findings,
231        max_cyclomatic,
232        duplication_clone_groups,
233    }
234}
235
236fn compute_audit_attribution(
237    check: Option<&CheckResult>,
238    dupes: Option<&DupesResult>,
239    health: Option<&HealthResult>,
240    base: Option<&AuditKeySnapshot>,
241    gate: AuditGate,
242) -> AuditAttribution {
243    let dead_code = check
244        .map(|r| {
245            count_introduced(
246                &dead_code_keys(&r.results, &r.config.root),
247                base.map(|b| &b.dead_code),
248            )
249        })
250        .unwrap_or_default();
251    let complexity = health
252        .map(|r| {
253            count_introduced(
254                &health_keys(&r.report, &r.config.root),
255                base.map(|b| &b.health),
256            )
257        })
258        .unwrap_or_default();
259    let duplication = dupes
260        .map(|r| {
261            count_introduced(
262                &dupes_keys(&r.report, &r.config.root),
263                base.map(|b| &b.dupes),
264            )
265        })
266        .unwrap_or_default();
267
268    AuditAttribution {
269        gate,
270        dead_code_introduced: dead_code.0,
271        dead_code_inherited: dead_code.1,
272        complexity_introduced: complexity.0,
273        complexity_inherited: complexity.1,
274        duplication_introduced: duplication.0,
275        duplication_inherited: duplication.1,
276    }
277}
278
279fn compute_introduced_verdict(
280    check: Option<&CheckResult>,
281    dupes: Option<&DupesResult>,
282    health: Option<&HealthResult>,
283    base: Option<&AuditKeySnapshot>,
284) -> AuditVerdict {
285    let mut has_errors = false;
286    let mut has_warnings = false;
287
288    if let Some(result) = check {
289        let (errors, warnings) = introduced_check_verdict(result, base);
290        has_errors |= errors;
291        has_warnings |= warnings;
292    }
293    if let Some(result) = health {
294        has_errors |= introduced_health_has_errors(result, base);
295    }
296    if let Some(result) = dupes {
297        let (errors, warnings) = introduced_dupes_verdict(result, base);
298        has_errors |= errors;
299        has_warnings |= warnings;
300    }
301
302    if has_errors {
303        AuditVerdict::Fail
304    } else if has_warnings {
305        AuditVerdict::Warn
306    } else {
307        AuditVerdict::Pass
308    }
309}
310
311/// Error/warning contribution from dead-code issues introduced since the base.
312fn introduced_check_verdict(result: &CheckResult, base: Option<&AuditKeySnapshot>) -> (bool, bool) {
313    let base_keys = base.map(|b| &b.dead_code);
314    let mut introduced = result.results.clone();
315    retain_introduced_dead_code(&mut introduced, &result.config.root, base_keys);
316    if crate::check::has_error_severity_issues(
317        &introduced,
318        &result.config.rules,
319        Some(&result.config),
320    ) {
321        (true, false)
322    } else if introduced.total_issues() > 0 {
323        (false, true)
324    } else {
325        (false, false)
326    }
327}
328
329/// True when complexity findings were introduced since the base (always an error).
330fn introduced_health_has_errors(result: &HealthResult, base: Option<&AuditKeySnapshot>) -> bool {
331    let base_keys = base.map(|b| &b.health);
332    result.report.findings.iter().any(|finding| {
333        !base_keys
334            .is_some_and(|keys| keys.contains(&health_finding_key(finding, &result.config.root)))
335    })
336}
337
338/// Error/warning contribution from clone groups introduced since the base.
339fn introduced_dupes_verdict(result: &DupesResult, base: Option<&AuditKeySnapshot>) -> (bool, bool) {
340    let base_keys = base.map(|b| &b.dupes);
341    let introduced = result
342        .report
343        .clone_groups
344        .iter()
345        .filter(|group| {
346            !base_keys
347                .is_some_and(|keys| keys.contains(&dupe_group_key(group, &result.config.root)))
348        })
349        .count();
350    if introduced == 0 {
351        return (false, false);
352    }
353    if result.threshold > 0.0 && result.report.stats.duplication_percentage > result.threshold {
354        (true, false)
355    } else {
356        (false, true)
357    }
358}
359
360pub struct AuditKeySnapshot {
361    dead_code: FxHashSet<String>,
362    health: FxHashSet<String>,
363    dupes: FxHashSet<String>,
364    /// Review-brief delta substrate (populated only on the brief path; empty
365    /// otherwise). Cross-zone boundary EDGE keys (`<from_zone>->-<to_zone>`),
366    /// one per distinct zone pair (R2 first-edge-only framing).
367    boundary_edges: FxHashSet<String>,
368    /// Canonical circular-dependency keys (rotation-independent file set).
369    cycles: FxHashSet<String>,
370    /// Exports-aware public-export keys (`<rel_path>::<name>`), the surface
371    /// reachable through `package.json` `exports` + re-export reachability.
372    public_api: FxHashSet<String>,
373}
374
375fn count_introduced(keys: &FxHashSet<String>, base: Option<&FxHashSet<String>>) -> (usize, usize) {
376    let Some(base) = base else {
377        return (0, 0);
378    };
379    keys.iter().fold((0, 0), |(introduced, inherited), key| {
380        if base.contains(key) {
381            (introduced, inherited + 1)
382        } else {
383            (introduced + 1, inherited)
384        }
385    })
386}
387
388/// If fallow's process inherited any ambient git repo-state env vars (typical
389/// when invoked from a `pre-commit` / `pre-push` hook or a tool wrapping git),
390/// surface the most likely culprit so a user hitting an unexpected worktree
391/// failure can short-circuit the diagnosis. Returns `None` otherwise.
392fn ambient_git_env_hint() -> Option<String> {
393    use fallow_engine::AMBIENT_GIT_ENV_VARS;
394    for var in AMBIENT_GIT_ENV_VARS {
395        if let Ok(value) = std::env::var(var)
396            && !value.is_empty()
397        {
398            return Some(format!(
399                "{var}={value} is set in the environment; if fallow is being \
400invoked from a git hook this can interfere with worktree operations. Re-run \
401with `env -u {var} fallow audit` to confirm."
402            ));
403        }
404    }
405    None
406}
407
408fn compute_base_snapshot(
409    opts: &AuditOptions<'_>,
410    base_ref: &str,
411    changed_files: &FxHashSet<PathBuf>,
412    base_sha: Option<&str>,
413) -> Result<AuditKeySnapshot, ExitCode> {
414    let Some(worktree) = BaseWorktree::create(opts.root, base_ref, base_sha) else {
415        use std::fmt::Write as _;
416        let mut message =
417            format!("could not create a temporary worktree for base ref '{base_ref}'");
418        if let Some(hint) = ambient_git_env_hint() {
419            let _ = write!(message, "\n  hint: {hint}");
420        }
421        return Err(emit_error(&message, 2, opts.output));
422    };
423    let base_root = base_analysis_root(opts.root, worktree.path());
424    let base_cache_dir = remap_cache_dir_for_base_worktree(opts.root, &base_root, opts.cache_dir);
425    let current_config_path = opts
426        .config_path
427        .clone()
428        .or_else(|| fallow_config::FallowConfig::find_config_path(opts.root));
429    let base_opts =
430        build_base_audit_options(opts, &base_root, &current_config_path, &base_cache_dir);
431
432    let base_changed_files = remap_focus_files(changed_files, opts.root, &base_root);
433    let check_production = opts.production_dead_code.unwrap_or(opts.production);
434    let health_production = opts.production_health.unwrap_or(opts.production);
435    let share_dead_code_parse_with_health = check_production == health_production;
436
437    let (check_res, dupes_res) = rayon::join(
438        || run_audit_check(&base_opts, None, share_dead_code_parse_with_health),
439        || run_audit_dupes(&base_opts, None, base_changed_files.as_ref(), None),
440    );
441    let mut check = check_res?;
442    let dupes = dupes_res?;
443    // Compute the exports-aware public-export set against the BASE graph while it
444    // is still retained on the check result, BEFORE health consumes it. The
445    // public_api delta is brief-only, so this only runs on the brief path.
446    let base_public_api = if opts.brief {
447        public_api_keys_from_check(check.as_ref(), &base_root)
448    } else {
449        FxHashSet::default()
450    };
451    let shared_parse = if share_dead_code_parse_with_health {
452        check.as_mut().and_then(|r| r.shared_parse.take())
453    } else {
454        None
455    };
456    let health = run_audit_health(&base_opts, None, shared_parse)?;
457    if let Some(ref mut check) = check {
458        check.shared_parse = None;
459    }
460
461    Ok(snapshot_from_results(
462        check.as_ref(),
463        dupes.as_ref(),
464        health.as_ref(),
465        base_public_api,
466    ))
467}
468
469/// Build an `AuditKeySnapshot` of dead-code/health/dupes keys from analysis
470/// results. `public_api` is the exports-aware public-export key set, computed by
471/// the caller from the retained graph BEFORE it is dropped (empty off the brief
472/// path). Boundary-edge and cycle delta keys are derived directly from the
473/// dead-code results, so they are always available.
474fn snapshot_from_results(
475    check: Option<&CheckResult>,
476    dupes: Option<&DupesResult>,
477    health: Option<&HealthResult>,
478    public_api: FxHashSet<String>,
479) -> AuditKeySnapshot {
480    let (boundary_edges, cycles) = check.map_or_else(
481        || (FxHashSet::default(), FxHashSet::default()),
482        |r| {
483            (
484                review_deltas::boundary_edge_keys(&r.results.boundary_violations),
485                review_deltas::cycle_keys(&r.results.circular_dependencies, &r.config.root),
486            )
487        },
488    );
489    AuditKeySnapshot {
490        dead_code: check.map_or_else(FxHashSet::default, |r| {
491            dead_code_keys(&r.results, &r.config.root)
492        }),
493        health: health.map_or_else(FxHashSet::default, |r| {
494            health_keys(&r.report, &r.config.root)
495        }),
496        dupes: dupes.map_or_else(FxHashSet::default, |r| {
497            dupes_keys(&r.report, &r.config.root)
498        }),
499        boundary_edges,
500        cycles,
501        public_api,
502    }
503}
504
505/// Compute the exports-aware public-export key set from a check result's retained
506/// graph. Returns an empty set when the graph was not retained (off the brief
507/// path) so non-brief base snapshots stay cheap. Loads the root `package.json`
508/// and discovers workspaces so the exports-aware entry resolution (R4) can run.
509fn public_api_keys_from_check(check: Option<&CheckResult>, root: &Path) -> FxHashSet<String> {
510    let Some(check) = check else {
511        return FxHashSet::default();
512    };
513    let Some(graph) = check
514        .shared_parse
515        .as_ref()
516        .and_then(|sp| sp.analysis_output.as_ref())
517        .and_then(|out| out.graph.as_ref())
518    else {
519        return FxHashSet::default();
520    };
521    let root_pkg = fallow_config::PackageJson::load(&check.config.root.join("package.json")).ok();
522    let workspaces = fallow_config::discover_workspaces(&check.config.root);
523    review_deltas::public_export_keys_for(
524        graph,
525        &check.config,
526        root_pkg.as_ref(),
527        &workspaces,
528        root,
529    )
530}
531
532/// Build the `AuditOptions` for the isolated base-worktree analysis pass.
533#[expect(
534    clippy::ref_option,
535    reason = "AuditOptions.config_path is &Option<PathBuf>; the borrow is stored into the returned struct"
536)]
537fn build_base_audit_options<'a>(
538    opts: &AuditOptions<'a>,
539    base_root: &'a Path,
540    current_config_path: &'a Option<PathBuf>,
541    base_cache_dir: &'a Path,
542) -> AuditOptions<'a> {
543    AuditOptions {
544        root: base_root,
545        config_path: current_config_path,
546        cache_dir: base_cache_dir,
547        output: opts.output,
548        no_cache: opts.no_cache,
549        threads: opts.threads,
550        quiet: true,
551        changed_since: None,
552        production: opts.production,
553        production_dead_code: opts.production_dead_code,
554        production_health: opts.production_health,
555        production_dupes: opts.production_dupes,
556        workspace: opts.workspace,
557        changed_workspaces: None,
558        explain: false,
559        explain_skipped: false,
560        performance: false,
561        group_by: opts.group_by,
562        dead_code_baseline: None,
563        health_baseline: None,
564        dupes_baseline: None,
565        max_crap: opts.max_crap,
566        coverage: opts.coverage,
567        coverage_root: opts.coverage_root,
568        gate: AuditGate::All,
569        include_entry_exports: opts.include_entry_exports,
570        runtime_coverage: None,
571        min_invocations_hot: opts.min_invocations_hot,
572        brief: false,
573        max_decisions: 4,
574        walkthrough_guide: false,
575        walkthrough: false,
576        mark_viewed: &[],
577        show_cleared: false,
578        walkthrough_file: None,
579        show_deprioritized: false,
580    }
581}
582
583fn base_analysis_root(current_root: &Path, base_worktree_root: &Path) -> PathBuf {
584    let Some(git_root) = git_toplevel(current_root) else {
585        return base_worktree_root.to_path_buf();
586    };
587    let current_root =
588        dunce::canonicalize(current_root).unwrap_or_else(|_| current_root.to_path_buf());
589    match current_root.strip_prefix(&git_root) {
590        Ok(relative) => base_worktree_root.join(relative),
591        Err(err) => {
592            tracing::warn!(
593                current_root = %current_root.display(),
594                git_root = %git_root.display(),
595                error = %err,
596                "Could not remap audit base root into the base worktree; falling back to worktree root"
597            );
598            base_worktree_root.to_path_buf()
599        }
600    }
601}
602
603fn current_keys_as_base_keys(
604    check: Option<&CheckResult>,
605    dupes: Option<&DupesResult>,
606    health: Option<&HealthResult>,
607) -> AuditKeySnapshot {
608    // Reuse path (no behavioral change vs base): head IS base, so the delta
609    // sets are the head's own keys, which makes every head-minus-base delta
610    // empty. `public_api_keys` is the head set already computed on the brief
611    // path; the boundary/cycle keys come from the head results.
612    let public_api = check
613        .and_then(|r| r.public_api_keys.clone())
614        .unwrap_or_default();
615    snapshot_from_results(check, dupes, health, public_api)
616}
617
618fn can_reuse_current_as_base(
619    opts: &AuditOptions<'_>,
620    base_ref: &str,
621    changed_files: &FxHashSet<PathBuf>,
622) -> bool {
623    let Some(git_root) = git_toplevel(opts.root) else {
624        return false;
625    };
626    let cache_dir = opts.cache_dir.to_path_buf();
627    let canonical_cache_dir = dunce::canonicalize(&cache_dir).ok();
628    // Spawn the batched base-file reader lazily: a changeset of only cache
629    // artifacts or docs never touches git, so it spawns zero processes.
630    let mut reader: Option<BaseFileReader> = None;
631    for path in changed_files {
632        if is_fallow_cache_artifact(path, &cache_dir, canonical_cache_dir.as_deref()) {
633            continue;
634        }
635        if !is_analysis_input(path) {
636            if is_non_behavioral_doc(path) {
637                continue;
638            }
639            return false;
640        }
641        let Ok(current) = std::fs::read_to_string(path) else {
642            return false;
643        };
644        let Ok(relative) = path.strip_prefix(&git_root) else {
645            return false;
646        };
647        let reader = match reader.as_mut() {
648            Some(reader) => reader,
649            None => {
650                let Some(spawned) = BaseFileReader::spawn(opts.root) else {
651                    return false;
652                };
653                reader.insert(spawned)
654            }
655        };
656        let Some(base) = reader.read(base_ref, relative) else {
657            return false;
658        };
659        if current == base {
660            continue;
661        }
662        if !js_ts_tokens_equivalent(path, &current, &base) {
663            return false;
664        }
665    }
666    true
667}
668
669/// A long-lived `git cat-file --batch` child process used to read the base
670/// version of changed files without spawning one `git show` per file.
671///
672/// Requests and responses are strictly lockstep (one request line, one
673/// response) to avoid pipe-buffer deadlock. Per-file comparison semantics are
674/// byte-identical to the previous `git show` path: a missing object yields
675/// `None` (treated as not reusable), and content is read with lossy UTF-8
676/// conversion to match `String::from_utf8_lossy`.
677///
678/// The child is owned through a [`ScopedChild`](crate::signal::ScopedChild) so
679/// an interrupt (SIGINT/SIGTERM) during a large reuse loop kills the long-lived
680/// `cat-file` process via the signal registry instead of orphaning it.
681struct BaseFileReader {
682    /// The registered `cat-file --batch` child. Wrapped in `Option` so `Drop`
683    /// can `take()` it and call the consuming `ScopedChild::wait` after closing
684    /// stdin, reaping the child and deregistering its PID.
685    child: Option<crate::signal::ScopedChild>,
686    /// Wrapped in `Option` so `Drop` can `take()` and drop it explicitly,
687    /// closing the pipe before the blocking wait (which would otherwise block).
688    stdin: Option<std::process::ChildStdin>,
689    stdout: std::io::BufReader<std::process::ChildStdout>,
690}
691
692impl BaseFileReader {
693    /// Spawn a single `git cat-file --batch` process rooted at `root`.
694    ///
695    /// Returns `None` on spawn failure or if the child's stdio pipes are
696    /// unavailable; the caller then degrades to "not reusable" (returns
697    /// `false`), mirroring the previous per-file `git show` failure behavior.
698    fn spawn(root: &Path) -> Option<Self> {
699        let mut command = Command::new("git");
700        command
701            .args(["cat-file", "--batch"])
702            .current_dir(root)
703            .stdin(std::process::Stdio::piped())
704            .stdout(std::process::Stdio::piped())
705            .stderr(std::process::Stdio::null());
706        clear_ambient_git_env(&mut command);
707        let mut child = crate::signal::ScopedChild::spawn(&mut command).ok()?;
708        let stdin = child.take_stdin()?;
709        let stdout = child.take_stdout()?;
710        Some(Self {
711            child: Some(child),
712            stdin: Some(stdin),
713            stdout: std::io::BufReader::new(stdout),
714        })
715    }
716
717    /// Read the base version of `relative` at `base_ref`.
718    ///
719    /// Writes one `<base_ref>:<path>` request line (forward-slash separators)
720    /// and reads exactly one response in lockstep. Returns `None` if the object
721    /// is missing (the ` missing` header path), on any parse or IO error, or if
722    /// the path contains a newline (which would corrupt the request stream).
723    fn read(&mut self, base_ref: &str, relative: &Path) -> Option<String> {
724        use std::io::{BufRead, Read};
725
726        let relative = relative.to_string_lossy().replace('\\', "/");
727        // A newline in the path cannot be expressed as a single batch request
728        // line; treat it as not reusable rather than writing a corrupt request.
729        if relative.contains('\n') {
730            return None;
731        }
732
733        let stdin = self.stdin.as_mut()?;
734        writeln!(stdin, "{base_ref}:{relative}").ok()?;
735        stdin.flush().ok()?;
736
737        let mut header = String::new();
738        if self.stdout.read_line(&mut header).ok()? == 0 {
739            return None;
740        }
741        // `git cat-file --batch` reports a missing object as `<spec> missing\n`.
742        if header.trim_end().ends_with(" missing") {
743            return None;
744        }
745        // Otherwise the header is `<oid> <type> <size>\n`; parse the size.
746        let size: usize = header.trim_end().rsplit(' ').next()?.parse().ok()?;
747        let mut buf = vec![0u8; size];
748        self.stdout.read_exact(&mut buf).ok()?;
749        // Consume the single trailing newline that follows the object content.
750        // An off-by-one here corrupts every subsequent read in the batch.
751        let mut newline = [0u8; 1];
752        self.stdout.read_exact(&mut newline).ok()?;
753
754        Some(String::from_utf8_lossy(&buf).into_owned())
755    }
756}
757
758impl Drop for BaseFileReader {
759    fn drop(&mut self) {
760        // Close stdin so the child sees EOF and exits, then reap it through the
761        // ScopedChild's blocking `wait` (which also deregisters the PID from the
762        // signal registry). Dropping the `ChildStdin` closes the pipe; doing
763        // this before the wait prevents it from blocking.
764        self.stdin.take();
765        if let Some(child) = self.child.take() {
766            let _ = child.wait();
767        }
768    }
769}
770
771fn is_fallow_cache_artifact(
772    path: &Path,
773    cache_dir: &Path,
774    canonical_cache_dir: Option<&Path>,
775) -> bool {
776    path.starts_with(cache_dir)
777        || canonical_cache_dir.is_some_and(|canonical| path.starts_with(canonical))
778}
779
780fn remap_cache_dir_for_base_worktree(
781    current_root: &Path,
782    base_worktree_root: &Path,
783    cache_dir: &Path,
784) -> PathBuf {
785    if cache_dir.is_absolute()
786        && let Ok(relative) = cache_dir.strip_prefix(current_root)
787    {
788        return base_worktree_root.join(relative);
789    }
790    cache_dir.to_path_buf()
791}
792
793fn is_analysis_input(path: &Path) -> bool {
794    matches!(
795        path.extension().and_then(|ext| ext.to_str()),
796        Some(
797            "js" | "jsx"
798                | "ts"
799                | "tsx"
800                | "mjs"
801                | "mts"
802                | "cjs"
803                | "cts"
804                | "vue"
805                | "svelte"
806                | "astro"
807                | "mdx"
808                | "css"
809                | "scss"
810        )
811    )
812}
813
814fn is_non_behavioral_doc(path: &Path) -> bool {
815    matches!(
816        path.extension().and_then(|ext| ext.to_str()),
817        Some("md" | "markdown" | "txt" | "rst" | "adoc")
818    )
819}
820
821fn js_ts_tokens_equivalent(path: &Path, current: &str, base: &str) -> bool {
822    if current.contains("fallow-ignore") || base.contains("fallow-ignore") {
823        return false;
824    }
825    if !matches!(
826        path.extension().and_then(|ext| ext.to_str()),
827        Some("js" | "jsx" | "ts" | "tsx" | "mjs" | "mts" | "cjs" | "cts")
828    ) {
829        return false;
830    }
831    fallow_engine::source_token_kinds_equivalent(path, current, base, false)
832}
833
834fn remap_focus_files(
835    files: &FxHashSet<PathBuf>,
836    from_root: &Path,
837    to_root: &Path,
838) -> Option<FxHashSet<PathBuf>> {
839    let mut remapped = FxHashSet::default();
840    for file in files {
841        if let Ok(relative) = file.strip_prefix(from_root) {
842            remapped.insert(to_root.join(relative));
843        }
844    }
845    if remapped.is_empty() {
846        return None;
847    }
848    Some(remapped)
849}
850
851#[cfg(test)]
852use std::time::SystemTime;
853
854#[cfg(test)]
855use crate::base_worktree::{
856    ReusableWorktreeLock, WorktreeCleanupGuard, audit_worktree_pid, days_to_duration,
857    is_fallow_audit_worktree_path, is_reusable_audit_worktree_path, list_audit_worktrees,
858    materialize_base_dependency_context, parse_worktree_list, paths_equal, process_is_alive,
859    remove_audit_worktree, reusable_worktree_last_used_path, reusable_worktree_lock_path,
860    sweep_orphan_audit_worktrees, touch_last_used,
861};
862
863pub use fallow_api::audit_keys as keys;
864
865#[path = "audit_review_deltas.rs"]
866pub mod review_deltas;
867
868#[path = "audit_weakening.rs"]
869pub mod weakening;
870
871#[path = "audit_routing.rs"]
872pub mod routing;
873
874use keys::{
875    dead_code_keys, dupe_group_key, dupes_keys, health_finding_key, health_keys,
876    retain_introduced_dead_code,
877};
878
879struct HeadAnalyses {
880    check: Option<CheckResult>,
881    dupes: Option<DupesResult>,
882    health: Option<HealthResult>,
883}
884
885/// HEAD analyses result paired with an optional freshly computed base snapshot
886/// (present only when a real base worktree was run in parallel).
887type HeadAndBaseResult = (
888    Result<HeadAnalyses, ExitCode>,
889    Option<Result<AuditKeySnapshot, ExitCode>>,
890);
891
892/// Run the HEAD analyses, optionally alongside a fresh base snapshot via
893/// `rayon::join` when `run_base` is set. Mirrors the previous inline branch.
894fn run_audit_head_and_base(
895    opts: &AuditOptions<'_>,
896    changed_since: Option<&str>,
897    changed_files: &FxHashSet<PathBuf>,
898    base_ref: &str,
899    base_cache_key: Option<&AuditBaseSnapshotCacheKey>,
900    run_base: bool,
901) -> HeadAndBaseResult {
902    if run_base {
903        let base_sha = base_cache_key.map(|key| key.base_sha.as_str());
904        let (h, b) = rayon::join(
905            || run_audit_head_analyses(opts, changed_since, changed_files),
906            || compute_base_snapshot(opts, base_ref, changed_files, base_sha),
907        );
908        (h, Some(b))
909    } else {
910        (
911            run_audit_head_analyses(opts, changed_since, changed_files),
912            None,
913        )
914    }
915}
916
917struct AuditResultParts {
918    verdict: AuditVerdict,
919    summary: AuditSummary,
920    attribution: AuditAttribution,
921    base_snapshot: Option<AuditKeySnapshot>,
922    base_snapshot_skipped: bool,
923    changed_files_count: usize,
924    changed_files: FxHashSet<PathBuf>,
925    base_ref: String,
926    base_description: Option<String>,
927    head_sha: Option<String>,
928    output: OutputFormat,
929    performance: bool,
930    check: Option<CheckResult>,
931    dupes: Option<DupesResult>,
932    health: Option<HealthResult>,
933    elapsed: Duration,
934    review_deltas: Option<crate::audit_brief::ReviewDeltas>,
935    weakening_signals: Vec<weakening::WeakeningSignal>,
936    routing: Option<routing::RoutingFacts>,
937    decision_surface: Option<crate::audit_decision_surface::DecisionSurface>,
938    graph_snapshot_hash: Option<String>,
939    change_anchors: Vec<crate::audit_walkthrough::ChangeAnchor>,
940}
941
942/// Run the three HEAD-side analyses with intra-pipeline sharing intact:
943/// check first (so its parsed modules are available), then dupes (which can
944/// reuse check's discovered file list when production settings match), then
945/// health (which can reuse check's parsed modules when production settings
946/// match). Designed to be called from inside `rayon::join` alongside
947/// [`compute_base_snapshot`], which operates on an isolated worktree.
948fn run_audit_head_analyses(
949    opts: &AuditOptions<'_>,
950    changed_since: Option<&str>,
951    changed_files: &FxHashSet<PathBuf>,
952) -> Result<HeadAnalyses, ExitCode> {
953    let check_production = opts.production_dead_code.unwrap_or(opts.production);
954    let health_production = opts.production_health.unwrap_or(opts.production);
955    let dupes_production = opts.production_dupes.unwrap_or(opts.production);
956    let share_dead_code_parse_with_health = check_production == health_production;
957    let share_dead_code_files_with_dupes =
958        share_dead_code_parse_with_health && check_production == dupes_production;
959
960    let mut check = run_audit_check(opts, changed_since, share_dead_code_parse_with_health)?;
961    let dupes_files = if share_dead_code_files_with_dupes {
962        check
963            .as_ref()
964            .and_then(|r| r.shared_parse.as_ref().map(|sp| sp.files.clone()))
965    } else {
966        None
967    };
968    let dupes = run_audit_dupes(opts, changed_since, Some(changed_files), dupes_files)?;
969    // Compute the impact closure AND the exports-aware public-export key
970    // set for the review brief BEFORE health consumes the shared parse (which
971    // owns the retained graph). Both are stored on the check result so they
972    // survive the graph drop.
973    if opts.brief
974        && let Some(ref mut check) = check
975    {
976        check.impact_closure = compute_brief_impact_closure(opts.root, check, changed_files);
977        check.public_api_keys = Some(public_api_keys_from_check(Some(check), opts.root));
978        check.partition_order = compute_brief_partition_order(opts.root, check, changed_files);
979        check.focus_facts = compute_brief_focus_facts(opts.root, check, changed_files);
980        check.export_lines = compute_brief_export_lines(opts.root, check, changed_files);
981        check.internal_consumers =
982            compute_brief_internal_consumers(opts.root, check, changed_files);
983    }
984    let shared_parse = if share_dead_code_parse_with_health {
985        check.as_mut().and_then(|r| r.shared_parse.take())
986    } else {
987        None
988    };
989    let health = run_audit_health(opts, changed_since, shared_parse)?;
990    Ok(HeadAnalyses {
991        check,
992        dupes,
993        health,
994    })
995}
996
997/// Compute the impact closure for the review brief from the check result's
998/// retained graph against the changed-file set.
999///
1000/// Delegates changed-path resolution and graph traversal to the engine, then
1001/// returns `{ in_diff, affected_not_shown, coordination_gap }`. Returns `None`
1002/// when the graph was not retained (off the brief path) or no changed file maps
1003/// to a known module.
1004fn compute_brief_impact_closure(
1005    root: &std::path::Path,
1006    check: &CheckResult,
1007    changed_files: &FxHashSet<PathBuf>,
1008) -> Option<fallow_engine::ImpactClosurePaths> {
1009    let graph = check
1010        .shared_parse
1011        .as_ref()
1012        .and_then(|sp| sp.analysis_output.as_ref())
1013        .and_then(|out| out.graph.as_ref())?;
1014
1015    fallow_engine::impact_closure_for_changed_paths(graph, root, changed_files)
1016}
1017
1018/// Compute the partition + order for the review brief's stage 2 from the
1019/// check result's retained graph against the changed-file set.
1020///
1021/// Maps each changed absolute path to its graph `FileId`, groups the changed
1022/// files into by-module units, and computes a dependency-sensible review order
1023/// over those units. Returns `None` when the graph was not retained (off the
1024/// brief path) or no changed file maps to a known module.
1025fn compute_brief_partition_order(
1026    root: &std::path::Path,
1027    check: &CheckResult,
1028    changed_files: &FxHashSet<PathBuf>,
1029) -> Option<fallow_engine::PartitionOrderPaths> {
1030    let graph = check
1031        .shared_parse
1032        .as_ref()
1033        .and_then(|sp| sp.analysis_output.as_ref())
1034        .and_then(|out| out.graph.as_ref())?;
1035
1036    fallow_engine::partition_order_for_changed_paths(graph, root, changed_files)
1037}
1038
1039/// Precompute the per-changed-file `rel_path -> [(export-name, 1-based line)]` map
1040/// for the decision surface, from the retained graph's export spans + each file's
1041/// line offsets, BEFORE health drops the graph. Lets a coordination / public-API
1042/// decision anchor to the exact export line. `None` when the graph is not retained.
1043fn compute_brief_export_lines(
1044    root: &std::path::Path,
1045    check: &CheckResult,
1046    changed_files: &FxHashSet<PathBuf>,
1047) -> Option<FxHashMap<String, Vec<(String, u32)>>> {
1048    let graph = check
1049        .shared_parse
1050        .as_ref()
1051        .and_then(|sp| sp.analysis_output.as_ref())
1052        .and_then(|out| out.graph.as_ref())?;
1053
1054    fallow_engine::export_lines_for_changed_paths(graph, root, changed_files)
1055}
1056
1057/// Precompute the per-anchor honest consumer count for the decision surface:
1058/// `rel_path -> count of distinct in-repo modules OUTSIDE the diff that directly
1059/// import the anchor file`, from the retained graph's reverse-deps BEFORE health
1060/// drops the graph (mirroring [`compute_brief_export_lines`]). This is the honest
1061/// per-decision DISPLAY number ("N in-repo modules already depend on this"),
1062/// distinct from the project-wide `affected_not_shown` ranking proxy. Importers
1063/// that are themselves part of the diff are excluded (they are the change, not a
1064/// pre-existing dependent). `None` when the graph is not retained.
1065fn compute_brief_internal_consumers(
1066    root: &std::path::Path,
1067    check: &CheckResult,
1068    changed_files: &FxHashSet<PathBuf>,
1069) -> Option<FxHashMap<String, u64>> {
1070    let graph = check
1071        .shared_parse
1072        .as_ref()
1073        .and_then(|sp| sp.analysis_output.as_ref())
1074        .and_then(|out| out.graph.as_ref())?;
1075
1076    fallow_engine::internal_consumers_for_changed_paths(graph, root, changed_files)
1077}
1078
1079/// Compute the per-file focus graph facts (fan-in/out + the dynamic-dispatch /
1080/// re-export-indirection confidence-flag signals) for the review brief's stage 4
1081/// weighted focus map, from the check result's retained graph against the
1082/// changed-file set.
1083///
1084/// Maps each changed absolute path to its graph `FileId`, computes the per-file
1085/// blast + confidence signals, and path-resolves them. Returns `None` when the
1086/// graph was not retained (off the brief path) or no changed file maps to a known
1087/// module.
1088fn compute_brief_focus_facts(
1089    root: &std::path::Path,
1090    check: &CheckResult,
1091    changed_files: &FxHashSet<PathBuf>,
1092) -> Option<Vec<fallow_engine::FocusFileFactsPaths>> {
1093    let graph = check
1094        .shared_parse
1095        .as_ref()
1096        .and_then(|sp| sp.analysis_output.as_ref())
1097        .and_then(|out| out.graph.as_ref())?;
1098
1099    fallow_engine::focus_facts_for_changed_paths(graph, root, changed_files)
1100}
1101
1102/// Run the audit pipeline: resolve base ref, run analyses, compute verdict.
1103pub fn execute_audit(opts: &AuditOptions<'_>) -> Result<AuditResult, ExitCode> {
1104    let start = Instant::now();
1105
1106    let (base_ref, base_description) = resolve_base_ref(opts)?;
1107
1108    let Some(changed_files) = crate::check::get_changed_files(opts.root, &base_ref) else {
1109        return Err(emit_error(
1110            &format!(
1111                "could not determine changed files for base ref '{base_ref}'. Verify the ref exists in this git repository"
1112            ),
1113            2,
1114            opts.output,
1115        ));
1116    };
1117    let changed_files_count = changed_files.len();
1118
1119    if changed_files.is_empty() {
1120        return Ok(empty_audit_result(
1121            base_ref,
1122            base_description,
1123            opts,
1124            start.elapsed(),
1125        ));
1126    }
1127
1128    // Sweep only once audit will do real changed-code work. A clean tree never
1129    // creates or reuses a base worktree, so keeping the no-change fast path
1130    // free of worktree-listing IO is both safe and visibly cheaper.
1131    sweep_old_reusable_caches(
1132        opts.root,
1133        resolve_cache_max_age(opts.root, opts.config_path.as_ref()),
1134        opts.quiet,
1135    );
1136
1137    let changed_since = Some(base_ref.as_str());
1138
1139    let needs_real_base_snapshot = matches!(opts.gate, AuditGate::NewOnly)
1140        && !can_reuse_current_as_base(opts, &base_ref, &changed_files);
1141    let base_cache_key = if needs_real_base_snapshot {
1142        audit_base_snapshot_cache_key(opts, &base_ref, &changed_files)?
1143    } else {
1144        None
1145    };
1146    let cached_base_snapshot = base_cache_key
1147        .as_ref()
1148        .and_then(|key| load_cached_base_snapshot(opts, key));
1149
1150    let (head_res, base_res) = run_audit_head_and_base(
1151        opts,
1152        changed_since,
1153        &changed_files,
1154        &base_ref,
1155        base_cache_key.as_ref(),
1156        needs_real_base_snapshot && cached_base_snapshot.is_none(),
1157    );
1158
1159    assemble_audit_result(AuditAssemblyInput {
1160        opts,
1161        head_res,
1162        base_res,
1163        cached_base_snapshot,
1164        base_cache_key,
1165        changed_files,
1166        changed_files_count,
1167        base_ref,
1168        base_description,
1169        start,
1170    })
1171}
1172
1173/// Inputs threaded from the audit prelude into [`assemble_audit_result`].
1174struct AuditAssemblyInput<'a> {
1175    opts: &'a AuditOptions<'a>,
1176    head_res: Result<HeadAnalyses, ExitCode>,
1177    base_res: Option<Result<AuditKeySnapshot, ExitCode>>,
1178    cached_base_snapshot: Option<AuditKeySnapshot>,
1179    base_cache_key: Option<AuditBaseSnapshotCacheKey>,
1180    changed_files: FxHashSet<PathBuf>,
1181    changed_files_count: usize,
1182    base_ref: String,
1183    base_description: Option<String>,
1184    start: Instant,
1185}
1186
1187/// Resolve the base snapshot, compute attribution/verdict/summary, and build the
1188/// final `AuditResult` from the HEAD-side analyses.
1189fn assemble_audit_result(input: AuditAssemblyInput<'_>) -> Result<AuditResult, ExitCode> {
1190    let opts = input.opts;
1191    let head = input.head_res?;
1192    let mut check_result = head.check;
1193    let dupes_result = head.dupes;
1194    let health_result = head.health;
1195
1196    let (base_snapshot, base_snapshot_skipped) = resolve_base_snapshot(
1197        opts,
1198        input.cached_base_snapshot,
1199        input.base_res,
1200        input.base_cache_key.as_ref(),
1201        CurrentAnalysisRefs {
1202            check: check_result.as_ref(),
1203            dupes: dupes_result.as_ref(),
1204            health: health_result.as_ref(),
1205        },
1206    )?;
1207    if let Some(ref mut check) = check_result {
1208        check.shared_parse = None;
1209    }
1210    let (attribution, verdict, summary) = compute_audit_outcome(
1211        opts.gate,
1212        check_result.as_ref(),
1213        dupes_result.as_ref(),
1214        health_result.as_ref(),
1215        base_snapshot.as_ref(),
1216    );
1217
1218    // Review-brief data: deltas (head-vs-base sets), weakening (base-vs-head
1219    // diff scan over the changed files), and ownership routing. Brief-path only.
1220    let (review_deltas, weakening_signals, routing) = if opts.brief {
1221        compute_brief_e3_data(
1222            opts,
1223            check_result.as_ref(),
1224            base_snapshot.as_ref(),
1225            &input.changed_files,
1226            &input.base_ref,
1227        )
1228    } else {
1229        (None, Vec::new(), None)
1230    };
1231
1232    // Decision surface (the apex): classify the SOLID-3 candidates from the
1233    // deltas + coordination gaps, anchor each `signal_id`, rank, cap, and pair
1234    // the routed expert. Brief-path only.
1235    let decision_surface = if opts.brief {
1236        Some(compute_decision_surface(
1237            opts,
1238            check_result.as_ref(),
1239            review_deltas.as_ref(),
1240            routing.as_ref(),
1241        ))
1242    } else {
1243        None
1244    };
1245
1246    // Per-hunk change anchors (the region-level anchor set): derived from the
1247    // SAME diff source the run used (an opt-in `--diff-stdin`/`--diff-file` diff,
1248    // else the committed `base...HEAD`), so emission and validation anchor to one
1249    // changed set. Brief-path only.
1250    let change_anchors = if opts.brief {
1251        compute_change_anchors(opts.root, &input.base_ref)
1252    } else {
1253        Vec::new()
1254    };
1255
1256    // Graph-snapshot hash (the staleness pin): a deterministic hash of the
1257    // HEAD-side key sets plus the resolved base ref + head sha + the change-anchor
1258    // id set. Brief-path only. The verifier is the graph: a mutated tree changes a
1259    // key set OR a changed region, changes this hash, and refuses a stale agent
1260    // walkthrough on reentry (so a cited change_anchor that moved is refused too).
1261    let head_sha = get_head_sha(opts.root);
1262    let graph_snapshot_hash = if opts.brief {
1263        Some(compute_graph_snapshot_hash(
1264            check_result.as_ref(),
1265            dupes_result.as_ref(),
1266            health_result.as_ref(),
1267            &input.base_ref,
1268            head_sha.as_deref(),
1269            &change_anchors,
1270        ))
1271    } else {
1272        None
1273    };
1274
1275    Ok(build_audit_result(AuditResultParts {
1276        verdict,
1277        summary,
1278        attribution,
1279        base_snapshot,
1280        base_snapshot_skipped,
1281        changed_files_count: input.changed_files_count,
1282        changed_files: input.changed_files,
1283        base_ref: input.base_ref,
1284        base_description: input.base_description,
1285        head_sha,
1286        output: opts.output,
1287        performance: opts.performance,
1288        check: check_result,
1289        dupes: dupes_result,
1290        health: health_result,
1291        elapsed: input.start.elapsed(),
1292        review_deltas,
1293        weakening_signals,
1294        routing,
1295        decision_surface,
1296        graph_snapshot_hash,
1297        change_anchors,
1298    }))
1299}
1300
1301/// Compute the deterministic graph-snapshot hash from the HEAD-side analysis
1302/// results plus the resolved base ref + head sha. Reuses [`snapshot_from_results`]
1303/// for the six key sets (dead_code / health / dupes / boundary_edges / cycles /
1304/// public_api), each sorted, then folds in the base ref and head sha so the same
1305/// tree compared against the same base always yields the same hash.
1306///
1307/// The verifier is the graph: any structural change (a new finding, a new edge,
1308/// a new export) shifts a key set and changes this hash, so a stale agent
1309/// walkthrough whose echoed hash no longer matches is REFUSED on reentry.
1310fn compute_graph_snapshot_hash(
1311    check: Option<&CheckResult>,
1312    dupes: Option<&DupesResult>,
1313    health: Option<&HealthResult>,
1314    base_ref: &str,
1315    head_sha: Option<&str>,
1316    change_anchors: &[crate::audit_walkthrough::ChangeAnchor],
1317) -> String {
1318    // The HEAD public-export set was computed on the brief path and retained on
1319    // the check result (`public_api_keys`); reuse it so the hash is exports-aware
1320    // without re-walking the graph.
1321    let public_api = check
1322        .and_then(|c| c.public_api_keys.clone())
1323        .unwrap_or_default();
1324    let snapshot = snapshot_from_results(check, dupes, health, public_api);
1325    let mut bytes: Vec<u8> = Vec::new();
1326    // Sorted key sets, each length-prefixed, so the byte stream is unambiguous.
1327    for set in [
1328        &snapshot.dead_code,
1329        &snapshot.health,
1330        &snapshot.dupes,
1331        &snapshot.boundary_edges,
1332        &snapshot.cycles,
1333        &snapshot.public_api,
1334    ] {
1335        for key in sorted_keys(set) {
1336            bytes.extend_from_slice(key.as_bytes());
1337            bytes.push(0);
1338        }
1339        bytes.push(1);
1340    }
1341    // Seventh key set: the SORTED change-anchor id set, so a moved/added/removed
1342    // changed region shifts this hash and a cited change_anchor that moved is
1343    // refused as stale (the finding key sets are line-independent and would not
1344    // otherwise cover the region-level anchors).
1345    let mut anchor_ids: Vec<&str> = change_anchors
1346        .iter()
1347        .map(|a| a.change_anchor.as_str())
1348        .collect();
1349    anchor_ids.sort_unstable();
1350    for id in anchor_ids {
1351        bytes.extend_from_slice(id.as_bytes());
1352        bytes.push(0);
1353    }
1354    bytes.push(1);
1355    bytes.extend_from_slice(base_ref.as_bytes());
1356    bytes.push(0);
1357    bytes.extend_from_slice(head_sha.unwrap_or("").as_bytes());
1358    format!("graph:{:016x}", xxh3_64(&bytes))
1359}
1360
1361/// Derive the per-hunk change anchors for this run from the SAME diff source the
1362/// run used: the opt-in shared diff (`--diff-stdin` / `--diff-file`) when present,
1363/// else the committed merge-base diff `base_ref...HEAD`. Using one source for both
1364/// the guide emission and the `--walkthrough-file` validation keeps the emitted
1365/// anchor set equal to the set validated on reentry (a committed-vs-staged
1366/// mismatch would otherwise reject every staged region's anchor). A diff that
1367/// cannot be computed yields an empty set (no anchors, never a panic).
1368fn compute_change_anchors(
1369    root: &std::path::Path,
1370    base_ref: &str,
1371) -> Vec<crate::audit_walkthrough::ChangeAnchor> {
1372    if let Some(raw) = crate::report::ci::diff_filter::shared_diff_raw() {
1373        return crate::audit_walkthrough::parse_change_anchors(raw);
1374    }
1375    fallow_engine::try_get_changed_diff(root, base_ref)
1376        .map(|diff| crate::audit_walkthrough::parse_change_anchors(&diff))
1377        .unwrap_or_default()
1378}
1379
1380/// Compute the decision surface from the assembled brief inputs: gather the
1381/// boundary anchors (one representative per introduced zone-pair), the
1382/// coordination gaps, and the impact-closure blast magnitude, then run the
1383/// extractor. The cap is taken from the audit options (clamped to [3, 5] by the
1384/// extractor). Returns an empty surface when no check result is available.
1385fn compute_decision_surface(
1386    opts: &AuditOptions<'_>,
1387    check: Option<&CheckResult>,
1388    review_deltas: Option<&crate::audit_brief::ReviewDeltas>,
1389    routing: Option<&routing::RoutingFacts>,
1390) -> crate::audit_decision_surface::DecisionSurface {
1391    use crate::audit_decision_surface::{
1392        BoundaryAnchor, CoordinationAnchor, DecisionInputs, extract_decision_surface,
1393    };
1394
1395    let (Some(check), Some(deltas)) = (check, review_deltas) else {
1396        return crate::audit_decision_surface::DecisionSurface::default();
1397    };
1398    let root = &check.config.root;
1399
1400    // One representative boundary anchor per introduced zone-pair: pick the first
1401    // violation whose zone-pair key matches an introduced edge, for the file +
1402    // line suppression/routing anchor.
1403    let mut boundary_anchors: Vec<BoundaryAnchor> = Vec::new();
1404    let mut seen_pairs: FxHashSet<String> = FxHashSet::default();
1405    for finding in &check.results.boundary_violations {
1406        let key = review_deltas::boundary_edge_key(finding);
1407        if !deltas.boundary_introduced.contains(&key) || !seen_pairs.insert(key.clone()) {
1408            continue;
1409        }
1410        boundary_anchors.push(BoundaryAnchor {
1411            zone_pair_key: key,
1412            from_file: keys::relative_key_path(&finding.violation.from_path, root),
1413            from_zone: finding.violation.from_zone.clone(),
1414            to_zone: finding.violation.to_zone.clone(),
1415            line: finding.violation.line,
1416        });
1417    }
1418
1419    // Coordination gaps projected to the public-API/contract decision shape.
1420    // Aggregate per changed file: ONE contract decision per changed file (R1
1421    // batch-consolidate), counting its distinct non-diff consumers as the blast.
1422    let closure = check.impact_closure.as_ref();
1423    let mut coordination: Vec<CoordinationAnchor> = closure
1424        .map(|c| aggregate_coordination_gaps(&c.coordination_gap))
1425        .unwrap_or_default();
1426    let affected_not_shown = closure.map_or(0, |c| c.affected_not_shown.len() as u64);
1427
1428    let empty_routing = routing::RoutingFacts::default();
1429    let routing = routing.unwrap_or(&empty_routing);
1430
1431    // Head-source reader for suppression checks AND for resolving a contract
1432    // symbol's declaration line: read the on-disk (head) content of an anchor file
1433    // by its root-relative path. Best-effort; an unreadable file is not suppressed.
1434    let root_owned = root.clone();
1435    let head_source = move |rel: &str| std::fs::read_to_string(root_owned.join(rel)).ok();
1436
1437    // Resolve a contract symbol's 1-based declaration line from the per-file
1438    // export-line map precomputed on the brief path (the graph is already dropped
1439    // by health here, so we cannot re-derive it now). Lets coordination /
1440    // public-API decisions deep-link to the exact export instead of the file head.
1441    let export_lines = check.export_lines.as_ref();
1442    let resolve_line = |rel: &str, symbols: &[String]| -> u32 {
1443        let Some(exports) = export_lines.and_then(|map| map.get(rel)) else {
1444            return 0;
1445        };
1446        exports
1447            .iter()
1448            .find(|(name, _)| symbols.iter().any(|s| name == s))
1449            .or_else(|| exports.first())
1450            .map_or(0, |(_, line)| *line)
1451    };
1452    for anchor in &mut coordination {
1453        anchor.line = resolve_line(&anchor.changed_file, &anchor.consumed_symbols);
1454    }
1455    let public_api_anchor_line = deltas.public_api_added.first().map_or(0, |key| {
1456        let mut parts = key.splitn(2, "::");
1457        let path = parts.next().unwrap_or_default();
1458        let name = parts.next().unwrap_or_default();
1459        resolve_line(path, &[name.to_string()])
1460    });
1461
1462    // Rename resolver: a head (post-rename) root-relative path -> its pre-rename
1463    // path, from the diff's rename pairs. Best-effort (empty without a shared diff
1464    // or renames); lets each decision carry a rename-durable `previous_signal_id`.
1465    let rename_old_path = |rel: &str| -> Option<String> {
1466        crate::report::ci::diff_filter::shared_diff_index()
1467            .and_then(|idx| idx.old_path_for(rel))
1468            .map(str::to_string)
1469    };
1470
1471    // Honest per-anchor consumer count, looked up from the map precomputed before
1472    // the graph drop. `0` for an anchor with no recorded importers (a new file).
1473    let internal_consumers_map = check.internal_consumers.as_ref();
1474    let internal_consumers = |rel: &str| -> u64 {
1475        internal_consumers_map
1476            .and_then(|map| map.get(rel))
1477            .copied()
1478            .unwrap_or(0)
1479    };
1480
1481    extract_decision_surface(&DecisionInputs {
1482        deltas,
1483        boundary_anchors: &boundary_anchors,
1484        coordination: &coordination,
1485        public_api_anchor_line,
1486        affected_not_shown,
1487        routing,
1488        head_source: &head_source,
1489        rename_old_path: &rename_old_path,
1490        internal_consumers: &internal_consumers,
1491        cap: opts.max_decisions,
1492    })
1493}
1494
1495/// Aggregate per-(changed, consumer) coordination gaps into ONE contract anchor
1496/// per changed file (R1 batch-consolidate), with the distinct-consumer count as
1497/// the blast and the union of consumed symbols as the contract. Sorted by changed
1498/// file for deterministic output.
1499fn aggregate_coordination_gaps(
1500    gaps: &[fallow_engine::CoordinationGapPaths],
1501) -> Vec<crate::audit_decision_surface::CoordinationAnchor> {
1502    use crate::audit_decision_surface::CoordinationAnchor;
1503    let mut by_file: FxHashMap<String, (u64, FxHashSet<String>)> = FxHashMap::default();
1504    for gap in gaps {
1505        let entry = by_file
1506            .entry(gap.changed_file.clone())
1507            .or_insert_with(|| (0, FxHashSet::default()));
1508        entry.0 += 1;
1509        for symbol in &gap.consumed_symbols {
1510            entry.1.insert(symbol.clone());
1511        }
1512    }
1513    let mut anchors: Vec<CoordinationAnchor> = by_file
1514        .into_iter()
1515        .map(|(changed_file, (consumer_count, symbols))| {
1516            let mut consumed_symbols: Vec<String> = symbols.into_iter().collect();
1517            consumed_symbols.sort_unstable();
1518            CoordinationAnchor {
1519                changed_file,
1520                consumed_symbols,
1521                consumer_count,
1522                line: 0,
1523            }
1524        })
1525        .collect();
1526    anchors.sort_by(|a, b| a.changed_file.cmp(&b.changed_file));
1527    anchors
1528}
1529
1530/// Compute the review-brief data: the diff-aware deltas (head sets vs base
1531/// snapshot), the weakening-signal pass (base-vs-head diff over the changed
1532/// files), and ownership routing. Pure-ish: weakening + routing shell out to git
1533/// (via [`BaseFileReader`] / churn), so this runs only on the brief path.
1534fn compute_brief_e3_data(
1535    opts: &AuditOptions<'_>,
1536    check: Option<&CheckResult>,
1537    base_snapshot: Option<&AuditKeySnapshot>,
1538    changed_files: &FxHashSet<PathBuf>,
1539    base_ref: &str,
1540) -> (
1541    Option<crate::audit_brief::ReviewDeltas>,
1542    Vec<weakening::WeakeningSignal>,
1543    Option<routing::RoutingFacts>,
1544) {
1545    let deltas = check.map(|check| {
1546        let head_boundary = review_deltas::boundary_edge_keys(&check.results.boundary_violations);
1547        let head_cycles =
1548            review_deltas::cycle_keys(&check.results.circular_dependencies, &check.config.root);
1549        let head_public_api = check.public_api_keys.clone().unwrap_or_default();
1550        let empty = FxHashSet::default();
1551        let (base_boundary, base_cycles, base_public_api) = base_snapshot
1552            .map_or((&empty, &empty, &empty), |b| {
1553                (&b.boundary_edges, &b.cycles, &b.public_api)
1554            });
1555        crate::audit_brief::build_review_deltas(
1556            &head_boundary,
1557            base_boundary,
1558            &head_cycles,
1559            base_cycles,
1560            &head_public_api,
1561            base_public_api,
1562        )
1563    });
1564
1565    let weakening_signals = compute_weakening_signals(opts.root, base_ref, changed_files);
1566
1567    let routing =
1568        check.map(|check| routing::compute_routing(opts.root, &check.config, changed_files));
1569
1570    (deltas, weakening_signals, routing)
1571}
1572
1573/// Run the weakening-signal pass over the changed files: read each file's base
1574/// content via [`BaseFileReader`], diff it against the on-disk head content, and
1575/// emit a [`weakening::WeakeningSignal`] per detected weakening. Best-effort: a
1576/// file whose base or head cannot be read is skipped silently.
1577fn compute_weakening_signals(
1578    root: &Path,
1579    base_ref: &str,
1580    changed_files: &FxHashSet<PathBuf>,
1581) -> Vec<weakening::WeakeningSignal> {
1582    use weakening::{WeakeningKind, WeakeningSignal};
1583
1584    let Some(git_root) = git_toplevel(root) else {
1585        return Vec::new();
1586    };
1587    let Some(mut reader) = BaseFileReader::spawn(root) else {
1588        return Vec::new();
1589    };
1590
1591    let mut signals: Vec<WeakeningSignal> = Vec::new();
1592    // Sort the changed files for deterministic signal ordering.
1593    let mut files: Vec<&PathBuf> = changed_files.iter().collect();
1594    files.sort();
1595
1596    for abs in files {
1597        let Ok(relative) = abs.strip_prefix(&git_root) else {
1598            continue;
1599        };
1600        let rel_str = relative.to_string_lossy().replace('\\', "/");
1601        let head = std::fs::read_to_string(abs).unwrap_or_default();
1602        let base = reader.read(base_ref, relative).unwrap_or_default();
1603        // A net-new file (no base) or a non-source file still gets the scan; the
1604        // detectors are no-ops on irrelevant content.
1605
1606        if weakening::is_test_file(&rel_str) {
1607            for token in weakening::detect_test_weakening(&base, &head) {
1608                signals.push(WeakeningSignal {
1609                    kind: WeakeningKind::TestWeakened,
1610                    file: rel_str.clone(),
1611                    evidence: format!("{token} added"),
1612                });
1613            }
1614            for ev in weakening::detect_removed_tests(&base, &head) {
1615                signals.push(WeakeningSignal {
1616                    kind: WeakeningKind::TestWeakened,
1617                    file: rel_str.clone(),
1618                    evidence: ev,
1619                });
1620            }
1621        }
1622        for ev in weakening::detect_added_suppressions(&base, &head) {
1623            signals.push(WeakeningSignal {
1624                kind: WeakeningKind::SuppressionAdded,
1625                file: rel_str.clone(),
1626                evidence: ev,
1627            });
1628        }
1629        for ev in weakening::detect_lowered_thresholds(&base, &head) {
1630            signals.push(WeakeningSignal {
1631                kind: WeakeningKind::ThresholdLowered,
1632                file: rel_str.clone(),
1633                evidence: ev,
1634            });
1635        }
1636        if weakening::is_ci_file(&rel_str) {
1637            for ev in weakening::detect_removed_security_steps(&base, &head) {
1638                signals.push(WeakeningSignal {
1639                    kind: WeakeningKind::SecurityCheckRemoved,
1640                    file: rel_str.clone(),
1641                    evidence: ev,
1642                });
1643            }
1644        }
1645    }
1646    signals
1647}
1648
1649/// Attribution, verdict, and summary computed together from the HEAD analyses and
1650/// base snapshot. Also records the final result count for telemetry.
1651fn compute_audit_outcome(
1652    gate: AuditGate,
1653    check: Option<&CheckResult>,
1654    dupes: Option<&DupesResult>,
1655    health: Option<&HealthResult>,
1656    base: Option<&AuditKeySnapshot>,
1657) -> (AuditAttribution, AuditVerdict, AuditSummary) {
1658    let attribution = compute_audit_attribution(check, dupes, health, base, gate);
1659    let verdict = compute_audit_verdict(gate, check, dupes, health, base);
1660    let summary = build_summary(check, dupes, health);
1661    crate::telemetry::note_final_result_count(
1662        summary.dead_code_issues + summary.complexity_findings + summary.duplication_clone_groups,
1663    );
1664    (attribution, verdict, summary)
1665}
1666
1667/// Resolve the base key snapshot for the `new`-only gate: prefer the cache, then a
1668/// freshly computed base worktree (persisting it), else fall back to current keys
1669/// (marking the snapshot skipped). Returns `(None, false)` outside `new`-only mode.
1670/// The current-run analysis result references threaded together so the base
1671/// snapshot resolver can fall back to the current keys without a six-deep
1672/// argument list. Bundled refs of the optional check / dupes / health results.
1673#[derive(Clone, Copy)]
1674struct CurrentAnalysisRefs<'a> {
1675    check: Option<&'a CheckResult>,
1676    dupes: Option<&'a DupesResult>,
1677    health: Option<&'a HealthResult>,
1678}
1679
1680fn resolve_base_snapshot(
1681    opts: &AuditOptions<'_>,
1682    cached_base_snapshot: Option<AuditKeySnapshot>,
1683    base_res: Option<Result<AuditKeySnapshot, ExitCode>>,
1684    base_cache_key: Option<&AuditBaseSnapshotCacheKey>,
1685    current: CurrentAnalysisRefs<'_>,
1686) -> Result<(Option<AuditKeySnapshot>, bool), ExitCode> {
1687    if !matches!(opts.gate, AuditGate::NewOnly) {
1688        return Ok((None, false));
1689    }
1690    if let Some(snapshot) = cached_base_snapshot {
1691        return Ok((Some(snapshot), false));
1692    }
1693    if let Some(base_res) = base_res {
1694        let snapshot = base_res?;
1695        if let Some(key) = base_cache_key {
1696            save_cached_base_snapshot(opts, key, &snapshot);
1697        }
1698        return Ok((Some(snapshot), false));
1699    }
1700    let CurrentAnalysisRefs {
1701        check,
1702        dupes,
1703        health,
1704    } = current;
1705    Ok((Some(current_keys_as_base_keys(check, dupes, health)), true))
1706}
1707
1708/// Pick the audit verdict: the introduced-only gate in `new`-only mode, otherwise
1709/// the full backlog verdict.
1710fn compute_audit_verdict(
1711    gate: AuditGate,
1712    check: Option<&CheckResult>,
1713    dupes: Option<&DupesResult>,
1714    health: Option<&HealthResult>,
1715    base: Option<&AuditKeySnapshot>,
1716) -> AuditVerdict {
1717    if matches!(gate, AuditGate::NewOnly) {
1718        compute_introduced_verdict(check, dupes, health, base)
1719    } else {
1720        compute_verdict(check, dupes, health)
1721    }
1722}
1723
1724fn build_audit_result(parts: AuditResultParts) -> AuditResult {
1725    AuditResult {
1726        verdict: parts.verdict,
1727        summary: parts.summary,
1728        attribution: parts.attribution,
1729        base_snapshot: parts.base_snapshot,
1730        base_snapshot_skipped: parts.base_snapshot_skipped,
1731        changed_files_count: parts.changed_files_count,
1732        changed_files: parts.changed_files.into_iter().collect(),
1733        base_ref: parts.base_ref,
1734        base_description: parts.base_description,
1735        head_sha: parts.head_sha,
1736        output: parts.output,
1737        performance: parts.performance,
1738        check: parts.check,
1739        dupes: parts.dupes,
1740        health: parts.health,
1741        elapsed: parts.elapsed,
1742        review_deltas: parts.review_deltas,
1743        weakening_signals: parts.weakening_signals,
1744        routing: parts.routing,
1745        decision_surface: parts.decision_surface,
1746        graph_snapshot_hash: parts.graph_snapshot_hash,
1747        change_anchors: parts.change_anchors,
1748    }
1749}
1750
1751/// Build an empty pass result when no files have changed.
1752fn empty_audit_result(
1753    base_ref: String,
1754    base_description: Option<String>,
1755    opts: &AuditOptions<'_>,
1756    elapsed: Duration,
1757) -> AuditResult {
1758    crate::telemetry::note_final_result_count(0);
1759
1760    let head_sha = get_head_sha(opts.root);
1761    // An empty changeset is a valid graph state: pin a hash on the brief path so
1762    // the walkthrough guide still carries a stable snapshot pin (no findings, so
1763    // the hash folds only the base ref + head sha).
1764    let graph_snapshot_hash = if opts.brief {
1765        // An empty changeset has no changed regions, so no change anchors.
1766        Some(compute_graph_snapshot_hash(
1767            None,
1768            None,
1769            None,
1770            &base_ref,
1771            head_sha.as_deref(),
1772            &[],
1773        ))
1774    } else {
1775        None
1776    };
1777
1778    AuditResult {
1779        verdict: AuditVerdict::Pass,
1780        summary: AuditSummary {
1781            dead_code_issues: 0,
1782            dead_code_has_errors: false,
1783            complexity_findings: 0,
1784            max_cyclomatic: None,
1785            duplication_clone_groups: 0,
1786        },
1787        attribution: AuditAttribution {
1788            gate: opts.gate,
1789            ..AuditAttribution::default()
1790        },
1791        base_snapshot: None,
1792        base_snapshot_skipped: false,
1793        changed_files_count: 0,
1794        changed_files: Vec::new(),
1795        base_ref,
1796        base_description,
1797        head_sha,
1798        output: opts.output,
1799        performance: opts.performance,
1800        check: None,
1801        dupes: None,
1802        health: None,
1803        elapsed,
1804        review_deltas: None,
1805        weakening_signals: Vec::new(),
1806        routing: None,
1807        decision_surface: None,
1808        graph_snapshot_hash,
1809        change_anchors: Vec::new(),
1810    }
1811}
1812
1813/// Run dead code analysis for the audit pipeline.
1814fn run_audit_check<'a>(
1815    opts: &'a AuditOptions<'a>,
1816    changed_since: Option<&'a str>,
1817    retain_modules_for_health: bool,
1818) -> Result<Option<CheckResult>, ExitCode> {
1819    let filters = IssueFilters::default();
1820    // The review brief needs the module graph for the impact closure, which
1821    // rides the retained-modules path. Force retention on the brief path even
1822    // when health does not share the dead-code parse (mismatched production
1823    // modes), so the graph is available before health consumes the shared parse.
1824    let retain_modules_for_health = retain_modules_for_health || opts.brief;
1825    let trace_opts = TraceOptions {
1826        trace_export: None,
1827        trace_file: None,
1828        trace_dependency: None,
1829        impact_closure: None,
1830        performance: opts.performance,
1831    };
1832    match crate::check::execute_check(&CheckOptions {
1833        root: opts.root,
1834        config_path: opts.config_path,
1835        output: opts.output,
1836        no_cache: opts.no_cache,
1837        threads: opts.threads,
1838        quiet: opts.quiet,
1839        fail_on_issues: false,
1840        filters: &filters,
1841        changed_since,
1842        diff_index: None,
1843        use_shared_diff_index: true,
1844        baseline: opts.dead_code_baseline,
1845        save_baseline: None,
1846        sarif_file: None,
1847        production: opts.production_dead_code.unwrap_or(opts.production),
1848        production_override: opts.production_dead_code,
1849        workspace: opts.workspace,
1850        changed_workspaces: opts.changed_workspaces,
1851        group_by: opts.group_by,
1852        include_dupes: false,
1853        trace_opts: &trace_opts,
1854        explain: opts.explain,
1855        top: None,
1856        file: &[],
1857        include_entry_exports: opts.include_entry_exports,
1858        summary: false,
1859        regression_opts: crate::regression::RegressionOpts {
1860            fail_on_regression: false,
1861            tolerance: crate::regression::Tolerance::Absolute(0),
1862            regression_baseline_file: None,
1863            save_target: crate::regression::SaveRegressionTarget::None,
1864            scoped: true,
1865            quiet: opts.quiet,
1866            output: opts.output,
1867        },
1868        retain_modules_for_health,
1869        defer_performance: false,
1870    }) {
1871        Ok(r) => Ok(Some(r)),
1872        Err(code) => Err(code),
1873    }
1874}
1875
1876/// Run duplication analysis for the audit pipeline.
1877///
1878/// Reads duplication settings from the project config file so that user
1879/// options like `ignoreImports`, `crossLanguage`, and `skipLocal` are
1880/// respected (same as combined mode).
1881fn run_audit_dupes<'a>(
1882    opts: &'a AuditOptions<'a>,
1883    changed_since: Option<&'a str>,
1884    changed_files: Option<&'a FxHashSet<PathBuf>>,
1885    pre_discovered: Option<Vec<fallow_types::discover::DiscoveredFile>>,
1886) -> Result<Option<DupesResult>, ExitCode> {
1887    let dupes_cfg = match crate::load_config_for_analysis(
1888        opts.root,
1889        opts.config_path,
1890        crate::ConfigLoadOptions {
1891            output: opts.output,
1892            no_cache: opts.no_cache,
1893            threads: opts.threads,
1894            production_override: opts
1895                .production_dupes
1896                .or_else(|| opts.production.then_some(true)),
1897            quiet: opts.quiet,
1898        },
1899        fallow_config::ProductionAnalysis::Dupes,
1900    ) {
1901        Ok(c) => c.duplicates,
1902        Err(code) => return Err(code),
1903    };
1904    let dupes_opts = build_audit_dupes_options(opts, changed_since, changed_files, &dupes_cfg);
1905    let dupes_run = if let Some(files) = pre_discovered {
1906        crate::dupes::execute_dupes_with_files(&dupes_opts, files)
1907    } else {
1908        crate::dupes::execute_dupes(&dupes_opts)
1909    };
1910    match dupes_run {
1911        Ok(r) => Ok(Some(r)),
1912        Err(code) => Err(code),
1913    }
1914}
1915
1916/// Build the `DupesOptions` for an audit run from project config + audit options.
1917fn build_audit_dupes_options<'a>(
1918    opts: &'a AuditOptions<'a>,
1919    changed_since: Option<&'a str>,
1920    changed_files: Option<&'a FxHashSet<PathBuf>>,
1921    dupes_cfg: &fallow_config::DuplicatesConfig,
1922) -> DupesOptions<'a> {
1923    DupesOptions {
1924        root: opts.root,
1925        config_path: opts.config_path,
1926        output: opts.output,
1927        no_cache: opts.no_cache,
1928        threads: opts.threads,
1929        quiet: opts.quiet,
1930        mode: Some(DupesMode::from(dupes_cfg.mode)),
1931        min_tokens: Some(dupes_cfg.min_tokens),
1932        min_lines: Some(dupes_cfg.min_lines),
1933        min_occurrences: Some(dupes_cfg.min_occurrences),
1934        threshold: Some(dupes_cfg.threshold),
1935        skip_local: dupes_cfg.skip_local,
1936        cross_language: dupes_cfg.cross_language,
1937        ignore_imports: Some(dupes_cfg.ignore_imports),
1938        top: None,
1939        baseline_path: opts.dupes_baseline,
1940        save_baseline_path: None,
1941        production: opts.production_dupes.unwrap_or(opts.production),
1942        production_override: opts.production_dupes,
1943        trace: None,
1944        changed_since,
1945        diff_index: None,
1946        use_shared_diff_index: true,
1947        changed_files,
1948        workspace: opts.workspace,
1949        changed_workspaces: opts.changed_workspaces,
1950        explain: opts.explain,
1951        explain_skipped: opts.explain_skipped,
1952        summary: false,
1953        group_by: opts.group_by,
1954        performance: false,
1955    }
1956}
1957
1958/// Run complexity analysis for the audit pipeline (findings only, no scores/hotspots/targets).
1959fn run_audit_health<'a>(
1960    opts: &'a AuditOptions<'a>,
1961    changed_since: Option<&'a str>,
1962    shared_parse: Option<fallow_engine::HealthSharedParseData>,
1963) -> Result<Option<HealthResult>, ExitCode> {
1964    let runtime_coverage = match opts.runtime_coverage {
1965        Some(path) => match crate::health::coverage::prepare_options(
1966            path,
1967            opts.min_invocations_hot,
1968            None,
1969            None,
1970            opts.output,
1971        ) {
1972            Ok(options) => Some(options),
1973            Err(code) => return Err(code),
1974        },
1975        None => None,
1976    };
1977
1978    let health_opts = build_audit_health_options(opts, changed_since, runtime_coverage);
1979    let health_run = if let Some(shared) = shared_parse {
1980        crate::health::execute_health_with_shared_parse(&health_opts, shared)
1981    } else {
1982        crate::health::execute_health(&health_opts)
1983    };
1984    match health_run {
1985        Ok(r) => Ok(Some(r)),
1986        Err(code) => Err(code),
1987    }
1988}
1989
1990/// Build the findings-only `HealthOptions` for an audit run (no scores, hotspots,
1991/// ownership, or targets; `--churn-file` is health-only).
1992fn build_audit_health_options<'a>(
1993    opts: &'a AuditOptions<'a>,
1994    changed_since: Option<&'a str>,
1995    runtime_coverage: Option<fallow_engine::RuntimeCoverageOptions>,
1996) -> HealthOptions<'a> {
1997    HealthOptions {
1998        root: opts.root,
1999        config_path: opts.config_path,
2000        output: opts.output,
2001        no_cache: opts.no_cache,
2002        threads: opts.threads,
2003        quiet: opts.quiet,
2004        thresholds: fallow_engine::HealthThresholdOverrides {
2005            max_cyclomatic: None,
2006            max_cognitive: None,
2007            max_crap: opts.max_crap,
2008        },
2009        top: None,
2010        sort: fallow_engine::HealthSort::Cyclomatic,
2011        production: opts.production_health.unwrap_or(opts.production),
2012        production_override: opts.production_health,
2013        changed_since,
2014        diff_index: None,
2015        use_shared_diff_index: true,
2016        workspace: opts.workspace,
2017        changed_workspaces: opts.changed_workspaces,
2018        baseline: opts.health_baseline,
2019        save_baseline: None,
2020        complexity: true,
2021        file_scores: false,
2022        coverage_gaps: false,
2023        config_activates_coverage_gaps: false,
2024        hotspots: false,
2025        ownership: false,
2026        ownership_emails: None,
2027        targets: false,
2028        css: false,
2029        force_full: false,
2030        score_only_output: false,
2031        enforce_coverage_gap_gate: false,
2032        effort: None,
2033        score: false,
2034        gates: fallow_engine::HealthGateOptions::default(),
2035        since: None,
2036        min_commits: None,
2037        explain: opts.explain,
2038        summary: false,
2039        save_snapshot: None,
2040        trend: false,
2041        coverage_inputs: fallow_engine::HealthCoverageInputs {
2042            coverage: opts.coverage,
2043            coverage_root: opts.coverage_root,
2044        },
2045        performance: opts.performance,
2046        runtime_coverage,
2047        churn_file: None,
2048        complexity_breakdown: false,
2049        group_by: opts.group_by.map(Into::into),
2050    }
2051}
2052
2053#[path = "audit_output.rs"]
2054mod output;
2055
2056pub use output::audit_json_header_input;
2057pub use output::{
2058    insert_audit_dead_code_json, insert_audit_duplication_json, insert_audit_health_json,
2059    print_audit_findings, print_audit_result,
2060};
2061
2062/// Run the full audit command: execute analyses, print results, return exit code.
2063/// Run audit, optionally tagged with a gate marker (e.g. `"pre-commit"`) so
2064/// Fallow Impact can record a containment event when the gate blocks then
2065/// clears. The marker only affects the local Impact store; it never changes
2066/// the verdict, exit code, or output.
2067pub fn run_audit(opts: &AuditOptions<'_>, gate_marker: Option<&str>) -> ExitCode {
2068    if let Err(e) = fallow_engine::validate_coverage_root_absolute(opts.coverage_root) {
2069        return emit_error(&e, 2, opts.output);
2070    }
2071    let coverage_resolved = opts
2072        .coverage
2073        .map(|p| crate::health::scoring::resolve_relative_to_root(p, Some(opts.root)));
2074    let runtime_coverage_resolved = opts
2075        .runtime_coverage
2076        .map(|p| crate::health::scoring::resolve_relative_to_root(p, Some(opts.root)));
2077    let resolved_opts = AuditOptions {
2078        coverage: coverage_resolved.as_deref(),
2079        runtime_coverage: runtime_coverage_resolved.as_deref(),
2080        ..*opts
2081    };
2082    match execute_audit(&resolved_opts) {
2083        Ok(result) => {
2084            let mut findings = result
2085                .check
2086                .as_ref()
2087                .map(|c| crate::impact::collect_dead_code_findings(&c.results))
2088                .unwrap_or_default();
2089            if let Some(health) = result.health.as_ref() {
2090                findings.extend(crate::impact::collect_complexity_findings(&health.report));
2091            }
2092            let clones = result
2093                .dupes
2094                .as_ref()
2095                .map(|d| crate::impact::collect_clone_findings(&d.report))
2096                .unwrap_or_default();
2097            let empty_supps: Vec<fallow_types::results::ActiveSuppression> = Vec::new();
2098            let suppressions = result.check.as_ref().map_or(empty_supps.as_slice(), |c| {
2099                c.results.active_suppressions.as_slice()
2100            });
2101            let attribution = crate::impact::AttributionInput {
2102                root: opts.root,
2103                scope: crate::impact::Scope::ChangedFiles(&result.changed_files),
2104                findings,
2105                clones,
2106                suppressions,
2107            };
2108            crate::impact::record_audit_run(
2109                opts.root,
2110                &result.summary,
2111                &crate::impact::AuditRunRecord {
2112                    verdict: result.verdict,
2113                    gate: gate_marker.is_some(),
2114                    git_sha: result.head_sha.as_deref(),
2115                    version: env!("CARGO_PKG_VERSION"),
2116                    timestamp: &crate::vital_signs::chrono_timestamp(),
2117                    attribution: Some(&attribution),
2118                },
2119            );
2120            // Walkthrough surfaces take priority over the brief body when
2121            // requested. Both always exit 0.
2122            if opts.walkthrough_guide {
2123                crate::audit_brief::print_walkthrough_guide_result(&result)
2124            } else if opts.walkthrough {
2125                crate::audit_brief::print_walkthrough_human_result(
2126                    &result,
2127                    opts.root,
2128                    opts.cache_dir,
2129                    opts.mark_viewed,
2130                    opts.show_cleared,
2131                    opts.quiet,
2132                )
2133            } else if let Some(path) = opts.walkthrough_file {
2134                crate::audit_brief::print_walkthrough_file_result(&result, path)
2135            } else if opts.brief {
2136                // Exit-0 seam: the brief renders the same analysis but never
2137                // gates on the verdict. `print_brief_result` always returns
2138                // SUCCESS.
2139                crate::audit_brief::print_brief_result(
2140                    &result,
2141                    opts.quiet,
2142                    opts.explain,
2143                    opts.show_deprioritized,
2144                )
2145            } else {
2146                print_audit_result(&result, opts.quiet, opts.explain)
2147            }
2148        }
2149        Err(code) => code,
2150    }
2151}
2152
2153/// Run the standalone `fallow decision-surface` command: the separable, cheap
2154/// apex. Executes the SAME changed-code analysis the review brief runs (it is
2155/// the brief path, NOT the full project pipeline), then emits ONLY the decision
2156/// surface envelope. Always exit 0 (the surface is advisory, never a gate).
2157///
2158/// The MCP `decision_surface` tool wraps this command. It is callable without the
2159/// full pipeline because it reuses `execute_audit` in brief mode (changed-code
2160/// scope), not bare `fallow`.
2161#[must_use]
2162pub fn run_decision_surface(opts: &AuditOptions<'_>) -> ExitCode {
2163    // Force brief mode: the decision surface is only computed on the brief path.
2164    let brief_opts = AuditOptions {
2165        brief: true,
2166        ..*opts
2167    };
2168    match execute_audit(&brief_opts) {
2169        Ok(result) => crate::audit_brief::print_decision_surface_result(&result, opts.quiet),
2170        Err(code) => code,
2171    }
2172}
2173
2174#[cfg(test)]
2175#[path = "audit_tests.rs"]
2176mod tests;