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}