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