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    /// Every gate this run ARMED, keyed by name, absent when it armed none.
209    /// Each entry is the same rule that decides the exit code, so a CI
210    /// integration reads the verdict instead of guessing from a process status
211    /// it usually cannot see. A gate fails the build when `status` is `fail`
212    /// AND `enforced` is true. Armed, not evaluated: fallow's default severity
213    /// rules fail a run with no flag at all, so an absent object means "no gate
214    /// was asked for", never "nothing failed". See [`crate::GateOutcomes`].
215    #[serde(default, skip_serializing_if = "Option::is_none")]
216    pub gate_outcomes: Option<crate::GateOutcomes>,
217    /// `_meta` block with metric / rule definitions, when `--explain` was
218    /// passed.
219    #[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")]
220    pub meta: Option<Meta>,
221    /// Dead-code findings scoped to the audit changeset.
222    #[serde(default, skip_serializing_if = "Option::is_none")]
223    pub dead_code: Option<DeadCode>,
224    /// Duplication findings scoped to the audit changeset.
225    #[serde(default, skip_serializing_if = "Option::is_none")]
226    pub duplication: Option<Duplication>,
227    /// Complexity findings scoped to the audit changeset.
228    #[serde(default, skip_serializing_if = "Option::is_none")]
229    pub complexity: Option<Complexity>,
230    /// Read-only follow-up commands computed from this run's findings. See
231    /// `CheckOutput::next_steps` for the contract.
232    #[serde(default, skip_serializing_if = "Vec::is_empty")]
233    pub next_steps: Vec<NextStep>,
234}
235
236/// Audit command singleton carried by [`AuditOutput`].
237#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
238#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
239#[serde(rename_all = "lowercase")]
240pub enum AuditCommand {
241    /// The only value: `audit`.
242    Audit,
243}
244
245/// Bare `fallow --format json` envelope.
246#[derive(Debug, Clone, Serialize)]
247#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
248#[cfg_attr(
249    feature = "schema",
250    schemars(title = "fallow --format json (bare, combined)")
251)]
252pub struct CombinedOutput<Check, Dupes, Health> {
253    /// Combined output schema version.
254    #[cfg_attr(feature = "schema", schemars(with = "CombinedSchemaVersion"))]
255    pub schema_version: SchemaVersion,
256    /// Fallow CLI version that produced this output.
257    pub version: ToolVersion,
258    /// Wall-clock analysis duration in milliseconds.
259    pub elapsed_ms: ElapsedMs,
260    /// Every gate this run ARMED, keyed by name, absent when it armed none.
261    /// Each entry is the same rule that decides the exit code, so a CI
262    /// integration reads the verdict instead of guessing from a process status
263    /// it usually cannot see. A gate fails the build when `status` is `fail`
264    /// AND `enforced` is true. Armed, not evaluated: fallow's default severity
265    /// rules fail a run with no flag at all, so an absent object means "no gate
266    /// was asked for", never "nothing failed". See [`crate::GateOutcomes`].
267    #[serde(default, skip_serializing_if = "Option::is_none")]
268    pub gate_outcomes: Option<crate::GateOutcomes>,
269    /// Per-section `_meta` blocks, when `--explain` was passed.
270    #[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")]
271    pub meta: Option<CombinedMeta>,
272    /// Dead-code section of the combined run.
273    #[serde(default, skip_serializing_if = "Option::is_none")]
274    pub check: Option<Check>,
275    /// Duplication section of the combined run.
276    #[serde(default, skip_serializing_if = "Option::is_none")]
277    pub dupes: Option<Dupes>,
278    /// Health section of the combined run.
279    #[serde(default, skip_serializing_if = "Option::is_none")]
280    pub health: Option<Health>,
281    /// Workspace-discovery, source-discovery, and analysis-stage diagnostics
282    /// for the run (issue #2366). See `CheckOutput::workspace_diagnostics` for
283    /// the full contract: root-relative paths, omitted when empty. The
284    /// combined envelope carries them here rather than inside a section, so a
285    /// run that skips a section (`--skip check`, `--only health`,
286    /// `--only dupes`) still reports every diagnostic its analyses recorded.
287    #[serde(default, skip_serializing_if = "Vec::is_empty")]
288    pub workspace_diagnostics: Vec<WorkspaceDiagnostic>,
289    /// Read-only follow-up commands aggregated across the combined run's
290    /// findings. See `CheckOutput::next_steps` for the contract.
291    #[serde(default, skip_serializing_if = "Vec::is_empty")]
292    pub next_steps: Vec<NextStep>,
293}
294
295/// Optional `_meta` block for [`CombinedOutput`].
296#[derive(Debug, Clone, Serialize)]
297#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
298pub struct CombinedMeta {
299    /// `_meta` block for the dead-code section.
300    #[serde(default, skip_serializing_if = "Option::is_none")]
301    pub check: Option<Meta>,
302    /// `_meta` block for the duplication section.
303    #[serde(default, skip_serializing_if = "Option::is_none")]
304    pub dupes: Option<Meta>,
305    /// `_meta` block for the health section.
306    #[serde(default, skip_serializing_if = "Option::is_none")]
307    pub health: Option<Meta>,
308    /// Telemetry identifiers for the run.
309    #[serde(default, skip_serializing_if = "Option::is_none")]
310    pub telemetry: Option<TelemetryMeta>,
311}
312
313/// Typed root of every fallow JSON envelope shape that serializes as a JSON
314/// object and participates in the documented `FallowOutput` contract. The
315/// schema derived from this enum drives the document-root `oneOf` in
316/// `docs/output-schema.json`.
317///
318/// The wire shape carries a top-level `kind` discriminator so agents and
319/// schema-validating clients can select the variant in O(1) instead of probing
320/// for unique field presence.
321///
322/// One envelope is intentionally NOT in this enum:
323/// - `CodeClimateOutput` serializes as a bare JSON array
324///   (`#[serde(transparent)]`) per the Code Climate / GitLab Code Quality
325///   spec; `#[serde(tag = ...)]` cannot internally tag a non-object
326///   variant and wrapping the array would break the spec. The root schema
327///   carries it as a sibling `oneOf` branch alongside `FallowOutput`.
328#[derive(Debug, Clone, Serialize)]
329#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
330#[cfg_attr(
331    feature = "schema",
332    schemars(title = "fallow --format json (typed root)")
333)]
334#[serde(tag = "kind")]
335#[allow(
336    dead_code,
337    reason = "some variants are schema-emit only, but runtime roots serialize through this enum where practical"
338)]
339pub enum FallowOutput<
340    Audit,
341    Explain,
342    Inspect,
343    Trace,
344    ReviewEnvelope,
345    ReviewReconcile,
346    CoverageSetup,
347    CoverageAnalyze,
348    ListBoundaries,
349    Workspaces,
350    Health,
351    Dupes,
352    CheckGrouped,
353    Impact,
354    ImpactCrossRepo,
355    SecuritySummary,
356    Security,
357    SecuritySurvivors,
358    SecurityBlindSpots,
359    Check,
360    Combined,
361    FeatureFlags,
362    AuditBrief,
363    DecisionSurface,
364    WalkthroughGuide,
365    WalkthroughValidation,
366    SuppressionInventory,
367    Doctor,
368    TypeAwareStatus,
369    SimilarCode,
370    SimilarCodeInspect,
371    SimilarCodeReview,
372    SimilarCodeStatus,
373    SimilarCodeCacheClear,
374> {
375    /// `fallow audit --format json`.
376    #[serde(rename = "audit")]
377    Audit(Audit),
378    /// `fallow explain <issue-type> --format json`.
379    #[serde(rename = "explain")]
380    Explain(Explain),
381    /// `fallow inspect --format json`.
382    #[serde(rename = "inspect_target")]
383    Inspect(Inspect),
384    /// `fallow trace <symbol> --format json`.
385    #[serde(rename = "trace")]
386    Trace(Trace),
387    /// `fallow --format review-github` / `--format review-gitlab`.
388    #[serde(rename = "review-envelope")]
389    ReviewEnvelope(ReviewEnvelope),
390    /// `fallow ci reconcile-review --format json`.
391    #[serde(rename = "review-reconcile")]
392    ReviewReconcile(ReviewReconcile),
393    /// `fallow coverage setup --json`.
394    #[serde(rename = "coverage-setup")]
395    CoverageSetup(CoverageSetup),
396    /// `fallow coverage analyze --format json`.
397    #[serde(rename = "coverage-analyze")]
398    CoverageAnalyze(CoverageAnalyze),
399    /// `fallow list --boundaries --format json`.
400    #[serde(rename = "list-boundaries")]
401    ListBoundaries(ListBoundaries),
402    /// `fallow workspaces --format json`.
403    #[serde(rename = "list-workspaces")]
404    Workspaces(Workspaces),
405    /// `fallow health --format json`.
406    #[serde(rename = "health")]
407    Health(Health),
408    /// `fallow dupes --format json`.
409    #[serde(rename = "dupes")]
410    Dupes(Dupes),
411    /// `fallow dead-code --format json --group-by <mode>`.
412    #[serde(rename = "dead-code-grouped")]
413    CheckGrouped(CheckGrouped),
414    /// `fallow impact --format json`.
415    #[serde(rename = "impact")]
416    Impact(Impact),
417    /// `fallow impact --all --format json`.
418    #[serde(rename = "impact-cross-repo")]
419    ImpactCrossRepo(ImpactCrossRepo),
420    /// `fallow security --summary --format json`.
421    #[serde(rename = "security")]
422    SecuritySummary(SecuritySummary),
423    /// `fallow security --format json`.
424    #[serde(rename = "security")]
425    Security(Security),
426    /// `fallow security survivors --format json`.
427    #[serde(rename = "security-survivors")]
428    SecuritySurvivors(SecuritySurvivors),
429    /// `fallow security blind-spots --format json`.
430    #[serde(rename = "security-blind-spots")]
431    SecurityBlindSpots(SecurityBlindSpots),
432    /// `fallow dead-code --format json`.
433    #[serde(rename = "dead-code")]
434    Check(Check),
435    /// Bare `fallow --format json`.
436    #[serde(rename = "combined")]
437    Combined(Combined),
438    /// `fallow flags --format json`.
439    #[serde(rename = "feature-flags")]
440    FeatureFlags(FeatureFlags),
441    /// `fallow audit --brief --format json`.
442    #[serde(rename = "audit-brief")]
443    AuditBrief(AuditBrief),
444    /// `fallow decision-surface --format json`.
445    #[serde(rename = "decision-surface")]
446    DecisionSurface(DecisionSurface),
447    /// `fallow review --walkthrough-guide --format json`.
448    #[serde(rename = "review-walkthrough-guide")]
449    WalkthroughGuide(WalkthroughGuide),
450    /// `fallow review --walkthrough-file --format json`.
451    #[serde(rename = "review-walkthrough-validation")]
452    WalkthroughValidation(WalkthroughValidation),
453    /// `fallow suppressions --format json`.
454    #[serde(rename = "suppression-inventory")]
455    SuppressionInventory(SuppressionInventory),
456    /// `fallow doctor --format json`.
457    #[serde(rename = "doctor")]
458    Doctor(Doctor),
459    /// `fallow type-aware status --format json`.
460    #[serde(rename = "type-aware-status")]
461    TypeAwareStatus(TypeAwareStatus),
462    /// `fallow similar-code --format json`.
463    #[serde(rename = "similar-code")]
464    SimilarCode(SimilarCode),
465    /// `fallow similar-code inspect --format json`.
466    #[serde(rename = "similar-code-inspect")]
467    SimilarCodeInspect(SimilarCodeInspect),
468    /// `fallow similar-code review --format json`.
469    #[serde(rename = "similar-code-review")]
470    SimilarCodeReview(SimilarCodeReview),
471    /// `fallow similar-code status --format json` and successful setup output.
472    #[serde(rename = "similar-code-status")]
473    SimilarCodeStatus(SimilarCodeStatus),
474    /// `fallow similar-code cache clear --format json`.
475    #[serde(rename = "similar-code-cache-clear")]
476    SimilarCodeCacheClear(SimilarCodeCacheClear),
477}
478
479#[cfg(test)]
480mod tests {
481    use fallow_types::envelope::{ElapsedMs, SchemaVersion, ToolVersion};
482    use serde_json::json;
483
484    use super::*;
485
486    #[test]
487    fn apply_root_kind_sets_tagged_mode() {
488        let mut value = json!({});
489
490        apply_root_kind(&mut value, "dead_code", RootEnvelopeMode::Tagged);
491
492        assert_eq!(value["kind"], "dead_code");
493    }
494
495    #[test]
496    fn apply_root_kind_prepends_without_reordering_existing_fields() {
497        let mut value = json!({ "schema_version": 1, "summary": { "total": 0 } });
498
499        apply_root_kind(&mut value, "example", RootEnvelopeMode::Tagged);
500
501        assert_eq!(
502            serde_json::to_string(&value).expect("root output should serialize"),
503            r#"{"kind":"example","schema_version":1,"summary":{"total":0}}"#
504        );
505    }
506
507    #[test]
508    fn apply_root_kind_preserves_existing_value_and_moves_it_first() {
509        let mut value = json!({ "before": 1, "kind": "custom", "after": 2 });
510
511        apply_root_kind(&mut value, "replacement", RootEnvelopeMode::Tagged);
512
513        assert_eq!(
514            serde_json::to_string(&value).expect("root output should serialize"),
515            r#"{"kind":"custom","before":1,"after":2}"#
516        );
517    }
518
519    #[test]
520    fn apply_root_kind_preserves_non_object_roots() {
521        let mut value = json!(["not", "an", "object"]);
522
523        apply_root_kind(&mut value, "example", RootEnvelopeMode::Tagged);
524
525        assert_eq!(value, json!(["not", "an", "object"]));
526    }
527
528    #[test]
529    fn attach_telemetry_meta_sets_analysis_run_id() {
530        let mut value = json!({});
531
532        attach_telemetry_meta(&mut value, Some("run-123"));
533
534        assert_eq!(
535            value["_meta"]["telemetry"]["analysis_run_id"],
536            json!("run-123")
537        );
538    }
539
540    #[test]
541    fn attach_telemetry_meta_preserves_non_object_roots() {
542        let mut value = json!(["not", "an", "object"]);
543
544        attach_telemetry_meta(&mut value, Some("run-123"));
545
546        assert_eq!(value, json!(["not", "an", "object"]));
547    }
548
549    #[test]
550    fn serialize_named_json_output_applies_explicit_kind() {
551        let value = serialize_named_json_output(
552            json!({
553                "schema_version": 1,
554                "summary": { "total": 0 }
555            }),
556            "example",
557            RootEnvelopeMode::Tagged,
558        )
559        .expect("named output should serialize");
560
561        assert_eq!(value["kind"], "example");
562        assert_eq!(value["summary"]["total"], 0);
563    }
564
565    #[test]
566    fn serialize_audit_json_output_applies_audit_kind() {
567        let value = serialize_audit_json_output(
568            AuditOutput {
569                gate_outcomes: None,
570                schema_version: SchemaVersion(7),
571                version: ToolVersion("1.2.3".to_string()),
572                command: AuditCommand::Audit,
573                verdict: "pass",
574                changed_files_count: 2,
575                base_ref: "origin/main".to_string(),
576                base_description: Some("merge-base with origin/main".to_string()),
577                head_sha: Some("abc123".to_string()),
578                elapsed_ms: ElapsedMs(42),
579                base_snapshot_skipped: Some(false),
580                summary: json!({ "dead_code_issues": 0 }),
581                attribution: json!({ "gate": "new_only" }),
582                meta: None,
583                dead_code: Some(json!({ "summary": { "total_issues": 0 } })),
584                duplication: None::<serde_json::Value>,
585                complexity: None::<serde_json::Value>,
586                next_steps: Vec::new(),
587            },
588            RootEnvelopeMode::Tagged,
589            Some("run-audit"),
590        )
591        .expect("audit output should serialize");
592
593        assert_eq!(value["kind"], "audit");
594        assert_eq!(value["command"], "audit");
595        assert_eq!(value["dead_code"]["summary"]["total_issues"], 0);
596        assert_eq!(value["_meta"]["telemetry"]["analysis_run_id"], "run-audit");
597    }
598
599    #[test]
600    fn serialize_combined_json_output_applies_combined_kind() {
601        let value = serialize_combined_json_output(
602            CombinedOutput {
603                gate_outcomes: None,
604                schema_version: SchemaVersion(7),
605                version: ToolVersion("1.2.3".to_string()),
606                elapsed_ms: ElapsedMs(42),
607                meta: None,
608                check: Some(json!({ "summary": { "total_issues": 0 } })),
609                dupes: None::<serde_json::Value>,
610                health: None::<serde_json::Value>,
611                workspace_diagnostics: Vec::new(),
612                next_steps: Vec::new(),
613            },
614            RootEnvelopeMode::Tagged,
615            Some("run-combined"),
616        )
617        .expect("combined output should serialize");
618
619        assert_eq!(value["kind"], "combined");
620        assert_eq!(value["check"]["summary"]["total_issues"], 0);
621        assert_eq!(
622            value["_meta"]["telemetry"]["analysis_run_id"],
623            "run-combined"
624        );
625    }
626}