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