Skip to main content

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}