Skip to main content

fallow_cli/
output_envelope.rs

1//! Typed envelope structs for the JSON output contract.
2//!
3//! Each top-level fallow command (`check`, `dupes`, `health`, `audit`,
4//! `explain`, `coverage setup`, plus the bare combined invocation and the
5//! CodeClimate / review-envelope side outputs) emits a distinct envelope
6//! shape. This module is the schema-side source of truth for those shapes:
7//! every type carries `Serialize` plus a cfg-gated `JsonSchema` derive so the
8//! committed `docs/output-schema.json` can be regenerated from Rust.
9//!
10//! Living in `fallow-cli` rather than `fallow-types` because the body fields
11//! pull in `DuplicationReport` (from `fallow-core`) and `HealthReport` (from
12//! this crate), neither of which is reachable from the lower-level types
13//! crate. The shared utility shapes (`SchemaVersion`, `Meta`,
14//! `BaselineDeltas`, ...) still live in `fallow_types::envelope` because they
15//! depend only on serde primitives.
16//!
17//! Runtime construction of these envelopes happens in
18//! `crates/cli/src/report/json.rs`; the JSON layer builds an envelope struct
19//! and converts it to a `serde_json::Value` via `serde_json::to_value`. The
20//! only remaining work on the `Value` tree is path relativisation
21//! (`strip_root_prefix`) and the cross-result-type suppress-line action
22//! harmonizer (`harmonize_multi_kind_suppress_line_actions`); both span
23//! envelope boundaries that typed wrappers do not.
24//!
25//! Runtime emit for the CodeClimate, review-envelope, and coverage-setup
26//! shapes now flows through the typed structs in this module:
27//! `crates/cli/src/report/codeclimate.rs` constructs `CodeClimateIssue`
28//! directly via `cc_issue`,
29//! `crates/cli/src/report/ci/review.rs::render_review_envelope` constructs
30//! `ReviewEnvelopeOutput`, and
31//! `crates/cli/src/coverage/mod.rs::build_setup_envelope` constructs
32//! `CoverageSetupOutput`. The wire `serde_json::Value` is the
33//! `serde_json::to_value(&envelope)` of those typed structs, so adding a
34//! field to one of those structs automatically flows to the wire. The
35//! `AuditOutput` and `ListBoundariesOutput` families remain
36//! schema-source-of-truth only (their wire is still hand-built via
37//! `serde_json::json!`); the drift gate keeps them honest.
38
39use fallow_core::results::AnalysisResults;
40use fallow_types::envelope::{
41    BaselineDeltas, BaselineMatch, CheckSummary, ElapsedMs, EntryPoints, Meta, RegressionResult,
42    SchemaVersion, ToolVersion,
43};
44use serde::Serialize;
45
46use crate::audit::{AuditAttribution, AuditSummary, AuditVerdict};
47use crate::health_types::{HealthGroup, HealthReport, RuntimeCoverageReport};
48use crate::output_dupes::DupesReportPayload;
49use crate::report::dupes_grouping::DuplicationGroup;
50
51/// Envelope emitted by `fallow coverage setup --json`. Deterministic
52/// agent-readable runtime coverage setup instructions. In workspaces,
53/// `members` carries one entry per detected runtime package; `runtime_targets`
54/// is the union of all member targets.
55///
56/// Constructed at runtime by
57/// `crates/cli/src/coverage/mod.rs::build_setup_envelope`; the wire is
58/// `serde_json::to_value(&envelope)`. The drift gate keeps this struct
59/// aligned with `docs/output-schema.json`.
60#[derive(Debug, Clone, Serialize)]
61#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
62#[cfg_attr(feature = "schema", schemars(title = "fallow coverage setup --json"))]
63pub struct CoverageSetupOutput {
64    /// Standalone coverage setup envelope version (always `"1"`).
65    pub schema_version: CoverageSetupSchemaVersion,
66    /// Primary detected runtime framework. For workspaces this mirrors the
67    /// first emitted runtime member; `unknown` means no runtime member was
68    /// detected.
69    pub framework_detected: CoverageSetupFramework,
70    /// Detected JavaScript package manager. `null` when none could be
71    /// resolved.
72    pub package_manager: Option<CoverageSetupPackageManager>,
73    /// Union of runtime targets across emitted members.
74    pub runtime_targets: Vec<CoverageSetupRuntimeTarget>,
75    /// Per-runtime-workspace setup recipes. Pure aggregator roots and
76    /// build-only library packages are omitted.
77    pub members: Vec<CoverageSetupMember>,
78    /// Always `null` today. Reserved for a future "config has been written
79    /// to disk" indicator.
80    pub config_written: Option<serde_json::Value>,
81    /// Shell commands the agent should run from the workspace root.
82    pub commands: Vec<String>,
83    /// Compatibility copy of the primary member's files, with workspace
84    /// prefixes when the primary member is not the root.
85    pub files_to_edit: Vec<CoverageSetupFileToEdit>,
86    /// Compatibility copy of the primary member's snippets, with workspace
87    /// prefixes when the primary member is not the root.
88    pub snippets: Vec<CoverageSetupSnippet>,
89    /// Optional Dockerfile RUN/COPY snippet to enable the beacon in
90    /// containerised deployments.
91    pub dockerfile_snippet: Option<String>,
92    /// Ordered next-step instructions for the agent / human operator.
93    pub next_steps: Vec<String>,
94    /// Non-fatal warnings raised during setup detection.
95    pub warnings: Vec<String>,
96    /// `_meta` block emitted only when `--explain` is passed.
97    #[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")]
98    pub meta: Option<serde_json::Value>,
99}
100
101/// Singleton schema-version discriminator for [`CoverageSetupOutput`].
102#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
103#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
104pub enum CoverageSetupSchemaVersion {
105    /// First release of the coverage setup envelope.
106    #[serde(rename = "1")]
107    V1,
108}
109
110/// Framework label inside coverage setup output.
111#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
112#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
113#[serde(rename_all = "snake_case")]
114pub enum CoverageSetupFramework {
115    /// Next.js (`framework: "nextjs"`).
116    #[serde(rename = "nextjs")]
117    NextJs,
118    /// NestJS (`framework: "nestjs"`).
119    #[serde(rename = "nestjs")]
120    NestJs,
121    /// Nuxt (`framework: "nuxt"`).
122    Nuxt,
123    /// SvelteKit (`framework: "sveltekit"`).
124    #[serde(rename = "sveltekit")]
125    SvelteKit,
126    /// Astro (`framework: "astro"`).
127    Astro,
128    /// Remix (`framework: "remix"`).
129    Remix,
130    /// Vite (`framework: "vite"`).
131    Vite,
132    /// Plain Node.js (no framework).
133    PlainNode,
134    /// Could not determine.
135    Unknown,
136}
137
138/// Package manager label inside coverage setup output.
139#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
140#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
141#[serde(rename_all = "lowercase")]
142pub enum CoverageSetupPackageManager {
143    /// `npm`.
144    Npm,
145    /// `pnpm`.
146    Pnpm,
147    /// `yarn`.
148    Yarn,
149    /// `bun`.
150    Bun,
151}
152
153/// Runtime target inside coverage setup output.
154#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
155#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
156#[serde(rename_all = "lowercase")]
157pub enum CoverageSetupRuntimeTarget {
158    /// Node.js runtime target.
159    Node,
160    /// Browser runtime target.
161    Browser,
162}
163
164/// Per-workspace setup recipe inside [`CoverageSetupOutput::members`].
165#[derive(Debug, Clone, Serialize)]
166#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
167pub struct CoverageSetupMember {
168    /// Workspace package name (or root marker for single-package projects).
169    pub name: String,
170    /// Workspace path relative to the analysed root, or `.` for the root
171    /// member.
172    pub path: String,
173    /// Framework detected for this member.
174    pub framework_detected: CoverageSetupFramework,
175    /// Package manager detected for this member.
176    pub package_manager: Option<CoverageSetupPackageManager>,
177    /// Runtime targets supported by this member's framework.
178    pub runtime_targets: Vec<CoverageSetupRuntimeTarget>,
179    /// Files the agent should edit to wire in the beacon.
180    pub files_to_edit: Vec<CoverageSetupFileToEdit>,
181    /// Code snippets the agent should paste into the edited files.
182    pub snippets: Vec<CoverageSetupSnippet>,
183    /// Optional Dockerfile snippet specific to this member.
184    pub dockerfile_snippet: Option<String>,
185    /// Member-scoped warnings.
186    pub warnings: Vec<String>,
187}
188
189/// Single file to edit inside [`CoverageSetupMember::files_to_edit`] or
190/// [`CoverageSetupOutput::files_to_edit`].
191#[derive(Debug, Clone, Serialize)]
192#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
193pub struct CoverageSetupFileToEdit {
194    /// Workspace-relative path to the file to edit.
195    pub path: String,
196    /// Why the file needs editing (e.g. `"Mount the beacon middleware"`).
197    pub reason: String,
198}
199
200/// Single code snippet inside [`CoverageSetupMember::snippets`] or
201/// [`CoverageSetupOutput::snippets`].
202#[derive(Debug, Clone, Serialize)]
203#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
204pub struct CoverageSetupSnippet {
205    /// Short label identifying the snippet (used by the human renderer).
206    pub label: String,
207    /// Workspace-relative path the snippet should be pasted into.
208    pub path: String,
209    /// Snippet content (literal source text).
210    pub content: String,
211}
212
213/// Envelope emitted by `fallow audit --format json`. Combines dead code,
214/// complexity, and duplication scoped to changed files with a verdict
215/// (`pass` / `warn` / `fail`), a per-category summary, optional
216/// new-vs-inherited attribution, and full sub-results.
217///
218/// Like [`CombinedOutput`], `audit`'s `duplication` and `complexity`
219/// sub-keys hold body shapes rather than per-command envelopes:
220/// `duplication` is [`DupesReportPayload`] (the typed wrapper payload
221/// emitted via `crate::output_dupes::DupesReportPayload::from_report`),
222/// `complexity` is [`HealthReport`]. `dead_code` is the full
223/// [`CheckOutput`] envelope. The committed schema points `duplication`
224/// at `#/definitions/DupesReportPayload` and `complexity` at
225/// `#/definitions/HealthReport` so the documented shape matches the
226/// wire; the `committed_property_refs_match_derived_property_refs`
227/// drift test enforces the alignment.
228#[derive(Debug, Clone, Serialize)]
229#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
230#[cfg_attr(feature = "schema", schemars(title = "fallow audit --format json"))]
231#[allow(
232    dead_code,
233    reason = "schema-source-of-truth: audit.rs still builds the wire via serde_json::json!; this struct locks the schema shape via the drift gate. Migration is a follow-up to issue #384 items 3a/3b/3c."
234)]
235pub struct AuditOutput {
236    /// Schema version for this output format.
237    pub schema_version: SchemaVersion,
238    /// Fallow tool version that produced this output.
239    pub version: ToolVersion,
240    /// Singleton command discriminator (always `"audit"`).
241    pub command: AuditCommand,
242    /// Overall verdict: `pass` (no issues), `warn` (warn-severity only,
243    /// exit 0), or `fail` (error-severity issues, exit 1).
244    pub verdict: AuditVerdict,
245    /// Number of files changed between base ref and HEAD.
246    pub changed_files_count: u32,
247    /// Git ref used as comparison base (explicit or auto-detected).
248    pub base_ref: String,
249    /// Short SHA of HEAD. Omitted when git is unavailable.
250    #[serde(default, skip_serializing_if = "Option::is_none")]
251    pub head_sha: Option<String>,
252    /// Analysis duration in milliseconds.
253    pub elapsed_ms: ElapsedMs,
254    /// Only emitted when --performance is set. true means audit reused the
255    /// current run's keys as the base snapshot because every changed file was
256    /// either a non-behavioral doc or token-equivalent at the base ref (the
257    /// docs-only-diff fast path); false means the regular base worktree
258    /// analysis ran.
259    #[serde(default, skip_serializing_if = "Option::is_none")]
260    pub base_snapshot_skipped: Option<bool>,
261    /// Per-category summary counts.
262    pub summary: AuditSummary,
263    /// Counts split by whether each finding was introduced by the current
264    /// changeset or already existed at the base ref. The default audit gate is
265    /// new-only, so inherited findings are context. With audit.gate or --gate
266    /// set to all, audit skips the extra base-snapshot attribution pass and
267    /// these counts stay zero.
268    pub attribution: AuditAttribution,
269    /// Full dead code results (omitted if no changed files). Issue objects
270    /// include introduced: true/false when audit can compare against the base
271    /// ref.
272    #[serde(default, skip_serializing_if = "Option::is_none")]
273    pub dead_code: Option<CheckOutput>,
274    /// Full duplication results (omitted if no changed files). Clone groups
275    /// include introduced: true/false when audit can compare against the base
276    /// ref. Carries typed [`crate::output_dupes::CloneGroupFinding`] and
277    /// [`crate::output_dupes::CloneFamilyFinding`] wrappers (matches what
278    /// `crates/cli/src/audit.rs` emits via
279    /// `crate::output_dupes::DupesReportPayload::from_report`).
280    #[serde(default, skip_serializing_if = "Option::is_none")]
281    pub duplication: Option<DupesReportPayload>,
282    /// Full complexity results (omitted if no changed files). Findings include
283    /// introduced: true/false when audit can compare against the base ref.
284    #[serde(default, skip_serializing_if = "Option::is_none")]
285    pub complexity: Option<HealthReport>,
286}
287
288/// Singleton `command` discriminator for [`AuditOutput`].
289#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
290#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
291#[serde(rename_all = "lowercase")]
292#[allow(dead_code, reason = "schema-source-of-truth: see `AuditOutput`.")]
293pub enum AuditCommand {
294    /// The only valid command discriminator for `AuditOutput`.
295    Audit,
296}
297
298/// Envelope emitted by bare `fallow --format json` (the combined
299/// invocation). Wraps the per-analysis sub-results inside a single envelope
300/// with the standard `schema_version` / `version` / `elapsed_ms` header.
301///
302/// Each sub-result is `Option<...>` so `--only` / `--skip` can suppress a
303/// pass without leaving an empty key on the wire. The `check` sub-result is
304/// the full [`CheckOutput`] envelope (including its own `schema_version` /
305/// `version` / `elapsed_ms`), `dupes` is the typed [`DupesReportPayload`]
306/// emitted via `crate::output_dupes::DupesReportPayload::from_report`, and
307/// `health` is the bare [`HealthReport`] body: the runtime emit calls
308/// `serde_json::to_value(&report)` directly rather than wrapping it in the
309/// per-command envelope. The committed schema points `dupes` at
310/// `#/definitions/DupesReportPayload` and `health` at
311/// `#/definitions/HealthReport` so the documented shape matches the
312/// wire; the `committed_property_refs_match_derived_property_refs`
313/// drift test enforces the alignment.
314#[derive(Debug, Clone, Serialize)]
315#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
316#[cfg_attr(
317    feature = "schema",
318    schemars(title = "fallow --format json (bare, combined)")
319)]
320pub struct CombinedOutput {
321    /// Schema version for this output format.
322    pub schema_version: SchemaVersion,
323    /// Fallow tool version that produced this output.
324    pub version: ToolVersion,
325    /// Analysis duration in milliseconds.
326    pub elapsed_ms: ElapsedMs,
327    /// 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}