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