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