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