Skip to main content

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