Skip to main content

fallow_config/config/
rules.rs

1use fallow_types::suppress::IssueKind;
2use schemars::JsonSchema;
3use serde::{Deserialize, Serialize};
4
5/// Severity level for rules.
6///
7/// Controls whether an issue type sets a non-zero exit code (`error`), is
8/// reported without an effect on the exit code (`warn`), or is suppressed
9/// entirely (`off`). `--fail-on-issues` promotes `warn` to `error`. The
10/// dead-code count gate of the GitHub Action counts `warn` findings unless its
11/// `fail-on-issues` input is `false`.
12#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, JsonSchema)]
13#[serde(rename_all = "lowercase")]
14pub enum Severity {
15    /// Report and set a non-zero exit code.
16    #[default]
17    Error,
18    /// Report without an effect on the exit code.
19    Warn,
20    /// Don't detect or report.
21    Off,
22}
23
24impl Severity {
25    /// Default value for fields that should default to `Warn` instead of `Error`.
26    const fn default_warn() -> Self {
27        Self::Warn
28    }
29
30    /// Default value for fields that should default to `Off`.
31    const fn default_off() -> Self {
32        Self::Off
33    }
34}
35
36impl std::fmt::Display for Severity {
37    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
38        match self {
39            Self::Error => write!(f, "error"),
40            Self::Warn => write!(f, "warn"),
41            Self::Off => write!(f, "off"),
42        }
43    }
44}
45
46impl std::str::FromStr for Severity {
47    type Err = String;
48
49    fn from_str(s: &str) -> Result<Self, Self::Err> {
50        match s.to_lowercase().as_str() {
51            "error" => Ok(Self::Error),
52            "warn" | "warning" => Ok(Self::Warn),
53            "off" | "none" => Ok(Self::Off),
54            other => Err(format!(
55                "unknown severity: '{other}' (expected error, warn, or off)"
56            )),
57        }
58    }
59}
60
61/// Per-issue-type severity configuration.
62///
63/// Controls which issue types cause CI failure, are reported as warnings,
64/// or are suppressed entirely. Most fields default to `Severity::Error`.
65///
66/// Rule names use kebab-case in config files (e.g., `"unused-files": "error"`).
67#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize, JsonSchema)]
68#[serde(rename_all = "kebab-case")]
69pub struct RulesConfig {
70    /// A file reachable from no entry point. Defaults to `error`.
71    #[serde(default, alias = "unused-file")]
72    pub unused_files: Severity,
73    /// An exported symbol no other module imports. Defaults to `error`.
74    #[serde(default, alias = "unused-export")]
75    pub unused_exports: Severity,
76    /// An exported type no other module uses. Defaults to `error`.
77    #[serde(default, alias = "unused-type")]
78    pub unused_types: Severity,
79    /// An exported signature referencing a same-file private type. Opt-in;
80    /// defaults to `off`.
81    #[serde(default = "Severity::default_off", alias = "private-type-leak")]
82    pub private_type_leaks: Severity,
83    /// Whether the user explicitly configured `private-type-leaks` (rather
84    /// than the field taking its `off` default). Populated during config file
85    /// loading, not by serde: type-aware hosts default this opt-in rule to
86    /// `warn`, and this flag lets an explicit user `off` win over that
87    /// default (issue #2170).
88    ///
89    /// Because the field is `serde(skip)`, any serialize/deserialize
90    /// round-trip of [`FallowConfig`](crate::FallowConfig) silently resets it
91    /// to `false`, which would re-enable the type-aware `warn` default for a
92    /// user who explicitly set the rule to `off`. Do not route configs through
93    /// a serde round-trip after [`FallowConfig::load`](crate::FallowConfig::load)
94    /// without re-recording this flag.
95    #[serde(skip)]
96    pub private_type_leaks_configured: bool,
97    /// An export marked `@deprecated` that still has at least one reachable
98    /// reference. Opt-in; defaults to `off`. A per-path override resolves on
99    /// the file that declares the export, not on the consumer.
100    #[serde(default = "Severity::default_off", alias = "deprecated-export-in-use")]
101    pub deprecated_exports_in_use: Severity,
102    /// A declared `dependencies` entry never observed used. Defaults to
103    /// `error`.
104    #[serde(default, alias = "unused-dependency")]
105    pub unused_dependencies: Severity,
106    /// A declared `devDependencies` entry never observed used. Defaults to
107    /// `warn`; production mode forces it to `off`.
108    #[serde(default = "Severity::default_warn", alias = "unused-dev-dependency")]
109    pub unused_dev_dependencies: Severity,
110    /// A declared `optionalDependencies` entry never observed used. Defaults
111    /// to `warn`; production mode forces it to `off`.
112    #[serde(
113        default = "Severity::default_warn",
114        alias = "unused-optional-dependency"
115    )]
116    pub unused_optional_dependencies: Severity,
117    /// An enum member read nowhere in the project. Defaults to `error`.
118    #[serde(default, alias = "unused-enum-member")]
119    pub unused_enum_members: Severity,
120    /// A class member used nowhere; `usedClassMembers` and decorator
121    /// exemptions carve out framework-invoked members. Defaults to `error`.
122    #[serde(default, alias = "unused-class-member")]
123    pub unused_class_members: Severity,
124    /// Store members (Pinia `state` / `getters` / `actions` key, or a
125    /// setup-store returned key) declared but never accessed by any consumer
126    /// project-wide. Defaults to `warn`, not `error` like the closed-set
127    /// class/enum member rules: a store has an OPEN declaration surface
128    /// (plugins, `$onAction`, dynamic dispatch) so analyzer confidence is
129    /// genuinely lower. A `warn` finding does not change the CLI exit code
130    /// unless `--fail-on-issues` is set. The dead-code count gate of the GitHub
131    /// Action counts it unless `fail-on-issues` is `false`. Promotable to
132    /// `error` once validated on a codebase.
133    #[serde(default = "Severity::default_warn", alias = "unused-store-member")]
134    pub unused_store_members: Severity,
135    /// Vue `inject(KEY)` / Svelte `getContext(KEY)` whose symbol KEY is
136    /// `provide`/`setContext`'d nowhere in the project (the
137    /// injected-never-provided dead-half). Defaults to `warn`, not `error`: a
138    /// DI key has an open provide surface (plugins, app-level provide) so
139    /// analyzer confidence is lower. A `warn` finding does not change the CLI
140    /// exit code unless `--fail-on-issues` is set. The dead-code count gate of
141    /// the GitHub Action counts it unless `fail-on-issues` is `false`.
142    #[serde(default = "Severity::default_warn", alias = "unprovided-inject")]
143    pub unprovided_injects: Severity,
144    /// Vue/Svelte single-file component reachable in the module graph but
145    /// rendered nowhere in the project (the imported-but-never-rendered
146    /// dead-half). Defaults to `warn`, not `error`: a component can be rendered
147    /// reflectively (dynamic `<component :is>`), so analyzer confidence is
148    /// lower. A `warn` finding does not change the CLI exit code unless
149    /// `--fail-on-issues` is set. The dead-code count gate of the GitHub Action
150    /// counts it unless `fail-on-issues` is `false`.
151    #[serde(default = "Severity::default_warn", alias = "unrendered-component")]
152    pub unrendered_components: Severity,
153    /// Vue `<script setup>` `defineProps`, Svelte 5 `$props()`, or React
154    /// declared prop referenced nowhere inside its own component. The
155    /// single-component dead-input direction. Defaults to `warn`, not `error`:
156    /// a prop can be part of a deliberately-stable public component API, so
157    /// analyzer confidence is lower. A `warn` finding does not change the CLI
158    /// exit code unless `--fail-on-issues` is set. The dead-code count gate of
159    /// the GitHub Action counts it unless `fail-on-issues` is `false`.
160    #[serde(default = "Severity::default_warn", alias = "unused-component-prop")]
161    pub unused_component_props: Severity,
162    /// Used optional inputs absent from inspected callers. Requires manual review; off by default.
163    #[serde(default = "Severity::default_off", alias = "absent-component-prop")]
164    pub absent_component_props: Severity,
165    /// Vue `<script setup>` `defineEmits` declared event emitted nowhere inside
166    /// its own single-file component (no `emit('<name>')` call). The
167    /// single-file dead-input direction. Defaults to `warn`, not `error`: an
168    /// emit can be part of a deliberately-stable public component API, so
169    /// analyzer confidence is lower. A `warn` finding does not change the CLI
170    /// exit code unless `--fail-on-issues` is set. The dead-code count gate of
171    /// the GitHub Action counts it unless `fail-on-issues` is `false`.
172    #[serde(default = "Severity::default_warn", alias = "unused-component-emit")]
173    pub unused_component_emits: Severity,
174    /// Angular `@Input()` / signal `input()` / `model()` declared input read
175    /// nowhere inside its own component (neither the inline/external template
176    /// nor the class body). The single-file dead-input direction, the Angular
177    /// analogue of `unused-component-prop`. Defaults to `warn`, not `error`: an
178    /// input can be part of a deliberately-stable public component API, so
179    /// analyzer confidence is lower. A `warn` finding does not change the CLI
180    /// exit code unless `--fail-on-issues` is set. The dead-code count gate of
181    /// the GitHub Action counts it unless `fail-on-issues` is `false`.
182    #[serde(default = "Severity::default_warn", alias = "unused-component-input")]
183    pub unused_component_inputs: Severity,
184    /// Angular `@Output()` / signal `output()` declared output emitted nowhere
185    /// inside its own component (no `this.<output>.emit(...)`). The single-file
186    /// dead-output direction, the Angular analogue of `unused-component-emit`.
187    /// Defaults to `warn`, not `error`: an output can be part of a
188    /// deliberately-stable public component API, so analyzer confidence is
189    /// lower. A `warn` finding does not change the CLI exit code unless
190    /// `--fail-on-issues` is set. The dead-code count gate of the GitHub Action
191    /// counts it unless `fail-on-issues` is `false`.
192    #[serde(default = "Severity::default_warn", alias = "unused-component-output")]
193    pub unused_component_outputs: Severity,
194    /// Svelte component dispatching a custom event via
195    /// `createEventDispatcher()` whose event name is listened to nowhere in the
196    /// analyzed project. The cross-file dead-output direction (no
197    /// eslint-plugin-svelte / svelte-check rule covers the listener side).
198    /// Defaults to `warn`, not `error`: a dispatched event can be part of a
199    /// deliberately-stable public component API, or a listener may be added
200    /// later, so analyzer confidence is lower. A `warn` finding does not change
201    /// the CLI exit code unless `--fail-on-issues` is set. The dead-code count
202    /// gate of the GitHub Action counts it unless `fail-on-issues` is `false`.
203    #[serde(default = "Severity::default_warn", alias = "unused-svelte-event")]
204    pub unused_svelte_events: Severity,
205    /// Effect layer composed only into other layers that nothing uses. Gated
206    /// on the project declaring `effect`. Defaults to `warn`: code outside the
207    /// analyzed project can still compose the layer.
208    #[serde(default = "Severity::default_warn", alias = "unused-effect-layer")]
209    pub unused_effect_layers: Severity,
210    /// Effect tagged error class that only type positions or `catchTag`
211    /// handlers reference. Gated on the project declaring `effect`. Defaults
212    /// to `warn`: consumers outside the project can construct the error.
213    #[serde(default = "Severity::default_warn", alias = "unused-tagged-error")]
214    pub unused_tagged_errors: Severity,
215    /// `catchTag` handler for a project tag that no code produces, when no
216    /// `unused-tagged-errors` finding covers it. Gated on the project
217    /// declaring `effect`. Defaults to `warn`.
218    #[serde(default = "Severity::default_warn", alias = "unproduced-catch-tag")]
219    pub unproduced_catch_tags: Severity,
220    /// Next.js Server Action (an export of a `"use server"` file) referenced by
221    /// no code in the project: no import-and-call, no `action={fn}` binding, no
222    /// `<form action={fn}>`. Cross-graph dead-export direction, reclassified
223    /// out of `unused-export` for `"use server"` files. Defaults to `warn`, not
224    /// `error`: the rule is new and false-negative-preferring, and reflective
225    /// action-dispatch shapes can hide a real consumer. The rule stays at
226    /// `warn` until corpus validation. A `warn` finding does not change the CLI
227    /// exit code unless `--fail-on-issues` is set. The dead-code count gate of
228    /// the GitHub Action counts it unless `fail-on-issues` is `false`.
229    #[serde(default = "Severity::default_warn", alias = "unused-server-action")]
230    pub unused_server_actions: Severity,
231    /// SvelteKit `+page.{ts,server.ts,js,server.js}` `load()` return-object key
232    /// read by no consumer: not off the sibling `+page.svelte`'s `data.<key>`,
233    /// nor project-wide via `page.data.<key>` / `$page.data.<key>`. Cross-file
234    /// dead-input direction. Defaults to `warn`, not `error`: the rule is new
235    /// and false-negative-preferring (a whole-object `data` pass abstains), and
236    /// a load fetch can have side effects so deletion is a human call. The rule
237    /// stays at `warn` until corpus validation. A `warn` finding does not
238    /// change the CLI exit code unless `--fail-on-issues` is set. The dead-code
239    /// count gate of the GitHub Action counts it unless `fail-on-issues` is
240    /// `false`.
241    #[serde(default = "Severity::default_warn", alias = "unused-load-data-key")]
242    pub unused_load_data_keys: Severity,
243    /// React/Preact prop forwarded unchanged through `>= N` intermediate
244    /// pass-through components until a component that substantively consumes it.
245    /// A graph-derived health signal. Defaults to `off` (opt-in), like
246    /// `private-type-leak` / `security-*`: the located per-chain records and the
247    /// small capped health penalty are dormant until the user enables the rule.
248    /// `error` enforces the finding in CI; `warn` stays advisory even under
249    /// strict flags. Overrides resolve for the source hop and can enable the
250    /// rule while the base rule is off.
251    #[serde(default = "Severity::default_off", alias = "prop-drilling")]
252    pub prop_drilling: Severity,
253    /// A React/Preact component whose entire body is `return <Child {...props}/>`
254    /// (pure structural indirection, a candidate for inlining). A graph-derived
255    /// health signal. Defaults to `off` (opt-in), like `prop-drilling`: the
256    /// located per-wrapper records are dormant until the user enables the rule.
257    #[serde(default = "Severity::default_off", alias = "thin-wrapper")]
258    pub thin_wrapper: Severity,
259    /// Three or more React/Preact components across two or more files whose
260    /// statically-harvested prop NAME set is identical after stripping ubiquitous
261    /// DOM / passthrough names (a missing shared `Props` type / base component).
262    /// A graph-derived structural-refactor health signal. Defaults to `off`
263    /// (opt-in), like `thin-wrapper`: the located per-component records are
264    /// dormant until the user enables the rule.
265    #[serde(default = "Severity::default_off", alias = "duplicate-prop-shape")]
266    pub duplicate_prop_shape: Severity,
267    /// A CSS / CSS-in-JS design-token DRIFT finding (a hardcoded value where a
268    /// design token exists, e.g. a Tailwind arbitrary value). A styling-domain
269    /// advisory surfaced in `fallow audit`; defaults to `warn` (verdict-neutral).
270    /// Set to `error` to gate CI on styling drift, or `off` to silence.
271    #[serde(default = "Severity::default_warn", alias = "css-token-drift")]
272    pub css_token_drift: Severity,
273    /// A CSS / CSS-in-JS DUPLICATE declaration block (copy-pasted rule body).
274    /// A styling-domain advisory surfaced in `fallow audit`; defaults to `warn`
275    /// (verdict-neutral). Set to `error` to gate, or `off` to silence.
276    #[serde(default = "Severity::default_warn", alias = "css-duplicate-block")]
277    pub css_duplicate_block: Severity,
278    /// CSS selector / nesting / important-density complexity. A styling-domain
279    /// advisory surfaced in `fallow audit`; defaults to `warn`
280    /// (verdict-neutral). Set to `error` to gate, or `off` to silence.
281    #[serde(default = "Severity::default_warn", alias = "css-selector-complexity")]
282    pub css_selector_complexity: Severity,
283    /// CSS dead surface, such as unused scoped SFC classes. A styling-domain
284    /// advisory surfaced in `fallow audit`; defaults to `warn`
285    /// (verdict-neutral). Set to `error` to gate, or `off` to silence.
286    #[serde(default = "Severity::default_warn", alias = "css-dead-surface")]
287    pub css_dead_surface: Severity,
288    /// CSS broken references, such as missing classes or keyframes. A
289    /// styling-domain advisory surfaced by deep CSS audit mode; defaults
290    /// to `warn` (verdict-neutral). Set to `error` to gate, or `off` to silence.
291    #[serde(default = "Severity::default_warn", alias = "css-broken-reference")]
292    pub css_broken_reference: Severity,
293    /// A function above the cyclomatic ceiling (`health.maxCyclomatic` or a
294    /// `thresholdOverrides` entry). The threshold decides if the finding
295    /// exists; this rule decides if it fails the run. Defaults to `error`.
296    /// `warn` reports the finding without a failure, and `off` hides it. A
297    /// finding above several ceilings takes the most severe of their rules.
298    /// The rule applies before the `health --min-severity` band gate.
299    /// After you set a kind to `off`, save the health baseline again: its
300    /// entries for the hidden findings no longer match.
301    #[serde(default)]
302    pub complexity_cyclomatic: Severity,
303    /// A function above the cognitive ceiling (`health.maxCognitive` or a
304    /// `thresholdOverrides` entry). The threshold decides if the finding
305    /// exists; this rule decides if it fails the run. Defaults to `error`.
306    #[serde(default)]
307    pub complexity_cognitive: Severity,
308    /// A function above the CRAP ceiling (`health.maxCrap` or a
309    /// `thresholdOverrides` entry). The threshold decides if the finding
310    /// exists; this rule decides if it fails the run. Defaults to `error`.
311    /// `off` hides the findings only. `health.maxCrap: 0` also turns off the
312    /// threshold-relative file-score signals.
313    #[serde(default)]
314    pub complexity_crap: Severity,
315    /// An import specifier that resolves to no file or package. Defaults to
316    /// `error`.
317    #[serde(default, alias = "unresolved-import")]
318    pub unresolved_imports: Severity,
319    /// An imported package not declared in any relevant `package.json`.
320    /// Defaults to `error`.
321    #[serde(default, alias = "unlisted-dependency")]
322    pub unlisted_dependencies: Severity,
323    /// The same export name provided by multiple modules; `ignoreExports`
324    /// excludes intentional barrel re-exports. Defaults to `error`.
325    #[serde(default, alias = "duplicate-export")]
326    pub duplicate_exports: Severity,
327    /// A production dependency imported only via type-only imports (a
328    /// `devDependencies` candidate). Defaults to `warn`.
329    #[serde(default = "Severity::default_warn", alias = "type-only-dependency")]
330    pub type_only_dependencies: Severity,
331    /// A production dependency imported only by test files. Defaults to
332    /// `warn`.
333    #[serde(default = "Severity::default_warn", alias = "test-only-dependency")]
334    pub test_only_dependencies: Severity,
335    /// A `devDependencies` entry imported by production code, which a
336    /// production-only install would omit and break at runtime. Defaults to
337    /// `warn`.
338    #[serde(
339        default = "Severity::default_warn",
340        alias = "dev-dependency-in-production"
341    )]
342    pub dev_dependencies_in_production: Severity,
343    /// A circular import chain between modules. Defaults to `error`.
344    #[serde(default, alias = "circular-dependency")]
345    pub circular_dependencies: Severity,
346    /// A cycle or self-loop in the re-export subgraph (barrel files
347    /// re-exporting from each other in a loop). Defaults to `warn`.
348    #[serde(
349        default = "Severity::default_warn",
350        alias = "re-export-cycles",
351        alias = "reexport-cycle",
352        alias = "reexport-cycles"
353    )]
354    pub re_export_cycle: Severity,
355    /// A dependency cycle between workspace packages, built from resolved
356    /// cross-package imports. Defaults to `warn`.
357    #[serde(default = "Severity::default_warn", alias = "package-cycles")]
358    pub package_cycle: Severity,
359    /// An import crossing a forbidden architecture-boundary edge declared in
360    /// the `boundaries` config. Defaults to `error`.
361    #[serde(default, alias = "boundary-violations")]
362    pub boundary_violation: Severity,
363    /// A runtime file or export with no test dependency path. Opt-in;
364    /// defaults to `off`.
365    #[serde(default = "Severity::default_off", alias = "coverage-gap")]
366    pub coverage_gaps: Severity,
367    /// A detected feature-flag pattern (tuned via the `flags` config).
368    /// Opt-in; defaults to `off`.
369    #[serde(default = "Severity::default_off", alias = "feature-flag")]
370    pub feature_flags: Severity,
371    /// A `fallow-ignore` comment or `@expected-unused` tag that no longer
372    /// matches any issue. Defaults to `warn`.
373    #[serde(default = "Severity::default_warn", alias = "stale-suppression")]
374    pub stale_suppressions: Severity,
375    /// Opt-in suppression hygiene rule: when enabled, every `fallow-ignore-*`
376    /// comment and `@expected-unused` tag must carry a `-- <reason>` suffix.
377    #[serde(default = "Severity::default_off", alias = "suppression-reason")]
378    pub require_suppression_reason: Severity,
379    /// A `pnpm-workspace.yaml` catalog entry referenced by no workspace
380    /// package. Defaults to `warn`.
381    #[serde(default = "Severity::default_warn", alias = "unused-catalog-entry")]
382    pub unused_catalog_entries: Severity,
383    /// A named pnpm catalog group declaring no entries. Defaults to `warn`.
384    #[serde(default = "Severity::default_warn", alias = "empty-catalog-group")]
385    pub empty_catalog_groups: Severity,
386    /// A workspace `package.json` `catalog:` / `catalog:<name>` reference
387    /// pointing at a catalog that does not declare the consumed package.
388    /// Defaults to `error`; suppressible only via `ignoreCatalogReferences`.
389    #[serde(default, alias = "unresolved-catalog-reference")]
390    pub unresolved_catalog_references: Severity,
391    /// A pnpm, npm, or Bun override entry whose target package no workspace
392    /// `package.json` declares and the active readable lockfile does not
393    /// resolve. Defaults to `warn`.
394    #[serde(
395        default = "Severity::default_warn",
396        alias = "unused-dependency-override"
397    )]
398    pub unused_dependency_overrides: Severity,
399    /// A pnpm, npm, or Bun override or Bun `resolutions` entry whose key or
400    /// value cannot be parsed in its declaration source's grammar. Defaults to
401    /// `error`.
402    #[serde(default, alias = "misconfigured-dependency-override")]
403    pub misconfigured_dependency_overrides: Severity,
404    /// Opt-in (default off): a `"use client"` file that transitively imports a
405    /// module reading a non-public `process.env` secret. Surfaced only by
406    /// `fallow security`; never under bare `fallow` or the `audit` gate.
407    #[serde(default = "Severity::default_off")]
408    pub security_client_server_leak: Severity,
409    /// Opt-in (default off): a syntactic tainted-sink candidate matched against
410    /// the data-driven catalogue (`security_matchers.toml`). ONE knob gates ALL
411    /// catalogue categories. Surfaced only by `fallow security`; never under
412    /// bare `fallow` or the `audit` gate.
413    #[serde(default = "Severity::default_off")]
414    pub security_sink: Severity,
415    /// Master severity for rule-pack findings (`rulePacks` config). Defaults
416    /// to `warn` so enabling a brand-new policy pack never hard-fails CI on
417    /// its first run; individual pack rules opt up via `"severity": "error"`.
418    /// `off` is a kill switch that disables the whole evaluator (per-rule
419    /// severity cannot resurrect it).
420    #[serde(default = "Severity::default_warn", alias = "policy-violations")]
421    pub policy_violation: Severity,
422    /// A `"use client"` file that exports a Next.js server-only /
423    /// route-segment config name (e.g. `metadata`, `revalidate`, `GET`).
424    /// Next.js rejects this at build time; fallow catches it statically.
425    /// Defaults to `warn`.
426    #[serde(default = "Severity::default_warn", alias = "invalid-client-exports")]
427    pub invalid_client_export: Severity,
428    /// A barrel file that re-exports BOTH a `"use client"` origin module AND a
429    /// server-only origin module. Importing one name from such a barrel drags
430    /// the other's directive context across the React Server Components
431    /// boundary (the Next.js App Router footgun). Defaults to `warn`.
432    #[serde(
433        default = "Severity::default_warn",
434        alias = "mixed-client-server-barrels"
435    )]
436    pub mixed_client_server_barrel: Severity,
437    /// A `"use client"` / `"use server"` directive written as an expression
438    /// statement after a non-directive statement (an import, a const), so the
439    /// RSC bundler parses it as an ordinary string and silently ignores it.
440    /// The intended client/server boundary never takes effect. Defaults to
441    /// `warn`.
442    #[serde(default = "Severity::default_warn", alias = "misplaced-directives")]
443    pub misplaced_directive: Severity,
444    /// Two or more Next.js App Router route files that resolve to the same URL
445    /// within one app-root. Next.js fails the build ("You cannot have two
446    /// parallel pages that resolve to the same path"); fallow catches it
447    /// statically and names every colliding file. Defaults to `error`: the
448    /// project already fails `next build`, so flagging it as an error aligns
449    /// fallow's exit code with the build it mirrors.
450    #[serde(default, alias = "route-collisions")]
451    pub route_collision: Severity,
452    /// Sibling Next.js dynamic route segments at one tree position using
453    /// different param spellings (`[id]` vs `[slug]`). Next.js throws "You
454    /// cannot use different slug names for the same dynamic path" at dev and
455    /// production runtime when the position is hit; `next build` does NOT catch
456    /// it (the build succeeds), so CI passes while the route crashes on its
457    /// first request. fallow catches it statically. Defaults to `error`: the
458    /// route is a deterministic runtime crash on first request, so failing CI
459    /// is the honest signal even though `next build` stays green (this is the
460    /// "error-runtime" severity tier, shared with `route-collision`).
461    #[serde(default, alias = "dynamic-segment-name-conflicts")]
462    pub dynamic_segment_name_conflict: Severity,
463    /// An audit diff that weakens tests: a new skip or focus marker, removed
464    /// test cases, fewer assertions, or a lowered coverage threshold.
465    /// `fallow audit` only. Defaults to `warn`.
466    #[serde(default = "Severity::default_warn")]
467    pub weakened_tests: Severity,
468    /// An audit diff that adds a lint, type-check, coverage or Fallow
469    /// suppression comment. `fallow audit` only. Defaults to `warn`.
470    #[serde(default = "Severity::default_warn", alias = "added-suppressions")]
471    pub added_suppression: Severity,
472    /// An audit diff that adds a test-environment branch to a non-test source
473    /// file. `fallow audit` only. Defaults to `warn`.
474    #[serde(default = "Severity::default_warn", alias = "test-only-branches")]
475    pub test_only_branch: Severity,
476    /// An audit diff that edits one copy of a base clone group so that it no
477    /// longer matches its siblings, while no sibling changed. `fallow audit`
478    /// only. Opt-in; defaults to `off`, because copies often differ on
479    /// purpose.
480    #[serde(default = "Severity::default_off", alias = "diverged-clone-siblings")]
481    pub diverged_clone_sibling: Severity,
482    /// An audit diff that adds a dependency to a `package.json`. `fallow
483    /// audit` only. Opt-in; defaults to `off`.
484    #[serde(default = "Severity::default_off", alias = "added-dependencies")]
485    pub added_dependency: Severity,
486}
487
488impl Default for RulesConfig {
489    fn default() -> Self {
490        Self {
491            unused_files: Severity::Error,
492            unused_exports: Severity::Error,
493            unused_types: Severity::Error,
494            private_type_leaks: Severity::Off,
495            private_type_leaks_configured: false,
496            deprecated_exports_in_use: Severity::Off,
497            unused_dependencies: Severity::Error,
498            unused_dev_dependencies: Severity::Warn,
499            unused_optional_dependencies: Severity::Warn,
500            unused_enum_members: Severity::Error,
501            unused_class_members: Severity::Error,
502            unused_store_members: Severity::Warn,
503            unprovided_injects: Severity::Warn,
504            unrendered_components: Severity::Warn,
505            unused_component_props: Severity::Warn,
506            absent_component_props: Severity::Off,
507            unused_component_emits: Severity::Warn,
508            unused_component_inputs: Severity::Warn,
509            unused_component_outputs: Severity::Warn,
510            unused_svelte_events: Severity::Warn,
511            unused_effect_layers: Severity::Warn,
512            unused_tagged_errors: Severity::Warn,
513            unproduced_catch_tags: Severity::Warn,
514            unused_server_actions: Severity::Warn,
515            unused_load_data_keys: Severity::Warn,
516            prop_drilling: Severity::Off,
517            thin_wrapper: Severity::Off,
518            duplicate_prop_shape: Severity::Off,
519            css_token_drift: Severity::Warn,
520            css_duplicate_block: Severity::Warn,
521            css_selector_complexity: Severity::Warn,
522            css_dead_surface: Severity::Warn,
523            css_broken_reference: Severity::Warn,
524            complexity_cyclomatic: Severity::Error,
525            complexity_cognitive: Severity::Error,
526            complexity_crap: Severity::Error,
527            unresolved_imports: Severity::Error,
528            unlisted_dependencies: Severity::Error,
529            duplicate_exports: Severity::Error,
530            type_only_dependencies: Severity::Warn,
531            test_only_dependencies: Severity::Warn,
532            dev_dependencies_in_production: Severity::Warn,
533            circular_dependencies: Severity::Error,
534            re_export_cycle: Severity::Warn,
535            package_cycle: Severity::Warn,
536            boundary_violation: Severity::Error,
537            coverage_gaps: Severity::Off,
538            feature_flags: Severity::Off,
539            stale_suppressions: Severity::Warn,
540            require_suppression_reason: Severity::Off,
541            unused_catalog_entries: Severity::Warn,
542            empty_catalog_groups: Severity::Warn,
543            unresolved_catalog_references: Severity::Error,
544            unused_dependency_overrides: Severity::Warn,
545            misconfigured_dependency_overrides: Severity::Error,
546            security_client_server_leak: Severity::Off,
547            security_sink: Severity::Off,
548            policy_violation: Severity::Warn,
549            invalid_client_export: Severity::Warn,
550            mixed_client_server_barrel: Severity::Warn,
551            misplaced_directive: Severity::Warn,
552            route_collision: Severity::Error,
553            dynamic_segment_name_conflict: Severity::Error,
554            weakened_tests: Severity::Warn,
555            added_suppression: Severity::Warn,
556            test_only_branch: Severity::Warn,
557            diverged_clone_sibling: Severity::Off,
558            added_dependency: Severity::Off,
559        }
560    }
561}
562
563macro_rules! apply_partial_rules {
564    ($target:expr, $partial:expr, [$($field:ident),+ $(,)?]) => {
565        $(
566            if let Some(severity) = $partial.$field {
567                $target.$field = severity;
568            }
569        )+
570    };
571}
572
573impl RulesConfig {
574    /// Map an [`IssueKind`] to its configured [`Severity`] in this config.
575    ///
576    /// Single source of truth for the kind-to-severity mapping, shared by core
577    /// suppression gating (`severity_for_kind`) and the agent capability
578    /// manifest (`fallow schema`'s per-rule `default_severity`). Exhaustive by
579    /// design: a new `IssueKind` variant is a compile error here, forcing the
580    /// implementer to decide which `RulesConfig` field (if any) gates emission.
581    /// Kinds with no matching field (`Complexity`, `CodeDuplication`, gated by
582    /// their own command rather than a rule) return the non-`Off`
583    /// `Severity::Error`; in core these short-circuit earlier via
584    /// `NON_CORE_KINDS` so the value is unobservable there.
585    #[must_use]
586    pub const fn severity_for_kind(&self, kind: IssueKind) -> Severity {
587        match kind {
588            IssueKind::UnusedFile => self.unused_files,
589            IssueKind::UnusedExport => self.unused_exports,
590            IssueKind::UnusedType => self.unused_types,
591            IssueKind::PrivateTypeLeak => self.private_type_leaks,
592            IssueKind::DeprecatedExportInUse => self.deprecated_exports_in_use,
593            IssueKind::UnusedDependency => self.unused_dependencies,
594            IssueKind::UnusedDevDependency => self.unused_dev_dependencies,
595            IssueKind::UnusedEnumMember => self.unused_enum_members,
596            IssueKind::UnusedClassMember => self.unused_class_members,
597            IssueKind::UnusedStoreMember => self.unused_store_members,
598            IssueKind::UnprovidedInject => self.unprovided_injects,
599            IssueKind::UnresolvedImport => self.unresolved_imports,
600            IssueKind::UnlistedDependency => self.unlisted_dependencies,
601            IssueKind::DuplicateExport => self.duplicate_exports,
602            IssueKind::CircularDependency => self.circular_dependencies,
603            IssueKind::ReExportCycle => self.re_export_cycle,
604            IssueKind::PackageCycle => self.package_cycle,
605            IssueKind::TypeOnlyDependency => self.type_only_dependencies,
606            IssueKind::TestOnlyDependency => self.test_only_dependencies,
607            IssueKind::DevDependencyInProduction => self.dev_dependencies_in_production,
608            IssueKind::BoundaryViolation => self.boundary_violation,
609            IssueKind::CoverageGaps => self.coverage_gaps,
610            IssueKind::FeatureFlag => self.feature_flags,
611            IssueKind::StaleSuppression => self.stale_suppressions,
612            IssueKind::PnpmCatalogEntry => self.unused_catalog_entries,
613            IssueKind::EmptyCatalogGroup => self.empty_catalog_groups,
614            IssueKind::UnresolvedCatalogReference => self.unresolved_catalog_references,
615            IssueKind::UnusedDependencyOverride => self.unused_dependency_overrides,
616            IssueKind::MisconfiguredDependencyOverride => self.misconfigured_dependency_overrides,
617            IssueKind::SecurityClientServerLeak => self.security_client_server_leak,
618            IssueKind::SecuritySink => self.security_sink,
619            IssueKind::PolicyViolation => self.policy_violation,
620            IssueKind::InvalidClientExport => self.invalid_client_export,
621            IssueKind::MixedClientServerBarrel => self.mixed_client_server_barrel,
622            IssueKind::MisplacedDirective => self.misplaced_directive,
623            IssueKind::RouteCollision => self.route_collision,
624            IssueKind::DynamicSegmentNameConflict => self.dynamic_segment_name_conflict,
625            IssueKind::UnrenderedComponent => self.unrendered_components,
626            IssueKind::UnusedComponentProp => self.unused_component_props,
627            IssueKind::AbsentComponentProp => self.absent_component_props,
628            IssueKind::UnusedComponentEmit => self.unused_component_emits,
629            IssueKind::UnusedComponentInput => self.unused_component_inputs,
630            IssueKind::UnusedComponentOutput => self.unused_component_outputs,
631            IssueKind::UnusedSvelteEvent => self.unused_svelte_events,
632            IssueKind::UnusedEffectLayer => self.unused_effect_layers,
633            IssueKind::UnusedTaggedError => self.unused_tagged_errors,
634            IssueKind::UnproducedCatchTag => self.unproduced_catch_tags,
635            IssueKind::UnusedServerAction => self.unused_server_actions,
636            IssueKind::UnusedLoadDataKey => self.unused_load_data_keys,
637            IssueKind::PropDrilling => self.prop_drilling,
638            IssueKind::ThinWrapper => self.thin_wrapper,
639            IssueKind::DuplicatePropShape => self.duplicate_prop_shape,
640            IssueKind::CssTokenDrift => self.css_token_drift,
641            IssueKind::CssDuplicateBlock => self.css_duplicate_block,
642            IssueKind::CssSelectorComplexity => self.css_selector_complexity,
643            IssueKind::CssDeadSurface => self.css_dead_surface,
644            IssueKind::CssBrokenReference => self.css_broken_reference,
645            IssueKind::WeakenedTests => self.weakened_tests,
646            IssueKind::AddedSuppression => self.added_suppression,
647            IssueKind::TestOnlyBranch => self.test_only_branch,
648            IssueKind::DivergedCloneSibling => self.diverged_clone_sibling,
649            IssueKind::AddedDependency => self.added_dependency,
650            IssueKind::Complexity | IssueKind::CodeDuplication => Severity::Error,
651        }
652    }
653
654    /// The gate severity of a complexity finding from the kinds that exceeded
655    /// their threshold.
656    ///
657    /// The most severe rule of the contributing kinds wins. The result is
658    /// `Off` only when every contributing kind is `off`, and the finding is
659    /// then dropped. A kind that did not contribute has no effect.
660    #[must_use]
661    pub fn complexity_severity(&self, cyclomatic: bool, cognitive: bool, crap: bool) -> Severity {
662        [
663            (cyclomatic, self.complexity_cyclomatic),
664            (cognitive, self.complexity_cognitive),
665            (crap, self.complexity_crap),
666        ]
667        .into_iter()
668        .filter_map(|(contributed, severity)| contributed.then_some(severity))
669        .max_by_key(|severity| match severity {
670            Severity::Error => 2,
671            Severity::Warn => 1,
672            Severity::Off => 0,
673        })
674        .unwrap_or(Severity::Off)
675    }
676
677    /// Apply a partial rules config on top. Only `Some` fields override.
678    pub const fn apply_partial(&mut self, partial: &PartialRulesConfig) {
679        apply_partial_rules!(
680            self,
681            partial,
682            [
683                unused_files,
684                unused_exports,
685                unused_types,
686                private_type_leaks,
687                deprecated_exports_in_use,
688                unused_dependencies,
689                unused_dev_dependencies,
690                unused_optional_dependencies,
691            ]
692        );
693        apply_partial_rules!(
694            self,
695            partial,
696            [
697                unused_enum_members,
698                unused_class_members,
699                unused_store_members,
700                unprovided_injects,
701                unrendered_components,
702                unused_component_props,
703                absent_component_props,
704                unused_component_emits,
705                unused_component_inputs,
706                unused_component_outputs,
707                unused_svelte_events,
708                unused_effect_layers,
709                unused_tagged_errors,
710                unproduced_catch_tags,
711                unused_server_actions,
712                unused_load_data_keys,
713                prop_drilling,
714                thin_wrapper,
715                duplicate_prop_shape,
716                css_token_drift,
717                css_duplicate_block,
718                css_selector_complexity,
719                css_dead_surface,
720                css_broken_reference,
721                complexity_cyclomatic,
722                complexity_cognitive,
723                complexity_crap,
724            ]
725        );
726        apply_partial_rules!(
727            self,
728            partial,
729            [
730                unresolved_imports,
731                unlisted_dependencies,
732                duplicate_exports,
733                type_only_dependencies,
734                test_only_dependencies,
735                dev_dependencies_in_production,
736                circular_dependencies,
737                re_export_cycle,
738                package_cycle,
739                boundary_violation,
740            ]
741        );
742        apply_partial_rules!(
743            self,
744            partial,
745            [
746                coverage_gaps,
747                feature_flags,
748                stale_suppressions,
749                require_suppression_reason,
750                unused_catalog_entries,
751                empty_catalog_groups,
752                unresolved_catalog_references,
753                unused_dependency_overrides,
754                misconfigured_dependency_overrides,
755            ]
756        );
757        apply_partial_rules!(
758            self,
759            partial,
760            [
761                security_client_server_leak,
762                security_sink,
763                policy_violation,
764                invalid_client_export,
765                mixed_client_server_barrel,
766                misplaced_directive,
767                route_collision,
768                dynamic_segment_name_conflict,
769                weakened_tests,
770                added_suppression,
771                test_only_branch,
772                diverged_clone_sibling,
773                added_dependency,
774            ]
775        );
776    }
777}
778
779/// The default [`Severity`] for an [`IssueKind`] under zero config.
780///
781/// Equivalent to `RulesConfig::default().severity_for_kind(kind)`, exposed as a
782/// free function so the agent capability manifest (`fallow schema`) can publish
783/// each rule's out-of-the-box severity without threading a config through. There
784/// is exactly ONE kind-to-severity table (`RulesConfig::severity_for_kind`); this
785/// reuses it rather than duplicating the mapping.
786#[must_use]
787pub fn default_severity_for_kind(kind: IssueKind) -> Severity {
788    RulesConfig::default().severity_for_kind(kind)
789}
790
791/// Whether a rule is opt-in: its default severity is `Off`, so it detects and
792/// reports nothing until a user explicitly enables it.
793///
794/// This is the single most load-bearing manifest signal for agents: enabling an
795/// opt-in rule blindly (`private-type-leak`, `security-*`) is the most common way
796/// to flood a repo with findings, so the manifest exposes it as a first-class bool.
797#[must_use]
798pub fn is_opt_in_kind(kind: IssueKind) -> bool {
799    matches!(default_severity_for_kind(kind), Severity::Off)
800}
801
802/// Partial per-issue-type severity for overrides. All fields optional.
803#[derive(Debug, Default, Clone, Deserialize, Serialize, JsonSchema)]
804#[serde(rename_all = "kebab-case")]
805pub struct PartialRulesConfig {
806    /// Optional override for [`RulesConfig::unused_files`].
807    #[serde(
808        default,
809        alias = "unused-file",
810        skip_serializing_if = "Option::is_none"
811    )]
812    pub unused_files: Option<Severity>,
813    /// Optional override for [`RulesConfig::unused_exports`].
814    #[serde(
815        default,
816        alias = "unused-export",
817        skip_serializing_if = "Option::is_none"
818    )]
819    pub unused_exports: Option<Severity>,
820    /// Optional override for [`RulesConfig::unused_types`].
821    #[serde(
822        default,
823        alias = "unused-type",
824        skip_serializing_if = "Option::is_none"
825    )]
826    pub unused_types: Option<Severity>,
827    /// Optional override for [`RulesConfig::private_type_leaks`].
828    #[serde(
829        default,
830        alias = "private-type-leak",
831        skip_serializing_if = "Option::is_none"
832    )]
833    pub private_type_leaks: Option<Severity>,
834    /// Optional override for [`RulesConfig::deprecated_exports_in_use`].
835    #[serde(
836        default,
837        alias = "deprecated-export-in-use",
838        skip_serializing_if = "Option::is_none"
839    )]
840    pub deprecated_exports_in_use: Option<Severity>,
841    /// Optional override for [`RulesConfig::unused_dependencies`].
842    #[serde(
843        default,
844        alias = "unused-dependency",
845        skip_serializing_if = "Option::is_none"
846    )]
847    pub unused_dependencies: Option<Severity>,
848    /// Optional override for [`RulesConfig::unused_dev_dependencies`].
849    #[serde(
850        default,
851        alias = "unused-dev-dependency",
852        skip_serializing_if = "Option::is_none"
853    )]
854    pub unused_dev_dependencies: Option<Severity>,
855    /// Optional override for [`RulesConfig::unused_optional_dependencies`].
856    #[serde(
857        default,
858        alias = "unused-optional-dependency",
859        skip_serializing_if = "Option::is_none"
860    )]
861    pub unused_optional_dependencies: Option<Severity>,
862    /// Optional override for [`RulesConfig::unused_enum_members`].
863    #[serde(
864        default,
865        alias = "unused-enum-member",
866        skip_serializing_if = "Option::is_none"
867    )]
868    pub unused_enum_members: Option<Severity>,
869    /// Optional override for [`RulesConfig::unused_class_members`].
870    #[serde(
871        default,
872        alias = "unused-class-member",
873        skip_serializing_if = "Option::is_none"
874    )]
875    pub unused_class_members: Option<Severity>,
876    /// Optional override for [`RulesConfig::unused_store_members`].
877    #[serde(
878        default,
879        alias = "unused-store-member",
880        skip_serializing_if = "Option::is_none"
881    )]
882    pub unused_store_members: Option<Severity>,
883    /// Optional override for [`RulesConfig::unprovided_injects`].
884    #[serde(
885        default,
886        alias = "unprovided-inject",
887        skip_serializing_if = "Option::is_none"
888    )]
889    pub unprovided_injects: Option<Severity>,
890    /// Optional override for [`RulesConfig::unrendered_components`].
891    #[serde(
892        default,
893        alias = "unrendered-component",
894        skip_serializing_if = "Option::is_none"
895    )]
896    pub unrendered_components: Option<Severity>,
897    /// Optional override for [`RulesConfig::unused_component_props`].
898    #[serde(
899        default,
900        alias = "unused-component-prop",
901        skip_serializing_if = "Option::is_none"
902    )]
903    pub unused_component_props: Option<Severity>,
904    /// Optional override for absent optional component inputs.
905    #[serde(
906        default,
907        alias = "absent-component-prop",
908        skip_serializing_if = "Option::is_none"
909    )]
910    pub absent_component_props: Option<Severity>,
911    /// Optional override for [`RulesConfig::unused_component_emits`].
912    #[serde(
913        default,
914        alias = "unused-component-emit",
915        skip_serializing_if = "Option::is_none"
916    )]
917    pub unused_component_emits: Option<Severity>,
918    /// Optional override for [`RulesConfig::unused_component_inputs`].
919    #[serde(
920        default,
921        alias = "unused-component-input",
922        skip_serializing_if = "Option::is_none"
923    )]
924    pub unused_component_inputs: Option<Severity>,
925    /// Optional override for [`RulesConfig::unused_component_outputs`].
926    #[serde(
927        default,
928        alias = "unused-component-output",
929        skip_serializing_if = "Option::is_none"
930    )]
931    pub unused_component_outputs: Option<Severity>,
932    /// Optional override for [`RulesConfig::unused_svelte_events`].
933    #[serde(
934        default,
935        alias = "unused-svelte-event",
936        skip_serializing_if = "Option::is_none"
937    )]
938    pub unused_svelte_events: Option<Severity>,
939    /// Optional override for [`RulesConfig::unused_effect_layers`].
940    #[serde(
941        default,
942        alias = "unused-effect-layer",
943        skip_serializing_if = "Option::is_none"
944    )]
945    pub unused_effect_layers: Option<Severity>,
946    /// Optional override for [`RulesConfig::unused_tagged_errors`].
947    #[serde(
948        default,
949        alias = "unused-tagged-error",
950        skip_serializing_if = "Option::is_none"
951    )]
952    pub unused_tagged_errors: Option<Severity>,
953    /// Optional override for [`RulesConfig::unproduced_catch_tags`].
954    #[serde(
955        default,
956        alias = "unproduced-catch-tag",
957        skip_serializing_if = "Option::is_none"
958    )]
959    pub unproduced_catch_tags: Option<Severity>,
960    /// Optional override for [`RulesConfig::unused_server_actions`].
961    #[serde(
962        default,
963        alias = "unused-server-action",
964        skip_serializing_if = "Option::is_none"
965    )]
966    pub unused_server_actions: Option<Severity>,
967    /// Optional override for [`RulesConfig::unused_load_data_keys`].
968    #[serde(
969        default,
970        alias = "unused-load-data-key",
971        skip_serializing_if = "Option::is_none"
972    )]
973    pub unused_load_data_keys: Option<Severity>,
974    /// Optional override for [`RulesConfig::prop_drilling`].
975    #[serde(
976        default,
977        alias = "prop-drilling",
978        skip_serializing_if = "Option::is_none"
979    )]
980    pub prop_drilling: Option<Severity>,
981    /// Optional override for [`RulesConfig::thin_wrapper`].
982    #[serde(
983        default,
984        alias = "thin-wrapper",
985        skip_serializing_if = "Option::is_none"
986    )]
987    pub thin_wrapper: Option<Severity>,
988    /// Optional override for [`RulesConfig::duplicate_prop_shape`].
989    #[serde(
990        default,
991        alias = "duplicate-prop-shape",
992        skip_serializing_if = "Option::is_none"
993    )]
994    pub duplicate_prop_shape: Option<Severity>,
995    /// Optional override for [`RulesConfig::css_token_drift`].
996    #[serde(
997        default,
998        alias = "css-token-drift",
999        skip_serializing_if = "Option::is_none"
1000    )]
1001    pub css_token_drift: Option<Severity>,
1002    /// Optional override for [`RulesConfig::css_duplicate_block`].
1003    #[serde(
1004        default,
1005        alias = "css-duplicate-block",
1006        skip_serializing_if = "Option::is_none"
1007    )]
1008    pub css_duplicate_block: Option<Severity>,
1009    /// Optional override for [`RulesConfig::css_selector_complexity`].
1010    #[serde(
1011        default,
1012        alias = "css-selector-complexity",
1013        skip_serializing_if = "Option::is_none"
1014    )]
1015    pub css_selector_complexity: Option<Severity>,
1016    /// Optional override for [`RulesConfig::css_dead_surface`].
1017    #[serde(
1018        default,
1019        alias = "css-dead-surface",
1020        skip_serializing_if = "Option::is_none"
1021    )]
1022    pub css_dead_surface: Option<Severity>,
1023    /// Optional override for [`RulesConfig::css_broken_reference`].
1024    #[serde(
1025        default,
1026        alias = "css-broken-reference",
1027        skip_serializing_if = "Option::is_none"
1028    )]
1029    pub css_broken_reference: Option<Severity>,
1030    /// Optional override for [`RulesConfig::complexity_cyclomatic`].
1031    #[serde(default, skip_serializing_if = "Option::is_none")]
1032    pub complexity_cyclomatic: Option<Severity>,
1033    /// Optional override for [`RulesConfig::complexity_cognitive`].
1034    #[serde(default, skip_serializing_if = "Option::is_none")]
1035    pub complexity_cognitive: Option<Severity>,
1036    /// Optional override for [`RulesConfig::complexity_crap`].
1037    #[serde(default, skip_serializing_if = "Option::is_none")]
1038    pub complexity_crap: Option<Severity>,
1039    /// Optional override for [`RulesConfig::unresolved_imports`].
1040    #[serde(
1041        default,
1042        alias = "unresolved-import",
1043        skip_serializing_if = "Option::is_none"
1044    )]
1045    pub unresolved_imports: Option<Severity>,
1046    /// Optional override for [`RulesConfig::unlisted_dependencies`].
1047    #[serde(
1048        default,
1049        alias = "unlisted-dependency",
1050        skip_serializing_if = "Option::is_none"
1051    )]
1052    pub unlisted_dependencies: Option<Severity>,
1053    /// Optional override for [`RulesConfig::duplicate_exports`].
1054    #[serde(
1055        default,
1056        alias = "duplicate-export",
1057        skip_serializing_if = "Option::is_none"
1058    )]
1059    pub duplicate_exports: Option<Severity>,
1060    /// Optional override for [`RulesConfig::type_only_dependencies`].
1061    #[serde(
1062        default,
1063        alias = "type-only-dependency",
1064        skip_serializing_if = "Option::is_none"
1065    )]
1066    pub type_only_dependencies: Option<Severity>,
1067    /// Optional override for [`RulesConfig::test_only_dependencies`].
1068    #[serde(
1069        default,
1070        alias = "test-only-dependency",
1071        skip_serializing_if = "Option::is_none"
1072    )]
1073    pub test_only_dependencies: Option<Severity>,
1074    /// Optional override for [`RulesConfig::dev_dependencies_in_production`].
1075    #[serde(
1076        default,
1077        alias = "dev-dependency-in-production",
1078        skip_serializing_if = "Option::is_none"
1079    )]
1080    pub dev_dependencies_in_production: Option<Severity>,
1081    /// Optional override for [`RulesConfig::circular_dependencies`].
1082    #[serde(
1083        default,
1084        alias = "circular-dependency",
1085        skip_serializing_if = "Option::is_none"
1086    )]
1087    pub circular_dependencies: Option<Severity>,
1088    /// Optional override for [`RulesConfig::re_export_cycle`].
1089    #[serde(
1090        default,
1091        alias = "re-export-cycles",
1092        alias = "reexport-cycle",
1093        alias = "reexport-cycles",
1094        skip_serializing_if = "Option::is_none"
1095    )]
1096    pub re_export_cycle: Option<Severity>,
1097    /// Optional override for [`RulesConfig::package_cycle`].
1098    #[serde(
1099        default,
1100        alias = "package-cycles",
1101        skip_serializing_if = "Option::is_none"
1102    )]
1103    pub package_cycle: Option<Severity>,
1104    /// Optional override for [`RulesConfig::boundary_violation`].
1105    #[serde(
1106        default,
1107        alias = "boundary-violations",
1108        skip_serializing_if = "Option::is_none"
1109    )]
1110    pub boundary_violation: Option<Severity>,
1111    /// Optional override for [`RulesConfig::coverage_gaps`].
1112    #[serde(
1113        default,
1114        alias = "coverage-gap",
1115        skip_serializing_if = "Option::is_none"
1116    )]
1117    pub coverage_gaps: Option<Severity>,
1118    /// Optional override for [`RulesConfig::feature_flags`].
1119    #[serde(
1120        default,
1121        alias = "feature-flag",
1122        skip_serializing_if = "Option::is_none"
1123    )]
1124    pub feature_flags: Option<Severity>,
1125    /// Optional override for [`RulesConfig::stale_suppressions`].
1126    #[serde(
1127        default,
1128        alias = "stale-suppression",
1129        skip_serializing_if = "Option::is_none"
1130    )]
1131    pub stale_suppressions: Option<Severity>,
1132    /// Optional override for [`RulesConfig::require_suppression_reason`].
1133    #[serde(
1134        default,
1135        alias = "suppression-reason",
1136        skip_serializing_if = "Option::is_none"
1137    )]
1138    pub require_suppression_reason: Option<Severity>,
1139    /// Optional override for [`RulesConfig::unused_catalog_entries`].
1140    #[serde(
1141        default,
1142        alias = "unused-catalog-entry",
1143        skip_serializing_if = "Option::is_none"
1144    )]
1145    pub unused_catalog_entries: Option<Severity>,
1146    /// Optional override for [`RulesConfig::empty_catalog_groups`].
1147    #[serde(
1148        default,
1149        alias = "empty-catalog-group",
1150        skip_serializing_if = "Option::is_none"
1151    )]
1152    pub empty_catalog_groups: Option<Severity>,
1153    /// Optional override for [`RulesConfig::unresolved_catalog_references`].
1154    #[serde(
1155        default,
1156        alias = "unresolved-catalog-reference",
1157        skip_serializing_if = "Option::is_none"
1158    )]
1159    pub unresolved_catalog_references: Option<Severity>,
1160    /// Optional override for [`RulesConfig::unused_dependency_overrides`].
1161    #[serde(
1162        default,
1163        alias = "unused-dependency-override",
1164        skip_serializing_if = "Option::is_none"
1165    )]
1166    pub unused_dependency_overrides: Option<Severity>,
1167    /// Optional override for [`RulesConfig::misconfigured_dependency_overrides`].
1168    #[serde(
1169        default,
1170        alias = "misconfigured-dependency-override",
1171        skip_serializing_if = "Option::is_none"
1172    )]
1173    pub misconfigured_dependency_overrides: Option<Severity>,
1174    /// Optional override for [`RulesConfig::security_client_server_leak`].
1175    #[serde(default, skip_serializing_if = "Option::is_none")]
1176    pub security_client_server_leak: Option<Severity>,
1177    /// Optional override for [`RulesConfig::security_sink`].
1178    #[serde(default, skip_serializing_if = "Option::is_none")]
1179    pub security_sink: Option<Severity>,
1180    /// Optional override for [`RulesConfig::policy_violation`].
1181    #[serde(
1182        default,
1183        alias = "policy-violations",
1184        skip_serializing_if = "Option::is_none"
1185    )]
1186    pub policy_violation: Option<Severity>,
1187    /// Optional override for [`RulesConfig::invalid_client_export`].
1188    #[serde(
1189        default,
1190        alias = "invalid-client-exports",
1191        skip_serializing_if = "Option::is_none"
1192    )]
1193    pub invalid_client_export: Option<Severity>,
1194    /// Optional override for [`RulesConfig::mixed_client_server_barrel`].
1195    #[serde(
1196        default,
1197        alias = "mixed-client-server-barrels",
1198        skip_serializing_if = "Option::is_none"
1199    )]
1200    pub mixed_client_server_barrel: Option<Severity>,
1201    /// Optional override for [`RulesConfig::misplaced_directive`].
1202    #[serde(
1203        default,
1204        alias = "misplaced-directives",
1205        skip_serializing_if = "Option::is_none"
1206    )]
1207    pub misplaced_directive: Option<Severity>,
1208    /// Optional override for [`RulesConfig::route_collision`].
1209    #[serde(
1210        default,
1211        alias = "route-collisions",
1212        skip_serializing_if = "Option::is_none"
1213    )]
1214    pub route_collision: Option<Severity>,
1215    /// Optional override for [`RulesConfig::dynamic_segment_name_conflict`].
1216    #[serde(
1217        default,
1218        alias = "dynamic-segment-name-conflicts",
1219        skip_serializing_if = "Option::is_none"
1220    )]
1221    pub dynamic_segment_name_conflict: Option<Severity>,
1222    /// Optional override for [`RulesConfig::weakened_tests`].
1223    #[serde(default, skip_serializing_if = "Option::is_none")]
1224    pub weakened_tests: Option<Severity>,
1225    /// Optional override for [`RulesConfig::added_suppression`].
1226    #[serde(
1227        default,
1228        alias = "added-suppressions",
1229        skip_serializing_if = "Option::is_none"
1230    )]
1231    pub added_suppression: Option<Severity>,
1232    /// Optional override for [`RulesConfig::test_only_branch`].
1233    #[serde(
1234        default,
1235        alias = "test-only-branches",
1236        skip_serializing_if = "Option::is_none"
1237    )]
1238    pub test_only_branch: Option<Severity>,
1239    /// Optional override for [`RulesConfig::diverged_clone_sibling`].
1240    #[serde(
1241        default,
1242        alias = "diverged-clone-siblings",
1243        skip_serializing_if = "Option::is_none"
1244    )]
1245    pub diverged_clone_sibling: Option<Severity>,
1246    /// Optional override for [`RulesConfig::added_dependency`].
1247    #[serde(
1248        default,
1249        alias = "added-dependencies",
1250        skip_serializing_if = "Option::is_none"
1251    )]
1252    pub added_dependency: Option<Severity>,
1253}
1254
1255/// Every rule name accepted by `RulesConfig` deserialization, in kebab-case.
1256///
1257/// Includes both the canonical name produced by `#[serde(rename_all = "kebab-case")]`
1258/// and every `#[serde(alias = ...)]` value. Used by
1259/// `find_unknown_rule_keys` to detect typos in user-supplied configs and
1260/// emit a `tracing::warn!` suggestion at config load time.
1261///
1262/// Keep in sync with the `#[serde]` attributes on `RulesConfig` and
1263/// `PartialRulesConfig`. Nothing enforces that: the only guard here pins the
1264/// list's length to a literal, so adding a rule to the struct and forgetting
1265/// this list leaves every test green while the new name warns as an unknown
1266/// key in user configs. A real pin needs the provenance of each entry, because
1267/// the list also covers rule names that no `RulesConfig` field produces, so a
1268/// field-count comparison would not even be correct.
1269pub const KNOWN_RULE_NAMES: &[&str] = &[
1270    "unused-files",
1271    "unused-exports",
1272    "unused-types",
1273    "private-type-leaks",
1274    "deprecated-exports-in-use",
1275    "unused-dependencies",
1276    "unused-dev-dependencies",
1277    "unused-optional-dependencies",
1278    "unused-enum-members",
1279    "unused-class-members",
1280    "unused-store-members",
1281    "unprovided-injects",
1282    "unrendered-components",
1283    "unused-component-props",
1284    "absent-component-props",
1285    "unused-component-emits",
1286    "unused-component-inputs",
1287    "unused-component-outputs",
1288    "unused-svelte-events",
1289    "unused-effect-layers",
1290    "unused-tagged-errors",
1291    "unproduced-catch-tags",
1292    "unused-server-actions",
1293    "unused-load-data-keys",
1294    "prop-drilling",
1295    "thin-wrapper",
1296    "duplicate-prop-shape",
1297    "css-token-drift",
1298    "css-duplicate-block",
1299    "css-selector-complexity",
1300    "css-dead-surface",
1301    "css-broken-reference",
1302    "complexity-cyclomatic",
1303    "complexity-cognitive",
1304    "complexity-crap",
1305    "unresolved-imports",
1306    "unlisted-dependencies",
1307    "duplicate-exports",
1308    "type-only-dependencies",
1309    "test-only-dependencies",
1310    "dev-dependencies-in-production",
1311    "circular-dependencies",
1312    "re-export-cycle",
1313    "package-cycle",
1314    "boundary-violation",
1315    "coverage-gaps",
1316    "feature-flags",
1317    "stale-suppressions",
1318    "require-suppression-reason",
1319    "suppression-reason",
1320    "unused-catalog-entries",
1321    "empty-catalog-groups",
1322    "unresolved-catalog-references",
1323    "unused-dependency-overrides",
1324    "misconfigured-dependency-overrides",
1325    "security-client-server-leak",
1326    "security-sink",
1327    "policy-violation",
1328    "policy-violations",
1329    "invalid-client-export",
1330    "mixed-client-server-barrel",
1331    "misplaced-directive",
1332    "route-collision",
1333    "dynamic-segment-name-conflict",
1334    "unused-file",
1335    "unused-export",
1336    "unused-type",
1337    "private-type-leak",
1338    "deprecated-export-in-use",
1339    "unused-dependency",
1340    "unused-dev-dependency",
1341    "unused-optional-dependency",
1342    "unused-enum-member",
1343    "unused-class-member",
1344    "unused-store-member",
1345    "unprovided-inject",
1346    "unrendered-component",
1347    "unused-component-prop",
1348    "absent-component-prop",
1349    "unused-component-emit",
1350    "unused-component-input",
1351    "unused-component-output",
1352    "unused-svelte-event",
1353    "unused-effect-layer",
1354    "unused-tagged-error",
1355    "unproduced-catch-tag",
1356    "unused-server-action",
1357    "unused-load-data-key",
1358    "unresolved-import",
1359    "unlisted-dependency",
1360    "duplicate-export",
1361    "type-only-dependency",
1362    "test-only-dependency",
1363    "dev-dependency-in-production",
1364    "circular-dependency",
1365    "re-export-cycles",
1366    "reexport-cycle",
1367    "reexport-cycles",
1368    "package-cycles",
1369    "boundary-violations",
1370    "coverage-gap",
1371    "feature-flag",
1372    "stale-suppression",
1373    "unused-catalog-entry",
1374    "empty-catalog-group",
1375    "unresolved-catalog-reference",
1376    "unused-dependency-override",
1377    "misconfigured-dependency-override",
1378    "invalid-client-exports",
1379    "mixed-client-server-barrels",
1380    "misplaced-directives",
1381    "route-collisions",
1382    "dynamic-segment-name-conflicts",
1383    "weakened-tests",
1384    "added-suppression",
1385    "added-suppressions",
1386    "test-only-branch",
1387    "test-only-branches",
1388    "diverged-clone-sibling",
1389    "diverged-clone-siblings",
1390    "added-dependency",
1391    "added-dependencies",
1392];
1393
1394/// Find the closest known rule name to `input` when it is plausibly a typo.
1395///
1396/// Thin wrapper over [`crate::levenshtein::closest_match`] that scopes the
1397/// candidate set to [`KNOWN_RULE_NAMES`]. The `'static` lifetime comes from that
1398/// slice, so the suggestion can be embedded in tracing warnings without
1399/// allocation. `closest_match` lowercases `input` itself.
1400#[must_use]
1401pub fn closest_known_rule_name(input: &str) -> Option<&'static str> {
1402    crate::levenshtein::closest_match(input, KNOWN_RULE_NAMES.iter().copied())
1403}
1404
1405/// An unknown key found inside a `rules` (or `overrides[].rules`) object.
1406///
1407/// Surfaced by [`find_unknown_rule_keys`] so the caller (config loader) can
1408/// emit one `tracing::warn!` per entry without coupling the detection logic
1409/// to a tracing subscriber.
1410#[derive(Debug, Clone, PartialEq, Eq)]
1411pub struct UnknownRuleKey {
1412    /// Human-readable source label, e.g. `"rules"` or `"overrides[2].rules"`.
1413    pub context: String,
1414    /// The unknown key as it appeared in the user's config.
1415    pub key: String,
1416    /// Closest known rule name when one is within plausible-typo distance.
1417    pub suggestion: Option<&'static str>,
1418}
1419
1420/// Collect every unknown key from a `rules`-shaped JSON object.
1421///
1422/// Returns an empty `Vec` when `value` is not an object or every key is
1423/// recognized (canonical kebab-case or a documented alias). Called from
1424/// [`crate::config::parsing`] after `extends` merge and before
1425/// `serde_json::from_value::<FallowConfig>`, so the warning lists keys from
1426/// the final merged config rather than per-file partials.
1427#[must_use]
1428pub fn find_unknown_rule_keys(value: &serde_json::Value, context: &str) -> Vec<UnknownRuleKey> {
1429    let Some(map) = value.as_object() else {
1430        return Vec::new();
1431    };
1432
1433    map.keys()
1434        .filter(|key| !KNOWN_RULE_NAMES.contains(&key.as_str()))
1435        .map(|key| UnknownRuleKey {
1436            context: context.to_owned(),
1437            key: key.clone(),
1438            suggestion: closest_known_rule_name(key),
1439        })
1440        .collect()
1441}
1442
1443#[cfg(test)]
1444mod tests {
1445    use super::*;
1446
1447    #[test]
1448    fn severity_for_kind_maps_configured_fields_and_default() {
1449        let rules = RulesConfig {
1450            unused_exports: Severity::Warn,
1451            unused_types: Severity::Off,
1452            unresolved_imports: Severity::Warn,
1453            boundary_violation: Severity::Off,
1454            ..RulesConfig::default()
1455        };
1456
1457        assert_eq!(
1458            rules.severity_for_kind(IssueKind::UnusedExport),
1459            Severity::Warn
1460        );
1461        assert_eq!(
1462            rules.severity_for_kind(IssueKind::UnusedType),
1463            Severity::Off
1464        );
1465        assert_eq!(
1466            rules.severity_for_kind(IssueKind::UnresolvedImport),
1467            Severity::Warn
1468        );
1469        assert_eq!(
1470            rules.severity_for_kind(IssueKind::BoundaryViolation),
1471            Severity::Off
1472        );
1473        assert_eq!(
1474            rules.severity_for_kind(IssueKind::UnusedFile),
1475            Severity::Error
1476        );
1477        assert_eq!(
1478            rules.severity_for_kind(IssueKind::PrivateTypeLeak),
1479            Severity::Off
1480        );
1481        assert_eq!(
1482            rules.severity_for_kind(IssueKind::Complexity),
1483            Severity::Error
1484        );
1485        assert_eq!(
1486            rules.severity_for_kind(IssueKind::CodeDuplication),
1487            Severity::Error
1488        );
1489    }
1490
1491    #[test]
1492    fn rules_default_severities() {
1493        let rules = RulesConfig::default();
1494        assert_eq!(rules.unused_files, Severity::Error);
1495        assert_eq!(rules.unused_exports, Severity::Error);
1496        assert_eq!(rules.unused_types, Severity::Error);
1497        assert_eq!(rules.private_type_leaks, Severity::Off);
1498        assert_eq!(rules.unused_dependencies, Severity::Error);
1499        assert_eq!(rules.unused_dev_dependencies, Severity::Warn);
1500        assert_eq!(rules.unused_optional_dependencies, Severity::Warn);
1501        assert_eq!(rules.unused_enum_members, Severity::Error);
1502        assert_eq!(rules.unused_class_members, Severity::Error);
1503        assert_eq!(rules.unresolved_imports, Severity::Error);
1504        assert_eq!(rules.unlisted_dependencies, Severity::Error);
1505        assert_eq!(rules.duplicate_exports, Severity::Error);
1506        assert_eq!(rules.type_only_dependencies, Severity::Warn);
1507        assert_eq!(rules.test_only_dependencies, Severity::Warn);
1508        assert_eq!(rules.circular_dependencies, Severity::Error);
1509        assert_eq!(rules.boundary_violation, Severity::Error);
1510        assert_eq!(rules.coverage_gaps, Severity::Off);
1511        assert_eq!(rules.feature_flags, Severity::Off);
1512        assert_eq!(rules.stale_suppressions, Severity::Warn);
1513        assert_eq!(rules.unused_catalog_entries, Severity::Warn);
1514        assert_eq!(rules.empty_catalog_groups, Severity::Warn);
1515        assert_eq!(rules.unresolved_catalog_references, Severity::Error);
1516    }
1517
1518    #[test]
1519    fn rules_deserialize_kebab_case() {
1520        let json_str = r#"{
1521            "unused-files": "error",
1522            "unused-exports": "warn",
1523            "unused-types": "off"
1524        }"#;
1525        let rules: RulesConfig = serde_json::from_str(json_str).unwrap();
1526        assert_eq!(rules.unused_files, Severity::Error);
1527        assert_eq!(rules.unused_exports, Severity::Warn);
1528        assert_eq!(rules.unused_types, Severity::Off);
1529        assert_eq!(rules.unresolved_imports, Severity::Error);
1530    }
1531
1532    #[test]
1533    fn empty_rules_object_matches_default_impl() {
1534        // Regression for issue #1745: a present-but-empty `rules` object must
1535        // deserialize field-by-field to exactly the same severities as an
1536        // absent `rules` key (which fills from `RulesConfig::default()`).
1537        // A bare `#[serde(default)]` resolves to `Severity::default()` (Error),
1538        // so any field whose Default-impl value is Warn/Off silently promoted
1539        // to Error, flipping exit codes (coverage-gaps gated `fallow health`,
1540        // the component/store/inject rules gated CI on Vue/Svelte/Angular).
1541        let from_empty: RulesConfig = serde_json::from_str("{}").unwrap();
1542        assert_eq!(
1543            from_empty,
1544            RulesConfig::default(),
1545            "empty `rules` object must equal RulesConfig::default(); a field whose \
1546             Default is Warn/Off needs an explicit #[serde(default = ...)]"
1547        );
1548    }
1549
1550    #[test]
1551    fn empty_rules_object_preserves_non_error_defaults() {
1552        // Spot-check the nine fields fixed by #1745 so the intent is explicit
1553        // even if the wholesale equality assertion above is later relaxed.
1554        let rules: RulesConfig = serde_json::from_str("{}").unwrap();
1555        assert_eq!(rules.coverage_gaps, Severity::Off);
1556        assert_eq!(rules.unused_store_members, Severity::Warn);
1557        assert_eq!(rules.unprovided_injects, Severity::Warn);
1558        assert_eq!(rules.unrendered_components, Severity::Warn);
1559        assert_eq!(rules.unused_component_props, Severity::Warn);
1560        assert_eq!(rules.unused_component_emits, Severity::Warn);
1561        assert_eq!(rules.unused_component_inputs, Severity::Warn);
1562        assert_eq!(rules.unused_component_outputs, Severity::Warn);
1563        assert_eq!(rules.unused_server_actions, Severity::Warn);
1564    }
1565
1566    #[test]
1567    fn explicit_rule_severity_still_overrides_default() {
1568        // Control: the fix must not break explicit opt-in. Setting a value
1569        // still wins over the (now correct) default.
1570        let rules: RulesConfig =
1571            serde_json::from_str(r#"{ "coverage-gaps": "error", "unused-component-prop": "off" }"#)
1572                .unwrap();
1573        assert_eq!(rules.coverage_gaps, Severity::Error);
1574        assert_eq!(rules.unused_component_props, Severity::Off);
1575    }
1576
1577    #[test]
1578    fn rules_re_export_cycle_default_is_warn() {
1579        let rules = RulesConfig::default();
1580        assert_eq!(rules.re_export_cycle, Severity::Warn);
1581    }
1582
1583    #[test]
1584    fn rules_package_cycle_default_is_warn() {
1585        let rules = RulesConfig::default();
1586        assert_eq!(rules.package_cycle, Severity::Warn);
1587    }
1588
1589    #[test]
1590    fn package_cycle_aliases_round_trip_to_the_same_field() {
1591        for alias in ["package-cycle", "package-cycles"] {
1592            let json = format!(r#"{{"{alias}": "error"}}"#);
1593            let partial: PartialRulesConfig = serde_json::from_str(&json)
1594                .unwrap_or_else(|e| panic!("'{alias}' should deserialize: {e}"));
1595            assert_eq!(partial.package_cycle, Some(Severity::Error), "{alias}");
1596            let rules: RulesConfig = serde_json::from_str(&json)
1597                .unwrap_or_else(|e| panic!("'{alias}' should deserialize: {e}"));
1598            assert_eq!(rules.package_cycle, Severity::Error, "{alias}");
1599        }
1600    }
1601
1602    #[test]
1603    fn rules_deserialize_re_export_cycle_aliases() {
1604        for token in [
1605            "re-export-cycle",
1606            "re-export-cycles",
1607            "reexport-cycle",
1608            "reexport-cycles",
1609        ] {
1610            let json_str = format!(r#"{{ "{token}": "error" }}"#);
1611            let rules: RulesConfig = serde_json::from_str(&json_str)
1612                .unwrap_or_else(|e| panic!("alias {token} did not deserialize: {e}"));
1613            assert_eq!(
1614                rules.re_export_cycle,
1615                Severity::Error,
1616                "alias {token} should set re_export_cycle"
1617            );
1618        }
1619    }
1620
1621    #[test]
1622    fn rules_deserialize_circular_dependency_alias() {
1623        let json_str = r#"{
1624            "circular-dependency": "off"
1625        }"#;
1626        let rules: RulesConfig = serde_json::from_str(json_str).unwrap();
1627        assert_eq!(rules.circular_dependencies, Severity::Off);
1628    }
1629
1630    #[test]
1631    fn rules_deserialize_boundary_violations_alias() {
1632        let json_str = r#"{
1633            "boundary-violations": "off"
1634        }"#;
1635        let rules: RulesConfig = serde_json::from_str(json_str).unwrap();
1636        assert_eq!(rules.boundary_violation, Severity::Off);
1637
1638        let partial: PartialRulesConfig = serde_json::from_str(json_str).unwrap();
1639        assert_eq!(partial.boundary_violation, Some(Severity::Off));
1640    }
1641
1642    #[test]
1643    fn rules_deserialize_singular_aliases_for_every_plural_rule() {
1644        let json_str = r#"{
1645            "unused-file": "off",
1646            "unused-export": "off",
1647            "unused-type": "off",
1648            "private-type-leak": "warn",
1649            "unused-dependency": "off",
1650            "unused-dev-dependency": "off",
1651            "unused-optional-dependency": "off",
1652            "unused-enum-member": "off",
1653            "unused-class-member": "off",
1654            "unresolved-import": "off",
1655            "unlisted-dependency": "off",
1656            "duplicate-export": "off",
1657            "type-only-dependency": "off",
1658            "test-only-dependency": "off",
1659            "coverage-gap": "warn",
1660            "feature-flag": "warn",
1661            "stale-suppression": "off",
1662            "suppression-reason": "warn",
1663            "unused-catalog-entry": "error",
1664            "empty-catalog-group": "error",
1665            "unresolved-catalog-reference": "warn"
1666        }"#;
1667
1668        let rules: RulesConfig = serde_json::from_str(json_str).unwrap();
1669        assert_eq!(rules.unused_files, Severity::Off);
1670        assert_eq!(rules.unused_exports, Severity::Off);
1671        assert_eq!(rules.unused_types, Severity::Off);
1672        assert_eq!(rules.private_type_leaks, Severity::Warn);
1673        assert_eq!(rules.unused_dependencies, Severity::Off);
1674        assert_eq!(rules.unused_dev_dependencies, Severity::Off);
1675        assert_eq!(rules.unused_optional_dependencies, Severity::Off);
1676        assert_eq!(rules.unused_enum_members, Severity::Off);
1677        assert_eq!(rules.unused_class_members, Severity::Off);
1678        assert_eq!(rules.unresolved_imports, Severity::Off);
1679        assert_eq!(rules.unlisted_dependencies, Severity::Off);
1680        assert_eq!(rules.duplicate_exports, Severity::Off);
1681        assert_eq!(rules.type_only_dependencies, Severity::Off);
1682        assert_eq!(rules.test_only_dependencies, Severity::Off);
1683        assert_eq!(rules.coverage_gaps, Severity::Warn);
1684        assert_eq!(rules.feature_flags, Severity::Warn);
1685        assert_eq!(rules.stale_suppressions, Severity::Off);
1686        assert_eq!(rules.require_suppression_reason, Severity::Warn);
1687        assert_eq!(rules.unused_catalog_entries, Severity::Error);
1688        assert_eq!(rules.empty_catalog_groups, Severity::Error);
1689        assert_eq!(rules.unresolved_catalog_references, Severity::Warn);
1690
1691        let partial: PartialRulesConfig = serde_json::from_str(json_str).unwrap();
1692        assert_eq!(partial.unused_files, Some(Severity::Off));
1693        assert_eq!(partial.unused_exports, Some(Severity::Off));
1694        assert_eq!(partial.unused_types, Some(Severity::Off));
1695        assert_eq!(partial.private_type_leaks, Some(Severity::Warn));
1696        assert_eq!(partial.unused_dependencies, Some(Severity::Off));
1697        assert_eq!(partial.unused_dev_dependencies, Some(Severity::Off));
1698        assert_eq!(partial.unused_optional_dependencies, Some(Severity::Off));
1699        assert_eq!(partial.unused_enum_members, Some(Severity::Off));
1700        assert_eq!(partial.unused_class_members, Some(Severity::Off));
1701        assert_eq!(partial.unresolved_imports, Some(Severity::Off));
1702        assert_eq!(partial.unlisted_dependencies, Some(Severity::Off));
1703        assert_eq!(partial.duplicate_exports, Some(Severity::Off));
1704        assert_eq!(partial.type_only_dependencies, Some(Severity::Off));
1705        assert_eq!(partial.test_only_dependencies, Some(Severity::Off));
1706        assert_eq!(partial.coverage_gaps, Some(Severity::Warn));
1707        assert_eq!(partial.feature_flags, Some(Severity::Warn));
1708        assert_eq!(partial.stale_suppressions, Some(Severity::Off));
1709        assert_eq!(partial.require_suppression_reason, Some(Severity::Warn));
1710        assert_eq!(partial.unused_catalog_entries, Some(Severity::Error));
1711        assert_eq!(partial.empty_catalog_groups, Some(Severity::Error));
1712        assert_eq!(partial.unresolved_catalog_references, Some(Severity::Warn));
1713    }
1714
1715    #[test]
1716    fn severity_from_str() {
1717        assert_eq!("error".parse::<Severity>().unwrap(), Severity::Error);
1718        assert_eq!("warn".parse::<Severity>().unwrap(), Severity::Warn);
1719        assert_eq!("warning".parse::<Severity>().unwrap(), Severity::Warn);
1720        assert_eq!("off".parse::<Severity>().unwrap(), Severity::Off);
1721        assert_eq!("none".parse::<Severity>().unwrap(), Severity::Off);
1722        assert!("invalid".parse::<Severity>().is_err());
1723    }
1724
1725    #[test]
1726    fn apply_partial_only_some_fields() {
1727        let mut rules = RulesConfig::default();
1728        let partial = PartialRulesConfig {
1729            unused_files: Some(Severity::Warn),
1730            unused_exports: Some(Severity::Off),
1731            ..Default::default()
1732        };
1733        rules.apply_partial(&partial);
1734        assert_eq!(rules.unused_files, Severity::Warn);
1735        assert_eq!(rules.unused_exports, Severity::Off);
1736        assert_eq!(rules.unused_types, Severity::Error);
1737        assert_eq!(rules.unresolved_imports, Severity::Error);
1738        assert_eq!(rules.require_suppression_reason, Severity::Off);
1739    }
1740
1741    #[test]
1742    fn require_suppression_reason_deserializes_canonical_and_alias() {
1743        let rules: RulesConfig = serde_json::from_str(
1744            r#"{
1745                "require-suppression-reason": "error"
1746            }"#,
1747        )
1748        .unwrap();
1749        assert_eq!(rules.require_suppression_reason, Severity::Error);
1750
1751        let partial: PartialRulesConfig = serde_json::from_str(
1752            r#"{
1753                "suppression-reason": "warn"
1754            }"#,
1755        )
1756        .unwrap();
1757        assert_eq!(partial.require_suppression_reason, Some(Severity::Warn));
1758    }
1759
1760    #[test]
1761    fn complexity_rules_default_to_error_and_deserialize() {
1762        let rules = RulesConfig::default();
1763        assert_eq!(rules.complexity_cyclomatic, Severity::Error);
1764        assert_eq!(rules.complexity_cognitive, Severity::Error);
1765        assert_eq!(rules.complexity_crap, Severity::Error);
1766
1767        let partial: PartialRulesConfig =
1768            serde_json::from_str(r#"{ "complexity-crap": "warn", "complexity-cognitive": "off" }"#)
1769                .unwrap();
1770        assert_eq!(partial.complexity_crap, Some(Severity::Warn));
1771        assert_eq!(partial.complexity_cognitive, Some(Severity::Off));
1772        assert_eq!(partial.complexity_cyclomatic, None);
1773    }
1774
1775    #[test]
1776    fn complexity_severity_takes_the_most_severe_contributing_rule() {
1777        let rules = RulesConfig {
1778            complexity_cyclomatic: Severity::Warn,
1779            complexity_cognitive: Severity::Off,
1780            complexity_crap: Severity::Error,
1781            ..RulesConfig::default()
1782        };
1783        assert_eq!(
1784            rules.complexity_severity(true, false, true),
1785            Severity::Error
1786        );
1787        assert_eq!(rules.complexity_severity(true, true, false), Severity::Warn);
1788        assert_eq!(rules.complexity_severity(false, true, false), Severity::Off);
1789        assert_eq!(
1790            rules.complexity_severity(true, false, false),
1791            Severity::Warn
1792        );
1793        assert_eq!(
1794            rules.complexity_severity(false, false, false),
1795            Severity::Off
1796        );
1797    }
1798
1799    #[test]
1800    fn severity_display() {
1801        assert_eq!(Severity::Error.to_string(), "error");
1802        assert_eq!(Severity::Warn.to_string(), "warn");
1803        assert_eq!(Severity::Off.to_string(), "off");
1804    }
1805
1806    #[test]
1807    fn apply_partial_all_none_changes_nothing() {
1808        let mut rules = RulesConfig::default();
1809        let original = rules.clone();
1810        let partial = PartialRulesConfig::default(); // all None
1811        rules.apply_partial(&partial);
1812        assert_eq!(rules.unused_files, original.unused_files);
1813        assert_eq!(rules.unused_exports, original.unused_exports);
1814        assert_eq!(
1815            rules.type_only_dependencies,
1816            original.type_only_dependencies
1817        );
1818    }
1819
1820    #[test]
1821    fn apply_partial_all_fields_set() {
1822        let mut rules = RulesConfig::default();
1823        let partial = PartialRulesConfig {
1824            unused_files: Some(Severity::Off),
1825            unused_exports: Some(Severity::Off),
1826            unused_types: Some(Severity::Off),
1827            private_type_leaks: Some(Severity::Off),
1828            unused_dependencies: Some(Severity::Off),
1829            unused_dev_dependencies: Some(Severity::Off),
1830            unused_optional_dependencies: Some(Severity::Off),
1831            unused_enum_members: Some(Severity::Off),
1832            unused_class_members: Some(Severity::Off),
1833            unused_store_members: Some(Severity::Off),
1834            unprovided_injects: Some(Severity::Off),
1835            unrendered_components: Some(Severity::Off),
1836            unused_component_props: Some(Severity::Off),
1837            absent_component_props: Some(Severity::Off),
1838            unused_component_emits: Some(Severity::Off),
1839            unused_component_inputs: Some(Severity::Off),
1840            unused_component_outputs: Some(Severity::Off),
1841            unused_svelte_events: Some(Severity::Off),
1842            unused_effect_layers: Some(Severity::Off),
1843            unused_tagged_errors: Some(Severity::Off),
1844            unproduced_catch_tags: Some(Severity::Off),
1845            unused_server_actions: Some(Severity::Off),
1846            unused_load_data_keys: Some(Severity::Off),
1847            prop_drilling: Some(Severity::Off),
1848            thin_wrapper: Some(Severity::Off),
1849            duplicate_prop_shape: Some(Severity::Off),
1850            css_token_drift: Some(Severity::Off),
1851            css_duplicate_block: Some(Severity::Off),
1852            css_selector_complexity: Some(Severity::Off),
1853            css_dead_surface: Some(Severity::Off),
1854            css_broken_reference: Some(Severity::Off),
1855            complexity_cyclomatic: Some(Severity::Off),
1856            complexity_cognitive: Some(Severity::Off),
1857            complexity_crap: Some(Severity::Off),
1858            unresolved_imports: Some(Severity::Off),
1859            unlisted_dependencies: Some(Severity::Off),
1860            duplicate_exports: Some(Severity::Off),
1861            type_only_dependencies: Some(Severity::Off),
1862            test_only_dependencies: Some(Severity::Off),
1863            dev_dependencies_in_production: Some(Severity::Off),
1864            circular_dependencies: Some(Severity::Off),
1865            re_export_cycle: Some(Severity::Off),
1866            package_cycle: Some(Severity::Off),
1867            boundary_violation: Some(Severity::Off),
1868            coverage_gaps: Some(Severity::Off),
1869            feature_flags: Some(Severity::Off),
1870            stale_suppressions: Some(Severity::Off),
1871            require_suppression_reason: Some(Severity::Off),
1872            unused_catalog_entries: Some(Severity::Off),
1873            empty_catalog_groups: Some(Severity::Off),
1874            unresolved_catalog_references: Some(Severity::Off),
1875            unused_dependency_overrides: Some(Severity::Off),
1876            misconfigured_dependency_overrides: Some(Severity::Off),
1877            security_client_server_leak: Some(Severity::Off),
1878            security_sink: Some(Severity::Off),
1879            policy_violation: Some(Severity::Off),
1880            invalid_client_export: Some(Severity::Off),
1881            mixed_client_server_barrel: Some(Severity::Off),
1882            misplaced_directive: Some(Severity::Off),
1883            route_collision: Some(Severity::Off),
1884            dynamic_segment_name_conflict: Some(Severity::Off),
1885            deprecated_exports_in_use: Some(Severity::Off),
1886            weakened_tests: Some(Severity::Off),
1887            added_suppression: Some(Severity::Off),
1888            test_only_branch: Some(Severity::Off),
1889            diverged_clone_sibling: Some(Severity::Off),
1890            added_dependency: Some(Severity::Off),
1891        };
1892        rules.apply_partial(&partial);
1893        assert_eq!(rules.unused_files, Severity::Off);
1894        assert_eq!(rules.private_type_leaks, Severity::Off);
1895        assert_eq!(rules.circular_dependencies, Severity::Off);
1896        assert_eq!(rules.type_only_dependencies, Severity::Off);
1897        assert_eq!(rules.test_only_dependencies, Severity::Off);
1898        assert_eq!(rules.boundary_violation, Severity::Off);
1899        assert_eq!(rules.coverage_gaps, Severity::Off);
1900        assert_eq!(rules.feature_flags, Severity::Off);
1901        assert_eq!(rules.stale_suppressions, Severity::Off);
1902        assert_eq!(rules.require_suppression_reason, Severity::Off);
1903        assert_eq!(rules.security_sink, Severity::Off);
1904        assert_eq!(rules.policy_violation, Severity::Off);
1905        assert_eq!(rules.invalid_client_export, Severity::Off);
1906        assert_eq!(rules.mixed_client_server_barrel, Severity::Off);
1907        assert_eq!(rules.misplaced_directive, Severity::Off);
1908        assert_eq!(rules.unrendered_components, Severity::Off);
1909        assert_eq!(rules.unused_component_props, Severity::Off);
1910        assert_eq!(rules.unused_component_emits, Severity::Off);
1911        assert_eq!(rules.unused_component_inputs, Severity::Off);
1912        assert_eq!(rules.unused_component_outputs, Severity::Off);
1913        assert_eq!(rules.unused_svelte_events, Severity::Off);
1914        assert_eq!(rules.route_collision, Severity::Off);
1915        assert_eq!(rules.dynamic_segment_name_conflict, Severity::Off);
1916    }
1917
1918    #[test]
1919    fn diff_risk_rules_default_and_parse_from_kebab_keys() {
1920        let rules = RulesConfig::default();
1921        assert_eq!(rules.weakened_tests, Severity::Warn);
1922        assert_eq!(rules.added_suppression, Severity::Warn);
1923        assert_eq!(rules.test_only_branch, Severity::Warn);
1924        assert_eq!(rules.diverged_clone_sibling, Severity::Off);
1925        assert_eq!(rules.added_dependency, Severity::Off);
1926
1927        let json = r#"{"weakened-tests": "error", "added-dependency": "warn"}"#;
1928        let partial: PartialRulesConfig = serde_json::from_str(json).expect("partial rules");
1929        let mut merged = RulesConfig::default();
1930        merged.apply_partial(&partial);
1931        assert_eq!(merged.weakened_tests, Severity::Error);
1932        assert_eq!(merged.added_dependency, Severity::Warn);
1933        assert_eq!(merged.added_suppression, Severity::Warn);
1934        assert_eq!(
1935            merged.severity_for_kind(IssueKind::WeakenedTests),
1936            Severity::Error
1937        );
1938        assert_eq!(
1939            merged.severity_for_kind(IssueKind::AddedDependency),
1940            Severity::Warn
1941        );
1942    }
1943
1944    #[test]
1945    fn policy_violation_defaults_to_warn() {
1946        let rules = RulesConfig::default();
1947        assert_eq!(rules.policy_violation, Severity::Warn);
1948    }
1949
1950    #[test]
1951    fn policy_violation_accepts_plural_alias() {
1952        let json = r#"{ "policy-violations": "error" }"#;
1953        let rules: RulesConfig = serde_json::from_str(json).unwrap();
1954        assert_eq!(rules.policy_violation, Severity::Error);
1955    }
1956
1957    #[test]
1958    fn severity_from_str_case_insensitive() {
1959        assert_eq!("ERROR".parse::<Severity>().unwrap(), Severity::Error);
1960        assert_eq!("Warn".parse::<Severity>().unwrap(), Severity::Warn);
1961        assert_eq!("OFF".parse::<Severity>().unwrap(), Severity::Off);
1962        assert_eq!("Warning".parse::<Severity>().unwrap(), Severity::Warn);
1963        assert_eq!("NONE".parse::<Severity>().unwrap(), Severity::Off);
1964    }
1965
1966    #[test]
1967    fn severity_from_str_invalid_returns_error() {
1968        let result = "critical".parse::<Severity>();
1969        assert!(result.is_err());
1970        let err = result.unwrap_err();
1971        assert!(
1972            err.contains("unknown severity"),
1973            "Expected descriptive error, got: {err}"
1974        );
1975    }
1976
1977    /// Pins the list's length only. This does NOT compare against
1978    /// `RulesConfig`, so it cannot catch a rule added to the struct and
1979    /// missing here. It exists to make an accidental deletion loud, nothing
1980    /// more. See the note on [`KNOWN_RULE_NAMES`].
1981    #[test]
1982    fn known_rule_names_list_length_is_pinned() {
1983        assert_eq!(KNOWN_RULE_NAMES.len(), 122);
1984    }
1985
1986    /// The reverse of `known_rule_names_covers_every_struct_field`. That one
1987    /// proves every real rule is listed; this one proves every listed name is
1988    /// real. A stale entry is not inert: `closest_known_rule_name` suggests
1989    /// from this list, so a removed or renamed rule left behind here gets
1990    /// offered to a user as the fix for their typo.
1991    #[test]
1992    fn known_rule_names_holds_no_unreachable_entry() {
1993        let serialized =
1994            serde_json::to_value(RulesConfig::default()).expect("RulesConfig serializes");
1995        let canonical = serialized
1996            .as_object()
1997            .expect("RulesConfig serializes to an object");
1998        let source = include_str!("rules.rs");
1999
2000        let unreachable: Vec<&&str> = KNOWN_RULE_NAMES
2001            .iter()
2002            .filter(|name| !canonical.contains_key(**name))
2003            .filter(|name| !source.contains(&format!("alias = \"{name}\"")))
2004            .collect();
2005
2006        assert!(
2007            unreachable.is_empty(),
2008            "KNOWN_RULE_NAMES entries that are neither a serialized rule nor a declared \
2009             serde alias: {unreachable:?}. closest_known_rule_name suggests from this list, \
2010             so each one can be offered to a user as a rule that does not exist."
2011        );
2012    }
2013
2014    #[test]
2015    fn known_rule_names_has_no_duplicates() {
2016        let mut sorted: Vec<&str> = KNOWN_RULE_NAMES.to_vec();
2017        sorted.sort_unstable();
2018        let original_len = sorted.len();
2019        sorted.dedup();
2020        assert_eq!(
2021            sorted.len(),
2022            original_len,
2023            "KNOWN_RULE_NAMES contains a duplicate"
2024        );
2025    }
2026
2027    #[test]
2028    fn known_rule_names_covers_every_serde_alias_in_source() {
2029        let source = include_str!("rules.rs");
2030
2031        let mut aliases_found = Vec::new();
2032        for line in source.lines() {
2033            let trimmed = line.trim();
2034            if trimmed.starts_with("//") {
2035                continue;
2036            }
2037            let Some(after) = trimmed.split("alias = \"").nth(1) else {
2038                continue;
2039            };
2040            let Some(end) = after.find('"') else {
2041                continue;
2042            };
2043            let alias = &after[..end];
2044            if alias.is_empty() || !alias.chars().all(|c| c.is_ascii_lowercase() || c == '-') {
2045                continue;
2046            }
2047            aliases_found.push(alias.to_owned());
2048        }
2049
2050        assert_eq!(
2051            aliases_found.len(),
2052            126,
2053            "expected 126 source-level alias attrs (63 per struct); got {}: {:?}",
2054            aliases_found.len(),
2055            aliases_found
2056        );
2057
2058        for alias in &aliases_found {
2059            assert!(
2060                KNOWN_RULE_NAMES.contains(&alias.as_str()),
2061                "serde alias '{alias}' is in rules.rs source but missing from KNOWN_RULE_NAMES"
2062            );
2063        }
2064    }
2065
2066    #[test]
2067    fn re_export_cycle_aliases_all_round_trip_to_the_same_field() {
2068        for alias in [
2069            "re-export-cycle",
2070            "re-export-cycles",
2071            "reexport-cycle",
2072            "reexport-cycles",
2073        ] {
2074            let json = format!(r#"{{"{alias}": "warn"}}"#);
2075            let partial: PartialRulesConfig = serde_json::from_str(&json)
2076                .unwrap_or_else(|e| panic!("'{alias}' should deserialize: {e}"));
2077            assert_eq!(
2078                partial.re_export_cycle,
2079                Some(Severity::Warn),
2080                "'{alias}' should set re_export_cycle to Warn"
2081            );
2082            let serialized = serde_json::to_value(&partial).unwrap();
2083            let map = serialized.as_object().unwrap();
2084            assert_eq!(
2085                map.len(),
2086                1,
2087                "'{alias}' should resolve to exactly one field, got: {map:?}"
2088            );
2089        }
2090    }
2091
2092    #[test]
2093    fn every_known_rule_name_round_trips_through_partial() {
2094        for &name in KNOWN_RULE_NAMES {
2095            let json = format!(r#"{{"{name}": "warn"}}"#);
2096            let partial: PartialRulesConfig = serde_json::from_str(&json)
2097                .unwrap_or_else(|e| panic!("'{name}' should deserialize: {e}"));
2098
2099            let serialized = serde_json::to_value(&partial).unwrap();
2100            let map = serialized.as_object().unwrap();
2101            assert_eq!(
2102                map.len(),
2103                1,
2104                "'{name}' should resolve to exactly one field, got: {map:?}"
2105            );
2106        }
2107    }
2108
2109    #[test]
2110    fn known_rule_names_covers_every_struct_field() {
2111        let json = serde_json::to_value(RulesConfig::default()).unwrap();
2112        let obj = json.as_object().unwrap();
2113        for key in obj.keys() {
2114            assert!(
2115                KNOWN_RULE_NAMES.contains(&key.as_str()),
2116                "field '{key}' is serialized but missing from KNOWN_RULE_NAMES"
2117            );
2118        }
2119    }
2120
2121    #[test]
2122    fn closest_known_rule_name_suggests_for_obvious_typo() {
2123        assert_eq!(
2124            closest_known_rule_name("unsued-files"),
2125            Some("unused-files")
2126        );
2127        assert_eq!(
2128            closest_known_rule_name("circular-dependnecy"),
2129            Some("circular-dependency")
2130        );
2131        assert_eq!(
2132            closest_known_rule_name("unused-dep"),
2133            None,
2134            "too short for a confident suggestion"
2135        );
2136    }
2137
2138    #[test]
2139    fn closest_known_rule_name_returns_none_for_novel_input() {
2140        assert_eq!(closest_known_rule_name("totally-fabricated"), None);
2141        assert_eq!(closest_known_rule_name("foo"), None);
2142    }
2143
2144    #[test]
2145    fn closest_known_rule_name_is_case_insensitive() {
2146        assert_eq!(
2147            closest_known_rule_name("UNSUED-FILES"),
2148            Some("unused-files")
2149        );
2150    }
2151
2152    #[test]
2153    fn closest_known_rule_name_returns_none_for_exact_match() {
2154        assert_eq!(closest_known_rule_name("unused-files"), None);
2155    }
2156
2157    #[test]
2158    fn find_unknown_rule_keys_flags_typo() {
2159        let v = serde_json::json!({
2160            "unsued-files": "warn",
2161            "unused-exports": "off",
2162        });
2163        let unknown = find_unknown_rule_keys(&v, "rules");
2164        assert_eq!(unknown.len(), 1);
2165        assert_eq!(unknown[0].key, "unsued-files");
2166        assert_eq!(unknown[0].context, "rules");
2167        assert_eq!(unknown[0].suggestion, Some("unused-files"));
2168    }
2169
2170    #[test]
2171    fn find_unknown_rule_keys_passes_aliases() {
2172        let v = serde_json::json!({
2173            "unused-file": "warn",
2174            "circular-dependency": "off",
2175            "boundary-violations": "warn",
2176        });
2177        let unknown = find_unknown_rule_keys(&v, "rules");
2178        assert!(
2179            unknown.is_empty(),
2180            "documented aliases must not flag as unknown: {unknown:?}"
2181        );
2182    }
2183
2184    #[test]
2185    fn find_unknown_rule_keys_returns_multiple_typos() {
2186        let v = serde_json::json!({
2187            "unsued-files": "warn",
2188            "circular-dependnecy": "off",
2189        });
2190        let unknown = find_unknown_rule_keys(&v, "rules");
2191        assert_eq!(unknown.len(), 2);
2192    }
2193
2194    #[test]
2195    fn find_unknown_rule_keys_carries_context() {
2196        let v = serde_json::json!({ "unsued-files": "warn" });
2197        let unknown = find_unknown_rule_keys(&v, "overrides[2].rules");
2198        assert_eq!(unknown[0].context, "overrides[2].rules");
2199    }
2200
2201    #[test]
2202    fn find_unknown_rule_keys_empty_when_not_object() {
2203        let v = serde_json::json!(null);
2204        assert!(find_unknown_rule_keys(&v, "rules").is_empty());
2205
2206        let v = serde_json::json!([1, 2, 3]);
2207        assert!(find_unknown_rule_keys(&v, "rules").is_empty());
2208    }
2209
2210    #[test]
2211    fn find_unknown_rule_keys_no_suggestion_for_novel_name() {
2212        let v = serde_json::json!({ "totally-fabricated-rule": "warn" });
2213        let unknown = find_unknown_rule_keys(&v, "rules");
2214        assert_eq!(unknown.len(), 1);
2215        assert_eq!(unknown[0].suggestion, None);
2216    }
2217
2218    #[test]
2219    fn partial_rules_empty_json() {
2220        let partial: PartialRulesConfig = serde_json::from_str("{}").unwrap();
2221        assert!(partial.unused_files.is_none());
2222        assert!(partial.unused_exports.is_none());
2223        assert!(partial.unused_types.is_none());
2224        assert!(partial.unused_dependencies.is_none());
2225        assert!(partial.circular_dependencies.is_none());
2226        assert!(partial.boundary_violation.is_none());
2227        assert!(partial.coverage_gaps.is_none());
2228        assert!(partial.feature_flags.is_none());
2229        assert!(partial.stale_suppressions.is_none());
2230    }
2231
2232    #[test]
2233    fn partial_rules_subset_json() {
2234        let json = r#"{
2235            "unused-files": "warn",
2236            "circular-dependencies": "off"
2237        }"#;
2238        let partial: PartialRulesConfig = serde_json::from_str(json).unwrap();
2239        assert_eq!(partial.unused_files, Some(Severity::Warn));
2240        assert_eq!(partial.circular_dependencies, Some(Severity::Off));
2241        assert!(partial.unused_exports.is_none());
2242    }
2243
2244    #[test]
2245    fn partial_rules_deserialize_circular_dependency_alias() {
2246        let json = r#"{
2247            "circular-dependency": "warn"
2248        }"#;
2249        let partial: PartialRulesConfig = serde_json::from_str(json).unwrap();
2250        assert_eq!(partial.circular_dependencies, Some(Severity::Warn));
2251    }
2252
2253    #[test]
2254    fn partial_rules_all_fields_json() {
2255        let json = r#"{
2256            "unused-files": "error",
2257            "unused-exports": "warn",
2258            "unused-types": "off",
2259            "unused-dependencies": "error",
2260            "unused-dev-dependencies": "warn",
2261            "unused-optional-dependencies": "off",
2262            "unused-enum-members": "error",
2263            "unused-class-members": "warn",
2264            "unresolved-imports": "off",
2265            "unlisted-dependencies": "error",
2266            "duplicate-exports": "warn",
2267            "type-only-dependencies": "off",
2268            "test-only-dependencies": "error",
2269            "circular-dependencies": "warn",
2270            "boundary-violation": "off",
2271            "coverage-gaps": "warn",
2272            "feature-flags": "error",
2273            "stale-suppressions": "off"
2274        }"#;
2275        let partial: PartialRulesConfig = serde_json::from_str(json).unwrap();
2276        assert_eq!(partial.unused_files, Some(Severity::Error));
2277        assert_eq!(partial.unused_exports, Some(Severity::Warn));
2278        assert_eq!(partial.unused_types, Some(Severity::Off));
2279        assert_eq!(partial.unused_dependencies, Some(Severity::Error));
2280        assert_eq!(partial.unused_dev_dependencies, Some(Severity::Warn));
2281        assert_eq!(partial.unused_optional_dependencies, Some(Severity::Off));
2282        assert_eq!(partial.unused_enum_members, Some(Severity::Error));
2283        assert_eq!(partial.unused_class_members, Some(Severity::Warn));
2284        assert_eq!(partial.unresolved_imports, Some(Severity::Off));
2285        assert_eq!(partial.unlisted_dependencies, Some(Severity::Error));
2286        assert_eq!(partial.duplicate_exports, Some(Severity::Warn));
2287        assert_eq!(partial.type_only_dependencies, Some(Severity::Off));
2288        assert_eq!(partial.test_only_dependencies, Some(Severity::Error));
2289        assert_eq!(partial.circular_dependencies, Some(Severity::Warn));
2290        assert_eq!(partial.boundary_violation, Some(Severity::Off));
2291        assert_eq!(partial.coverage_gaps, Some(Severity::Warn));
2292        assert_eq!(partial.feature_flags, Some(Severity::Error));
2293        assert_eq!(partial.stale_suppressions, Some(Severity::Off));
2294    }
2295
2296    #[test]
2297    fn partial_rules_none_fields_not_serialized() {
2298        let partial = PartialRulesConfig::default();
2299        let json = serde_json::to_string(&partial).unwrap();
2300        assert_eq!(
2301            json, "{}",
2302            "all-None partial should serialize to empty object"
2303        );
2304    }
2305
2306    #[test]
2307    fn partial_rules_some_fields_serialized() {
2308        let partial = PartialRulesConfig {
2309            unused_files: Some(Severity::Warn),
2310            ..Default::default()
2311        };
2312        let json = serde_json::to_string(&partial).unwrap();
2313        assert!(json.contains("unused-files"));
2314        assert!(!json.contains("unused-exports"));
2315    }
2316
2317    #[test]
2318    fn severity_json_deserialization() {
2319        let error: Severity = serde_json::from_str(r#""error""#).unwrap();
2320        assert_eq!(error, Severity::Error);
2321
2322        let warn: Severity = serde_json::from_str(r#""warn""#).unwrap();
2323        assert_eq!(warn, Severity::Warn);
2324
2325        let off: Severity = serde_json::from_str(r#""off""#).unwrap();
2326        assert_eq!(off, Severity::Off);
2327    }
2328
2329    #[test]
2330    fn severity_invalid_json_value_rejected() {
2331        let result: Result<Severity, _> = serde_json::from_str(r#""critical""#);
2332        assert!(result.is_err());
2333    }
2334
2335    #[test]
2336    fn severity_default_is_error() {
2337        assert_eq!(Severity::default(), Severity::Error);
2338    }
2339
2340    #[test]
2341    fn rules_config_json_roundtrip() {
2342        let rules = RulesConfig {
2343            unused_files: Severity::Warn,
2344            unused_exports: Severity::Off,
2345            type_only_dependencies: Severity::Error,
2346            ..RulesConfig::default()
2347        };
2348        let json = serde_json::to_string(&rules).unwrap();
2349        let restored: RulesConfig = serde_json::from_str(&json).unwrap();
2350        assert_eq!(restored.unused_files, Severity::Warn);
2351        assert_eq!(restored.unused_exports, Severity::Off);
2352        assert_eq!(restored.type_only_dependencies, Severity::Error);
2353        assert_eq!(restored.unused_dependencies, Severity::Error); // default
2354    }
2355
2356    #[test]
2357    fn apply_partial_preserves_type_only_default() {
2358        let mut rules = RulesConfig::default();
2359        let partial = PartialRulesConfig {
2360            unused_files: Some(Severity::Off),
2361            ..Default::default()
2362        };
2363        rules.apply_partial(&partial);
2364        assert_eq!(rules.type_only_dependencies, Severity::Warn);
2365        assert_eq!(rules.test_only_dependencies, Severity::Warn);
2366    }
2367}