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