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