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