Skip to main content

fallow_cli/
audit_brief.rs

1//! `fallow audit --brief` (alias `fallow review`): a deterministic rendering
2//! mode layered over the existing audit analysis.
3//!
4//! The brief answers "where do I look?" rather than "will CI block this?". It
5//! is composition + rendering over the same [`crate::audit::AuditResult`] that
6//! drives `fallow audit`; it runs no new analysis and, critically, ALWAYS exits
7//! 0 so a reviewer or agent can read the orientation even when the underlying
8//! audit verdict is `fail`. The verdict is still computed and carried in the
9//! brief JSON informationally, but it never drives the exit code on this path.
10//!
11//! The JSON envelope is independently versioned and tagged as
12//! `kind: "audit-brief"` so the brief shape evolves on its own cadence without
13//! bumping the main `--format json` contract.
14
15use std::process::ExitCode;
16
17pub use fallow_output::{
18    CoordinationGapFact, DiffTriage, GraphFacts, ImpactClosureFacts, PartitionFacts,
19    ReviewBriefSchemaVersion, ReviewBriefSubtractSections, ReviewDeltas, ReviewEffort,
20    ReviewUnitFact, RiskClass,
21};
22use fallow_types::results::AnalysisResults;
23use rustc_hash::FxHashSet;
24
25use crate::audit::AuditResult;
26use crate::report::sink::outln;
27
28pub type ReviewBriefOutput = fallow_output::StandardReviewBriefOutput;
29
30/// A file count at or above which a changeset is classified [`RiskClass::High`].
31const RISK_HIGH_FILES: usize = 20;
32/// A net-line count at or above which a changeset is classified
33/// [`RiskClass::High`]. Stub-only in v1 (net lines are not yet threaded); kept
34/// as a named constant so the threshold is documented where the classifier
35/// lives.
36const RISK_HIGH_LINES: i64 = 500;
37/// A file count at or above which a changeset is classified
38/// [`RiskClass::Medium`].
39const RISK_MEDIUM_FILES: usize = 5;
40/// A net-line count at or above which a changeset is classified
41/// [`RiskClass::Medium`].
42const RISK_MEDIUM_LINES: i64 = 100;
43
44/// The honest-scope note stamped on every coordination-gap entry (ADR-001).
45const COORDINATION_GAP_NOTE: &str = "syntactic attention pointer, not a correctness proof";
46
47/// Build the deltas from head sets vs a base set, sorted for determinism.
48#[must_use]
49#[allow(
50    clippy::implicit_hasher,
51    reason = "callers always pass the audit FxHashSet key sets; generalizing the hasher adds noise"
52)]
53pub fn build_review_deltas(
54    head_boundary: &FxHashSet<String>,
55    base_boundary: &FxHashSet<String>,
56    head_cycles: &FxHashSet<String>,
57    base_cycles: &FxHashSet<String>,
58    head_public_api: &FxHashSet<String>,
59    base_public_api: &FxHashSet<String>,
60) -> ReviewDeltas {
61    use crate::audit::review_deltas::introduced_keys;
62    ReviewDeltas {
63        boundary_introduced: introduced_keys(head_boundary, base_boundary),
64        cycle_introduced: introduced_keys(head_cycles, base_cycles),
65        public_api_added: introduced_keys(head_public_api, base_public_api),
66    }
67}
68
69/// Classify a changeset's risk purely from its size. `net_lines` is consulted
70/// when present (it is `None` on the v1 file-level audit path).
71#[must_use]
72pub fn classify_risk(files: usize, net_lines: Option<i64>) -> RiskClass {
73    let lines = net_lines.unwrap_or(0).abs();
74    if files >= RISK_HIGH_FILES || lines >= RISK_HIGH_LINES {
75        RiskClass::High
76    } else if files >= RISK_MEDIUM_FILES || lines >= RISK_MEDIUM_LINES {
77        RiskClass::Medium
78    } else {
79        RiskClass::Low
80    }
81}
82
83/// Map a [`RiskClass`] to the suggested reviewer effort.
84#[must_use]
85pub fn review_effort_for(risk: RiskClass) -> ReviewEffort {
86    match risk {
87        RiskClass::Low => ReviewEffort::Glance,
88        RiskClass::Medium => ReviewEffort::Review,
89        RiskClass::High => ReviewEffort::DeepDive,
90    }
91}
92
93/// Build the Stage 0 triage facts from the audit result.
94#[must_use]
95pub fn build_triage(result: &AuditResult) -> DiffTriage {
96    let files = result.changed_files_count;
97    // v1: no diff index is threaded into the file-level audit, so hunks and net
98    // lines are honestly absent. They populate on `--diff-file`/`--diff-stdin`.
99    let hunks = None;
100    let net_lines = None;
101    let risk_class = classify_risk(files, net_lines);
102    DiffTriage {
103        files,
104        hunks,
105        net_lines,
106        risk_class,
107        review_effort: review_effort_for(risk_class),
108    }
109}
110
111/// Derive the Stage 1 graph facts from the analysis results plus the impact
112/// closure.
113///
114/// `boundaries_touched` is the deduped, sorted boundary-violation zone set;
115/// `reachable_from` is the impact closure's affected-not-shown set (modules the
116/// changed code reaches / affects, none in the diff). `exports_added` /
117/// `api_width_delta` stay stubbed until the export-surface delta.
118#[must_use]
119pub fn derive_graph_facts(
120    results: &AnalysisResults,
121    closure: Option<&fallow_engine::module_graph::ImpactClosurePaths>,
122) -> GraphFacts {
123    let mut zones: FxHashSet<String> = FxHashSet::default();
124    for finding in &results.boundary_violations {
125        zones.insert(finding.violation.from_zone.clone());
126        zones.insert(finding.violation.to_zone.clone());
127    }
128    let mut boundaries_touched: Vec<String> = zones.into_iter().collect();
129    boundaries_touched.sort();
130
131    let reachable_from = closure
132        .map(|c| c.affected_not_shown.clone())
133        .unwrap_or_default();
134
135    GraphFacts {
136        exports_added: 0,
137        api_width_delta: 0,
138        reachable_from,
139        boundaries_touched,
140    }
141}
142
143/// Build the Stage 3 impact-closure facts from the audit result's retained
144/// closure (computed on the brief path). Returns an empty closure when no graph
145/// was retained (the closure is `None`).
146#[must_use]
147fn build_impact_closure_facts(result: &AuditResult) -> ImpactClosureFacts {
148    let Some(closure) = result
149        .check
150        .as_ref()
151        .and_then(|c| c.impact_closure.as_ref())
152    else {
153        return ImpactClosureFacts::default();
154    };
155    let coordination_gap = closure
156        .coordination_gap
157        .iter()
158        .map(|gap| CoordinationGapFact {
159            changed_file: gap.changed_file.clone(),
160            consumer_file: gap.consumer_file.clone(),
161            consumed_symbols: gap.consumed_symbols.clone(),
162            note: COORDINATION_GAP_NOTE.to_string(),
163        })
164        .collect();
165    ImpactClosureFacts {
166        affected_not_shown: closure.affected_not_shown.clone(),
167        coordination_gap,
168    }
169}
170
171/// Build the Stage 2 partition facts from the audit result's retained
172/// partition+order (computed on the brief path). Returns an empty partition when
173/// no graph was retained (the partition is `None`).
174#[must_use]
175fn build_partition_facts(result: &AuditResult) -> PartitionFacts {
176    let Some(partition) = result
177        .check
178        .as_ref()
179        .and_then(|c| c.partition_order.as_ref())
180    else {
181        return PartitionFacts::default();
182    };
183    let units = partition
184        .units
185        .iter()
186        .map(|unit| ReviewUnitFact {
187            module_dir: unit.module_dir.clone(),
188            files: unit.files.clone(),
189        })
190        .collect();
191    PartitionFacts {
192        units,
193        order: partition.order.clone(),
194    }
195}
196
197/// Build the Stage 4 weighted focus map from the audit result's retained
198/// per-file graph facts plus the deltas / coordination signals. Returns an
199/// empty focus map when no graph facts were retained (off the brief path or no
200/// changed file mapped to a module).
201///
202/// The boundary risk-zone signal reuses the `from_path` of boundary violations
203/// whose introduced edge is in `deltas.boundary_introduced` (the same surface
204/// the decision surface reads). The security taint signal is wired as an EMPTY
205/// slice today: the brief path runs the bare dead-code analysis, not the opt-in
206/// `fallow security` taint engine, so `results.security_findings` is empty. The
207/// seam lights up the moment a security pass is threaded onto the brief, with no
208/// focus-map code change.
209#[must_use]
210fn build_focus_map(result: &AuditResult, deltas: &ReviewDeltas) -> crate::audit_focus::FocusMap {
211    use crate::audit_focus::{BoundaryZoneFile, FocusInputs, build_focus_map};
212
213    let Some(check) = result.check.as_ref() else {
214        return crate::audit_focus::FocusMap::default();
215    };
216    let Some(graph_facts) = check.focus_facts.as_ref() else {
217        return crate::audit_focus::FocusMap::default();
218    };
219    let root = &check.config.root;
220
221    // Boundary risk-zone files: the importing `from_path` of each boundary
222    // violation whose introduced zone-pair edge is in the delta set, deduped.
223    let mut seen_pairs: FxHashSet<String> = FxHashSet::default();
224    let mut boundary_files: Vec<BoundaryZoneFile> = Vec::new();
225    for finding in &check.results.boundary_violations {
226        let key = crate::audit::review_deltas::boundary_edge_key(finding);
227        if !deltas.boundary_introduced.contains(&key) || !seen_pairs.insert(key) {
228            continue;
229        }
230        boundary_files.push(BoundaryZoneFile {
231            from_file: crate::audit::keys::relative_key_path(&finding.violation.from_path, root),
232        });
233    }
234
235    // Coordination-gap changed files (the signature-change change-shape proxy):
236    // the changed files whose contract is consumed outside the diff.
237    let coordination_changed_files: Vec<String> = check
238        .impact_closure
239        .as_ref()
240        .map(|c| {
241            let mut files: Vec<String> = c
242                .coordination_gap
243                .iter()
244                .map(|gap| gap.changed_file.clone())
245                .collect();
246            files.sort_unstable();
247            files.dedup();
248            files
249        })
250        .unwrap_or_default();
251
252    // Security taint touch: the brief path carries no security findings (the taint
253    // engine is the opt-in `fallow security` command), so this is empty today. The
254    // seam is a pure function of this slice; it lights up when a security pass is
255    // threaded onto the brief.
256    let taint_touched_files = taint_touched_files(result.check.as_ref());
257
258    // Runtime evidence (paid): `Some` only on the `--runtime-coverage` path.
259    // It weights hot files and enables safe-skip; `None` in free mode, where the
260    // focus map degrades to the deterministic no-runtime baseline byte-for-byte.
261    let runtime_focus = build_runtime_focus(result, root);
262
263    build_focus_map(&FocusInputs {
264        graph_facts,
265        boundary_files: &boundary_files,
266        public_api_added: &deltas.public_api_added,
267        coordination_changed_files: &coordination_changed_files,
268        taint_touched_files: &taint_touched_files,
269        runtime: runtime_focus.as_ref(),
270    })
271}
272
273/// Build the per-file [`crate::audit_focus::RuntimeFocus`] from the
274/// runtime-coverage health report, or `None` when the run carried no
275/// `--runtime-coverage` data (free mode, where the focus map stays byte-identical
276/// to the no-runtime baseline).
277///
278/// Hot files come from the report's `hot_paths` (peak invocation per file). Cold
279/// files are the runtime-proven-unused ones: a file with at least one
280/// `safe_to_delete` finding, NO finding of any other verdict, and no hot path.
281///
282/// Honest boundary: the report's `findings` omit `active` functions, so a file
283/// can carry a live, executed function this signal never sees. `hot_paths` only
284/// surfaces functions at/above the configured hot threshold (`min_invocations_hot`,
285/// default 100, raisable via `--min-invocations-hot`), so an `active` function in
286/// the `[low_traffic .. hot)` band shows up in NEITHER list:
287/// such a file, if its only retained finding is `safe_to_delete`, is classified
288/// cold here despite having run. This is why the cold signal is never trusted on
289/// its own: the safe-skip label additionally requires zero static risk and no
290/// confidence flag, applies only to a file already in the diff, and is always
291/// advisory (the skip stays in the escape-hatch list, never hidden). Paths are
292/// normalized to the brief's root-relative forward-slashed space so the
293/// focus-map joins are byte-exact.
294fn build_runtime_focus(
295    result: &AuditResult,
296    root: &std::path::Path,
297) -> Option<crate::audit_focus::RuntimeFocus> {
298    let report = result.health.as_ref()?.report.runtime_coverage.as_ref()?;
299
300    let hot_pairs: Vec<(String, u64)> = report
301        .hot_paths
302        .iter()
303        .map(|hot| {
304            (
305                crate::audit::keys::relative_key_path(&hot.path, root),
306                hot.invocations,
307            )
308        })
309        .collect();
310
311    // Partition findings into safe_to_delete (cold candidate) vs any other verdict
312    // (the disqualifier that keeps a mixed-verdict file out of the cold set).
313    let mut safe_to_delete: FxHashSet<String> = FxHashSet::default();
314    let mut other_verdict: FxHashSet<String> = FxHashSet::default();
315    for finding in &report.findings {
316        let file = crate::audit::keys::relative_key_path(&finding.path, root);
317        if matches!(
318            finding.verdict,
319            fallow_output::RuntimeCoverageVerdict::SafeToDelete
320        ) {
321            safe_to_delete.insert(file);
322        } else {
323            other_verdict.insert(file);
324        }
325    }
326
327    reconcile_runtime_focus(hot_pairs, &safe_to_delete, &other_verdict)
328}
329
330/// Reconcile the projected runtime signals into a [`crate::audit_focus::RuntimeFocus`]:
331/// peak-aggregate hot invocations per file, and keep a file cold only when it has
332/// a `safe_to_delete` finding, no other-verdict finding, and no hot path (so the
333/// hot and cold lists are disjoint by construction). Returns `None` when both
334/// lists are empty. Pure (no I/O), so the mixed-verdict exclusion, the
335/// hot-excludes-cold filter, and the peak aggregation are unit-tested without
336/// constructing a full health report.
337fn reconcile_runtime_focus(
338    hot_pairs: Vec<(String, u64)>,
339    safe_to_delete: &FxHashSet<String>,
340    other_verdict: &FxHashSet<String>,
341) -> Option<crate::audit_focus::RuntimeFocus> {
342    use crate::audit_focus::{RuntimeFocus, RuntimeHotFile};
343
344    // Peak invocation per hot file (max across the file's hot functions).
345    let mut hot_by_file: rustc_hash::FxHashMap<String, u64> = rustc_hash::FxHashMap::default();
346    for (file, invocations) in hot_pairs {
347        let entry = hot_by_file.entry(file).or_insert(0);
348        *entry = (*entry).max(invocations);
349    }
350
351    let mut hot_files: Vec<RuntimeHotFile> = hot_by_file
352        .into_iter()
353        .map(|(file, invocations)| RuntimeHotFile { file, invocations })
354        .collect();
355    hot_files.sort_by(|a, b| a.file.cmp(&b.file));
356    let hot_set: FxHashSet<&str> = hot_files.iter().map(|hot| hot.file.as_str()).collect();
357
358    let mut cold_files: Vec<String> = safe_to_delete
359        .iter()
360        .filter(|file| !other_verdict.contains(*file) && !hot_set.contains(file.as_str()))
361        .cloned()
362        .collect();
363    cold_files.sort();
364
365    if hot_files.is_empty() && cold_files.is_empty() {
366        return None;
367    }
368    Some(RuntimeFocus {
369        hot_files,
370        cold_files,
371    })
372}
373
374/// Collect the root-relative file paths a security source -> sink taint trace
375/// touches, from any retained `security_findings` (anchor + every trace hop).
376///
377/// Today the brief path runs the bare dead-code analysis, so `security_findings`
378/// is empty and this returns an empty Vec (the security-taint seam contributes
379/// 0). The function is a pure projection over the findings slice, so the moment a
380/// future epic threads a security pass onto the brief, the focus map's taint
381/// signal lights up with no focus-map code change.
382fn taint_touched_files(check: Option<&crate::check::CheckResult>) -> Vec<String> {
383    let Some(check) = check else {
384        return Vec::new();
385    };
386    let root = &check.config.root;
387    let mut touched: FxHashSet<String> = FxHashSet::default();
388    for finding in &check.results.security_findings {
389        touched.insert(crate::audit::keys::relative_key_path(&finding.path, root));
390        for hop in &finding.trace {
391            touched.insert(crate::audit::keys::relative_key_path(&hop.path, root));
392        }
393    }
394    let mut files: Vec<String> = touched.into_iter().collect();
395    files.sort();
396    files
397}
398
399/// Assemble the structured [`ReviewBriefOutput`] for an audit result. Pure: no
400/// timestamps, no randomness, so two runs over the same tree serialize
401/// byte-identically.
402#[must_use]
403pub fn build_brief_output(result: &AuditResult) -> ReviewBriefOutput {
404    let triage = build_triage(result);
405    let closure = result
406        .check
407        .as_ref()
408        .and_then(|c| c.impact_closure.as_ref());
409    let deltas = result.review_deltas.clone().unwrap_or_default();
410    let mut graph_facts = result.check.as_ref().map_or_else(
411        || GraphFacts {
412            exports_added: 0,
413            api_width_delta: 0,
414            reachable_from: Vec::new(),
415            boundaries_touched: Vec::new(),
416        },
417        |check| derive_graph_facts(&check.results, closure),
418    );
419    // The exports-aware delta fills the previously-stubbed export facts:
420    // `exports_added` / `api_width_delta` count the public-API surface the change
421    // widened, not raw internal churn.
422    let added = deltas.public_api_added.len();
423    graph_facts.exports_added = added;
424    graph_facts.api_width_delta = i64::try_from(added).unwrap_or(i64::MAX);
425    let partition = build_partition_facts(result);
426    let impact_closure = build_impact_closure_facts(result);
427    let focus = build_focus_map(result, &deltas);
428    ReviewBriefOutput {
429        schema_version: ReviewBriefSchemaVersion::default(),
430        version: env!("CARGO_PKG_VERSION").to_string(),
431        command: "audit-brief".to_string(),
432        triage,
433        graph_facts,
434        partition,
435        impact_closure,
436        focus,
437        deltas,
438        weakening: result.weakening_signals.clone(),
439        routing: result.routing.clone().unwrap_or_default(),
440        decisions: result.decision_surface.clone().unwrap_or_default(),
441    }
442}
443
444/// Build the reused "subtract" section (dead-code / duplication / complexity)
445/// for the brief JSON value, mirroring `fallow audit --format json`.
446fn build_brief_subtract_sections(
447    result: &AuditResult,
448) -> Result<ReviewBriefSubtractSections, ExitCode> {
449    let mut obj = serde_json::Map::new();
450    if let Some(ref check) = result.check {
451        crate::audit::insert_audit_dead_code_json(&mut obj, result, check)?;
452    }
453    if let Some(ref dupes) = result.dupes {
454        crate::audit::insert_audit_duplication_json(&mut obj, result, dupes)?;
455    }
456    if let Some(ref health) = result.health {
457        crate::audit::insert_audit_health_json(&mut obj, result, health)?;
458    }
459    Ok(ReviewBriefSubtractSections {
460        dead_code: obj.remove("dead_code"),
461        duplication: obj.remove("duplication"),
462        complexity: obj.remove("complexity"),
463    })
464}
465
466/// Build the complete brief JSON value: the versioned brief header, the
467/// informational audit verdict header, the triage + graph-facts stages, and the
468/// reused subtract section.
469fn build_brief_json(result: &AuditResult) -> Result<serde_json::Value, ExitCode> {
470    let brief = build_brief_output(result);
471    let audit_header = fallow_api::build_audit_header_map(crate::audit::audit_json_header_input(
472        result,
473    ))
474    .map_err(|err| {
475        crate::error::emit_error(
476            &format!("JSON serialization error: {err}"),
477            2,
478            fallow_config::OutputFormat::Json,
479        )
480    })?;
481    let subtract = build_brief_subtract_sections(result)?;
482    fallow_output::build_review_brief_json_output(&brief, audit_header, subtract).map_err(|err| {
483        crate::error::emit_error(
484            &format!("JSON serialization error: {err}"),
485            2,
486            fallow_config::OutputFormat::Json,
487        )
488    })
489}
490
491/// Render the brief as JSON. Always returns `SUCCESS`; a serialization failure
492/// surfaces the error but the brief contract still exits 0.
493fn print_brief_json(result: &AuditResult) -> ExitCode {
494    match build_brief_json(result) {
495        Ok(output) => {
496            let Ok(output) = fallow_output::serialize_review_brief_json_output(
497                output,
498                crate::output_runtime::current_root_envelope_mode(),
499                crate::output_runtime::telemetry_analysis_run_id().as_deref(),
500            ) else {
501                return ExitCode::SUCCESS;
502            };
503            let _ = crate::report::emit_json(&output, "audit-brief");
504            ExitCode::SUCCESS
505        }
506        Err(_) => ExitCode::SUCCESS,
507    }
508}
509
510/// Render the brief in human / compact / markdown form: a short orientation
511/// header (scope, risk, effort, boundaries) followed by the same findings
512/// sections `fallow audit` prints.
513fn print_brief_human(result: &AuditResult, quiet: bool, explain: bool, show_deprioritized: bool) {
514    let brief = build_brief_output(result);
515
516    if !quiet {
517        eprintln!();
518        // The decision surface is the apex; it LEADS (collapse-by-default).
519        print_decision_surface_human(&brief.decisions);
520        // The upstream stages are the decision surface's drill-down derivation.
521        eprintln!(
522            "Review brief (drill-down): {} changed file{} vs {} \u{00b7} risk {} \u{00b7} effort {}",
523            result.changed_files_count,
524            crate::report::plural(result.changed_files_count),
525            result.base_ref,
526            risk_label(brief.triage.risk_class),
527            effort_label(brief.triage.review_effort),
528        );
529        if !brief.graph_facts.boundaries_touched.is_empty() {
530            eprintln!(
531                "  boundaries touched: {}",
532                brief.graph_facts.boundaries_touched.join(", ")
533            );
534        }
535        print_partition_human(&brief.partition);
536        print_impact_closure_human(&brief.impact_closure);
537        print_focus_human(&brief.focus, show_deprioritized);
538        print_deltas_human(&brief.deltas);
539        print_weakening_human(&brief.weakening);
540        print_routing_human(&brief.routing);
541    }
542
543    // Always render the findings sections so the brief shows WHERE to look, even
544    // when the underlying verdict is a fail. Headers stay off (the brief owns its
545    // own header line above).
546    crate::audit::print_audit_findings(result, quiet, explain, false);
547}
548
549/// Print the Stage 2 partition + order on the human brief: the by-module units
550/// and the dependency-sensible review order (definitions before consumers).
551/// Caller has already gated on `!quiet`. Renders nothing when no unit was
552/// computed (no graph retained, or every changed file is non-source).
553fn print_partition_human(partition: &PartitionFacts) {
554    if partition.units.is_empty() {
555        return;
556    }
557    eprintln!(
558        "  partition: {} unit{} (by module)",
559        partition.units.len(),
560        crate::report::plural(partition.units.len()),
561    );
562    if !partition.order.is_empty() {
563        let labeled: Vec<String> = partition.order.iter().map(|dir| unit_label(dir)).collect();
564        eprintln!("  review order: {}", labeled.join(" \u{2192} "));
565    }
566}
567
568/// Label a unit's module directory for human output; the empty root-group key
569/// renders as `<root>` so it is not a blank token.
570fn unit_label(module_dir: &str) -> String {
571    if module_dir.is_empty() {
572        "<root>".to_string()
573    } else {
574        module_dir.to_string()
575    }
576}
577
578/// Print the Stage 3 impact-closure summary on the human brief: the count of
579/// affected-but-not-shown files and each coordination gap (the precise
580/// inter-module attention pointer). Caller has already gated on `!quiet`.
581fn print_impact_closure_human(closure: &ImpactClosureFacts) {
582    if !closure.affected_not_shown.is_empty() {
583        eprintln!(
584            "  impact closure: {} file{} affected beyond the diff",
585            closure.affected_not_shown.len(),
586            crate::report::plural(closure.affected_not_shown.len()),
587        );
588    }
589    for gap in &closure.coordination_gap {
590        eprintln!(
591            "  coordination gap: {} consumes {} from {} (not in this diff)",
592            gap.consumer_file,
593            gap.consumed_symbols.join(", "),
594            gap.changed_file,
595        );
596    }
597}
598
599/// Print the Stage 4 weighted focus map on the human brief: the ranked
600/// `review-here` units (with reason + any low-confidence flag), then the
601/// de-prioritized count as a collapsed escape hatch. `--show-deprioritized`
602/// re-expands the full de-prioritized list ("show me what you de-prioritized").
603/// Caller has already gated on `!quiet`. Renders nothing when no unit was scored.
604fn print_focus_human(focus: &crate::audit_focus::FocusMap, show_deprioritized: bool) {
605    if focus.total_units() == 0 {
606        return;
607    }
608    if !focus.review_here.is_empty() {
609        eprintln!(
610            "  focus: {} unit{} to review here (of {} changed)",
611            focus.review_here.len(),
612            crate::report::plural(focus.review_here.len()),
613            focus.total_units(),
614        );
615        for unit in &focus.review_here {
616            eprintln!(
617                "    [{}] {}: {}",
618                unit.label.token(),
619                unit.file,
620                unit.reason
621            );
622            for flag in &unit.confidence {
623                eprintln!("      confidence {}", flag.message());
624            }
625        }
626    }
627    if focus.deprioritized.is_empty() {
628        return;
629    }
630    if show_deprioritized {
631        eprintln!("  de-prioritized ({}):", focus.deprioritized.len());
632        for unit in &focus.deprioritized {
633            eprintln!(
634                "    [{}] {}: {}",
635                unit.label.token(),
636                unit.file,
637                unit.reason
638            );
639            for flag in &unit.confidence {
640                eprintln!("      confidence {}", flag.message());
641            }
642        }
643    } else {
644        eprintln!(
645            "  de-prioritized: {} unit{} (run with --show-deprioritized to list)",
646            focus.deprioritized.len(),
647            crate::report::plural(focus.deprioritized.len()),
648        );
649    }
650}
651
652/// Print the diff-aware deltas (6.A): boundary/cycle introduced and the
653/// exports-aware public-API surface delta (batch-consolidated per R1). Caller
654/// has already gated on `!quiet`.
655fn print_deltas_human(deltas: &ReviewDeltas) {
656    for edge in &deltas.boundary_introduced {
657        eprintln!("  new boundary edge: {edge} (not present at base)");
658    }
659    for cycle in &deltas.cycle_introduced {
660        eprintln!("  new circular dependency: {cycle} (not present at base)");
661    }
662    if !deltas.public_api_added.is_empty() {
663        eprintln!(
664            "  public API surface widened by {} export{} (exports-aware)",
665            deltas.public_api_added.len(),
666            crate::report::plural(deltas.public_api_added.len()),
667        );
668    }
669}
670
671/// Print the weakening signals (6.F headline). Advisory, reviewer-private.
672fn print_weakening_human(signals: &[crate::audit::weakening::WeakeningSignal]) {
673    if signals.is_empty() {
674        return;
675    }
676    eprintln!(
677        "  weakening signals ({}, reviewer-private, advisory):",
678        signals.len()
679    );
680    for signal in signals {
681        eprintln!(
682            "    {}: {} in {}",
683            weakening_label(signal.kind),
684            signal.evidence,
685            signal.file,
686        );
687    }
688}
689
690/// Print the ownership routing (6.D): per-unit expert + bus-factor flag.
691fn print_routing_human(routing: &crate::audit::routing::RoutingFacts) {
692    for unit in &routing.units {
693        if unit.expert.is_empty() {
694            continue;
695        }
696        let bus = if unit.bus_factor_one {
697            " (bus-factor 1)"
698        } else {
699            ""
700        };
701        eprintln!(
702            "  review {}: ask {}{bus}",
703            unit.file,
704            unit.expert.join(", "),
705        );
706    }
707}
708
709/// Print the decision surface (the apex, 6.G): the ranked, capped set of
710/// consequential structural decisions, each as a framed judgment question with
711/// its routed expert. Caller has already gated on `!quiet`. Leads the brief.
712fn print_decision_surface_human(surface: &crate::audit_decision_surface::DecisionSurface) {
713    if surface.decisions.is_empty() {
714        eprintln!("Decisions: none (no consequential structural decision in this change)");
715        eprintln!();
716        return;
717    }
718    eprintln!("Decisions to make ({}):", surface.decisions.len());
719    for (i, decision) in surface.decisions.iter().enumerate() {
720        // Taste ownership: the question first (never an answer), then the honest
721        // graph fact, then the named trade-off. The human reads reversibility from
722        // the count; the tool never labels the door or recommends a choice.
723        eprintln!(
724            "  {}. [{}] {}",
725            i + 1,
726            decision.category.tag(),
727            decision.question
728        );
729        if !decision.tradeoff.is_empty() {
730            eprintln!("     trade-off: {}", decision.tradeoff);
731        }
732        if !decision.expert.is_empty() {
733            let bus = if decision.bus_factor_one {
734                " (bus-factor 1)"
735            } else {
736                ""
737            };
738            eprintln!("     ask: {}{bus}", decision.expert.join(", "));
739        }
740    }
741    if let Some(note) = &surface.truncated {
742        eprintln!("  ... {}", note.reason);
743    }
744    eprintln!();
745}
746
747fn weakening_label(kind: crate::audit::weakening::WeakeningKind) -> &'static str {
748    use crate::audit::weakening::WeakeningKind;
749    match kind {
750        WeakeningKind::TestWeakened => "test weakened",
751        WeakeningKind::ThresholdLowered => "threshold lowered",
752        WeakeningKind::SuppressionAdded => "suppression added",
753        WeakeningKind::SecurityCheckRemoved => "security check removed",
754    }
755}
756
757fn risk_label(risk: RiskClass) -> &'static str {
758    match risk {
759        RiskClass::Low => "low",
760        RiskClass::Medium => "medium",
761        RiskClass::High => "high",
762    }
763}
764
765fn effort_label(effort: ReviewEffort) -> &'static str {
766    match effort {
767        ReviewEffort::Glance => "glance",
768        ReviewEffort::Review => "review",
769        ReviewEffort::DeepDive => "deep-dive",
770    }
771}
772
773/// Print the brief and return an exit code that is ALWAYS `SUCCESS`.
774///
775/// This is the exit-0 seam: `fallow review` (and `fallow audit --brief`) never
776/// gate on the audit verdict. The verdict is still carried in the JSON output
777/// informationally. Format dispatch mirrors `print_audit_result`, but every arm
778/// forces success: JSON renders the brief envelope; human / compact / markdown
779/// render the brief orientation header plus findings; any other format
780/// (SARIF, CodeClimate, PR/review envelopes, badge) is rendered through the
781/// standard audit path and then forced to success so the format stays usable
782/// without re-implementing it for the brief.
783#[must_use]
784pub fn print_brief_result(
785    result: &AuditResult,
786    quiet: bool,
787    explain: bool,
788    show_deprioritized: bool,
789) -> ExitCode {
790    use fallow_config::OutputFormat;
791
792    match result.output {
793        OutputFormat::Json => print_brief_json(result),
794        OutputFormat::Human | OutputFormat::Compact | OutputFormat::Markdown => {
795            print_brief_human(result, quiet, explain, show_deprioritized);
796            ExitCode::SUCCESS
797        }
798        _ => {
799            // For machine/CI formats not specific to the brief, delegate to the
800            // standard audit renderer for the body, then force success: the
801            // brief invariant is exit-0 regardless of verdict.
802            let _ = crate::audit::print_audit_result(result, quiet, explain);
803            ExitCode::SUCCESS
804        }
805    }
806}
807
808/// Render the SEPARABLE decision-surface envelope (the `decision_surface` MCP
809/// tool's output + `fallow decision-surface`). Emits ONLY the ranked, capped
810/// decisions with structured `actions[]`, never the full brief. Always exit 0.
811///
812/// JSON renders the typed decision-surface envelope (`kind:
813/// "decision-surface"`); human / compact / markdown render the apex header.
814#[must_use]
815pub fn print_decision_surface_result(result: &AuditResult, quiet: bool) -> ExitCode {
816    use fallow_config::OutputFormat;
817
818    let surface = result.decision_surface.clone().unwrap_or_default();
819    match result.output {
820        OutputFormat::Json => {
821            let output = crate::audit_decision_surface::build_decision_surface_output(&surface);
822            match fallow_output::serialize_decision_surface_json_output(
823                output,
824                crate::output_runtime::current_root_envelope_mode(),
825                crate::output_runtime::telemetry_analysis_run_id().as_deref(),
826            ) {
827                Ok(value) => {
828                    let _ = crate::report::emit_json(&value, "decision-surface");
829                    ExitCode::SUCCESS
830                }
831                Err(_) => ExitCode::SUCCESS,
832            }
833        }
834        _ => {
835            if !quiet {
836                print_decision_surface_human(&surface);
837            }
838            ExitCode::SUCCESS
839        }
840    }
841}
842
843/// Render the agent-contract WALKTHROUGH GUIDE: the digest (brief +
844/// decision surface), the review direction, the JSON schema the agent returns,
845/// and the deterministic graph-snapshot pin. JSON renders the typed guide
846/// envelope (`kind: "review-walkthrough-guide"`). Every format emits the guide
847/// as JSON: the guide is an agent-facing contract, not a human walkthrough.
848/// Always exit 0.
849#[must_use]
850pub fn print_walkthrough_guide_result(result: &AuditResult) -> ExitCode {
851    let guide = crate::audit_walkthrough::build_guide_from_result(result);
852    if let Ok(value) = fallow_output::serialize_walkthrough_guide_json_output(
853        guide,
854        crate::output_runtime::current_root_envelope_mode(),
855        crate::output_runtime::telemetry_analysis_run_id().as_deref(),
856    ) {
857        let _ = crate::report::emit_json(&value, "review-walkthrough-guide");
858    }
859    ExitCode::SUCCESS
860}
861
862/// Ingest the agent's judgment JSON from `path` and POST-VALIDATE it against
863/// the live graph: reject unanchored signal_ids (anti-hallucination), refuse the
864/// whole payload when the echoed graph-snapshot hash is stale (the tree moved).
865/// JSON renders the typed walkthrough-validation envelope (`kind:
866/// "review-walkthrough-validation"`). Always exit 0 (advisory).
867///
868/// A path that cannot be read yields an empty agent payload (default `""` hash),
869/// which never matches the current hash, so it is refused as stale, the safe
870/// direction: a missing or garbled agent file never accepts a judgment.
871#[must_use]
872pub fn print_walkthrough_file_result(result: &AuditResult, path: &std::path::Path) -> ExitCode {
873    let contents = std::fs::read_to_string(path).unwrap_or_default();
874    let agent = crate::audit_walkthrough::parse_agent_walkthrough(&contents);
875    let surface = result.decision_surface.clone().unwrap_or_default();
876    let current_hash = result.graph_snapshot_hash.clone().unwrap_or_default();
877    let change_anchor_ids =
878        crate::audit_walkthrough::change_anchor_allowlist(&result.change_anchors);
879    let validation = crate::audit_walkthrough::validate_walkthrough(
880        &agent,
881        &surface,
882        &change_anchor_ids,
883        &current_hash,
884    );
885    if let Ok(value) = fallow_output::serialize_walkthrough_validation_json_output(
886        validation,
887        crate::output_runtime::current_root_envelope_mode(),
888        crate::output_runtime::telemetry_analysis_run_id().as_deref(),
889    ) {
890        let _ = crate::report::emit_json(&value, "review-walkthrough-validation");
891    }
892    ExitCode::SUCCESS
893}
894
895/// Render the EXISTING walkthrough guide as a HUMAN terminal tour (or markdown
896/// with `--format markdown`). The guide data is built unchanged by
897/// [`crate::audit_walkthrough::build_guide_from_result`]; this only renders it.
898///
899/// Format dispatch on `result.output`:
900/// - `Json` delegates verbatim to [`print_walkthrough_guide_result`], so
901///   `--walkthrough --format json` is byte-identical to `--walkthrough-guide
902///   --format json` (the json-reuse seam, zero duplication).
903/// - `Markdown` emits a paste-into-PR markdown tour to STDOUT.
904/// - every other format (Human / Compact / the CI envelopes) emits the colored
905///   staged terminal tour: the Review Focus header + final status to stderr, the
906///   tour body to stdout. The guide is advisory, never a CI gate envelope, so
907///   SARIF / CodeClimate / PR-comment formats fall through to the human tour
908///   rather than implying gate semantics.
909///
910/// `root` and `cache_dir` are threaded from `AuditOptions` because `AuditResult`
911/// carries neither: `root` displays paths, `cache_dir` locates the local
912/// viewed-state ledger. `mark_viewed` records files as viewed BEFORE rendering;
913/// the render itself is read-only. Always exit 0, even when the verdict is Fail.
914#[must_use]
915pub fn print_walkthrough_human_result(
916    result: &AuditResult,
917    root: &std::path::Path,
918    cache_dir: &std::path::Path,
919    mark_viewed: &[std::path::PathBuf],
920    show_cleared: bool,
921    quiet: bool,
922) -> ExitCode {
923    use fallow_config::OutputFormat;
924
925    // JSON reuses the single guide JSON path verbatim (no second serializer).
926    if matches!(result.output, OutputFormat::Json) {
927        return print_walkthrough_guide_result(result);
928    }
929
930    let guide = crate::audit_walkthrough::build_guide_from_result(result);
931    record_walkthrough_marks(&guide, root, cache_dir, mark_viewed);
932
933    // Load the viewed-state ledger ONCE and share it across both surfaces, so the
934    // markdown render honors `--mark-viewed` the same way the human render does
935    // (the two formats agree on the same on-disk state instead of markdown
936    // silently ignoring it).
937    let viewed = crate::walkthrough_state::load_viewed_state(cache_dir);
938
939    if matches!(result.output, OutputFormat::Markdown) {
940        let viewed_files = crate::report::walkthrough_viewed_files(&guide, &viewed);
941        let markdown = fallow_api::build_walkthrough_markdown(&guide, root, &viewed_files);
942        outln!("{markdown}");
943        return ExitCode::SUCCESS;
944    }
945
946    let render = crate::report::build_walkthrough_human(&guide, &viewed, show_cleared);
947    if !quiet {
948        for line in &render.header {
949            eprintln!("{line}");
950        }
951    }
952    for line in &render.body {
953        outln!("{line}");
954    }
955    if !quiet {
956        eprintln!("{}", render.status);
957    }
958    ExitCode::SUCCESS
959}
960
961/// Record each `--mark-viewed` path as viewed against the current guide hash.
962///
963/// Paths are normalized to the guide's root-relative VIEW key (the guide stores
964/// root-relative paths in `direction.order`). IO failures are swallowed: the
965/// viewed-state is a local convenience and must never change the exit code.
966fn record_walkthrough_marks(
967    guide: &crate::audit_walkthrough::WalkthroughGuide,
968    root: &std::path::Path,
969    cache_dir: &std::path::Path,
970    mark_viewed: &[std::path::PathBuf],
971) {
972    if mark_viewed.is_empty() {
973        return;
974    }
975    let keys: Vec<String> = mark_viewed
976        .iter()
977        .map(|path| walkthrough_view_key(path, root))
978        .collect();
979    let _ = crate::walkthrough_state::mark_viewed(cache_dir, &keys, &guide.graph_snapshot_hash);
980}
981
982/// Normalize a `--mark-viewed` path to the guide's root-relative, forward-slashed
983/// VIEW key, so a user can pass either an absolute or a relative path.
984fn walkthrough_view_key(path: &std::path::Path, root: &std::path::Path) -> String {
985    let rel = path.strip_prefix(root).unwrap_or(path);
986    rel.to_string_lossy().replace('\\', "/")
987}
988
989#[cfg(test)]
990mod tests {
991    use std::time::Duration;
992
993    use fallow_config::{AuditGate, OutputFormat};
994    use fallow_output::REVIEW_BRIEF_SCHEMA_VERSION;
995    use rustc_hash::FxHashSet;
996
997    use crate::audit::{AuditAttribution, AuditResult, AuditSummary, AuditVerdict};
998
999    fn str_set(files: &[&str]) -> FxHashSet<String> {
1000        files.iter().map(|file| (*file).to_string()).collect()
1001    }
1002
1003    // Producer: the runtime hot/cold reconciliation. A file with a peak hot
1004    // path is hot (peak = max over its functions); a file with only a
1005    // safe_to_delete finding is cold; a mixed-verdict file is excluded; a file
1006    // that is both safe_to_delete AND hot stays hot (disjoint lists).
1007    #[test]
1008    fn reconcile_runtime_focus_classifies_hot_cold_and_excludes_mixed() {
1009        let hot_pairs = vec![
1010            ("src/hot.ts".to_string(), 120),
1011            ("src/hot.ts".to_string(), 900),  // peak wins
1012            ("src/both.ts".to_string(), 300), // also safe_to_delete below -> stays hot
1013        ];
1014        let safe = str_set(&["src/cold.ts", "src/mixed.ts", "src/both.ts"]);
1015        let other = str_set(&["src/mixed.ts"]); // mixed.ts also has a review_required -> not cold
1016        let focus =
1017            super::reconcile_runtime_focus(hot_pairs, &safe, &other).expect("non-empty focus");
1018
1019        // Hot files: peak-aggregated, sorted.
1020        let hot: Vec<(&str, u64)> = focus
1021            .hot_files
1022            .iter()
1023            .map(|hot| (hot.file.as_str(), hot.invocations))
1024            .collect();
1025        assert_eq!(hot, vec![("src/both.ts", 300), ("src/hot.ts", 900)]);
1026
1027        // Cold = safe_to_delete minus other-verdict minus hot. Only `cold.ts`.
1028        assert_eq!(focus.cold_files, vec!["src/cold.ts".to_string()]);
1029    }
1030
1031    // Producer: no signal at all -> None (free-mode fall-through).
1032    #[test]
1033    fn reconcile_runtime_focus_is_none_when_empty() {
1034        assert!(super::reconcile_runtime_focus(Vec::new(), &str_set(&[]), &str_set(&[])).is_none());
1035    }
1036
1037    use super::*;
1038
1039    fn audit_result(verdict: AuditVerdict, output: OutputFormat) -> AuditResult {
1040        AuditResult {
1041            verdict,
1042            summary: AuditSummary {
1043                dead_code_issues: 0,
1044                dead_code_has_errors: false,
1045                complexity_findings: 0,
1046                max_cyclomatic: None,
1047                duplication_clone_groups: 0,
1048            },
1049            attribution: AuditAttribution {
1050                gate: AuditGate::NewOnly,
1051                ..AuditAttribution::default()
1052            },
1053            base_snapshot: None,
1054            base_snapshot_skipped: false,
1055            changed_files_count: 0,
1056            changed_files: Vec::new(),
1057            base_ref: "origin/main".to_string(),
1058            base_description: None,
1059            head_sha: None,
1060            output,
1061            performance: false,
1062            check: None,
1063            dupes: None,
1064            health: None,
1065            elapsed: Duration::ZERO,
1066            review_deltas: None,
1067            weakening_signals: Vec::new(),
1068            routing: None,
1069            decision_surface: None,
1070            graph_snapshot_hash: None,
1071            change_anchors: Vec::new(),
1072        }
1073    }
1074
1075    #[test]
1076    fn brief_mode_always_returns_success_even_when_verdict_is_fail() {
1077        // Human path.
1078        let human = audit_result(AuditVerdict::Fail, OutputFormat::Human);
1079        assert_eq!(
1080            print_brief_result(&human, true, false, false),
1081            ExitCode::SUCCESS
1082        );
1083
1084        // JSON path.
1085        let json = audit_result(AuditVerdict::Fail, OutputFormat::Json);
1086        assert_eq!(
1087            print_brief_result(&json, true, false, false),
1088            ExitCode::SUCCESS
1089        );
1090    }
1091
1092    #[test]
1093    fn brief_json_validates_against_audit_brief_schema_variant() {
1094        let result = audit_result(AuditVerdict::Fail, OutputFormat::Json);
1095        let value = fallow_output::serialize_review_brief_json_output(
1096            build_brief_json(&result).expect("brief json must build"),
1097            crate::output_runtime::current_root_envelope_mode(),
1098            crate::output_runtime::telemetry_analysis_run_id().as_deref(),
1099        )
1100        .expect("brief json must serialize");
1101
1102        assert_eq!(value["kind"], "audit-brief");
1103        assert_eq!(value["command"], "audit-brief");
1104        assert_eq!(value["schema_version"], REVIEW_BRIEF_SCHEMA_VERSION);
1105    }
1106
1107    #[test]
1108    fn brief_json_is_byte_identical_on_repeated_serialization() {
1109        // `elapsed: Duration::ZERO` and no telemetry: the brief JSON carries no
1110        // timestamps or randomness, so two builds serialize byte-identically.
1111        let result = audit_result(AuditVerdict::Warn, OutputFormat::Json);
1112        let first = build_brief_json(&result).expect("first build");
1113        let second = build_brief_json(&result).expect("second build");
1114        let first_str = serde_json::to_string_pretty(&first).expect("serialize first");
1115        let second_str = serde_json::to_string_pretty(&second).expect("serialize second");
1116        assert_eq!(first_str, second_str);
1117    }
1118
1119    #[test]
1120    fn risk_class_thresholds_are_pure_functions_of_size() {
1121        assert_eq!(classify_risk(0, None), RiskClass::Low);
1122        assert_eq!(classify_risk(RISK_MEDIUM_FILES, None), RiskClass::Medium);
1123        assert_eq!(classify_risk(RISK_HIGH_FILES, None), RiskClass::High);
1124        assert_eq!(classify_risk(1, Some(RISK_HIGH_LINES)), RiskClass::High);
1125        assert_eq!(review_effort_for(RiskClass::High), ReviewEffort::DeepDive);
1126    }
1127
1128    #[test]
1129    fn brief_json_includes_empty_impact_closure_when_no_graph_retained() {
1130        // check: None -> no closure; the impact_closure object must still be
1131        // present and empty so consumers can rely on its presence.
1132        let result = audit_result(AuditVerdict::Warn, OutputFormat::Json);
1133        let value = build_brief_json(&result).expect("brief json must build");
1134        assert!(value.get("impact_closure").is_some(), "{value}");
1135        assert_eq!(
1136            value["impact_closure"]["affected_not_shown"],
1137            serde_json::json!([])
1138        );
1139        assert_eq!(
1140            value["impact_closure"]["coordination_gap"],
1141            serde_json::json!([])
1142        );
1143    }
1144
1145    #[test]
1146    fn derive_graph_facts_populates_reachable_from_from_closure() {
1147        use fallow_engine::module_graph::{CoordinationGapPaths, ImpactClosurePaths};
1148        let results = AnalysisResults::default();
1149        let closure = ImpactClosurePaths {
1150            in_diff: vec!["src/core.ts".to_string()],
1151            affected_not_shown: vec!["src/app.ts".to_string(), "src/mid.ts".to_string()],
1152            coordination_gap: vec![CoordinationGapPaths {
1153                changed_file: "src/core.ts".to_string(),
1154                consumer_file: "src/mid.ts".to_string(),
1155                consumed_symbols: vec!["compute".to_string()],
1156            }],
1157        };
1158        let facts = derive_graph_facts(&results, Some(&closure));
1159        assert_eq!(
1160            facts.reachable_from,
1161            vec!["src/app.ts".to_string(), "src/mid.ts".to_string()]
1162        );
1163    }
1164
1165    #[test]
1166    fn coordination_gap_fact_carries_honest_scope_note() {
1167        let gap = CoordinationGapFact {
1168            changed_file: "src/core.ts".to_string(),
1169            consumer_file: "src/mid.ts".to_string(),
1170            consumed_symbols: vec!["compute".to_string()],
1171            note: COORDINATION_GAP_NOTE.to_string(),
1172        };
1173        assert!(gap.note.contains("attention pointer"));
1174        assert!(gap.note.contains("not a correctness proof"));
1175    }
1176}