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::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. When a workspace
38/// package still pins a hardcoded version of the same package, `fallow fix`
39/// skips the entry to avoid breaking `pnpm install`, and the action is
40/// emitted with `auto_fixable: false`.
41/// - `remove-dependency` vs `move-dependency` (dependency findings): when the
42/// finding's `used_in_workspaces` array is non-empty, the primary action
43/// flips to `move-dependency` with `auto_fixable: false` (`fallow fix` will
44/// not remove a dependency that another workspace imports). On findings
45/// without cross-workspace consumers the action stays `remove-dependency`
46/// with `auto_fixable: true`.
47/// - `add-to-config` for `ignoreExports` (`duplicate-exports`): `true` when
48/// `fallow fix` can safely apply the action without further user setup.
49/// That is: a fallow config file exists on disk, OR no config exists AND
50/// the working directory is NOT inside a monorepo subpackage (in which
51/// case the applier creates `.fallowrc.json` from `fallow init`'s
52/// framework-aware scaffolding and layers the new rules on top).
53/// `false` inside a monorepo subpackage with no workspace-root config
54/// (the applier refuses to fragment per-package configs across the
55/// monorepo and points at the workspace root instead).
56/// - `update-catalog-reference` (`unresolved-catalog-references`): always
57/// `false` today (the catalog-switching applier is not wired in yet); the
58/// field is non-singleton so that future enablement does not require a
59/// schema change.
60///
61/// All `suppress-line` and `suppress-file` actions are uniformly
62/// `auto_fixable: false`. The field is non-singleton on the wire so that a
63/// future auto-applier (e.g. an LLM-driven suppression writer) can promote
64/// individual variants without a schema bump.
65#[derive(Debug, Clone, Serialize)]
66#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
67#[serde(untagged)]
68pub enum IssueAction {
69 /// A code-change fix the user can apply (auto-fixable by `fallow fix` for
70 /// some variants, manual for others).
71 Fix(FixAction),
72 /// Place a `// fallow-ignore-next-line ...` comment above the offending
73 /// line. Always manual.
74 SuppressLine(SuppressLineAction),
75 /// Place a `// fallow-ignore-file ...` comment at the top of the file.
76 /// Always manual.
77 SuppressFile(SuppressFileAction),
78 /// Add the offending finding to the fallow config (e.g.
79 /// `ignoreDependencies: ["lodash"]`). Auto-fixable for the array-shaped
80 /// `ignoreExports` variant when `fallow fix` can safely apply the
81 /// action (config file exists, or no config exists and the working
82 /// directory is not inside a monorepo subpackage); manual otherwise.
83 AddToConfig(AddToConfigAction),
84}
85
86/// A code-change fix. `type` is one of the kebab-case identifiers in
87/// [`FixActionType`].
88#[derive(Debug, Clone, Serialize)]
89#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
90pub struct FixAction {
91 /// Kebab-case identifier for the fix action.
92 #[serde(rename = "type")]
93 pub kind: FixActionType,
94 /// Whether `fallow fix` can apply this fix automatically. Evaluated PER
95 /// FINDING, not per action type: the same `type` may carry
96 /// `auto_fixable: true` on one finding and `auto_fixable: false` on
97 /// another when per-instance guards in the applier discriminate (e.g.
98 /// `remove-catalog-entry` flips on `hardcoded_consumers`, the primary
99 /// dependency action flips between `remove-dependency` /
100 /// `move-dependency` on `used_in_workspaces`). Filter on this bool of
101 /// each individual action, not on `type`. See the [`IssueAction`]
102 /// enum-level docs for the full list of per-instance flips.
103 pub auto_fixable: bool,
104 /// Human-readable description of the fix.
105 pub description: String,
106 /// Optional context note. Present on non-auto-fixable actions, and on
107 /// auto-fixable re-export findings to warn about public API surface.
108 #[serde(default, skip_serializing_if = "Option::is_none")]
109 pub note: Option<String>,
110 /// Only present on `update-catalog-reference` actions: catalogs in the
111 /// same workspace that DO declare the package, sorted lexicographically.
112 /// Lets agents pick the catalog to switch to without re-reading the
113 /// source.
114 #[serde(default, skip_serializing_if = "Option::is_none")]
115 pub available_in_catalogs: Option<Vec<String>>,
116 /// Only present on `update-catalog-reference` actions when exactly one
117 /// alternative catalog declares the package: the unambiguous switch
118 /// target. Lets deterministic (non-LLM) agents land the edit without
119 /// picking from a list. Absent when `available_in_catalogs` has zero
120 /// or more than one entry.
121 #[serde(default, skip_serializing_if = "Option::is_none")]
122 pub suggested_target: Option<String>,
123}
124
125/// Discriminant string for [`FixAction`]. Kebab-case per the JSON output
126/// contract.
127#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
128#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
129#[serde(rename_all = "kebab-case")]
130pub enum FixActionType {
131 /// Remove an export declaration from a source file.
132 RemoveExport,
133 /// Delete an entire unused file.
134 DeleteFile,
135 /// Remove an entry from `dependencies` / `devDependencies` in
136 /// `package.json`.
137 RemoveDependency,
138 /// Move an entry between `dependencies` and `devDependencies`.
139 MoveDependency,
140 /// Remove an enum member from a TypeScript enum.
141 RemoveEnumMember,
142 /// Remove a class member (method or property).
143 RemoveClassMember,
144 /// Resolve an unresolved import (manual).
145 ResolveImport,
146 /// Install a missing dependency.
147 InstallDependency,
148 /// Remove a duplicate export (the canonical action for
149 /// `duplicate-exports`).
150 RemoveDuplicate,
151 /// Move a production dependency to `devDependencies`
152 /// (used by type-only-dependency and test-only-dependency findings).
153 MoveToDev,
154 /// Break a circular dependency by refactoring imports.
155 RefactorCycle,
156 /// Break a re-export cycle by removing an `export * from` (or
157 /// `export { ... } from`) statement on any one member file. Re-export
158 /// cycles are structurally always bugs (chain propagation through the
159 /// loop is a no-op), so there is no auto-fix; the action is manual.
160 RefactorReExportCycle,
161 /// Resolve a boundary violation by refactoring the import.
162 RefactorBoundary,
163 /// Convert an import statement to a type-only import (used by
164 /// private-type-leak findings).
165 ExportType,
166 /// Remove an unused catalog entry from `pnpm-workspace.yaml`.
167 RemoveCatalogEntry,
168 /// Remove an empty named catalog group from `pnpm-workspace.yaml`.
169 RemoveEmptyCatalogGroup,
170 /// Update an existing `catalog:` reference in a workspace `package.json`
171 /// to point at a different (declared) catalog.
172 UpdateCatalogReference,
173 /// Add the missing entry to the referenced catalog.
174 AddCatalogEntry,
175 /// Remove the catalog reference from the workspace `package.json` and
176 /// replace it with a hardcoded version.
177 RemoveCatalogReference,
178 /// Remove an unused dependency override entry.
179 RemoveDependencyOverride,
180 /// Fix a misconfigured dependency override entry (unparsable key or empty
181 /// value).
182 FixDependencyOverride,
183 /// Replace a banned call or banned import flagged by a rule-pack rule
184 /// (manual; the rule's message usually names the sanctioned alternative).
185 ResolvePolicyViolation,
186}
187
188/// Inline-comment suppression for a single finding line.
189#[derive(Debug, Clone, Serialize)]
190#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
191pub struct SuppressLineAction {
192 /// Action type identifier.
193 #[serde(rename = "type")]
194 pub kind: SuppressLineKind,
195 /// Always false for suppress actions.
196 pub auto_fixable: bool,
197 /// Human-readable description of the suppression.
198 pub description: String,
199 /// The inline comment to place above the line (e.g.,
200 /// `// fallow-ignore-next-line unused-export`). When multiple
201 /// suppressible findings share the same path and line, this may contain a
202 /// comma-separated issue-kind list such as
203 /// `// fallow-ignore-next-line unused-export, complexity`.
204 pub comment: String,
205 /// Present on multi-location issue types (e.g., `duplicate_exports`) to
206 /// indicate the comment must be applied at each location.
207 #[serde(default, skip_serializing_if = "Option::is_none")]
208 pub scope: Option<SuppressLineScope>,
209}
210
211/// Singleton discriminant for [`SuppressLineAction`].
212#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
213#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
214#[serde(rename_all = "kebab-case")]
215pub enum SuppressLineKind {
216 /// `// fallow-ignore-next-line <kind>` directive.
217 SuppressLine,
218}
219
220/// Scope marker for line suppressions that span multiple locations.
221#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
222#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
223#[serde(rename_all = "kebab-case")]
224pub enum SuppressLineScope {
225 /// Apply the suppression comment at each location of the multi-location
226 /// finding (e.g., every `duplicate_exports` site).
227 PerLocation,
228}
229
230/// File-wide suppression placed at the top of the source file.
231#[derive(Debug, Clone, Serialize)]
232#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
233pub struct SuppressFileAction {
234 /// Action type identifier.
235 #[serde(rename = "type")]
236 pub kind: SuppressFileKind,
237 /// Always false for suppress actions.
238 pub auto_fixable: bool,
239 /// Human-readable description of the suppression.
240 pub description: String,
241 /// The file-level comment to place at the top of the file (e.g.,
242 /// `// fallow-ignore-file unused-file`).
243 pub comment: String,
244}
245
246/// Singleton discriminant for [`SuppressFileAction`].
247#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
248#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
249#[serde(rename_all = "kebab-case")]
250pub enum SuppressFileKind {
251 /// `// fallow-ignore-file <kind>` directive.
252 SuppressFile,
253}
254
255/// Edit a fallow config file (`.fallowrc.json`, `fallow.toml`, etc.) to
256/// add the offending value to an `ignore*` rule.
257#[derive(Debug, Clone, Serialize)]
258#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
259pub struct AddToConfigAction {
260 /// Action type identifier.
261 #[serde(rename = "type")]
262 pub kind: AddToConfigKind,
263 /// True when `fallow fix` can apply this config action automatically.
264 /// Evaluated PER FINDING, not per action type: `ignoreExports`
265 /// duplicate-export actions are auto-fixable when `fallow fix` can
266 /// safely write the rule, which today means EITHER a fallow config
267 /// file already exists OR no config exists and the working directory
268 /// is NOT inside a monorepo subpackage (in which case the applier
269 /// creates `.fallowrc.json` from `fallow init`'s framework-aware
270 /// scaffolding). The action is `false` inside a monorepo subpackage
271 /// with no workspace-root config because the applier refuses to
272 /// fragment per-package configs across the monorepo. Older scalar
273 /// config-ignore actions (e.g. `ignoreDependencies` on dependency
274 /// findings) are always manual today. Filter on this bool of each
275 /// individual action, not on the `type` alone. See the [`IssueAction`]
276 /// enum-level docs for the full list of per-instance flips.
277 pub auto_fixable: bool,
278 /// Human-readable description of the config change.
279 pub description: String,
280 /// The fallow config key to add the value to (e.g.,
281 /// `ignoreDependencies`).
282 pub config_key: String,
283 /// Value to add to the config key. Shape depends on `config_key`. For
284 /// scalar config keys (`ignoreDependencies`, others) this is a string
285 /// such as `"lodash"`. For `ignoreExports` this is an array of
286 /// `{ file, exports }` rule objects so the snippet can be merged into
287 /// the user's config verbatim. For `ignoreCatalogReferences` and
288 /// `ignoreDependencyOverrides` this is an object whose shape matches the
289 /// rule entry users add to their fallow config.
290 pub value: AddToConfigValue,
291 /// Optional URL pointing at a stable JSON Schema fragment that describes
292 /// the shape of `value`. Agents that intend to validate `value` before
293 /// writing it into a user's config can fetch the linked schema and run
294 /// it against `value`. The URL is a JSON Pointer fragment into fallow's
295 /// main config schema (e.g.
296 /// `schema.json#/properties/ignoreExports` for the ignoreExports
297 /// action, or `schema.json#/properties/ignoreDependencies/items` for
298 /// the per-package ignoreDependencies action). Strictly additive:
299 /// consumers that ignore the field keep working unchanged.
300 #[serde(default, skip_serializing_if = "Option::is_none")]
301 pub value_schema: Option<String>,
302}
303
304/// Singleton discriminant for [`AddToConfigAction`].
305#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
306#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
307#[serde(rename_all = "kebab-case")]
308pub enum AddToConfigKind {
309 /// Append a value into a fallow config `ignore*` list.
310 AddToConfig,
311}
312
313/// Value payload for [`AddToConfigAction::value`]. The variants line up with
314/// the documented per-`config_key` shapes; deserialization is untagged so
315/// downstream consumers can switch on the JSON value's type.
316#[derive(Debug, Clone, Serialize)]
317#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
318#[serde(untagged)]
319pub enum AddToConfigValue {
320 /// Scalar string value (e.g., a package name for
321 /// `ignoreDependencies: ["lodash"]`).
322 Scalar(String),
323 /// Array of file+export rule objects for `ignoreExports`.
324 ExportsRules(Vec<IgnoreExportsRule>),
325 /// Free-form object for rule-shaped keys like
326 /// `ignoreCatalogReferences` / `ignoreDependencyOverrides`. The shape
327 /// matches the rule entry users add to their fallow config; consumers
328 /// validate against the per-key schema referenced by `value_schema`.
329 RuleObject(serde_json::Map<String, serde_json::Value>),
330}
331
332/// Single `ignoreExports` rule entry. The fallow config accepts an array of
333/// these under the `ignoreExports` key.
334#[derive(Debug, Clone, Serialize)]
335#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
336pub struct IgnoreExportsRule {
337 /// File path (forward slashes, relative to project root) to which this
338 /// rule applies. Globs are accepted.
339 pub file: String,
340 /// Names of exports inside `file` to silently treat as used.
341 pub exports: Vec<String>,
342}