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