Skip to main content

fallow_output/
audit_brief.rs

1//! Audit brief output contracts.
2
3use crate::root_envelopes::{RootEnvelopeMode, attach_telemetry_meta, serialize_named_json_output};
4use 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}