Skip to main content

fallow_config/config/
rules.rs

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