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