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