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