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