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    let root_pkg = fallow_config::PackageJson::load(&check.config.root.join("package.json")).ok();
585    review_deltas::public_export_keys_for(
586        graph,
587        &check.config,
588        root_pkg.as_ref(),
589        &check.workspaces,
590        root,
591    )
592}
593
594/// Build the `AuditOptions` for the isolated base-worktree analysis pass.
595#[expect(
596    clippy::ref_option,
597    reason = "AuditOptions.config_path is &Option<PathBuf>; the borrow is stored into the returned struct"
598)]
599fn build_base_audit_options<'a>(
600    opts: &AuditOptions<'a>,
601    base_root: &'a Path,
602    current_config_path: &'a Option<PathBuf>,
603    base_cache_dir: &'a Path,
604) -> AuditOptions<'a> {
605    AuditOptions {
606        root: base_root,
607        config_path: current_config_path,
608        cache_dir: base_cache_dir,
609        output: opts.output,
610        no_cache: opts.no_cache,
611        threads: opts.threads,
612        quiet: true,
613        changed_since: None,
614        production: opts.production,
615        production_dead_code: opts.production_dead_code,
616        production_health: opts.production_health,
617        production_dupes: opts.production_dupes,
618        workspace: opts.workspace,
619        changed_workspaces: None,
620        explain: false,
621        explain_skipped: false,
622        performance: false,
623        group_by: opts.group_by,
624        dead_code_baseline: None,
625        health_baseline: None,
626        dupes_baseline: None,
627        max_crap: opts.max_crap,
628        coverage: opts.coverage,
629        coverage_root: opts.coverage_root,
630        gate: AuditGate::All,
631        include_entry_exports: opts.include_entry_exports,
632        // Base styling keys keep opt-in `rules.css-* = error` gated on
633        // introduced findings only; the base snapshot is cached.
634        css: opts.css,
635        css_deep: opts.css_deep,
636        runtime_coverage: None,
637        min_invocations_hot: opts.min_invocations_hot,
638        brief: false,
639        max_decisions: 4,
640        walkthrough_guide: false,
641        walkthrough: false,
642        mark_viewed: &[],
643        show_cleared: false,
644        walkthrough_file: None,
645        show_deprioritized: false,
646    }
647}
648
649fn base_analysis_root(current_root: &Path, base_worktree_root: &Path) -> PathBuf {
650    let Some(git_root) = git_toplevel(current_root) else {
651        return base_worktree_root.to_path_buf();
652    };
653    let current_root =
654        dunce::canonicalize(current_root).unwrap_or_else(|_| current_root.to_path_buf());
655    match current_root.strip_prefix(&git_root) {
656        Ok(relative) => base_worktree_root.join(relative),
657        Err(err) => {
658            tracing::warn!(
659                current_root = %current_root.display(),
660                git_root = %git_root.display(),
661                error = %err,
662                "Could not remap audit base root into the base worktree; falling back to worktree root"
663            );
664            base_worktree_root.to_path_buf()
665        }
666    }
667}
668
669fn current_keys_as_base_keys(
670    check: Option<&CheckResult>,
671    dupes: Option<&DupesResult>,
672    health: Option<&HealthResult>,
673) -> AuditKeySnapshot {
674    // Reuse path (no behavioral change vs base): head IS base, so the delta
675    // sets are the head's own keys, which makes every head-minus-base delta
676    // empty. `public_api_keys` is the head set already computed on the brief
677    // path; the boundary/cycle keys come from the head results.
678    let public_api = check
679        .and_then(|r| r.public_api_keys.clone())
680        .unwrap_or_default();
681    snapshot_from_results(check, dupes, health, public_api)
682}
683
684fn can_reuse_current_as_base(
685    opts: &AuditOptions<'_>,
686    base_ref: &str,
687    changed_files: &FxHashSet<PathBuf>,
688) -> bool {
689    let Some(git_root) = git_toplevel(opts.root) else {
690        return false;
691    };
692    let cache_dir = opts.cache_dir.to_path_buf();
693    let canonical_cache_dir = dunce::canonicalize(&cache_dir).ok();
694    // Spawn the batched base-file reader lazily: a changeset of only cache
695    // artifacts or docs never touches git, so it spawns zero processes.
696    let mut reader: Option<BaseFileReader> = None;
697    for path in changed_files {
698        if is_fallow_cache_artifact(path, &cache_dir, canonical_cache_dir.as_deref()) {
699            continue;
700        }
701        if !is_analysis_input(path) {
702            if is_non_behavioral_doc(path) {
703                continue;
704            }
705            return false;
706        }
707        let Ok(current) = std::fs::read_to_string(path) else {
708            return false;
709        };
710        let Ok(relative) = path.strip_prefix(&git_root) else {
711            return false;
712        };
713        let reader = match reader.as_mut() {
714            Some(reader) => reader,
715            None => {
716                let Some(spawned) = BaseFileReader::spawn(opts.root) else {
717                    return false;
718                };
719                reader.insert(spawned)
720            }
721        };
722        let Some(base) = reader.read(base_ref, relative) else {
723            return false;
724        };
725        if current == base {
726            continue;
727        }
728        if !js_ts_tokens_equivalent(path, &current, &base) {
729            return false;
730        }
731    }
732    true
733}
734
735/// A long-lived `git cat-file --batch` child process used to read the base
736/// version of changed files without spawning one `git show` per file.
737///
738/// Requests and responses are strictly lockstep (one request line, one
739/// response) to avoid pipe-buffer deadlock. Per-file comparison semantics are
740/// byte-identical to the previous `git show` path: a missing object yields
741/// `None` (treated as not reusable), and content is read with lossy UTF-8
742/// conversion to match `String::from_utf8_lossy`.
743///
744/// The child is owned through a [`ScopedChild`](crate::signal::ScopedChild) so
745/// an interrupt (SIGINT/SIGTERM) during a large reuse loop kills the long-lived
746/// `cat-file` process via the signal registry instead of orphaning it.
747struct BaseFileReader {
748    /// The registered `cat-file --batch` child. Wrapped in `Option` so `Drop`
749    /// can `take()` it and call the consuming `ScopedChild::wait` after closing
750    /// stdin, reaping the child and deregistering its PID.
751    child: Option<crate::signal::ScopedChild>,
752    /// Wrapped in `Option` so `Drop` can `take()` and drop it explicitly,
753    /// closing the pipe before the blocking wait (which would otherwise block).
754    stdin: Option<std::process::ChildStdin>,
755    stdout: std::io::BufReader<std::process::ChildStdout>,
756}
757
758impl BaseFileReader {
759    /// Spawn a single `git cat-file --batch` process rooted at `root`.
760    ///
761    /// Returns `None` on spawn failure or if the child's stdio pipes are
762    /// unavailable; the caller then degrades to "not reusable" (returns
763    /// `false`), mirroring the previous per-file `git show` failure behavior.
764    fn spawn(root: &Path) -> Option<Self> {
765        let mut command = Command::new("git");
766        command
767            .args(["cat-file", "--batch"])
768            .current_dir(root)
769            .stdin(std::process::Stdio::piped())
770            .stdout(std::process::Stdio::piped())
771            .stderr(std::process::Stdio::null());
772        clear_ambient_git_env(&mut command);
773        let mut child = crate::signal::ScopedChild::spawn(&mut command).ok()?;
774        let stdin = child.take_stdin()?;
775        let stdout = child.take_stdout()?;
776        Some(Self {
777            child: Some(child),
778            stdin: Some(stdin),
779            stdout: std::io::BufReader::new(stdout),
780        })
781    }
782
783    /// Read the base version of `relative` at `base_ref`.
784    ///
785    /// Writes one `<base_ref>:<path>` request line (forward-slash separators)
786    /// and reads exactly one response in lockstep. Returns `None` if the object
787    /// is missing (the ` missing` header path), on any parse or IO error, or if
788    /// the path contains a newline (which would corrupt the request stream).
789    fn read(&mut self, base_ref: &str, relative: &Path) -> Option<String> {
790        use std::io::{BufRead, Read};
791
792        let relative = relative.to_string_lossy().replace('\\', "/");
793        // A newline in the path cannot be expressed as a single batch request
794        // line; treat it as not reusable rather than writing a corrupt request.
795        if relative.contains('\n') {
796            return None;
797        }
798
799        let stdin = self.stdin.as_mut()?;
800        writeln!(stdin, "{base_ref}:{relative}").ok()?;
801        stdin.flush().ok()?;
802
803        let mut header = String::new();
804        if self.stdout.read_line(&mut header).ok()? == 0 {
805            return None;
806        }
807        // `git cat-file --batch` reports a missing object as `<spec> missing\n`.
808        if header.trim_end().ends_with(" missing") {
809            return None;
810        }
811        // Otherwise the header is `<oid> <type> <size>\n`; parse the size.
812        let size: usize = header.trim_end().rsplit(' ').next()?.parse().ok()?;
813        let mut buf = vec![0u8; size];
814        self.stdout.read_exact(&mut buf).ok()?;
815        // Consume the single trailing newline that follows the object content.
816        // An off-by-one here corrupts every subsequent read in the batch.
817        let mut newline = [0u8; 1];
818        self.stdout.read_exact(&mut newline).ok()?;
819
820        Some(String::from_utf8_lossy(&buf).into_owned())
821    }
822}
823
824impl Drop for BaseFileReader {
825    fn drop(&mut self) {
826        // Close stdin so the child sees EOF and exits, then reap it through the
827        // ScopedChild's blocking `wait` (which also deregisters the PID from the
828        // signal registry). Dropping the `ChildStdin` closes the pipe; doing
829        // this before the wait prevents it from blocking.
830        self.stdin.take();
831        if let Some(child) = self.child.take() {
832            let _ = child.wait();
833        }
834    }
835}
836
837fn is_fallow_cache_artifact(
838    path: &Path,
839    cache_dir: &Path,
840    canonical_cache_dir: Option<&Path>,
841) -> bool {
842    path.starts_with(cache_dir)
843        || canonical_cache_dir.is_some_and(|canonical| path.starts_with(canonical))
844}
845
846fn remap_cache_dir_for_base_worktree(
847    current_root: &Path,
848    base_worktree_root: &Path,
849    cache_dir: &Path,
850) -> PathBuf {
851    if cache_dir.is_absolute()
852        && let Ok(relative) = cache_dir.strip_prefix(current_root)
853    {
854        return base_worktree_root.join(relative);
855    }
856    cache_dir.to_path_buf()
857}
858
859fn is_analysis_input(path: &Path) -> bool {
860    matches!(
861        path.extension().and_then(|ext| ext.to_str()),
862        Some(
863            "js" | "jsx"
864                | "ts"
865                | "tsx"
866                | "mjs"
867                | "mts"
868                | "cjs"
869                | "cts"
870                | "vue"
871                | "svelte"
872                | "astro"
873                | "mdx"
874                | "css"
875                | "scss"
876        )
877    )
878}
879
880fn is_non_behavioral_doc(path: &Path) -> bool {
881    matches!(
882        path.extension().and_then(|ext| ext.to_str()),
883        Some("md" | "markdown" | "txt" | "rst" | "adoc")
884    )
885}
886
887fn js_ts_tokens_equivalent(path: &Path, current: &str, base: &str) -> bool {
888    if current.contains("fallow-ignore") || base.contains("fallow-ignore") {
889        return false;
890    }
891    if !matches!(
892        path.extension().and_then(|ext| ext.to_str()),
893        Some("js" | "jsx" | "ts" | "tsx" | "mjs" | "mts" | "cjs" | "cts")
894    ) {
895        return false;
896    }
897    fallow_engine::duplicates::source_token_kinds_equivalent(path, current, base, false)
898}
899
900fn remap_focus_files(
901    files: &FxHashSet<PathBuf>,
902    from_root: &Path,
903    to_root: &Path,
904) -> Option<FxHashSet<PathBuf>> {
905    let mut remapped = FxHashSet::default();
906    for file in files {
907        if let Ok(relative) = file.strip_prefix(from_root) {
908            remapped.insert(to_root.join(relative));
909        }
910    }
911    if remapped.is_empty() {
912        return None;
913    }
914    Some(remapped)
915}
916
917#[cfg(test)]
918use std::time::SystemTime;
919
920#[cfg(test)]
921use crate::base_worktree::{
922    ReusableWorktreeLock, WorktreeCleanupGuard, audit_worktree_pid, days_to_duration,
923    is_fallow_audit_worktree_path, is_reusable_audit_worktree_path, list_audit_worktrees,
924    materialize_base_dependency_context, parse_worktree_list, paths_equal, process_is_alive,
925    remove_audit_worktree, reusable_worktree_last_used_path, reusable_worktree_lock_path,
926    sweep_orphan_audit_worktrees, touch_last_used,
927};
928
929pub use fallow_api::audit_keys as keys;
930
931#[path = "audit_review_deltas.rs"]
932pub mod review_deltas;
933
934#[path = "audit_weakening.rs"]
935pub mod weakening;
936
937#[path = "audit_routing.rs"]
938pub mod routing;
939
940use keys::{
941    dead_code_keys, dupe_group_key, dupes_keys, health_finding_key, health_keys,
942    retain_introduced_dead_code, styling_finding_key, styling_keys,
943};
944
945struct HeadAnalyses {
946    check: Option<CheckResult>,
947    dupes: Option<DupesResult>,
948    health: Option<HealthResult>,
949}
950
951/// HEAD analyses result paired with an optional freshly computed base snapshot
952/// (present only when a real base worktree was run in parallel).
953type HeadAndBaseResult = (
954    Result<HeadAnalyses, ExitCode>,
955    Option<Result<AuditKeySnapshot, ExitCode>>,
956);
957
958/// Run the HEAD analyses, optionally alongside a fresh base snapshot via
959/// `rayon::join` when `run_base` is set. Mirrors the previous inline branch.
960fn run_audit_head_and_base(
961    opts: &AuditOptions<'_>,
962    changed_since: Option<&str>,
963    changed_files: &FxHashSet<PathBuf>,
964    base_ref: &str,
965    base_cache_key: Option<&AuditBaseSnapshotCacheKey>,
966    run_base: bool,
967) -> HeadAndBaseResult {
968    if run_base {
969        let base_sha = base_cache_key.map(|key| key.base_sha.as_str());
970        let (h, b) = rayon::join(
971            || run_audit_head_analyses(opts, changed_since, changed_files),
972            || compute_base_snapshot(opts, base_ref, changed_files, base_sha),
973        );
974        (h, Some(b))
975    } else {
976        (
977            run_audit_head_analyses(opts, changed_since, changed_files),
978            None,
979        )
980    }
981}
982
983struct AuditResultParts {
984    verdict: AuditVerdict,
985    summary: AuditSummary,
986    attribution: AuditAttribution,
987    base_snapshot: Option<AuditKeySnapshot>,
988    base_snapshot_skipped: bool,
989    changed_files_count: usize,
990    changed_files: FxHashSet<PathBuf>,
991    base_ref: String,
992    base_description: Option<String>,
993    head_sha: Option<String>,
994    output: OutputFormat,
995    performance: bool,
996    check: Option<CheckResult>,
997    dupes: Option<DupesResult>,
998    health: Option<HealthResult>,
999    elapsed: Duration,
1000    review_deltas: Option<crate::audit_brief::ReviewDeltas>,
1001    weakening_signals: Vec<weakening::WeakeningSignal>,
1002    routing: Option<routing::RoutingFacts>,
1003    decision_surface: Option<crate::audit_decision_surface::DecisionSurface>,
1004    graph_snapshot_hash: Option<String>,
1005    change_anchors: Vec<crate::audit_walkthrough::ChangeAnchor>,
1006}
1007
1008/// Run the three HEAD-side analyses with intra-pipeline sharing intact:
1009/// check first (so its parsed modules are available), then dupes (which can
1010/// reuse check's discovered file list when production settings match), then
1011/// health (which can reuse check's parsed modules when production settings
1012/// match). Designed to be called from inside `rayon::join` alongside
1013/// [`compute_base_snapshot`], which operates on an isolated worktree.
1014fn run_audit_head_analyses(
1015    opts: &AuditOptions<'_>,
1016    changed_since: Option<&str>,
1017    changed_files: &FxHashSet<PathBuf>,
1018) -> Result<HeadAnalyses, ExitCode> {
1019    let check_production = opts.production_dead_code.unwrap_or(opts.production);
1020    let health_production = opts.production_health.unwrap_or(opts.production);
1021    let dupes_production = opts.production_dupes.unwrap_or(opts.production);
1022    let share_dead_code_parse_with_health = check_production == health_production;
1023    let share_dead_code_files_with_dupes =
1024        share_dead_code_parse_with_health && check_production == dupes_production;
1025
1026    let mut check = run_audit_check(opts, changed_since, share_dead_code_parse_with_health)?;
1027    let dupes_files = if share_dead_code_files_with_dupes {
1028        check
1029            .as_ref()
1030            .and_then(|r| r.shared_parse.as_ref().map(|sp| sp.files.clone()))
1031    } else {
1032        None
1033    };
1034    let dupes = run_audit_dupes(opts, changed_since, Some(changed_files), dupes_files)?;
1035    // Compute the impact closure AND the exports-aware public-export key
1036    // set for the review brief BEFORE health consumes the shared parse (which
1037    // owns the retained graph). Both are stored on the check result so they
1038    // survive the graph drop.
1039    if opts.brief
1040        && let Some(ref mut check) = check
1041    {
1042        check.impact_closure = compute_brief_impact_closure(opts.root, check, changed_files);
1043        check.public_api_keys = Some(public_api_keys_from_check(Some(check), opts.root));
1044        check.partition_order = compute_brief_partition_order(opts.root, check, changed_files);
1045        check.focus_facts = compute_brief_focus_facts(opts.root, check, changed_files);
1046        check.export_lines = compute_brief_export_lines(opts.root, check, changed_files);
1047        check.internal_consumers =
1048            compute_brief_internal_consumers(opts.root, check, changed_files);
1049    }
1050    let shared_parse = if share_dead_code_parse_with_health {
1051        check.as_mut().and_then(|r| r.shared_parse.take())
1052    } else {
1053        None
1054    };
1055    let health = run_audit_health(opts, changed_since, shared_parse)?;
1056    Ok(HeadAnalyses {
1057        check,
1058        dupes,
1059        health,
1060    })
1061}
1062
1063/// Compute the impact closure for the review brief from the check result's
1064/// retained graph against the changed-file set.
1065///
1066/// Delegates changed-path resolution and graph traversal to the engine, then
1067/// returns `{ in_diff, affected_not_shown, coordination_gap }`. Returns `None`
1068/// when the graph was not retained (off the brief path) or no changed file maps
1069/// to a known module.
1070fn compute_brief_impact_closure(
1071    root: &std::path::Path,
1072    check: &CheckResult,
1073    changed_files: &FxHashSet<PathBuf>,
1074) -> Option<fallow_engine::module_graph::ImpactClosurePaths> {
1075    let graph = check
1076        .shared_parse
1077        .as_ref()
1078        .and_then(|sp| sp.analysis_output.as_ref())
1079        .and_then(|out| out.graph.as_ref())?;
1080
1081    fallow_engine::module_graph::impact_closure_for_changed_paths(graph, root, changed_files)
1082}
1083
1084/// Compute the partition + order for the review brief's stage 2 from the
1085/// check result's retained graph against the changed-file set.
1086///
1087/// Maps each changed absolute path to its graph `FileId`, groups the changed
1088/// files into by-module units, and computes a dependency-sensible review order
1089/// over those units. Returns `None` when the graph was not retained (off the
1090/// brief path) or no changed file maps to a known module.
1091fn compute_brief_partition_order(
1092    root: &std::path::Path,
1093    check: &CheckResult,
1094    changed_files: &FxHashSet<PathBuf>,
1095) -> Option<fallow_engine::module_graph::PartitionOrderPaths> {
1096    let graph = check
1097        .shared_parse
1098        .as_ref()
1099        .and_then(|sp| sp.analysis_output.as_ref())
1100        .and_then(|out| out.graph.as_ref())?;
1101
1102    fallow_engine::module_graph::partition_order_for_changed_paths(graph, root, changed_files)
1103}
1104
1105/// Precompute the per-changed-file `rel_path -> [(export-name, 1-based line)]` map
1106/// for the decision surface, from the retained graph's export spans + each file's
1107/// line offsets, BEFORE health drops the graph. Lets a coordination / public-API
1108/// decision anchor to the exact export line. `None` when the graph is not retained.
1109fn compute_brief_export_lines(
1110    root: &std::path::Path,
1111    check: &CheckResult,
1112    changed_files: &FxHashSet<PathBuf>,
1113) -> Option<FxHashMap<String, Vec<(String, u32)>>> {
1114    let graph = check
1115        .shared_parse
1116        .as_ref()
1117        .and_then(|sp| sp.analysis_output.as_ref())
1118        .and_then(|out| out.graph.as_ref())?;
1119
1120    fallow_engine::module_graph::export_lines_for_changed_paths(graph, root, changed_files)
1121}
1122
1123/// Precompute the per-anchor honest consumer count for the decision surface:
1124/// `rel_path -> count of distinct in-repo modules OUTSIDE the diff that directly
1125/// import the anchor file`, from the retained graph's reverse-deps BEFORE health
1126/// drops the graph (mirroring [`compute_brief_export_lines`]). This is the honest
1127/// per-decision DISPLAY number ("N in-repo modules already depend on this"),
1128/// distinct from the project-wide `affected_not_shown` ranking proxy. Importers
1129/// that are themselves part of the diff are excluded (they are the change, not a
1130/// pre-existing dependent). `None` when the graph is not retained.
1131fn compute_brief_internal_consumers(
1132    root: &std::path::Path,
1133    check: &CheckResult,
1134    changed_files: &FxHashSet<PathBuf>,
1135) -> Option<FxHashMap<String, u64>> {
1136    let graph = check
1137        .shared_parse
1138        .as_ref()
1139        .and_then(|sp| sp.analysis_output.as_ref())
1140        .and_then(|out| out.graph.as_ref())?;
1141
1142    fallow_engine::module_graph::internal_consumers_for_changed_paths(graph, root, changed_files)
1143}
1144
1145/// Compute the per-file focus graph facts (fan-in/out + the dynamic-dispatch /
1146/// re-export-indirection confidence-flag signals) for the review brief's stage 4
1147/// weighted focus map, from the check result's retained graph against the
1148/// changed-file set.
1149///
1150/// Maps each changed absolute path to its graph `FileId`, computes the per-file
1151/// blast + confidence signals, and path-resolves them. Returns `None` when the
1152/// graph was not retained (off the brief path) or no changed file maps to a known
1153/// module.
1154fn compute_brief_focus_facts(
1155    root: &std::path::Path,
1156    check: &CheckResult,
1157    changed_files: &FxHashSet<PathBuf>,
1158) -> Option<Vec<fallow_engine::module_graph::FocusFileFactsPaths>> {
1159    let graph = check
1160        .shared_parse
1161        .as_ref()
1162        .and_then(|sp| sp.analysis_output.as_ref())
1163        .and_then(|out| out.graph.as_ref())?;
1164
1165    fallow_engine::module_graph::focus_facts_for_changed_paths(graph, root, changed_files)
1166}
1167
1168/// Run the audit pipeline: resolve base ref, run analyses, compute verdict.
1169pub fn execute_audit(opts: &AuditOptions<'_>) -> Result<AuditResult, ExitCode> {
1170    let start = Instant::now();
1171
1172    let (base_ref, base_description) = resolve_base_ref(opts)?;
1173
1174    let Some(changed_files) = crate::check::get_changed_files(opts.root, &base_ref) else {
1175        return Err(emit_error(
1176            &format!(
1177                "could not determine changed files for base ref '{base_ref}'. Verify the ref exists in this git repository"
1178            ),
1179            2,
1180            opts.output,
1181        ));
1182    };
1183    let changed_files_count = changed_files.len();
1184
1185    if changed_files.is_empty() {
1186        return Ok(empty_audit_result(
1187            base_ref,
1188            base_description,
1189            opts,
1190            start.elapsed(),
1191        ));
1192    }
1193
1194    // Sweep only once audit will do real changed-code work. A clean tree never
1195    // creates or reuses a base worktree, so keeping the no-change fast path
1196    // free of worktree-listing IO is both safe and visibly cheaper.
1197    sweep_old_reusable_caches(
1198        opts.root,
1199        resolve_cache_max_age(opts.root, opts.config_path.as_ref()),
1200        opts.quiet,
1201    );
1202
1203    let changed_since = Some(base_ref.as_str());
1204
1205    let needs_real_base_snapshot = matches!(opts.gate, AuditGate::NewOnly)
1206        && !can_reuse_current_as_base(opts, &base_ref, &changed_files);
1207    let base_cache_key = if needs_real_base_snapshot {
1208        audit_base_snapshot_cache_key(opts, &base_ref, &changed_files)?
1209    } else {
1210        None
1211    };
1212    let cached_base_snapshot = base_cache_key
1213        .as_ref()
1214        .and_then(|key| load_cached_base_snapshot(opts, key));
1215
1216    let (head_res, base_res) = run_audit_head_and_base(
1217        opts,
1218        changed_since,
1219        &changed_files,
1220        &base_ref,
1221        base_cache_key.as_ref(),
1222        needs_real_base_snapshot && cached_base_snapshot.is_none(),
1223    );
1224
1225    assemble_audit_result(AuditAssemblyInput {
1226        opts,
1227        head_res,
1228        base_res,
1229        cached_base_snapshot,
1230        base_cache_key,
1231        changed_files,
1232        changed_files_count,
1233        base_ref,
1234        base_description,
1235        start,
1236    })
1237}
1238
1239/// Inputs threaded from the audit prelude into [`assemble_audit_result`].
1240struct AuditAssemblyInput<'a> {
1241    opts: &'a AuditOptions<'a>,
1242    head_res: Result<HeadAnalyses, ExitCode>,
1243    base_res: Option<Result<AuditKeySnapshot, ExitCode>>,
1244    cached_base_snapshot: Option<AuditKeySnapshot>,
1245    base_cache_key: Option<AuditBaseSnapshotCacheKey>,
1246    changed_files: FxHashSet<PathBuf>,
1247    changed_files_count: usize,
1248    base_ref: String,
1249    base_description: Option<String>,
1250    start: Instant,
1251}
1252
1253/// Resolve the base snapshot, compute attribution/verdict/summary, and build the
1254/// final `AuditResult` from the HEAD-side analyses.
1255fn assemble_audit_result(input: AuditAssemblyInput<'_>) -> Result<AuditResult, ExitCode> {
1256    let opts = input.opts;
1257    let head = input.head_res?;
1258    let mut check_result = head.check;
1259    let dupes_result = head.dupes;
1260    let health_result = head.health;
1261
1262    let (base_snapshot, base_snapshot_skipped) = resolve_base_snapshot(
1263        opts,
1264        input.cached_base_snapshot,
1265        input.base_res,
1266        input.base_cache_key.as_ref(),
1267        CurrentAnalysisRefs {
1268            check: check_result.as_ref(),
1269            dupes: dupes_result.as_ref(),
1270            health: health_result.as_ref(),
1271        },
1272    )?;
1273    if let Some(ref mut check) = check_result {
1274        check.shared_parse = None;
1275    }
1276    let (attribution, verdict, summary) = compute_audit_outcome(
1277        opts.gate,
1278        check_result.as_ref(),
1279        dupes_result.as_ref(),
1280        health_result.as_ref(),
1281        base_snapshot.as_ref(),
1282    );
1283
1284    // Review-brief data: deltas (head-vs-base sets), weakening (base-vs-head
1285    // diff scan over the changed files), and ownership routing. Brief-path only.
1286    let (review_deltas, weakening_signals, routing) = if opts.brief {
1287        compute_brief_e3_data(
1288            opts,
1289            check_result.as_ref(),
1290            base_snapshot.as_ref(),
1291            &input.changed_files,
1292            &input.base_ref,
1293        )
1294    } else {
1295        (None, Vec::new(), None)
1296    };
1297
1298    // Decision surface (the apex): classify the SOLID-3 candidates from the
1299    // deltas + coordination gaps, anchor each `signal_id`, rank, cap, and pair
1300    // the routed expert. Brief-path only.
1301    let decision_surface = if opts.brief {
1302        Some(compute_decision_surface(
1303            opts,
1304            check_result.as_ref(),
1305            review_deltas.as_ref(),
1306            routing.as_ref(),
1307        ))
1308    } else {
1309        None
1310    };
1311
1312    // Per-hunk change anchors (the region-level anchor set): derived from the
1313    // SAME diff source the run used (an opt-in `--diff-stdin`/`--diff-file` diff,
1314    // else the committed `base...HEAD`), so emission and validation anchor to one
1315    // changed set. Brief-path only.
1316    let change_anchors = if opts.brief {
1317        compute_change_anchors(opts.root, &input.base_ref)
1318    } else {
1319        Vec::new()
1320    };
1321
1322    // Graph-snapshot hash (the staleness pin): a deterministic hash of the
1323    // HEAD-side key sets plus the resolved base ref + head sha + the change-anchor
1324    // id set. Brief-path only. The verifier is the graph: a mutated tree changes a
1325    // key set OR a changed region, changes this hash, and refuses a stale agent
1326    // walkthrough on reentry (so a cited change_anchor that moved is refused too).
1327    let head_sha = get_head_sha(opts.root);
1328    let graph_snapshot_hash = if opts.brief {
1329        Some(compute_graph_snapshot_hash(
1330            check_result.as_ref(),
1331            dupes_result.as_ref(),
1332            health_result.as_ref(),
1333            &input.base_ref,
1334            head_sha.as_deref(),
1335            &change_anchors,
1336        ))
1337    } else {
1338        None
1339    };
1340
1341    Ok(build_audit_result(AuditResultParts {
1342        verdict,
1343        summary,
1344        attribution,
1345        base_snapshot,
1346        base_snapshot_skipped,
1347        changed_files_count: input.changed_files_count,
1348        changed_files: input.changed_files,
1349        base_ref: input.base_ref,
1350        base_description: input.base_description,
1351        head_sha,
1352        output: opts.output,
1353        performance: opts.performance,
1354        check: check_result,
1355        dupes: dupes_result,
1356        health: health_result,
1357        elapsed: input.start.elapsed(),
1358        review_deltas,
1359        weakening_signals,
1360        routing,
1361        decision_surface,
1362        graph_snapshot_hash,
1363        change_anchors,
1364    }))
1365}
1366
1367/// Compute the deterministic graph-snapshot hash from the HEAD-side analysis
1368/// results plus the resolved base ref + head sha. Reuses [`snapshot_from_results`]
1369/// for the six key sets (dead_code / health / dupes / boundary_edges / cycles /
1370/// public_api), each sorted, then folds in the base ref and head sha so the same
1371/// tree compared against the same base always yields the same hash.
1372///
1373/// The verifier is the graph: any structural change (a new finding, a new edge,
1374/// a new export) shifts a key set and changes this hash, so a stale agent
1375/// walkthrough whose echoed hash no longer matches is REFUSED on reentry.
1376fn compute_graph_snapshot_hash(
1377    check: Option<&CheckResult>,
1378    dupes: Option<&DupesResult>,
1379    health: Option<&HealthResult>,
1380    base_ref: &str,
1381    head_sha: Option<&str>,
1382    change_anchors: &[crate::audit_walkthrough::ChangeAnchor],
1383) -> String {
1384    // The HEAD public-export set was computed on the brief path and retained on
1385    // the check result (`public_api_keys`); reuse it so the hash is exports-aware
1386    // without re-walking the graph.
1387    let public_api = check
1388        .and_then(|c| c.public_api_keys.clone())
1389        .unwrap_or_default();
1390    let snapshot = snapshot_from_results(check, dupes, health, public_api);
1391    let mut bytes: Vec<u8> = Vec::new();
1392    // Sorted key sets, each length-prefixed, so the byte stream is unambiguous.
1393    for set in [
1394        &snapshot.dead_code,
1395        &snapshot.health,
1396        &snapshot.dupes,
1397        &snapshot.boundary_edges,
1398        &snapshot.cycles,
1399        &snapshot.public_api,
1400    ] {
1401        for key in sorted_keys(set) {
1402            bytes.extend_from_slice(key.as_bytes());
1403            bytes.push(0);
1404        }
1405        bytes.push(1);
1406    }
1407    // Seventh key set: the SORTED change-anchor id set, so a moved/added/removed
1408    // changed region shifts this hash and a cited change_anchor that moved is
1409    // refused as stale (the finding key sets are line-independent and would not
1410    // otherwise cover the region-level anchors).
1411    let mut anchor_ids: Vec<&str> = change_anchors
1412        .iter()
1413        .map(|a| a.change_anchor.as_str())
1414        .collect();
1415    anchor_ids.sort_unstable();
1416    for id in anchor_ids {
1417        bytes.extend_from_slice(id.as_bytes());
1418        bytes.push(0);
1419    }
1420    bytes.push(1);
1421    bytes.extend_from_slice(base_ref.as_bytes());
1422    bytes.push(0);
1423    bytes.extend_from_slice(head_sha.unwrap_or("").as_bytes());
1424    format!("graph:{:016x}", xxh3_64(&bytes))
1425}
1426
1427/// Derive the per-hunk change anchors for this run from the SAME diff source the
1428/// run used: the opt-in shared diff (`--diff-stdin` / `--diff-file`) when present,
1429/// else the committed merge-base diff `base_ref...HEAD`. Using one source for both
1430/// the guide emission and the `--walkthrough-file` validation keeps the emitted
1431/// anchor set equal to the set validated on reentry (a committed-vs-staged
1432/// mismatch would otherwise reject every staged region's anchor). A diff that
1433/// cannot be computed yields an empty set (no anchors, never a panic).
1434fn compute_change_anchors(
1435    root: &std::path::Path,
1436    base_ref: &str,
1437) -> Vec<crate::audit_walkthrough::ChangeAnchor> {
1438    if let Some(raw) = crate::report::ci::diff_filter::shared_diff_raw() {
1439        return crate::audit_walkthrough::parse_change_anchors(raw);
1440    }
1441    fallow_engine::changed_files::try_get_changed_diff(root, base_ref)
1442        .map(|diff| crate::audit_walkthrough::parse_change_anchors(&diff))
1443        .unwrap_or_default()
1444}
1445
1446/// Compute the decision surface from the assembled brief inputs: gather the
1447/// boundary anchors (one representative per introduced zone-pair), the
1448/// coordination gaps, and the impact-closure blast magnitude, then run the
1449/// extractor. The cap is taken from the audit options (clamped to [3, 5] by the
1450/// extractor). Returns an empty surface when no check result is available.
1451fn compute_decision_surface(
1452    opts: &AuditOptions<'_>,
1453    check: Option<&CheckResult>,
1454    review_deltas: Option<&crate::audit_brief::ReviewDeltas>,
1455    routing: Option<&routing::RoutingFacts>,
1456) -> crate::audit_decision_surface::DecisionSurface {
1457    use crate::audit_decision_surface::{
1458        BoundaryAnchor, CoordinationAnchor, DecisionInputs, extract_decision_surface,
1459    };
1460
1461    let (Some(check), Some(deltas)) = (check, review_deltas) else {
1462        return crate::audit_decision_surface::DecisionSurface::default();
1463    };
1464    let root = &check.config.root;
1465
1466    // One representative boundary anchor per introduced zone-pair: pick the first
1467    // violation whose zone-pair key matches an introduced edge, for the file +
1468    // line suppression/routing anchor.
1469    let mut boundary_anchors: Vec<BoundaryAnchor> = Vec::new();
1470    let mut seen_pairs: FxHashSet<String> = FxHashSet::default();
1471    for finding in &check.results.boundary_violations {
1472        let key = review_deltas::boundary_edge_key(finding);
1473        if !deltas.boundary_introduced.contains(&key) || !seen_pairs.insert(key.clone()) {
1474            continue;
1475        }
1476        boundary_anchors.push(BoundaryAnchor {
1477            zone_pair_key: key,
1478            from_file: keys::relative_key_path(&finding.violation.from_path, root),
1479            from_zone: finding.violation.from_zone.clone(),
1480            to_zone: finding.violation.to_zone.clone(),
1481            line: finding.violation.line,
1482        });
1483    }
1484
1485    // Coordination gaps projected to the public-API/contract decision shape.
1486    // Aggregate per changed file: ONE contract decision per changed file (R1
1487    // batch-consolidate), counting its distinct non-diff consumers as the blast.
1488    let closure = check.impact_closure.as_ref();
1489    let mut coordination: Vec<CoordinationAnchor> = closure
1490        .map(|c| aggregate_coordination_gaps(&c.coordination_gap))
1491        .unwrap_or_default();
1492    let affected_not_shown = closure.map_or(0, |c| c.affected_not_shown.len() as u64);
1493
1494    let empty_routing = routing::RoutingFacts::default();
1495    let routing = routing.unwrap_or(&empty_routing);
1496
1497    // Head-source reader for suppression checks AND for resolving a contract
1498    // symbol's declaration line: read the on-disk (head) content of an anchor file
1499    // by its root-relative path. Best-effort; an unreadable file is not suppressed.
1500    let root_owned = root.clone();
1501    let head_source = move |rel: &str| std::fs::read_to_string(root_owned.join(rel)).ok();
1502
1503    // Resolve a contract symbol's 1-based declaration line from the per-file
1504    // export-line map precomputed on the brief path (the graph is already dropped
1505    // by health here, so we cannot re-derive it now). Lets coordination /
1506    // public-API decisions deep-link to the exact export instead of the file head.
1507    let export_lines = check.export_lines.as_ref();
1508    let resolve_line = |rel: &str, symbols: &[String]| -> u32 {
1509        let Some(exports) = export_lines.and_then(|map| map.get(rel)) else {
1510            return 0;
1511        };
1512        exports
1513            .iter()
1514            .find(|(name, _)| symbols.iter().any(|s| name == s))
1515            .or_else(|| exports.first())
1516            .map_or(0, |(_, line)| *line)
1517    };
1518    for anchor in &mut coordination {
1519        anchor.line = resolve_line(&anchor.changed_file, &anchor.consumed_symbols);
1520    }
1521    let public_api_anchor_line = deltas.public_api_added.first().map_or(0, |key| {
1522        let mut parts = key.splitn(2, "::");
1523        let path = parts.next().unwrap_or_default();
1524        let name = parts.next().unwrap_or_default();
1525        resolve_line(path, &[name.to_string()])
1526    });
1527
1528    // Rename resolver: a head (post-rename) root-relative path -> its pre-rename
1529    // path, from the diff's rename pairs. Best-effort (empty without a shared diff
1530    // or renames); lets each decision carry a rename-durable `previous_signal_id`.
1531    let rename_old_path = |rel: &str| -> Option<String> {
1532        crate::report::ci::diff_filter::shared_diff_index()
1533            .and_then(|idx| idx.old_path_for(rel))
1534            .map(str::to_string)
1535    };
1536
1537    // Honest per-anchor consumer count, looked up from the map precomputed before
1538    // the graph drop. `0` for an anchor with no recorded importers (a new file).
1539    let internal_consumers_map = check.internal_consumers.as_ref();
1540    let internal_consumers = |rel: &str| -> u64 {
1541        internal_consumers_map
1542            .and_then(|map| map.get(rel))
1543            .copied()
1544            .unwrap_or(0)
1545    };
1546
1547    extract_decision_surface(&DecisionInputs {
1548        deltas,
1549        boundary_anchors: &boundary_anchors,
1550        coordination: &coordination,
1551        public_api_anchor_line,
1552        affected_not_shown,
1553        routing,
1554        head_source: &head_source,
1555        rename_old_path: &rename_old_path,
1556        internal_consumers: &internal_consumers,
1557        cap: opts.max_decisions,
1558    })
1559}
1560
1561/// Aggregate per-(changed, consumer) coordination gaps into ONE contract anchor
1562/// per changed file (R1 batch-consolidate), with the distinct-consumer count as
1563/// the blast and the union of consumed symbols as the contract. Sorted by changed
1564/// file for deterministic output.
1565fn aggregate_coordination_gaps(
1566    gaps: &[fallow_engine::module_graph::CoordinationGapPaths],
1567) -> Vec<crate::audit_decision_surface::CoordinationAnchor> {
1568    use crate::audit_decision_surface::CoordinationAnchor;
1569    let mut by_file: FxHashMap<String, (u64, FxHashSet<String>)> = FxHashMap::default();
1570    for gap in gaps {
1571        let entry = by_file
1572            .entry(gap.changed_file.clone())
1573            .or_insert_with(|| (0, FxHashSet::default()));
1574        entry.0 += 1;
1575        for symbol in &gap.consumed_symbols {
1576            entry.1.insert(symbol.clone());
1577        }
1578    }
1579    let mut anchors: Vec<CoordinationAnchor> = by_file
1580        .into_iter()
1581        .map(|(changed_file, (consumer_count, symbols))| {
1582            let mut consumed_symbols: Vec<String> = symbols.into_iter().collect();
1583            consumed_symbols.sort_unstable();
1584            CoordinationAnchor {
1585                changed_file,
1586                consumed_symbols,
1587                consumer_count,
1588                line: 0,
1589            }
1590        })
1591        .collect();
1592    anchors.sort_by(|a, b| a.changed_file.cmp(&b.changed_file));
1593    anchors
1594}
1595
1596/// Compute the review-brief data: the diff-aware deltas (head sets vs base
1597/// snapshot), the weakening-signal pass (base-vs-head diff over the changed
1598/// files), and ownership routing. Pure-ish: weakening + routing shell out to git
1599/// (via [`BaseFileReader`] / churn), so this runs only on the brief path.
1600fn compute_brief_e3_data(
1601    opts: &AuditOptions<'_>,
1602    check: Option<&CheckResult>,
1603    base_snapshot: Option<&AuditKeySnapshot>,
1604    changed_files: &FxHashSet<PathBuf>,
1605    base_ref: &str,
1606) -> (
1607    Option<crate::audit_brief::ReviewDeltas>,
1608    Vec<weakening::WeakeningSignal>,
1609    Option<routing::RoutingFacts>,
1610) {
1611    let deltas = check.map(|check| {
1612        let head_boundary = review_deltas::boundary_edge_keys(&check.results.boundary_violations);
1613        let head_cycles =
1614            review_deltas::cycle_keys(&check.results.circular_dependencies, &check.config.root);
1615        let head_public_api = check.public_api_keys.clone().unwrap_or_default();
1616        let empty = FxHashSet::default();
1617        let (base_boundary, base_cycles, base_public_api) = base_snapshot
1618            .map_or((&empty, &empty, &empty), |b| {
1619                (&b.boundary_edges, &b.cycles, &b.public_api)
1620            });
1621        crate::audit_brief::build_review_deltas(
1622            &head_boundary,
1623            base_boundary,
1624            &head_cycles,
1625            base_cycles,
1626            &head_public_api,
1627            base_public_api,
1628        )
1629    });
1630
1631    let weakening_signals = compute_weakening_signals(opts.root, base_ref, changed_files);
1632
1633    let routing =
1634        check.map(|check| routing::compute_routing(opts.root, &check.config, changed_files));
1635
1636    (deltas, weakening_signals, routing)
1637}
1638
1639/// Run the weakening-signal pass over the changed files: read each file's base
1640/// content via [`BaseFileReader`], diff it against the on-disk head content, and
1641/// emit a [`weakening::WeakeningSignal`] per detected weakening. Best-effort: a
1642/// file whose base or head cannot be read is skipped silently.
1643fn compute_weakening_signals(
1644    root: &Path,
1645    base_ref: &str,
1646    changed_files: &FxHashSet<PathBuf>,
1647) -> Vec<weakening::WeakeningSignal> {
1648    use weakening::{WeakeningKind, WeakeningSignal};
1649
1650    let Some(git_root) = git_toplevel(root) else {
1651        return Vec::new();
1652    };
1653    let Some(mut reader) = BaseFileReader::spawn(root) else {
1654        return Vec::new();
1655    };
1656
1657    let mut signals: Vec<WeakeningSignal> = Vec::new();
1658    // Sort the changed files for deterministic signal ordering.
1659    let mut files: Vec<&PathBuf> = changed_files.iter().collect();
1660    files.sort();
1661
1662    for abs in files {
1663        let Ok(relative) = abs.strip_prefix(&git_root) else {
1664            continue;
1665        };
1666        let rel_str = relative.to_string_lossy().replace('\\', "/");
1667        let head = std::fs::read_to_string(abs).unwrap_or_default();
1668        let base = reader.read(base_ref, relative).unwrap_or_default();
1669        // A net-new file (no base) or a non-source file still gets the scan; the
1670        // detectors are no-ops on irrelevant content.
1671
1672        if weakening::is_test_file(&rel_str) {
1673            for token in weakening::detect_test_weakening(&base, &head) {
1674                signals.push(WeakeningSignal {
1675                    kind: WeakeningKind::TestWeakened,
1676                    file: rel_str.clone(),
1677                    evidence: format!("{token} added"),
1678                });
1679            }
1680            for ev in weakening::detect_removed_tests(&base, &head) {
1681                signals.push(WeakeningSignal {
1682                    kind: WeakeningKind::TestWeakened,
1683                    file: rel_str.clone(),
1684                    evidence: ev,
1685                });
1686            }
1687        }
1688        for ev in weakening::detect_added_suppressions(&base, &head) {
1689            signals.push(WeakeningSignal {
1690                kind: WeakeningKind::SuppressionAdded,
1691                file: rel_str.clone(),
1692                evidence: ev,
1693            });
1694        }
1695        for ev in weakening::detect_lowered_thresholds(&base, &head) {
1696            signals.push(WeakeningSignal {
1697                kind: WeakeningKind::ThresholdLowered,
1698                file: rel_str.clone(),
1699                evidence: ev,
1700            });
1701        }
1702        if weakening::is_ci_file(&rel_str) {
1703            for ev in weakening::detect_removed_security_steps(&base, &head) {
1704                signals.push(WeakeningSignal {
1705                    kind: WeakeningKind::SecurityCheckRemoved,
1706                    file: rel_str.clone(),
1707                    evidence: ev,
1708                });
1709            }
1710        }
1711    }
1712    signals
1713}
1714
1715/// Attribution, verdict, and summary computed together from the HEAD analyses and
1716/// base snapshot. Also records the final result count for telemetry.
1717fn compute_audit_outcome(
1718    gate: AuditGate,
1719    check: Option<&CheckResult>,
1720    dupes: Option<&DupesResult>,
1721    health: Option<&HealthResult>,
1722    base: Option<&AuditKeySnapshot>,
1723) -> (AuditAttribution, AuditVerdict, AuditSummary) {
1724    let attribution = compute_audit_attribution(check, dupes, health, base, gate);
1725    let verdict = compute_audit_verdict(gate, check, dupes, health, base);
1726    let summary = build_summary(check, dupes, health);
1727    crate::telemetry::note_final_result_count(
1728        summary.dead_code_issues + summary.complexity_findings + summary.duplication_clone_groups,
1729    );
1730    (attribution, verdict, summary)
1731}
1732
1733/// Resolve the base key snapshot for the `new`-only gate: prefer the cache, then a
1734/// freshly computed base worktree (persisting it), else fall back to current keys
1735/// (marking the snapshot skipped). Returns `(None, false)` outside `new`-only mode.
1736/// The current-run analysis result references threaded together so the base
1737/// snapshot resolver can fall back to the current keys without a six-deep
1738/// argument list. Bundled refs of the optional check / dupes / health results.
1739#[derive(Clone, Copy)]
1740struct CurrentAnalysisRefs<'a> {
1741    check: Option<&'a CheckResult>,
1742    dupes: Option<&'a DupesResult>,
1743    health: Option<&'a HealthResult>,
1744}
1745
1746fn resolve_base_snapshot(
1747    opts: &AuditOptions<'_>,
1748    cached_base_snapshot: Option<AuditKeySnapshot>,
1749    base_res: Option<Result<AuditKeySnapshot, ExitCode>>,
1750    base_cache_key: Option<&AuditBaseSnapshotCacheKey>,
1751    current: CurrentAnalysisRefs<'_>,
1752) -> Result<(Option<AuditKeySnapshot>, bool), ExitCode> {
1753    if !matches!(opts.gate, AuditGate::NewOnly) {
1754        return Ok((None, false));
1755    }
1756    if let Some(snapshot) = cached_base_snapshot {
1757        return Ok((Some(snapshot), false));
1758    }
1759    if let Some(base_res) = base_res {
1760        let snapshot = base_res?;
1761        if let Some(key) = base_cache_key {
1762            save_cached_base_snapshot(opts, key, &snapshot);
1763        }
1764        return Ok((Some(snapshot), false));
1765    }
1766    let CurrentAnalysisRefs {
1767        check,
1768        dupes,
1769        health,
1770    } = current;
1771    Ok((Some(current_keys_as_base_keys(check, dupes, health)), true))
1772}
1773
1774/// Pick the audit verdict: the introduced-only gate in `new`-only mode, otherwise
1775/// the full backlog verdict.
1776fn compute_audit_verdict(
1777    gate: AuditGate,
1778    check: Option<&CheckResult>,
1779    dupes: Option<&DupesResult>,
1780    health: Option<&HealthResult>,
1781    base: Option<&AuditKeySnapshot>,
1782) -> AuditVerdict {
1783    if matches!(gate, AuditGate::NewOnly) {
1784        compute_introduced_verdict(check, dupes, health, base)
1785    } else {
1786        compute_verdict(check, dupes, health)
1787    }
1788}
1789
1790fn build_audit_result(parts: AuditResultParts) -> AuditResult {
1791    AuditResult {
1792        verdict: parts.verdict,
1793        summary: parts.summary,
1794        attribution: parts.attribution,
1795        base_snapshot: parts.base_snapshot,
1796        base_snapshot_skipped: parts.base_snapshot_skipped,
1797        changed_files_count: parts.changed_files_count,
1798        changed_files: parts.changed_files.into_iter().collect(),
1799        base_ref: parts.base_ref,
1800        base_description: parts.base_description,
1801        head_sha: parts.head_sha,
1802        output: parts.output,
1803        performance: parts.performance,
1804        check: parts.check,
1805        dupes: parts.dupes,
1806        health: parts.health,
1807        elapsed: parts.elapsed,
1808        review_deltas: parts.review_deltas,
1809        weakening_signals: parts.weakening_signals,
1810        routing: parts.routing,
1811        decision_surface: parts.decision_surface,
1812        graph_snapshot_hash: parts.graph_snapshot_hash,
1813        change_anchors: parts.change_anchors,
1814    }
1815}
1816
1817/// Build an empty pass result when no files have changed.
1818fn empty_audit_result(
1819    base_ref: String,
1820    base_description: Option<String>,
1821    opts: &AuditOptions<'_>,
1822    elapsed: Duration,
1823) -> AuditResult {
1824    crate::telemetry::note_final_result_count(0);
1825
1826    let head_sha = get_head_sha(opts.root);
1827    // An empty changeset is a valid graph state: pin a hash on the brief path so
1828    // the walkthrough guide still carries a stable snapshot pin (no findings, so
1829    // the hash folds only the base ref + head sha).
1830    let graph_snapshot_hash = if opts.brief {
1831        // An empty changeset has no changed regions, so no change anchors.
1832        Some(compute_graph_snapshot_hash(
1833            None,
1834            None,
1835            None,
1836            &base_ref,
1837            head_sha.as_deref(),
1838            &[],
1839        ))
1840    } else {
1841        None
1842    };
1843
1844    AuditResult {
1845        verdict: AuditVerdict::Pass,
1846        summary: AuditSummary {
1847            dead_code_issues: 0,
1848            dead_code_has_errors: false,
1849            complexity_findings: 0,
1850            max_cyclomatic: None,
1851            duplication_clone_groups: 0,
1852        },
1853        attribution: AuditAttribution {
1854            gate: opts.gate,
1855            ..AuditAttribution::default()
1856        },
1857        base_snapshot: None,
1858        base_snapshot_skipped: false,
1859        changed_files_count: 0,
1860        changed_files: Vec::new(),
1861        base_ref,
1862        base_description,
1863        head_sha,
1864        output: opts.output,
1865        performance: opts.performance,
1866        check: None,
1867        dupes: None,
1868        health: None,
1869        elapsed,
1870        review_deltas: None,
1871        weakening_signals: Vec::new(),
1872        routing: None,
1873        decision_surface: None,
1874        graph_snapshot_hash,
1875        change_anchors: Vec::new(),
1876    }
1877}
1878
1879/// Run dead code analysis for the audit pipeline.
1880fn run_audit_check<'a>(
1881    opts: &'a AuditOptions<'a>,
1882    changed_since: Option<&'a str>,
1883    retain_modules_for_health: bool,
1884) -> Result<Option<CheckResult>, ExitCode> {
1885    let filters = IssueFilters::default();
1886    // The review brief needs the module graph for the impact closure, which
1887    // rides the retained-modules path. Force retention on the brief path even
1888    // when health does not share the dead-code parse (mismatched production
1889    // modes), so the graph is available before health consumes the shared parse.
1890    let retain_modules_for_health = retain_modules_for_health || opts.brief;
1891    let trace_opts = TraceOptions {
1892        trace_export: None,
1893        trace_file: None,
1894        trace_dependency: None,
1895        impact_closure: None,
1896        performance: opts.performance,
1897    };
1898    match crate::check::execute_check(&CheckOptions {
1899        root: opts.root,
1900        config_path: opts.config_path,
1901        output: opts.output,
1902        no_cache: opts.no_cache,
1903        threads: opts.threads,
1904        quiet: opts.quiet,
1905        fail_on_issues: false,
1906        filters: &filters,
1907        changed_since,
1908        diff_index: None,
1909        use_shared_diff_index: true,
1910        baseline: opts.dead_code_baseline,
1911        save_baseline: None,
1912        sarif_file: None,
1913        production: opts.production_dead_code.unwrap_or(opts.production),
1914        production_override: opts.production_dead_code,
1915        workspace: opts.workspace,
1916        changed_workspaces: opts.changed_workspaces,
1917        group_by: opts.group_by,
1918        include_dupes: false,
1919        trace_opts: &trace_opts,
1920        explain: opts.explain,
1921        top: None,
1922        file: &[],
1923        include_entry_exports: opts.include_entry_exports,
1924        summary: false,
1925        regression_opts: crate::regression::RegressionOpts {
1926            fail_on_regression: false,
1927            tolerance: crate::regression::Tolerance::Absolute(0),
1928            regression_baseline_file: None,
1929            save_target: crate::regression::SaveRegressionTarget::None,
1930            scoped: true,
1931            quiet: opts.quiet,
1932            output: opts.output,
1933        },
1934        retain_modules_for_health,
1935        defer_performance: false,
1936    }) {
1937        Ok(r) => Ok(Some(r)),
1938        Err(code) => Err(code),
1939    }
1940}
1941
1942/// Run duplication analysis for the audit pipeline.
1943///
1944/// Reads duplication settings from the project config file so that user
1945/// options like `ignoreImports`, `crossLanguage`, and `skipLocal` are
1946/// respected (same as combined mode).
1947fn run_audit_dupes<'a>(
1948    opts: &'a AuditOptions<'a>,
1949    changed_since: Option<&'a str>,
1950    changed_files: Option<&'a FxHashSet<PathBuf>>,
1951    pre_discovered: Option<Vec<fallow_types::discover::DiscoveredFile>>,
1952) -> Result<Option<DupesResult>, ExitCode> {
1953    let dupes_cfg = match crate::load_config_for_analysis(
1954        opts.root,
1955        opts.config_path,
1956        crate::ConfigLoadOptions {
1957            output: opts.output,
1958            no_cache: opts.no_cache,
1959            threads: opts.threads,
1960            production_override: opts
1961                .production_dupes
1962                .or_else(|| opts.production.then_some(true)),
1963            quiet: opts.quiet,
1964        },
1965        fallow_config::ProductionAnalysis::Dupes,
1966    ) {
1967        Ok(c) => c.duplicates,
1968        Err(code) => return Err(code),
1969    };
1970    let dupes_opts = build_audit_dupes_options(opts, changed_since, changed_files, &dupes_cfg);
1971    let dupes_run = if let Some(files) = pre_discovered {
1972        crate::dupes::execute_dupes_with_files(&dupes_opts, files)
1973    } else {
1974        crate::dupes::execute_dupes(&dupes_opts)
1975    };
1976    match dupes_run {
1977        Ok(r) => Ok(Some(r)),
1978        Err(code) => Err(code),
1979    }
1980}
1981
1982/// Build the `DupesOptions` for an audit run from project config + audit options.
1983fn build_audit_dupes_options<'a>(
1984    opts: &'a AuditOptions<'a>,
1985    changed_since: Option<&'a str>,
1986    changed_files: Option<&'a FxHashSet<PathBuf>>,
1987    dupes_cfg: &fallow_config::DuplicatesConfig,
1988) -> DupesOptions<'a> {
1989    DupesOptions {
1990        root: opts.root,
1991        config_path: opts.config_path,
1992        output: opts.output,
1993        no_cache: opts.no_cache,
1994        threads: opts.threads,
1995        quiet: opts.quiet,
1996        mode: Some(DupesMode::from(dupes_cfg.mode)),
1997        min_tokens: Some(dupes_cfg.min_tokens),
1998        min_lines: Some(dupes_cfg.min_lines),
1999        min_occurrences: Some(dupes_cfg.min_occurrences),
2000        threshold: Some(dupes_cfg.threshold),
2001        skip_local: dupes_cfg.skip_local,
2002        cross_language: dupes_cfg.cross_language,
2003        ignore_imports: Some(dupes_cfg.ignore_imports),
2004        top: None,
2005        baseline_path: opts.dupes_baseline,
2006        save_baseline_path: None,
2007        production: opts.production_dupes.unwrap_or(opts.production),
2008        production_override: opts.production_dupes,
2009        trace: None,
2010        changed_since,
2011        diff_index: None,
2012        use_shared_diff_index: true,
2013        changed_files,
2014        workspace: opts.workspace,
2015        changed_workspaces: opts.changed_workspaces,
2016        explain: opts.explain,
2017        explain_skipped: opts.explain_skipped,
2018        summary: false,
2019        group_by: opts.group_by,
2020        performance: false,
2021    }
2022}
2023
2024/// Run complexity analysis for the audit pipeline (findings only, no scores/hotspots/targets).
2025fn run_audit_health<'a>(
2026    opts: &'a AuditOptions<'a>,
2027    changed_since: Option<&'a str>,
2028    shared_parse: Option<fallow_engine::health::HealthSharedParseData>,
2029) -> Result<Option<HealthResult>, ExitCode> {
2030    let runtime_coverage = match opts.runtime_coverage {
2031        Some(path) => match crate::health::coverage::prepare_options(
2032            path,
2033            opts.min_invocations_hot,
2034            None,
2035            None,
2036            opts.output,
2037        ) {
2038            Ok(options) => Some(options),
2039            Err(code) => return Err(code),
2040        },
2041        None => None,
2042    };
2043
2044    let health_opts = build_audit_health_options(opts, changed_since, runtime_coverage);
2045    let health_run = if let Some(shared) = shared_parse {
2046        crate::health::execute_health_with_shared_parse(&health_opts, shared)
2047    } else {
2048        crate::health::execute_health(&health_opts)
2049    };
2050    match health_run {
2051        Ok(r) => Ok(Some(r)),
2052        Err(code) => Err(code),
2053    }
2054}
2055
2056/// Build the findings-only `HealthOptions` for an audit run (no scores, hotspots,
2057/// ownership, or targets; `--churn-file` is health-only).
2058fn build_audit_health_options<'a>(
2059    opts: &'a AuditOptions<'a>,
2060    changed_since: Option<&'a str>,
2061    runtime_coverage: Option<fallow_engine::health::RuntimeCoverageOptions>,
2062) -> HealthOptions<'a> {
2063    HealthOptions {
2064        root: opts.root,
2065        config_path: opts.config_path,
2066        output: opts.output,
2067        no_cache: opts.no_cache,
2068        threads: opts.threads,
2069        quiet: opts.quiet,
2070        thresholds: fallow_engine::health::HealthThresholdOverrides {
2071            max_cyclomatic: None,
2072            max_cognitive: None,
2073            max_crap: opts.max_crap,
2074        },
2075        top: None,
2076        sort: fallow_engine::health::HealthSort::Cyclomatic,
2077        production: opts.production_health.unwrap_or(opts.production),
2078        production_override: opts.production_health,
2079        changed_since,
2080        diff_index: None,
2081        use_shared_diff_index: true,
2082        workspace: opts.workspace,
2083        changed_workspaces: opts.changed_workspaces,
2084        baseline: opts.health_baseline,
2085        save_baseline: None,
2086        complexity: true,
2087        file_scores: false,
2088        coverage_gaps: false,
2089        config_activates_coverage_gaps: false,
2090        hotspots: false,
2091        ownership: false,
2092        ownership_emails: None,
2093        targets: false,
2094        // Styling analytics surface in `fallow audit` so a coding agent gets
2095        // styling feedback in the same stream it already reads for dead-code +
2096        // complexity. Changed-file-scoped (cheap) + dep-gated; descriptive only
2097        // (verdict-neutral). See .plans/styling-findings-in-audit.md (Slice 1).
2098        css: opts.css,
2099        css_deep: opts.css_deep,
2100        force_full: false,
2101        score_only_output: false,
2102        enforce_coverage_gap_gate: false,
2103        effort: None,
2104        score: false,
2105        gates: fallow_engine::health::HealthGateOptions::default(),
2106        since: None,
2107        min_commits: None,
2108        explain: opts.explain,
2109        summary: false,
2110        save_snapshot: None,
2111        trend: false,
2112        coverage_inputs: fallow_engine::health::HealthCoverageInputs {
2113            coverage: opts.coverage,
2114            coverage_root: opts.coverage_root,
2115        },
2116        performance: opts.performance,
2117        runtime_coverage,
2118        churn_file: None,
2119        complexity_breakdown: false,
2120        group_by: opts.group_by.map(Into::into),
2121    }
2122}
2123
2124#[path = "audit_output.rs"]
2125mod output;
2126
2127pub use output::audit_json_header_input;
2128pub use output::{
2129    insert_audit_dead_code_json, insert_audit_duplication_json, insert_audit_health_json,
2130    print_audit_findings, print_audit_result,
2131};
2132
2133/// Run the full audit command: execute analyses, print results, return exit code.
2134/// Run audit, optionally tagged with a gate marker (e.g. `"pre-commit"`) so
2135/// Fallow Impact can record a containment event when the gate blocks then
2136/// clears. The marker only affects the local Impact store; it never changes
2137/// the verdict, exit code, or output.
2138pub fn run_audit(opts: &AuditOptions<'_>, gate_marker: Option<&str>) -> ExitCode {
2139    if let Err(e) = fallow_engine::health::validate_coverage_root_absolute(opts.coverage_root) {
2140        return emit_error(&e, 2, opts.output);
2141    }
2142    let coverage_resolved = opts
2143        .coverage
2144        .map(|p| crate::health::scoring::resolve_relative_to_root(p, Some(opts.root)));
2145    let runtime_coverage_resolved = opts
2146        .runtime_coverage
2147        .map(|p| crate::health::scoring::resolve_relative_to_root(p, Some(opts.root)));
2148    let resolved_opts = AuditOptions {
2149        coverage: coverage_resolved.as_deref(),
2150        runtime_coverage: runtime_coverage_resolved.as_deref(),
2151        ..*opts
2152    };
2153    match execute_audit(&resolved_opts) {
2154        Ok(result) => {
2155            let mut findings = result
2156                .check
2157                .as_ref()
2158                .map(|c| crate::impact::collect_dead_code_findings(&c.results))
2159                .unwrap_or_default();
2160            if let Some(health) = result.health.as_ref() {
2161                findings.extend(crate::impact::collect_complexity_findings(&health.report));
2162            }
2163            let clones = result
2164                .dupes
2165                .as_ref()
2166                .map(|d| crate::impact::collect_clone_findings(&d.report))
2167                .unwrap_or_default();
2168            let empty_supps: Vec<fallow_types::results::ActiveSuppression> = Vec::new();
2169            let suppressions = result.check.as_ref().map_or(empty_supps.as_slice(), |c| {
2170                c.results.active_suppressions.as_slice()
2171            });
2172            let attribution = crate::impact::AttributionInput {
2173                root: opts.root,
2174                scope: crate::impact::Scope::ChangedFiles(&result.changed_files),
2175                findings,
2176                clones,
2177                suppressions,
2178            };
2179            crate::impact::record_audit_run(
2180                opts.root,
2181                &result.summary,
2182                &crate::impact::AuditRunRecord {
2183                    verdict: result.verdict,
2184                    gate: gate_marker.is_some(),
2185                    git_sha: result.head_sha.as_deref(),
2186                    version: env!("CARGO_PKG_VERSION"),
2187                    timestamp: &crate::vital_signs::chrono_timestamp(),
2188                    attribution: Some(&attribution),
2189                },
2190            );
2191            // Walkthrough surfaces take priority over the brief body when
2192            // requested. Both always exit 0.
2193            if opts.walkthrough_guide {
2194                crate::audit_brief::print_walkthrough_guide_result(&result)
2195            } else if opts.walkthrough {
2196                crate::audit_brief::print_walkthrough_human_result(
2197                    &result,
2198                    opts.root,
2199                    opts.cache_dir,
2200                    opts.mark_viewed,
2201                    opts.show_cleared,
2202                    opts.quiet,
2203                )
2204            } else if let Some(path) = opts.walkthrough_file {
2205                crate::audit_brief::print_walkthrough_file_result(&result, path)
2206            } else if opts.brief {
2207                // Exit-0 seam: the brief renders the same analysis but never
2208                // gates on the verdict. `print_brief_result` always returns
2209                // SUCCESS.
2210                crate::audit_brief::print_brief_result(
2211                    &result,
2212                    opts.quiet,
2213                    opts.explain,
2214                    opts.show_deprioritized,
2215                )
2216            } else {
2217                print_audit_result(&result, opts.quiet, opts.explain)
2218            }
2219        }
2220        Err(code) => code,
2221    }
2222}
2223
2224/// Run the standalone `fallow decision-surface` command: the separable, cheap
2225/// apex. Executes the SAME changed-code analysis the review brief runs (it is
2226/// the brief path, NOT the full project pipeline), then emits ONLY the decision
2227/// surface envelope. Always exit 0 (the surface is advisory, never a gate).
2228///
2229/// The MCP `decision_surface` tool wraps this command. It is callable without the
2230/// full pipeline because it reuses `execute_audit` in brief mode (changed-code
2231/// scope), not bare `fallow`.
2232#[must_use]
2233pub fn run_decision_surface(opts: &AuditOptions<'_>) -> ExitCode {
2234    // Force brief mode: the decision surface is only computed on the brief path.
2235    let brief_opts = AuditOptions {
2236        brief: true,
2237        ..*opts
2238    };
2239    match execute_audit(&brief_opts) {
2240        Ok(result) => crate::audit_brief::print_decision_surface_result(&result, opts.quiet),
2241        Err(code) => code,
2242    }
2243}
2244
2245#[cfg(test)]
2246#[path = "audit_tests.rs"]
2247mod tests;