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;
26
27pub type ReviewBriefOutput = fallow_output::StandardReviewBriefOutput;
28
29/// A file count at or above which a changeset is classified [`RiskClass::High`].
30const RISK_HIGH_FILES: usize = 20;
31/// A net-line count at or above which a changeset is classified
32/// [`RiskClass::High`]. Stub-only in v1 (net lines are not yet threaded); kept
33/// as a named constant so the threshold is documented where the classifier
34/// lives.
35const RISK_HIGH_LINES: i64 = 500;
36/// A file count at or above which a changeset is classified
37/// [`RiskClass::Medium`].
38const RISK_MEDIUM_FILES: usize = 5;
39/// A net-line count at or above which a changeset is classified
40/// [`RiskClass::Medium`].
41const RISK_MEDIUM_LINES: i64 = 100;
42
43/// The honest-scope note stamped on every coordination-gap entry (ADR-001).
44const COORDINATION_GAP_NOTE: &str = "syntactic attention pointer, not a correctness proof";
45
46/// Build the deltas from head sets vs a base set, sorted for determinism.
47#[must_use]
48#[allow(
49    clippy::implicit_hasher,
50    reason = "callers always pass the audit FxHashSet key sets; generalizing the hasher adds noise"
51)]
52pub fn build_review_deltas(
53    head_boundary: &FxHashSet<String>,
54    base_boundary: &FxHashSet<String>,
55    head_cycles: &FxHashSet<String>,
56    base_cycles: &FxHashSet<String>,
57    head_public_api: &FxHashSet<String>,
58    base_public_api: &FxHashSet<String>,
59) -> ReviewDeltas {
60    use crate::audit::review_deltas::introduced_keys;
61    ReviewDeltas {
62        boundary_introduced: introduced_keys(head_boundary, base_boundary),
63        cycle_introduced: introduced_keys(head_cycles, base_cycles),
64        public_api_added: introduced_keys(head_public_api, base_public_api),
65    }
66}
67
68/// Classify a changeset's risk purely from its size. `net_lines` is consulted
69/// when present (it is `None` on the v1 file-level audit path).
70#[must_use]
71pub fn classify_risk(files: usize, net_lines: Option<i64>) -> RiskClass {
72    let lines = net_lines.unwrap_or(0).abs();
73    if files >= RISK_HIGH_FILES || lines >= RISK_HIGH_LINES {
74        RiskClass::High
75    } else if files >= RISK_MEDIUM_FILES || lines >= RISK_MEDIUM_LINES {
76        RiskClass::Medium
77    } else {
78        RiskClass::Low
79    }
80}
81
82/// Map a [`RiskClass`] to the suggested reviewer effort.
83#[must_use]
84pub fn review_effort_for(risk: RiskClass) -> ReviewEffort {
85    match risk {
86        RiskClass::Low => ReviewEffort::Glance,
87        RiskClass::Medium => ReviewEffort::Review,
88        RiskClass::High => ReviewEffort::DeepDive,
89    }
90}
91
92/// Build the Stage 0 triage facts from the audit result.
93#[must_use]
94pub fn build_triage(result: &AuditResult) -> DiffTriage {
95    let files = result.changed_files_count;
96    // v1: no diff index is threaded into the file-level audit, so hunks and net
97    // lines are honestly absent. They populate on `--diff-file`/`--diff-stdin`.
98    let hunks = None;
99    let net_lines = None;
100    let risk_class = classify_risk(files, net_lines);
101    DiffTriage {
102        files,
103        hunks,
104        net_lines,
105        risk_class,
106        review_effort: review_effort_for(risk_class),
107    }
108}
109
110/// Derive the Stage 1 graph facts from the analysis results plus the impact
111/// closure.
112///
113/// `boundaries_touched` is the deduped, sorted boundary-violation zone set;
114/// `reachable_from` is the impact closure's affected-not-shown set (modules the
115/// changed code reaches / affects, none in the diff). `exports_added` /
116/// `api_width_delta` stay stubbed until the export-surface delta.
117#[must_use]
118pub fn derive_graph_facts(
119    results: &AnalysisResults,
120    closure: Option<&fallow_engine::graph::ImpactClosurePaths>,
121) -> GraphFacts {
122    let mut zones: FxHashSet<String> = FxHashSet::default();
123    for finding in &results.boundary_violations {
124        zones.insert(finding.violation.from_zone.clone());
125        zones.insert(finding.violation.to_zone.clone());
126    }
127    let mut boundaries_touched: Vec<String> = zones.into_iter().collect();
128    boundaries_touched.sort();
129
130    let reachable_from = closure
131        .map(|c| c.affected_not_shown.clone())
132        .unwrap_or_default();
133
134    GraphFacts {
135        exports_added: 0,
136        api_width_delta: 0,
137        reachable_from,
138        boundaries_touched,
139    }
140}
141
142/// Build the Stage 3 impact-closure facts from the audit result's retained
143/// closure (computed on the brief path). Returns an empty closure when no graph
144/// was retained (the closure is `None`).
145#[must_use]
146fn build_impact_closure_facts(result: &AuditResult) -> ImpactClosureFacts {
147    let Some(closure) = result
148        .check
149        .as_ref()
150        .and_then(|c| c.impact_closure.as_ref())
151    else {
152        return ImpactClosureFacts::default();
153    };
154    let coordination_gap = closure
155        .coordination_gap
156        .iter()
157        .map(|gap| CoordinationGapFact {
158            changed_file: gap.changed_file.clone(),
159            consumer_file: gap.consumer_file.clone(),
160            consumed_symbols: gap.consumed_symbols.clone(),
161            note: COORDINATION_GAP_NOTE.to_string(),
162        })
163        .collect();
164    ImpactClosureFacts {
165        affected_not_shown: closure.affected_not_shown.clone(),
166        coordination_gap,
167    }
168}
169
170/// Build the Stage 2 partition facts from the audit result's retained
171/// partition+order (computed on the brief path). Returns an empty partition when
172/// no graph was retained (the partition is `None`).
173#[must_use]
174fn build_partition_facts(result: &AuditResult) -> PartitionFacts {
175    let Some(partition) = result
176        .check
177        .as_ref()
178        .and_then(|c| c.partition_order.as_ref())
179    else {
180        return PartitionFacts::default();
181    };
182    let units = partition
183        .units
184        .iter()
185        .map(|unit| ReviewUnitFact {
186            module_dir: unit.module_dir.clone(),
187            files: unit.files.clone(),
188        })
189        .collect();
190    PartitionFacts {
191        units,
192        order: partition.order.clone(),
193    }
194}
195
196/// Build the Stage 4 weighted focus map from the audit result's retained
197/// per-file graph facts plus the deltas / coordination signals. Returns an
198/// empty focus map when no graph facts were retained (off the brief path or no
199/// changed file mapped to a module).
200///
201/// The boundary risk-zone signal reuses the `from_path` of boundary violations
202/// whose introduced edge is in `deltas.boundary_introduced` (the same surface
203/// the decision surface reads). The security taint signal is wired as an EMPTY
204/// slice today: the brief path runs the bare dead-code analysis, not the opt-in
205/// `fallow security` taint engine, so `results.security_findings` is empty. The
206/// seam lights up the moment a security pass is threaded onto the brief, with no
207/// focus-map code change.
208#[must_use]
209fn build_focus_map(result: &AuditResult, deltas: &ReviewDeltas) -> crate::audit_focus::FocusMap {
210    use crate::audit_focus::{BoundaryZoneFile, FocusInputs, build_focus_map};
211
212    let Some(check) = result.check.as_ref() else {
213        return crate::audit_focus::FocusMap::default();
214    };
215    let Some(graph_facts) = check.focus_facts.as_ref() else {
216        return crate::audit_focus::FocusMap::default();
217    };
218    let root = &check.config.root;
219
220    // Boundary risk-zone files: the importing `from_path` of each boundary
221    // violation whose introduced zone-pair edge is in the delta set, deduped.
222    let mut seen_pairs: FxHashSet<String> = FxHashSet::default();
223    let mut boundary_files: Vec<BoundaryZoneFile> = Vec::new();
224    for finding in &check.results.boundary_violations {
225        let key = crate::audit::review_deltas::boundary_edge_key(finding);
226        if !deltas.boundary_introduced.contains(&key) || !seen_pairs.insert(key) {
227            continue;
228        }
229        boundary_files.push(BoundaryZoneFile {
230            from_file: crate::audit::keys::relative_key_path(&finding.violation.from_path, root),
231        });
232    }
233
234    // Coordination-gap changed files (the signature-change change-shape proxy):
235    // the changed files whose contract is consumed outside the diff.
236    let coordination_changed_files: Vec<String> = check
237        .impact_closure
238        .as_ref()
239        .map(|c| {
240            let mut files: Vec<String> = c
241                .coordination_gap
242                .iter()
243                .map(|gap| gap.changed_file.clone())
244                .collect();
245            files.sort_unstable();
246            files.dedup();
247            files
248        })
249        .unwrap_or_default();
250
251    // Security taint touch: the brief path carries no security findings (the taint
252    // engine is the opt-in `fallow security` command), so this is empty today. The
253    // seam is a pure function of this slice; it lights up when a security pass is
254    // threaded onto the brief.
255    let taint_touched_files = taint_touched_files(result.check.as_ref());
256
257    build_focus_map(&FocusInputs {
258        graph_facts,
259        boundary_files: &boundary_files,
260        public_api_added: &deltas.public_api_added,
261        coordination_changed_files: &coordination_changed_files,
262        taint_touched_files: &taint_touched_files,
263    })
264}
265
266/// Collect the root-relative file paths a security source -> sink taint trace
267/// touches, from any retained `security_findings` (anchor + every trace hop).
268///
269/// Today the brief path runs the bare dead-code analysis, so `security_findings`
270/// is empty and this returns an empty Vec (the security-taint seam contributes
271/// 0). The function is a pure projection over the findings slice, so the moment a
272/// future epic threads a security pass onto the brief, the focus map's taint
273/// signal lights up with no focus-map code change.
274fn taint_touched_files(check: Option<&crate::check::CheckResult>) -> Vec<String> {
275    let Some(check) = check else {
276        return Vec::new();
277    };
278    let root = &check.config.root;
279    let mut touched: FxHashSet<String> = FxHashSet::default();
280    for finding in &check.results.security_findings {
281        touched.insert(crate::audit::keys::relative_key_path(&finding.path, root));
282        for hop in &finding.trace {
283            touched.insert(crate::audit::keys::relative_key_path(&hop.path, root));
284        }
285    }
286    let mut files: Vec<String> = touched.into_iter().collect();
287    files.sort();
288    files
289}
290
291/// Assemble the structured [`ReviewBriefOutput`] for an audit result. Pure: no
292/// timestamps, no randomness, so two runs over the same tree serialize
293/// byte-identically.
294#[must_use]
295pub fn build_brief_output(result: &AuditResult) -> ReviewBriefOutput {
296    let triage = build_triage(result);
297    let closure = result
298        .check
299        .as_ref()
300        .and_then(|c| c.impact_closure.as_ref());
301    let deltas = result.review_deltas.clone().unwrap_or_default();
302    let mut graph_facts = result.check.as_ref().map_or_else(
303        || GraphFacts {
304            exports_added: 0,
305            api_width_delta: 0,
306            reachable_from: Vec::new(),
307            boundaries_touched: Vec::new(),
308        },
309        |check| derive_graph_facts(&check.results, closure),
310    );
311    // The exports-aware delta fills the previously-stubbed export facts:
312    // `exports_added` / `api_width_delta` count the public-API surface the change
313    // widened, not raw internal churn.
314    let added = deltas.public_api_added.len();
315    graph_facts.exports_added = added;
316    graph_facts.api_width_delta = i64::try_from(added).unwrap_or(i64::MAX);
317    let partition = build_partition_facts(result);
318    let impact_closure = build_impact_closure_facts(result);
319    let focus = build_focus_map(result, &deltas);
320    ReviewBriefOutput {
321        schema_version: ReviewBriefSchemaVersion::default(),
322        version: env!("CARGO_PKG_VERSION").to_string(),
323        command: "audit-brief".to_string(),
324        triage,
325        graph_facts,
326        partition,
327        impact_closure,
328        focus,
329        deltas,
330        weakening: result.weakening_signals.clone(),
331        routing: result.routing.clone().unwrap_or_default(),
332        decisions: result.decision_surface.clone().unwrap_or_default(),
333    }
334}
335
336/// Build the reused "subtract" section (dead-code / duplication / complexity)
337/// for the brief JSON value, mirroring `fallow audit --format json`.
338fn build_brief_subtract_sections(
339    result: &AuditResult,
340) -> Result<ReviewBriefSubtractSections, ExitCode> {
341    let mut obj = serde_json::Map::new();
342    if let Some(ref check) = result.check {
343        crate::audit::insert_audit_dead_code_json(&mut obj, result, check)?;
344    }
345    if let Some(ref dupes) = result.dupes {
346        crate::audit::insert_audit_duplication_json(&mut obj, result, dupes)?;
347    }
348    if let Some(ref health) = result.health {
349        crate::audit::insert_audit_health_json(&mut obj, result, health)?;
350    }
351    Ok(ReviewBriefSubtractSections {
352        dead_code: obj.remove("dead_code"),
353        duplication: obj.remove("duplication"),
354        complexity: obj.remove("complexity"),
355    })
356}
357
358/// Build the complete brief JSON value: the versioned brief header, the
359/// informational audit verdict header, the triage + graph-facts stages, and the
360/// reused subtract section.
361fn build_brief_json(result: &AuditResult) -> Result<serde_json::Value, ExitCode> {
362    let brief = build_brief_output(result);
363    let audit_header = fallow_api::build_audit_header_map(crate::audit::audit_json_header_input(
364        result,
365    ))
366    .map_err(|err| {
367        crate::error::emit_error(
368            &format!("JSON serialization error: {err}"),
369            2,
370            fallow_config::OutputFormat::Json,
371        )
372    })?;
373    let subtract = build_brief_subtract_sections(result)?;
374    fallow_output::build_review_brief_json_output(&brief, audit_header, subtract).map_err(|err| {
375        crate::error::emit_error(
376            &format!("JSON serialization error: {err}"),
377            2,
378            fallow_config::OutputFormat::Json,
379        )
380    })
381}
382
383/// Render the brief as JSON. Always returns `SUCCESS`; a serialization failure
384/// surfaces the error but the brief contract still exits 0.
385fn print_brief_json(result: &AuditResult) -> ExitCode {
386    match build_brief_json(result) {
387        Ok(output) => {
388            let Ok(output) = fallow_output::serialize_review_brief_json_output(
389                output,
390                crate::output_runtime::current_root_envelope_mode(),
391                crate::output_runtime::telemetry_analysis_run_id().as_deref(),
392            ) else {
393                return ExitCode::SUCCESS;
394            };
395            let _ = crate::report::emit_json(&output, "audit-brief");
396            ExitCode::SUCCESS
397        }
398        Err(_) => ExitCode::SUCCESS,
399    }
400}
401
402/// Render the brief in human / compact / markdown form: a short orientation
403/// header (scope, risk, effort, boundaries) followed by the same findings
404/// sections `fallow audit` prints.
405fn print_brief_human(result: &AuditResult, quiet: bool, explain: bool, show_deprioritized: bool) {
406    let brief = build_brief_output(result);
407
408    if !quiet {
409        eprintln!();
410        // The decision surface is the apex; it LEADS (collapse-by-default).
411        print_decision_surface_human(&brief.decisions);
412        // The upstream stages are the decision surface's drill-down derivation.
413        eprintln!(
414            "Review brief (drill-down): {} changed file{} vs {} \u{00b7} risk {} \u{00b7} effort {}",
415            result.changed_files_count,
416            crate::report::plural(result.changed_files_count),
417            result.base_ref,
418            risk_label(brief.triage.risk_class),
419            effort_label(brief.triage.review_effort),
420        );
421        if !brief.graph_facts.boundaries_touched.is_empty() {
422            eprintln!(
423                "  boundaries touched: {}",
424                brief.graph_facts.boundaries_touched.join(", ")
425            );
426        }
427        print_partition_human(&brief.partition);
428        print_impact_closure_human(&brief.impact_closure);
429        print_focus_human(&brief.focus, show_deprioritized);
430        print_deltas_human(&brief.deltas);
431        print_weakening_human(&brief.weakening);
432        print_routing_human(&brief.routing);
433    }
434
435    // Always render the findings sections so the brief shows WHERE to look, even
436    // when the underlying verdict is a fail. Headers stay off (the brief owns its
437    // own header line above).
438    crate::audit::print_audit_findings(result, quiet, explain, false);
439}
440
441/// Print the Stage 2 partition + order on the human brief: the by-module units
442/// and the dependency-sensible review order (definitions before consumers).
443/// Caller has already gated on `!quiet`. Renders nothing when no unit was
444/// computed (no graph retained, or every changed file is non-source).
445fn print_partition_human(partition: &PartitionFacts) {
446    if partition.units.is_empty() {
447        return;
448    }
449    eprintln!(
450        "  partition: {} unit{} (by module)",
451        partition.units.len(),
452        crate::report::plural(partition.units.len()),
453    );
454    if !partition.order.is_empty() {
455        let labeled: Vec<String> = partition.order.iter().map(|dir| unit_label(dir)).collect();
456        eprintln!("  review order: {}", labeled.join(" \u{2192} "));
457    }
458}
459
460/// Label a unit's module directory for human output; the empty root-group key
461/// renders as `<root>` so it is not a blank token.
462fn unit_label(module_dir: &str) -> String {
463    if module_dir.is_empty() {
464        "<root>".to_string()
465    } else {
466        module_dir.to_string()
467    }
468}
469
470/// Print the Stage 3 impact-closure summary on the human brief: the count of
471/// affected-but-not-shown files and each coordination gap (the precise
472/// inter-module attention pointer). Caller has already gated on `!quiet`.
473fn print_impact_closure_human(closure: &ImpactClosureFacts) {
474    if !closure.affected_not_shown.is_empty() {
475        eprintln!(
476            "  impact closure: {} file{} affected beyond the diff",
477            closure.affected_not_shown.len(),
478            crate::report::plural(closure.affected_not_shown.len()),
479        );
480    }
481    for gap in &closure.coordination_gap {
482        eprintln!(
483            "  coordination gap: {} consumes {} from {} (not in this diff)",
484            gap.consumer_file,
485            gap.consumed_symbols.join(", "),
486            gap.changed_file,
487        );
488    }
489}
490
491/// Print the Stage 4 weighted focus map on the human brief: the ranked
492/// `review-here` units (with reason + any low-confidence flag), then the
493/// de-prioritized count as a collapsed escape hatch. `--show-deprioritized`
494/// re-expands the full de-prioritized list ("show me what you de-prioritized").
495/// Caller has already gated on `!quiet`. Renders nothing when no unit was scored.
496fn print_focus_human(focus: &crate::audit_focus::FocusMap, show_deprioritized: bool) {
497    if focus.total_units() == 0 {
498        return;
499    }
500    if !focus.review_here.is_empty() {
501        eprintln!(
502            "  focus: {} unit{} to review here (of {} changed)",
503            focus.review_here.len(),
504            crate::report::plural(focus.review_here.len()),
505            focus.total_units(),
506        );
507        for unit in &focus.review_here {
508            eprintln!(
509                "    [{}] {}: {}",
510                unit.label.token(),
511                unit.file,
512                unit.reason
513            );
514            for flag in &unit.confidence {
515                eprintln!("      confidence {}", flag.message());
516            }
517        }
518    }
519    if focus.deprioritized.is_empty() {
520        return;
521    }
522    if show_deprioritized {
523        eprintln!("  de-prioritized ({}):", focus.deprioritized.len());
524        for unit in &focus.deprioritized {
525            eprintln!(
526                "    [{}] {}: {}",
527                unit.label.token(),
528                unit.file,
529                unit.reason
530            );
531            for flag in &unit.confidence {
532                eprintln!("      confidence {}", flag.message());
533            }
534        }
535    } else {
536        eprintln!(
537            "  de-prioritized: {} unit{} (run with --show-deprioritized to list)",
538            focus.deprioritized.len(),
539            crate::report::plural(focus.deprioritized.len()),
540        );
541    }
542}
543
544/// Print the diff-aware deltas (6.A): boundary/cycle introduced and the
545/// exports-aware public-API surface delta (batch-consolidated per R1). Caller
546/// has already gated on `!quiet`.
547fn print_deltas_human(deltas: &ReviewDeltas) {
548    for edge in &deltas.boundary_introduced {
549        eprintln!("  new boundary edge: {edge} (not present at base)");
550    }
551    for cycle in &deltas.cycle_introduced {
552        eprintln!("  new circular dependency: {cycle} (not present at base)");
553    }
554    if !deltas.public_api_added.is_empty() {
555        eprintln!(
556            "  public API surface widened by {} export{} (exports-aware)",
557            deltas.public_api_added.len(),
558            crate::report::plural(deltas.public_api_added.len()),
559        );
560    }
561}
562
563/// Print the weakening signals (6.F headline). Advisory, reviewer-private.
564fn print_weakening_human(signals: &[crate::audit::weakening::WeakeningSignal]) {
565    if signals.is_empty() {
566        return;
567    }
568    eprintln!(
569        "  weakening signals ({}, reviewer-private, advisory):",
570        signals.len()
571    );
572    for signal in signals {
573        eprintln!(
574            "    {}: {} in {}",
575            weakening_label(signal.kind),
576            signal.evidence,
577            signal.file,
578        );
579    }
580}
581
582/// Print the ownership routing (6.D): per-unit expert + bus-factor flag.
583fn print_routing_human(routing: &crate::audit::routing::RoutingFacts) {
584    for unit in &routing.units {
585        if unit.expert.is_empty() {
586            continue;
587        }
588        let bus = if unit.bus_factor_one {
589            " (bus-factor 1)"
590        } else {
591            ""
592        };
593        eprintln!(
594            "  review {}: ask {}{bus}",
595            unit.file,
596            unit.expert.join(", "),
597        );
598    }
599}
600
601/// Print the decision surface (the apex, 6.G): the ranked, capped set of
602/// consequential structural decisions, each as a framed judgment question with
603/// its routed expert. Caller has already gated on `!quiet`. Leads the brief.
604fn print_decision_surface_human(surface: &crate::audit_decision_surface::DecisionSurface) {
605    if surface.decisions.is_empty() {
606        eprintln!("Decisions: none (no consequential structural decision in this change)");
607        eprintln!();
608        return;
609    }
610    eprintln!("Decisions to make ({}):", surface.decisions.len());
611    for (i, decision) in surface.decisions.iter().enumerate() {
612        // Taste ownership: the question first (never an answer), then the honest
613        // graph fact, then the named trade-off. The human reads reversibility from
614        // the count; the tool never labels the door or recommends a choice.
615        eprintln!(
616            "  {}. [{}] {}",
617            i + 1,
618            decision.category.tag(),
619            decision.question
620        );
621        if !decision.tradeoff.is_empty() {
622            eprintln!("     trade-off: {}", decision.tradeoff);
623        }
624        if !decision.expert.is_empty() {
625            let bus = if decision.bus_factor_one {
626                " (bus-factor 1)"
627            } else {
628                ""
629            };
630            eprintln!("     ask: {}{bus}", decision.expert.join(", "));
631        }
632    }
633    if let Some(note) = &surface.truncated {
634        eprintln!("  ... {}", note.reason);
635    }
636    eprintln!();
637}
638
639fn weakening_label(kind: crate::audit::weakening::WeakeningKind) -> &'static str {
640    use crate::audit::weakening::WeakeningKind;
641    match kind {
642        WeakeningKind::TestWeakened => "test weakened",
643        WeakeningKind::ThresholdLowered => "threshold lowered",
644        WeakeningKind::SuppressionAdded => "suppression added",
645        WeakeningKind::SecurityCheckRemoved => "security check removed",
646    }
647}
648
649fn risk_label(risk: RiskClass) -> &'static str {
650    match risk {
651        RiskClass::Low => "low",
652        RiskClass::Medium => "medium",
653        RiskClass::High => "high",
654    }
655}
656
657fn effort_label(effort: ReviewEffort) -> &'static str {
658    match effort {
659        ReviewEffort::Glance => "glance",
660        ReviewEffort::Review => "review",
661        ReviewEffort::DeepDive => "deep-dive",
662    }
663}
664
665/// Print the brief and return an exit code that is ALWAYS `SUCCESS`.
666///
667/// This is the exit-0 seam: `fallow review` (and `fallow audit --brief`) never
668/// gate on the audit verdict. The verdict is still carried in the JSON output
669/// informationally. Format dispatch mirrors `print_audit_result`, but every arm
670/// forces success: JSON renders the brief envelope; human / compact / markdown
671/// render the brief orientation header plus findings; any other format
672/// (SARIF, CodeClimate, PR/review envelopes, badge) is rendered through the
673/// standard audit path and then forced to success so the format stays usable
674/// without re-implementing it for the brief.
675#[must_use]
676pub fn print_brief_result(
677    result: &AuditResult,
678    quiet: bool,
679    explain: bool,
680    show_deprioritized: bool,
681) -> ExitCode {
682    use fallow_config::OutputFormat;
683
684    match result.output {
685        OutputFormat::Json => print_brief_json(result),
686        OutputFormat::Human | OutputFormat::Compact | OutputFormat::Markdown => {
687            print_brief_human(result, quiet, explain, show_deprioritized);
688            ExitCode::SUCCESS
689        }
690        _ => {
691            // For machine/CI formats not specific to the brief, delegate to the
692            // standard audit renderer for the body, then force success: the
693            // brief invariant is exit-0 regardless of verdict.
694            let _ = crate::audit::print_audit_result(result, quiet, explain);
695            ExitCode::SUCCESS
696        }
697    }
698}
699
700/// Render the SEPARABLE decision-surface envelope (the `decision_surface` MCP
701/// tool's output + `fallow decision-surface`). Emits ONLY the ranked, capped
702/// decisions with structured `actions[]`, never the full brief. Always exit 0.
703///
704/// JSON renders the typed decision-surface envelope (`kind:
705/// "decision-surface"`); human / compact / markdown render the apex header.
706#[must_use]
707pub fn print_decision_surface_result(result: &AuditResult, quiet: bool) -> ExitCode {
708    use fallow_config::OutputFormat;
709
710    let surface = result.decision_surface.clone().unwrap_or_default();
711    match result.output {
712        OutputFormat::Json => {
713            let output = crate::audit_decision_surface::build_decision_surface_output(&surface);
714            match fallow_output::serialize_decision_surface_json_output(
715                output,
716                crate::output_runtime::current_root_envelope_mode(),
717                crate::output_runtime::telemetry_analysis_run_id().as_deref(),
718            ) {
719                Ok(value) => {
720                    let _ = crate::report::emit_json(&value, "decision-surface");
721                    ExitCode::SUCCESS
722                }
723                Err(_) => ExitCode::SUCCESS,
724            }
725        }
726        _ => {
727            if !quiet {
728                print_decision_surface_human(&surface);
729            }
730            ExitCode::SUCCESS
731        }
732    }
733}
734
735/// Render the agent-contract WALKTHROUGH GUIDE: the digest (brief +
736/// decision surface), the review direction, the JSON schema the agent returns,
737/// and the deterministic graph-snapshot pin. JSON renders the typed guide
738/// envelope (`kind: "review-walkthrough-guide"`). Every format emits the guide
739/// as JSON: the guide is an agent-facing contract, not a human walkthrough.
740/// Always exit 0.
741#[must_use]
742pub fn print_walkthrough_guide_result(result: &AuditResult) -> ExitCode {
743    let guide = crate::audit_walkthrough::build_guide_from_result(result);
744    if let Ok(value) = fallow_output::serialize_walkthrough_guide_json_output(
745        guide,
746        crate::output_runtime::current_root_envelope_mode(),
747        crate::output_runtime::telemetry_analysis_run_id().as_deref(),
748    ) {
749        let _ = crate::report::emit_json(&value, "review-walkthrough-guide");
750    }
751    ExitCode::SUCCESS
752}
753
754/// Ingest the agent's judgment JSON from `path` and POST-VALIDATE it against
755/// the live graph: reject unanchored signal_ids (anti-hallucination), refuse the
756/// whole payload when the echoed graph-snapshot hash is stale (the tree moved).
757/// JSON renders the typed walkthrough-validation envelope (`kind:
758/// "review-walkthrough-validation"`). Always exit 0 (advisory).
759///
760/// A path that cannot be read yields an empty agent payload (default `""` hash),
761/// which never matches the current hash, so it is refused as stale, the safe
762/// direction: a missing or garbled agent file never accepts a judgment.
763#[must_use]
764pub fn print_walkthrough_file_result(result: &AuditResult, path: &std::path::Path) -> ExitCode {
765    let contents = std::fs::read_to_string(path).unwrap_or_default();
766    let agent = crate::audit_walkthrough::parse_agent_walkthrough(&contents);
767    let surface = result.decision_surface.clone().unwrap_or_default();
768    let current_hash = result.graph_snapshot_hash.clone().unwrap_or_default();
769    let change_anchor_ids =
770        crate::audit_walkthrough::change_anchor_allowlist(&result.change_anchors);
771    let validation = crate::audit_walkthrough::validate_walkthrough(
772        &agent,
773        &surface,
774        &change_anchor_ids,
775        &current_hash,
776    );
777    if let Ok(value) = fallow_output::serialize_walkthrough_validation_json_output(
778        validation,
779        crate::output_runtime::current_root_envelope_mode(),
780        crate::output_runtime::telemetry_analysis_run_id().as_deref(),
781    ) {
782        let _ = crate::report::emit_json(&value, "review-walkthrough-validation");
783    }
784    ExitCode::SUCCESS
785}
786
787#[cfg(test)]
788mod tests {
789    use std::time::Duration;
790
791    use fallow_config::{AuditGate, OutputFormat};
792    use fallow_output::REVIEW_BRIEF_SCHEMA_VERSION;
793
794    use crate::audit::{AuditAttribution, AuditResult, AuditSummary, AuditVerdict};
795
796    use super::*;
797
798    fn audit_result(verdict: AuditVerdict, output: OutputFormat) -> AuditResult {
799        AuditResult {
800            verdict,
801            summary: AuditSummary {
802                dead_code_issues: 0,
803                dead_code_has_errors: false,
804                complexity_findings: 0,
805                max_cyclomatic: None,
806                duplication_clone_groups: 0,
807            },
808            attribution: AuditAttribution {
809                gate: AuditGate::NewOnly,
810                ..AuditAttribution::default()
811            },
812            base_snapshot: None,
813            base_snapshot_skipped: false,
814            changed_files_count: 0,
815            changed_files: Vec::new(),
816            base_ref: "origin/main".to_string(),
817            base_description: None,
818            head_sha: None,
819            output,
820            performance: false,
821            check: None,
822            dupes: None,
823            health: None,
824            elapsed: Duration::ZERO,
825            review_deltas: None,
826            weakening_signals: Vec::new(),
827            routing: None,
828            decision_surface: None,
829            graph_snapshot_hash: None,
830            change_anchors: Vec::new(),
831        }
832    }
833
834    #[test]
835    fn brief_mode_always_returns_success_even_when_verdict_is_fail() {
836        // Human path.
837        let human = audit_result(AuditVerdict::Fail, OutputFormat::Human);
838        assert_eq!(
839            print_brief_result(&human, true, false, false),
840            ExitCode::SUCCESS
841        );
842
843        // JSON path.
844        let json = audit_result(AuditVerdict::Fail, OutputFormat::Json);
845        assert_eq!(
846            print_brief_result(&json, true, false, false),
847            ExitCode::SUCCESS
848        );
849    }
850
851    #[test]
852    fn brief_json_validates_against_audit_brief_schema_variant() {
853        let result = audit_result(AuditVerdict::Fail, OutputFormat::Json);
854        let value = fallow_output::serialize_review_brief_json_output(
855            build_brief_json(&result).expect("brief json must build"),
856            crate::output_runtime::current_root_envelope_mode(),
857            crate::output_runtime::telemetry_analysis_run_id().as_deref(),
858        )
859        .expect("brief json must serialize");
860
861        assert_eq!(value["kind"], "audit-brief");
862        assert_eq!(value["command"], "audit-brief");
863        assert_eq!(value["schema_version"], REVIEW_BRIEF_SCHEMA_VERSION);
864    }
865
866    #[test]
867    fn brief_json_is_byte_identical_on_repeated_serialization() {
868        // `elapsed: Duration::ZERO` and no telemetry: the brief JSON carries no
869        // timestamps or randomness, so two builds serialize byte-identically.
870        let result = audit_result(AuditVerdict::Warn, OutputFormat::Json);
871        let first = build_brief_json(&result).expect("first build");
872        let second = build_brief_json(&result).expect("second build");
873        let first_str = serde_json::to_string_pretty(&first).expect("serialize first");
874        let second_str = serde_json::to_string_pretty(&second).expect("serialize second");
875        assert_eq!(first_str, second_str);
876    }
877
878    #[test]
879    fn risk_class_thresholds_are_pure_functions_of_size() {
880        assert_eq!(classify_risk(0, None), RiskClass::Low);
881        assert_eq!(classify_risk(RISK_MEDIUM_FILES, None), RiskClass::Medium);
882        assert_eq!(classify_risk(RISK_HIGH_FILES, None), RiskClass::High);
883        assert_eq!(classify_risk(1, Some(RISK_HIGH_LINES)), RiskClass::High);
884        assert_eq!(review_effort_for(RiskClass::High), ReviewEffort::DeepDive);
885    }
886
887    #[test]
888    fn brief_json_includes_empty_impact_closure_when_no_graph_retained() {
889        // check: None -> no closure; the impact_closure object must still be
890        // present and empty so consumers can rely on its presence.
891        let result = audit_result(AuditVerdict::Warn, OutputFormat::Json);
892        let value = build_brief_json(&result).expect("brief json must build");
893        assert!(value.get("impact_closure").is_some(), "{value}");
894        assert_eq!(
895            value["impact_closure"]["affected_not_shown"],
896            serde_json::json!([])
897        );
898        assert_eq!(
899            value["impact_closure"]["coordination_gap"],
900            serde_json::json!([])
901        );
902    }
903
904    #[test]
905    fn derive_graph_facts_populates_reachable_from_from_closure() {
906        use fallow_engine::graph::{CoordinationGapPaths, ImpactClosurePaths};
907        let results = AnalysisResults::default();
908        let closure = ImpactClosurePaths {
909            in_diff: vec!["src/core.ts".to_string()],
910            affected_not_shown: vec!["src/app.ts".to_string(), "src/mid.ts".to_string()],
911            coordination_gap: vec![CoordinationGapPaths {
912                changed_file: "src/core.ts".to_string(),
913                consumer_file: "src/mid.ts".to_string(),
914                consumed_symbols: vec!["compute".to_string()],
915            }],
916        };
917        let facts = derive_graph_facts(&results, Some(&closure));
918        assert_eq!(
919            facts.reachable_from,
920            vec!["src/app.ts".to_string(), "src/mid.ts".to_string()]
921        );
922    }
923
924    #[test]
925    fn coordination_gap_fact_carries_honest_scope_note() {
926        let gap = CoordinationGapFact {
927            changed_file: "src/core.ts".to_string(),
928            consumer_file: "src/mid.ts".to_string(),
929            consumed_symbols: vec!["compute".to_string()],
930            note: COORDINATION_GAP_NOTE.to_string(),
931        };
932        assert!(gap.note.contains("attention pointer"));
933        assert!(gap.note.contains("not a correctness proof"));
934    }
935}