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 an independently-versioned
12//! [`crate::output_envelope::FallowOutput::AuditBrief`] variant
13//! (`kind: "audit-brief"`) so the brief shape evolves on its own cadence
14//! without bumping the main `--format json` contract.
15
16use std::process::ExitCode;
17
18use fallow_types::results::AnalysisResults;
19use rustc_hash::FxHashSet;
20use serde::Serialize;
21
22use crate::audit::AuditResult;
23
24/// Wire version for the `fallow audit --brief --format json` envelope. Bumped
25/// independently of the main `--format json` contract: the brief shape can grow
26/// without touching `report::SCHEMA_VERSION`.
27///
28/// v2: adds the stage-4 weighted `focus` map (composite attention score per
29/// unit + no-skip labels + confidence flags + the `deprioritized` escape hatch).
30/// v5: adds `change_anchors` to the walkthrough guide and `anchor_kind` /
31/// `change_anchor` to the walkthrough validation judgments. This version pins the
32/// brief, the walkthrough guide, and the validation envelope together; it is a
33/// SEMANTIC marker only (the integer is not pinned in the wire schema, so the
34/// bump alone produces no schema/`.d.ts` diff).
35pub const REVIEW_BRIEF_SCHEMA_VERSION: u32 = 5;
36
37/// A file count at or above which a changeset is classified [`RiskClass::High`].
38const RISK_HIGH_FILES: usize = 20;
39/// A net-line count at or above which a changeset is classified
40/// [`RiskClass::High`]. Stub-only in v1 (net lines are not yet threaded); kept
41/// as a named constant so the threshold is documented where the classifier
42/// lives.
43const RISK_HIGH_LINES: i64 = 500;
44/// A file count at or above which a changeset is classified
45/// [`RiskClass::Medium`].
46const RISK_MEDIUM_FILES: usize = 5;
47/// A net-line count at or above which a changeset is classified
48/// [`RiskClass::Medium`].
49const RISK_MEDIUM_LINES: i64 = 100;
50
51/// Independently-versioned wire-version newtype for the brief envelope.
52/// Serializes as the integer `REVIEW_BRIEF_SCHEMA_VERSION`.
53#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
54#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
55pub struct ReviewBriefSchemaVersion(pub u32);
56
57impl Default for ReviewBriefSchemaVersion {
58    fn default() -> Self {
59        Self(REVIEW_BRIEF_SCHEMA_VERSION)
60    }
61}
62
63/// Coarse risk classification for a changeset, a pure function of the change
64/// size (file count plus, once threaded, net lines).
65#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
66#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
67#[serde(rename_all = "snake_case")]
68pub enum RiskClass {
69    /// Small, contained change.
70    Low,
71    /// Moderately sized change.
72    Medium,
73    /// Large change spanning many files or lines.
74    High,
75}
76
77/// Suggested reviewer effort, a pure function of [`RiskClass`].
78#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
79#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
80#[serde(rename_all = "snake_case")]
81pub enum ReviewEffort {
82    /// A quick scan is enough.
83    Glance,
84    /// A normal line-by-line review.
85    Review,
86    /// A careful, deep review is warranted.
87    DeepDive,
88}
89
90/// Stage 0 of the brief: triage facts derived purely from the diff size.
91///
92/// `hunks` and `net_lines` are `None` in v1: the file-level audit does not yet
93/// thread a `DiffIndex` (from `report/ci/diff_filter.rs`). They populate later,
94/// on `--diff-file` / `--diff-stdin`, without a schema bump.
95#[derive(Debug, Clone, Serialize)]
96#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
97pub struct DiffTriage {
98    /// Number of changed files in the audit scope.
99    pub files: usize,
100    /// Number of diff hunks. `None` in v1 (no diff index threaded yet).
101    #[serde(default, skip_serializing_if = "Option::is_none")]
102    pub hunks: Option<usize>,
103    /// Net added-minus-removed lines. `None` in v1 (no diff index threaded yet).
104    #[serde(default, skip_serializing_if = "Option::is_none")]
105    pub net_lines: Option<i64>,
106    /// Coarse risk class derived from the change size.
107    pub risk_class: RiskClass,
108    /// Suggested reviewer effort derived from `risk_class`.
109    pub review_effort: ReviewEffort,
110}
111
112/// Stage 1 of the brief: graph-derived orientation facts.
113///
114/// `boundaries_touched` is derived from the run's boundary-violation zones;
115/// `reachable_from` is populated by the impact closure (the affected-not-shown
116/// set: modules the changed code is reachable from / affects, none in the diff).
117/// `exports_added` / `api_width_delta` stay honestly stubbed (`0`) until the
118/// export-surface delta lands. The fields are present and correctly typed so
119/// values fill in later without a schema bump.
120#[derive(Debug, Clone, Serialize)]
121#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
122pub struct GraphFacts {
123    /// Number of exports added by the changeset. Stubbed to `0` in v1.
124    pub exports_added: usize,
125    /// Change in public API width (added minus removed exports). Stubbed to `0`
126    /// in v1.
127    pub api_width_delta: i64,
128    /// Root-relative paths of modules the changed code is reachable from / affects
129    /// (the impact closure's affected-but-not-in-diff set), deduped and sorted.
130    /// Empty when no graph was retained or nothing depends on the changed files.
131    pub reachable_from: Vec<String>,
132    /// Architecture boundary zones touched by the changeset, deduped and sorted.
133    /// Derived from the run's boundary-violation findings.
134    pub boundaries_touched: Vec<String>,
135}
136
137/// Stage 3 of the brief: the impact closure. The transitive
138/// affected-but-not-in-diff set plus the coordination gap. The differentiator a
139/// diff tool fundamentally cannot do, because it has no graph.
140///
141/// Honest scope (ADR-001, syntactic): the coordination gap is an attention
142/// pointer at the exact inter-module failure mode, NOT a correctness proof.
143#[derive(Debug, Clone, Default, Serialize)]
144#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
145pub struct ImpactClosureFacts {
146    /// Root-relative paths transitively affected by the changeset (reverse-deps +
147    /// re-export chains) that are NOT in the diff, deduped and sorted.
148    pub affected_not_shown: Vec<String>,
149    /// Coordination gaps: a changed file exports a contract consumed by a module
150    /// absent from the diff. One entry per (changed file, consumer) pair.
151    pub coordination_gap: Vec<CoordinationGapFact>,
152}
153
154/// One coordination-gap entry: a changed file exports symbols consumed by a
155/// `consumer_file` that is NOT in the diff. Deduped per (changed, consumer) pair
156/// (firing-precision rule R2).
157#[derive(Debug, Clone, Serialize)]
158#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
159pub struct CoordinationGapFact {
160    /// Root-relative path of the changed file whose contract is consumed elsewhere.
161    pub changed_file: String,
162    /// Root-relative path of the consumer module that is NOT in the diff.
163    pub consumer_file: String,
164    /// The exported symbol names the consumer references, sorted.
165    pub consumed_symbols: Vec<String>,
166    /// Honest scope note: this is a syntactic attention pointer, not a proof.
167    pub note: String,
168}
169
170/// The honest-scope note stamped on every coordination-gap entry (ADR-001).
171const COORDINATION_GAP_NOTE: &str = "syntactic attention pointer, not a correctness proof";
172
173/// Stage 2 of the brief: the partition + order. The changed files split into
174/// coherent BY-MODULE units (the only byte-identical-deterministic clustering
175/// definition straight from the graph), plus a dependency-sensible review ORDER
176/// over those units (definitions before consumers, mechanical/leaf units last,
177/// ties broken by the path sort). Stage 2 sits UNDER the decision surface as a
178/// drill-down; it is the backbone the directed-review loop hands the agent.
179///
180/// Feature-cluster and concern partitioning are deferred (they need scoring
181/// heuristics whose tie-breaks are a fresh nondeterminism surface).
182#[derive(Debug, Clone, Default, Serialize)]
183#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
184pub struct PartitionFacts {
185    /// The by-module units, sorted by module directory. Empty when no graph was
186    /// retained or no changed file maps to a known module.
187    pub units: Vec<ReviewUnitFact>,
188    /// The dependency-sensible review order: module-directory strings,
189    /// definitions before consumers, mechanical/leaf units last. A permutation of
190    /// the `units` module directories.
191    pub order: Vec<String>,
192}
193
194/// One review unit: a coherent by-module cluster of the changed set.
195#[derive(Debug, Clone, Serialize)]
196#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
197pub struct ReviewUnitFact {
198    /// The module directory the unit covers (root-relative, forward-slashed).
199    /// The empty string is the repository-root group.
200    pub module_dir: String,
201    /// The changed files in this unit, path-sorted.
202    pub files: Vec<String>,
203}
204
205/// Diff-aware deterministic deltas (6.A), framed new-vs-pre-existing against
206/// the audit base snapshot. Each entry is a brief summary/verdict line.
207///
208/// `public_api` is batch-consolidated to ONE decision per change (rule R1):
209/// the `added` list carries the introduced public-export keys as evidence, but a
210/// reviewer reads "the public surface widened by N", never one decision per
211/// symbol.
212#[derive(Debug, Clone, Default, Serialize)]
213#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
214pub struct ReviewDeltas {
215    /// Cross-zone boundary EDGES introduced vs base (R2 first-edge-only: one per
216    /// `<from_zone>-><to_zone>` pair, never per import). New-vs-pre-existing.
217    pub boundary_introduced: Vec<String>,
218    /// Circular dependencies introduced vs base (canonical file-set keys).
219    pub cycle_introduced: Vec<String>,
220    /// Exports-aware public-API surface delta: the public-export keys
221    /// (`<rel_path>::<name>`) added vs base, resolved through `package.json`
222    /// `exports` + re-export reachability. A symbol re-exported only through an
223    /// internal barrel NOT in `exports` is absent here (zero delta); one
224    /// reachable through an `exports` path is present (exactly one).
225    pub public_api_added: Vec<String>,
226}
227
228/// Build the deltas from head sets vs a base set, sorted for determinism.
229#[must_use]
230#[allow(
231    clippy::implicit_hasher,
232    reason = "callers always pass the audit FxHashSet key sets; generalizing the hasher adds noise"
233)]
234pub fn build_review_deltas(
235    head_boundary: &FxHashSet<String>,
236    base_boundary: &FxHashSet<String>,
237    head_cycles: &FxHashSet<String>,
238    base_cycles: &FxHashSet<String>,
239    head_public_api: &FxHashSet<String>,
240    base_public_api: &FxHashSet<String>,
241) -> ReviewDeltas {
242    use crate::audit::review_deltas::introduced_keys;
243    ReviewDeltas {
244        boundary_introduced: introduced_keys(head_boundary, base_boundary),
245        cycle_introduced: introduced_keys(head_cycles, base_cycles),
246        public_api_added: introduced_keys(head_public_api, base_public_api),
247    }
248}
249
250/// The full `fallow audit --brief --format json` envelope. Carries the
251/// informational verdict, the triage and graph-facts orientation stages, plus
252/// the reused "subtract" section (the same dead-code / duplication / complexity
253/// payload `fallow audit --format json` emits).
254#[derive(Debug, Clone, Serialize)]
255#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
256#[cfg_attr(
257    feature = "schema",
258    schemars(title = "fallow audit --brief --format json")
259)]
260pub struct ReviewBriefOutput {
261    /// Independently-versioned brief schema version.
262    pub schema_version: ReviewBriefSchemaVersion,
263    /// Fallow CLI version that produced this output.
264    pub version: String,
265    /// Command discriminator singleton: always `"audit-brief"`.
266    pub command: String,
267    /// Stage 0: diff triage.
268    pub triage: DiffTriage,
269    /// Stage 1: graph orientation facts.
270    pub graph_facts: GraphFacts,
271    /// Stage 2: the partition + order (by-module units + dependency-sensible
272    /// review order). The backbone the directed-review loop hands the agent.
273    pub partition: PartitionFacts,
274    /// Stage 3: the impact closure (affected-not-shown + coordination gap).
275    pub impact_closure: ImpactClosureFacts,
276    /// Stage 4: the weighted focus map. A composite attention score per
277    /// changed-file unit (fan-in/out + security taint + risk zone + change shape),
278    /// with `review-here` / `not-prioritized` labels (NEVER `skip` in free mode),
279    /// a per-unit confidence flag, and the FULL `deprioritized` escape-hatch list
280    /// so every de-prioritized piece is reachable. Stage 4 sits UNDER the decision
281    /// surface as drill-down.
282    pub focus: crate::audit_focus::FocusMap,
283    /// 6.A: diff-aware deterministic deltas (boundary/cycle introduced +
284    /// exports-aware public-API surface delta), new-vs-pre-existing.
285    pub deltas: ReviewDeltas,
286    /// 6.F, headline: reviewer-private weakening signals (tests
287    /// removed/skipped, thresholds lowered, suppressions added, security steps
288    /// removed). Advisory, never gates, never auto-posted.
289    pub weakening: Vec<crate::audit::weakening::WeakeningSignal>,
290    /// 6.D: ownership-aware reviewer routing (per-file expert + bus-factor).
291    pub routing: crate::audit::routing::RoutingFacts,
292    /// 6.G, the APEX: the decision surface. The ranked, capped,
293    /// signal_id-anchored set of consequential structural decisions, each framed
294    /// as a judgment question with its routed expert. This is the only thing the
295    /// brief visibly leads with; the stages above are its drill-down derivation.
296    pub decisions: crate::audit_decision_surface::DecisionSurface,
297}
298
299/// Classify a changeset's risk purely from its size. `net_lines` is consulted
300/// when present (it is `None` on the v1 file-level audit path).
301#[must_use]
302pub fn classify_risk(files: usize, net_lines: Option<i64>) -> RiskClass {
303    let lines = net_lines.unwrap_or(0).abs();
304    if files >= RISK_HIGH_FILES || lines >= RISK_HIGH_LINES {
305        RiskClass::High
306    } else if files >= RISK_MEDIUM_FILES || lines >= RISK_MEDIUM_LINES {
307        RiskClass::Medium
308    } else {
309        RiskClass::Low
310    }
311}
312
313/// Map a [`RiskClass`] to the suggested reviewer effort.
314#[must_use]
315pub fn review_effort_for(risk: RiskClass) -> ReviewEffort {
316    match risk {
317        RiskClass::Low => ReviewEffort::Glance,
318        RiskClass::Medium => ReviewEffort::Review,
319        RiskClass::High => ReviewEffort::DeepDive,
320    }
321}
322
323/// Build the Stage 0 triage facts from the audit result.
324#[must_use]
325pub fn build_triage(result: &AuditResult) -> DiffTriage {
326    let files = result.changed_files_count;
327    // v1: no diff index is threaded into the file-level audit, so hunks and net
328    // lines are honestly absent. They populate on `--diff-file`/`--diff-stdin`.
329    let hunks = None;
330    let net_lines = None;
331    let risk_class = classify_risk(files, net_lines);
332    DiffTriage {
333        files,
334        hunks,
335        net_lines,
336        risk_class,
337        review_effort: review_effort_for(risk_class),
338    }
339}
340
341/// Derive the Stage 1 graph facts from the analysis results plus the impact
342/// closure.
343///
344/// `boundaries_touched` is the deduped, sorted boundary-violation zone set;
345/// `reachable_from` is the impact closure's affected-not-shown set (modules the
346/// changed code reaches / affects, none in the diff). `exports_added` /
347/// `api_width_delta` stay stubbed until the export-surface delta.
348#[must_use]
349pub fn derive_graph_facts(
350    results: &AnalysisResults,
351    closure: Option<&fallow_core::graph::ImpactClosurePaths>,
352) -> GraphFacts {
353    let mut zones: FxHashSet<String> = FxHashSet::default();
354    for finding in &results.boundary_violations {
355        zones.insert(finding.violation.from_zone.clone());
356        zones.insert(finding.violation.to_zone.clone());
357    }
358    let mut boundaries_touched: Vec<String> = zones.into_iter().collect();
359    boundaries_touched.sort();
360
361    let reachable_from = closure
362        .map(|c| c.affected_not_shown.clone())
363        .unwrap_or_default();
364
365    GraphFacts {
366        exports_added: 0,
367        api_width_delta: 0,
368        reachable_from,
369        boundaries_touched,
370    }
371}
372
373/// Build the Stage 3 impact-closure facts from the audit result's retained
374/// closure (computed on the brief path). Returns an empty closure when no graph
375/// was retained (the closure is `None`).
376#[must_use]
377fn build_impact_closure_facts(result: &AuditResult) -> ImpactClosureFacts {
378    let Some(closure) = result
379        .check
380        .as_ref()
381        .and_then(|c| c.impact_closure.as_ref())
382    else {
383        return ImpactClosureFacts::default();
384    };
385    let coordination_gap = closure
386        .coordination_gap
387        .iter()
388        .map(|gap| CoordinationGapFact {
389            changed_file: gap.changed_file.clone(),
390            consumer_file: gap.consumer_file.clone(),
391            consumed_symbols: gap.consumed_symbols.clone(),
392            note: COORDINATION_GAP_NOTE.to_string(),
393        })
394        .collect();
395    ImpactClosureFacts {
396        affected_not_shown: closure.affected_not_shown.clone(),
397        coordination_gap,
398    }
399}
400
401/// Build the Stage 2 partition facts from the audit result's retained
402/// partition+order (computed on the brief path). Returns an empty partition when
403/// no graph was retained (the partition is `None`).
404#[must_use]
405fn build_partition_facts(result: &AuditResult) -> PartitionFacts {
406    let Some(partition) = result
407        .check
408        .as_ref()
409        .and_then(|c| c.partition_order.as_ref())
410    else {
411        return PartitionFacts::default();
412    };
413    let units = partition
414        .units
415        .iter()
416        .map(|unit| ReviewUnitFact {
417            module_dir: unit.module_dir.clone(),
418            files: unit.files.clone(),
419        })
420        .collect();
421    PartitionFacts {
422        units,
423        order: partition.order.clone(),
424    }
425}
426
427/// Build the Stage 4 weighted focus map from the audit result's retained
428/// per-file graph facts plus the deltas / coordination signals. Returns an
429/// empty focus map when no graph facts were retained (off the brief path or no
430/// changed file mapped to a module).
431///
432/// The boundary risk-zone signal reuses the `from_path` of boundary violations
433/// whose introduced edge is in `deltas.boundary_introduced` (the same surface
434/// the decision surface reads). The security taint signal is wired as an EMPTY
435/// slice today: the brief path runs the bare dead-code analysis, not the opt-in
436/// `fallow security` taint engine, so `results.security_findings` is empty. The
437/// seam lights up the moment a security pass is threaded onto the brief, with no
438/// focus-map code change.
439#[must_use]
440fn build_focus_map(result: &AuditResult, deltas: &ReviewDeltas) -> crate::audit_focus::FocusMap {
441    use crate::audit_focus::{BoundaryZoneFile, FocusInputs, build_focus_map};
442
443    let Some(check) = result.check.as_ref() else {
444        return crate::audit_focus::FocusMap::default();
445    };
446    let Some(graph_facts) = check.focus_facts.as_ref() else {
447        return crate::audit_focus::FocusMap::default();
448    };
449    let root = &check.config.root;
450
451    // Boundary risk-zone files: the importing `from_path` of each boundary
452    // violation whose introduced zone-pair edge is in the delta set, deduped.
453    let mut seen_pairs: FxHashSet<String> = FxHashSet::default();
454    let mut boundary_files: Vec<BoundaryZoneFile> = Vec::new();
455    for finding in &check.results.boundary_violations {
456        let key = crate::audit::review_deltas::boundary_edge_key(finding);
457        if !deltas.boundary_introduced.contains(&key) || !seen_pairs.insert(key) {
458            continue;
459        }
460        boundary_files.push(BoundaryZoneFile {
461            from_file: crate::audit::keys::relative_key_path(&finding.violation.from_path, root),
462        });
463    }
464
465    // Coordination-gap changed files (the signature-change change-shape proxy):
466    // the changed files whose contract is consumed outside the diff.
467    let coordination_changed_files: Vec<String> = check
468        .impact_closure
469        .as_ref()
470        .map(|c| {
471            let mut files: Vec<String> = c
472                .coordination_gap
473                .iter()
474                .map(|gap| gap.changed_file.clone())
475                .collect();
476            files.sort_unstable();
477            files.dedup();
478            files
479        })
480        .unwrap_or_default();
481
482    // Security taint touch: the brief path carries no security findings (the taint
483    // engine is the opt-in `fallow security` command), so this is empty today. The
484    // seam is a pure function of this slice; it lights up when a security pass is
485    // threaded onto the brief.
486    let taint_touched_files = taint_touched_files(result.check.as_ref());
487
488    build_focus_map(&FocusInputs {
489        graph_facts,
490        boundary_files: &boundary_files,
491        public_api_added: &deltas.public_api_added,
492        coordination_changed_files: &coordination_changed_files,
493        taint_touched_files: &taint_touched_files,
494    })
495}
496
497/// Collect the root-relative file paths a security source -> sink taint trace
498/// touches, from any retained `security_findings` (anchor + every trace hop).
499///
500/// Today the brief path runs the bare dead-code analysis, so `security_findings`
501/// is empty and this returns an empty Vec (the security-taint seam contributes
502/// 0). The function is a pure projection over the findings slice, so the moment a
503/// future epic threads a security pass onto the brief, the focus map's taint
504/// signal lights up with no focus-map code change.
505fn taint_touched_files(check: Option<&crate::check::CheckResult>) -> Vec<String> {
506    let Some(check) = check else {
507        return Vec::new();
508    };
509    let root = &check.config.root;
510    let mut touched: FxHashSet<String> = FxHashSet::default();
511    for finding in &check.results.security_findings {
512        touched.insert(crate::audit::keys::relative_key_path(&finding.path, root));
513        for hop in &finding.trace {
514            touched.insert(crate::audit::keys::relative_key_path(&hop.path, root));
515        }
516    }
517    let mut files: Vec<String> = touched.into_iter().collect();
518    files.sort();
519    files
520}
521
522/// Assemble the structured [`ReviewBriefOutput`] for an audit result. Pure: no
523/// timestamps, no randomness, so two runs over the same tree serialize
524/// byte-identically.
525#[must_use]
526pub fn build_brief_output(result: &AuditResult) -> ReviewBriefOutput {
527    let triage = build_triage(result);
528    let closure = result
529        .check
530        .as_ref()
531        .and_then(|c| c.impact_closure.as_ref());
532    let deltas = result.review_deltas.clone().unwrap_or_default();
533    let mut graph_facts = result.check.as_ref().map_or_else(
534        || GraphFacts {
535            exports_added: 0,
536            api_width_delta: 0,
537            reachable_from: Vec::new(),
538            boundaries_touched: Vec::new(),
539        },
540        |check| derive_graph_facts(&check.results, closure),
541    );
542    // The exports-aware delta fills the previously-stubbed export facts:
543    // `exports_added` / `api_width_delta` count the public-API surface the change
544    // widened, not raw internal churn.
545    let added = deltas.public_api_added.len();
546    graph_facts.exports_added = added;
547    graph_facts.api_width_delta = i64::try_from(added).unwrap_or(i64::MAX);
548    let partition = build_partition_facts(result);
549    let impact_closure = build_impact_closure_facts(result);
550    let focus = build_focus_map(result, &deltas);
551    ReviewBriefOutput {
552        schema_version: ReviewBriefSchemaVersion::default(),
553        version: env!("CARGO_PKG_VERSION").to_string(),
554        command: "audit-brief".to_string(),
555        triage,
556        graph_facts,
557        partition,
558        impact_closure,
559        focus,
560        deltas,
561        weakening: result.weakening_signals.clone(),
562        routing: result.routing.clone().unwrap_or_default(),
563        decisions: result.decision_surface.clone().unwrap_or_default(),
564    }
565}
566
567/// Insert the Stage 0 triage object into the brief JSON map.
568fn insert_brief_triage_json(
569    obj: &mut serde_json::Map<String, serde_json::Value>,
570    brief: &ReviewBriefOutput,
571) {
572    if let Ok(value) = serde_json::to_value(&brief.triage) {
573        obj.insert("triage".into(), value);
574    }
575}
576
577/// Insert the Stage 1 graph-facts object into the brief JSON map.
578fn insert_brief_graph_facts_json(
579    obj: &mut serde_json::Map<String, serde_json::Value>,
580    brief: &ReviewBriefOutput,
581) {
582    if let Ok(value) = serde_json::to_value(&brief.graph_facts) {
583        obj.insert("graph_facts".into(), value);
584    }
585}
586
587/// Insert the Stage 2 partition + order object into the brief JSON map.
588fn insert_brief_partition_json(
589    obj: &mut serde_json::Map<String, serde_json::Value>,
590    brief: &ReviewBriefOutput,
591) {
592    if let Ok(value) = serde_json::to_value(&brief.partition) {
593        obj.insert("partition".into(), value);
594    }
595}
596
597/// Insert the Stage 3 impact-closure object into the brief JSON map.
598fn insert_brief_impact_closure_json(
599    obj: &mut serde_json::Map<String, serde_json::Value>,
600    brief: &ReviewBriefOutput,
601) {
602    if let Ok(value) = serde_json::to_value(&brief.impact_closure) {
603        obj.insert("impact_closure".into(), value);
604    }
605}
606
607/// Insert the Stage 4 weighted focus map into the brief JSON map. The
608/// `deprioritized` escape-hatch list is ALWAYS present (every de-prioritized
609/// unit), so nothing is hidden regardless of `--show-deprioritized` (a
610/// human-rendering-only flag).
611fn insert_brief_focus_json(
612    obj: &mut serde_json::Map<String, serde_json::Value>,
613    brief: &ReviewBriefOutput,
614) {
615    if let Ok(value) = serde_json::to_value(&brief.focus) {
616        obj.insert("focus".into(), value);
617    }
618}
619
620/// Insert the deltas / weakening / routing sections into the brief JSON map.
621fn insert_brief_e3_json(
622    obj: &mut serde_json::Map<String, serde_json::Value>,
623    brief: &ReviewBriefOutput,
624) {
625    if let Ok(value) = serde_json::to_value(&brief.deltas) {
626        obj.insert("deltas".into(), value);
627    }
628    if let Ok(value) = serde_json::to_value(&brief.weakening) {
629        obj.insert("weakening".into(), value);
630    }
631    if let Ok(value) = serde_json::to_value(&brief.routing) {
632        obj.insert("routing".into(), value);
633    }
634}
635
636/// Insert the decision surface (the apex) into the brief JSON map.
637fn insert_brief_decisions_json(
638    obj: &mut serde_json::Map<String, serde_json::Value>,
639    brief: &ReviewBriefOutput,
640) {
641    if let Ok(value) = serde_json::to_value(&brief.decisions) {
642        obj.insert("decisions".into(), value);
643    }
644}
645
646/// Insert the reused "subtract" section (dead-code / duplication / complexity)
647/// into the brief JSON map, mirroring `fallow audit --format json`. Returns the
648/// failing exit code if any sub-payload fails to serialize; the caller maps that
649/// to a force-success on the brief path but surfaces the serialization error.
650fn insert_brief_subtract_json(
651    obj: &mut serde_json::Map<String, serde_json::Value>,
652    result: &AuditResult,
653) -> Result<(), ExitCode> {
654    if let Some(ref check) = result.check {
655        crate::audit::insert_audit_dead_code_json(obj, result, check)?;
656    }
657    if let Some(ref dupes) = result.dupes {
658        crate::audit::insert_audit_duplication_json(obj, result, dupes)?;
659    }
660    if let Some(ref health) = result.health {
661        crate::audit::insert_audit_health_json(obj, result, health)?;
662    }
663    Ok(())
664}
665
666/// Build the complete brief JSON value: the versioned brief header, the
667/// informational audit verdict header, the triage + graph-facts stages, and the
668/// reused subtract section.
669fn build_brief_json(result: &AuditResult) -> Result<serde_json::Value, ExitCode> {
670    let brief = build_brief_output(result);
671    let mut obj = serde_json::Map::new();
672
673    obj.insert(
674        "schema_version".into(),
675        serde_json::to_value(brief.schema_version).unwrap_or(serde_json::Value::Null),
676    );
677    obj.insert(
678        "version".into(),
679        serde_json::Value::String(brief.version.clone()),
680    );
681    obj.insert(
682        "command".into(),
683        serde_json::Value::String(brief.command.clone()),
684    );
685
686    // The audit verdict and scope are carried informationally so the brief is a
687    // superset of the audit header; the verdict never drives the brief exit.
688    crate::audit::insert_audit_json_header(&mut obj, result);
689    // Re-stamp the brief command over the audit header's `"audit"` / its
690    // `SCHEMA_VERSION`, so the document advertises the brief contract.
691    obj.insert(
692        "schema_version".into(),
693        serde_json::to_value(brief.schema_version).unwrap_or(serde_json::Value::Null),
694    );
695    obj.insert(
696        "command".into(),
697        serde_json::Value::String(brief.command.clone()),
698    );
699
700    // The decision surface is the apex; it leads the JSON (collapse-by-
701    // default), with the stages below as its drill-down derivation.
702    insert_brief_decisions_json(&mut obj, &brief);
703    insert_brief_triage_json(&mut obj, &brief);
704    insert_brief_graph_facts_json(&mut obj, &brief);
705    insert_brief_partition_json(&mut obj, &brief);
706    insert_brief_impact_closure_json(&mut obj, &brief);
707    insert_brief_focus_json(&mut obj, &brief);
708    insert_brief_e3_json(&mut obj, &brief);
709    insert_brief_subtract_json(&mut obj, result)?;
710
711    Ok(serde_json::Value::Object(obj))
712}
713
714/// Render the brief as JSON. Always returns `SUCCESS`; a serialization failure
715/// surfaces the error but the brief contract still exits 0.
716fn print_brief_json(result: &AuditResult) -> ExitCode {
717    match build_brief_json(result) {
718        Ok(mut output) => {
719            crate::output_envelope::apply_root_kind(&mut output, "audit-brief");
720            crate::output_envelope::attach_telemetry_meta(&mut output);
721            let _ = crate::report::emit_json(&output, "audit-brief");
722            ExitCode::SUCCESS
723        }
724        Err(_) => ExitCode::SUCCESS,
725    }
726}
727
728/// Render the brief in human / compact / markdown form: a short orientation
729/// header (scope, risk, effort, boundaries) followed by the same findings
730/// sections `fallow audit` prints.
731fn print_brief_human(result: &AuditResult, quiet: bool, explain: bool, show_deprioritized: bool) {
732    let brief = build_brief_output(result);
733
734    if !quiet {
735        eprintln!();
736        // The decision surface is the apex; it LEADS (collapse-by-default).
737        print_decision_surface_human(&brief.decisions);
738        // The upstream stages are the decision surface's drill-down derivation.
739        eprintln!(
740            "Review brief (drill-down): {} changed file{} vs {} \u{00b7} risk {} \u{00b7} effort {}",
741            result.changed_files_count,
742            crate::report::plural(result.changed_files_count),
743            result.base_ref,
744            risk_label(brief.triage.risk_class),
745            effort_label(brief.triage.review_effort),
746        );
747        if !brief.graph_facts.boundaries_touched.is_empty() {
748            eprintln!(
749                "  boundaries touched: {}",
750                brief.graph_facts.boundaries_touched.join(", ")
751            );
752        }
753        print_partition_human(&brief.partition);
754        print_impact_closure_human(&brief.impact_closure);
755        print_focus_human(&brief.focus, show_deprioritized);
756        print_deltas_human(&brief.deltas);
757        print_weakening_human(&brief.weakening);
758        print_routing_human(&brief.routing);
759    }
760
761    // Always render the findings sections so the brief shows WHERE to look, even
762    // when the underlying verdict is a fail. Headers stay off (the brief owns its
763    // own header line above).
764    crate::audit::print_audit_findings(result, quiet, explain, false);
765}
766
767/// Print the Stage 2 partition + order on the human brief: the by-module units
768/// and the dependency-sensible review order (definitions before consumers).
769/// Caller has already gated on `!quiet`. Renders nothing when no unit was
770/// computed (no graph retained, or every changed file is non-source).
771fn print_partition_human(partition: &PartitionFacts) {
772    if partition.units.is_empty() {
773        return;
774    }
775    eprintln!(
776        "  partition: {} unit{} (by module)",
777        partition.units.len(),
778        crate::report::plural(partition.units.len()),
779    );
780    if !partition.order.is_empty() {
781        let labeled: Vec<String> = partition.order.iter().map(|dir| unit_label(dir)).collect();
782        eprintln!("  review order: {}", labeled.join(" \u{2192} "));
783    }
784}
785
786/// Label a unit's module directory for human output; the empty root-group key
787/// renders as `<root>` so it is not a blank token.
788fn unit_label(module_dir: &str) -> String {
789    if module_dir.is_empty() {
790        "<root>".to_string()
791    } else {
792        module_dir.to_string()
793    }
794}
795
796/// Print the Stage 3 impact-closure summary on the human brief: the count of
797/// affected-but-not-shown files and each coordination gap (the precise
798/// inter-module attention pointer). Caller has already gated on `!quiet`.
799fn print_impact_closure_human(closure: &ImpactClosureFacts) {
800    if !closure.affected_not_shown.is_empty() {
801        eprintln!(
802            "  impact closure: {} file{} affected beyond the diff",
803            closure.affected_not_shown.len(),
804            crate::report::plural(closure.affected_not_shown.len()),
805        );
806    }
807    for gap in &closure.coordination_gap {
808        eprintln!(
809            "  coordination gap: {} consumes {} from {} (not in this diff)",
810            gap.consumer_file,
811            gap.consumed_symbols.join(", "),
812            gap.changed_file,
813        );
814    }
815}
816
817/// Print the Stage 4 weighted focus map on the human brief: the ranked
818/// `review-here` units (with reason + any low-confidence flag), then the
819/// de-prioritized count as a collapsed escape hatch. `--show-deprioritized`
820/// re-expands the full de-prioritized list ("show me what you de-prioritized").
821/// Caller has already gated on `!quiet`. Renders nothing when no unit was scored.
822fn print_focus_human(focus: &crate::audit_focus::FocusMap, show_deprioritized: bool) {
823    if focus.total_units() == 0 {
824        return;
825    }
826    if !focus.review_here.is_empty() {
827        eprintln!(
828            "  focus: {} unit{} to review here (of {} changed)",
829            focus.review_here.len(),
830            crate::report::plural(focus.review_here.len()),
831            focus.total_units(),
832        );
833        for unit in &focus.review_here {
834            eprintln!(
835                "    [{}] {}: {}",
836                unit.label.token(),
837                unit.file,
838                unit.reason
839            );
840            for flag in &unit.confidence {
841                eprintln!("      confidence {}", flag.message());
842            }
843        }
844    }
845    if focus.deprioritized.is_empty() {
846        return;
847    }
848    if show_deprioritized {
849        eprintln!("  de-prioritized ({}):", focus.deprioritized.len());
850        for unit in &focus.deprioritized {
851            eprintln!(
852                "    [{}] {}: {}",
853                unit.label.token(),
854                unit.file,
855                unit.reason
856            );
857            for flag in &unit.confidence {
858                eprintln!("      confidence {}", flag.message());
859            }
860        }
861    } else {
862        eprintln!(
863            "  de-prioritized: {} unit{} (run with --show-deprioritized to list)",
864            focus.deprioritized.len(),
865            crate::report::plural(focus.deprioritized.len()),
866        );
867    }
868}
869
870/// Print the diff-aware deltas (6.A): boundary/cycle introduced and the
871/// exports-aware public-API surface delta (batch-consolidated per R1). Caller
872/// has already gated on `!quiet`.
873fn print_deltas_human(deltas: &ReviewDeltas) {
874    for edge in &deltas.boundary_introduced {
875        eprintln!("  new boundary edge: {edge} (not present at base)");
876    }
877    for cycle in &deltas.cycle_introduced {
878        eprintln!("  new circular dependency: {cycle} (not present at base)");
879    }
880    if !deltas.public_api_added.is_empty() {
881        eprintln!(
882            "  public API surface widened by {} export{} (exports-aware)",
883            deltas.public_api_added.len(),
884            crate::report::plural(deltas.public_api_added.len()),
885        );
886    }
887}
888
889/// Print the weakening signals (6.F headline). Advisory, reviewer-private.
890fn print_weakening_human(signals: &[crate::audit::weakening::WeakeningSignal]) {
891    if signals.is_empty() {
892        return;
893    }
894    eprintln!(
895        "  weakening signals ({}, reviewer-private, advisory):",
896        signals.len()
897    );
898    for signal in signals {
899        eprintln!(
900            "    {}: {} in {}",
901            weakening_label(signal.kind),
902            signal.evidence,
903            signal.file,
904        );
905    }
906}
907
908/// Print the ownership routing (6.D): per-unit expert + bus-factor flag.
909fn print_routing_human(routing: &crate::audit::routing::RoutingFacts) {
910    for unit in &routing.units {
911        if unit.expert.is_empty() {
912            continue;
913        }
914        let bus = if unit.bus_factor_one {
915            " (bus-factor 1)"
916        } else {
917            ""
918        };
919        eprintln!(
920            "  review {}: ask {}{bus}",
921            unit.file,
922            unit.expert.join(", "),
923        );
924    }
925}
926
927/// Print the decision surface (the apex, 6.G): the ranked, capped set of
928/// consequential structural decisions, each as a framed judgment question with
929/// its routed expert. Caller has already gated on `!quiet`. Leads the brief.
930fn print_decision_surface_human(surface: &crate::audit_decision_surface::DecisionSurface) {
931    if surface.decisions.is_empty() {
932        eprintln!("Decisions: none (no consequential structural decision in this change)");
933        eprintln!();
934        return;
935    }
936    eprintln!("Decisions to make ({}):", surface.decisions.len());
937    for (i, decision) in surface.decisions.iter().enumerate() {
938        // Taste ownership: the question first (never an answer), then the honest
939        // graph fact, then the named trade-off. The human reads reversibility from
940        // the count; the tool never labels the door or recommends a choice.
941        eprintln!(
942            "  {}. [{}] {}",
943            i + 1,
944            decision.category.tag(),
945            decision.question
946        );
947        if !decision.tradeoff.is_empty() {
948            eprintln!("     trade-off: {}", decision.tradeoff);
949        }
950        if !decision.expert.is_empty() {
951            let bus = if decision.bus_factor_one {
952                " (bus-factor 1)"
953            } else {
954                ""
955            };
956            eprintln!("     ask: {}{bus}", decision.expert.join(", "));
957        }
958    }
959    if let Some(note) = &surface.truncated {
960        eprintln!("  ... {}", note.reason);
961    }
962    eprintln!();
963}
964
965fn weakening_label(kind: crate::audit::weakening::WeakeningKind) -> &'static str {
966    use crate::audit::weakening::WeakeningKind;
967    match kind {
968        WeakeningKind::TestWeakened => "test weakened",
969        WeakeningKind::ThresholdLowered => "threshold lowered",
970        WeakeningKind::SuppressionAdded => "suppression added",
971        WeakeningKind::SecurityCheckRemoved => "security check removed",
972    }
973}
974
975fn risk_label(risk: RiskClass) -> &'static str {
976    match risk {
977        RiskClass::Low => "low",
978        RiskClass::Medium => "medium",
979        RiskClass::High => "high",
980    }
981}
982
983fn effort_label(effort: ReviewEffort) -> &'static str {
984    match effort {
985        ReviewEffort::Glance => "glance",
986        ReviewEffort::Review => "review",
987        ReviewEffort::DeepDive => "deep-dive",
988    }
989}
990
991/// Print the brief and return an exit code that is ALWAYS `SUCCESS`.
992///
993/// This is the exit-0 seam: `fallow review` (and `fallow audit --brief`) never
994/// gate on the audit verdict. The verdict is still carried in the JSON output
995/// informationally. Format dispatch mirrors `print_audit_result`, but every arm
996/// forces success: JSON renders the brief envelope; human / compact / markdown
997/// render the brief orientation header plus findings; any other format
998/// (SARIF, CodeClimate, PR/review envelopes, badge) is rendered through the
999/// standard audit path and then forced to success so the format stays usable
1000/// without re-implementing it for the brief.
1001#[must_use]
1002pub fn print_brief_result(
1003    result: &AuditResult,
1004    quiet: bool,
1005    explain: bool,
1006    show_deprioritized: bool,
1007) -> ExitCode {
1008    use fallow_config::OutputFormat;
1009
1010    match result.output {
1011        OutputFormat::Json => print_brief_json(result),
1012        OutputFormat::Human | OutputFormat::Compact | OutputFormat::Markdown => {
1013            print_brief_human(result, quiet, explain, show_deprioritized);
1014            ExitCode::SUCCESS
1015        }
1016        _ => {
1017            // For machine/CI formats not specific to the brief, delegate to the
1018            // standard audit renderer for the body, then force success: the
1019            // brief invariant is exit-0 regardless of verdict.
1020            let _ = crate::audit::print_audit_result(result, quiet, explain);
1021            ExitCode::SUCCESS
1022        }
1023    }
1024}
1025
1026/// Render the SEPARABLE decision-surface envelope (the `decision_surface` MCP
1027/// tool's output + `fallow decision-surface`). Emits ONLY the ranked, capped
1028/// decisions with structured `actions[]`, never the full brief. Always exit 0.
1029///
1030/// JSON renders the `FallowOutput::DecisionSurface` envelope (`kind:
1031/// "decision-surface"`); human / compact / markdown render the apex header.
1032#[must_use]
1033pub fn print_decision_surface_result(result: &AuditResult, quiet: bool) -> ExitCode {
1034    use fallow_config::OutputFormat;
1035
1036    let surface = result.decision_surface.clone().unwrap_or_default();
1037    match result.output {
1038        OutputFormat::Json => {
1039            let envelope = crate::output_envelope::FallowOutput::DecisionSurface(
1040                crate::audit_decision_surface::build_decision_surface_output(&surface),
1041            );
1042            match serde_json::to_value(&envelope) {
1043                Ok(mut value) => {
1044                    crate::output_envelope::apply_root_kind(&mut value, "decision-surface");
1045                    crate::output_envelope::attach_telemetry_meta(&mut value);
1046                    let _ = crate::report::emit_json(&value, "decision-surface");
1047                    ExitCode::SUCCESS
1048                }
1049                Err(_) => ExitCode::SUCCESS,
1050            }
1051        }
1052        _ => {
1053            if !quiet {
1054                print_decision_surface_human(&surface);
1055            }
1056            ExitCode::SUCCESS
1057        }
1058    }
1059}
1060
1061/// Render the agent-contract WALKTHROUGH GUIDE: the digest (brief +
1062/// decision surface), the review direction, the JSON schema the agent returns,
1063/// and the deterministic graph-snapshot pin. JSON renders the
1064/// `FallowOutput::WalkthroughGuide` envelope (`kind: "review-walkthrough-guide"`).
1065/// Every format emits the guide as JSON: the guide is an agent-facing contract,
1066/// not a human walkthrough. Always exit 0.
1067#[must_use]
1068pub fn print_walkthrough_guide_result(result: &AuditResult) -> ExitCode {
1069    let guide = crate::audit_walkthrough::build_guide_from_result(result);
1070    let envelope = crate::output_envelope::FallowOutput::WalkthroughGuide(guide);
1071    if let Ok(mut value) = serde_json::to_value(&envelope) {
1072        crate::output_envelope::apply_root_kind(&mut value, "review-walkthrough-guide");
1073        crate::output_envelope::attach_telemetry_meta(&mut value);
1074        let _ = crate::report::emit_json(&value, "review-walkthrough-guide");
1075    }
1076    ExitCode::SUCCESS
1077}
1078
1079/// Ingest the agent's judgment JSON from `path` and POST-VALIDATE it against
1080/// the live graph: reject unanchored signal_ids (anti-hallucination), refuse the
1081/// whole payload when the echoed graph-snapshot hash is stale (the tree moved).
1082/// JSON renders the `FallowOutput::WalkthroughValidation` envelope (`kind:
1083/// "review-walkthrough-validation"`). Always exit 0 (advisory).
1084///
1085/// A path that cannot be read yields an empty agent payload (default `""` hash),
1086/// which never matches the current hash, so it is refused as stale, the safe
1087/// direction: a missing or garbled agent file never accepts a judgment.
1088#[must_use]
1089pub fn print_walkthrough_file_result(result: &AuditResult, path: &std::path::Path) -> ExitCode {
1090    let contents = std::fs::read_to_string(path).unwrap_or_default();
1091    let agent = crate::audit_walkthrough::parse_agent_walkthrough(&contents);
1092    let surface = result.decision_surface.clone().unwrap_or_default();
1093    let current_hash = result.graph_snapshot_hash.clone().unwrap_or_default();
1094    let change_anchor_ids =
1095        crate::audit_walkthrough::change_anchor_allowlist(&result.change_anchors);
1096    let validation = crate::audit_walkthrough::validate_walkthrough(
1097        &agent,
1098        &surface,
1099        &change_anchor_ids,
1100        &current_hash,
1101    );
1102    let envelope = crate::output_envelope::FallowOutput::WalkthroughValidation(validation);
1103    if let Ok(mut value) = serde_json::to_value(&envelope) {
1104        crate::output_envelope::apply_root_kind(&mut value, "review-walkthrough-validation");
1105        crate::output_envelope::attach_telemetry_meta(&mut value);
1106        let _ = crate::report::emit_json(&value, "review-walkthrough-validation");
1107    }
1108    ExitCode::SUCCESS
1109}
1110
1111#[cfg(test)]
1112mod tests {
1113    use std::time::Duration;
1114
1115    use fallow_config::{AuditGate, OutputFormat};
1116
1117    use crate::audit::{AuditAttribution, AuditResult, AuditSummary, AuditVerdict};
1118
1119    use super::*;
1120
1121    fn audit_result(verdict: AuditVerdict, output: OutputFormat) -> AuditResult {
1122        AuditResult {
1123            verdict,
1124            summary: AuditSummary {
1125                dead_code_issues: 0,
1126                dead_code_has_errors: false,
1127                complexity_findings: 0,
1128                max_cyclomatic: None,
1129                duplication_clone_groups: 0,
1130            },
1131            attribution: AuditAttribution {
1132                gate: AuditGate::NewOnly,
1133                ..AuditAttribution::default()
1134            },
1135            base_snapshot: None,
1136            base_snapshot_skipped: false,
1137            changed_files_count: 0,
1138            changed_files: Vec::new(),
1139            base_ref: "origin/main".to_string(),
1140            base_description: None,
1141            head_sha: None,
1142            output,
1143            performance: false,
1144            check: None,
1145            dupes: None,
1146            health: None,
1147            elapsed: Duration::ZERO,
1148            review_deltas: None,
1149            weakening_signals: Vec::new(),
1150            routing: None,
1151            decision_surface: None,
1152            graph_snapshot_hash: None,
1153            change_anchors: Vec::new(),
1154        }
1155    }
1156
1157    #[test]
1158    fn brief_mode_always_returns_success_even_when_verdict_is_fail() {
1159        // Human path.
1160        let human = audit_result(AuditVerdict::Fail, OutputFormat::Human);
1161        assert_eq!(
1162            print_brief_result(&human, true, false, false),
1163            ExitCode::SUCCESS
1164        );
1165
1166        // JSON path.
1167        let json = audit_result(AuditVerdict::Fail, OutputFormat::Json);
1168        assert_eq!(
1169            print_brief_result(&json, true, false, false),
1170            ExitCode::SUCCESS
1171        );
1172    }
1173
1174    #[test]
1175    fn brief_json_validates_against_audit_brief_schema_variant() {
1176        let result = audit_result(AuditVerdict::Fail, OutputFormat::Json);
1177        let mut value = build_brief_json(&result).expect("brief json must build");
1178        crate::output_envelope::apply_root_kind(&mut value, "audit-brief");
1179
1180        assert_eq!(value["kind"], "audit-brief");
1181        assert_eq!(value["command"], "audit-brief");
1182        assert_eq!(value["schema_version"], REVIEW_BRIEF_SCHEMA_VERSION);
1183    }
1184
1185    #[test]
1186    fn brief_json_is_byte_identical_on_repeated_serialization() {
1187        // `elapsed: Duration::ZERO` and no telemetry: the brief JSON carries no
1188        // timestamps or randomness, so two builds serialize byte-identically.
1189        let result = audit_result(AuditVerdict::Warn, OutputFormat::Json);
1190        let first = build_brief_json(&result).expect("first build");
1191        let second = build_brief_json(&result).expect("second build");
1192        let first_str = serde_json::to_string_pretty(&first).expect("serialize first");
1193        let second_str = serde_json::to_string_pretty(&second).expect("serialize second");
1194        assert_eq!(first_str, second_str);
1195    }
1196
1197    #[test]
1198    fn risk_class_thresholds_are_pure_functions_of_size() {
1199        assert_eq!(classify_risk(0, None), RiskClass::Low);
1200        assert_eq!(classify_risk(RISK_MEDIUM_FILES, None), RiskClass::Medium);
1201        assert_eq!(classify_risk(RISK_HIGH_FILES, None), RiskClass::High);
1202        assert_eq!(classify_risk(1, Some(RISK_HIGH_LINES)), RiskClass::High);
1203        assert_eq!(review_effort_for(RiskClass::High), ReviewEffort::DeepDive);
1204    }
1205
1206    #[test]
1207    fn brief_json_includes_empty_impact_closure_when_no_graph_retained() {
1208        // check: None -> no closure; the impact_closure object must still be
1209        // present and empty so consumers can rely on its presence.
1210        let result = audit_result(AuditVerdict::Warn, OutputFormat::Json);
1211        let value = build_brief_json(&result).expect("brief json must build");
1212        assert!(value.get("impact_closure").is_some(), "{value}");
1213        assert_eq!(
1214            value["impact_closure"]["affected_not_shown"],
1215            serde_json::json!([])
1216        );
1217        assert_eq!(
1218            value["impact_closure"]["coordination_gap"],
1219            serde_json::json!([])
1220        );
1221    }
1222
1223    #[test]
1224    fn derive_graph_facts_populates_reachable_from_from_closure() {
1225        use fallow_core::graph::{CoordinationGapPaths, ImpactClosurePaths};
1226        let results = AnalysisResults::default();
1227        let closure = ImpactClosurePaths {
1228            in_diff: vec!["src/core.ts".to_string()],
1229            affected_not_shown: vec!["src/app.ts".to_string(), "src/mid.ts".to_string()],
1230            coordination_gap: vec![CoordinationGapPaths {
1231                changed_file: "src/core.ts".to_string(),
1232                consumer_file: "src/mid.ts".to_string(),
1233                consumed_symbols: vec!["compute".to_string()],
1234            }],
1235        };
1236        let facts = derive_graph_facts(&results, Some(&closure));
1237        assert_eq!(
1238            facts.reachable_from,
1239            vec!["src/app.ts".to_string(), "src/mid.ts".to_string()]
1240        );
1241    }
1242
1243    #[test]
1244    fn coordination_gap_fact_carries_honest_scope_note() {
1245        let gap = CoordinationGapFact {
1246            changed_file: "src/core.ts".to_string(),
1247            consumer_file: "src/mid.ts".to_string(),
1248            consumed_symbols: vec!["compute".to_string()],
1249            note: COORDINATION_GAP_NOTE.to_string(),
1250        };
1251        assert!(gap.note.contains("attention pointer"));
1252        assert!(gap.note.contains("not a correctness proof"));
1253    }
1254}