Skip to main content

fallow_cli/
output_envelope.rs

1//! Typed envelope structs for the JSON output contract.
2//!
3//! This module is the schema-side source of truth for fallow's top-level JSON
4//! envelopes.
5
6use std::sync::Mutex;
7use std::sync::atomic::{AtomicBool, Ordering};
8
9use fallow_core::results::AnalysisResults;
10use fallow_types::envelope::{
11    BaselineDeltas, BaselineMatch, CheckSummary, ElapsedMs, EntryPoints, Meta, RegressionResult,
12    SchemaVersion, TelemetryMeta, ToolVersion,
13};
14use serde::Serialize;
15
16use crate::audit::{AuditAttribution, AuditSummary, AuditVerdict};
17use crate::health_types::{HealthGroup, HealthReport, RuntimeCoverageReport};
18use crate::output_dupes::DupesReportPayload;
19use crate::report::dupes_grouping::DuplicationGroup;
20
21static LEGACY_ENVELOPE: AtomicBool = AtomicBool::new(false);
22static TELEMETRY_ANALYSIS_RUN_ID: Mutex<Option<String>> = Mutex::new(None);
23
24#[derive(Debug, Clone, Copy, PartialEq, Eq)]
25pub enum EnvelopeMode {
26    Tagged,
27    Legacy,
28}
29
30impl EnvelopeMode {
31    #[must_use]
32    pub fn current() -> Self {
33        if LEGACY_ENVELOPE.load(Ordering::Relaxed) {
34            Self::Legacy
35        } else {
36            Self::Tagged
37        }
38    }
39}
40
41pub fn set_legacy_envelope(enabled: bool) {
42    LEGACY_ENVELOPE.store(enabled, Ordering::Relaxed);
43}
44
45pub fn set_telemetry_analysis_run_id(run_id: Option<String>) {
46    if let Ok(mut current) = TELEMETRY_ANALYSIS_RUN_ID.lock() {
47        *current = run_id;
48    }
49}
50
51fn telemetry_analysis_run_id() -> Option<String> {
52    TELEMETRY_ANALYSIS_RUN_ID
53        .lock()
54        .ok()
55        .and_then(|id| id.clone())
56}
57
58pub fn serialize_root_output(output: FallowOutput) -> Result<serde_json::Value, serde_json::Error> {
59    serialize_root_output_with_mode(output, EnvelopeMode::current())
60}
61
62pub fn serialize_root_output_without_telemetry(
63    output: FallowOutput,
64) -> Result<serde_json::Value, serde_json::Error> {
65    let mut value = serde_json::to_value(output)?;
66    if EnvelopeMode::current() == EnvelopeMode::Legacy {
67        remove_root_kind(&mut value);
68    }
69    Ok(value)
70}
71
72pub fn serialize_root_output_with_mode(
73    output: FallowOutput,
74    mode: EnvelopeMode,
75) -> Result<serde_json::Value, serde_json::Error> {
76    let mut value = serde_json::to_value(output)?;
77    if mode == EnvelopeMode::Legacy {
78        remove_root_kind(&mut value);
79    }
80    attach_telemetry_meta(&mut value);
81    Ok(value)
82}
83
84pub fn attach_telemetry_meta(value: &mut serde_json::Value) {
85    let Some(run_id) = telemetry_analysis_run_id() else {
86        return;
87    };
88    let serde_json::Value::Object(map) = value else {
89        return;
90    };
91    let meta = map
92        .entry("_meta".to_string())
93        .or_insert_with(|| serde_json::Value::Object(serde_json::Map::new()));
94    if !meta.is_object() {
95        *meta = serde_json::Value::Object(serde_json::Map::new());
96    }
97    if let serde_json::Value::Object(meta_map) = meta {
98        meta_map.insert(
99            "telemetry".to_string(),
100            serde_json::json!({ "analysis_run_id": run_id }),
101        );
102    }
103}
104
105/// Remove only the document-root discriminator for the one-cycle
106/// compatibility mode. Nested objects may carry their own meaningful `kind`
107/// fields, so this intentionally does not recurse.
108pub fn remove_root_kind(value: &mut serde_json::Value) {
109    if let serde_json::Value::Object(map) = value {
110        map.remove("kind");
111    }
112}
113
114pub fn apply_root_kind(value: &mut serde_json::Value, kind: &'static str) {
115    if EnvelopeMode::current() == EnvelopeMode::Tagged
116        && let serde_json::Value::Object(map) = value
117    {
118        map.insert(
119            "kind".to_string(),
120            serde_json::Value::String(kind.to_string()),
121        );
122    }
123}
124/// `fallow coverage setup --json` envelope.
125#[derive(Debug, Clone, Serialize)]
126#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
127#[cfg_attr(feature = "schema", schemars(title = "fallow coverage setup --json"))]
128pub struct CoverageSetupOutput {
129    pub schema_version: CoverageSetupSchemaVersion,
130    pub framework_detected: CoverageSetupFramework,
131    pub package_manager: Option<CoverageSetupPackageManager>,
132    pub runtime_targets: Vec<CoverageSetupRuntimeTarget>,
133    pub members: Vec<CoverageSetupMember>,
134    pub config_written: Option<serde_json::Value>,
135    pub commands: Vec<String>,
136    pub files_to_edit: Vec<CoverageSetupFileToEdit>,
137    pub snippets: Vec<CoverageSetupSnippet>,
138    pub dockerfile_snippet: Option<String>,
139    pub next_steps: Vec<String>,
140    pub warnings: Vec<String>,
141    #[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")]
142    pub meta: Option<serde_json::Value>,
143}
144
145#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
146#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
147pub enum CoverageSetupSchemaVersion {
148    #[serde(rename = "1")]
149    V1,
150}
151
152#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
153#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
154#[serde(rename_all = "snake_case")]
155pub enum CoverageSetupFramework {
156    #[serde(rename = "nextjs")]
157    NextJs,
158    #[serde(rename = "nestjs")]
159    NestJs,
160    Nuxt,
161    #[serde(rename = "sveltekit")]
162    SvelteKit,
163    Astro,
164    Remix,
165    Vite,
166    PlainNode,
167    Unknown,
168}
169
170#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
171#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
172#[serde(rename_all = "lowercase")]
173pub enum CoverageSetupPackageManager {
174    Npm,
175    Pnpm,
176    Yarn,
177    Bun,
178}
179
180#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
181#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
182#[serde(rename_all = "lowercase")]
183pub enum CoverageSetupRuntimeTarget {
184    Node,
185    Browser,
186}
187
188#[derive(Debug, Clone, Serialize)]
189#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
190pub struct CoverageSetupMember {
191    pub name: String,
192    pub path: String,
193    pub framework_detected: CoverageSetupFramework,
194    pub package_manager: Option<CoverageSetupPackageManager>,
195    pub runtime_targets: Vec<CoverageSetupRuntimeTarget>,
196    pub files_to_edit: Vec<CoverageSetupFileToEdit>,
197    pub snippets: Vec<CoverageSetupSnippet>,
198    pub dockerfile_snippet: Option<String>,
199    pub warnings: Vec<String>,
200}
201
202#[derive(Debug, Clone, Serialize)]
203#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
204pub struct CoverageSetupFileToEdit {
205    pub path: String,
206    pub reason: String,
207}
208
209#[derive(Debug, Clone, Serialize)]
210#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
211pub struct CoverageSetupSnippet {
212    pub label: String,
213    pub path: String,
214    pub content: String,
215}
216
217/// `fallow audit --format json` envelope.
218#[derive(Debug, Clone, Serialize)]
219#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
220#[cfg_attr(feature = "schema", schemars(title = "fallow audit --format json"))]
221#[allow(
222    dead_code,
223    reason = "schema-source-of-truth: audit.rs still builds the wire via serde_json::json!; this struct locks the schema shape via the drift gate. Migration is a follow-up to issue #384 items 3a/3b/3c."
224)]
225pub struct AuditOutput {
226    pub schema_version: SchemaVersion,
227    pub version: ToolVersion,
228    pub command: AuditCommand,
229    pub verdict: AuditVerdict,
230    pub changed_files_count: u32,
231    pub base_ref: String,
232    /// Human-readable provenance of `base_ref`, e.g. `merge-base with
233    /// origin/main`, `local main`, or `FALLOW_AUDIT_BASE=upstream/main`.
234    /// Present when the base was auto-detected or set via `FALLOW_AUDIT_BASE`;
235    /// absent for an explicit `--base` (the ref the user typed is already
236    /// self-describing).
237    #[serde(default, skip_serializing_if = "Option::is_none")]
238    pub base_description: Option<String>,
239    #[serde(default, skip_serializing_if = "Option::is_none")]
240    pub head_sha: Option<String>,
241    pub elapsed_ms: ElapsedMs,
242    #[serde(default, skip_serializing_if = "Option::is_none")]
243    pub base_snapshot_skipped: Option<bool>,
244    pub summary: AuditSummary,
245    pub attribution: AuditAttribution,
246    #[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")]
247    pub meta: Option<Meta>,
248    #[serde(default, skip_serializing_if = "Option::is_none")]
249    pub dead_code: Option<CheckOutput>,
250    #[serde(default, skip_serializing_if = "Option::is_none")]
251    pub duplication: Option<DupesReportPayload>,
252    #[serde(default, skip_serializing_if = "Option::is_none")]
253    pub complexity: Option<HealthReport>,
254}
255
256#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
257#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
258#[serde(rename_all = "lowercase")]
259#[allow(dead_code, reason = "schema-source-of-truth: see `AuditOutput`.")]
260pub enum AuditCommand {
261    Audit,
262}
263
264/// Bare `fallow --format json` envelope.
265#[derive(Debug, Clone, Serialize)]
266#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
267#[cfg_attr(
268    feature = "schema",
269    schemars(title = "fallow --format json (bare, combined)")
270)]
271pub struct CombinedOutput {
272    pub schema_version: SchemaVersion,
273    pub version: ToolVersion,
274    pub elapsed_ms: ElapsedMs,
275    #[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")]
276    pub meta: Option<CombinedMeta>,
277    #[serde(default, skip_serializing_if = "Option::is_none")]
278    pub check: Option<CheckOutput>,
279    #[serde(default, skip_serializing_if = "Option::is_none")]
280    pub dupes: Option<DupesReportPayload>,
281    #[serde(default, skip_serializing_if = "Option::is_none")]
282    pub health: Option<HealthReport>,
283}
284
285#[derive(Debug, Clone, Serialize)]
286#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
287pub struct CombinedMeta {
288    #[serde(default, skip_serializing_if = "Option::is_none")]
289    pub check: Option<Meta>,
290    #[serde(default, skip_serializing_if = "Option::is_none")]
291    pub dupes: Option<Meta>,
292    #[serde(default, skip_serializing_if = "Option::is_none")]
293    pub health: Option<Meta>,
294    #[serde(default, skip_serializing_if = "Option::is_none")]
295    pub telemetry: Option<TelemetryMeta>,
296}
297
298#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
299#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
300pub enum CoverageAnalyzeSchemaVersion {
301    #[serde(rename = "1")]
302    V1,
303}
304
305#[derive(Debug, Clone, Serialize)]
306#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
307#[cfg_attr(
308    feature = "schema",
309    schemars(title = "fallow coverage analyze --format json")
310)]
311pub struct CoverageAnalyzeOutput {
312    pub schema_version: CoverageAnalyzeSchemaVersion,
313    pub version: ToolVersion,
314    pub elapsed_ms: ElapsedMs,
315    pub runtime_coverage: RuntimeCoverageReport,
316    #[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")]
317    pub meta: Option<Meta>,
318}
319
320#[derive(Debug, Clone, Serialize)]
321#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
322#[cfg_attr(feature = "schema", schemars(title = "fallow dupes --format json"))]
323pub struct DupesOutput {
324    pub schema_version: SchemaVersion,
325    pub version: ToolVersion,
326    pub elapsed_ms: ElapsedMs,
327    #[serde(flatten)]
328    pub report: DupesReportPayload,
329    #[serde(default, skip_serializing_if = "Option::is_none")]
330    pub grouped_by: Option<GroupByMode>,
331    #[serde(default, skip_serializing_if = "Option::is_none")]
332    pub total_issues: Option<usize>,
333    #[serde(default, skip_serializing_if = "Option::is_none")]
334    pub groups: Option<Vec<DuplicationGroup>>,
335    /// `_meta` block with metric / rule definitions, emitted when `--explain`
336    /// is passed (always present in MCP responses).
337    #[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")]
338    pub meta: Option<Meta>,
339    /// Workspace-discovery diagnostics surfaced during config load
340    /// (issue #473). See [`CheckOutput::workspace_diagnostics`] for the full
341    /// contract; the same list is repeated on each top-level command's
342    /// envelope so single-command consumers see it without having to look at
343    /// a separate top-level field.
344    #[serde(default, skip_serializing_if = "Vec::is_empty")]
345    pub workspace_diagnostics: Vec<fallow_config::WorkspaceDiagnostic>,
346}
347
348/// Envelope emitted by `fallow dead-code --format json` (plus the `check`
349/// block inside the combined and audit envelopes).
350///
351/// The body is the full `AnalysisResults` flattened into the envelope so
352/// every issue array (`unused_files`, `unused_exports`, ...) lives at the
353/// top level, matching the existing wire shape. `entry_points` lifts the
354/// otherwise `#[serde(skip)]`'d `AnalysisResults::entry_point_summary` back
355/// into the JSON output. `summary` carries the per-category counts the
356/// JSON layer always emits.
357#[derive(Debug, Clone, Serialize)]
358#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
359#[cfg_attr(feature = "schema", schemars(title = "fallow dead-code --format json"))]
360pub struct CheckOutput {
361    pub schema_version: SchemaVersion,
362    pub version: ToolVersion,
363    pub elapsed_ms: ElapsedMs,
364    pub total_issues: usize,
365    #[serde(default, skip_serializing_if = "Option::is_none")]
366    pub entry_points: Option<EntryPoints>,
367    pub summary: CheckSummary,
368    #[serde(flatten)]
369    pub results: AnalysisResults,
370    #[serde(default, skip_serializing_if = "Option::is_none")]
371    pub baseline_deltas: Option<BaselineDeltas>,
372    #[serde(default, skip_serializing_if = "Option::is_none")]
373    pub baseline: Option<BaselineMatch>,
374    #[serde(default, skip_serializing_if = "Option::is_none")]
375    pub regression: Option<RegressionResult>,
376    #[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")]
377    pub meta: Option<Meta>,
378    #[serde(default, skip_serializing_if = "Vec::is_empty")]
379    pub workspace_diagnostics: Vec<fallow_config::WorkspaceDiagnostic>,
380}
381
382/// Envelope emitted by `fallow dead-code --group-by ... --format json`.
383///
384/// Issues are partitioned into resolver buckets (CODEOWNERS team, directory
385/// prefix, workspace package, or GitLab CODEOWNERS section) instead of flat
386/// arrays. Each bucket carries the same issue-array shape as the ungrouped
387/// `CheckOutput` body, plus per-group `key` / `owners` / `total_issues`.
388#[derive(Debug, Clone, Serialize)]
389#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
390#[cfg_attr(
391    feature = "schema",
392    schemars(
393        title = "fallow dead-code --group-by <owner|directory|package|section> --format json"
394    )
395)]
396pub struct CheckGroupedOutput {
397    pub schema_version: SchemaVersion,
398    pub version: ToolVersion,
399    pub elapsed_ms: ElapsedMs,
400    pub grouped_by: GroupByMode,
401    pub total_issues: usize,
402    pub groups: Vec<CheckGroupedEntry>,
403    #[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")]
404    pub meta: Option<Meta>,
405}
406
407/// Single resolver bucket inside `CheckGroupedOutput`. Carries the group's
408/// identifier, optional section owners, and a per-group flattened
409/// `AnalysisResults`.
410#[derive(Debug, Clone, Serialize)]
411#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
412pub struct CheckGroupedEntry {
413    pub key: String,
414    #[serde(default, skip_serializing_if = "Option::is_none")]
415    pub owners: Option<Vec<String>>,
416    pub total_issues: usize,
417    #[serde(flatten)]
418    pub results: AnalysisResults,
419}
420
421/// Envelope emitted by `fallow health --format json` (plus the `health` block
422/// inside the combined and audit envelopes).
423///
424/// The body is `HealthReport` flattened into the envelope so every report
425/// field (`findings`, `summary`, `vital_signs`, `hotspots`, `actions_meta`,
426/// ...) lives at the top level. Grouped runs populate `grouped_by` +
427/// `groups` with per-bucket recomputed metrics. The `actions_meta`
428/// breadcrumb is modeled on `HealthReport` as an `Option<HealthActionsMeta>`
429/// and is set at construction time by the report builder when the active
430/// `HealthActionContext` requests suppress-line omission, so the schema
431/// documents the field and serde populates it natively.
432#[derive(Debug, Clone, Serialize)]
433#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
434#[cfg_attr(feature = "schema", schemars(title = "fallow health --format json"))]
435pub struct HealthOutput {
436    pub schema_version: SchemaVersion,
437    pub version: ToolVersion,
438    pub elapsed_ms: ElapsedMs,
439    #[serde(flatten)]
440    pub report: HealthReport,
441    #[serde(default, skip_serializing_if = "Option::is_none")]
442    pub grouped_by: Option<GroupByMode>,
443    #[serde(default, skip_serializing_if = "Option::is_none")]
444    pub groups: Option<Vec<HealthGroup>>,
445    #[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")]
446    pub meta: Option<Meta>,
447    #[serde(default, skip_serializing_if = "Vec::is_empty")]
448    pub workspace_diagnostics: Vec<fallow_config::WorkspaceDiagnostic>,
449}
450
451/// Envelope emitted by `fallow explain <issue-type> --format json`.
452///
453/// Standalone rule explanation. This command does not run project analysis
454/// and intentionally returns a compact object without `schema_version` /
455/// `version` metadata; consumers that need those should call any other
456/// fallow JSON-producing command.
457#[derive(Debug, Clone, Serialize)]
458#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
459#[cfg_attr(
460    feature = "schema",
461    schemars(title = "fallow explain <issue-type> --format json")
462)]
463pub struct ExplainOutput {
464    pub id: String,
465    pub name: String,
466    pub summary: String,
467    pub rationale: String,
468    pub example: String,
469    pub how_to_fix: String,
470    pub docs: String,
471}
472
473/// Envelope emitted by `fallow --format codeclimate` and
474/// `fallow --format gitlab-codequality`. GitLab Code Quality consumes the
475/// same shape. The wire form is a bare JSON array, not an object.
476#[derive(Debug, Clone, Serialize)]
477#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
478#[cfg_attr(
479    feature = "schema",
480    schemars(title = "fallow --format codeclimate / gitlab-codequality")
481)]
482#[serde(transparent)]
483#[allow(
484    dead_code,
485    reason = "schema-source-of-truth wrapper: runtime emits a `Vec<CodeClimateIssue>` directly via `codeclimate::issues_to_value`; this newtype exists so `schemars` can title and document the bare-array shape for the drift gate."
486)]
487pub struct CodeClimateOutput(pub Vec<CodeClimateIssue>);
488
489/// Single CodeClimate-compatible issue inside [`CodeClimateOutput`].
490#[derive(Debug, Clone, Serialize)]
491#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
492pub struct CodeClimateIssue {
493    #[serde(rename = "type")]
494    pub kind: CodeClimateIssueKind,
495    pub check_name: String,
496    pub description: String,
497    pub categories: Vec<String>,
498    pub severity: CodeClimateSeverity,
499    pub fingerprint: String,
500    pub location: CodeClimateLocation,
501}
502
503/// Discriminator value for [`CodeClimateIssue::kind`].
504#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
505#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
506#[serde(rename_all = "lowercase")]
507pub enum CodeClimateIssueKind {
508    /// The only valid CodeClimate type today.
509    Issue,
510}
511
512/// CodeClimate severity scale.
513#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
514#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
515#[serde(rename_all = "lowercase")]
516pub enum CodeClimateSeverity {
517    /// Informational. Reserved for future severity mappings; not produced
518    /// by the current runtime path (which only emits Minor / Major /
519    /// Critical via `severity_to_codeclimate` and the health / runtime-
520    /// coverage match arms).
521    #[allow(
522        dead_code,
523        reason = "schema-source-of-truth: documents the full CodeClimate severity spec; runtime never produces this variant today, but the schema needs it so consumers can validate against either fallow output or a third-party CodeClimate emitter without spec divergence."
524    )]
525    Info,
526    /// Minor finding.
527    Minor,
528    /// Major finding.
529    Major,
530    /// Critical finding.
531    Critical,
532    /// Blocker (highest severity). Reserved for future severity
533    /// mappings; not produced by the current runtime path.
534    #[allow(
535        dead_code,
536        reason = "schema-source-of-truth: documents the full CodeClimate severity spec; runtime never produces this variant today, but the schema needs it so consumers can validate against either fallow output or a third-party CodeClimate emitter without spec divergence."
537    )]
538    Blocker,
539}
540
541/// Location block inside [`CodeClimateIssue::location`].
542#[derive(Debug, Clone, Serialize)]
543#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
544pub struct CodeClimateLocation {
545    /// File path relative to the analysed root.
546    pub path: String,
547    /// Wrapper carrying the begin line so the schema lines up with
548    /// CodeClimate's spec.
549    pub lines: CodeClimateLines,
550}
551
552/// `lines.begin` for [`CodeClimateLocation`].
553#[derive(Debug, Clone, Copy, Serialize)]
554#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
555pub struct CodeClimateLines {
556    /// 1-based start line.
557    pub begin: u32,
558}
559
560/// Envelope emitted by `fallow --format review-github` / `review-gitlab`.
561#[derive(Debug, Clone, Serialize)]
562#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
563#[cfg_attr(
564    feature = "schema",
565    schemars(title = "fallow --format review-github / review-gitlab")
566)]
567pub struct ReviewEnvelopeOutput {
568    #[serde(default, skip_serializing_if = "Option::is_none")]
569    pub event: Option<ReviewEnvelopeEvent>,
570    pub body: String,
571    #[serde(default = "ReviewEnvelopeSummary::empty_default")]
572    pub summary: ReviewEnvelopeSummary,
573    pub comments: Vec<ReviewComment>,
574    #[serde(default = "default_marker_regex")]
575    pub marker_regex: String,
576    #[serde(default = "default_marker_regex_flags")]
577    pub marker_regex_flags: String,
578    pub meta: ReviewEnvelopeMeta,
579}
580
581/// Default for [`ReviewEnvelopeOutput::marker_regex`].
582#[must_use]
583pub fn default_marker_regex() -> String {
584    MARKER_REGEX_V2.to_owned()
585}
586
587/// Default for [`ReviewEnvelopeOutput::marker_regex_flags`].
588#[must_use]
589pub fn default_marker_regex_flags() -> String {
590    MARKER_REGEX_FLAGS_V2.to_owned()
591}
592
593/// Canonical v2 marker-regex literal.
594pub const MARKER_REGEX_V2: &str =
595    r"^<!-- fallow-fingerprint:v2: ((?:[a-z]+:)?[0-9a-f]{16}) -->\s*$";
596
597/// Canonical v2 marker-regex flags.
598pub const MARKER_REGEX_FLAGS_V2: &str = "m";
599
600/// Summary block on [`ReviewEnvelopeOutput`].
601#[derive(Debug, Clone, Serialize, Default)]
602#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
603pub struct ReviewEnvelopeSummary {
604    pub body: String,
605    pub fingerprint: String,
606}
607
608impl ReviewEnvelopeSummary {
609    /// Empty-default factory for [`ReviewEnvelopeOutput::summary`].
610    #[must_use]
611    #[allow(
612        dead_code,
613        reason = "referenced via serde default = \"...\" attr; no direct callsite until Deserialize is derived"
614    )]
615    pub fn empty_default() -> Self {
616        Self::default()
617    }
618}
619
620/// Singleton GitHub review-event marker.
621#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
622#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
623pub enum ReviewEnvelopeEvent {
624    #[serde(rename = "COMMENT")]
625    Comment,
626}
627
628/// Per-line review comment. Schema is an `anyOf` between GitHub and GitLab
629/// shapes; at runtime every entry in a single envelope comes from the same
630/// provider because the envelope is built from one provider's branch in
631/// `crates/cli/src/report/ci/review.rs::render_review_envelope`.
632#[derive(Debug, Clone, Serialize)]
633#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
634#[serde(untagged)]
635pub enum ReviewComment {
636    GitHub(GitHubReviewComment),
637    GitLab(GitLabReviewComment),
638}
639
640/// GitHub pull-request review comment.
641#[derive(Debug, Clone, Serialize)]
642#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
643pub struct GitHubReviewComment {
644    pub path: String,
645    pub line: u32,
646    pub side: GitHubReviewSide,
647    pub body: String,
648    pub fingerprint: String,
649    #[serde(default, skip_serializing_if = "is_false")]
650    pub truncated: bool,
651}
652
653/// Singleton side discriminator for [`GitHubReviewComment::side`].
654#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
655#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
656pub enum GitHubReviewSide {
657    #[serde(rename = "RIGHT")]
658    Right,
659}
660
661/// GitLab merge-request discussion comment.
662#[derive(Debug, Clone, Serialize)]
663#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
664pub struct GitLabReviewComment {
665    pub body: String,
666    pub position: GitLabReviewPosition,
667    pub fingerprint: String,
668    #[serde(default, skip_serializing_if = "is_false")]
669    pub truncated: bool,
670}
671
672/// Helper for `skip_serializing_if = "is_false"` on `truncated` fields above.
673/// Serde calls `skip_serializing_if` with `&T`, so the reference signature
674/// is dictated by the trait and cannot be changed to pass-by-value. Uses
675/// `#[allow]` rather than `#[expect]` per `.claude/rules/code-quality.md`:
676/// `trivially_copy_pass_by_ref` is a pedantic lint that fires inconsistently
677/// across build configurations (lib vs bin), which would trigger
678/// `unfulfilled_lint_expectations` under `#[expect]`.
679#[must_use]
680#[allow(
681    clippy::trivially_copy_pass_by_ref,
682    reason = "serde's skip_serializing_if requires fn(&T) -> bool"
683)]
684pub fn is_false(value: &bool) -> bool {
685    !*value
686}
687
688/// `position` block inside [`GitLabReviewComment`]. Mirrors the GitLab
689/// merge-request discussion-position API.
690#[derive(Debug, Clone, Serialize)]
691#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
692pub struct GitLabReviewPosition {
693    #[serde(default, skip_serializing_if = "Option::is_none")]
694    pub base_sha: Option<String>,
695    #[serde(default, skip_serializing_if = "Option::is_none")]
696    pub start_sha: Option<String>,
697    #[serde(default, skip_serializing_if = "Option::is_none")]
698    pub head_sha: Option<String>,
699    pub position_type: GitLabReviewPositionType,
700    pub old_path: String,
701    pub new_path: String,
702    pub new_line: u32,
703}
704
705/// Singleton position-type discriminator for [`GitLabReviewPosition`].
706#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
707#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
708#[serde(rename_all = "lowercase")]
709pub enum GitLabReviewPositionType {
710    Text,
711}
712
713/// `meta` block inside [`ReviewEnvelopeOutput`].
714#[derive(Debug, Clone, Serialize)]
715#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
716pub struct ReviewEnvelopeMeta {
717    pub schema: ReviewEnvelopeSchema,
718    pub provider: ReviewProvider,
719    #[serde(default, skip_serializing_if = "Option::is_none")]
720    pub check_conclusion: Option<ReviewCheckConclusion>,
721}
722
723/// Schema-version discriminator for the review envelope.
724#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
725#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
726pub enum ReviewEnvelopeSchema {
727    /// First release of the review envelope format. Historical only; no v1
728    /// emit path remains on the current code. Retained on the enum so a
729    /// future Deserialize derive can still parse v1 captures (e.g. from
730    /// committed snapshots predating the issue #528 migration) without
731    /// erroring on an unknown variant.
732    #[serde(rename = "fallow-review-envelope/v1")]
733    #[allow(
734        dead_code,
735        reason = "kept for forward-compat with v1 historical inputs once Deserialize is derived"
736    )]
737    V1,
738    /// Issue #528 evolution. Adds (1) the [`ReviewEnvelopeOutput::summary`]
739    /// block, (2) [`ReviewEnvelopeOutput::marker_regex`], (3) same-line
740    /// `(path, line)` merging in `comments[]` with a
741    /// `merged:<16-char hash>` primary fingerprint over sorted constituent
742    /// fingerprints (identity shifts whenever the set of constituents
743    /// changes, so the bundled skip-if-fingerprint-exists wrappers
744    /// correctly re-post on content change), (4) UTF-8-safe body
745    /// truncation at the GitLab/GitHub note-size floor (65,536 bytes)
746    /// with paired `truncated: bool` + `<!-- fallow-truncated -->`
747    /// signals, (5) `:v2:`-namespaced marker shape
748    /// (`<!-- fallow-fingerprint:v2: <fingerprint> -->`) preventing v1
749    /// marker collision and user-paste spoofing, and (6) diff-aware
750    /// `position.old_path` for renamed files on GitLab.
751    #[serde(rename = "fallow-review-envelope/v2")]
752    V2,
753}
754
755/// Review-envelope provider tag.
756#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
757#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
758#[serde(rename_all = "lowercase")]
759pub enum ReviewProvider {
760    /// GitHub pull-request review envelope.
761    Github,
762    /// GitLab merge-request discussion envelope.
763    Gitlab,
764}
765
766/// `meta.check_conclusion` for the GitHub review envelope. Maps to the
767/// GitHub Checks API conclusion field.
768#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
769#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
770#[serde(rename_all = "lowercase")]
771pub enum ReviewCheckConclusion {
772    /// No findings.
773    Success,
774    /// Findings but none gated as failure.
775    Neutral,
776    /// At least one finding gated as failure.
777    Failure,
778}
779
780/// Envelope emitted by `fallow ci reconcile-review --format json`. Used by
781/// CI integrations to drive comment carry-over and stale-comment cleanup
782/// across PR / MR revisions.
783#[derive(Debug, Clone, Serialize)]
784#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
785#[cfg_attr(
786    feature = "schema",
787    schemars(title = "fallow ci reconcile-review --format json")
788)]
789pub struct ReviewReconcileOutput {
790    pub schema: ReviewReconcileSchema,
791    pub provider: ReviewProvider,
792    pub target: Option<String>,
793    pub dry_run: bool,
794    pub comments: u32,
795    pub current_fingerprints: u32,
796    pub existing_fingerprints: u32,
797    pub new_fingerprints: u32,
798    pub stale_fingerprints: u32,
799    pub new: Vec<String>,
800    pub stale: Vec<String>,
801    pub provider_warning: Option<String>,
802    pub resolution_comments_posted: u32,
803    pub threads_resolved: u32,
804    #[serde(default, skip_serializing_if = "Option::is_none")]
805    pub apply_hint: Option<String>,
806    pub apply_errors: Vec<String>,
807    #[serde(default, skip_serializing_if = "Vec::is_empty")]
808    pub failed_fingerprints: Vec<String>,
809    #[serde(default, skip_serializing_if = "Vec::is_empty")]
810    pub unapplied_fingerprints: Vec<String>,
811}
812
813/// Schema-version discriminator for the review reconcile envelope.
814#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
815#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
816pub enum ReviewReconcileSchema {
817    /// First release of the review reconcile format.
818    #[serde(rename = "fallow-review-reconcile/v1")]
819    V1,
820}
821
822/// Resolver mode label for grouped envelopes (dead-code, dupes, health).
823///
824/// `owner` groups by CODEOWNERS team, `directory` groups by top-level
825/// directory prefix, `package` groups by workspace package name, `section`
826/// groups by GitLab CODEOWNERS `[Section]` header name.
827#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
828#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
829#[serde(rename_all = "lowercase")]
830pub enum GroupByMode {
831    Owner,
832    Directory,
833    Package,
834    Section,
835}
836/// Envelope emitted by `fallow list --boundaries --format json`. Surfaces
837/// the architecture boundary zones, rules, and (issue #373) the user's
838/// pre-expansion `autoDiscover` logical groups so consumers can render
839/// grouping intent that `expand_auto_discover` would otherwise flatten out
840/// of `zones[]`.
841#[derive(Debug, Clone, Serialize)]
842#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
843#[cfg_attr(
844    feature = "schema",
845    schemars(title = "fallow list --boundaries --format json")
846)]
847#[allow(
848    dead_code,
849    reason = "schema-source-of-truth: list.rs still builds the wire via serde_json::json!; this struct and its sub-types lock the schema shape via the drift gate. Migration is a follow-up to issue #384 items 3a/3b/3c."
850)]
851pub struct ListBoundariesOutput {
852    pub boundaries: BoundariesListing,
853}
854
855/// `fallow workspaces --format json` envelope.
856#[derive(Debug, Clone, Serialize)]
857#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
858#[cfg_attr(
859    feature = "schema",
860    schemars(title = "fallow workspaces --format json")
861)]
862pub struct WorkspacesOutput {
863    /// Number of workspace package entries in `workspaces`.
864    pub workspace_count: usize,
865    /// Workspace packages discovered from package manager and tsconfig workspace
866    /// declarations. Paths are project-root-relative and use forward slashes.
867    pub workspaces: Vec<WorkspaceInfo>,
868    /// Workspace discovery diagnostics produced while reading workspace
869    /// declarations. Present for compatibility with the current wire contract,
870    /// even when empty.
871    pub workspace_diagnostics: Vec<fallow_config::WorkspaceDiagnostic>,
872}
873
874/// One workspace package emitted by `fallow workspaces --format json`.
875#[derive(Debug, Clone, Serialize)]
876#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
877pub struct WorkspaceInfo {
878    /// Package name from the workspace package.json. This is the value accepted
879    /// by `--workspace <name>`.
880    pub name: String,
881    /// Project-root-relative path to the workspace directory, normalized to
882    /// forward slashes for cross-platform JSON consumers.
883    pub path: String,
884    /// Whether the package is a generated or platform-specific dependency
885    /// package rather than a hand-authored workspace.
886    pub is_internal_dependency: bool,
887}
888
889/// `boundaries` block carried by [`ListBoundariesOutput`].
890#[derive(Debug, Clone, Serialize)]
891#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
892#[allow(
893    dead_code,
894    reason = "schema-source-of-truth: see `ListBoundariesOutput`."
895)]
896pub struct BoundariesListing {
897    pub configured: bool,
898    pub zone_count: usize,
899    pub zones: Vec<BoundariesListZone>,
900    pub rule_count: usize,
901    pub rules: Vec<BoundariesListRule>,
902    pub logical_group_count: usize,
903    pub logical_groups: Vec<BoundariesListLogicalGroup>,
904}
905
906/// A boundary zone after preset and `autoDiscover` expansion. Each entry
907/// classifies files into a single zone via glob patterns.
908#[derive(Debug, Clone, Serialize)]
909#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
910#[allow(
911    dead_code,
912    reason = "schema-source-of-truth: see `ListBoundariesOutput`."
913)]
914pub struct BoundariesListZone {
915    pub name: String,
916    pub patterns: Vec<String>,
917    pub file_count: usize,
918}
919
920/// A boundary import rule, expanded to operate on concrete child zone
921/// names after `autoDiscover` flattening. The user's pre-expansion rule
922/// (keyed on the logical parent name, if any) is preserved on the
923/// corresponding [`BoundariesListLogicalGroup::authored_rule`].
924#[derive(Debug, Clone, Serialize)]
925#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
926#[allow(
927    dead_code,
928    reason = "schema-source-of-truth: see `ListBoundariesOutput`."
929)]
930pub struct BoundariesListRule {
931    pub from: String,
932    pub allow: Vec<String>,
933}
934
935/// A pre-expansion `autoDiscover` logical group surfaced for observability
936/// (issue #373). Captured during `expand_auto_discover` so consumers can
937/// see the user-authored parent name and grouping intent after expansion
938/// would otherwise flatten it out of [`BoundariesListing::zones`].
939#[derive(Debug, Clone, Serialize)]
940#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
941#[allow(
942    dead_code,
943    reason = "schema-source-of-truth: see `ListBoundariesOutput`."
944)]
945pub struct BoundariesListLogicalGroup {
946    pub name: String,
947    pub children: Vec<String>,
948    pub auto_discover: Vec<String>,
949    pub status: fallow_config::LogicalGroupStatus,
950    pub source_zone_index: usize,
951    pub file_count: usize,
952    #[serde(default, skip_serializing_if = "Option::is_none")]
953    pub authored_rule: Option<fallow_config::AuthoredRule>,
954    #[serde(default, skip_serializing_if = "Option::is_none")]
955    pub fallback_zone: Option<String>,
956    #[serde(default, skip_serializing_if = "Option::is_none")]
957    pub merged_from: Option<Vec<usize>>,
958    #[serde(default, skip_serializing_if = "Option::is_none")]
959    pub original_zone_root: Option<String>,
960    #[serde(default, skip_serializing_if = "Vec::is_empty")]
961    pub child_source_indices: Vec<usize>,
962}
963
964/// Typed root of every fallow JSON envelope shape that serializes as a JSON
965/// object and participates in the documented `FallowOutput` contract. The
966/// schema derived from this enum drives the document-root `oneOf` in
967/// `docs/output-schema.json`.
968///
969/// The default wire shape now carries a top-level `kind` discriminator so
970/// agents and schema-validating clients can select the variant in O(1) instead
971/// of probing for unique field presence. `--legacy-envelope` is a one-cycle
972/// compatibility flag that removes only this document-root `kind` field from
973/// CLI JSON output; nested report objects are not rewritten.
974///
975/// One envelope is intentionally NOT in this enum:
976/// - `CodeClimateOutput` serializes as a bare JSON array
977///   (`#[serde(transparent)]`) per the Code Climate / GitLab Code Quality
978///   spec; `#[serde(tag = ...)]` cannot internally tag a non-object
979///   variant and wrapping the array would break the spec. The root schema
980///   carries it as a sibling `oneOf` branch alongside `FallowOutput`.
981#[derive(Debug, Clone, Serialize)]
982#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
983#[cfg_attr(
984    feature = "schema",
985    schemars(title = "fallow --format json (typed root)")
986)]
987#[serde(tag = "kind")]
988#[allow(
989    dead_code,
990    reason = "some variants are schema-emit only, but runtime roots serialize through this enum where practical"
991)]
992pub enum FallowOutput {
993    /// `fallow audit --format json`. Required `command: "audit"` singleton
994    /// plus `verdict` and `summary`.
995    #[serde(rename = "audit")]
996    Audit(AuditOutput),
997    /// `fallow explain <issue-type> --format json`. Required `id`, `name`,
998    /// `rationale`, `example`, `how_to_fix`, `docs`; no `schema_version`.
999    #[serde(rename = "explain")]
1000    Explain(ExplainOutput),
1001    /// `fallow --format review-github` / `--format review-gitlab`. Required
1002    /// `body`, `comments`, `meta`; no `schema_version`.
1003    #[serde(rename = "review-envelope")]
1004    ReviewEnvelope(ReviewEnvelopeOutput),
1005    /// `fallow ci reconcile-review --format json`. Required `schema`
1006    /// singleton plus `provider`, `comments`, and the various
1007    /// `*_fingerprints` arrays.
1008    #[serde(rename = "review-reconcile")]
1009    ReviewReconcile(ReviewReconcileOutput),
1010    /// `fallow coverage setup --json`. Required `schema_version` singleton
1011    /// plus `framework_detected`, `members`, `commands`, `snippets`.
1012    #[serde(rename = "coverage-setup")]
1013    CoverageSetup(CoverageSetupOutput),
1014    /// `fallow coverage analyze --format json`. Required
1015    /// `schema_version: "1"` singleton plus `version`, `elapsed_ms`,
1016    /// `runtime_coverage`.
1017    #[serde(rename = "coverage-analyze")]
1018    CoverageAnalyze(CoverageAnalyzeOutput),
1019    /// `fallow list --boundaries --format json`. Required `boundaries`
1020    /// sub-object; no `schema_version`.
1021    #[serde(rename = "list-boundaries")]
1022    ListBoundaries(ListBoundariesOutput),
1023    /// `fallow workspaces --format json`. Required `workspace_count`,
1024    /// `workspaces`, and `workspace_diagnostics`.
1025    #[serde(rename = "list-workspaces")]
1026    Workspaces(WorkspacesOutput),
1027    /// `fallow health --format json`. Required `report: HealthReport`.
1028    #[serde(rename = "health")]
1029    Health(HealthOutput),
1030    /// `fallow dupes --format json`. Required `report: DupesReportPayload`
1031    /// (typed wrapper payload carrying `clone_groups[]: CloneGroupFinding`
1032    /// and `clone_families[]: CloneFamilyFinding`).
1033    #[serde(rename = "dupes")]
1034    Dupes(DupesOutput),
1035    /// `fallow dead-code --format json --group-by <mode>`. Required `grouped_by`
1036    /// plus a `groups` array.
1037    #[serde(rename = "dead-code-grouped")]
1038    CheckGrouped(CheckGroupedOutput),
1039    /// `fallow impact --format json`. Required `enabled`, `record_count`,
1040    /// `containment_count`, `recent_containment`; no global `schema_version`,
1041    /// `command`, `total_issues`, or `report`.
1042    #[serde(rename = "impact")]
1043    Impact(crate::impact::ImpactReport),
1044    /// `fallow security --summary --format json`. Required `summary`; no
1045    /// per-finding arrays.
1046    #[serde(rename = "security")]
1047    SecuritySummary(crate::security::SecuritySummaryOutput),
1048    /// `fallow security --format json`. Required `security_findings`,
1049    /// `unresolved_edge_files`, and `unresolved_callee_sites`; ordered before the
1050    /// broader variants because the `security_findings` discriminator is uniquely
1051    /// present here.
1052    #[serde(rename = "security")]
1053    Security(crate::security::SecurityOutput),
1054    /// `fallow dead-code --format json`.
1055    /// Required `total_issues` plus `summary: CheckSummary`.
1056    #[serde(rename = "dead-code")]
1057    Check(CheckOutput),
1058    /// Bare `fallow --format json` (combined dead-code + dupes + health).
1059    /// Required `schema_version`, `version`, and `elapsed_ms`, with optional
1060    /// `check`, `dupes`, and `health` subreports.
1061    #[serde(rename = "combined")]
1062    Combined(CombinedOutput),
1063}
1064
1065#[cfg(test)]
1066mod tests {
1067    use fallow_types::envelope::{ElapsedMs, SchemaVersion, ToolVersion};
1068
1069    use super::*;
1070
1071    static TEST_TELEMETRY_RUN_ID_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
1072
1073    struct TelemetryRunIdGuard {
1074        _lock: std::sync::MutexGuard<'static, ()>,
1075    }
1076
1077    impl TelemetryRunIdGuard {
1078        fn set(run_id: Option<&str>) -> Self {
1079            let lock = TEST_TELEMETRY_RUN_ID_LOCK
1080                .lock()
1081                .unwrap_or_else(|poisoned| poisoned.into_inner());
1082            set_telemetry_analysis_run_id(run_id.map(str::to_owned));
1083            Self { _lock: lock }
1084        }
1085    }
1086
1087    impl Drop for TelemetryRunIdGuard {
1088        fn drop(&mut self) {
1089            set_telemetry_analysis_run_id(None);
1090        }
1091    }
1092
1093    fn combined_output() -> CombinedOutput {
1094        CombinedOutput {
1095            schema_version: SchemaVersion(crate::report::SCHEMA_VERSION),
1096            version: ToolVersion("test".to_string()),
1097            elapsed_ms: ElapsedMs(0),
1098            meta: None,
1099            check: None,
1100            dupes: None,
1101            health: None,
1102        }
1103    }
1104
1105    #[test]
1106    fn root_output_serializes_kind_by_default() {
1107        let _guard = TelemetryRunIdGuard::set(None);
1108        let value = serialize_root_output_with_mode(
1109            FallowOutput::Combined(combined_output()),
1110            EnvelopeMode::Tagged,
1111        )
1112        .expect("combined root should serialize");
1113
1114        assert_eq!(value["kind"], serde_json::Value::String("combined".into()));
1115        assert_eq!(value["schema_version"], crate::report::SCHEMA_VERSION);
1116    }
1117
1118    #[test]
1119    fn legacy_mode_removes_only_root_kind() {
1120        let _guard = TelemetryRunIdGuard::set(None);
1121        let value = serialize_root_output_with_mode(
1122            FallowOutput::Combined(combined_output()),
1123            EnvelopeMode::Legacy,
1124        )
1125        .expect("combined root should serialize");
1126
1127        assert!(value.get("kind").is_none());
1128
1129        let mut nested = serde_json::json!({
1130            "kind": "root",
1131            "action": {
1132                "kind": "suppress"
1133            }
1134        });
1135        remove_root_kind(&mut nested);
1136        assert!(nested.get("kind").is_none());
1137        assert_eq!(nested["action"]["kind"], "suppress");
1138    }
1139
1140    #[test]
1141    fn root_output_attaches_telemetry_meta() {
1142        let _guard = TelemetryRunIdGuard::set(Some("run_test123"));
1143        let value = serialize_root_output_with_mode(
1144            FallowOutput::Combined(combined_output()),
1145            EnvelopeMode::Tagged,
1146        )
1147        .expect("combined root should serialize");
1148
1149        assert_eq!(
1150            value["_meta"]["telemetry"]["analysis_run_id"].as_str(),
1151            Some("run_test123")
1152        );
1153    }
1154
1155    #[test]
1156    fn telemetry_meta_preserves_existing_meta_sections() {
1157        let mut output = combined_output();
1158        output.meta = Some(CombinedMeta {
1159            check: Some(Meta {
1160                docs: Some("https://example.com/check".to_string()),
1161                ..Meta::default()
1162            }),
1163            dupes: None,
1164            health: None,
1165            telemetry: None,
1166        });
1167
1168        let _guard = TelemetryRunIdGuard::set(Some("run_test123"));
1169        let value =
1170            serialize_root_output_with_mode(FallowOutput::Combined(output), EnvelopeMode::Tagged)
1171                .expect("combined root should serialize");
1172
1173        assert_eq!(
1174            value["_meta"]["check"]["docs"].as_str(),
1175            Some("https://example.com/check")
1176        );
1177        assert_eq!(
1178            value["_meta"]["telemetry"]["analysis_run_id"].as_str(),
1179            Some("run_test123")
1180        );
1181    }
1182}