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