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