Skip to main content

fallow_output/
audit_brief.rs

1//! Audit brief output contracts.
2
3use crate::root_envelopes::{RootEnvelopeMode, attach_telemetry_meta, serialize_named_json_output};
4use serde::Serialize;
5use serde_json::{Map, Value};
6
7/// Wire version for the `fallow audit --brief --format json` envelope.
8pub const REVIEW_BRIEF_SCHEMA_VERSION: u32 = 5;
9
10/// Independently-versioned wire-version newtype for the brief envelope.
11/// Serializes as the integer `REVIEW_BRIEF_SCHEMA_VERSION`.
12#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
13#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
14pub struct ReviewBriefSchemaVersion(pub u32);
15
16impl Default for ReviewBriefSchemaVersion {
17    fn default() -> Self {
18        Self(REVIEW_BRIEF_SCHEMA_VERSION)
19    }
20}
21
22/// Coarse risk classification for a changeset, a pure function of the change
23/// size (file count plus, once threaded, net lines).
24#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
25#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
26#[serde(rename_all = "snake_case")]
27pub enum RiskClass {
28    /// Small, contained change.
29    Low,
30    /// Moderately sized change.
31    Medium,
32    /// Large change spanning many files or lines.
33    High,
34}
35
36/// Suggested reviewer effort, a pure function of [`RiskClass`].
37#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
38#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
39#[serde(rename_all = "snake_case")]
40pub enum ReviewEffort {
41    /// A quick scan is enough.
42    Glance,
43    /// A normal line-by-line review.
44    Review,
45    /// A careful, deep review is warranted.
46    DeepDive,
47}
48
49/// Stage 0 of the brief: triage facts derived purely from the diff size.
50///
51/// `hunks` and `net_lines` are `None` in v1: the file-level audit does not yet
52/// thread a `DiffIndex` (from `report/ci/diff_filter.rs`). They populate later,
53/// on `--diff-file` / `--diff-stdin`, without a schema bump.
54#[derive(Debug, Clone, Serialize)]
55#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
56pub struct DiffTriage {
57    /// Number of changed files in the audit scope.
58    pub files: usize,
59    /// Number of diff hunks. `None` in v1 (no diff index threaded yet).
60    #[serde(default, skip_serializing_if = "Option::is_none")]
61    pub hunks: Option<usize>,
62    /// Net added-minus-removed lines. `None` in v1 (no diff index threaded yet).
63    #[serde(default, skip_serializing_if = "Option::is_none")]
64    pub net_lines: Option<i64>,
65    /// Coarse risk class derived from the change size.
66    pub risk_class: RiskClass,
67    /// Suggested reviewer effort derived from `risk_class`.
68    pub review_effort: ReviewEffort,
69}
70
71/// Stage 1 of the brief: graph-derived orientation facts.
72///
73/// `boundaries_touched` is derived from the run's boundary-violation zones;
74/// `reachable_from` is populated by the impact closure (the affected-not-shown
75/// set: modules the changed code is reachable from / affects, none in the diff).
76/// `exports_added` / `api_width_delta` stay honestly stubbed (`0`) until the
77/// export-surface delta lands. The fields are present and correctly typed so
78/// values fill in later without a schema bump.
79#[derive(Debug, Clone, Serialize)]
80#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
81pub struct GraphFacts {
82    /// Number of exports added by the changeset. Stubbed to `0` in v1.
83    pub exports_added: usize,
84    /// Change in public API width (added minus removed exports). Stubbed to `0`
85    /// in v1.
86    pub api_width_delta: i64,
87    /// Root-relative paths of modules the changed code is reachable from / affects
88    /// (the impact closure's affected-but-not-in-diff set), deduped and sorted.
89    /// Empty when no graph was retained or nothing depends on the changed files.
90    pub reachable_from: Vec<String>,
91    /// Architecture boundary zones touched by the changeset, deduped and sorted.
92    /// Derived from the run's boundary-violation findings.
93    pub boundaries_touched: Vec<String>,
94}
95
96/// Stage 3 of the brief: the impact closure. The transitive
97/// affected-but-not-in-diff set plus the coordination gap. The differentiator a
98/// diff tool fundamentally cannot do, because it has no graph.
99///
100/// Honest scope (ADR-001, syntactic): the coordination gap is an attention
101/// pointer at the exact inter-module failure mode, NOT a correctness proof.
102#[derive(Debug, Clone, Default, Serialize)]
103#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
104pub struct ImpactClosureFacts {
105    /// Root-relative paths transitively affected by the changeset (reverse-deps +
106    /// re-export chains) that are NOT in the diff, deduped and sorted.
107    pub affected_not_shown: Vec<String>,
108    /// Coordination gaps: a changed file exports a contract consumed by a module
109    /// absent from the diff. One entry per (changed file, consumer) pair.
110    pub coordination_gap: Vec<CoordinationGapFact>,
111}
112
113/// One coordination-gap entry: a changed file exports symbols consumed by a
114/// `consumer_file` that is NOT in the diff. Deduped per (changed, consumer) pair
115/// (firing-precision rule R2).
116#[derive(Debug, Clone, Serialize)]
117#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
118pub struct CoordinationGapFact {
119    /// Root-relative path of the changed file whose contract is consumed elsewhere.
120    pub changed_file: String,
121    /// Root-relative path of the consumer module that is NOT in the diff.
122    pub consumer_file: String,
123    /// The exported symbol names the consumer references, sorted.
124    pub consumed_symbols: Vec<String>,
125    /// Honest scope note: this is a syntactic attention pointer, not a proof.
126    pub note: String,
127}
128
129/// Stage 2 of the brief: the partition + order. The changed files split into
130/// coherent BY-MODULE units (the only byte-identical-deterministic clustering
131/// definition straight from the graph), plus a dependency-sensible review ORDER
132/// over those units (definitions before consumers, mechanical/leaf units last,
133/// ties broken by the path sort). Stage 2 sits UNDER the decision surface as a
134/// drill-down; it is the backbone the directed-review loop hands the agent.
135///
136/// Feature-cluster and concern partitioning are deferred (they need scoring
137/// heuristics whose tie-breaks are a fresh nondeterminism surface).
138#[derive(Debug, Clone, Default, Serialize)]
139#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
140pub struct PartitionFacts {
141    /// The by-module units, sorted by module directory. Empty when no graph was
142    /// retained or no changed file maps to a known module.
143    pub units: Vec<ReviewUnitFact>,
144    /// The dependency-sensible review order: module-directory strings,
145    /// definitions before consumers, mechanical/leaf units last. A permutation of
146    /// the `units` module directories.
147    pub order: Vec<String>,
148}
149
150/// One review unit: a coherent by-module cluster of the changed set.
151#[derive(Debug, Clone, Serialize)]
152#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
153pub struct ReviewUnitFact {
154    /// The module directory the unit covers (root-relative, forward-slashed).
155    /// The empty string is the repository-root group.
156    pub module_dir: String,
157    /// The changed files in this unit, path-sorted.
158    pub files: Vec<String>,
159}
160
161/// Diff-aware deterministic deltas (6.A), framed new-vs-pre-existing against
162/// the audit base snapshot. Each entry is a brief summary/verdict line.
163///
164/// `public_api` is batch-consolidated to ONE decision per change (rule R1):
165/// the `added` list carries the introduced public-export keys as evidence, but a
166/// reviewer reads "the public surface widened by N", never one decision per
167/// symbol.
168#[derive(Debug, Clone, Default, Serialize)]
169#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
170pub struct ReviewDeltas {
171    /// Cross-zone boundary EDGES introduced vs base (R2 first-edge-only: one per
172    /// `<from_zone>-><to_zone>` pair, never per import). New-vs-pre-existing.
173    pub boundary_introduced: Vec<String>,
174    /// Circular dependencies introduced vs base (canonical file-set keys).
175    pub cycle_introduced: Vec<String>,
176    /// Exports-aware public-API surface delta: the public-export keys
177    /// (`<rel_path>::<name>`) added vs base, resolved through `package.json`
178    /// `exports` + re-export reachability. A symbol re-exported only through an
179    /// internal barrel NOT in `exports` is absent here (zero delta); one
180    /// reachable through an `exports` path is present (exactly one).
181    pub public_api_added: Vec<String>,
182}
183
184/// The full `fallow audit --brief --format json` envelope. Carries the
185/// informational verdict, the triage and graph-facts orientation stages, plus
186/// the reused "subtract" section (the same dead-code / duplication / complexity
187/// payload `fallow audit --format json` emits).
188#[derive(Debug, Clone, Serialize)]
189#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
190#[cfg_attr(
191    feature = "schema",
192    schemars(title = "fallow audit --brief --format json")
193)]
194pub struct ReviewBriefOutput<Focus, Weakening, Routing, Decisions> {
195    /// Independently-versioned brief schema version.
196    pub schema_version: ReviewBriefSchemaVersion,
197    /// Fallow CLI version that produced this output.
198    pub version: String,
199    /// Command discriminator singleton: always `"audit-brief"`.
200    pub command: String,
201    pub triage: DiffTriage,
202    /// Stage 1: graph orientation facts.
203    pub graph_facts: GraphFacts,
204    /// Stage 2: the partition + order (by-module units + dependency-sensible
205    /// review order). The backbone the directed-review loop hands the agent.
206    pub partition: PartitionFacts,
207    /// Stage 3: the impact closure (affected-not-shown + coordination gap).
208    pub impact_closure: ImpactClosureFacts,
209    /// Stage 4: the weighted focus map. A composite attention score per
210    /// changed-file unit (fan-in/out + security taint + risk zone + change shape),
211    /// with `review-here` / `not-prioritized` labels (NEVER `skip` in free mode),
212    /// a per-unit confidence flag, and the FULL `deprioritized` escape-hatch list
213    /// so every de-prioritized piece is reachable. Stage 4 sits UNDER the decision
214    /// surface as drill-down.
215    pub focus: Focus,
216    /// 6.A: diff-aware deterministic deltas (boundary/cycle introduced +
217    /// exports-aware public-API surface delta), new-vs-pre-existing.
218    pub deltas: ReviewDeltas,
219    /// 6.F, headline: reviewer-private weakening signals (tests
220    /// removed/skipped, thresholds lowered, suppressions added, security steps
221    /// removed). Advisory, never gates, never auto-posted.
222    pub weakening: Vec<Weakening>,
223    /// 6.D: ownership-aware reviewer routing (per-file expert + bus-factor).
224    pub routing: Routing,
225    /// 6.G, the APEX: the decision surface. The ranked, capped,
226    /// signal_id-anchored set of consequential structural decisions, each framed
227    /// as a judgment question with its routed expert. This is the only thing the
228    /// brief visibly leads with; the stages above are its drill-down derivation.
229    pub decisions: Decisions,
230}
231
232/// The standard audit brief payload shape used by the CLI, schema emitter,
233/// API, and agent-facing review surfaces.
234pub type StandardReviewBriefOutput = ReviewBriefOutput<
235    crate::audit_focus::FocusMap,
236    crate::audit_weakening::WeakeningSignal,
237    crate::audit_routing::RoutingFacts,
238    crate::audit_decision_surface::DecisionSurface,
239>;
240
241/// CLI-built audit subreports that are embedded in the audit brief envelope.
242///
243/// The brief envelope and field ordering belong to `fallow-output`; the
244/// underlying subreport payloads are still supplied by the CLI until their
245/// builders are fully command-neutral.
246#[derive(Debug, Clone, Default)]
247pub struct ReviewBriefSubtractSections {
248    pub dead_code: Option<Value>,
249    pub duplication: Option<Value>,
250    pub complexity: Option<Value>,
251}
252
253fn insert_serialized<T: Serialize>(
254    obj: &mut Map<String, Value>,
255    key: &'static str,
256    value: &T,
257) -> Result<(), serde_json::Error> {
258    obj.insert(key.to_string(), serde_json::to_value(value)?);
259    Ok(())
260}
261
262/// Build the complete `fallow audit --brief --format json` value.
263///
264/// `audit_header` carries informational audit scope fields such as verdict,
265/// base ref, summary, and attribution. This function restamps the independent
266/// brief schema and command after merging that header so the resulting document
267/// advertises the brief contract rather than the regular audit JSON contract.
268pub fn build_review_brief_json_output<Focus, Weakening, Routing, Decisions>(
269    brief: &ReviewBriefOutput<Focus, Weakening, Routing, Decisions>,
270    audit_header: Map<String, Value>,
271    subtract: ReviewBriefSubtractSections,
272) -> Result<Value, serde_json::Error>
273where
274    Focus: Serialize,
275    Weakening: Serialize,
276    Routing: Serialize,
277    Decisions: Serialize,
278{
279    let mut obj = Map::new();
280
281    insert_serialized(&mut obj, "schema_version", &brief.schema_version)?;
282    obj.insert("version".into(), Value::String(brief.version.clone()));
283    obj.insert("command".into(), Value::String(brief.command.clone()));
284
285    for (key, value) in audit_header {
286        obj.insert(key, value);
287    }
288
289    insert_serialized(&mut obj, "schema_version", &brief.schema_version)?;
290    obj.insert("command".into(), Value::String(brief.command.clone()));
291
292    insert_serialized(&mut obj, "decisions", &brief.decisions)?;
293    insert_serialized(&mut obj, "triage", &brief.triage)?;
294    insert_serialized(&mut obj, "graph_facts", &brief.graph_facts)?;
295    insert_serialized(&mut obj, "partition", &brief.partition)?;
296    insert_serialized(&mut obj, "impact_closure", &brief.impact_closure)?;
297    insert_serialized(&mut obj, "focus", &brief.focus)?;
298    insert_serialized(&mut obj, "deltas", &brief.deltas)?;
299    insert_serialized(&mut obj, "weakening", &brief.weakening)?;
300    insert_serialized(&mut obj, "routing", &brief.routing)?;
301
302    if let Some(value) = subtract.dead_code {
303        obj.insert("dead_code".into(), value);
304    }
305    if let Some(value) = subtract.duplication {
306        obj.insert("duplication".into(), value);
307    }
308    if let Some(value) = subtract.complexity {
309        obj.insert("complexity".into(), value);
310    }
311
312    Ok(Value::Object(obj))
313}
314
315fn serialize_agent_contract_json_output<T: Serialize>(
316    output: T,
317    kind: &'static str,
318    mode: RootEnvelopeMode,
319    analysis_run_id: Option<&str>,
320) -> Result<Value, serde_json::Error> {
321    let mut value = serialize_named_json_output(output, kind, mode)?;
322    attach_telemetry_meta(&mut value, analysis_run_id);
323    Ok(value)
324}
325
326/// Serialize the `fallow audit --brief --format json` envelope.
327///
328/// # Errors
329///
330/// Returns a serde error when the brief output cannot be converted to JSON.
331pub fn serialize_review_brief_json_output<T: Serialize>(
332    output: T,
333    mode: RootEnvelopeMode,
334    analysis_run_id: Option<&str>,
335) -> Result<Value, serde_json::Error> {
336    serialize_agent_contract_json_output(output, "audit-brief", mode, analysis_run_id)
337}
338
339/// Serialize the standalone decision-surface envelope.
340///
341/// # Errors
342///
343/// Returns a serde error when the decision-surface output cannot be converted
344/// to JSON.
345pub fn serialize_decision_surface_json_output<T: Serialize>(
346    output: T,
347    mode: RootEnvelopeMode,
348    analysis_run_id: Option<&str>,
349) -> Result<Value, serde_json::Error> {
350    serialize_agent_contract_json_output(output, "decision-surface", mode, analysis_run_id)
351}
352
353/// Serialize the review walkthrough guide envelope.
354///
355/// # Errors
356///
357/// Returns a serde error when the walkthrough guide cannot be converted to
358/// JSON.
359pub fn serialize_walkthrough_guide_json_output<T: Serialize>(
360    output: T,
361    mode: RootEnvelopeMode,
362    analysis_run_id: Option<&str>,
363) -> Result<Value, serde_json::Error> {
364    serialize_agent_contract_json_output(output, "review-walkthrough-guide", mode, analysis_run_id)
365}
366
367/// Serialize the review walkthrough validation envelope.
368///
369/// # Errors
370///
371/// Returns a serde error when the walkthrough validation cannot be converted
372/// to JSON.
373pub fn serialize_walkthrough_validation_json_output<T: Serialize>(
374    output: T,
375    mode: RootEnvelopeMode,
376    analysis_run_id: Option<&str>,
377) -> Result<Value, serde_json::Error> {
378    serialize_agent_contract_json_output(
379        output,
380        "review-walkthrough-validation",
381        mode,
382        analysis_run_id,
383    )
384}
385
386#[cfg(test)]
387mod tests {
388    use super::*;
389    use serde_json::json;
390
391    #[test]
392    fn review_brief_json_output_restamps_audit_header_contract() {
393        let brief = ReviewBriefOutput {
394            schema_version: ReviewBriefSchemaVersion::default(),
395            version: "1.2.3".to_string(),
396            command: "audit-brief".to_string(),
397            triage: DiffTriage {
398                files: 1,
399                hunks: None,
400                net_lines: None,
401                risk_class: RiskClass::Low,
402                review_effort: ReviewEffort::Glance,
403            },
404            graph_facts: GraphFacts {
405                exports_added: 0,
406                api_width_delta: 0,
407                reachable_from: Vec::new(),
408                boundaries_touched: Vec::new(),
409            },
410            partition: PartitionFacts::default(),
411            impact_closure: ImpactClosureFacts::default(),
412            focus: json!({"units": []}),
413            deltas: ReviewDeltas::default(),
414            weakening: Vec::<Value>::new(),
415            routing: json!({"units": []}),
416            decisions: json!({"decisions": []}),
417        };
418        let mut audit_header = Map::new();
419        audit_header.insert("schema_version".into(), json!(999));
420        audit_header.insert("command".into(), json!("audit"));
421        audit_header.insert("verdict".into(), json!("fail"));
422
423        let value = build_review_brief_json_output(
424            &brief,
425            audit_header,
426            ReviewBriefSubtractSections {
427                dead_code: Some(json!({"issues": []})),
428                duplication: None,
429                complexity: None,
430            },
431        )
432        .expect("brief output should serialize");
433
434        assert_eq!(value["schema_version"], REVIEW_BRIEF_SCHEMA_VERSION);
435        assert_eq!(value["command"], "audit-brief");
436        assert_eq!(value["verdict"], "fail");
437        assert_eq!(value["dead_code"]["issues"], json!([]));
438    }
439
440    #[test]
441    fn review_brief_serializer_owns_root_contract() {
442        let value = serialize_review_brief_json_output(
443            json!({"command": "audit-brief"}),
444            RootEnvelopeMode::Tagged,
445            Some("run-brief"),
446        )
447        .expect("brief output should serialize");
448
449        assert_eq!(value["kind"], "audit-brief");
450        assert_eq!(value["_meta"]["telemetry"]["analysis_run_id"], "run-brief");
451    }
452
453    #[test]
454    fn decision_surface_serializer_owns_root_contract() {
455        let value = serialize_decision_surface_json_output(
456            json!({"decisions": []}),
457            RootEnvelopeMode::Tagged,
458            Some("run-decision"),
459        )
460        .expect("decision surface should serialize");
461
462        assert_eq!(value["kind"], "decision-surface");
463        assert_eq!(
464            value["_meta"]["telemetry"]["analysis_run_id"],
465            "run-decision"
466        );
467    }
468}