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.
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}