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