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    /// Impact closure scoped to the inspected file as the seed: the transitive
562    /// affected-but-not-in-diff set + coordination gap.
563    pub impact_closure: InspectEvidenceSection,
564    /// OPT-IN symbol-level call chain. Present only when `--symbol-chain` was
565    /// requested AND the target is a SYMBOL (best-effort, syntactic, OFF the
566    /// ranked path). `None` (omitted) by default: symbol-level chains are
567    /// best-effort and not part of the trusted ranked evidence.
568    #[serde(default, skip_serializing_if = "Option::is_none")]
569    pub symbol_chain: Option<InspectEvidenceSection>,
570}
571
572#[derive(Debug, Clone, Serialize)]
573#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
574pub struct InspectEvidenceSection {
575    pub status: InspectSectionStatus,
576    pub scope: InspectEvidenceScope,
577    #[serde(default, skip_serializing_if = "Option::is_none")]
578    pub message: Option<String>,
579    #[serde(default, skip_serializing_if = "Option::is_none")]
580    pub data: Option<serde_json::Value>,
581}
582
583impl InspectEvidenceSection {
584    #[must_use]
585    pub fn ok(scope: InspectEvidenceScope, data: serde_json::Value) -> Self {
586        Self {
587            status: InspectSectionStatus::Ok,
588            scope,
589            message: None,
590            data: Some(data),
591        }
592    }
593
594    #[must_use]
595    pub fn error(scope: InspectEvidenceScope, message: String) -> Self {
596        Self {
597            status: InspectSectionStatus::Error,
598            scope,
599            message: Some(message),
600            data: None,
601        }
602    }
603}
604
605#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
606#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
607#[serde(rename_all = "snake_case")]
608pub enum InspectSectionStatus {
609    Ok,
610    Error,
611}
612
613#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
614#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
615#[serde(rename_all = "snake_case")]
616pub enum InspectEvidenceScope {
617    Symbol,
618    File,
619    ProjectFilteredToFile,
620}
621
622/// Envelope emitted by `fallow --format codeclimate` and
623/// `fallow --format gitlab-codequality`. GitLab Code Quality consumes the
624/// same shape. The wire form is a bare JSON array, not an object.
625#[derive(Debug, Clone, Serialize)]
626#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
627#[cfg_attr(
628    feature = "schema",
629    schemars(title = "fallow --format codeclimate / gitlab-codequality")
630)]
631#[serde(transparent)]
632#[allow(
633    dead_code,
634    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."
635)]
636pub struct CodeClimateOutput(pub Vec<CodeClimateIssue>);
637
638/// Single CodeClimate-compatible issue inside [`CodeClimateOutput`].
639#[derive(Debug, Clone, Serialize)]
640#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
641pub struct CodeClimateIssue {
642    #[serde(rename = "type")]
643    pub kind: CodeClimateIssueKind,
644    pub check_name: String,
645    pub description: String,
646    pub categories: Vec<String>,
647    pub severity: CodeClimateSeverity,
648    pub fingerprint: String,
649    pub location: CodeClimateLocation,
650}
651
652/// Discriminator value for [`CodeClimateIssue::kind`].
653#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
654#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
655#[serde(rename_all = "lowercase")]
656pub enum CodeClimateIssueKind {
657    /// The only valid CodeClimate type today.
658    Issue,
659}
660
661/// CodeClimate severity scale.
662#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
663#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
664#[serde(rename_all = "lowercase")]
665pub enum CodeClimateSeverity {
666    /// Informational. Reserved for future severity mappings; not produced
667    /// by the current runtime path (which only emits Minor / Major /
668    /// Critical via `severity_to_codeclimate` and the health / runtime-
669    /// coverage match arms).
670    #[allow(
671        dead_code,
672        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."
673    )]
674    Info,
675    /// Minor finding.
676    Minor,
677    /// Major finding.
678    Major,
679    /// Critical finding.
680    Critical,
681    /// Blocker (highest severity). Reserved for future severity
682    /// mappings; not produced by the current runtime path.
683    #[allow(
684        dead_code,
685        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."
686    )]
687    Blocker,
688}
689
690/// Location block inside [`CodeClimateIssue::location`].
691#[derive(Debug, Clone, Serialize)]
692#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
693pub struct CodeClimateLocation {
694    /// File path relative to the analysed root.
695    pub path: String,
696    /// Wrapper carrying the begin line so the schema lines up with
697    /// CodeClimate's spec.
698    pub lines: CodeClimateLines,
699}
700
701/// `lines.begin` for [`CodeClimateLocation`].
702#[derive(Debug, Clone, Copy, Serialize)]
703#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
704pub struct CodeClimateLines {
705    /// 1-based start line.
706    pub begin: u32,
707}
708
709/// Envelope emitted by `fallow --format review-github` / `review-gitlab`.
710#[derive(Debug, Clone, Serialize)]
711#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
712#[cfg_attr(
713    feature = "schema",
714    schemars(title = "fallow --format review-github / review-gitlab")
715)]
716pub struct ReviewEnvelopeOutput {
717    #[serde(default, skip_serializing_if = "Option::is_none")]
718    pub event: Option<ReviewEnvelopeEvent>,
719    pub body: String,
720    #[serde(default = "ReviewEnvelopeSummary::empty_default")]
721    pub summary: ReviewEnvelopeSummary,
722    pub comments: Vec<ReviewComment>,
723    #[serde(default = "default_marker_regex")]
724    pub marker_regex: String,
725    #[serde(default = "default_marker_regex_flags")]
726    pub marker_regex_flags: String,
727    pub meta: ReviewEnvelopeMeta,
728}
729
730/// Default for [`ReviewEnvelopeOutput::marker_regex`].
731#[must_use]
732pub fn default_marker_regex() -> String {
733    MARKER_REGEX_V2.to_owned()
734}
735
736/// Default for [`ReviewEnvelopeOutput::marker_regex_flags`].
737#[must_use]
738pub fn default_marker_regex_flags() -> String {
739    MARKER_REGEX_FLAGS_V2.to_owned()
740}
741
742/// Canonical v2 marker-regex literal.
743pub const MARKER_REGEX_V2: &str =
744    r"^<!-- fallow-fingerprint:v2: ((?:[a-z]+:)?[0-9a-f]{16}) -->\s*$";
745
746/// Canonical v2 marker-regex flags.
747pub const MARKER_REGEX_FLAGS_V2: &str = "m";
748
749/// Summary block on [`ReviewEnvelopeOutput`].
750#[derive(Debug, Clone, Serialize, Default)]
751#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
752pub struct ReviewEnvelopeSummary {
753    pub body: String,
754    pub fingerprint: String,
755}
756
757impl ReviewEnvelopeSummary {
758    /// Empty-default factory for [`ReviewEnvelopeOutput::summary`].
759    #[must_use]
760    #[allow(
761        dead_code,
762        reason = "referenced via serde default = \"...\" attr; no direct callsite until Deserialize is derived"
763    )]
764    pub fn empty_default() -> Self {
765        Self::default()
766    }
767}
768
769/// Singleton GitHub review-event marker.
770#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
771#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
772pub enum ReviewEnvelopeEvent {
773    #[serde(rename = "COMMENT")]
774    Comment,
775}
776
777/// Per-line review comment. Schema is an `anyOf` between GitHub and GitLab
778/// shapes; at runtime every entry in a single envelope comes from the same
779/// provider because the envelope is built from one provider's branch in
780/// `crates/cli/src/report/ci/review.rs::render_review_envelope`.
781#[derive(Debug, Clone, Serialize)]
782#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
783#[serde(untagged)]
784pub enum ReviewComment {
785    GitHub(GitHubReviewComment),
786    GitLab(GitLabReviewComment),
787}
788
789/// GitHub pull-request review comment.
790#[derive(Debug, Clone, Serialize)]
791#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
792pub struct GitHubReviewComment {
793    pub path: String,
794    pub line: u32,
795    pub side: GitHubReviewSide,
796    pub body: String,
797    pub fingerprint: String,
798    #[serde(default, skip_serializing_if = "is_false")]
799    pub truncated: bool,
800}
801
802/// Singleton side discriminator for [`GitHubReviewComment::side`].
803#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
804#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
805pub enum GitHubReviewSide {
806    #[serde(rename = "RIGHT")]
807    Right,
808}
809
810/// GitLab merge-request discussion comment.
811#[derive(Debug, Clone, Serialize)]
812#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
813pub struct GitLabReviewComment {
814    pub body: String,
815    pub position: GitLabReviewPosition,
816    pub fingerprint: String,
817    #[serde(default, skip_serializing_if = "is_false")]
818    pub truncated: bool,
819}
820
821/// Helper for `skip_serializing_if = "is_false"` on `truncated` fields above.
822/// Serde calls `skip_serializing_if` with `&T`, so the reference signature
823/// is dictated by the trait and cannot be changed to pass-by-value. Uses
824/// `#[allow]` rather than `#[expect]` per `.claude/rules/code-quality.md`:
825/// `trivially_copy_pass_by_ref` is a pedantic lint that fires inconsistently
826/// across build configurations (lib vs bin), which would trigger
827/// `unfulfilled_lint_expectations` under `#[expect]`.
828#[must_use]
829#[allow(
830    clippy::trivially_copy_pass_by_ref,
831    reason = "serde's skip_serializing_if requires fn(&T) -> bool"
832)]
833pub fn is_false(value: &bool) -> bool {
834    !*value
835}
836
837/// `position` block inside [`GitLabReviewComment`]. Mirrors the GitLab
838/// merge-request discussion-position API.
839#[derive(Debug, Clone, Serialize)]
840#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
841pub struct GitLabReviewPosition {
842    #[serde(default, skip_serializing_if = "Option::is_none")]
843    pub base_sha: Option<String>,
844    #[serde(default, skip_serializing_if = "Option::is_none")]
845    pub start_sha: Option<String>,
846    #[serde(default, skip_serializing_if = "Option::is_none")]
847    pub head_sha: Option<String>,
848    pub position_type: GitLabReviewPositionType,
849    pub old_path: String,
850    pub new_path: String,
851    pub new_line: u32,
852}
853
854/// Singleton position-type discriminator for [`GitLabReviewPosition`].
855#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
856#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
857#[serde(rename_all = "lowercase")]
858pub enum GitLabReviewPositionType {
859    Text,
860}
861
862/// `meta` block inside [`ReviewEnvelopeOutput`].
863#[derive(Debug, Clone, Serialize)]
864#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
865pub struct ReviewEnvelopeMeta {
866    pub schema: ReviewEnvelopeSchema,
867    pub provider: ReviewProvider,
868    #[serde(default, skip_serializing_if = "Option::is_none")]
869    pub check_conclusion: Option<ReviewCheckConclusion>,
870}
871
872/// Schema-version discriminator for the review envelope.
873#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
874#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
875pub enum ReviewEnvelopeSchema {
876    /// First release of the review envelope format. Historical only; no v1
877    /// emit path remains on the current code. Retained on the enum so a
878    /// future Deserialize derive can still parse v1 captures (e.g. from
879    /// committed snapshots predating the issue #528 migration) without
880    /// erroring on an unknown variant.
881    #[serde(rename = "fallow-review-envelope/v1")]
882    #[allow(
883        dead_code,
884        reason = "kept for forward-compat with v1 historical inputs once Deserialize is derived"
885    )]
886    V1,
887    /// Issue #528 evolution. Adds (1) the [`ReviewEnvelopeOutput::summary`]
888    /// block, (2) [`ReviewEnvelopeOutput::marker_regex`], (3) same-line
889    /// `(path, line)` merging in `comments[]` with a
890    /// `merged:<16-char hash>` primary fingerprint over sorted constituent
891    /// fingerprints (identity shifts whenever the set of constituents
892    /// changes, so the bundled skip-if-fingerprint-exists wrappers
893    /// correctly re-post on content change), (4) UTF-8-safe body
894    /// truncation at the GitLab/GitHub note-size floor (65,536 bytes)
895    /// with paired `truncated: bool` + `<!-- fallow-truncated -->`
896    /// signals, (5) `:v2:`-namespaced marker shape
897    /// (`<!-- fallow-fingerprint:v2: <fingerprint> -->`) preventing v1
898    /// marker collision and user-paste spoofing, and (6) diff-aware
899    /// `position.old_path` for renamed files on GitLab.
900    #[serde(rename = "fallow-review-envelope/v2")]
901    V2,
902}
903
904/// Review-envelope provider tag.
905#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
906#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
907#[serde(rename_all = "lowercase")]
908pub enum ReviewProvider {
909    /// GitHub pull-request review envelope.
910    Github,
911    /// GitLab merge-request discussion envelope.
912    Gitlab,
913}
914
915/// `meta.check_conclusion` for the GitHub review envelope. Maps to the
916/// GitHub Checks API conclusion field.
917#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
918#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
919#[serde(rename_all = "lowercase")]
920pub enum ReviewCheckConclusion {
921    /// No findings.
922    Success,
923    /// Findings but none gated as failure.
924    Neutral,
925    /// At least one finding gated as failure.
926    Failure,
927}
928
929/// Envelope emitted by `fallow ci reconcile-review --format json`. Used by
930/// CI integrations to drive comment carry-over and stale-comment cleanup
931/// across PR / MR revisions.
932#[derive(Debug, Clone, Serialize)]
933#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
934#[cfg_attr(
935    feature = "schema",
936    schemars(title = "fallow ci reconcile-review --format json")
937)]
938pub struct ReviewReconcileOutput {
939    pub schema: ReviewReconcileSchema,
940    pub provider: ReviewProvider,
941    pub target: Option<String>,
942    pub dry_run: bool,
943    pub comments: u32,
944    pub current_fingerprints: u32,
945    pub existing_fingerprints: u32,
946    pub new_fingerprints: u32,
947    pub stale_fingerprints: u32,
948    pub new: Vec<String>,
949    pub stale: Vec<String>,
950    pub provider_warning: Option<String>,
951    pub resolution_comments_posted: u32,
952    pub threads_resolved: u32,
953    #[serde(default, skip_serializing_if = "Option::is_none")]
954    pub apply_hint: Option<String>,
955    pub apply_errors: Vec<String>,
956    #[serde(default, skip_serializing_if = "Vec::is_empty")]
957    pub failed_fingerprints: Vec<String>,
958    #[serde(default, skip_serializing_if = "Vec::is_empty")]
959    pub unapplied_fingerprints: Vec<String>,
960}
961
962/// Schema-version discriminator for the review reconcile envelope.
963#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
964#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
965pub enum ReviewReconcileSchema {
966    /// First release of the review reconcile format.
967    #[serde(rename = "fallow-review-reconcile/v1")]
968    V1,
969}
970
971/// Resolver mode label for grouped envelopes (dead-code, dupes, health).
972///
973/// `owner` groups by CODEOWNERS team, `directory` groups by top-level
974/// directory prefix, `package` groups by workspace package name, `section`
975/// groups by GitLab CODEOWNERS `[Section]` header name.
976#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
977#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
978#[serde(rename_all = "lowercase")]
979pub enum GroupByMode {
980    Owner,
981    Directory,
982    Package,
983    Section,
984}
985/// Envelope emitted by `fallow list --boundaries --format json`. Surfaces
986/// the architecture boundary zones, rules, and (issue #373) the user's
987/// pre-expansion `autoDiscover` logical groups so consumers can render
988/// grouping intent that `expand_auto_discover` would otherwise flatten out
989/// of `zones[]`.
990#[derive(Debug, Clone, Serialize)]
991#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
992#[cfg_attr(
993    feature = "schema",
994    schemars(title = "fallow list --boundaries --format json")
995)]
996#[allow(
997    dead_code,
998    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."
999)]
1000pub struct ListBoundariesOutput {
1001    pub boundaries: BoundariesListing,
1002}
1003
1004/// `fallow workspaces --format json` envelope.
1005#[derive(Debug, Clone, Serialize)]
1006#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1007#[cfg_attr(
1008    feature = "schema",
1009    schemars(title = "fallow workspaces --format json")
1010)]
1011pub struct WorkspacesOutput {
1012    /// Number of workspace package entries in `workspaces`.
1013    pub workspace_count: usize,
1014    /// Workspace packages discovered from package manager and tsconfig workspace
1015    /// declarations. Paths are project-root-relative and use forward slashes.
1016    pub workspaces: Vec<WorkspaceInfo>,
1017    /// Workspace discovery diagnostics produced while reading workspace
1018    /// declarations. Present for compatibility with the current wire contract,
1019    /// even when empty.
1020    pub workspace_diagnostics: Vec<fallow_config::WorkspaceDiagnostic>,
1021}
1022
1023/// One workspace package emitted by `fallow workspaces --format json`.
1024#[derive(Debug, Clone, Serialize)]
1025#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1026pub struct WorkspaceInfo {
1027    /// Package name from the workspace package.json. This is the value accepted
1028    /// by `--workspace <name>`.
1029    pub name: String,
1030    /// Project-root-relative path to the workspace directory, normalized to
1031    /// forward slashes for cross-platform JSON consumers.
1032    pub path: String,
1033    /// Whether the package is a generated or platform-specific dependency
1034    /// package rather than a hand-authored workspace.
1035    pub is_internal_dependency: bool,
1036}
1037
1038/// `boundaries` block carried by [`ListBoundariesOutput`].
1039#[derive(Debug, Clone, Serialize)]
1040#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1041#[allow(
1042    dead_code,
1043    reason = "schema-source-of-truth: see `ListBoundariesOutput`."
1044)]
1045pub struct BoundariesListing {
1046    pub configured: bool,
1047    pub zone_count: usize,
1048    pub zones: Vec<BoundariesListZone>,
1049    pub rule_count: usize,
1050    pub rules: Vec<BoundariesListRule>,
1051    pub logical_group_count: usize,
1052    pub logical_groups: Vec<BoundariesListLogicalGroup>,
1053}
1054
1055/// A boundary zone after preset and `autoDiscover` expansion. Each entry
1056/// classifies files into a single zone via glob patterns.
1057#[derive(Debug, Clone, Serialize)]
1058#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1059#[allow(
1060    dead_code,
1061    reason = "schema-source-of-truth: see `ListBoundariesOutput`."
1062)]
1063pub struct BoundariesListZone {
1064    pub name: String,
1065    pub patterns: Vec<String>,
1066    pub file_count: usize,
1067}
1068
1069/// A boundary import rule, expanded to operate on concrete child zone
1070/// names after `autoDiscover` flattening. The user's pre-expansion rule
1071/// (keyed on the logical parent name, if any) is preserved on the
1072/// corresponding [`BoundariesListLogicalGroup::authored_rule`].
1073#[derive(Debug, Clone, Serialize)]
1074#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1075#[allow(
1076    dead_code,
1077    reason = "schema-source-of-truth: see `ListBoundariesOutput`."
1078)]
1079pub struct BoundariesListRule {
1080    pub from: String,
1081    pub allow: Vec<String>,
1082}
1083
1084/// A pre-expansion `autoDiscover` logical group surfaced for observability
1085/// (issue #373). Captured during `expand_auto_discover` so consumers can
1086/// see the user-authored parent name and grouping intent after expansion
1087/// would otherwise flatten it out of [`BoundariesListing::zones`].
1088#[derive(Debug, Clone, Serialize)]
1089#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1090#[allow(
1091    dead_code,
1092    reason = "schema-source-of-truth: see `ListBoundariesOutput`."
1093)]
1094pub struct BoundariesListLogicalGroup {
1095    pub name: String,
1096    pub children: Vec<String>,
1097    pub auto_discover: Vec<String>,
1098    pub status: fallow_config::LogicalGroupStatus,
1099    pub source_zone_index: usize,
1100    pub file_count: usize,
1101    #[serde(default, skip_serializing_if = "Option::is_none")]
1102    pub authored_rule: Option<fallow_config::AuthoredRule>,
1103    #[serde(default, skip_serializing_if = "Option::is_none")]
1104    pub fallback_zone: Option<String>,
1105    #[serde(default, skip_serializing_if = "Option::is_none")]
1106    pub merged_from: Option<Vec<usize>>,
1107    #[serde(default, skip_serializing_if = "Option::is_none")]
1108    pub original_zone_root: Option<String>,
1109    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1110    pub child_source_indices: Vec<usize>,
1111}
1112
1113/// Typed root of every fallow JSON envelope shape that serializes as a JSON
1114/// object and participates in the documented `FallowOutput` contract. The
1115/// schema derived from this enum drives the document-root `oneOf` in
1116/// `docs/output-schema.json`.
1117///
1118/// The default wire shape now carries a top-level `kind` discriminator so
1119/// agents and schema-validating clients can select the variant in O(1) instead
1120/// of probing for unique field presence. `--legacy-envelope` is a one-cycle
1121/// compatibility flag that removes only this document-root `kind` field from
1122/// CLI JSON output; nested report objects are not rewritten.
1123///
1124/// One envelope is intentionally NOT in this enum:
1125/// - `CodeClimateOutput` serializes as a bare JSON array
1126///   (`#[serde(transparent)]`) per the Code Climate / GitLab Code Quality
1127///   spec; `#[serde(tag = ...)]` cannot internally tag a non-object
1128///   variant and wrapping the array would break the spec. The root schema
1129///   carries it as a sibling `oneOf` branch alongside `FallowOutput`.
1130#[derive(Debug, Clone, Serialize)]
1131#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1132#[cfg_attr(
1133    feature = "schema",
1134    schemars(title = "fallow --format json (typed root)")
1135)]
1136#[serde(tag = "kind")]
1137#[allow(
1138    dead_code,
1139    reason = "some variants are schema-emit only, but runtime roots serialize through this enum where practical"
1140)]
1141pub enum FallowOutput {
1142    /// `fallow audit --format json`. Required `command: "audit"` singleton
1143    /// plus `verdict` and `summary`.
1144    #[serde(rename = "audit")]
1145    Audit(AuditOutput),
1146    /// `fallow explain <issue-type> --format json`. Required `id`, `name`,
1147    /// `rationale`, `example`, `how_to_fix`, `docs`; no `schema_version`.
1148    #[serde(rename = "explain")]
1149    Explain(ExplainOutput),
1150    /// `fallow inspect --format json`. Required `target`, `identity`,
1151    /// `evidence`, and `warnings`; no `schema_version`.
1152    #[serde(rename = "inspect_target")]
1153    Inspect(InspectOutput),
1154    /// `fallow trace <symbol> --format json` (symbol-level call chains).
1155    /// Required `file`, `symbol`, `symbol_found`, `depth`, `best_effort`,
1156    /// `reason`; optional `callers`, `callees`, `unresolved_callees`. Its OWN
1157    /// surface, best-effort and EXPLICITLY OFF the ranked path: NEVER folded
1158    /// into the ranked brief and NEVER a focus-map input.
1159    #[serde(rename = "trace")]
1160    Trace(fallow_core::trace_chain::SymbolChainTrace),
1161    /// `fallow --format review-github` / `--format review-gitlab`. Required
1162    /// `body`, `comments`, `meta`; no `schema_version`.
1163    #[serde(rename = "review-envelope")]
1164    ReviewEnvelope(ReviewEnvelopeOutput),
1165    /// `fallow ci reconcile-review --format json`. Required `schema`
1166    /// singleton plus `provider`, `comments`, and the various
1167    /// `*_fingerprints` arrays.
1168    #[serde(rename = "review-reconcile")]
1169    ReviewReconcile(ReviewReconcileOutput),
1170    /// `fallow coverage setup --json`. Required `schema_version` singleton
1171    /// plus `framework_detected`, `members`, `commands`, `snippets`.
1172    #[serde(rename = "coverage-setup")]
1173    CoverageSetup(CoverageSetupOutput),
1174    /// `fallow coverage analyze --format json`. Required
1175    /// `schema_version: "1"` singleton plus `version`, `elapsed_ms`,
1176    /// `runtime_coverage`.
1177    #[serde(rename = "coverage-analyze")]
1178    CoverageAnalyze(CoverageAnalyzeOutput),
1179    /// `fallow list --boundaries --format json`. Required `boundaries`
1180    /// sub-object; no `schema_version`.
1181    #[serde(rename = "list-boundaries")]
1182    ListBoundaries(ListBoundariesOutput),
1183    /// `fallow workspaces --format json`. Required `workspace_count`,
1184    /// `workspaces`, and `workspace_diagnostics`.
1185    #[serde(rename = "list-workspaces")]
1186    Workspaces(WorkspacesOutput),
1187    /// `fallow health --format json`. Required `report: HealthReport`.
1188    #[serde(rename = "health")]
1189    Health(HealthOutput),
1190    /// `fallow dupes --format json`. Required `report: DupesReportPayload`
1191    /// (typed wrapper payload carrying `clone_groups[]: CloneGroupFinding`
1192    /// and `clone_families[]: CloneFamilyFinding`).
1193    #[serde(rename = "dupes")]
1194    Dupes(DupesOutput),
1195    /// `fallow dead-code --format json --group-by <mode>`. Required `grouped_by`
1196    /// plus a `groups` array.
1197    #[serde(rename = "dead-code-grouped")]
1198    CheckGrouped(CheckGroupedOutput),
1199    /// `fallow impact --format json`. Required `enabled`, `record_count`,
1200    /// `containment_count`, `recent_containment`; no global `schema_version`,
1201    /// `command`, `total_issues`, or `report`.
1202    #[serde(rename = "impact")]
1203    Impact(crate::impact::ImpactReport),
1204    /// `fallow impact --all --format json`. Required `project_count`,
1205    /// `tracked_count`, `totals`, `projects`; the cross-repo roll-up. Each
1206    /// `projects[]` entry embeds a per-project `report` (the same shape as the
1207    /// `impact` variant). Independently versioned via `CrossRepoImpactSchemaVersion`.
1208    #[serde(rename = "impact-cross-repo")]
1209    ImpactCrossRepo(crate::impact::CrossRepoImpactReport),
1210    /// `fallow security --summary --format json`. Required `summary`; no
1211    /// per-finding arrays.
1212    #[serde(rename = "security")]
1213    SecuritySummary(crate::security::SecuritySummaryOutput),
1214    /// `fallow security --format json`. Required `security_findings`,
1215    /// `unresolved_edge_files`, and `unresolved_callee_sites`; ordered before the
1216    /// broader variants because the `security_findings` discriminator is uniquely
1217    /// present here.
1218    #[serde(rename = "security")]
1219    Security(crate::security::SecurityOutput),
1220    /// `fallow security survivors --format json`. Required `survivors` and
1221    /// `needs_human_review`, both keyed by `finding_id`.
1222    #[serde(rename = "security-survivors")]
1223    SecuritySurvivors(crate::security::SecuritySurvivorsOutput),
1224    /// `fallow security blind-spots --format json`. Required `summary` and
1225    /// grouped unresolved-callee diagnostics.
1226    #[serde(rename = "security-blind-spots")]
1227    SecurityBlindSpots(crate::security::SecurityBlindSpotsOutput),
1228    /// `fallow dead-code --format json`.
1229    /// Required `total_issues` plus `summary: CheckSummary`.
1230    #[serde(rename = "dead-code")]
1231    Check(CheckOutput),
1232    /// Bare `fallow --format json` (combined dead-code + dupes + health).
1233    /// Required `schema_version`, `version`, and `elapsed_ms`, with optional
1234    /// `check`, `dupes`, and `health` subreports.
1235    #[serde(rename = "combined")]
1236    Combined(CombinedOutput),
1237    /// `fallow audit --brief --format json` (alias `fallow review`). Required
1238    /// `schema_version`, `version`, `command: "audit-brief"`, `triage`, and
1239    /// `graph_facts`. Independently versioned via `ReviewBriefSchemaVersion`;
1240    /// always emitted with exit 0.
1241    #[serde(rename = "audit-brief")]
1242    AuditBrief(crate::audit_brief::ReviewBriefOutput),
1243    /// `fallow decision-surface --format json` (the `decision_surface` MCP tool's
1244    /// output). The separable, cheap apex: a ranked, capped, signal_id-anchored
1245    /// set of consequential structural decisions framed as judgment questions,
1246    /// each with structured `actions[]`. Independently versioned; always exit 0.
1247    #[serde(rename = "decision-surface")]
1248    DecisionSurface(crate::audit_decision_surface::DecisionSurfaceOutput),
1249    /// `fallow review --walkthrough-guide --format json`. The digest +
1250    /// schema the agent fetches: the brief + decision surface, the review
1251    /// direction, the graph-snapshot pin, and the embedded agent schema. The
1252    /// digest is graph-derived only (injection-resistant). Always exit 0.
1253    #[serde(rename = "review-walkthrough-guide")]
1254    WalkthroughGuide(crate::audit_walkthrough::WalkthroughGuide),
1255    /// `fallow review --walkthrough-file --format json`. The post-validation
1256    /// of an agent's judgment JSON against the live graph: accepted (anchored,
1257    /// framing fenced), rejected (unanchored), and a stale flag (the tree moved).
1258    /// The verifier is the graph, never a second model. Always exit 0.
1259    #[serde(rename = "review-walkthrough-validation")]
1260    WalkthroughValidation(crate::audit_walkthrough::WalkthroughValidation),
1261}
1262
1263#[cfg(test)]
1264mod tests {
1265    use fallow_types::envelope::{ElapsedMs, SchemaVersion, ToolVersion};
1266
1267    use super::*;
1268
1269    static TEST_TELEMETRY_RUN_ID_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
1270
1271    struct TelemetryRunIdGuard {
1272        _lock: std::sync::MutexGuard<'static, ()>,
1273    }
1274
1275    impl TelemetryRunIdGuard {
1276        fn set(run_id: Option<&str>) -> Self {
1277            let lock = TEST_TELEMETRY_RUN_ID_LOCK
1278                .lock()
1279                .unwrap_or_else(|poisoned| poisoned.into_inner());
1280            set_telemetry_analysis_run_id(run_id.map(str::to_owned));
1281            Self { _lock: lock }
1282        }
1283    }
1284
1285    impl Drop for TelemetryRunIdGuard {
1286        fn drop(&mut self) {
1287            set_telemetry_analysis_run_id(None);
1288        }
1289    }
1290
1291    fn combined_output() -> CombinedOutput {
1292        CombinedOutput {
1293            schema_version: SchemaVersion(crate::report::SCHEMA_VERSION),
1294            version: ToolVersion("test".to_string()),
1295            elapsed_ms: ElapsedMs(0),
1296            meta: None,
1297            check: None,
1298            dupes: None,
1299            health: None,
1300            next_steps: Vec::new(),
1301        }
1302    }
1303
1304    #[test]
1305    fn root_output_serializes_kind_by_default() {
1306        let _guard = TelemetryRunIdGuard::set(None);
1307        let value = serialize_root_output_with_mode(
1308            FallowOutput::Combined(combined_output()),
1309            EnvelopeMode::Tagged,
1310        )
1311        .expect("combined root should serialize");
1312
1313        assert_eq!(value["kind"], serde_json::Value::String("combined".into()));
1314        assert_eq!(value["schema_version"], crate::report::SCHEMA_VERSION);
1315    }
1316
1317    #[test]
1318    fn legacy_mode_removes_only_root_kind() {
1319        let _guard = TelemetryRunIdGuard::set(None);
1320        let value = serialize_root_output_with_mode(
1321            FallowOutput::Combined(combined_output()),
1322            EnvelopeMode::Legacy,
1323        )
1324        .expect("combined root should serialize");
1325
1326        assert!(value.get("kind").is_none());
1327
1328        let mut nested = serde_json::json!({
1329            "kind": "root",
1330            "action": {
1331                "kind": "suppress"
1332            }
1333        });
1334        remove_root_kind(&mut nested);
1335        assert!(nested.get("kind").is_none());
1336        assert_eq!(nested["action"]["kind"], "suppress");
1337    }
1338
1339    #[test]
1340    fn root_output_attaches_telemetry_meta() {
1341        let _guard = TelemetryRunIdGuard::set(Some("run_test123"));
1342        let value = serialize_root_output_with_mode(
1343            FallowOutput::Combined(combined_output()),
1344            EnvelopeMode::Tagged,
1345        )
1346        .expect("combined root should serialize");
1347
1348        assert_eq!(
1349            value["_meta"]["telemetry"]["analysis_run_id"].as_str(),
1350            Some("run_test123")
1351        );
1352    }
1353
1354    #[test]
1355    fn telemetry_meta_preserves_existing_meta_sections() {
1356        let mut output = combined_output();
1357        output.meta = Some(CombinedMeta {
1358            check: Some(Meta {
1359                docs: Some("https://example.com/check".to_string()),
1360                ..Meta::default()
1361            }),
1362            dupes: None,
1363            health: None,
1364            telemetry: None,
1365        });
1366
1367        let _guard = TelemetryRunIdGuard::set(Some("run_test123"));
1368        let value =
1369            serialize_root_output_with_mode(FallowOutput::Combined(output), EnvelopeMode::Tagged)
1370                .expect("combined root should serialize");
1371
1372        assert_eq!(
1373            value["_meta"]["check"]["docs"].as_str(),
1374            Some("https://example.com/check")
1375        );
1376        assert_eq!(
1377            value["_meta"]["telemetry"]["analysis_run_id"].as_str(),
1378            Some("run_test123")
1379        );
1380    }
1381}