fallow_output/audit_brief.rs
1//! Audit brief output contracts.
2
3use crate::root_envelopes::{RootEnvelopeMode, attach_telemetry_meta, serialize_named_json_output};
4use fallow_types::envelope::{ElapsedMs, Meta, ToolVersion};
5use serde::Serialize;
6use serde_json::Value;
7
8/// Wire version for the `fallow audit --brief --format json` envelope.
9pub const REVIEW_BRIEF_SCHEMA_VERSION: u32 = 10;
10
11/// Maximum number of affected-but-not-in-diff paths sampled into
12/// [`ImpactClosureFacts::affected_not_shown`].
13///
14/// The full count is preserved in [`ImpactClosureFacts::affected_count`]
15/// (aggregate-before-truncate), so capping the sample never distorts the count,
16/// and the SHAPE of the reach is carried by
17/// [`ImpactClosureFacts::affected_by_dir`] rather than by which files landed in
18/// the sample. Nothing that ranks or gates reads this list: the decision surface
19/// takes its blast metric from the uncapped engine closure.
20pub const AFFECTED_SAMPLE_CAP: usize = 10;
21
22/// Maximum number of directories reported in
23/// [`ImpactClosureFacts::affected_by_dir`]. Directories beyond the cap are the
24/// lightest ones and are counted in
25/// [`ImpactClosureFacts::affected_by_dir_omitted`].
26pub const AFFECTED_DIR_CAP: usize = 25;
27
28/// Independently-versioned wire-version newtype for the brief envelope.
29/// Serializes as the integer `REVIEW_BRIEF_SCHEMA_VERSION`.
30#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
31#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
32#[cfg_attr(
33 feature = "schema",
34 schemars(extend("const" = REVIEW_BRIEF_SCHEMA_VERSION))
35)]
36pub struct ReviewBriefSchemaVersion(pub u32);
37
38impl Default for ReviewBriefSchemaVersion {
39 fn default() -> Self {
40 Self(REVIEW_BRIEF_SCHEMA_VERSION)
41 }
42}
43
44/// Coarse risk classification for a changeset, a pure function of the change
45/// size (file count plus, once threaded, net lines).
46#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
47#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
48#[serde(rename_all = "snake_case")]
49pub enum RiskClass {
50 /// Small, contained change.
51 Low,
52 /// Moderately sized change.
53 Medium,
54 /// Large change spanning many files or lines.
55 High,
56}
57
58/// Suggested reviewer effort, a pure function of [`RiskClass`].
59#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
60#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
61#[serde(rename_all = "snake_case")]
62pub enum ReviewEffort {
63 /// A quick scan is enough.
64 Glance,
65 /// A normal line-by-line review.
66 Review,
67 /// A careful, deep review is warranted.
68 DeepDive,
69}
70
71/// Stage 0 of the brief: triage facts derived purely from the diff size.
72///
73/// `hunks` and `net_lines` are populated when the caller supplies parsed diff
74/// evidence. They remain absent when no diff is available.
75#[derive(Debug, Clone, Serialize)]
76#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
77pub struct DiffTriage {
78 /// Number of changed files in the audit scope.
79 pub files: usize,
80 /// Number of diff hunks, or `None` when no diff evidence was supplied.
81 #[serde(default, skip_serializing_if = "Option::is_none")]
82 pub hunks: Option<usize>,
83 /// Net added-minus-removed lines, or `None` without diff evidence.
84 #[serde(default, skip_serializing_if = "Option::is_none")]
85 pub net_lines: Option<i64>,
86 /// Coarse risk class derived from the change size.
87 pub risk_class: RiskClass,
88 /// Suggested reviewer effort derived from `risk_class`.
89 pub review_effort: ReviewEffort,
90}
91
92/// Stage 1 of the brief: graph-derived orientation facts.
93///
94/// `boundaries_touched` is derived from the run's boundary-violation zones.
95/// `exports_added` and `api_width_delta` both report the exports-aware public
96/// API widening count. Removed exports are not represented in this
97/// widening-only signal. The set of modules the changed code reaches is Stage
98/// 3's `impact_closure`, which owns both its magnitude and its paths.
99#[derive(Debug, Clone, Serialize)]
100#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
101pub struct GraphFacts {
102 /// Number of public API exports added by the changeset. Zero means the
103 /// changeset adds no public API exports.
104 pub exports_added: usize,
105 /// Widening-only public API delta, currently equal to `exports_added`.
106 /// Removed exports are not represented, so zero means no public API exports
107 /// were added.
108 pub api_width_delta: i64,
109 /// Architecture boundary zones touched by the changeset, deduped and sorted.
110 /// Derived from the run's boundary-violation findings.
111 pub boundaries_touched: Vec<String>,
112}
113
114/// Stage 3 of the brief: the impact closure. The transitive
115/// affected-but-not-in-diff set plus the coordination gap. The differentiator a
116/// diff tool fundamentally cannot do, because it has no graph.
117///
118/// Honest scope (ADR-001, syntactic): the coordination gap is an attention
119/// pointer at the exact inter-module failure mode, NOT a correctness proof.
120#[derive(Debug, Clone, Default, Serialize)]
121#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
122pub struct ImpactClosureFacts {
123 /// The FULL number of files transitively affected by the changeset
124 /// (reverse-deps + re-export chains) that are NOT in the diff. Computed
125 /// BEFORE [`affected_not_shown`](Self::affected_not_shown) is capped to a
126 /// sample, so it is always the true magnitude of the blast radius.
127 pub affected_count: usize,
128 /// A capped, path-sorted sample of the affected root-relative paths (at most
129 /// [`AFFECTED_SAMPLE_CAP`]), deduped. The full count lives in
130 /// [`affected_count`](Self::affected_count) and the distribution in
131 /// [`affected_by_dir`](Self::affected_by_dir); use this list to jump to
132 /// representative files, NEVER to enumerate the blast radius or to infer its
133 /// shape. Because it is a prefix of the sorted set, it clusters in whichever
134 /// directory sorts first. To reconstruct the full set, run
135 /// `fallow check --impact-closure <path>` once per changed file and union the
136 /// results: that flag seeds from a single file, so no single command
137 /// reproduces this changeset-wide union.
138 pub affected_not_shown: Vec<String>,
139 /// The blast radius rolled up by parent directory: how the affected files
140 /// distribute, heaviest directory first, ties broken by directory path so the
141 /// order is deterministic. This is the SHAPE signal, and unlike
142 /// [`affected_not_shown`](Self::affected_not_shown) its counts are exact for
143 /// every directory it lists. At most [`AFFECTED_DIR_CAP`] entries.
144 pub affected_by_dir: Vec<AffectedDirectory>,
145 /// How many directories did not fit within [`AFFECTED_DIR_CAP`] and are
146 /// absent from [`affected_by_dir`](Self::affected_by_dir). They are the
147 /// lightest ones; their files are still counted in
148 /// [`affected_count`](Self::affected_count). Zero when nothing was omitted.
149 /// Add this to `affected_by_dir.len()` for the true number of directories
150 /// the change reaches.
151 pub affected_by_dir_omitted: usize,
152 /// Coordination gaps: a changed file exports a contract consumed by a module
153 /// absent from the diff. One entry per (changed file, consumer) pair. NOT a
154 /// subset of [`affected_not_shown`](Self::affected_not_shown): the gap
155 /// deliberately skips story and test consumers that the affected set counts.
156 pub coordination_gap: Vec<CoordinationGapFact>,
157}
158
159impl ImpactClosureFacts {
160 /// Build the facts from the full closure, capping the file sample and the
161 /// directory rollup while preserving the exact total.
162 ///
163 /// `affected` must arrive deduped and path-sorted (the engine closure
164 /// guarantees both); the sample is its prefix.
165 #[must_use]
166 pub fn new(affected: &[String], coordination_gap: Vec<CoordinationGapFact>) -> Self {
167 let (affected_by_dir, affected_by_dir_omitted) = roll_up_by_directory(affected);
168 Self {
169 affected_count: affected.len(),
170 affected_not_shown: affected.iter().take(AFFECTED_SAMPLE_CAP).cloned().collect(),
171 affected_by_dir,
172 affected_by_dir_omitted,
173 coordination_gap,
174 }
175 }
176}
177
178/// One directory of the blast radius and how many affected files it holds.
179#[derive(Debug, Clone, Serialize)]
180#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
181pub struct AffectedDirectory {
182 /// Root-relative parent directory, forward-slashed. The empty string is the
183 /// repository root.
184 pub dir: String,
185 /// How many affected-but-not-in-diff files live directly in `dir`. Exact,
186 /// never sampled.
187 pub count: usize,
188}
189
190/// Roll the affected paths up by parent directory, heaviest first, capped at
191/// [`AFFECTED_DIR_CAP`]. Returns the kept rows and how many directories were
192/// dropped.
193///
194/// Sorting by count descending keeps the heaviest directories, which is the
195/// question a reviewer is actually asking ("did this leak somewhere new, or is
196/// it all inside the module I already changed?"). The directory path breaks
197/// ties so the order is total and stable across runs.
198fn roll_up_by_directory(affected: &[String]) -> (Vec<AffectedDirectory>, usize) {
199 let mut counts: rustc_hash::FxHashMap<&str, usize> = rustc_hash::FxHashMap::default();
200 for path in affected {
201 let dir = path.rsplit_once('/').map_or("", |(head, _)| head);
202 *counts.entry(dir).or_default() += 1;
203 }
204 let mut rows: Vec<AffectedDirectory> = counts
205 .into_iter()
206 .map(|(dir, count)| AffectedDirectory {
207 dir: dir.to_string(),
208 count,
209 })
210 .collect();
211 rows.sort_by(|a, b| b.count.cmp(&a.count).then_with(|| a.dir.cmp(&b.dir)));
212 let omitted = rows.len().saturating_sub(AFFECTED_DIR_CAP);
213 rows.truncate(AFFECTED_DIR_CAP);
214 (rows, omitted)
215}
216
217/// One coordination-gap entry: a changed file exports symbols consumed by a
218/// `consumer_file` that is NOT in the diff. Deduped per (changed, consumer) pair
219/// (firing-precision rule R2).
220#[derive(Debug, Clone, Serialize)]
221#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
222pub struct CoordinationGapFact {
223 /// Root-relative path of the changed file whose contract is consumed elsewhere.
224 pub changed_file: String,
225 /// Root-relative path of the consumer module that is NOT in the diff.
226 pub consumer_file: String,
227 /// The exported symbol names the consumer references, sorted.
228 pub consumed_symbols: Vec<String>,
229 /// Honest scope note: this is a syntactic attention pointer, not a proof.
230 pub note: String,
231}
232
233/// Stage 2 of the brief: the partition + order. The changed files split into
234/// coherent BY-MODULE units (the only byte-identical-deterministic clustering
235/// definition straight from the graph), plus a dependency-sensible review ORDER
236/// over those units (definitions before consumers, mechanical/leaf units last,
237/// ties broken by the path sort). Stage 2 sits UNDER the decision surface as a
238/// drill-down; it is the backbone the directed-review loop hands the agent.
239///
240/// Feature-cluster and concern partitioning are deferred (they need scoring
241/// heuristics whose tie-breaks are a fresh nondeterminism surface).
242#[derive(Debug, Clone, Default, Serialize)]
243#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
244pub struct PartitionFacts {
245 /// The by-module units, sorted by module directory. Empty when no graph was
246 /// retained or no changed file maps to a known module.
247 pub units: Vec<ReviewUnitFact>,
248 /// The dependency-sensible review order: module-directory strings,
249 /// definitions before consumers, mechanical/leaf units last. A permutation of
250 /// the `units` module directories.
251 pub order: Vec<String>,
252 /// Connected components of the inter-unit dependency graph: groups of
253 /// module directories that share no import edge with any unit outside the
254 /// group. Present only when there are two or more; a single slice is just
255 /// `order`. A slice proves the absence of import edges to the rest of the
256 /// change, nothing more: whether it can land on its own is still a
257 /// judgment (generated files and lockstep contracts share no edge and
258 /// still belong together). An orientation fact, never a demand to split.
259 #[serde(default, skip_serializing_if = "fewer_than_two_slices")]
260 pub independent_slices: Vec<Vec<String>>,
261}
262
263fn fewer_than_two_slices(slices: &[Vec<String>]) -> bool {
264 slices.len() < 2
265}
266
267/// One review unit: a coherent by-module cluster of the changed set.
268#[derive(Debug, Clone, Serialize)]
269#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
270pub struct ReviewUnitFact {
271 /// The module directory the unit covers (root-relative, forward-slashed).
272 /// The empty string is the repository-root group.
273 pub module_dir: String,
274 /// The changed files in this unit, path-sorted.
275 pub files: Vec<String>,
276}
277
278/// Diff-aware deterministic deltas (6.A), framed new-vs-pre-existing against
279/// the audit base snapshot. Each entry is a brief summary/verdict line.
280///
281/// `public_api` is batch-consolidated to ONE decision per change (rule R1):
282/// the `added` list carries the introduced public-export keys as evidence, but a
283/// reviewer reads "the public surface widened by N", never one decision per
284/// symbol.
285#[derive(Debug, Clone, Default, Serialize)]
286#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
287pub struct ReviewDeltas {
288 /// Cross-zone boundary EDGES introduced vs base (R2 first-edge-only: one per
289 /// `<from_zone>-><to_zone>` pair, never per import). New-vs-pre-existing.
290 pub boundary_introduced: Vec<String>,
291 /// Circular dependencies introduced vs base (canonical file-set keys).
292 pub cycle_introduced: Vec<String>,
293 /// Exports-aware public-API surface delta: the public-export keys
294 /// (`<rel_path>::<name>`) added vs base, resolved through `package.json`
295 /// `exports` + re-export reachability. A symbol re-exported only through an
296 /// internal barrel NOT in `exports` is absent here (zero delta); one
297 /// reachable through an `exports` path is present (exactly one).
298 pub public_api_added: Vec<String>,
299 /// Third-party dependencies a changed `package.json` declares that the base
300 /// manifest did not, as `<manifest>::<name>` keys. Every dependency section
301 /// participates. Always present, empty when no manifest changed.
302 pub dependency_added: Vec<String>,
303 /// Declared dependencies whose range moved across a major version (or a
304 /// `0.x` minor) vs base, as `<manifest>::<name>@<from>-><to>` keys. Minor
305 /// and patch moves are not candidates; a non-numeric range is skipped.
306 /// Always present, empty when nothing crossed a major version.
307 pub dependency_major_bumped: Vec<String>,
308}
309
310/// The full `fallow audit --brief --format json` envelope. Carries the
311/// informational verdict, the triage and graph-facts orientation stages, plus
312/// the reused "subtract" section (the same dead-code / duplication / complexity
313/// payload `fallow audit --format json` emits).
314#[derive(Debug, Clone, Serialize)]
315#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
316#[cfg_attr(
317 feature = "schema",
318 schemars(title = "fallow audit --brief --format json")
319)]
320pub struct ReviewBriefOutput<Focus, Weakening, Routing, Decisions> {
321 /// Independently-versioned brief schema version.
322 pub schema_version: ReviewBriefSchemaVersion,
323 /// Fallow CLI version that produced this output.
324 pub version: String,
325 /// Command discriminator singleton: always `"audit-brief"`.
326 pub command: String,
327 /// Stage 0: change-size triage (file/hunk/line counts, risk class, effort).
328 pub triage: DiffTriage,
329 /// Stage 1: graph orientation facts.
330 pub graph_facts: GraphFacts,
331 /// Stage 2: the partition + order (by-module units + dependency-sensible
332 /// review order). The backbone the directed-review loop hands the agent.
333 pub partition: PartitionFacts,
334 /// Stage 3: the impact closure (affected-not-shown + coordination gap).
335 pub impact_closure: ImpactClosureFacts,
336 /// Stage 4: the weighted focus map. A composite attention score per
337 /// changed-file unit (fan-in/out + security taint + risk zone + change shape),
338 /// with `review-here` / `not-prioritized` labels (NEVER `skip` in free mode),
339 /// a per-unit confidence flag, and the FULL `deprioritized` escape-hatch list
340 /// so every de-prioritized piece is reachable. Stage 4 sits UNDER the decision
341 /// surface as drill-down.
342 pub focus: Focus,
343 /// 6.A: diff-aware deterministic deltas (boundary/cycle introduced +
344 /// exports-aware public-API surface delta), new-vs-pre-existing.
345 pub deltas: ReviewDeltas,
346 /// 6.F, headline: reviewer-private weakening signals (tests
347 /// removed/skipped, thresholds lowered, suppressions added, security steps
348 /// removed). Advisory, never gates, never auto-posted.
349 pub weakening: Vec<Weakening>,
350 /// 6.D: ownership-aware reviewer routing (per-file expert + bus-factor).
351 pub routing: Routing,
352 /// 6.G, the APEX: the decision surface. The ranked, capped,
353 /// signal_id-anchored set of consequential structural decisions, each framed
354 /// as a judgment question with its routed expert. This is the only thing the
355 /// brief visibly leads with; the stages above are its drill-down derivation.
356 pub decisions: Decisions,
357 /// Branching conservation across the changeset: total branching against
358 /// the number of functions now holding it. Absent when no base comparison
359 /// ran, which keeps the wire shape byte-identical for a consumer that
360 /// never had a base snapshot.
361 #[serde(default, skip_serializing_if = "Option::is_none")]
362 pub branching: Option<crate::BranchingReport>,
363}
364
365/// The standard audit brief payload shape used by the CLI, schema emitter,
366/// API, and agent-facing review surfaces.
367pub type StandardReviewBriefOutput = ReviewBriefOutput<
368 crate::audit_focus::FocusMap,
369 crate::audit_weakening::WeakeningSignal,
370 crate::audit_routing::RoutingFacts,
371 crate::audit_decision_surface::DecisionSurface,
372>;
373
374/// Informational audit metadata carried by the review brief wire envelope.
375#[derive(Debug, Clone, Serialize)]
376#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
377pub struct ReviewBriefHeader<Verdict, Summary, Attribution> {
378 /// Fallow CLI version that produced this output.
379 pub version: ToolVersion,
380 /// Audit verdict, informational only on the brief path.
381 pub verdict: Verdict,
382 /// Number of changed files in the audit scope.
383 pub changed_files_count: u32,
384 /// Base ref used to determine the changeset.
385 pub base_ref: String,
386 /// Human-readable description of the resolved base, when available.
387 #[serde(default, skip_serializing_if = "Option::is_none")]
388 pub base_description: Option<String>,
389 /// Head commit SHA, when the audit ran against a committed head.
390 #[serde(default, skip_serializing_if = "Option::is_none")]
391 pub head_sha: Option<String>,
392 /// Analysis duration in milliseconds.
393 pub elapsed_ms: ElapsedMs,
394 /// Whether base-snapshot analysis was skipped for this run.
395 #[serde(default, skip_serializing_if = "Option::is_none")]
396 pub base_snapshot_skipped: Option<bool>,
397 /// Per-category audit summary.
398 pub summary: Summary,
399 /// Introduced-versus-inherited issue attribution.
400 pub attribution: Attribution,
401}
402
403/// Complete `fallow audit --brief --format json` wire envelope.
404///
405/// This is distinct from [`ReviewBriefOutput`], which is the reusable review
406/// digest embedded in walkthrough output. The wire envelope also carries audit
407/// metadata, optional telemetry, and the subtract-style analysis subreports.
408#[derive(Debug, Clone, Serialize)]
409#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
410#[cfg_attr(
411 feature = "schema",
412 schemars(title = "fallow audit --brief --format json")
413)]
414pub struct ReviewBriefWireOutput<
415 Focus,
416 Weakening,
417 Routing,
418 Decisions,
419 Verdict,
420 Summary,
421 Attribution,
422 DeadCode,
423 Duplication,
424 Complexity,
425> {
426 /// Independently-versioned brief schema version.
427 pub schema_version: ReviewBriefSchemaVersion,
428 /// Fallow CLI version that produced this output.
429 pub version: ToolVersion,
430 /// Command discriminator singleton: always `"audit-brief"`.
431 pub command: String,
432 /// Audit verdict, informational only on the brief path.
433 pub verdict: Verdict,
434 /// Number of changed files in the audit scope.
435 pub changed_files_count: u32,
436 /// Base ref used to determine the changeset.
437 pub base_ref: String,
438 /// Human-readable description of the resolved base, when available.
439 #[serde(default, skip_serializing_if = "Option::is_none")]
440 pub base_description: Option<String>,
441 /// Head commit SHA, when available.
442 #[serde(default, skip_serializing_if = "Option::is_none")]
443 pub head_sha: Option<String>,
444 /// Analysis duration in milliseconds.
445 pub elapsed_ms: ElapsedMs,
446 /// Whether base-snapshot analysis was skipped for this run.
447 #[serde(default, skip_serializing_if = "Option::is_none")]
448 pub base_snapshot_skipped: Option<bool>,
449 /// Per-category audit summary.
450 pub summary: Summary,
451 /// Introduced-versus-inherited issue attribution.
452 pub attribution: Attribution,
453 /// Optional metric definitions and local telemetry correlation metadata.
454 #[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")]
455 pub meta: Option<Meta>,
456 /// Ranked, capped review decisions.
457 pub decisions: Decisions,
458 /// Diff-size triage facts.
459 pub triage: DiffTriage,
460 /// Graph-derived orientation facts.
461 pub graph_facts: GraphFacts,
462 /// Changed-file partition and review order.
463 pub partition: PartitionFacts,
464 /// Transitive impact closure outside the diff.
465 pub impact_closure: ImpactClosureFacts,
466 /// Weighted focus map for changed-file units.
467 pub focus: Focus,
468 /// Deterministic introduced deltas against the base snapshot.
469 pub deltas: ReviewDeltas,
470 /// Reviewer-private weakening signals.
471 pub weakening: Vec<Weakening>,
472 /// Ownership-aware reviewer routing.
473 pub routing: Routing,
474 /// Dead-code findings scoped to the audit changeset.
475 #[serde(default, skip_serializing_if = "Option::is_none")]
476 pub dead_code: Option<DeadCode>,
477 /// Duplication findings scoped to the audit changeset.
478 #[serde(default, skip_serializing_if = "Option::is_none")]
479 pub duplication: Option<Duplication>,
480 /// Complexity findings scoped to the audit changeset.
481 #[serde(default, skip_serializing_if = "Option::is_none")]
482 pub complexity: Option<Complexity>,
483 /// Branching conservation across the changeset. Absent when no base
484 /// comparison ran.
485 #[serde(default, skip_serializing_if = "Option::is_none")]
486 pub branching: Option<crate::BranchingReport>,
487}
488
489/// CLI-built audit subreports that are embedded in the audit brief envelope.
490///
491/// The brief envelope and field ordering belong to `fallow-output`; the
492/// underlying subreport payloads are still supplied by the CLI until their
493/// builders are fully command-neutral.
494#[derive(Debug, Clone, Default)]
495pub struct ReviewBriefSubtractSections<DeadCode = Value, Duplication = Value, Complexity = Value> {
496 /// Dead-code subreport, when the CLI produced one for this changeset.
497 pub dead_code: Option<DeadCode>,
498 /// Duplication subreport, when the CLI produced one for this changeset.
499 pub duplication: Option<Duplication>,
500 /// Complexity subreport, when the CLI produced one for this changeset.
501 pub complexity: Option<Complexity>,
502}
503
504/// Build the complete `fallow audit --brief --format json` value.
505///
506/// `header` carries informational audit scope fields such as verdict, base ref,
507/// summary, and attribution. The independent brief schema and command always
508/// come from the typed brief payload.
509pub fn build_review_brief_json_output<
510 Focus,
511 Weakening,
512 Routing,
513 Decisions,
514 Verdict,
515 Summary,
516 Attribution,
517 DeadCode,
518 Duplication,
519 Complexity,
520>(
521 brief: ReviewBriefOutput<Focus, Weakening, Routing, Decisions>,
522 header: ReviewBriefHeader<Verdict, Summary, Attribution>,
523 subtract: ReviewBriefSubtractSections<DeadCode, Duplication, Complexity>,
524) -> Result<Value, serde_json::Error>
525where
526 Focus: Serialize,
527 Weakening: Serialize,
528 Routing: Serialize,
529 Decisions: Serialize,
530 Verdict: Serialize,
531 Summary: Serialize,
532 Attribution: Serialize,
533 DeadCode: Serialize,
534 Duplication: Serialize,
535 Complexity: Serialize,
536{
537 serde_json::to_value(ReviewBriefWireOutput {
538 schema_version: brief.schema_version,
539 version: header.version,
540 command: brief.command,
541 verdict: header.verdict,
542 changed_files_count: header.changed_files_count,
543 base_ref: header.base_ref,
544 base_description: header.base_description,
545 head_sha: header.head_sha,
546 elapsed_ms: header.elapsed_ms,
547 base_snapshot_skipped: header.base_snapshot_skipped,
548 summary: header.summary,
549 attribution: header.attribution,
550 meta: None,
551 decisions: brief.decisions,
552 triage: brief.triage,
553 graph_facts: brief.graph_facts,
554 partition: brief.partition,
555 impact_closure: brief.impact_closure,
556 focus: brief.focus,
557 deltas: brief.deltas,
558 weakening: brief.weakening,
559 routing: brief.routing,
560 dead_code: subtract.dead_code,
561 duplication: subtract.duplication,
562 complexity: subtract.complexity,
563 branching: brief.branching,
564 })
565}
566
567fn serialize_agent_contract_json_output<T: Serialize>(
568 output: T,
569 kind: &'static str,
570 mode: RootEnvelopeMode,
571 analysis_run_id: Option<&str>,
572) -> Result<Value, serde_json::Error> {
573 let mut value = serialize_named_json_output(output, kind, mode)?;
574 attach_telemetry_meta(&mut value, analysis_run_id);
575 Ok(value)
576}
577
578/// Serialize the `fallow audit --brief --format json` envelope.
579///
580/// # Errors
581///
582/// Returns a serde error when the brief output cannot be converted to JSON.
583pub fn serialize_review_brief_json_output<T: Serialize>(
584 output: T,
585 mode: RootEnvelopeMode,
586 analysis_run_id: Option<&str>,
587) -> Result<Value, serde_json::Error> {
588 serialize_agent_contract_json_output(output, "audit-brief", mode, analysis_run_id)
589}
590
591/// Serialize the standalone decision-surface envelope.
592///
593/// # Errors
594///
595/// Returns a serde error when the decision-surface output cannot be converted
596/// to JSON.
597pub fn serialize_decision_surface_json_output<T: Serialize>(
598 output: T,
599 mode: RootEnvelopeMode,
600 analysis_run_id: Option<&str>,
601) -> Result<Value, serde_json::Error> {
602 serialize_agent_contract_json_output(output, "decision-surface", mode, analysis_run_id)
603}
604
605/// Serialize the review walkthrough guide envelope.
606///
607/// # Errors
608///
609/// Returns a serde error when the walkthrough guide cannot be converted to
610/// JSON.
611pub fn serialize_walkthrough_guide_json_output<T: Serialize>(
612 output: T,
613 mode: RootEnvelopeMode,
614 analysis_run_id: Option<&str>,
615) -> Result<Value, serde_json::Error> {
616 serialize_agent_contract_json_output(output, "review-walkthrough-guide", mode, analysis_run_id)
617}
618
619/// Serialize the review walkthrough validation envelope.
620///
621/// # Errors
622///
623/// Returns a serde error when the walkthrough validation cannot be converted
624/// to JSON.
625pub fn serialize_walkthrough_validation_json_output<T: Serialize>(
626 output: T,
627 mode: RootEnvelopeMode,
628 analysis_run_id: Option<&str>,
629) -> Result<Value, serde_json::Error> {
630 serialize_agent_contract_json_output(
631 output,
632 "review-walkthrough-validation",
633 mode,
634 analysis_run_id,
635 )
636}
637
638#[cfg(test)]
639mod tests {
640 use super::*;
641 use serde_json::json;
642
643 /// Serialize a minimal brief through the real wire mapping.
644 fn brief_wire_value(branching: Option<crate::BranchingReport>) -> Value {
645 let brief = ReviewBriefOutput {
646 branching,
647 schema_version: ReviewBriefSchemaVersion::default(),
648 version: "1.2.3".to_string(),
649 command: "audit-brief".to_string(),
650 triage: DiffTriage {
651 files: 1,
652 hunks: None,
653 net_lines: None,
654 risk_class: RiskClass::Low,
655 review_effort: ReviewEffort::Glance,
656 },
657 graph_facts: GraphFacts {
658 exports_added: 0,
659 api_width_delta: 0,
660 boundaries_touched: Vec::new(),
661 },
662 partition: PartitionFacts::default(),
663 impact_closure: ImpactClosureFacts::default(),
664 focus: json!({"units": []}),
665 deltas: ReviewDeltas::default(),
666 weakening: Vec::<Value>::new(),
667 routing: json!({"units": []}),
668 decisions: json!({"decisions": []}),
669 };
670 let header = ReviewBriefHeader {
671 version: ToolVersion("1.2.3".to_string()),
672 verdict: json!("fail"),
673 changed_files_count: 1,
674 base_ref: "main".to_string(),
675 base_description: Some("merge base".to_string()),
676 head_sha: Some("abc123".to_string()),
677 elapsed_ms: ElapsedMs(12),
678 base_snapshot_skipped: Some(false),
679 summary: json!({"dead_code_issues": 0}),
680 attribution: json!({"gate": "new_only"}),
681 };
682
683 build_review_brief_json_output(
684 brief,
685 header,
686 ReviewBriefSubtractSections::<Value, Value, Value> {
687 dead_code: Some(json!({"issues": []})),
688 duplication: None,
689 complexity: None,
690 },
691 )
692 .expect("brief output should serialize")
693 }
694
695 #[test]
696 fn review_brief_json_output_assembles_typed_wire_contract() {
697 let value = brief_wire_value(None);
698
699 assert_eq!(value["schema_version"], REVIEW_BRIEF_SCHEMA_VERSION);
700 assert_eq!(value["command"], "audit-brief");
701 assert_eq!(value["verdict"], "fail");
702 assert_eq!(value["base_ref"], "main");
703 assert_eq!(value["summary"]["dead_code_issues"], 0);
704 assert_eq!(value["attribution"]["gate"], "new_only");
705 assert_eq!(value["dead_code"]["issues"], json!([]));
706 assert!(
707 value.get("branching").is_none(),
708 "absent when no base comparison ran, so the wire shape is unchanged for a consumer that never had a base snapshot"
709 );
710 }
711
712 #[test]
713 fn review_brief_json_output_carries_the_branching_block() {
714 // Widening `ReviewBriefOutput` alone would land the block in the
715 // walkthrough digest and leave it out of the brief JSON, because the
716 // wire struct is mapped field by field.
717 let base: crate::BranchingSnapshot = std::iter::once((
718 "src/a.ts".to_string(),
719 fallow_types::extract::FileBranching {
720 branch_points: 12,
721 functions: 1,
722 peak_cyclomatic: 13,
723 cognitive: 12,
724 cognitive_nesting_weight: 6,
725 has_module_unit: false,
726 has_synthetic_units: false,
727 },
728 ))
729 .collect();
730 let head: crate::BranchingSnapshot = std::iter::once((
731 "src/a.ts".to_string(),
732 fallow_types::extract::FileBranching {
733 branch_points: 12,
734 functions: 5,
735 peak_cyclomatic: 4,
736 cognitive: 6,
737 cognitive_nesting_weight: 0,
738 has_module_unit: false,
739 has_synthetic_units: false,
740 },
741 ))
742 .collect();
743 let report = crate::BranchingReport::compare(
744 &base,
745 &head,
746 crate::DEFAULT_BRANCHING_TOLERANCE,
747 &|_| false,
748 );
749
750 let value = brief_wire_value(Some(report));
751
752 assert_eq!(
753 value["branching"]["split_in_place"][0]["path"], "src/a.ts",
754 "the local claim reaches the wire, not only the digest"
755 );
756 assert_eq!(
757 value["branching"]["split_in_place"][0]["functions_after"],
758 5
759 );
760 assert!(
761 !value["branching"]
762 .as_object()
763 .expect("branching is an object")
764 .contains_key("verdict"),
765 "there is no changeset-level verdict to publish"
766 );
767 assert_eq!(value["branching"]["branch_points"]["delta"], 0);
768 assert_eq!(value["branching"]["functions"]["delta"], 4);
769 assert_eq!(value["branching"]["peak_unit_cyclomatic"]["delta"], -9);
770 assert_eq!(
771 value["branching"]["cognitive"]["attributed_to"],
772 "nesting-reset"
773 );
774 assert_eq!(value["branching"]["tolerance"], 2);
775 assert_eq!(value["branching"]["by_file"][0]["path"], "src/a.ts");
776 }
777
778 #[test]
779 fn review_brief_serializer_owns_root_contract() {
780 let value = serialize_review_brief_json_output(
781 json!({"command": "audit-brief"}),
782 RootEnvelopeMode::Tagged,
783 Some("run-brief"),
784 )
785 .expect("brief output should serialize");
786
787 assert_eq!(value["kind"], "audit-brief");
788 assert_eq!(value["_meta"]["telemetry"]["analysis_run_id"], "run-brief");
789 }
790
791 #[test]
792 fn decision_surface_serializer_owns_root_contract() {
793 let value = serialize_decision_surface_json_output(
794 json!({"decisions": []}),
795 RootEnvelopeMode::Tagged,
796 Some("run-decision"),
797 )
798 .expect("decision surface should serialize");
799
800 assert_eq!(value["kind"], "decision-surface");
801 assert_eq!(
802 value["_meta"]["telemetry"]["analysis_run_id"],
803 "run-decision"
804 );
805 }
806
807 /// `<dirs>` directories holding `<per_dir>` files each, path-sorted the way
808 /// the engine closure hands them over.
809 fn affected(dirs: usize, per_dir: usize) -> Vec<String> {
810 let mut paths: Vec<String> = (0..dirs)
811 .flat_map(|d| (0..per_dir).map(move |f| format!("src/zone{d:03}/file{f:03}.ts")))
812 .collect();
813 paths.sort();
814 paths
815 }
816
817 #[test]
818 fn a_closure_within_the_caps_is_reported_whole() {
819 let paths = affected(2, 3);
820 let facts = ImpactClosureFacts::new(&paths, Vec::new());
821
822 assert_eq!(facts.affected_count, 6);
823 assert_eq!(facts.affected_not_shown, paths);
824 assert_eq!(facts.affected_by_dir_omitted, 0);
825 assert_eq!(
826 facts
827 .affected_by_dir
828 .iter()
829 .map(|row| (row.dir.as_str(), row.count))
830 .collect::<Vec<_>>(),
831 vec![("src/zone000", 3), ("src/zone001", 3)]
832 );
833 }
834
835 #[test]
836 fn the_count_survives_capping_the_sample() {
837 let paths = affected(4, 40);
838 let facts = ImpactClosureFacts::new(&paths, Vec::new());
839
840 assert_eq!(
841 facts.affected_count, 160,
842 "the magnitude is computed before the sample is capped"
843 );
844 assert_eq!(facts.affected_not_shown.len(), AFFECTED_SAMPLE_CAP);
845 assert_eq!(
846 facts
847 .affected_by_dir
848 .iter()
849 .map(|row| row.count)
850 .sum::<usize>(),
851 facts.affected_count,
852 "an uncapped rollup accounts for every affected file"
853 );
854 }
855
856 #[test]
857 fn the_rollup_carries_weight_the_sample_cannot() {
858 // A prefix sample lands entirely in the directory that sorts first, so
859 // the rollup is the only thing that can say where the reach actually is.
860 let mut paths = affected(1, 12);
861 paths.extend((0..90).map(|f| format!("src/zzz_heavy/file{f:03}.ts")));
862 paths.sort();
863 let facts = ImpactClosureFacts::new(&paths, Vec::new());
864
865 assert!(
866 facts
867 .affected_not_shown
868 .iter()
869 .all(|path| path.starts_with("src/zone000/")),
870 "the fixture must produce a one-directory sample: {:?}",
871 facts.affected_not_shown
872 );
873 let heaviest = facts.affected_by_dir.first().expect("a rollup row");
874 assert_eq!(
875 (heaviest.dir.as_str(), heaviest.count),
876 ("src/zzz_heavy", 90)
877 );
878 }
879
880 #[test]
881 fn rollup_rows_beyond_the_cap_are_counted_not_dropped_silently() {
882 let paths = affected(AFFECTED_DIR_CAP + 7, 1);
883 let facts = ImpactClosureFacts::new(&paths, Vec::new());
884
885 assert_eq!(facts.affected_by_dir.len(), AFFECTED_DIR_CAP);
886 assert_eq!(facts.affected_by_dir_omitted, 7);
887 assert_eq!(facts.affected_count, AFFECTED_DIR_CAP + 7);
888 }
889
890 #[test]
891 fn equal_weight_directories_are_ordered_by_path() {
892 let facts = ImpactClosureFacts::new(&affected(3, 2), Vec::new());
893 let dirs: Vec<&str> = facts
894 .affected_by_dir
895 .iter()
896 .map(|row| row.dir.as_str())
897 .collect();
898 assert_eq!(
899 dirs,
900 vec!["src/zone000", "src/zone001", "src/zone002"],
901 "the path is the tie-break, so the order is total across runs"
902 );
903 }
904
905 #[test]
906 fn the_coordination_gap_is_never_capped() {
907 // The human brief routes a reader to `--format json` for the gaps and
908 // their symbols. That promise holds only while this constructor stores
909 // both whole, alongside two fields it does deliberately cap.
910 let symbols: Vec<String> = (0..40).map(|i| format!("symbol{i:02}")).collect();
911 let gaps: Vec<CoordinationGapFact> = (0..60)
912 .map(|i| CoordinationGapFact {
913 changed_file: "src/core.ts".to_string(),
914 consumer_file: format!("src/consumer{i:02}.ts"),
915 consumed_symbols: symbols.clone(),
916 note: String::new(),
917 })
918 .collect();
919 let facts = ImpactClosureFacts::new(&affected(40, 3), gaps);
920
921 assert_eq!(facts.coordination_gap.len(), 60);
922 assert!(
923 facts
924 .coordination_gap
925 .iter()
926 .all(|gap| gap.consumed_symbols.len() == 40),
927 "every consumed symbol survives, or the brief's json route is a lie"
928 );
929 assert!(
930 facts.affected_not_shown.len() < facts.affected_count
931 && facts.affected_by_dir_omitted > 0,
932 "the fixture must show the sibling fields really are capped"
933 );
934 }
935
936 #[test]
937 fn root_level_files_roll_up_under_the_empty_directory() {
938 let facts =
939 ImpactClosureFacts::new(&["play.ts".to_string(), "setup.ts".to_string()], Vec::new());
940 assert_eq!(facts.affected_by_dir.len(), 1);
941 assert_eq!(facts.affected_by_dir[0].dir, "");
942 assert_eq!(facts.affected_by_dir[0].count, 2);
943 }
944}