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