Skip to main content

fallow_output/
root_envelopes.rs

1//! Root JSON output envelopes shared by CLI and programmatic consumers.
2
3use fallow_types::envelope::{ElapsedMs, Meta, SchemaVersion, TelemetryMeta, ToolVersion};
4use fallow_types::output::NextStep;
5use serde::Serialize;
6
7/// Current schema version for `fallow audit --format json`.
8pub const AUDIT_SCHEMA_VERSION: u32 = 9;
9
10/// Current schema version for bare combined JSON output.
11///
12/// Version 10 tracks the embedded health contract: `threshold_overrides[]`
13/// rows gained the required `dimension` field and the `insufficient` status
14/// (issue #2163), and an envelope embedding a changed contract bumps with it.
15pub const COMBINED_SCHEMA_VERSION: u32 = 10;
16
17/// Schema projection for the audit envelope's exact version.
18#[cfg(feature = "schema")]
19#[allow(dead_code, reason = "schema-only type used by the field projection")]
20#[derive(schemars::JsonSchema)]
21#[schemars(extend("const" = AUDIT_SCHEMA_VERSION))]
22struct AuditSchemaVersion(u32);
23
24/// Schema projection for the combined envelope's exact version.
25#[cfg(feature = "schema")]
26#[allow(dead_code, reason = "schema-only type used by the field projection")]
27#[derive(schemars::JsonSchema)]
28#[schemars(extend("const" = COMBINED_SCHEMA_VERSION))]
29struct CombinedSchemaVersion(u32);
30
31/// JSON root envelope discriminator policy.
32#[derive(Debug, Clone, Copy, PartialEq, Eq)]
33pub enum RootEnvelopeMode {
34    /// Emit a top-level `kind` discriminator on the JSON root.
35    Tagged,
36}
37
38/// Serialize a typed fallow root envelope with the requested discriminator
39/// mode.
40///
41/// # Errors
42///
43/// Returns a serde error when the provided envelope cannot be converted to a
44/// JSON value.
45pub fn serialize_json_root_output<T: Serialize>(
46    output: T,
47    mode: RootEnvelopeMode,
48) -> Result<serde_json::Value, serde_json::Error> {
49    let _ = mode;
50    serde_json::to_value(output)
51}
52
53/// Serialize an output envelope and apply an explicit root discriminator.
54///
55/// Use this for command surfaces whose runtime shape is already a typed
56/// envelope struct and does not need to pass through the schema-only
57/// [`FallowOutput`] enum just to get a top-level `kind`.
58///
59/// # Errors
60///
61/// Returns a serde error when the provided envelope cannot be converted to a
62/// JSON value.
63pub fn serialize_named_json_output<T: Serialize>(
64    output: T,
65    kind: &'static str,
66    mode: RootEnvelopeMode,
67) -> Result<serde_json::Value, serde_json::Error> {
68    let mut value = serde_json::to_value(output)?;
69    apply_root_kind(&mut value, kind, mode);
70    Ok(value)
71}
72
73/// Serialize a typed `fallow audit --format json` envelope with the standard
74/// root discriminator policy.
75///
76/// # Errors
77///
78/// Returns a serde error when the provided envelope cannot be converted to a
79/// JSON value.
80pub fn serialize_audit_json_output<
81    Verdict,
82    Summary,
83    Attribution,
84    DeadCode,
85    Duplication,
86    Complexity,
87>(
88    output: AuditOutput<Verdict, Summary, Attribution, DeadCode, Duplication, Complexity>,
89    mode: RootEnvelopeMode,
90    analysis_run_id: Option<&str>,
91) -> Result<serde_json::Value, serde_json::Error>
92where
93    Verdict: Serialize,
94    Summary: Serialize,
95    Attribution: Serialize,
96    DeadCode: Serialize,
97    Duplication: Serialize,
98    Complexity: Serialize,
99{
100    let mut value = serde_json::to_value(output)?;
101    apply_root_kind(&mut value, "audit", mode);
102    attach_telemetry_meta(&mut value, analysis_run_id);
103    Ok(value)
104}
105
106/// Serialize a typed bare `fallow --format json` combined envelope with the
107/// standard root discriminator policy.
108///
109/// # Errors
110///
111/// Returns a serde error when the provided envelope cannot be converted to a
112/// JSON value.
113pub fn serialize_combined_json_output<Check, Dupes, Health>(
114    output: CombinedOutput<Check, Dupes, Health>,
115    mode: RootEnvelopeMode,
116    analysis_run_id: Option<&str>,
117) -> Result<serde_json::Value, serde_json::Error>
118where
119    Check: Serialize,
120    Dupes: Serialize,
121    Health: Serialize,
122{
123    let mut value = serde_json::to_value(output)?;
124    apply_root_kind(&mut value, "combined", mode);
125    attach_telemetry_meta(&mut value, analysis_run_id);
126    Ok(value)
127}
128
129/// Apply a document-root discriminator.
130pub fn apply_root_kind(value: &mut serde_json::Value, kind: &'static str, mode: RootEnvelopeMode) {
131    let _ = mode;
132    if let serde_json::Value::Object(map) = value {
133        let existing = std::mem::take(map);
134        map.insert(
135            "kind".to_string(),
136            serde_json::Value::String(kind.to_string()),
137        );
138        map.extend(existing);
139    }
140}
141
142/// Attach telemetry metadata to a JSON root object when a run id is available.
143pub fn attach_telemetry_meta(value: &mut serde_json::Value, analysis_run_id: Option<&str>) {
144    let Some(analysis_run_id) = analysis_run_id else {
145        return;
146    };
147    let serde_json::Value::Object(map) = value else {
148        return;
149    };
150    let meta = map
151        .entry("_meta".to_string())
152        .or_insert_with(|| serde_json::Value::Object(serde_json::Map::new()));
153    if !meta.is_object() {
154        *meta = serde_json::Value::Object(serde_json::Map::new());
155    }
156    if let serde_json::Value::Object(meta_map) = meta {
157        meta_map.insert(
158            "telemetry".to_string(),
159            serde_json::json!({ "analysis_run_id": analysis_run_id }),
160        );
161    }
162}
163
164/// `fallow audit --format json` envelope.
165#[derive(Debug, Clone, Serialize)]
166#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
167#[cfg_attr(feature = "schema", schemars(title = "fallow audit --format json"))]
168pub struct AuditOutput<Verdict, Summary, Attribution, DeadCode, Duplication, Complexity> {
169    /// Audit output schema version.
170    #[cfg_attr(feature = "schema", schemars(with = "AuditSchemaVersion"))]
171    pub schema_version: SchemaVersion,
172    /// Fallow CLI version that produced this output.
173    pub version: ToolVersion,
174    /// Command discriminator singleton: always `audit`.
175    pub command: AuditCommand,
176    /// Gate verdict for the audited change.
177    pub verdict: Verdict,
178    /// Number of changed files in the audit scope.
179    pub changed_files_count: u32,
180    /// Git ref the change was diffed against.
181    pub base_ref: String,
182    /// Human-readable provenance of `base_ref`, e.g. `merge-base with
183    /// origin/main`, `local main`, or `FALLOW_AUDIT_BASE=upstream/main`.
184    /// Present when the base was auto-detected or set via `FALLOW_AUDIT_BASE`;
185    /// absent for an explicit `--base` (the ref the user typed is already
186    /// self-describing).
187    #[serde(default, skip_serializing_if = "Option::is_none")]
188    pub base_description: Option<String>,
189    /// Commit SHA of the audited head tree, when resolvable.
190    #[serde(default, skip_serializing_if = "Option::is_none")]
191    pub head_sha: Option<String>,
192    /// Wall-clock analysis duration in milliseconds.
193    pub elapsed_ms: ElapsedMs,
194    /// True when base-snapshot analysis was skipped, so new-vs-inherited
195    /// attribution could not run.
196    #[serde(default, skip_serializing_if = "Option::is_none")]
197    pub base_snapshot_skipped: Option<bool>,
198    /// Aggregate finding counts for the audited change.
199    pub summary: Summary,
200    /// New-vs-inherited attribution of findings against the base.
201    pub attribution: Attribution,
202    /// `_meta` block with metric / rule definitions, when `--explain` was
203    /// passed.
204    #[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")]
205    pub meta: Option<Meta>,
206    /// Dead-code findings scoped to the audit changeset.
207    #[serde(default, skip_serializing_if = "Option::is_none")]
208    pub dead_code: Option<DeadCode>,
209    /// Duplication findings scoped to the audit changeset.
210    #[serde(default, skip_serializing_if = "Option::is_none")]
211    pub duplication: Option<Duplication>,
212    /// Complexity findings scoped to the audit changeset.
213    #[serde(default, skip_serializing_if = "Option::is_none")]
214    pub complexity: Option<Complexity>,
215    /// Read-only follow-up commands computed from this run's findings. See
216    /// `CheckOutput::next_steps` for the contract.
217    #[serde(default, skip_serializing_if = "Vec::is_empty")]
218    pub next_steps: Vec<NextStep>,
219}
220
221/// Audit command singleton carried by [`AuditOutput`].
222#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
223#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
224#[serde(rename_all = "lowercase")]
225pub enum AuditCommand {
226    /// The only value: `audit`.
227    Audit,
228}
229
230/// Bare `fallow --format json` envelope.
231#[derive(Debug, Clone, Serialize)]
232#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
233#[cfg_attr(
234    feature = "schema",
235    schemars(title = "fallow --format json (bare, combined)")
236)]
237pub struct CombinedOutput<Check, Dupes, Health> {
238    /// Combined output schema version.
239    #[cfg_attr(feature = "schema", schemars(with = "CombinedSchemaVersion"))]
240    pub schema_version: SchemaVersion,
241    /// Fallow CLI version that produced this output.
242    pub version: ToolVersion,
243    /// Wall-clock analysis duration in milliseconds.
244    pub elapsed_ms: ElapsedMs,
245    /// Per-section `_meta` blocks, when `--explain` was passed.
246    #[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")]
247    pub meta: Option<CombinedMeta>,
248    /// Dead-code section of the combined run.
249    #[serde(default, skip_serializing_if = "Option::is_none")]
250    pub check: Option<Check>,
251    /// Duplication section of the combined run.
252    #[serde(default, skip_serializing_if = "Option::is_none")]
253    pub dupes: Option<Dupes>,
254    /// Health section of the combined run.
255    #[serde(default, skip_serializing_if = "Option::is_none")]
256    pub health: Option<Health>,
257    /// Read-only follow-up commands aggregated across the combined run's
258    /// findings. See `CheckOutput::next_steps` for the contract.
259    #[serde(default, skip_serializing_if = "Vec::is_empty")]
260    pub next_steps: Vec<NextStep>,
261}
262
263/// Optional `_meta` block for [`CombinedOutput`].
264#[derive(Debug, Clone, Serialize)]
265#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
266pub struct CombinedMeta {
267    /// `_meta` block for the dead-code section.
268    #[serde(default, skip_serializing_if = "Option::is_none")]
269    pub check: Option<Meta>,
270    /// `_meta` block for the duplication section.
271    #[serde(default, skip_serializing_if = "Option::is_none")]
272    pub dupes: Option<Meta>,
273    /// `_meta` block for the health section.
274    #[serde(default, skip_serializing_if = "Option::is_none")]
275    pub health: Option<Meta>,
276    /// Telemetry identifiers for the run.
277    #[serde(default, skip_serializing_if = "Option::is_none")]
278    pub telemetry: Option<TelemetryMeta>,
279}
280
281/// Typed root of every fallow JSON envelope shape that serializes as a JSON
282/// object and participates in the documented `FallowOutput` contract. The
283/// schema derived from this enum drives the document-root `oneOf` in
284/// `docs/output-schema.json`.
285///
286/// The wire shape carries a top-level `kind` discriminator so agents and
287/// schema-validating clients can select the variant in O(1) instead of probing
288/// for unique field presence.
289///
290/// One envelope is intentionally NOT in this enum:
291/// - `CodeClimateOutput` serializes as a bare JSON array
292///   (`#[serde(transparent)]`) per the Code Climate / GitLab Code Quality
293///   spec; `#[serde(tag = ...)]` cannot internally tag a non-object
294///   variant and wrapping the array would break the spec. The root schema
295///   carries it as a sibling `oneOf` branch alongside `FallowOutput`.
296#[derive(Debug, Clone, Serialize)]
297#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
298#[cfg_attr(
299    feature = "schema",
300    schemars(title = "fallow --format json (typed root)")
301)]
302#[serde(tag = "kind")]
303#[allow(
304    dead_code,
305    reason = "some variants are schema-emit only, but runtime roots serialize through this enum where practical"
306)]
307pub enum FallowOutput<
308    Audit,
309    Explain,
310    Inspect,
311    Trace,
312    ReviewEnvelope,
313    ReviewReconcile,
314    CoverageSetup,
315    CoverageAnalyze,
316    ListBoundaries,
317    Workspaces,
318    Health,
319    Dupes,
320    CheckGrouped,
321    Impact,
322    ImpactCrossRepo,
323    SecuritySummary,
324    Security,
325    SecuritySurvivors,
326    SecurityBlindSpots,
327    Check,
328    Combined,
329    FeatureFlags,
330    AuditBrief,
331    DecisionSurface,
332    WalkthroughGuide,
333    WalkthroughValidation,
334    SuppressionInventory,
335    TypeAwareStatus,
336> {
337    /// `fallow audit --format json`.
338    #[serde(rename = "audit")]
339    Audit(Audit),
340    /// `fallow explain <issue-type> --format json`.
341    #[serde(rename = "explain")]
342    Explain(Explain),
343    /// `fallow inspect --format json`.
344    #[serde(rename = "inspect_target")]
345    Inspect(Inspect),
346    /// `fallow trace <symbol> --format json`.
347    #[serde(rename = "trace")]
348    Trace(Trace),
349    /// `fallow --format review-github` / `--format review-gitlab`.
350    #[serde(rename = "review-envelope")]
351    ReviewEnvelope(ReviewEnvelope),
352    /// `fallow ci reconcile-review --format json`.
353    #[serde(rename = "review-reconcile")]
354    ReviewReconcile(ReviewReconcile),
355    /// `fallow coverage setup --json`.
356    #[serde(rename = "coverage-setup")]
357    CoverageSetup(CoverageSetup),
358    /// `fallow coverage analyze --format json`.
359    #[serde(rename = "coverage-analyze")]
360    CoverageAnalyze(CoverageAnalyze),
361    /// `fallow list --boundaries --format json`.
362    #[serde(rename = "list-boundaries")]
363    ListBoundaries(ListBoundaries),
364    /// `fallow workspaces --format json`.
365    #[serde(rename = "list-workspaces")]
366    Workspaces(Workspaces),
367    /// `fallow health --format json`.
368    #[serde(rename = "health")]
369    Health(Health),
370    /// `fallow dupes --format json`.
371    #[serde(rename = "dupes")]
372    Dupes(Dupes),
373    /// `fallow dead-code --format json --group-by <mode>`.
374    #[serde(rename = "dead-code-grouped")]
375    CheckGrouped(CheckGrouped),
376    /// `fallow impact --format json`.
377    #[serde(rename = "impact")]
378    Impact(Impact),
379    /// `fallow impact --all --format json`.
380    #[serde(rename = "impact-cross-repo")]
381    ImpactCrossRepo(ImpactCrossRepo),
382    /// `fallow security --summary --format json`.
383    #[serde(rename = "security")]
384    SecuritySummary(SecuritySummary),
385    /// `fallow security --format json`.
386    #[serde(rename = "security")]
387    Security(Security),
388    /// `fallow security survivors --format json`.
389    #[serde(rename = "security-survivors")]
390    SecuritySurvivors(SecuritySurvivors),
391    /// `fallow security blind-spots --format json`.
392    #[serde(rename = "security-blind-spots")]
393    SecurityBlindSpots(SecurityBlindSpots),
394    /// `fallow dead-code --format json`.
395    #[serde(rename = "dead-code")]
396    Check(Check),
397    /// Bare `fallow --format json`.
398    #[serde(rename = "combined")]
399    Combined(Combined),
400    /// `fallow flags --format json`.
401    #[serde(rename = "feature-flags")]
402    FeatureFlags(FeatureFlags),
403    /// `fallow audit --brief --format json`.
404    #[serde(rename = "audit-brief")]
405    AuditBrief(AuditBrief),
406    /// `fallow decision-surface --format json`.
407    #[serde(rename = "decision-surface")]
408    DecisionSurface(DecisionSurface),
409    /// `fallow review --walkthrough-guide --format json`.
410    #[serde(rename = "review-walkthrough-guide")]
411    WalkthroughGuide(WalkthroughGuide),
412    /// `fallow review --walkthrough-file --format json`.
413    #[serde(rename = "review-walkthrough-validation")]
414    WalkthroughValidation(WalkthroughValidation),
415    /// `fallow suppressions --format json`.
416    #[serde(rename = "suppression-inventory")]
417    SuppressionInventory(SuppressionInventory),
418    /// `fallow type-aware status --format json`.
419    #[serde(rename = "type-aware-status")]
420    TypeAwareStatus(TypeAwareStatus),
421}
422
423#[cfg(test)]
424mod tests {
425    use fallow_types::envelope::{ElapsedMs, SchemaVersion, ToolVersion};
426    use serde_json::json;
427
428    use super::*;
429
430    #[test]
431    fn apply_root_kind_sets_tagged_mode() {
432        let mut value = json!({});
433
434        apply_root_kind(&mut value, "dead_code", RootEnvelopeMode::Tagged);
435
436        assert_eq!(value["kind"], "dead_code");
437    }
438
439    #[test]
440    fn attach_telemetry_meta_sets_analysis_run_id() {
441        let mut value = json!({});
442
443        attach_telemetry_meta(&mut value, Some("run-123"));
444
445        assert_eq!(
446            value["_meta"]["telemetry"]["analysis_run_id"],
447            json!("run-123")
448        );
449    }
450
451    #[test]
452    fn attach_telemetry_meta_preserves_non_object_roots() {
453        let mut value = json!(["not", "an", "object"]);
454
455        attach_telemetry_meta(&mut value, Some("run-123"));
456
457        assert_eq!(value, json!(["not", "an", "object"]));
458    }
459
460    #[test]
461    fn serialize_named_json_output_applies_explicit_kind() {
462        let value = serialize_named_json_output(
463            json!({
464                "schema_version": 1,
465                "summary": { "total": 0 }
466            }),
467            "example",
468            RootEnvelopeMode::Tagged,
469        )
470        .expect("named output should serialize");
471
472        assert_eq!(value["kind"], "example");
473        assert_eq!(value["summary"]["total"], 0);
474    }
475
476    #[test]
477    fn serialize_audit_json_output_applies_audit_kind() {
478        let value = serialize_audit_json_output(
479            AuditOutput {
480                schema_version: SchemaVersion(7),
481                version: ToolVersion("1.2.3".to_string()),
482                command: AuditCommand::Audit,
483                verdict: "pass",
484                changed_files_count: 2,
485                base_ref: "origin/main".to_string(),
486                base_description: Some("merge-base with origin/main".to_string()),
487                head_sha: Some("abc123".to_string()),
488                elapsed_ms: ElapsedMs(42),
489                base_snapshot_skipped: Some(false),
490                summary: json!({ "dead_code_issues": 0 }),
491                attribution: json!({ "gate": "new_only" }),
492                meta: None,
493                dead_code: Some(json!({ "summary": { "total_issues": 0 } })),
494                duplication: None::<serde_json::Value>,
495                complexity: None::<serde_json::Value>,
496                next_steps: Vec::new(),
497            },
498            RootEnvelopeMode::Tagged,
499            Some("run-audit"),
500        )
501        .expect("audit output should serialize");
502
503        assert_eq!(value["kind"], "audit");
504        assert_eq!(value["command"], "audit");
505        assert_eq!(value["dead_code"]["summary"]["total_issues"], 0);
506        assert_eq!(value["_meta"]["telemetry"]["analysis_run_id"], "run-audit");
507    }
508
509    #[test]
510    fn serialize_combined_json_output_applies_combined_kind() {
511        let value = serialize_combined_json_output(
512            CombinedOutput {
513                schema_version: SchemaVersion(7),
514                version: ToolVersion("1.2.3".to_string()),
515                elapsed_ms: ElapsedMs(42),
516                meta: None,
517                check: Some(json!({ "summary": { "total_issues": 0 } })),
518                dupes: None::<serde_json::Value>,
519                health: None::<serde_json::Value>,
520                next_steps: Vec::new(),
521            },
522            RootEnvelopeMode::Tagged,
523            Some("run-combined"),
524        )
525        .expect("combined output should serialize");
526
527        assert_eq!(value["kind"], "combined");
528        assert_eq!(value["check"]["summary"]["total_issues"], 0);
529        assert_eq!(
530            value["_meta"]["telemetry"]["analysis_run_id"],
531            "run-combined"
532        );
533    }
534}