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 /// Workspace-discovery diagnostics surfaced during config load
448 /// (issue #473). See [`CheckOutput::workspace_diagnostics`] for the full
449 /// contract; the same list is repeated on each top-level command's
450 /// envelope so single-command consumers see it without having to look at
451 /// a separate top-level field.
452 #[serde(default, skip_serializing_if = "Vec::is_empty")]
453 pub workspace_diagnostics: Vec<fallow_config::WorkspaceDiagnostic>,
454}
455
456/// Envelope emitted by `fallow dead-code --format json` (plus the `check`
457/// block inside the combined and audit envelopes).
458///
459/// The body is the full `AnalysisResults` flattened into the envelope so
460/// every issue array (`unused_files`, `unused_exports`, ...) lives at the
461/// top level, matching the existing wire shape. `entry_points` lifts the
462/// otherwise `#[serde(skip)]`'d `AnalysisResults::entry_point_summary` back
463/// into the JSON output. `summary` carries the per-category counts the
464/// JSON layer always emits.
465#[derive(Debug, Clone, Serialize)]
466#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
467#[cfg_attr(feature = "schema", schemars(title = "fallow dead-code --format json"))]
468pub struct CheckOutput {
469 /// Schema version for this output format.
470 pub schema_version: SchemaVersion,
471 /// Fallow tool version that produced this output.
472 pub version: ToolVersion,
473 /// Analysis duration in milliseconds.
474 pub elapsed_ms: ElapsedMs,
475 /// Total number of issues found across all categories.
476 pub total_issues: usize,
477 /// Entry-point detection summary. Present when the analysis populated
478 /// the metadata block; absent in synthesised fixtures.
479 #[serde(default, skip_serializing_if = "Option::is_none")]
480 pub entry_points: Option<EntryPoints>,
481 /// Per-category issue counts. Always present. When --summary is used,
482 /// individual issue arrays are omitted.
483 pub summary: CheckSummary,
484 /// All issue arrays flattened in from `AnalysisResults`.
485 #[serde(flatten)]
486 pub results: AnalysisResults,
487 /// Per-category delta comparison against a saved baseline. Only present
488 /// when `--baseline` is used (today only via the combined invocation).
489 #[serde(default, skip_serializing_if = "Option::is_none")]
490 pub baseline_deltas: Option<BaselineDeltas>,
491 /// Baseline match statistics. Only present when `--baseline` is used.
492 #[serde(default, skip_serializing_if = "Option::is_none")]
493 pub baseline: Option<BaselineMatch>,
494 /// Regression check result. Only present when `--fail-on-regression` is
495 /// used.
496 #[serde(default, skip_serializing_if = "Option::is_none")]
497 pub regression: Option<RegressionResult>,
498 /// `_meta` block with metric / rule definitions, emitted when `--explain`
499 /// is passed (always present in MCP responses).
500 #[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")]
501 pub meta: Option<Meta>,
502 /// Workspace-discovery diagnostics surfaced by
503 /// `discover_workspaces_with_diagnostics` (issue #473): malformed
504 /// declared-workspace `package.json`, glob matches with no `package.json`,
505 /// malformed `tsconfig.json`, missing tsconfig reference paths. Omitted
506 /// when empty so consumers on monorepos without discovery noise see no
507 /// new field. Pairing of `#[serde(default, skip_serializing_if = ...)]`
508 /// is required for schemars to mark the field non-required.
509 #[serde(default, skip_serializing_if = "Vec::is_empty")]
510 pub workspace_diagnostics: Vec<fallow_config::WorkspaceDiagnostic>,
511}
512
513/// Envelope emitted by `fallow dead-code --group-by ... --format json`.
514///
515/// Issues are partitioned into resolver buckets (CODEOWNERS team, directory
516/// prefix, workspace package, or GitLab CODEOWNERS section) instead of flat
517/// arrays. Each bucket carries the same issue-array shape as the ungrouped
518/// `CheckOutput` body, plus per-group `key` / `owners` / `total_issues`.
519#[derive(Debug, Clone, Serialize)]
520#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
521#[cfg_attr(
522 feature = "schema",
523 schemars(
524 title = "fallow dead-code --group-by <owner|directory|package|section> --format json"
525 )
526)]
527pub struct CheckGroupedOutput {
528 /// Schema version for this output format.
529 pub schema_version: SchemaVersion,
530 /// Fallow tool version that produced this output.
531 pub version: ToolVersion,
532 /// Analysis duration in milliseconds.
533 pub elapsed_ms: ElapsedMs,
534 /// The grouping strategy used. 'owner' groups by CODEOWNERS team,
535 /// 'directory' groups by top-level directory prefix, 'package' groups by
536 /// workspace package name, 'section' groups by GitLab CODEOWNERS
537 /// `[Section]` header name.
538 pub grouped_by: GroupByMode,
539 /// Total number of issues across all groups.
540 pub total_issues: usize,
541 /// One entry per group; each contains the same issue arrays as
542 /// `CheckOutput` plus the group key and per-group total.
543 pub groups: Vec<CheckGroupedEntry>,
544 /// `_meta` block with metric / rule definitions, emitted when `--explain`
545 /// is passed.
546 #[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")]
547 pub meta: Option<Meta>,
548}
549
550/// Single resolver bucket inside `CheckGroupedOutput`. Carries the group's
551/// identifier, optional section owners, and a per-group flattened
552/// `AnalysisResults`.
553#[derive(Debug, Clone, Serialize)]
554#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
555pub struct CheckGroupedEntry {
556 /// Group identifier produced by the resolver. For `package` grouping:
557 /// workspace package name. For `owner` grouping: the CODEOWNERS team.
558 /// For `directory` grouping: the top-level directory prefix. For
559 /// `section` grouping: the GitLab CODEOWNERS section name (or
560 /// `(no section)` / `(unowned)` for unmatched files).
561 pub key: String,
562 /// Section default owners (GitLab CODEOWNERS `[Section] @owner1
563 /// @owner2`). Emitted only when `grouped_by` is `section`. Empty for
564 /// the `(no section)` and `(unowned)` buckets.
565 #[serde(default, skip_serializing_if = "Option::is_none")]
566 pub owners: Option<Vec<String>>,
567 /// Total number of issues in this group.
568 pub total_issues: usize,
569 /// Per-group issue arrays restricted to files in this group.
570 #[serde(flatten)]
571 pub results: AnalysisResults,
572}
573
574/// Envelope emitted by `fallow health --format json` (plus the `health` block
575/// inside the combined and audit envelopes).
576///
577/// The body is `HealthReport` flattened into the envelope so every report
578/// field (`findings`, `summary`, `vital_signs`, `hotspots`, `actions_meta`,
579/// ...) lives at the top level. Grouped runs populate `grouped_by` +
580/// `groups` with per-bucket recomputed metrics. The `actions_meta`
581/// breadcrumb is modeled on `HealthReport` as an `Option<HealthActionsMeta>`
582/// and is set at construction time by the report builder when the active
583/// `HealthActionContext` requests suppress-line omission, so the schema
584/// documents the field and serde populates it natively.
585#[derive(Debug, Clone, Serialize)]
586#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
587#[cfg_attr(feature = "schema", schemars(title = "fallow health --format json"))]
588pub struct HealthOutput {
589 /// Schema version for this output format.
590 pub schema_version: SchemaVersion,
591 /// Fallow tool version that produced this output.
592 pub version: ToolVersion,
593 /// Analysis duration in milliseconds.
594 pub elapsed_ms: ElapsedMs,
595 /// All fields from `HealthReport` flattened in so the wire shape stays
596 /// a single object.
597 #[serde(flatten)]
598 pub report: HealthReport,
599 /// Resolver mode used when --group-by is active. Present only on grouped
600 /// output. The top-level `vital_signs`, `health_score`, and `summary` keep
601 /// the active run scope (for example after --workspace); per-group versions
602 /// live inside each entry of `groups`.
603 #[serde(default, skip_serializing_if = "Option::is_none")]
604 pub grouped_by: Option<GroupByMode>,
605 /// Per-group health output, present only when `--group-by` is active.
606 /// Each group recomputes its own `vital_signs` and `health_score` from
607 /// the files in that group, mirroring how `--workspace` scopes a single
608 /// subset.
609 #[serde(default, skip_serializing_if = "Option::is_none")]
610 pub groups: Option<Vec<HealthGroup>>,
611 /// `_meta` block with metric / rule definitions, emitted when `--explain`
612 /// is passed (always present in MCP responses).
613 #[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")]
614 pub meta: Option<Meta>,
615 /// Workspace-discovery diagnostics surfaced during config load
616 /// (issue #473). Mirror of [`CheckOutput::workspace_diagnostics`] so
617 /// stand-alone `fallow health --format json` consumers see the same
618 /// signal.
619 #[serde(default, skip_serializing_if = "Vec::is_empty")]
620 pub workspace_diagnostics: Vec<fallow_config::WorkspaceDiagnostic>,
621}
622
623/// Envelope emitted by `fallow explain <issue-type> --format json`.
624///
625/// Standalone rule explanation. This command does not run project analysis
626/// and intentionally returns a compact object without `schema_version` /
627/// `version` metadata; consumers that need those should call any other
628/// fallow JSON-producing command.
629#[derive(Debug, Clone, Serialize)]
630#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
631#[cfg_attr(
632 feature = "schema",
633 schemars(title = "fallow explain <issue-type> --format json")
634)]
635#[serde(deny_unknown_fields)]
636pub struct ExplainOutput {
637 /// Canonical rule id, for example `fallow/unused-export`.
638 pub id: String,
639 /// Human-readable rule name.
640 pub name: String,
641 /// Short one-line explanation of the issue.
642 pub summary: String,
643 /// Why the issue matters and what fallow checks.
644 pub rationale: String,
645 /// Concrete example of the finding.
646 pub example: String,
647 /// Recommended fix or suppression guidance.
648 pub how_to_fix: String,
649 /// Docs URL for the rule.
650 pub docs: String,
651}
652
653/// Envelope emitted by `fallow --format codeclimate` and
654/// `fallow --format gitlab-codequality`. GitLab Code Quality consumes the
655/// same shape. The wire form is a bare JSON array, not an object.
656#[derive(Debug, Clone, Serialize)]
657#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
658#[cfg_attr(
659 feature = "schema",
660 schemars(title = "fallow --format codeclimate / gitlab-codequality")
661)]
662#[serde(transparent)]
663#[allow(
664 dead_code,
665 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."
666)]
667pub struct CodeClimateOutput(pub Vec<CodeClimateIssue>);
668
669/// Single CodeClimate-compatible issue inside [`CodeClimateOutput`].
670#[derive(Debug, Clone, Serialize)]
671#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
672pub struct CodeClimateIssue {
673 /// Always the literal string `"issue"`.
674 #[serde(rename = "type")]
675 pub kind: CodeClimateIssueKind,
676 /// Fallow rule identifier (always starts with `fallow/`).
677 pub check_name: String,
678 /// Human-readable description of the finding.
679 pub description: String,
680 /// Free-form categories applied by the report renderer.
681 pub categories: Vec<String>,
682 /// CodeClimate-style severity.
683 pub severity: CodeClimateSeverity,
684 /// Stable fingerprint used by CI dashboards to deduplicate findings
685 /// across runs.
686 pub fingerprint: String,
687 /// File path + start line of the finding.
688 pub location: CodeClimateLocation,
689}
690
691/// Discriminator value for [`CodeClimateIssue::kind`].
692#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
693#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
694#[serde(rename_all = "lowercase")]
695pub enum CodeClimateIssueKind {
696 /// The only valid CodeClimate type today.
697 Issue,
698}
699
700/// CodeClimate severity scale.
701#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
702#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
703#[serde(rename_all = "lowercase")]
704pub enum CodeClimateSeverity {
705 /// Informational. Reserved for future severity mappings; not produced
706 /// by the current runtime path (which only emits Minor / Major /
707 /// Critical via `severity_to_codeclimate` and the health / runtime-
708 /// coverage match arms).
709 #[allow(
710 dead_code,
711 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."
712 )]
713 Info,
714 /// Minor finding.
715 Minor,
716 /// Major finding.
717 Major,
718 /// Critical finding.
719 Critical,
720 /// Blocker (highest severity). Reserved for future severity
721 /// mappings; not produced by the current runtime path.
722 #[allow(
723 dead_code,
724 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."
725 )]
726 Blocker,
727}
728
729/// Location block inside [`CodeClimateIssue::location`].
730#[derive(Debug, Clone, Serialize)]
731#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
732pub struct CodeClimateLocation {
733 /// File path relative to the analysed root.
734 pub path: String,
735 /// Wrapper carrying the begin line so the schema lines up with
736 /// CodeClimate's spec.
737 pub lines: CodeClimateLines,
738}
739
740/// `lines.begin` for [`CodeClimateLocation`].
741#[derive(Debug, Clone, Copy, Serialize)]
742#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
743pub struct CodeClimateLines {
744 /// 1-based start line.
745 pub begin: u32,
746}
747
748/// Envelope emitted by `fallow --format review-github` / `review-gitlab`.
749/// Consumed by `action/scripts/review.sh` and `ci/scripts/review.sh` to
750/// post inline PR / MR review comments.
751#[derive(Debug, Clone, Serialize)]
752#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
753#[cfg_attr(
754 feature = "schema",
755 schemars(title = "fallow --format review-github / review-gitlab")
756)]
757pub struct ReviewEnvelopeOutput {
758 /// GitHub review event. Omitted for GitLab.
759 #[serde(default, skip_serializing_if = "Option::is_none")]
760 pub event: Option<ReviewEnvelopeEvent>,
761 /// Review summary body (rendered above per-line comments). Deprecated in
762 /// v2 envelopes: prefer [`summary.body`](`ReviewEnvelopeSummary::body`),
763 /// which is byte-identical to this field but carries a stable
764 /// fingerprint for reconciliation. Kept on v2 emit so v1 consumers that
765 /// only look at `body` keep working.
766 pub body: String,
767 /// Sticky summary block (v2). Always present on v2 emit. Consumers
768 /// reconcile a single sticky PR/MR summary comment by
769 /// [`ReviewEnvelopeSummary::fingerprint`] matching, then upsert
770 /// [`ReviewEnvelopeSummary::body`] in place. Synthesized empty when
771 /// deserializing v1 historical input.
772 #[serde(default = "ReviewEnvelopeSummary::empty_default")]
773 pub summary: ReviewEnvelopeSummary,
774 /// Per-line comments. Each is either a [`GitHubReviewComment`] or a
775 /// [`GitLabReviewComment`] depending on `meta.provider`.
776 pub comments: Vec<ReviewComment>,
777 /// Regex consumers run against every existing PR/MR comment body to
778 /// extract a fallow-emitted fingerprint marker. Capture group 1 is the
779 /// fingerprint string (a bare 16-char hex hash for single-finding
780 /// comments, or `<kind>:<16-char-hex>` for compositions such as
781 /// `merged:` for same-line collapsed comments).
782 ///
783 /// The pattern is anchored with `^` / `$` and relies on multiline
784 /// matching to anchor at line boundaries inside a multi-line comment
785 /// body. Multiline is NOT baked into the pattern via `(?m)` (which
786 /// JavaScript RegExp rejects as `Invalid group`); instead the consumer
787 /// passes [`Self::marker_regex_flags`] as the flags argument to its
788 /// regex engine. JavaScript: `new RegExp(env.marker_regex,
789 /// env.marker_regex_flags)`. Rust: `regex::RegexBuilder::new(pat)
790 /// .multi_line(flags.contains('m')).build()` (or any equivalent).
791 #[serde(default = "default_marker_regex")]
792 pub marker_regex: String,
793 /// Flags consumers pass alongside [`Self::marker_regex`] when
794 /// constructing their regex engine. Currently always `"m"` (multiline
795 /// so the anchored `^` / `$` match at every line boundary within a
796 /// comment body). Emitting flags as a separate field instead of
797 /// baking `(?m)` into the pattern keeps the wire compatible with
798 /// JavaScript RegExp, which rejects inline flag groups outside a
799 /// `(?flags:X)` grouping.
800 #[serde(default = "default_marker_regex_flags")]
801 pub marker_regex_flags: String,
802 /// Envelope metadata block.
803 pub meta: ReviewEnvelopeMeta,
804}
805
806/// Default for [`ReviewEnvelopeOutput::marker_regex`]. The canonical regex is
807/// stable across the v2 schema. Consumers that hardcode this string instead
808/// of reading the field stay correct until a v3 bump.
809#[must_use]
810pub fn default_marker_regex() -> String {
811 MARKER_REGEX_V2.to_owned()
812}
813
814/// Default for [`ReviewEnvelopeOutput::marker_regex_flags`]. Always `"m"`
815/// today; emitted as a sibling field rather than baked into the regex
816/// because JavaScript RegExp rejects the standalone `(?m)` inline flag
817/// group with `SyntaxError: Invalid regular expression ... Invalid group`.
818#[must_use]
819pub fn default_marker_regex_flags() -> String {
820 MARKER_REGEX_FLAGS_V2.to_owned()
821}
822
823/// Canonical v2 marker-regex literal. Mirrored by
824/// [`MARKER_PREFIX_V2`](`crate::report::ci::review::MARKER_PREFIX_V2`) on the
825/// render side; if you change one, change the other and refresh both
826/// snapshots. NO `(?m)` baked into the pattern; consumers pass
827/// [`MARKER_REGEX_FLAGS_V2`] as the second arg to their regex engine so
828/// the `^` / `$` anchors match at line boundaries inside a multi-line
829/// comment body. Pairing pattern + flags lets the wire stay compatible
830/// with both Rust's `regex` crate (via `RegexBuilder::multi_line(true)`)
831/// and JavaScript RegExp (`new RegExp(pat, "m")`).
832pub const MARKER_REGEX_V2: &str =
833 r"^<!-- fallow-fingerprint:v2: ((?:[a-z]+:)?[0-9a-f]{16}) -->\s*$";
834
835/// Canonical v2 marker-regex flags. Paired with [`MARKER_REGEX_V2`].
836pub const MARKER_REGEX_FLAGS_V2: &str = "m";
837
838/// Summary block on [`ReviewEnvelopeOutput`]. Always present on v2 emit;
839/// `serde(default)` keeps schemars from marking it required so a future
840/// Deserialize derivation against v1 historical input synthesizes an empty
841/// value rather than erroring.
842#[derive(Debug, Clone, Serialize, Default)]
843#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
844pub struct ReviewEnvelopeSummary {
845 /// Markdown body of the summary. Byte-identical to the legacy top-level
846 /// [`ReviewEnvelopeOutput::body`] field; the duplication is intentional
847 /// so v1 consumers see no behavior change.
848 pub body: String,
849 /// FNV-1a 64-bit hash (16 lowercase hex chars) of the summary body
850 /// BEFORE the trailing fallow-fingerprint marker line is appended.
851 /// (Computing the hash from the post-marker body would be circular:
852 /// the marker contains the fingerprint, so the fingerprint cannot
853 /// depend on the marker.) To reproduce from [`Self::body`], strip the
854 /// line matching [`ReviewEnvelopeOutput::marker_regex`] together with
855 /// its leading separator newlines and hash the remainder. Stable
856 /// across runs that produce the same summary content; consumers
857 /// upsert the sticky summary comment by matching this fingerprint
858 /// against the marker_regex extraction of every existing comment body.
859 pub fingerprint: String,
860}
861
862impl ReviewEnvelopeSummary {
863 /// Empty-default factory used by `#[serde(default = "...")]` on
864 /// [`ReviewEnvelopeOutput::summary`]. Returns a zero-body, zero-
865 /// fingerprint value so v1 historical inputs deserialize without
866 /// inventing fabricated content.
867 ///
868 /// Referenced from the `default = "ReviewEnvelopeSummary::empty_default"`
869 /// attribute on the field; serde's macro resolves it lazily at derive
870 /// time without registering a direct call site, so without the explicit
871 /// allow the function tripped `dead_code` until a Deserialize derive
872 /// pulls it in. schemars also reads the attribute to mark the field
873 /// non-required in the schema's `required[]`.
874 #[must_use]
875 #[allow(
876 dead_code,
877 reason = "referenced via serde default = \"...\" attr; no direct callsite until Deserialize is derived"
878 )]
879 pub fn empty_default() -> Self {
880 Self::default()
881 }
882}
883
884/// Singleton GitHub review-event marker.
885#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
886#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
887pub enum ReviewEnvelopeEvent {
888 /// GitHub review event for an unblocking comment review.
889 #[serde(rename = "COMMENT")]
890 Comment,
891}
892
893/// Per-line review comment. Schema is an `anyOf` between GitHub and GitLab
894/// shapes; at runtime every entry in a single envelope comes from the same
895/// provider because the envelope is built from one provider's branch in
896/// `crates/cli/src/report/ci/review.rs::render_review_envelope`.
897#[derive(Debug, Clone, Serialize)]
898#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
899#[serde(untagged)]
900pub enum ReviewComment {
901 /// GitHub-shaped pull-request review comment.
902 GitHub(GitHubReviewComment),
903 /// GitLab-shaped merge-request discussion comment.
904 GitLab(GitLabReviewComment),
905}
906
907/// GitHub pull-request review comment.
908#[derive(Debug, Clone, Serialize)]
909#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
910pub struct GitHubReviewComment {
911 /// File path the comment targets, repo-root relative.
912 pub path: String,
913 /// 1-indexed line number the comment targets.
914 pub line: u32,
915 /// Always the literal string `"RIGHT"`; GitHub review comments target
916 /// current-state/new-side lines; deletion-side comments are not modeled
917 /// yet.
918 pub side: GitHubReviewSide,
919 /// Markdown body of the comment.
920 pub body: String,
921 /// Stable fingerprint for the comment, used by `fallow ci
922 /// reconcile-review` to detect carryover comments across PR revisions.
923 /// For single-finding comments the value is a bare 16-char hex FNV-1a
924 /// hash. For merged comments (multiple findings on the same path:line)
925 /// the value is `merged:<16-char hex>` over the sorted constituent
926 /// fingerprints, so the identity shifts whenever constituent findings
927 /// change membership. Bundled wrappers and `fallow ci reconcile-review`
928 /// dedupe on this primary fingerprint only; consumers wanting
929 /// update-in-place reconciliation (preserving reviewer reply threads
930 /// across content changes) implement their own identity tracking via
931 /// `marker_regex`.
932 pub fingerprint: String,
933 /// True when [`Self::body`] was truncated to fit a downstream provider's
934 /// note-size budget (today: 65,536 bytes). The body retains the closing
935 /// fallow-fingerprint marker so reconciliation continues to work after
936 /// truncation.
937 ///
938 /// Co-presence invariant: `truncated == true` always implies the body
939 /// contains an inline `<!-- fallow-truncated -->` HTML marker and the
940 /// `> Body truncated by fallow.` blockquote breadcrumb, and vice versa.
941 /// All three signals are emitted together; consumers may use any one
942 /// (the typed boolean is the authoritative machine-readable signal).
943 #[serde(default, skip_serializing_if = "is_false")]
944 pub truncated: bool,
945}
946
947/// Singleton side discriminator for [`GitHubReviewComment::side`].
948#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
949#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
950pub enum GitHubReviewSide {
951 /// GitHub review comments target the new-side line range.
952 #[serde(rename = "RIGHT")]
953 Right,
954}
955
956/// GitLab merge-request discussion comment.
957#[derive(Debug, Clone, Serialize)]
958#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
959pub struct GitLabReviewComment {
960 /// Markdown body of the comment.
961 pub body: String,
962 /// Position block describing where the comment attaches on the diff.
963 pub position: GitLabReviewPosition,
964 /// Stable fingerprint for the comment. See
965 /// [`GitHubReviewComment::fingerprint`] for the single vs `merged:`
966 /// shape contract; semantics are identical across providers.
967 pub fingerprint: String,
968 /// True when [`Self::body`] was truncated to fit GitLab's note-size
969 /// budget. See [`GitHubReviewComment::truncated`] for the full
970 /// co-presence invariant with the inline HTML marker and human
971 /// blockquote breadcrumb.
972 #[serde(default, skip_serializing_if = "is_false")]
973 pub truncated: bool,
974}
975
976/// Helper for `skip_serializing_if = "is_false"` on `truncated` fields above.
977/// Serde calls `skip_serializing_if` with `&T`, so the reference signature
978/// is dictated by the trait and cannot be changed to pass-by-value. Uses
979/// `#[allow]` rather than `#[expect]` per `.claude/rules/code-quality.md`:
980/// `trivially_copy_pass_by_ref` is a pedantic lint that fires inconsistently
981/// across build configurations (lib vs bin), which would trigger
982/// `unfulfilled_lint_expectations` under `#[expect]`.
983#[must_use]
984#[allow(
985 clippy::trivially_copy_pass_by_ref,
986 reason = "serde's skip_serializing_if requires fn(&T) -> bool"
987)]
988pub fn is_false(value: &bool) -> bool {
989 !*value
990}
991
992/// `position` block inside [`GitLabReviewComment`]. Mirrors the GitLab
993/// merge-request discussion-position API.
994#[derive(Debug, Clone, Serialize)]
995#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
996pub struct GitLabReviewPosition {
997 /// Merge-request base SHA.
998 #[serde(default, skip_serializing_if = "Option::is_none")]
999 pub base_sha: Option<String>,
1000 /// Merge-request start SHA.
1001 #[serde(default, skip_serializing_if = "Option::is_none")]
1002 pub start_sha: Option<String>,
1003 /// Merge-request head SHA.
1004 #[serde(default, skip_serializing_if = "Option::is_none")]
1005 pub head_sha: Option<String>,
1006 /// Always `"text"` today.
1007 pub position_type: GitLabReviewPositionType,
1008 /// File path on the base side.
1009 pub old_path: String,
1010 /// File path on the head side.
1011 pub new_path: String,
1012 /// 1-indexed line on the head side.
1013 pub new_line: u32,
1014}
1015
1016/// Singleton position-type discriminator for [`GitLabReviewPosition`].
1017#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
1018#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1019#[serde(rename_all = "lowercase")]
1020pub enum GitLabReviewPositionType {
1021 /// Plain-text diff position (only kind fallow emits today).
1022 Text,
1023}
1024
1025/// `meta` block inside [`ReviewEnvelopeOutput`].
1026#[derive(Debug, Clone, Serialize)]
1027#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1028pub struct ReviewEnvelopeMeta {
1029 /// Envelope schema marker. v2 emit always tags
1030 /// `fallow-review-envelope/v2`; v1 is recognized on deserialize for
1031 /// backward-compat with historical envelopes captured before the v2
1032 /// migration.
1033 pub schema: ReviewEnvelopeSchema,
1034 /// Which provider this envelope is shaped for.
1035 pub provider: ReviewProvider,
1036 /// Check conclusion derived from the underlying findings. Emitted only
1037 /// for GitHub envelopes today.
1038 #[serde(default, skip_serializing_if = "Option::is_none")]
1039 pub check_conclusion: Option<ReviewCheckConclusion>,
1040}
1041
1042/// Schema-version discriminator for the review envelope.
1043#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
1044#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1045pub enum ReviewEnvelopeSchema {
1046 /// First release of the review envelope format. Historical only; no v1
1047 /// emit path remains on the current code. Retained on the enum so a
1048 /// future Deserialize derive can still parse v1 captures (e.g. from
1049 /// committed snapshots predating the issue #528 migration) without
1050 /// erroring on an unknown variant.
1051 #[serde(rename = "fallow-review-envelope/v1")]
1052 #[allow(
1053 dead_code,
1054 reason = "kept for forward-compat with v1 historical inputs once Deserialize is derived"
1055 )]
1056 V1,
1057 /// Issue #528 evolution. Adds (1) the [`ReviewEnvelopeOutput::summary`]
1058 /// block, (2) [`ReviewEnvelopeOutput::marker_regex`], (3) same-line
1059 /// `(path, line)` merging in `comments[]` with a
1060 /// `merged:<16-char hash>` primary fingerprint over sorted constituent
1061 /// fingerprints (identity shifts whenever the set of constituents
1062 /// changes, so the bundled skip-if-fingerprint-exists wrappers
1063 /// correctly re-post on content change), (4) UTF-8-safe body
1064 /// truncation at the GitLab/GitHub note-size floor (65,536 bytes)
1065 /// with paired `truncated: bool` + `<!-- fallow-truncated -->`
1066 /// signals, (5) `:v2:`-namespaced marker shape
1067 /// (`<!-- fallow-fingerprint:v2: <fingerprint> -->`) preventing v1
1068 /// marker collision and user-paste spoofing, and (6) diff-aware
1069 /// `position.old_path` for renamed files on GitLab.
1070 #[serde(rename = "fallow-review-envelope/v2")]
1071 V2,
1072}
1073
1074/// Review-envelope provider tag.
1075#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
1076#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1077#[serde(rename_all = "lowercase")]
1078pub enum ReviewProvider {
1079 /// GitHub pull-request review envelope.
1080 Github,
1081 /// GitLab merge-request discussion envelope.
1082 Gitlab,
1083}
1084
1085/// `meta.check_conclusion` for the GitHub review envelope. Maps to the
1086/// GitHub Checks API conclusion field.
1087#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
1088#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1089#[serde(rename_all = "lowercase")]
1090pub enum ReviewCheckConclusion {
1091 /// No findings.
1092 Success,
1093 /// Findings but none gated as failure.
1094 Neutral,
1095 /// At least one finding gated as failure.
1096 Failure,
1097}
1098
1099/// Envelope emitted by `fallow ci reconcile-review --format json`. Used by
1100/// CI integrations to drive comment carry-over and stale-comment cleanup
1101/// across PR / MR revisions.
1102#[derive(Debug, Clone, Serialize)]
1103#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1104#[cfg_attr(
1105 feature = "schema",
1106 schemars(title = "fallow ci reconcile-review --format json")
1107)]
1108pub struct ReviewReconcileOutput {
1109 /// Envelope schema marker, always `fallow-review-reconcile/v1`.
1110 pub schema: ReviewReconcileSchema,
1111 /// Which provider this reconcile pass was for.
1112 pub provider: ReviewProvider,
1113 /// PR / MR target identifier supplied to `fallow ci reconcile-review`.
1114 /// `null` when the command ran without an explicit target.
1115 pub target: Option<String>,
1116 /// Whether the reconcile ran in dry-run mode.
1117 pub dry_run: bool,
1118 /// Number of comments in the supplied review envelope.
1119 pub comments: u32,
1120 /// Total fingerprints discovered in the supplied envelope.
1121 pub current_fingerprints: u32,
1122 /// Existing fingerprints already posted on the PR / MR.
1123 pub existing_fingerprints: u32,
1124 /// Newly-introduced fingerprints (current minus existing).
1125 pub new_fingerprints: u32,
1126 /// Stale fingerprints (existing minus current).
1127 pub stale_fingerprints: u32,
1128 /// Identifiers of the new fingerprints (subset of comments).
1129 pub new: Vec<String>,
1130 /// Identifiers of the stale fingerprints (subset of existing).
1131 pub stale: Vec<String>,
1132 /// Optional warning when the provider API was unreachable or
1133 /// auth-rejected. `null` on the happy path.
1134 pub provider_warning: Option<String>,
1135 /// Resolution comments actually posted (zero on dry runs).
1136 pub resolution_comments_posted: u32,
1137 /// Stale review threads actually resolved (zero on dry runs).
1138 pub threads_resolved: u32,
1139 /// Errors collected during apply, one entry per failure.
1140 pub apply_errors: Vec<String>,
1141}
1142
1143/// Schema-version discriminator for the review reconcile envelope.
1144#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
1145#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1146pub enum ReviewReconcileSchema {
1147 /// First release of the review reconcile format.
1148 #[serde(rename = "fallow-review-reconcile/v1")]
1149 V1,
1150}
1151
1152/// Resolver mode label for grouped envelopes (dead-code, dupes, health).
1153///
1154/// `owner` groups by CODEOWNERS team, `directory` groups by top-level
1155/// directory prefix, `package` groups by workspace package name, `section`
1156/// groups by GitLab CODEOWNERS `[Section]` header name.
1157#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
1158#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1159#[serde(rename_all = "lowercase")]
1160pub enum GroupByMode {
1161 /// Group by CODEOWNERS team.
1162 Owner,
1163 /// Group by top-level directory prefix.
1164 Directory,
1165 /// Group by workspace package name.
1166 Package,
1167 /// Group by GitLab CODEOWNERS `[Section]` header name.
1168 Section,
1169}
1170
1171// ── list --boundaries --format json envelope ────────────────────────
1172//
1173// The runtime path builds the wire shape via `serde_json::json!` in
1174// `crates/cli/src/list.rs::boundary_data_to_json`; the typed structs below
1175// exist so the drift gate can lock the schema shape against Rust source.
1176// A follow-up that swaps the runtime builder over to typed construction
1177// can land independently (out of scope for issue #384 items 3a/3b/3c).
1178
1179/// Envelope emitted by `fallow list --boundaries --format json`. Surfaces
1180/// the architecture boundary zones, rules, and (issue #373) the user's
1181/// pre-expansion `autoDiscover` logical groups so consumers can render
1182/// grouping intent that `expand_auto_discover` would otherwise flatten out
1183/// of `zones[]`.
1184#[derive(Debug, Clone, Serialize)]
1185#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1186#[cfg_attr(
1187 feature = "schema",
1188 schemars(title = "fallow list --boundaries --format json")
1189)]
1190#[allow(
1191 dead_code,
1192 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."
1193)]
1194pub struct ListBoundariesOutput {
1195 /// The boundaries section. The list command can also emit `files`,
1196 /// `plugins`, `entry_points` siblings under additional flags; those
1197 /// shapes are not part of this envelope today.
1198 pub boundaries: BoundariesListing,
1199}
1200
1201/// `boundaries` block carried by [`ListBoundariesOutput`].
1202#[derive(Debug, Clone, Serialize)]
1203#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1204#[allow(
1205 dead_code,
1206 reason = "schema-source-of-truth: see `ListBoundariesOutput`."
1207)]
1208pub struct BoundariesListing {
1209 /// `false` when the project has no `boundaries` configured; `true`
1210 /// otherwise. When `false` every array below is empty and every count
1211 /// is `0` (parity is enforced so consumers can read the counts without
1212 /// first branching on this flag).
1213 pub configured: bool,
1214 /// Length of [`Self::zones`]; emitted alongside the array for parity
1215 /// with `rule_count` / `logical_group_count`.
1216 pub zone_count: usize,
1217 /// Boundary zones after preset and `autoDiscover` expansion.
1218 pub zones: Vec<BoundariesListZone>,
1219 /// Length of [`Self::rules`].
1220 pub rule_count: usize,
1221 /// Boundary import rules, each `from -> allow[]`.
1222 pub rules: Vec<BoundariesListRule>,
1223 /// Length of [`Self::logical_groups`]. Always present (issue #373).
1224 pub logical_group_count: usize,
1225 /// Pre-expansion `autoDiscover` groups carrying the user-authored parent
1226 /// name and grouping intent (issue #373).
1227 pub logical_groups: Vec<BoundariesListLogicalGroup>,
1228}
1229
1230/// A boundary zone after preset and `autoDiscover` expansion. Each entry
1231/// classifies files into a single zone via glob patterns.
1232#[derive(Debug, Clone, Serialize)]
1233#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1234#[allow(
1235 dead_code,
1236 reason = "schema-source-of-truth: see `ListBoundariesOutput`."
1237)]
1238pub struct BoundariesListZone {
1239 /// Zone identifier as referenced in rules (e.g. `app`, `features/auth`).
1240 pub name: String,
1241 /// Compiled glob patterns. Children of an `autoDiscover` parent each
1242 /// carry a single pattern like `src/features/auth/**`.
1243 pub patterns: Vec<String>,
1244 /// Number of discovered files classified into this zone.
1245 pub file_count: usize,
1246}
1247
1248/// A boundary import rule, expanded to operate on concrete child zone
1249/// names after `autoDiscover` flattening. The user's pre-expansion rule
1250/// (keyed on the logical parent name, if any) is preserved on the
1251/// corresponding [`BoundariesListLogicalGroup::authored_rule`].
1252#[derive(Debug, Clone, Serialize)]
1253#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1254#[allow(
1255 dead_code,
1256 reason = "schema-source-of-truth: see `ListBoundariesOutput`."
1257)]
1258pub struct BoundariesListRule {
1259 /// Source zone the rule applies to.
1260 pub from: String,
1261 /// Target zones [`Self::from`] is allowed to import from. Self-imports
1262 /// are always allowed implicitly.
1263 pub allow: Vec<String>,
1264}
1265
1266/// A pre-expansion `autoDiscover` logical group surfaced for observability
1267/// (issue #373). Captured during `expand_auto_discover` so consumers can
1268/// see the user-authored parent name and grouping intent after expansion
1269/// would otherwise flatten it out of [`BoundariesListing::zones`].
1270#[derive(Debug, Clone, Serialize)]
1271#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1272#[allow(
1273 dead_code,
1274 reason = "schema-source-of-truth: see `ListBoundariesOutput`."
1275)]
1276pub struct BoundariesListLogicalGroup {
1277 /// Logical parent zone name as authored by the user.
1278 pub name: String,
1279 /// Discovered child zone names in stable directory-sorted order.
1280 pub children: Vec<String>,
1281 /// Verbatim `autoDiscover` strings from the user's config (not
1282 /// normalized) so round-trip tooling can match byte-for-byte.
1283 pub auto_discover: Vec<String>,
1284 /// Why [`Self::children`] is what it is.
1285 pub status: fallow_config::LogicalGroupStatus,
1286 /// Position of the parent zone in the user's pre-expansion `zones[]`.
1287 pub source_zone_index: usize,
1288 /// Sum of `file_count` across [`Self::children`] plus the fallback
1289 /// zone's `file_count` when present.
1290 pub file_count: usize,
1291 /// Pre-expansion rule keyed on the parent name, when the user wrote
1292 /// one.
1293 #[serde(skip_serializing_if = "Option::is_none")]
1294 pub authored_rule: Option<fallow_config::AuthoredRule>,
1295 /// When the parent zone also carried explicit `patterns`, it stayed in
1296 /// [`BoundariesListing::zones`] as a fallback classifier; this is its
1297 /// name. Equal to [`Self::name`] when present.
1298 #[serde(skip_serializing_if = "Option::is_none")]
1299 pub fallback_zone: Option<String>,
1300 /// Parent zone indices merged into this group when the user declared
1301 /// the same parent name multiple times.
1302 #[serde(skip_serializing_if = "Option::is_none")]
1303 pub merged_from: Option<Vec<usize>>,
1304 /// Echo of the parent zone's `root` (subtree scope) as the user wrote
1305 /// it. `None` when the parent had no `root` field.
1306 #[serde(skip_serializing_if = "Option::is_none")]
1307 pub original_zone_root: Option<String>,
1308 /// Parallel to [`Self::children`]: for child at index `i`, the index
1309 /// into [`Self::auto_discover`] of the path that produced it. Empty
1310 /// when only one path was authored (every child trivially maps to
1311 /// index 0). `serde(default)` keeps the schema's `required` array in
1312 /// step with the runtime's `skip_serializing_if` behavior.
1313 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1314 pub child_source_indices: Vec<usize>,
1315}
1316
1317/// Typed root of every fallow `--format json` envelope shape that
1318/// serializes as a JSON object. The schema derived from this enum drives
1319/// the document-root `oneOf` in `docs/output-schema.json`, replacing the
1320/// previously hand-maintained block.
1321///
1322/// `#[serde(untagged)]` preserves wire compatibility: consumers see exactly
1323/// the same top-level keys today (`schema_version`, `version`, plus the
1324/// per-envelope shape). The schema's `oneOf` lets agents narrow by trying
1325/// variants in order; field sets differ enough that the first matching
1326/// variant is the correct one in practice. Note that [`HealthOutput`] and
1327/// [`DupesOutput`] flatten their inner body (`HealthReport` /
1328/// `DuplicationReport`) into top-level fields, so the actual
1329/// discriminators are nested-body keys such as `health_score` (health) and
1330/// `clone_groups` (dupes), NOT `report` or `groups`.
1331///
1332/// Variant order is **most-specific first**. Schemars 1 preserves
1333/// declaration order in the emitted `oneOf`, and validators that enforce
1334/// strict `oneOf` (and any future migration that adds `Deserialize`) will
1335/// try branches top-to-bottom. The required-field sets shrink as we move
1336/// down the list, with [`CombinedOutput`] last because its three required
1337/// fields (`schema_version`, `version`, `elapsed_ms`) are a strict subset
1338/// of every other variant's required set; placing it earlier would let a
1339/// `CheckOutput` payload silently match `CombinedOutput` first.
1340///
1341/// One envelope is intentionally NOT in this enum:
1342/// - `CodeClimateOutput` serializes as a bare JSON array
1343/// (`#[serde(transparent)]`) per the Code Climate / GitLab Code Quality
1344/// spec; `#[serde(tag = ...)]` cannot internally tag a non-object
1345/// variant and wrapping the array would break the spec. The root schema
1346/// carries it as a sibling `oneOf` branch alongside `FallowOutput`.
1347///
1348/// A future major release plans to switch this to
1349/// `#[serde(tag = "kind")]` for true O(1) discriminability on AI / agent
1350/// consumers, paired with a one-cycle `--legacy-envelope` opt-out flag.
1351/// Tracked under issue #384.
1352#[derive(Debug, Clone, Serialize)]
1353#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1354#[cfg_attr(
1355 feature = "schema",
1356 schemars(title = "fallow --format json (typed root)")
1357)]
1358#[serde(untagged)]
1359#[allow(
1360 dead_code,
1361 reason = "consumed at schema-emit time only; runtime code uses the per-variant envelope structs directly"
1362)]
1363pub enum FallowOutput {
1364 /// `fallow audit --format json`. Required `command: "audit"` singleton
1365 /// plus `verdict` and `summary`.
1366 Audit(AuditOutput),
1367 /// `fallow explain <issue-type> --format json`. Required `id`, `name`,
1368 /// `rationale`, `example`, `how_to_fix`, `docs`; no `schema_version`.
1369 Explain(ExplainOutput),
1370 /// `fallow --format review-github` / `--format review-gitlab`. Required
1371 /// `body`, `comments`, `meta`; no `schema_version`.
1372 ReviewEnvelope(ReviewEnvelopeOutput),
1373 /// `fallow ci reconcile-review --format json`. Required `schema`
1374 /// singleton plus `provider`, `comments`, and the various
1375 /// `*_fingerprints` arrays.
1376 ReviewReconcile(ReviewReconcileOutput),
1377 /// `fallow coverage setup --json`. Required `schema_version` singleton
1378 /// plus `framework_detected`, `members`, `commands`, `snippets`.
1379 CoverageSetup(CoverageSetupOutput),
1380 /// `fallow coverage analyze --format json`. Required
1381 /// `schema_version: "1"` singleton plus `version`, `elapsed_ms`,
1382 /// `runtime_coverage`. The `runtime_coverage` discriminator field is
1383 /// uniquely present here; ordered before broader variants so untagged
1384 /// narrowing matches `CoverageAnalyzeOutput` first.
1385 CoverageAnalyze(CoverageAnalyzeOutput),
1386 /// `fallow list --boundaries --format json`. Required `boundaries`
1387 /// sub-object; no `schema_version`.
1388 ListBoundaries(ListBoundariesOutput),
1389 /// `fallow health --format json`. Required `report: HealthReport`.
1390 Health(HealthOutput),
1391 /// `fallow dupes --format json`. Required `report: DupesReportPayload`
1392 /// (typed wrapper payload carrying `clone_groups[]: CloneGroupFinding`
1393 /// and `clone_families[]: CloneFamilyFinding`).
1394 Dupes(DupesOutput),
1395 /// `fallow check --format json --group-by <mode>`. Required `grouped_by`
1396 /// plus a `groups` array; ordered before [`Self::Check`] because the
1397 /// `grouped_by` discriminator field is uniquely present here.
1398 CheckGrouped(CheckGroupedOutput),
1399 /// `fallow check --format json` / `fallow dead-code --format json`.
1400 /// Required `total_issues` plus `summary: CheckSummary`.
1401 Check(CheckOutput),
1402 /// Bare `fallow --format json` (combined dead-code + dupes + health).
1403 /// LAST because its required-field set (`schema_version`, `version`,
1404 /// `elapsed_ms`) is a strict subset of every other variant's required
1405 /// set; placing it earlier would let untagged narrowing match a
1406 /// `CheckOutput` payload against `CombinedOutput` first.
1407 Combined(CombinedOutput),
1408}