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    TraceError,
350    ReviewEnvelope,
351    ReviewReconcile,
352    CoverageSetup,
353    CoverageAnalyze,
354    ListBoundaries,
355    Workspaces,
356    Health,
357    Dupes,
358    CheckGrouped,
359    ArchitectureGrouped,
360    Impact,
361    ImpactCrossRepo,
362    SecuritySummary,
363    Security,
364    SecuritySurvivors,
365    SecurityBlindSpots,
366    Check,
367    Architecture,
368    Combined,
369    FeatureFlags,
370    AuditBrief,
371    DecisionSurface,
372    WalkthroughGuide,
373    WalkthroughValidation,
374    SuppressionInventory,
375    Doctor,
376    TypeAwareStatus,
377    SimilarCode,
378    SimilarCodeInspect,
379    SimilarCodeReview,
380    SimilarCodeStatus,
381    SimilarCodeCacheClear,
382> {
383    /// `fallow audit --format json`.
384    #[serde(rename = "audit")]
385    Audit(Audit),
386    /// `fallow explain <issue-type> --format json`.
387    #[serde(rename = "explain")]
388    Explain(Explain),
389    /// `fallow inspect --format json`.
390    #[serde(rename = "inspect_target")]
391    Inspect(Inspect),
392    /// `fallow trace <symbol> --format json`.
393    #[serde(rename = "trace")]
394    Trace(Trace),
395    /// `fallow trace-error [FILE|-] --format json`.
396    #[serde(rename = "trace-error")]
397    TraceError(TraceError),
398    /// `fallow --format review-github` / `--format review-gitlab`.
399    #[serde(rename = "review-envelope")]
400    ReviewEnvelope(ReviewEnvelope),
401    /// `fallow ci reconcile-review --format json`.
402    #[serde(rename = "review-reconcile")]
403    ReviewReconcile(ReviewReconcile),
404    /// `fallow coverage setup --json`.
405    #[serde(rename = "coverage-setup")]
406    CoverageSetup(CoverageSetup),
407    /// `fallow coverage analyze --format json`.
408    #[serde(rename = "coverage-analyze")]
409    CoverageAnalyze(CoverageAnalyze),
410    /// `fallow list --boundaries --format json`.
411    #[serde(rename = "list-boundaries")]
412    ListBoundaries(ListBoundaries),
413    /// `fallow workspaces --format json`.
414    #[serde(rename = "list-workspaces")]
415    Workspaces(Workspaces),
416    /// `fallow health --format json`.
417    #[serde(rename = "health")]
418    Health(Health),
419    /// `fallow dupes --format json`.
420    #[serde(rename = "dupes")]
421    Dupes(Dupes),
422    /// `fallow dead-code --format json --group-by <mode>`.
423    #[serde(rename = "dead-code-grouped")]
424    CheckGrouped(CheckGrouped),
425    /// `fallow architecture --format json --group-by <mode>`.
426    #[serde(rename = "architecture-grouped")]
427    ArchitectureGrouped(ArchitectureGrouped),
428    /// `fallow impact --format json`.
429    #[serde(rename = "impact")]
430    Impact(Impact),
431    /// `fallow impact --all --format json`.
432    #[serde(rename = "impact-cross-repo")]
433    ImpactCrossRepo(ImpactCrossRepo),
434    /// `fallow security --summary --format json`.
435    #[serde(rename = "security")]
436    SecuritySummary(SecuritySummary),
437    /// `fallow security --format json`.
438    #[serde(rename = "security")]
439    Security(Security),
440    /// `fallow security survivors --format json`.
441    #[serde(rename = "security-survivors")]
442    SecuritySurvivors(SecuritySurvivors),
443    /// `fallow security blind-spots --format json`.
444    #[serde(rename = "security-blind-spots")]
445    SecurityBlindSpots(SecurityBlindSpots),
446    /// `fallow dead-code --format json`.
447    #[serde(rename = "dead-code")]
448    Check(Check),
449    /// `fallow architecture --format json`.
450    #[serde(rename = "architecture")]
451    Architecture(Architecture),
452    /// Bare `fallow --format json`.
453    #[serde(rename = "combined")]
454    Combined(Combined),
455    /// `fallow flags --format json`.
456    #[serde(rename = "feature-flags")]
457    FeatureFlags(FeatureFlags),
458    /// `fallow audit --brief --format json`.
459    #[serde(rename = "audit-brief")]
460    AuditBrief(AuditBrief),
461    /// `fallow decision-surface --format json`.
462    #[serde(rename = "decision-surface")]
463    DecisionSurface(DecisionSurface),
464    /// `fallow review --walkthrough-guide --format json`.
465    #[serde(rename = "review-walkthrough-guide")]
466    WalkthroughGuide(WalkthroughGuide),
467    /// `fallow review --walkthrough-file --format json`.
468    #[serde(rename = "review-walkthrough-validation")]
469    WalkthroughValidation(WalkthroughValidation),
470    /// `fallow suppressions --format json`.
471    #[serde(rename = "suppression-inventory")]
472    SuppressionInventory(SuppressionInventory),
473    /// `fallow doctor --format json`.
474    #[serde(rename = "doctor")]
475    Doctor(Doctor),
476    /// `fallow type-aware status --format json`.
477    #[serde(rename = "type-aware-status")]
478    TypeAwareStatus(TypeAwareStatus),
479    /// `fallow similar-code --format json`.
480    #[serde(rename = "similar-code")]
481    SimilarCode(SimilarCode),
482    /// `fallow similar-code inspect --format json`.
483    #[serde(rename = "similar-code-inspect")]
484    SimilarCodeInspect(SimilarCodeInspect),
485    /// `fallow similar-code review --format json`.
486    #[serde(rename = "similar-code-review")]
487    SimilarCodeReview(SimilarCodeReview),
488    /// `fallow similar-code status --format json` and successful setup output.
489    #[serde(rename = "similar-code-status")]
490    SimilarCodeStatus(SimilarCodeStatus),
491    /// `fallow similar-code cache clear --format json`.
492    #[serde(rename = "similar-code-cache-clear")]
493    SimilarCodeCacheClear(SimilarCodeCacheClear),
494}
495
496#[cfg(test)]
497mod tests {
498    use fallow_types::envelope::{ElapsedMs, SchemaVersion, ToolVersion};
499    use serde_json::json;
500
501    use super::*;
502
503    #[test]
504    fn apply_root_kind_sets_kind() {
505        let mut value = json!({});
506
507        apply_root_kind(&mut value, "dead_code");
508
509        assert_eq!(value["kind"], "dead_code");
510    }
511
512    #[test]
513    fn apply_root_kind_prepends_without_reordering_existing_fields() {
514        let mut value = json!({ "schema_version": 1, "summary": { "total": 0 } });
515
516        apply_root_kind(&mut value, "example");
517
518        assert_eq!(
519            serde_json::to_string(&value).expect("root output should serialize"),
520            r#"{"kind":"example","schema_version":1,"summary":{"total":0}}"#
521        );
522    }
523
524    #[test]
525    fn apply_root_kind_preserves_existing_value_and_moves_it_first() {
526        let mut value = json!({ "before": 1, "kind": "custom", "after": 2 });
527
528        apply_root_kind(&mut value, "replacement");
529
530        assert_eq!(
531            serde_json::to_string(&value).expect("root output should serialize"),
532            r#"{"kind":"custom","before":1,"after":2}"#
533        );
534    }
535
536    #[test]
537    fn apply_root_kind_preserves_non_object_roots() {
538        let mut value = json!(["not", "an", "object"]);
539
540        apply_root_kind(&mut value, "example");
541
542        assert_eq!(value, json!(["not", "an", "object"]));
543    }
544
545    #[test]
546    fn attach_telemetry_meta_sets_analysis_run_id() {
547        let mut value = json!({});
548
549        attach_telemetry_meta(&mut value, Some("run-123"));
550
551        assert_eq!(
552            value["_meta"]["telemetry"]["analysis_run_id"],
553            json!("run-123")
554        );
555    }
556
557    #[test]
558    fn attach_telemetry_meta_preserves_non_object_roots() {
559        let mut value = json!(["not", "an", "object"]);
560
561        attach_telemetry_meta(&mut value, Some("run-123"));
562
563        assert_eq!(value, json!(["not", "an", "object"]));
564    }
565
566    #[test]
567    fn serialize_named_json_output_applies_explicit_kind() {
568        let value = serialize_named_json_output(
569            json!({
570                "schema_version": 1,
571                "summary": { "total": 0 }
572            }),
573            "example",
574        )
575        .expect("named output should serialize");
576
577        assert_eq!(value["kind"], "example");
578        assert_eq!(value["summary"]["total"], 0);
579    }
580
581    #[test]
582    fn serialize_audit_json_output_applies_audit_kind() {
583        let value = serialize_audit_json_output(
584            AuditOutput {
585                gate_outcomes: None,
586                schema_version: SchemaVersion(7),
587                version: ToolVersion("1.2.3".to_string()),
588                command: AuditCommand::Audit,
589                verdict: "pass",
590                changed_files_count: 2,
591                base_ref: "origin/main".to_string(),
592                base_description: Some("merge-base with origin/main".to_string()),
593                head_sha: Some("abc123".to_string()),
594                elapsed_ms: ElapsedMs(42),
595                base_snapshot_skipped: Some(false),
596                summary: json!({ "dead_code_issues": 0 }),
597                attribution: json!({ "gate": "new_only" }),
598                meta: None,
599                dead_code: Some(json!({ "summary": { "total_issues": 0 } })),
600                duplication: None::<serde_json::Value>,
601                complexity: None::<serde_json::Value>,
602                next_steps: Vec::new(),
603            },
604            Some("run-audit"),
605        )
606        .expect("audit output should serialize");
607
608        assert_eq!(value["kind"], "audit");
609        assert_eq!(value["command"], "audit");
610        assert_eq!(value["dead_code"]["summary"]["total_issues"], 0);
611        assert_eq!(value["_meta"]["telemetry"]["analysis_run_id"], "run-audit");
612    }
613
614    #[test]
615    fn serialize_combined_json_output_applies_combined_kind() {
616        let value = serialize_combined_json_output(
617            CombinedOutput {
618                package_baselines: Vec::new(),
619                gate_outcomes: None,
620                request_outcomes: None,
621                schema_version: SchemaVersion(7),
622                version: ToolVersion("1.2.3".to_string()),
623                elapsed_ms: ElapsedMs(42),
624                meta: None,
625                check: Some(json!({ "summary": { "total_issues": 0 } })),
626                dupes: None::<serde_json::Value>,
627                health: None::<serde_json::Value>,
628                workspace_diagnostics: Vec::new(),
629                next_steps: Vec::new(),
630            },
631            Some("run-combined"),
632        )
633        .expect("combined output should serialize");
634
635        assert_eq!(value["kind"], "combined");
636        assert_eq!(value["check"]["summary"]["total_issues"], 0);
637        assert_eq!(
638            value["_meta"]["telemetry"]["analysis_run_id"],
639            "run-combined"
640        );
641    }
642}