Skip to main content

fallow_cli/
output_envelope.rs

1//! Typed envelope structs for the JSON output contract.
2//!
3//! Each top-level fallow command (`check`, `dupes`, `health`, `audit`,
4//! `explain`, `coverage setup`, plus the bare combined invocation and the
5//! CodeClimate / review-envelope side outputs) emits a distinct envelope
6//! shape. This module is the schema-side source of truth for those shapes:
7//! every type carries `Serialize` plus a cfg-gated `JsonSchema` derive so the
8//! committed `docs/output-schema.json` can be regenerated from Rust.
9//!
10//! Living in `fallow-cli` rather than `fallow-types` because the body fields
11//! pull in `DuplicationReport` (from `fallow-core`) and `HealthReport` (from
12//! this crate), neither of which is reachable from the lower-level types
13//! crate. The shared utility shapes (`SchemaVersion`, `Meta`,
14//! `BaselineDeltas`, ...) still live in `fallow_types::envelope` because they
15//! depend only on serde primitives.
16//!
17//! Runtime construction of these envelopes happens in
18//! `crates/cli/src/report/json.rs`; the JSON layer builds an envelope struct
19//! and converts it to a `serde_json::Value` via `serde_json::to_value`. The
20//! only remaining work on the `Value` tree is path relativisation
21//! (`strip_root_prefix`) and the cross-result-type suppress-line action
22//! harmonizer (`harmonize_multi_kind_suppress_line_actions`); both span
23//! envelope boundaries that typed wrappers do not.
24//!
25//! Runtime emit for the CodeClimate, review-envelope, and coverage-setup
26//! shapes now flows through the typed structs in this module:
27//! `crates/cli/src/report/codeclimate.rs` constructs `CodeClimateIssue`
28//! directly via `cc_issue`,
29//! `crates/cli/src/report/ci/review.rs::render_review_envelope` constructs
30//! `ReviewEnvelopeOutput`, and
31//! `crates/cli/src/coverage/mod.rs::build_setup_envelope` constructs
32//! `CoverageSetupOutput`. The wire `serde_json::Value` is the
33//! `serde_json::to_value(&envelope)` of those typed structs, so adding a
34//! field to one of those structs automatically flows to the wire. The
35//! `AuditOutput` and `ListBoundariesOutput` families remain
36//! schema-source-of-truth only (their wire is still hand-built via
37//! `serde_json::json!`); the drift gate keeps them honest.
38
39use fallow_core::results::AnalysisResults;
40use fallow_types::envelope::{
41    BaselineDeltas, BaselineMatch, CheckSummary, ElapsedMs, EntryPoints, Meta, RegressionResult,
42    SchemaVersion, ToolVersion,
43};
44use serde::Serialize;
45
46use crate::audit::{AuditAttribution, AuditSummary, AuditVerdict};
47use crate::health_types::{HealthGroup, HealthReport, RuntimeCoverageReport};
48use crate::output_dupes::DupesReportPayload;
49use crate::report::dupes_grouping::DuplicationGroup;
50
51/// Envelope emitted by `fallow coverage setup --json`. Deterministic
52/// agent-readable runtime coverage setup instructions. In workspaces,
53/// `members` carries one entry per detected runtime package; `runtime_targets`
54/// is the union of all member targets.
55///
56/// Constructed at runtime by
57/// `crates/cli/src/coverage/mod.rs::build_setup_envelope`; the wire is
58/// `serde_json::to_value(&envelope)`. The drift gate keeps this struct
59/// aligned with `docs/output-schema.json`.
60#[derive(Debug, Clone, Serialize)]
61#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
62#[cfg_attr(feature = "schema", schemars(title = "fallow coverage setup --json"))]
63pub struct CoverageSetupOutput {
64    /// Standalone coverage setup envelope version (always `"1"`).
65    pub schema_version: CoverageSetupSchemaVersion,
66    /// Primary detected runtime framework. For workspaces this mirrors the
67    /// first emitted runtime member; `unknown` means no runtime member was
68    /// detected.
69    pub framework_detected: CoverageSetupFramework,
70    /// Detected JavaScript package manager. `null` when none could be
71    /// resolved.
72    pub package_manager: Option<CoverageSetupPackageManager>,
73    /// Union of runtime targets across emitted members.
74    pub runtime_targets: Vec<CoverageSetupRuntimeTarget>,
75    /// Per-runtime-workspace setup recipes. Pure aggregator roots and
76    /// build-only library packages are omitted.
77    pub members: Vec<CoverageSetupMember>,
78    /// Always `null` today. Reserved for a future "config has been written
79    /// to disk" indicator.
80    pub config_written: Option<serde_json::Value>,
81    /// Shell commands the agent should run from the workspace root.
82    pub commands: Vec<String>,
83    /// Compatibility copy of the primary member's files, with workspace
84    /// prefixes when the primary member is not the root.
85    pub files_to_edit: Vec<CoverageSetupFileToEdit>,
86    /// Compatibility copy of the primary member's snippets, with workspace
87    /// prefixes when the primary member is not the root.
88    pub snippets: Vec<CoverageSetupSnippet>,
89    /// Optional Dockerfile RUN/COPY snippet to enable the beacon in
90    /// containerised deployments.
91    pub dockerfile_snippet: Option<String>,
92    /// Ordered next-step instructions for the agent / human operator.
93    pub next_steps: Vec<String>,
94    /// Non-fatal warnings raised during setup detection.
95    pub warnings: Vec<String>,
96    /// `_meta` block emitted only when `--explain` is passed.
97    #[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")]
98    pub meta: Option<serde_json::Value>,
99}
100
101/// Singleton schema-version discriminator for [`CoverageSetupOutput`].
102#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
103#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
104pub enum CoverageSetupSchemaVersion {
105    /// First release of the coverage setup envelope.
106    #[serde(rename = "1")]
107    V1,
108}
109
110/// Framework label inside coverage setup output.
111#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
112#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
113#[serde(rename_all = "snake_case")]
114pub enum CoverageSetupFramework {
115    /// Next.js (`framework: "nextjs"`).
116    #[serde(rename = "nextjs")]
117    NextJs,
118    /// NestJS (`framework: "nestjs"`).
119    #[serde(rename = "nestjs")]
120    NestJs,
121    /// Nuxt (`framework: "nuxt"`).
122    Nuxt,
123    /// SvelteKit (`framework: "sveltekit"`).
124    #[serde(rename = "sveltekit")]
125    SvelteKit,
126    /// Astro (`framework: "astro"`).
127    Astro,
128    /// Remix (`framework: "remix"`).
129    Remix,
130    /// Vite (`framework: "vite"`).
131    Vite,
132    /// Plain Node.js (no framework).
133    PlainNode,
134    /// Could not determine.
135    Unknown,
136}
137
138/// Package manager label inside coverage setup output.
139#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
140#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
141#[serde(rename_all = "lowercase")]
142pub enum CoverageSetupPackageManager {
143    /// `npm`.
144    Npm,
145    /// `pnpm`.
146    Pnpm,
147    /// `yarn`.
148    Yarn,
149    /// `bun`.
150    Bun,
151}
152
153/// Runtime target inside coverage setup output.
154#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
155#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
156#[serde(rename_all = "lowercase")]
157pub enum CoverageSetupRuntimeTarget {
158    /// Node.js runtime target.
159    Node,
160    /// Browser runtime target.
161    Browser,
162}
163
164/// Per-workspace setup recipe inside [`CoverageSetupOutput::members`].
165#[derive(Debug, Clone, Serialize)]
166#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
167pub struct CoverageSetupMember {
168    /// Workspace package name (or root marker for single-package projects).
169    pub name: String,
170    /// Workspace path relative to the analysed root, or `.` for the root
171    /// member.
172    pub path: String,
173    /// Framework detected for this member.
174    pub framework_detected: CoverageSetupFramework,
175    /// Package manager detected for this member.
176    pub package_manager: Option<CoverageSetupPackageManager>,
177    /// Runtime targets supported by this member's framework.
178    pub runtime_targets: Vec<CoverageSetupRuntimeTarget>,
179    /// Files the agent should edit to wire in the beacon.
180    pub files_to_edit: Vec<CoverageSetupFileToEdit>,
181    /// Code snippets the agent should paste into the edited files.
182    pub snippets: Vec<CoverageSetupSnippet>,
183    /// Optional Dockerfile snippet specific to this member.
184    pub dockerfile_snippet: Option<String>,
185    /// Member-scoped warnings.
186    pub warnings: Vec<String>,
187}
188
189/// Single file to edit inside [`CoverageSetupMember::files_to_edit`] or
190/// [`CoverageSetupOutput::files_to_edit`].
191#[derive(Debug, Clone, Serialize)]
192#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
193pub struct CoverageSetupFileToEdit {
194    /// Workspace-relative path to the file to edit.
195    pub path: String,
196    /// Why the file needs editing (e.g. `"Mount the beacon middleware"`).
197    pub reason: String,
198}
199
200/// Single code snippet inside [`CoverageSetupMember::snippets`] or
201/// [`CoverageSetupOutput::snippets`].
202#[derive(Debug, Clone, Serialize)]
203#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
204pub struct CoverageSetupSnippet {
205    /// Short label identifying the snippet (used by the human renderer).
206    pub label: String,
207    /// Workspace-relative path the snippet should be pasted into.
208    pub path: String,
209    /// Snippet content (literal source text).
210    pub content: String,
211}
212
213/// Envelope emitted by `fallow audit --format json`. Combines dead code,
214/// complexity, and duplication scoped to changed files with a verdict
215/// (`pass` / `warn` / `fail`), a per-category summary, optional
216/// new-vs-inherited attribution, and full sub-results.
217///
218/// Like [`CombinedOutput`], `audit`'s `duplication` and `complexity`
219/// sub-keys hold body shapes rather than per-command envelopes:
220/// `duplication` is [`DupesReportPayload`] (the typed wrapper payload
221/// emitted via `crate::output_dupes::DupesReportPayload::from_report`),
222/// `complexity` is [`HealthReport`]. `dead_code` is the full
223/// [`CheckOutput`] envelope. The committed schema points `duplication`
224/// at `#/definitions/DupesReportPayload` and `complexity` at
225/// `#/definitions/HealthReport` so the documented shape matches the
226/// wire; the `committed_property_refs_match_derived_property_refs`
227/// drift test enforces the alignment.
228#[derive(Debug, Clone, Serialize)]
229#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
230#[cfg_attr(feature = "schema", schemars(title = "fallow audit --format json"))]
231#[allow(
232    dead_code,
233    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."
234)]
235pub struct AuditOutput {
236    /// Schema version for this output format.
237    pub schema_version: SchemaVersion,
238    /// Fallow tool version that produced this output.
239    pub version: ToolVersion,
240    /// Singleton command discriminator (always `"audit"`).
241    pub command: AuditCommand,
242    /// Overall verdict: `pass` (no issues), `warn` (warn-severity only,
243    /// exit 0), or `fail` (error-severity issues, exit 1).
244    pub verdict: AuditVerdict,
245    /// Number of files changed between base ref and HEAD.
246    pub changed_files_count: u32,
247    /// Git ref used as comparison base (explicit or auto-detected).
248    pub base_ref: String,
249    /// Short SHA of HEAD. Omitted when git is unavailable.
250    #[serde(default, skip_serializing_if = "Option::is_none")]
251    pub head_sha: Option<String>,
252    /// Analysis duration in milliseconds.
253    pub elapsed_ms: ElapsedMs,
254    /// Only emitted when --performance is set. true means audit reused the
255    /// current run's keys as the base snapshot because every changed file was
256    /// either a non-behavioral doc or token-equivalent at the base ref (the
257    /// docs-only-diff fast path); false means the regular base worktree
258    /// analysis ran.
259    #[serde(default, skip_serializing_if = "Option::is_none")]
260    pub base_snapshot_skipped: Option<bool>,
261    /// Per-category summary counts.
262    pub summary: AuditSummary,
263    /// Counts split by whether each finding was introduced by the current
264    /// changeset or already existed at the base ref. The default audit gate is
265    /// new-only, so inherited findings are context. With audit.gate or --gate
266    /// set to all, audit skips the extra base-snapshot attribution pass and
267    /// these counts stay zero.
268    pub attribution: AuditAttribution,
269    /// Full dead code results (omitted if no changed files). Issue objects
270    /// include introduced: true/false when audit can compare against the base
271    /// ref.
272    #[serde(default, skip_serializing_if = "Option::is_none")]
273    pub dead_code: Option<CheckOutput>,
274    /// Full duplication results (omitted if no changed files). Clone groups
275    /// include introduced: true/false when audit can compare against the base
276    /// ref. Carries typed [`crate::output_dupes::CloneGroupFinding`] and
277    /// [`crate::output_dupes::CloneFamilyFinding`] wrappers (matches what
278    /// `crates/cli/src/audit.rs` emits via
279    /// `crate::output_dupes::DupesReportPayload::from_report`).
280    #[serde(default, skip_serializing_if = "Option::is_none")]
281    pub duplication: Option<DupesReportPayload>,
282    /// Full complexity results (omitted if no changed files). Findings include
283    /// introduced: true/false when audit can compare against the base ref.
284    #[serde(default, skip_serializing_if = "Option::is_none")]
285    pub complexity: Option<HealthReport>,
286}
287
288/// Singleton `command` discriminator for [`AuditOutput`].
289#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
290#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
291#[serde(rename_all = "lowercase")]
292#[allow(dead_code, reason = "schema-source-of-truth: see `AuditOutput`.")]
293pub enum AuditCommand {
294    /// The only valid command discriminator for `AuditOutput`.
295    Audit,
296}
297
298/// Envelope emitted by bare `fallow --format json` (the combined
299/// invocation). Wraps the per-analysis sub-results inside a single envelope
300/// with the standard `schema_version` / `version` / `elapsed_ms` header.
301///
302/// Each sub-result is `Option<...>` so `--only` / `--skip` can suppress a
303/// pass without leaving an empty key on the wire. The `check` sub-result is
304/// the full [`CheckOutput`] envelope (including its own `schema_version` /
305/// `version` / `elapsed_ms`), `dupes` is the typed [`DupesReportPayload`]
306/// emitted via `crate::output_dupes::DupesReportPayload::from_report`, and
307/// `health` is the bare [`HealthReport`] body: the runtime emit calls
308/// `serde_json::to_value(&report)` directly rather than wrapping it in the
309/// per-command envelope. The committed schema points `dupes` at
310/// `#/definitions/DupesReportPayload` and `health` at
311/// `#/definitions/HealthReport` so the documented shape matches the
312/// wire; the `committed_property_refs_match_derived_property_refs`
313/// drift test enforces the alignment.
314#[derive(Debug, Clone, Serialize)]
315#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
316#[cfg_attr(
317    feature = "schema",
318    schemars(title = "fallow --format json (bare, combined)")
319)]
320pub struct CombinedOutput {
321    /// Schema version for this output format.
322    pub schema_version: SchemaVersion,
323    /// Fallow tool version that produced this output.
324    pub version: ToolVersion,
325    /// Analysis duration in milliseconds.
326    pub elapsed_ms: ElapsedMs,
327    /// Dead-code analysis sub-envelope. Absent when `--skip check`.
328    #[serde(default, skip_serializing_if = "Option::is_none")]
329    pub check: Option<CheckOutput>,
330    /// Duplication analysis body (typed [`DupesReportPayload`], not the full
331    /// `DupesOutput` envelope). Absent when `--skip dupes`. The payload
332    /// wraps each clone group / family with its typed `actions[]` array via
333    /// `crate::output_dupes::DupesReportPayload::from_report`.
334    #[serde(default, skip_serializing_if = "Option::is_none")]
335    pub dupes: Option<DupesReportPayload>,
336    /// Complexity analysis body (bare `HealthReport`, not the full
337    /// `HealthOutput` envelope). Absent when `--skip health`.
338    #[serde(default, skip_serializing_if = "Option::is_none")]
339    pub health: Option<HealthReport>,
340}
341
342/// Singleton schema-version discriminator for [`CoverageAnalyzeOutput`].
343/// Independent from the global [`SchemaVersion`] because the runtime
344/// coverage envelope versions independently from the rest of the
345/// JSON contract.
346#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
347#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
348pub enum CoverageAnalyzeSchemaVersion {
349    /// First release of the standalone `fallow coverage analyze` envelope.
350    #[serde(rename = "1")]
351    V1,
352}
353
354/// Envelope emitted by `fallow coverage analyze --format json`.
355///
356/// Focused runtime coverage analysis output. Local mode reads
357/// `--runtime-coverage <path>`. Cloud mode requires explicit `--cloud` /
358/// `--runtime-coverage-cloud` or `FALLOW_RUNTIME_COVERAGE_SOURCE=cloud`;
359/// `FALLOW_API_KEY` alone does NOT select cloud mode.
360///
361/// Constructed at runtime in
362/// `crates/cli/src/coverage/analyze.rs::print_runtime_json`; the wire is
363/// `serde_json::to_value(&envelope)`. The drift gate keeps this struct
364/// aligned with `docs/output-schema.json`. Carries its own schema-version
365/// discriminator ([`CoverageAnalyzeSchemaVersion`]) because runtime
366/// coverage iterates independently of the main JSON contract version.
367#[derive(Debug, Clone, Serialize)]
368#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
369#[cfg_attr(
370    feature = "schema",
371    schemars(title = "fallow coverage analyze --format json")
372)]
373pub struct CoverageAnalyzeOutput {
374    /// Standalone coverage analyze envelope version.
375    pub schema_version: CoverageAnalyzeSchemaVersion,
376    /// fallow CLI version.
377    pub version: ToolVersion,
378    /// Analysis duration in milliseconds.
379    pub elapsed_ms: ElapsedMs,
380    /// The same runtime coverage block emitted by health JSON.
381    pub runtime_coverage: RuntimeCoverageReport,
382    /// `_meta` block with metric / rule definitions, emitted when `--explain`
383    /// is passed. Populated via the post-pass injection in
384    /// `print_runtime_json` (matches the pattern used by every other typed
385    /// envelope; the typed struct sets this to `None` and the JSON layer
386    /// merges in the `crate::explain::coverage_analyze_meta()` payload).
387    #[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")]
388    pub meta: Option<Meta>,
389}
390
391/// Envelope emitted by `fallow dupes --format json` (plus the `dupes` block
392/// inside the combined and audit envelopes).
393///
394/// The body is the typed [`DupesReportPayload`] flattened into the envelope
395/// so the wire shape stays `{ schema_version, version, elapsed_ms,
396/// clone_groups, clone_families, stats, ... }` exactly as the existing JSON
397/// layer emits. The payload's `clone_groups` and `clone_families` carry
398/// typed [`crate::output_dupes::CloneGroupFinding`] /
399/// [`crate::output_dupes::CloneFamilyFinding`] wrappers so the `actions[]`
400/// field is part of the schema-derived contract.
401/// `grouped_by` / `groups` / `total_issues` are populated by the grouped
402/// builder; on the ungrouped path they stay `None` and `skip_serializing_if`
403/// drops them.
404#[derive(Debug, Clone, Serialize)]
405#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
406#[cfg_attr(feature = "schema", schemars(title = "fallow dupes --format json"))]
407pub struct DupesOutput {
408    /// Schema version for this output format.
409    pub schema_version: SchemaVersion,
410    /// Fallow tool version that produced this output.
411    pub version: ToolVersion,
412    /// Analysis duration in milliseconds.
413    pub elapsed_ms: ElapsedMs,
414    /// Project-level duplication payload (`clone_groups`, `clone_families`,
415    /// `stats`, optional `mirrored_directories`). Flattened so the wire shape
416    /// stays a single object. Carries typed [`crate::output_dupes::CloneGroupFinding`]
417    /// and [`crate::output_dupes::CloneFamilyFinding`] wrappers instead of bare
418    /// findings so the `actions[]` array (and audit-mode `introduced`) are part
419    /// of the schema-derived contract rather than a JSON post-pass.
420    #[serde(flatten)]
421    pub report: DupesReportPayload,
422    /// Resolver mode used for partitioning. Present only when `--group-by` is
423    /// active.
424    #[serde(default, skip_serializing_if = "Option::is_none")]
425    pub grouped_by: Option<GroupByMode>,
426    /// Total clone groups across all buckets when `--group-by` is active.
427    /// Mirrors the grouped check / health envelopes which expose
428    /// `total_issues` so MCP and CI consumers can read the same key across
429    /// commands.
430    #[serde(default, skip_serializing_if = "Option::is_none")]
431    pub total_issues: Option<usize>,
432    /// Per-group buckets when `--group-by` is active. Each clone group is
433    /// attributed to its largest-owner key (most instances; alphabetical
434    /// tiebreak). Sort: most clone groups first, then alphabetical, with
435    /// `(unowned)` pinned last.
436    ///
437    /// Each bucket's `clone_groups` and `clone_families` carry the typed
438    /// finding wrappers ([`crate::output_dupes::AttributedCloneGroupFinding`],
439    /// [`crate::output_dupes::CloneFamilyFinding`]) so the `actions[]`
440    /// augmentation is part of the schema-derived contract.
441    #[serde(default, skip_serializing_if = "Option::is_none")]
442    pub groups: Option<Vec<DuplicationGroup>>,
443    /// `_meta` block with metric / rule definitions, emitted when `--explain`
444    /// is passed (always present in MCP responses).
445    #[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")]
446    pub meta: Option<Meta>,
447}
448
449/// Envelope emitted by `fallow dead-code --format json` (plus the `check`
450/// block inside the combined and audit envelopes).
451///
452/// The body is the full `AnalysisResults` flattened into the envelope so
453/// every issue array (`unused_files`, `unused_exports`, ...) lives at the
454/// top level, matching the existing wire shape. `entry_points` lifts the
455/// otherwise `#[serde(skip)]`'d `AnalysisResults::entry_point_summary` back
456/// into the JSON output. `summary` carries the per-category counts the
457/// JSON layer always emits.
458#[derive(Debug, Clone, Serialize)]
459#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
460#[cfg_attr(feature = "schema", schemars(title = "fallow dead-code --format json"))]
461pub struct CheckOutput {
462    /// Schema version for this output format.
463    pub schema_version: SchemaVersion,
464    /// Fallow tool version that produced this output.
465    pub version: ToolVersion,
466    /// Analysis duration in milliseconds.
467    pub elapsed_ms: ElapsedMs,
468    /// Total number of issues found across all categories.
469    pub total_issues: usize,
470    /// Entry-point detection summary. Present when the analysis populated
471    /// the metadata block; absent in synthesised fixtures.
472    #[serde(default, skip_serializing_if = "Option::is_none")]
473    pub entry_points: Option<EntryPoints>,
474    /// Per-category issue counts. Always present. When --summary is used,
475    /// individual issue arrays are omitted.
476    pub summary: CheckSummary,
477    /// All issue arrays flattened in from `AnalysisResults`.
478    #[serde(flatten)]
479    pub results: AnalysisResults,
480    /// Per-category delta comparison against a saved baseline. Only present
481    /// when `--baseline` is used (today only via the combined invocation).
482    #[serde(default, skip_serializing_if = "Option::is_none")]
483    pub baseline_deltas: Option<BaselineDeltas>,
484    /// Baseline match statistics. Only present when `--baseline` is used.
485    #[serde(default, skip_serializing_if = "Option::is_none")]
486    pub baseline: Option<BaselineMatch>,
487    /// Regression check result. Only present when `--fail-on-regression` is
488    /// used.
489    #[serde(default, skip_serializing_if = "Option::is_none")]
490    pub regression: Option<RegressionResult>,
491    /// `_meta` block with metric / rule definitions, emitted when `--explain`
492    /// is passed (always present in MCP responses).
493    #[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")]
494    pub meta: Option<Meta>,
495}
496
497/// Envelope emitted by `fallow dead-code --group-by ... --format json`.
498///
499/// Issues are partitioned into resolver buckets (CODEOWNERS team, directory
500/// prefix, workspace package, or GitLab CODEOWNERS section) instead of flat
501/// arrays. Each bucket carries the same issue-array shape as the ungrouped
502/// `CheckOutput` body, plus per-group `key` / `owners` / `total_issues`.
503#[derive(Debug, Clone, Serialize)]
504#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
505#[cfg_attr(
506    feature = "schema",
507    schemars(
508        title = "fallow dead-code --group-by <owner|directory|package|section> --format json"
509    )
510)]
511pub struct CheckGroupedOutput {
512    /// Schema version for this output format.
513    pub schema_version: SchemaVersion,
514    /// Fallow tool version that produced this output.
515    pub version: ToolVersion,
516    /// Analysis duration in milliseconds.
517    pub elapsed_ms: ElapsedMs,
518    /// The grouping strategy used. 'owner' groups by CODEOWNERS team,
519    /// 'directory' groups by top-level directory prefix, 'package' groups by
520    /// workspace package name, 'section' groups by GitLab CODEOWNERS
521    /// `[Section]` header name.
522    pub grouped_by: GroupByMode,
523    /// Total number of issues across all groups.
524    pub total_issues: usize,
525    /// One entry per group; each contains the same issue arrays as
526    /// `CheckOutput` plus the group key and per-group total.
527    pub groups: Vec<CheckGroupedEntry>,
528    /// `_meta` block with metric / rule definitions, emitted when `--explain`
529    /// is passed.
530    #[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")]
531    pub meta: Option<Meta>,
532}
533
534/// Single resolver bucket inside `CheckGroupedOutput`. Carries the group's
535/// identifier, optional section owners, and a per-group flattened
536/// `AnalysisResults`.
537#[derive(Debug, Clone, Serialize)]
538#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
539pub struct CheckGroupedEntry {
540    /// Group identifier produced by the resolver. For `package` grouping:
541    /// workspace package name. For `owner` grouping: the CODEOWNERS team.
542    /// For `directory` grouping: the top-level directory prefix. For
543    /// `section` grouping: the GitLab CODEOWNERS section name (or
544    /// `(no section)` / `(unowned)` for unmatched files).
545    pub key: String,
546    /// Section default owners (GitLab CODEOWNERS `[Section] @owner1
547    /// @owner2`). Emitted only when `grouped_by` is `section`. Empty for
548    /// the `(no section)` and `(unowned)` buckets.
549    #[serde(default, skip_serializing_if = "Option::is_none")]
550    pub owners: Option<Vec<String>>,
551    /// Total number of issues in this group.
552    pub total_issues: usize,
553    /// Per-group issue arrays restricted to files in this group.
554    #[serde(flatten)]
555    pub results: AnalysisResults,
556}
557
558/// Envelope emitted by `fallow health --format json` (plus the `health` block
559/// inside the combined and audit envelopes).
560///
561/// The body is `HealthReport` flattened into the envelope so every report
562/// field (`findings`, `summary`, `vital_signs`, `hotspots`, `actions_meta`,
563/// ...) lives at the top level. Grouped runs populate `grouped_by` +
564/// `groups` with per-bucket recomputed metrics. The `actions_meta`
565/// breadcrumb is modeled on `HealthReport` as an `Option<HealthActionsMeta>`
566/// and is set at construction time by the report builder when the active
567/// `HealthActionContext` requests suppress-line omission, so the schema
568/// documents the field and serde populates it natively.
569#[derive(Debug, Clone, Serialize)]
570#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
571#[cfg_attr(feature = "schema", schemars(title = "fallow health --format json"))]
572pub struct HealthOutput {
573    /// Schema version for this output format.
574    pub schema_version: SchemaVersion,
575    /// Fallow tool version that produced this output.
576    pub version: ToolVersion,
577    /// Analysis duration in milliseconds.
578    pub elapsed_ms: ElapsedMs,
579    /// All fields from `HealthReport` flattened in so the wire shape stays
580    /// a single object.
581    #[serde(flatten)]
582    pub report: HealthReport,
583    /// Resolver mode used when --group-by is active. Present only on grouped
584    /// output. The top-level `vital_signs`, `health_score`, and `summary` keep
585    /// the active run scope (for example after --workspace); per-group versions
586    /// live inside each entry of `groups`.
587    #[serde(default, skip_serializing_if = "Option::is_none")]
588    pub grouped_by: Option<GroupByMode>,
589    /// Per-group health output, present only when `--group-by` is active.
590    /// Each group recomputes its own `vital_signs` and `health_score` from
591    /// the files in that group, mirroring how `--workspace` scopes a single
592    /// subset.
593    #[serde(default, skip_serializing_if = "Option::is_none")]
594    pub groups: Option<Vec<HealthGroup>>,
595    /// `_meta` block with metric / rule definitions, emitted when `--explain`
596    /// is passed (always present in MCP responses).
597    #[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")]
598    pub meta: Option<Meta>,
599}
600
601/// Envelope emitted by `fallow explain <issue-type> --format json`.
602///
603/// Standalone rule explanation. This command does not run project analysis
604/// and intentionally returns a compact object without `schema_version` /
605/// `version` metadata; consumers that need those should call any other
606/// fallow JSON-producing command.
607#[derive(Debug, Clone, Serialize)]
608#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
609#[cfg_attr(
610    feature = "schema",
611    schemars(title = "fallow explain <issue-type> --format json")
612)]
613#[serde(deny_unknown_fields)]
614pub struct ExplainOutput {
615    /// Canonical rule id, for example `fallow/unused-export`.
616    pub id: String,
617    /// Human-readable rule name.
618    pub name: String,
619    /// Short one-line explanation of the issue.
620    pub summary: String,
621    /// Why the issue matters and what fallow checks.
622    pub rationale: String,
623    /// Concrete example of the finding.
624    pub example: String,
625    /// Recommended fix or suppression guidance.
626    pub how_to_fix: String,
627    /// Docs URL for the rule.
628    pub docs: String,
629}
630
631/// Envelope emitted by `fallow --format codeclimate` and
632/// `fallow --format gitlab-codequality`. GitLab Code Quality consumes the
633/// same shape. The wire form is a bare JSON array, not an object.
634#[derive(Debug, Clone, Serialize)]
635#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
636#[cfg_attr(
637    feature = "schema",
638    schemars(title = "fallow --format codeclimate / gitlab-codequality")
639)]
640#[serde(transparent)]
641#[allow(
642    dead_code,
643    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."
644)]
645pub struct CodeClimateOutput(pub Vec<CodeClimateIssue>);
646
647/// Single CodeClimate-compatible issue inside [`CodeClimateOutput`].
648#[derive(Debug, Clone, Serialize)]
649#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
650pub struct CodeClimateIssue {
651    /// Always the literal string `"issue"`.
652    #[serde(rename = "type")]
653    pub kind: CodeClimateIssueKind,
654    /// Fallow rule identifier (always starts with `fallow/`).
655    pub check_name: String,
656    /// Human-readable description of the finding.
657    pub description: String,
658    /// Free-form categories applied by the report renderer.
659    pub categories: Vec<String>,
660    /// CodeClimate-style severity.
661    pub severity: CodeClimateSeverity,
662    /// Stable fingerprint used by CI dashboards to deduplicate findings
663    /// across runs.
664    pub fingerprint: String,
665    /// File path + start line of the finding.
666    pub location: CodeClimateLocation,
667}
668
669/// Discriminator value for [`CodeClimateIssue::kind`].
670#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
671#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
672#[serde(rename_all = "lowercase")]
673pub enum CodeClimateIssueKind {
674    /// The only valid CodeClimate type today.
675    Issue,
676}
677
678/// CodeClimate severity scale.
679#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
680#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
681#[serde(rename_all = "lowercase")]
682pub enum CodeClimateSeverity {
683    /// Informational. Reserved for future severity mappings; not produced
684    /// by the current runtime path (which only emits Minor / Major /
685    /// Critical via `severity_to_codeclimate` and the health / runtime-
686    /// coverage match arms).
687    #[allow(
688        dead_code,
689        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."
690    )]
691    Info,
692    /// Minor finding.
693    Minor,
694    /// Major finding.
695    Major,
696    /// Critical finding.
697    Critical,
698    /// Blocker (highest severity). Reserved for future severity
699    /// mappings; not produced by the current runtime path.
700    #[allow(
701        dead_code,
702        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."
703    )]
704    Blocker,
705}
706
707/// Location block inside [`CodeClimateIssue::location`].
708#[derive(Debug, Clone, Serialize)]
709#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
710pub struct CodeClimateLocation {
711    /// File path relative to the analysed root.
712    pub path: String,
713    /// Wrapper carrying the begin line so the schema lines up with
714    /// CodeClimate's spec.
715    pub lines: CodeClimateLines,
716}
717
718/// `lines.begin` for [`CodeClimateLocation`].
719#[derive(Debug, Clone, Copy, Serialize)]
720#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
721pub struct CodeClimateLines {
722    /// 1-based start line.
723    pub begin: u32,
724}
725
726/// Envelope emitted by `fallow --format review-github` / `review-gitlab`.
727/// Consumed by `action/scripts/review.sh` and `ci/scripts/review.sh` to
728/// post inline PR / MR review comments.
729#[derive(Debug, Clone, Serialize)]
730#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
731#[cfg_attr(
732    feature = "schema",
733    schemars(title = "fallow --format review-github / review-gitlab")
734)]
735pub struct ReviewEnvelopeOutput {
736    /// GitHub review event. Omitted for GitLab.
737    #[serde(default, skip_serializing_if = "Option::is_none")]
738    pub event: Option<ReviewEnvelopeEvent>,
739    /// Review summary body (rendered above per-line comments).
740    pub body: String,
741    /// Per-line comments. Each is either a [`GitHubReviewComment`] or a
742    /// [`GitLabReviewComment`] depending on `meta.provider`.
743    pub comments: Vec<ReviewComment>,
744    /// Envelope metadata block.
745    pub meta: ReviewEnvelopeMeta,
746}
747
748/// Singleton GitHub review-event marker.
749#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
750#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
751pub enum ReviewEnvelopeEvent {
752    /// GitHub review event for an unblocking comment review.
753    #[serde(rename = "COMMENT")]
754    Comment,
755}
756
757/// Per-line review comment. Schema is an `anyOf` between GitHub and GitLab
758/// shapes; at runtime every entry in a single envelope comes from the same
759/// provider because the envelope is built from one provider's branch in
760/// `crates/cli/src/report/ci/review.rs::render_review_envelope`.
761#[derive(Debug, Clone, Serialize)]
762#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
763#[serde(untagged)]
764pub enum ReviewComment {
765    /// GitHub-shaped pull-request review comment.
766    GitHub(GitHubReviewComment),
767    /// GitLab-shaped merge-request discussion comment.
768    GitLab(GitLabReviewComment),
769}
770
771/// GitHub pull-request review comment.
772#[derive(Debug, Clone, Serialize)]
773#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
774pub struct GitHubReviewComment {
775    /// File path the comment targets, repo-root relative.
776    pub path: String,
777    /// 1-indexed line number the comment targets.
778    pub line: u32,
779    /// Always the literal string `"RIGHT"`; GitHub review comments target
780    /// current-state/new-side lines; deletion-side comments are not modeled
781    /// yet.
782    pub side: GitHubReviewSide,
783    /// Markdown body of the comment.
784    pub body: String,
785    /// Stable fingerprint for the comment, used by `fallow ci
786    /// reconcile-review` to detect carryover comments across PR revisions.
787    pub fingerprint: String,
788}
789
790/// Singleton side discriminator for [`GitHubReviewComment::side`].
791#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
792#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
793pub enum GitHubReviewSide {
794    /// GitHub review comments target the new-side line range.
795    #[serde(rename = "RIGHT")]
796    Right,
797}
798
799/// GitLab merge-request discussion comment.
800#[derive(Debug, Clone, Serialize)]
801#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
802pub struct GitLabReviewComment {
803    /// Markdown body of the comment.
804    pub body: String,
805    /// Position block describing where the comment attaches on the diff.
806    pub position: GitLabReviewPosition,
807    /// Stable fingerprint for the comment.
808    pub fingerprint: String,
809}
810
811/// `position` block inside [`GitLabReviewComment`]. Mirrors the GitLab
812/// merge-request discussion-position API.
813#[derive(Debug, Clone, Serialize)]
814#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
815pub struct GitLabReviewPosition {
816    /// Merge-request base SHA.
817    #[serde(default, skip_serializing_if = "Option::is_none")]
818    pub base_sha: Option<String>,
819    /// Merge-request start SHA.
820    #[serde(default, skip_serializing_if = "Option::is_none")]
821    pub start_sha: Option<String>,
822    /// Merge-request head SHA.
823    #[serde(default, skip_serializing_if = "Option::is_none")]
824    pub head_sha: Option<String>,
825    /// Always `"text"` today.
826    pub position_type: GitLabReviewPositionType,
827    /// File path on the base side.
828    pub old_path: String,
829    /// File path on the head side.
830    pub new_path: String,
831    /// 1-indexed line on the head side.
832    pub new_line: u32,
833}
834
835/// Singleton position-type discriminator for [`GitLabReviewPosition`].
836#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
837#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
838#[serde(rename_all = "lowercase")]
839pub enum GitLabReviewPositionType {
840    /// Plain-text diff position (only kind fallow emits today).
841    Text,
842}
843
844/// `meta` block inside [`ReviewEnvelopeOutput`].
845#[derive(Debug, Clone, Serialize)]
846#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
847pub struct ReviewEnvelopeMeta {
848    /// Envelope schema marker, always `fallow-review-envelope/v1`.
849    pub schema: ReviewEnvelopeSchema,
850    /// Which provider this envelope is shaped for.
851    pub provider: ReviewProvider,
852    /// Check conclusion derived from the underlying findings. Emitted only
853    /// for GitHub envelopes today.
854    #[serde(default, skip_serializing_if = "Option::is_none")]
855    pub check_conclusion: Option<ReviewCheckConclusion>,
856}
857
858/// Schema-version discriminator for the review envelope.
859#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
860#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
861pub enum ReviewEnvelopeSchema {
862    /// First release of the review envelope format.
863    #[serde(rename = "fallow-review-envelope/v1")]
864    V1,
865}
866
867/// Review-envelope provider tag.
868#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
869#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
870#[serde(rename_all = "lowercase")]
871pub enum ReviewProvider {
872    /// GitHub pull-request review envelope.
873    Github,
874    /// GitLab merge-request discussion envelope.
875    Gitlab,
876}
877
878/// `meta.check_conclusion` for the GitHub review envelope. Maps to the
879/// GitHub Checks API conclusion field.
880#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
881#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
882#[serde(rename_all = "lowercase")]
883pub enum ReviewCheckConclusion {
884    /// No findings.
885    Success,
886    /// Findings but none gated as failure.
887    Neutral,
888    /// At least one finding gated as failure.
889    Failure,
890}
891
892/// Envelope emitted by `fallow ci reconcile-review --format json`. Used by
893/// CI integrations to drive comment carry-over and stale-comment cleanup
894/// across PR / MR revisions.
895#[derive(Debug, Clone, Serialize)]
896#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
897#[cfg_attr(
898    feature = "schema",
899    schemars(title = "fallow ci reconcile-review --format json")
900)]
901pub struct ReviewReconcileOutput {
902    /// Envelope schema marker, always `fallow-review-reconcile/v1`.
903    pub schema: ReviewReconcileSchema,
904    /// Which provider this reconcile pass was for.
905    pub provider: ReviewProvider,
906    /// PR / MR target identifier supplied to `fallow ci reconcile-review`.
907    /// `null` when the command ran without an explicit target.
908    pub target: Option<String>,
909    /// Whether the reconcile ran in dry-run mode.
910    pub dry_run: bool,
911    /// Number of comments in the supplied review envelope.
912    pub comments: u32,
913    /// Total fingerprints discovered in the supplied envelope.
914    pub current_fingerprints: u32,
915    /// Existing fingerprints already posted on the PR / MR.
916    pub existing_fingerprints: u32,
917    /// Newly-introduced fingerprints (current minus existing).
918    pub new_fingerprints: u32,
919    /// Stale fingerprints (existing minus current).
920    pub stale_fingerprints: u32,
921    /// Identifiers of the new fingerprints (subset of comments).
922    pub new: Vec<String>,
923    /// Identifiers of the stale fingerprints (subset of existing).
924    pub stale: Vec<String>,
925    /// Optional warning when the provider API was unreachable or
926    /// auth-rejected. `null` on the happy path.
927    pub provider_warning: Option<String>,
928    /// Resolution comments actually posted (zero on dry runs).
929    pub resolution_comments_posted: u32,
930    /// Stale review threads actually resolved (zero on dry runs).
931    pub threads_resolved: u32,
932    /// Errors collected during apply, one entry per failure.
933    pub apply_errors: Vec<String>,
934}
935
936/// Schema-version discriminator for the review reconcile envelope.
937#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
938#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
939pub enum ReviewReconcileSchema {
940    /// First release of the review reconcile format.
941    #[serde(rename = "fallow-review-reconcile/v1")]
942    V1,
943}
944
945/// Resolver mode label for grouped envelopes (dead-code, dupes, health).
946///
947/// `owner` groups by CODEOWNERS team, `directory` groups by top-level
948/// directory prefix, `package` groups by workspace package name, `section`
949/// groups by GitLab CODEOWNERS `[Section]` header name.
950#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
951#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
952#[serde(rename_all = "lowercase")]
953pub enum GroupByMode {
954    /// Group by CODEOWNERS team.
955    Owner,
956    /// Group by top-level directory prefix.
957    Directory,
958    /// Group by workspace package name.
959    Package,
960    /// Group by GitLab CODEOWNERS `[Section]` header name.
961    Section,
962}
963
964// ── list --boundaries --format json envelope ────────────────────────
965//
966// The runtime path builds the wire shape via `serde_json::json!` in
967// `crates/cli/src/list.rs::boundary_data_to_json`; the typed structs below
968// exist so the drift gate can lock the schema shape against Rust source.
969// A follow-up that swaps the runtime builder over to typed construction
970// can land independently (out of scope for issue #384 items 3a/3b/3c).
971
972/// Envelope emitted by `fallow list --boundaries --format json`. Surfaces
973/// the architecture boundary zones, rules, and (issue #373) the user's
974/// pre-expansion `autoDiscover` logical groups so consumers can render
975/// grouping intent that `expand_auto_discover` would otherwise flatten out
976/// of `zones[]`.
977#[derive(Debug, Clone, Serialize)]
978#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
979#[cfg_attr(
980    feature = "schema",
981    schemars(title = "fallow list --boundaries --format json")
982)]
983#[allow(
984    dead_code,
985    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."
986)]
987pub struct ListBoundariesOutput {
988    /// The boundaries section. The list command can also emit `files`,
989    /// `plugins`, `entry_points` siblings under additional flags; those
990    /// shapes are not part of this envelope today.
991    pub boundaries: BoundariesListing,
992}
993
994/// `boundaries` block carried by [`ListBoundariesOutput`].
995#[derive(Debug, Clone, Serialize)]
996#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
997#[allow(
998    dead_code,
999    reason = "schema-source-of-truth: see `ListBoundariesOutput`."
1000)]
1001pub struct BoundariesListing {
1002    /// `false` when the project has no `boundaries` configured; `true`
1003    /// otherwise. When `false` every array below is empty and every count
1004    /// is `0` (parity is enforced so consumers can read the counts without
1005    /// first branching on this flag).
1006    pub configured: bool,
1007    /// Length of [`Self::zones`]; emitted alongside the array for parity
1008    /// with `rule_count` / `logical_group_count`.
1009    pub zone_count: usize,
1010    /// Boundary zones after preset and `autoDiscover` expansion.
1011    pub zones: Vec<BoundariesListZone>,
1012    /// Length of [`Self::rules`].
1013    pub rule_count: usize,
1014    /// Boundary import rules, each `from -> allow[]`.
1015    pub rules: Vec<BoundariesListRule>,
1016    /// Length of [`Self::logical_groups`]. Always present (issue #373).
1017    pub logical_group_count: usize,
1018    /// Pre-expansion `autoDiscover` groups carrying the user-authored parent
1019    /// name and grouping intent (issue #373).
1020    pub logical_groups: Vec<BoundariesListLogicalGroup>,
1021}
1022
1023/// A boundary zone after preset and `autoDiscover` expansion. Each entry
1024/// classifies files into a single zone via glob patterns.
1025#[derive(Debug, Clone, Serialize)]
1026#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1027#[allow(
1028    dead_code,
1029    reason = "schema-source-of-truth: see `ListBoundariesOutput`."
1030)]
1031pub struct BoundariesListZone {
1032    /// Zone identifier as referenced in rules (e.g. `app`, `features/auth`).
1033    pub name: String,
1034    /// Compiled glob patterns. Children of an `autoDiscover` parent each
1035    /// carry a single pattern like `src/features/auth/**`.
1036    pub patterns: Vec<String>,
1037    /// Number of discovered files classified into this zone.
1038    pub file_count: usize,
1039}
1040
1041/// A boundary import rule, expanded to operate on concrete child zone
1042/// names after `autoDiscover` flattening. The user's pre-expansion rule
1043/// (keyed on the logical parent name, if any) is preserved on the
1044/// corresponding [`BoundariesListLogicalGroup::authored_rule`].
1045#[derive(Debug, Clone, Serialize)]
1046#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1047#[allow(
1048    dead_code,
1049    reason = "schema-source-of-truth: see `ListBoundariesOutput`."
1050)]
1051pub struct BoundariesListRule {
1052    /// Source zone the rule applies to.
1053    pub from: String,
1054    /// Target zones [`Self::from`] is allowed to import from. Self-imports
1055    /// are always allowed implicitly.
1056    pub allow: Vec<String>,
1057}
1058
1059/// A pre-expansion `autoDiscover` logical group surfaced for observability
1060/// (issue #373). Captured during `expand_auto_discover` so consumers can
1061/// see the user-authored parent name and grouping intent after expansion
1062/// would otherwise flatten it out of [`BoundariesListing::zones`].
1063#[derive(Debug, Clone, Serialize)]
1064#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1065#[allow(
1066    dead_code,
1067    reason = "schema-source-of-truth: see `ListBoundariesOutput`."
1068)]
1069pub struct BoundariesListLogicalGroup {
1070    /// Logical parent zone name as authored by the user.
1071    pub name: String,
1072    /// Discovered child zone names in stable directory-sorted order.
1073    pub children: Vec<String>,
1074    /// Verbatim `autoDiscover` strings from the user's config (not
1075    /// normalized) so round-trip tooling can match byte-for-byte.
1076    pub auto_discover: Vec<String>,
1077    /// Why [`Self::children`] is what it is.
1078    pub status: fallow_config::LogicalGroupStatus,
1079    /// Position of the parent zone in the user's pre-expansion `zones[]`.
1080    pub source_zone_index: usize,
1081    /// Sum of `file_count` across [`Self::children`] plus the fallback
1082    /// zone's `file_count` when present.
1083    pub file_count: usize,
1084    /// Pre-expansion rule keyed on the parent name, when the user wrote
1085    /// one.
1086    #[serde(skip_serializing_if = "Option::is_none")]
1087    pub authored_rule: Option<fallow_config::AuthoredRule>,
1088    /// When the parent zone also carried explicit `patterns`, it stayed in
1089    /// [`BoundariesListing::zones`] as a fallback classifier; this is its
1090    /// name. Equal to [`Self::name`] when present.
1091    #[serde(skip_serializing_if = "Option::is_none")]
1092    pub fallback_zone: Option<String>,
1093    /// Parent zone indices merged into this group when the user declared
1094    /// the same parent name multiple times.
1095    #[serde(skip_serializing_if = "Option::is_none")]
1096    pub merged_from: Option<Vec<usize>>,
1097    /// Echo of the parent zone's `root` (subtree scope) as the user wrote
1098    /// it. `None` when the parent had no `root` field.
1099    #[serde(skip_serializing_if = "Option::is_none")]
1100    pub original_zone_root: Option<String>,
1101    /// Parallel to [`Self::children`]: for child at index `i`, the index
1102    /// into [`Self::auto_discover`] of the path that produced it. Empty
1103    /// when only one path was authored (every child trivially maps to
1104    /// index 0). `serde(default)` keeps the schema's `required` array in
1105    /// step with the runtime's `skip_serializing_if` behavior.
1106    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1107    pub child_source_indices: Vec<usize>,
1108}
1109
1110/// Typed root of every fallow `--format json` envelope shape that
1111/// serializes as a JSON object. The schema derived from this enum drives
1112/// the document-root `oneOf` in `docs/output-schema.json`, replacing the
1113/// previously hand-maintained block.
1114///
1115/// `#[serde(untagged)]` preserves wire compatibility: consumers see exactly
1116/// the same top-level keys today (`schema_version`, `version`, plus the
1117/// per-envelope shape). The schema's `oneOf` lets agents narrow by trying
1118/// variants in order; field sets differ enough that the first matching
1119/// variant is the correct one in practice. Note that [`HealthOutput`] and
1120/// [`DupesOutput`] flatten their inner body (`HealthReport` /
1121/// `DuplicationReport`) into top-level fields, so the actual
1122/// discriminators are nested-body keys such as `health_score` (health) and
1123/// `clone_groups` (dupes), NOT `report` or `groups`.
1124///
1125/// Variant order is **most-specific first**. Schemars 1 preserves
1126/// declaration order in the emitted `oneOf`, and validators that enforce
1127/// strict `oneOf` (and any future migration that adds `Deserialize`) will
1128/// try branches top-to-bottom. The required-field sets shrink as we move
1129/// down the list, with [`CombinedOutput`] last because its three required
1130/// fields (`schema_version`, `version`, `elapsed_ms`) are a strict subset
1131/// of every other variant's required set; placing it earlier would let a
1132/// `CheckOutput` payload silently match `CombinedOutput` first.
1133///
1134/// One envelope is intentionally NOT in this enum:
1135/// - `CodeClimateOutput` serializes as a bare JSON array
1136///   (`#[serde(transparent)]`) per the Code Climate / GitLab Code Quality
1137///   spec; `#[serde(tag = ...)]` cannot internally tag a non-object
1138///   variant and wrapping the array would break the spec. The root schema
1139///   carries it as a sibling `oneOf` branch alongside `FallowOutput`.
1140///
1141/// A future major release plans to switch this to
1142/// `#[serde(tag = "kind")]` for true O(1) discriminability on AI / agent
1143/// consumers, paired with a one-cycle `--legacy-envelope` opt-out flag.
1144/// Tracked under issue #384.
1145#[derive(Debug, Clone, Serialize)]
1146#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1147#[cfg_attr(
1148    feature = "schema",
1149    schemars(title = "fallow --format json (typed root)")
1150)]
1151#[serde(untagged)]
1152#[allow(
1153    dead_code,
1154    reason = "consumed at schema-emit time only; runtime code uses the per-variant envelope structs directly"
1155)]
1156pub enum FallowOutput {
1157    /// `fallow audit --format json`. Required `command: "audit"` singleton
1158    /// plus `verdict` and `summary`.
1159    Audit(AuditOutput),
1160    /// `fallow explain <issue-type> --format json`. Required `id`, `name`,
1161    /// `rationale`, `example`, `how_to_fix`, `docs`; no `schema_version`.
1162    Explain(ExplainOutput),
1163    /// `fallow --format review-github` / `--format review-gitlab`. Required
1164    /// `body`, `comments`, `meta`; no `schema_version`.
1165    ReviewEnvelope(ReviewEnvelopeOutput),
1166    /// `fallow ci reconcile-review --format json`. Required `schema`
1167    /// singleton plus `provider`, `comments`, and the various
1168    /// `*_fingerprints` arrays.
1169    ReviewReconcile(ReviewReconcileOutput),
1170    /// `fallow coverage setup --json`. Required `schema_version` singleton
1171    /// plus `framework_detected`, `members`, `commands`, `snippets`.
1172    CoverageSetup(CoverageSetupOutput),
1173    /// `fallow coverage analyze --format json`. Required
1174    /// `schema_version: "1"` singleton plus `version`, `elapsed_ms`,
1175    /// `runtime_coverage`. The `runtime_coverage` discriminator field is
1176    /// uniquely present here; ordered before broader variants so untagged
1177    /// narrowing matches `CoverageAnalyzeOutput` first.
1178    CoverageAnalyze(CoverageAnalyzeOutput),
1179    /// `fallow list --boundaries --format json`. Required `boundaries`
1180    /// sub-object; no `schema_version`.
1181    ListBoundaries(ListBoundariesOutput),
1182    /// `fallow health --format json`. Required `report: HealthReport`.
1183    Health(HealthOutput),
1184    /// `fallow dupes --format json`. Required `report: DupesReportPayload`
1185    /// (typed wrapper payload carrying `clone_groups[]: CloneGroupFinding`
1186    /// and `clone_families[]: CloneFamilyFinding`).
1187    Dupes(DupesOutput),
1188    /// `fallow check --format json --group-by <mode>`. Required `grouped_by`
1189    /// plus a `groups` array; ordered before [`Self::Check`] because the
1190    /// `grouped_by` discriminator field is uniquely present here.
1191    CheckGrouped(CheckGroupedOutput),
1192    /// `fallow check --format json` / `fallow dead-code --format json`.
1193    /// Required `total_issues` plus `summary: CheckSummary`.
1194    Check(CheckOutput),
1195    /// Bare `fallow --format json` (combined dead-code + dupes + health).
1196    /// LAST because its required-field set (`schema_version`, `version`,
1197    /// `elapsed_ms`) is a strict subset of every other variant's required
1198    /// set; placing it earlier would let untagged narrowing match a
1199    /// `CheckOutput` payload against `CombinedOutput` first.
1200    Combined(CombinedOutput),
1201}