fallow_types/output.rs
1//! Types that describe fallow's JSON output contract.
2//!
3//! Today the JSON serialization layer (`crates/cli/src/report/json.rs`) builds
4//! its output via `serde_json::json!` macros. The types defined here are the
5//! schema-side counterpart of that output: they document, with Rust's type
6//! system, the augmentations the JSON layer adds to each per-finding struct
7//! (the `actions` array on every finding, the optional `introduced` flag in
8//! audit-mode sub-results).
9//!
10//! The `schema-emit` binary derives `JsonSchema` for these types (gated by the
11//! `schema` cargo feature) so the public `docs/output-schema.json` stays in
12//! sync with the Rust source of truth. A future refactor will route the JSON
13//! emission path through these types directly, eliminating the drift class
14//! between the augmentation list here and the `serde_json::json!` builders.
15
16use serde::{Deserialize, Serialize};
17
18/// A suggested action attached to a finding in the JSON output. Each finding
19/// carries an `actions` array; consumers (agents, IDE clients, CI bots) can
20/// dispatch on the `type` discriminant to choose the right remediation.
21///
22/// The discriminator is `type` (snake_case `type` field), the payload uses the
23/// matching kebab-case identifier per variant.
24///
25/// ## `auto_fixable` is per-finding, not per action type
26///
27/// Every action variant carries an `auto_fixable: bool` field. The value is
28/// evaluated PER FINDING, not per action type: the same action type may
29/// appear with `auto_fixable: true` on one finding and `auto_fixable: false`
30/// on another, depending on per-instance guards in the `fallow fix` applier.
31/// Agents that filter on `auto_fixable: true` must branch on the bool of
32/// each individual finding's action, not on the action `type` alone.
33///
34/// Current per-instance flips:
35///
36/// - `remove-catalog-entry` (`unused-catalog-entries`): `true` only when the
37/// finding's `hardcoded_consumers` array is empty and the source is
38/// `pnpm-workspace.yaml`. When a workspace package still pins a hardcoded
39/// version of the same package, `fallow fix` skips the entry to avoid
40/// breaking `pnpm install`. Bun `package.json` catalog entries are also
41/// emitted with `auto_fixable: false` because the current fixer is
42/// YAML-only.
43/// - `remove-dependency` vs `move-dependency` (dependency findings): when the
44/// finding's `used_in_workspaces` array is non-empty, the primary action
45/// flips to `move-dependency` with `auto_fixable: false` (`fallow fix` will
46/// not remove a dependency that another workspace imports). On findings
47/// without cross-workspace consumers the action stays `remove-dependency`
48/// with `auto_fixable: true`.
49/// - `add-to-config` for `ignoreExports` (`duplicate-exports`): `true` when
50/// `fallow fix` can safely apply the action without further user setup.
51/// That is: a fallow config file exists on disk, OR no config exists AND
52/// the working directory is NOT inside a monorepo subpackage (in which
53/// case the applier creates `.fallowrc.json` from `fallow init`'s
54/// framework-aware scaffolding and layers the new rules on top).
55/// `false` inside a monorepo subpackage with no workspace-root config
56/// (the applier refuses to fragment per-package configs across the
57/// monorepo and points at the workspace root instead).
58/// - `update-catalog-reference` (`unresolved-catalog-references`): always
59/// `false` today (the catalog-switching applier is not wired in yet); the
60/// field is non-singleton so that future enablement does not require a
61/// schema change.
62///
63/// All `suppress-line` and `suppress-file` actions are uniformly
64/// `auto_fixable: false`. The field is non-singleton on the wire so that a
65/// future auto-applier (e.g. an LLM-driven suppression writer) can promote
66/// individual variants without a schema bump.
67#[derive(Debug, Clone, Serialize, Deserialize)]
68#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
69#[serde(untagged)]
70pub enum IssueAction {
71 /// A code-change fix the user can apply (auto-fixable by `fallow fix` for
72 /// some variants, manual for others).
73 Fix(FixAction),
74 /// Place a `// fallow-ignore-next-line ...` comment above the offending
75 /// line. Always manual.
76 SuppressLine(SuppressLineAction),
77 /// Place a `// fallow-ignore-file ...` comment at the top of the file.
78 /// Always manual.
79 SuppressFile(SuppressFileAction),
80 /// Add the offending finding to the fallow config (e.g.
81 /// `ignoreDependencies: ["lodash"]`). Auto-fixable for the array-shaped
82 /// `ignoreExports` variant when `fallow fix` can safely apply the
83 /// action (config file exists, or no config exists and the working
84 /// directory is not inside a monorepo subpackage); manual otherwise.
85 AddToConfig(AddToConfigAction),
86}
87
88impl IssueAction {
89 /// Whether the current finding-specific action can be applied by
90 /// `fallow fix`.
91 #[must_use]
92 pub const fn is_auto_fixable(&self) -> bool {
93 match self {
94 Self::Fix(action) => action.auto_fixable,
95 Self::SuppressLine(action) => action.auto_fixable,
96 Self::SuppressFile(action) => action.auto_fixable,
97 Self::AddToConfig(action) => action.auto_fixable,
98 }
99 }
100}
101
102/// A code-change fix. `type` is one of the kebab-case identifiers in
103/// [`FixActionType`].
104#[derive(Debug, Clone, Serialize, Deserialize)]
105#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
106pub struct FixAction {
107 /// Kebab-case identifier for the fix action.
108 #[serde(rename = "type")]
109 pub kind: FixActionType,
110 /// Whether `fallow fix` can apply this fix automatically. Evaluated PER
111 /// FINDING, not per action type: the same `type` may carry
112 /// `auto_fixable: true` on one finding and `auto_fixable: false` on
113 /// another when per-instance guards in the applier discriminate (e.g.
114 /// `remove-catalog-entry` flips on `hardcoded_consumers` and catalog
115 /// source file, the primary dependency action flips between
116 /// `remove-dependency` / `move-dependency` on `used_in_workspaces`).
117 /// Filter on this bool of each individual action, not on `type`. See the
118 /// [`IssueAction`] enum-level docs for the full list of per-instance
119 /// flips.
120 ///
121 /// One flip is RUN-level rather than finding-level: a dead-code finding
122 /// carrying `reachability_caveats` reports `false` here, because a file
123 /// this run never fully read may hold the reference that credits it. Every
124 /// mutation surface honours the same gate, so a plan built from this flag
125 /// never expects a write `fallow fix`, the MCP fix tools, or the LSP quick
126 /// fix will refuse. The action stays in the array at the same position and
127 /// names the reason in [`Self::note`].
128 pub auto_fixable: bool,
129 /// Human-readable description of the fix.
130 pub description: String,
131 /// Optional context note. Present on non-auto-fixable actions, and on
132 /// auto-fixable re-export findings to warn about public API surface.
133 #[serde(default, skip_serializing_if = "Option::is_none")]
134 pub note: Option<String>,
135 /// Only present on `update-catalog-reference` actions: catalogs in the
136 /// same workspace that DO declare the package, sorted lexicographically.
137 /// Lets agents pick the catalog to switch to without re-reading the
138 /// source.
139 #[serde(default, skip_serializing_if = "Option::is_none")]
140 pub available_in_catalogs: Option<Vec<String>>,
141 /// Only present on `update-catalog-reference` actions when exactly one
142 /// alternative catalog declares the package: the unambiguous switch
143 /// target. Lets deterministic (non-LLM) agents land the edit without
144 /// picking from a list. Absent when `available_in_catalogs` has zero
145 /// or more than one entry.
146 #[serde(default, skip_serializing_if = "Option::is_none")]
147 pub suggested_target: Option<String>,
148}
149
150/// Discriminant string for [`FixAction`]. Kebab-case per the JSON output
151/// contract.
152#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
153#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
154#[serde(rename_all = "kebab-case")]
155pub enum FixActionType {
156 /// Remove an export declaration from a source file.
157 RemoveExport,
158 /// Delete an entire unused file.
159 DeleteFile,
160 /// Remove an entry from `dependencies` / `devDependencies` in
161 /// `package.json`.
162 RemoveDependency,
163 /// Move an entry between `dependencies` and `devDependencies`.
164 MoveDependency,
165 /// Remove an enum member from a TypeScript enum.
166 RemoveEnumMember,
167 /// Remove a class member (method or property).
168 RemoveClassMember,
169 /// Resolve an unresolved import (manual).
170 ResolveImport,
171 /// Install a missing dependency.
172 InstallDependency,
173 /// Remove a duplicate export (the canonical action for
174 /// `duplicate-exports`).
175 RemoveDuplicate,
176 /// Move a production dependency to `devDependencies`
177 /// (used by type-only-dependency and test-only-dependency findings).
178 MoveToDev,
179 /// Move a `devDependencies` entry to `dependencies`
180 /// (used by dev-dependency-in-production findings; the promote-side mirror
181 /// of [`FixActionType::MoveToDev`]).
182 MoveToProd,
183 /// Break a circular dependency by refactoring imports.
184 RefactorCycle,
185 /// Break a re-export cycle by removing an `export * from` (or
186 /// `export { ... } from`) statement on any one member file. Re-export
187 /// cycles are structurally always bugs (chain propagation through the
188 /// loop is a no-op), so there is no auto-fix; the action is manual.
189 RefactorReExportCycle,
190 /// Resolve a boundary violation by refactoring the import.
191 RefactorBoundary,
192 /// Convert an import statement to a type-only import (used by
193 /// private-type-leak findings).
194 ExportType,
195 /// Move the consumers of a `@deprecated` export to its replacement, then
196 /// remove the export. Manual: fallow does not rewrite consumers.
197 MigrateDeprecatedExport,
198 /// Remove an unused catalog entry. Auto-fix only supports `pnpm-workspace.yaml`;
199 /// Bun `package.json` catalogs are manual.
200 RemoveCatalogEntry,
201 /// Remove an empty named catalog group. Auto-fix only supports
202 /// `pnpm-workspace.yaml`; Bun `package.json` catalogs are manual.
203 RemoveEmptyCatalogGroup,
204 /// Update an existing `catalog:` reference in a workspace `package.json`
205 /// to point at a different (declared) catalog.
206 UpdateCatalogReference,
207 /// Add the missing entry to the referenced catalog.
208 AddCatalogEntry,
209 /// Remove the catalog reference from the workspace `package.json` and
210 /// replace it with a hardcoded version.
211 RemoveCatalogReference,
212 /// Remove an unused dependency override entry.
213 RemoveDependencyOverride,
214 /// Fix a misconfigured dependency override entry (unparsable key or empty
215 /// value).
216 FixDependencyOverride,
217 /// Replace a banned call or banned import flagged by a rule-pack rule
218 /// (manual; the rule's message usually names the sanctioned alternative).
219 ResolvePolicyViolation,
220 /// Move a server-only export out of a `"use client"` file into a
221 /// non-client module (manual; used by invalid-client-export findings).
222 MoveToServerModule,
223 /// Split a barrel that re-exports both client and server-only modules
224 /// into separate client and server barrels (manual; used by
225 /// mixed-client-server-barrel findings).
226 SplitMixedBarrel,
227 /// Hoist a misplaced `"use client"` / `"use server"` directive to the
228 /// leading prologue of the file (manual; used by misplaced-directive
229 /// findings).
230 HoistDirective,
231 /// Wire a server action to a project consumer or remove the unused action
232 /// export (manual; used by unused-server-action findings).
233 WireServerAction,
234 /// Add a provider for an injected key or remove the dead inject call
235 /// (manual; used by unprovided-inject findings).
236 ProvideInject,
237 /// Use a SvelteKit load-data key from the route UI or remove the unused
238 /// returned key (manual; used by unused-load-data-key findings).
239 UseLoadData,
240 /// Render a reachable component from project code or remove the component
241 /// (manual; used by unrendered-component findings).
242 RenderComponent,
243 /// Use a declared component prop or remove it from the component API
244 /// (manual; used by unused-component-prop findings).
245 UseComponentProp,
246 /// Review caller evidence, defaults and component API intent manually.
247 ReviewComponentProp,
248 /// Emit a declared component event or remove it from the component API
249 /// (manual; used by unused-component-emit findings).
250 EmitComponentEvent,
251 /// Add or forward a Svelte custom-event listener, or remove the dispatch
252 /// (manual; used by unused-svelte-event findings).
253 WireSvelteEvent,
254 /// Resolve a Next.js App Router route collision by moving or merging one of
255 /// the files that own the same URL (manual; suppressing a guaranteed build
256 /// error is never the right fix, so this is the primary action).
257 ResolveRouteCollision,
258 /// Resolve a Next.js dynamic-segment name conflict by renaming the dynamic
259 /// segments at the conflicting position to a single consistent slug name
260 /// (manual).
261 ResolveDynamicSegmentNameConflict,
262 /// Add a human-authored reason to a suppression that requires one.
263 AddSuppressionReason,
264 /// Remove or update a suppression that no longer matches a finding.
265 RemoveStaleSuppression,
266}
267
268/// Inline-comment suppression for a single finding line.
269#[derive(Debug, Clone, Serialize, Deserialize)]
270#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
271pub struct SuppressLineAction {
272 /// Action type identifier.
273 #[serde(rename = "type")]
274 pub kind: SuppressLineKind,
275 /// Always false for suppress actions.
276 pub auto_fixable: bool,
277 /// Human-readable description of the suppression.
278 pub description: String,
279 /// The inline comment to place above the line (e.g.,
280 /// `// fallow-ignore-next-line unused-export`). When multiple
281 /// suppressible findings share the same path and line, this may contain a
282 /// comma-separated issue-kind list such as
283 /// `// fallow-ignore-next-line unused-export, complexity`.
284 pub comment: String,
285 /// Present on multi-location issue types (e.g., `duplicate_exports`) to
286 /// indicate the comment must be applied at each location.
287 #[serde(default, skip_serializing_if = "Option::is_none")]
288 pub scope: Option<SuppressLineScope>,
289}
290
291/// Singleton discriminant for [`SuppressLineAction`].
292#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
293#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
294#[serde(rename_all = "kebab-case")]
295pub enum SuppressLineKind {
296 /// `// fallow-ignore-next-line <kind>` directive.
297 SuppressLine,
298}
299
300/// Scope marker for line suppressions that span multiple locations.
301#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
302#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
303#[serde(rename_all = "kebab-case")]
304pub enum SuppressLineScope {
305 /// Apply the suppression comment at each location of the multi-location
306 /// finding (e.g., every `duplicate_exports` site).
307 PerLocation,
308}
309
310/// File-wide suppression placed at the top of the source file.
311#[derive(Debug, Clone, Serialize, Deserialize)]
312#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
313pub struct SuppressFileAction {
314 /// Action type identifier.
315 #[serde(rename = "type")]
316 pub kind: SuppressFileKind,
317 /// Always false for suppress actions.
318 pub auto_fixable: bool,
319 /// Human-readable description of the suppression.
320 pub description: String,
321 /// The file-level comment to place at the top of the file (e.g.,
322 /// `// fallow-ignore-file unused-file`).
323 pub comment: String,
324}
325
326/// Singleton discriminant for [`SuppressFileAction`].
327#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
328#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
329#[serde(rename_all = "kebab-case")]
330pub enum SuppressFileKind {
331 /// `// fallow-ignore-file <kind>` directive.
332 SuppressFile,
333}
334
335/// Edit a fallow config file (`.fallowrc.json`, `fallow.toml`, etc.) to
336/// add the offending value to an `ignore*` rule.
337#[derive(Debug, Clone, Serialize, Deserialize)]
338#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
339pub struct AddToConfigAction {
340 /// Action type identifier.
341 #[serde(rename = "type")]
342 pub kind: AddToConfigKind,
343 /// True when `fallow fix` can apply this config action automatically.
344 /// Evaluated PER FINDING, not per action type: `ignoreExports`
345 /// duplicate-export actions are auto-fixable when `fallow fix` can
346 /// safely write the rule, which today means EITHER a fallow config
347 /// file already exists OR no config exists and the working directory
348 /// is NOT inside a monorepo subpackage (in which case the applier
349 /// creates `.fallowrc.json` from `fallow init`'s framework-aware
350 /// scaffolding). The action is `false` inside a monorepo subpackage
351 /// with no workspace-root config because the applier refuses to
352 /// fragment per-package configs across the monorepo. Older scalar
353 /// config-ignore actions (e.g. `ignoreDependencies` on dependency
354 /// findings) are always manual today. Filter on this bool of each
355 /// individual action, not on the `type` alone. See the [`IssueAction`]
356 /// enum-level docs for the full list of per-instance flips.
357 pub auto_fixable: bool,
358 /// Human-readable description of the config change.
359 pub description: String,
360 /// The fallow config key to add the value to (e.g.,
361 /// `ignoreDependencies`).
362 pub config_key: String,
363 /// Value to add to the config key. Shape depends on `config_key`. For
364 /// scalar config keys (`ignoreDependencies`, others) this is a string
365 /// such as `"lodash"`. For `ignoreExports` this is an array of
366 /// `{ file, exports }` rule objects so the snippet can be merged into
367 /// the user's config verbatim. For `ignoreCatalogReferences` and
368 /// `ignoreDependencyOverrides` this is an object whose shape matches the
369 /// rule entry users add to their fallow config.
370 pub value: AddToConfigValue,
371 /// Optional URL pointing at a stable JSON Schema fragment that describes
372 /// the shape of `value`. Agents that intend to validate `value` before
373 /// writing it into a user's config can fetch the linked schema and run
374 /// it against `value`. The URL is a JSON Pointer fragment into fallow's
375 /// main config schema (e.g.
376 /// `schema.json#/properties/ignoreExports` for the ignoreExports
377 /// action, or `schema.json#/properties/ignoreDependencies/items` for
378 /// the per-package ignoreDependencies action). Strictly additive:
379 /// consumers that ignore the field keep working unchanged.
380 #[serde(default, skip_serializing_if = "Option::is_none")]
381 pub value_schema: Option<String>,
382}
383
384/// Singleton discriminant for [`AddToConfigAction`].
385#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
386#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
387#[serde(rename_all = "kebab-case")]
388pub enum AddToConfigKind {
389 /// Append a value into a fallow config `ignore*` list.
390 AddToConfig,
391}
392
393/// Value payload for [`AddToConfigAction::value`]. The variants line up with
394/// the documented per-`config_key` shapes; deserialization is untagged so
395/// downstream consumers can switch on the JSON value's type.
396#[derive(Debug, Clone, Serialize, Deserialize)]
397#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
398#[serde(untagged)]
399pub enum AddToConfigValue {
400 /// Scalar string value (e.g., a package name for
401 /// `ignoreDependencies: ["lodash"]`).
402 Scalar(String),
403 /// Array of file+export rule objects for `ignoreExports`.
404 ExportsRules(Vec<IgnoreExportsRule>),
405 /// Free-form object for rule-shaped keys like
406 /// `ignoreCatalogReferences` / `ignoreDependencyOverrides`. The shape
407 /// matches the rule entry users add to their fallow config; consumers
408 /// validate against the per-key schema referenced by `value_schema`.
409 RuleObject(serde_json::Map<String, serde_json::Value>),
410}
411
412/// Single `ignoreExports` rule entry. The fallow config accepts an array of
413/// these under the `ignoreExports` key.
414#[derive(Debug, Clone, Serialize, Deserialize)]
415#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
416pub struct IgnoreExportsRule {
417 /// File path (forward slashes, relative to project root) to which this
418 /// rule applies. Globs are accepted.
419 pub file: String,
420 /// Names of exports inside `file` to silently treat as used.
421 pub exports: Vec<String>,
422}
423
424/// A read-only follow-up command fallow surfaces from the current findings,
425/// emitted as the top-level `next_steps` array on each command's JSON envelope.
426///
427/// `next_steps` exists to point agents and humans sideways to fallow's adjacent
428/// verification capabilities (trace, complexity breakdown, audit, workspace
429/// scoping) that telemetry shows agents rarely discover, because they act on the
430/// output in front of them rather than on reference docs.
431///
432/// ## Two hard contracts
433///
434/// 1. **Read-only.** A `next_step` NEVER suggests `fallow fix` or any mutating
435/// command. Fallow surfaces evidence and verification paths; deciding and
436/// applying the remediation is the agent's job.
437/// 2. **Runnable, placeholder-free.** `command` is always runnable as-is. It
438/// never contains an angle-bracket placeholder (`<...>`); finding-derived
439/// values are filled in from a real, deterministically-selected finding, and
440/// any environment- or user-specific value that cannot be made concrete lives
441/// in `reason` instead. An agent can copy `command` and run it without edits.
442///
443/// Both contracts are enforced by unit tests in
444/// `crates/cli/src/report/suggestions.rs`.
445///
446/// Note: a SEPARATE, unrelated `next_steps` field exists on the
447/// `coverage setup` envelope (`CoverageSetupOutput.next_steps`) as a plain
448/// `Vec<String>` of human onboarding steps. Consumers that read multiple
449/// envelope kinds must route on the envelope's `kind` before interpreting a
450/// `next_steps` field: on analysis envelopes it is `Vec<NextStep>` objects, on
451/// `coverage setup` it is `Vec<String>`.
452#[derive(Debug, Clone, Serialize)]
453#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
454pub struct NextStep {
455 /// Stable kebab-case key for machine dispatch and de-duplication
456 /// (for example `"trace-unused-export"`). Identity is stable across runs;
457 /// the `command` and `reason` strings may vary with the findings.
458 pub id: String,
459 /// A runnable, read-only command string. Placeholder-free by contract.
460 pub command: String,
461 /// One short phrase explaining why this helps. Carries any value that
462 /// cannot be made concrete in `command`.
463 pub reason: String,
464}