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}