fallow_output/audit_brief.rs
1//! Audit brief output contracts.
2
3use crate::root_envelopes::{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 = 12;
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 /// How far the change reaches across CODEOWNERS owner groups, computed
353 /// from the CODEOWNERS file alone. Absent when no CODEOWNERS file is
354 /// found or the file cannot be read.
355 #[serde(default, skip_serializing_if = "Option::is_none")]
356 pub ownership: Option<crate::OwnershipFacts>,
357 /// 6.G, the APEX: the decision surface. The ranked, capped,
358 /// signal_id-anchored set of consequential structural decisions, each framed
359 /// as a judgment question with its routed expert. This is the only thing the
360 /// brief visibly leads with; the stages above are its drill-down derivation.
361 pub decisions: Decisions,
362 /// Branching conservation across the changeset: total branching against
363 /// the number of functions now holding it. Absent when no base comparison
364 /// ran, which keeps the wire shape byte-identical for a consumer that
365 /// never had a base snapshot.
366 #[serde(default, skip_serializing_if = "Option::is_none")]
367 pub branching: Option<crate::BranchingReport>,
368}
369
370/// The standard audit brief payload shape used by the CLI, schema emitter,
371/// API, and agent-facing review surfaces.
372pub type StandardReviewBriefOutput = ReviewBriefOutput<
373 crate::audit_focus::FocusMap,
374 crate::audit_weakening::WeakeningSignal,
375 crate::audit_routing::RoutingFacts,
376 crate::audit_decision_surface::DecisionSurface,
377>;
378
379/// Informational audit metadata carried by the review brief wire envelope.
380#[derive(Debug, Clone, Serialize)]
381#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
382pub struct ReviewBriefHeader<Verdict, Summary, Attribution> {
383 /// Fallow CLI version that produced this output.
384 pub version: ToolVersion,
385 /// Audit verdict, informational only on the brief path.
386 pub verdict: Verdict,
387 /// Number of changed files in the audit scope.
388 pub changed_files_count: u32,
389 /// Base ref used to determine the changeset.
390 pub base_ref: String,
391 /// Human-readable description of the resolved base, when available.
392 #[serde(default, skip_serializing_if = "Option::is_none")]
393 pub base_description: Option<String>,
394 /// Head commit SHA, when the audit ran against a committed head.
395 #[serde(default, skip_serializing_if = "Option::is_none")]
396 pub head_sha: Option<String>,
397 /// Analysis duration in milliseconds.
398 pub elapsed_ms: ElapsedMs,
399 /// Whether base-snapshot analysis was skipped for this run.
400 #[serde(default, skip_serializing_if = "Option::is_none")]
401 pub base_snapshot_skipped: Option<bool>,
402 /// Per-category audit summary.
403 pub summary: Summary,
404 /// Introduced-versus-inherited issue attribution.
405 pub attribution: Attribution,
406}
407
408/// Complete `fallow audit --brief --format json` wire envelope.
409///
410/// This is distinct from [`ReviewBriefOutput`], which is the reusable review
411/// digest embedded in walkthrough output. The wire envelope also carries audit
412/// metadata, optional telemetry, and the subtract-style analysis subreports.
413#[derive(Debug, Clone, Serialize)]
414#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
415#[cfg_attr(
416 feature = "schema",
417 schemars(title = "fallow audit --brief --format json")
418)]
419pub struct ReviewBriefWireOutput<
420 Focus,
421 Weakening,
422 Routing,
423 Decisions,
424 Verdict,
425 Summary,
426 Attribution,
427 DeadCode,
428 Duplication,
429 Complexity,
430> {
431 /// Independently-versioned brief schema version.
432 pub schema_version: ReviewBriefSchemaVersion,
433 /// Fallow CLI version that produced this output.
434 pub version: ToolVersion,
435 /// Command discriminator singleton: always `"audit-brief"`.
436 pub command: String,
437 /// Audit verdict, informational only on the brief path.
438 pub verdict: Verdict,
439 /// Number of changed files in the audit scope.
440 pub changed_files_count: u32,
441 /// Base ref used to determine the changeset.
442 pub base_ref: String,
443 /// Human-readable description of the resolved base, when available.
444 #[serde(default, skip_serializing_if = "Option::is_none")]
445 pub base_description: Option<String>,
446 /// Head commit SHA, when available.
447 #[serde(default, skip_serializing_if = "Option::is_none")]
448 pub head_sha: Option<String>,
449 /// Analysis duration in milliseconds.
450 pub elapsed_ms: ElapsedMs,
451 /// Whether base-snapshot analysis was skipped for this run.
452 #[serde(default, skip_serializing_if = "Option::is_none")]
453 pub base_snapshot_skipped: Option<bool>,
454 /// Per-category audit summary.
455 pub summary: Summary,
456 /// Introduced-versus-inherited issue attribution.
457 pub attribution: Attribution,
458 /// Optional metric definitions and local telemetry correlation metadata.
459 #[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")]
460 pub meta: Option<Meta>,
461 /// Ranked, capped review decisions.
462 pub decisions: Decisions,
463 /// Diff-size triage facts.
464 pub triage: DiffTriage,
465 /// Graph-derived orientation facts.
466 pub graph_facts: GraphFacts,
467 /// Changed-file partition and review order.
468 pub partition: PartitionFacts,
469 /// Transitive impact closure outside the diff.
470 pub impact_closure: ImpactClosureFacts,
471 /// Weighted focus map for changed-file units.
472 pub focus: Focus,
473 /// Deterministic introduced deltas against the base snapshot.
474 pub deltas: ReviewDeltas,
475 /// Reviewer-private weakening signals.
476 pub weakening: Vec<Weakening>,
477 /// Ownership-aware reviewer routing.
478 pub routing: Routing,
479 /// Owner-group reach from the CODEOWNERS file. Absent when no CODEOWNERS
480 /// file is found or the file cannot be read.
481 #[serde(default, skip_serializing_if = "Option::is_none")]
482 pub ownership: Option<crate::OwnershipFacts>,
483 /// Dead-code findings scoped to the audit changeset.
484 #[serde(default, skip_serializing_if = "Option::is_none")]
485 pub dead_code: Option<DeadCode>,
486 /// Duplication findings scoped to the audit changeset.
487 #[serde(default, skip_serializing_if = "Option::is_none")]
488 pub duplication: Option<Duplication>,
489 /// Complexity findings scoped to the audit changeset.
490 #[serde(default, skip_serializing_if = "Option::is_none")]
491 pub complexity: Option<Complexity>,
492 /// Branching conservation across the changeset. Absent when no base
493 /// comparison ran.
494 #[serde(default, skip_serializing_if = "Option::is_none")]
495 pub branching: Option<crate::BranchingReport>,
496}
497
498/// CLI-built audit subreports that are embedded in the audit brief envelope.
499///
500/// The brief envelope and field ordering belong to `fallow-output`; the
501/// underlying subreport payloads are still supplied by the CLI until their
502/// builders are fully command-neutral.
503#[derive(Debug, Clone, Default)]
504pub struct ReviewBriefSubtractSections<DeadCode = Value, Duplication = Value, Complexity = Value> {
505 /// Dead-code subreport, when the CLI produced one for this changeset.
506 pub dead_code: Option<DeadCode>,
507 /// Duplication subreport, when the CLI produced one for this changeset.
508 pub duplication: Option<Duplication>,
509 /// Complexity subreport, when the CLI produced one for this changeset.
510 pub complexity: Option<Complexity>,
511}
512
513/// Build the complete `fallow audit --brief --format json` value.
514///
515/// `header` carries informational audit scope fields such as verdict, base ref,
516/// summary, and attribution. The independent brief schema and command always
517/// come from the typed brief payload.
518pub fn build_review_brief_json_output<
519 Focus,
520 Weakening,
521 Routing,
522 Decisions,
523 Verdict,
524 Summary,
525 Attribution,
526 DeadCode,
527 Duplication,
528 Complexity,
529>(
530 brief: ReviewBriefOutput<Focus, Weakening, Routing, Decisions>,
531 header: ReviewBriefHeader<Verdict, Summary, Attribution>,
532 subtract: ReviewBriefSubtractSections<DeadCode, Duplication, Complexity>,
533) -> Result<Value, serde_json::Error>
534where
535 Focus: Serialize,
536 Weakening: Serialize,
537 Routing: Serialize,
538 Decisions: Serialize,
539 Verdict: Serialize,
540 Summary: Serialize,
541 Attribution: Serialize,
542 DeadCode: Serialize,
543 Duplication: Serialize,
544 Complexity: Serialize,
545{
546 serde_json::to_value(ReviewBriefWireOutput {
547 schema_version: brief.schema_version,
548 version: header.version,
549 command: brief.command,
550 verdict: header.verdict,
551 changed_files_count: header.changed_files_count,
552 base_ref: header.base_ref,
553 base_description: header.base_description,
554 head_sha: header.head_sha,
555 elapsed_ms: header.elapsed_ms,
556 base_snapshot_skipped: header.base_snapshot_skipped,
557 summary: header.summary,
558 attribution: header.attribution,
559 meta: None,
560 decisions: brief.decisions,
561 triage: brief.triage,
562 graph_facts: brief.graph_facts,
563 partition: brief.partition,
564 impact_closure: brief.impact_closure,
565 focus: brief.focus,
566 deltas: brief.deltas,
567 weakening: brief.weakening,
568 routing: brief.routing,
569 ownership: brief.ownership,
570 dead_code: subtract.dead_code,
571 duplication: subtract.duplication,
572 complexity: subtract.complexity,
573 branching: brief.branching,
574 })
575}
576
577fn serialize_agent_contract_json_output<T: Serialize>(
578 output: T,
579 kind: &'static str,
580 analysis_run_id: Option<&str>,
581) -> Result<Value, serde_json::Error> {
582 let mut value = serialize_named_json_output(output, kind)?;
583 attach_telemetry_meta(&mut value, analysis_run_id);
584 Ok(value)
585}
586
587/// Serialize the `fallow audit --brief --format json` envelope.
588///
589/// # Errors
590///
591/// Returns a serde error when the brief output cannot be converted to JSON.
592pub fn serialize_review_brief_json_output<T: Serialize>(
593 output: T,
594 analysis_run_id: Option<&str>,
595) -> Result<Value, serde_json::Error> {
596 serialize_agent_contract_json_output(output, "audit-brief", analysis_run_id)
597}
598
599/// Serialize the standalone decision-surface envelope.
600///
601/// # Errors
602///
603/// Returns a serde error when the decision-surface output cannot be converted
604/// to JSON.
605pub fn serialize_decision_surface_json_output<T: Serialize>(
606 output: T,
607 analysis_run_id: Option<&str>,
608) -> Result<Value, serde_json::Error> {
609 serialize_agent_contract_json_output(output, "decision-surface", analysis_run_id)
610}
611
612/// Serialize the review walkthrough guide envelope.
613///
614/// # Errors
615///
616/// Returns a serde error when the walkthrough guide cannot be converted to
617/// JSON.
618pub fn serialize_walkthrough_guide_json_output<T: Serialize>(
619 output: T,
620 analysis_run_id: Option<&str>,
621) -> Result<Value, serde_json::Error> {
622 serialize_agent_contract_json_output(output, "review-walkthrough-guide", analysis_run_id)
623}
624
625/// Serialize the review walkthrough validation envelope.
626///
627/// # Errors
628///
629/// Returns a serde error when the walkthrough validation cannot be converted
630/// to JSON.
631pub fn serialize_walkthrough_validation_json_output<T: Serialize>(
632 output: T,
633 analysis_run_id: Option<&str>,
634) -> Result<Value, serde_json::Error> {
635 serialize_agent_contract_json_output(output, "review-walkthrough-validation", analysis_run_id)
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 ownership: None,
669 decisions: json!({"decisions": []}),
670 };
671 let header = ReviewBriefHeader {
672 version: ToolVersion("1.2.3".to_string()),
673 verdict: json!("fail"),
674 changed_files_count: 1,
675 base_ref: "main".to_string(),
676 base_description: Some("merge base".to_string()),
677 head_sha: Some("abc123".to_string()),
678 elapsed_ms: ElapsedMs(12),
679 base_snapshot_skipped: Some(false),
680 summary: json!({"dead_code_issues": 0}),
681 attribution: json!({"gate": "new_only"}),
682 };
683
684 build_review_brief_json_output(
685 brief,
686 header,
687 ReviewBriefSubtractSections::<Value, Value, Value> {
688 dead_code: Some(json!({"issues": []})),
689 duplication: None,
690 complexity: None,
691 },
692 )
693 .expect("brief output should serialize")
694 }
695
696 #[test]
697 fn review_brief_json_output_assembles_typed_wire_contract() {
698 let value = brief_wire_value(None);
699
700 assert_eq!(value["schema_version"], REVIEW_BRIEF_SCHEMA_VERSION);
701 assert_eq!(value["command"], "audit-brief");
702 assert_eq!(value["verdict"], "fail");
703 assert_eq!(value["base_ref"], "main");
704 assert_eq!(value["summary"]["dead_code_issues"], 0);
705 assert_eq!(value["attribution"]["gate"], "new_only");
706 assert_eq!(value["dead_code"]["issues"], json!([]));
707 assert!(
708 value.get("branching").is_none(),
709 "absent when no base comparison ran, so the wire shape is unchanged for a consumer that never had a base snapshot"
710 );
711 }
712
713 #[test]
714 fn review_brief_json_output_carries_the_branching_block() {
715 // Widening `ReviewBriefOutput` alone would land the block in the
716 // walkthrough digest and leave it out of the brief JSON, because the
717 // wire struct is mapped field by field.
718 let base: crate::BranchingSnapshot = std::iter::once((
719 "src/a.ts".to_string(),
720 fallow_types::extract::FileBranching {
721 branch_points: 12,
722 functions: 1,
723 peak_cyclomatic: 13,
724 cognitive: 12,
725 cognitive_nesting_weight: 6,
726 has_module_unit: false,
727 has_synthetic_units: false,
728 },
729 ))
730 .collect();
731 let head: crate::BranchingSnapshot = std::iter::once((
732 "src/a.ts".to_string(),
733 fallow_types::extract::FileBranching {
734 branch_points: 12,
735 functions: 5,
736 peak_cyclomatic: 4,
737 cognitive: 6,
738 cognitive_nesting_weight: 0,
739 has_module_unit: false,
740 has_synthetic_units: false,
741 },
742 ))
743 .collect();
744 let report = crate::BranchingReport::compare(
745 &base,
746 &head,
747 crate::DEFAULT_BRANCHING_TOLERANCE,
748 &|_| false,
749 );
750
751 let value = brief_wire_value(Some(report));
752
753 assert_eq!(
754 value["branching"]["split_in_place"][0]["path"], "src/a.ts",
755 "the local claim reaches the wire, not only the digest"
756 );
757 assert_eq!(
758 value["branching"]["split_in_place"][0]["functions_after"],
759 5
760 );
761 assert!(
762 !value["branching"]
763 .as_object()
764 .expect("branching is an object")
765 .contains_key("verdict"),
766 "there is no changeset-level verdict to publish"
767 );
768 assert_eq!(value["branching"]["branch_points"]["delta"], 0);
769 assert_eq!(value["branching"]["functions"]["delta"], 4);
770 assert_eq!(value["branching"]["peak_unit_cyclomatic"]["delta"], -9);
771 assert_eq!(
772 value["branching"]["cognitive"]["attributed_to"],
773 "nesting-reset"
774 );
775 assert_eq!(value["branching"]["tolerance"], 2);
776 assert_eq!(value["branching"]["by_file"][0]["path"], "src/a.ts");
777 }
778
779 #[test]
780 fn review_brief_serializer_owns_root_contract() {
781 let value = serialize_review_brief_json_output(
782 json!({"command": "audit-brief"}),
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 =
794 serialize_decision_surface_json_output(json!({"decisions": []}), Some("run-decision"))
795 .expect("decision surface should serialize");
796
797 assert_eq!(value["kind"], "decision-surface");
798 assert_eq!(
799 value["_meta"]["telemetry"]["analysis_run_id"],
800 "run-decision"
801 );
802 }
803
804 /// `<dirs>` directories holding `<per_dir>` files each, path-sorted the way
805 /// the engine closure hands them over.
806 fn affected(dirs: usize, per_dir: usize) -> Vec<String> {
807 let mut paths: Vec<String> = (0..dirs)
808 .flat_map(|d| (0..per_dir).map(move |f| format!("src/zone{d:03}/file{f:03}.ts")))
809 .collect();
810 paths.sort();
811 paths
812 }
813
814 #[test]
815 fn a_closure_within_the_caps_is_reported_whole() {
816 let paths = affected(2, 3);
817 let facts = ImpactClosureFacts::new(&paths, Vec::new());
818
819 assert_eq!(facts.affected_count, 6);
820 assert_eq!(facts.affected_not_shown, paths);
821 assert_eq!(facts.affected_by_dir_omitted, 0);
822 assert_eq!(
823 facts
824 .affected_by_dir
825 .iter()
826 .map(|row| (row.dir.as_str(), row.count))
827 .collect::<Vec<_>>(),
828 vec![("src/zone000", 3), ("src/zone001", 3)]
829 );
830 }
831
832 #[test]
833 fn the_count_survives_capping_the_sample() {
834 let paths = affected(4, 40);
835 let facts = ImpactClosureFacts::new(&paths, Vec::new());
836
837 assert_eq!(
838 facts.affected_count, 160,
839 "the magnitude is computed before the sample is capped"
840 );
841 assert_eq!(facts.affected_not_shown.len(), AFFECTED_SAMPLE_CAP);
842 assert_eq!(
843 facts
844 .affected_by_dir
845 .iter()
846 .map(|row| row.count)
847 .sum::<usize>(),
848 facts.affected_count,
849 "an uncapped rollup accounts for every affected file"
850 );
851 }
852
853 #[test]
854 fn the_rollup_carries_weight_the_sample_cannot() {
855 // A prefix sample lands entirely in the directory that sorts first, so
856 // the rollup is the only thing that can say where the reach actually is.
857 let mut paths = affected(1, 12);
858 paths.extend((0..90).map(|f| format!("src/zzz_heavy/file{f:03}.ts")));
859 paths.sort();
860 let facts = ImpactClosureFacts::new(&paths, Vec::new());
861
862 assert!(
863 facts
864 .affected_not_shown
865 .iter()
866 .all(|path| path.starts_with("src/zone000/")),
867 "the fixture must produce a one-directory sample: {:?}",
868 facts.affected_not_shown
869 );
870 let heaviest = facts.affected_by_dir.first().expect("a rollup row");
871 assert_eq!(
872 (heaviest.dir.as_str(), heaviest.count),
873 ("src/zzz_heavy", 90)
874 );
875 }
876
877 #[test]
878 fn rollup_rows_beyond_the_cap_are_counted_not_dropped_silently() {
879 let paths = affected(AFFECTED_DIR_CAP + 7, 1);
880 let facts = ImpactClosureFacts::new(&paths, Vec::new());
881
882 assert_eq!(facts.affected_by_dir.len(), AFFECTED_DIR_CAP);
883 assert_eq!(facts.affected_by_dir_omitted, 7);
884 assert_eq!(facts.affected_count, AFFECTED_DIR_CAP + 7);
885 }
886
887 #[test]
888 fn equal_weight_directories_are_ordered_by_path() {
889 let facts = ImpactClosureFacts::new(&affected(3, 2), Vec::new());
890 let dirs: Vec<&str> = facts
891 .affected_by_dir
892 .iter()
893 .map(|row| row.dir.as_str())
894 .collect();
895 assert_eq!(
896 dirs,
897 vec!["src/zone000", "src/zone001", "src/zone002"],
898 "the path is the tie-break, so the order is total across runs"
899 );
900 }
901
902 #[test]
903 fn the_coordination_gap_is_never_capped() {
904 // The human brief routes a reader to `--format json` for the gaps and
905 // their symbols. That promise holds only while this constructor stores
906 // both whole, alongside two fields it does deliberately cap.
907 let symbols: Vec<String> = (0..40).map(|i| format!("symbol{i:02}")).collect();
908 let gaps: Vec<CoordinationGapFact> = (0..60)
909 .map(|i| CoordinationGapFact {
910 changed_file: "src/core.ts".to_string(),
911 consumer_file: format!("src/consumer{i:02}.ts"),
912 consumed_symbols: symbols.clone(),
913 note: String::new(),
914 })
915 .collect();
916 let facts = ImpactClosureFacts::new(&affected(40, 3), gaps);
917
918 assert_eq!(facts.coordination_gap.len(), 60);
919 assert!(
920 facts
921 .coordination_gap
922 .iter()
923 .all(|gap| gap.consumed_symbols.len() == 40),
924 "every consumed symbol survives, or the brief's json route is a lie"
925 );
926 assert!(
927 facts.affected_not_shown.len() < facts.affected_count
928 && facts.affected_by_dir_omitted > 0,
929 "the fixture must show the sibling fields really are capped"
930 );
931 }
932
933 #[test]
934 fn root_level_files_roll_up_under_the_empty_directory() {
935 let facts =
936 ImpactClosureFacts::new(&["play.ts".to_string(), "setup.ts".to_string()], Vec::new());
937 assert_eq!(facts.affected_by_dir.len(), 1);
938 assert_eq!(facts.affected_by_dir[0].dir, "");
939 assert_eq!(facts.affected_by_dir[0].count, 2);
940 }
941}