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, 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, 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, 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, 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, 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, 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, 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, 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(default, alias = "circular-dependency")]
225    pub circular_dependencies: Severity,
226    #[serde(
227        default = "Severity::default_warn",
228        alias = "re-export-cycles",
229        alias = "reexport-cycle",
230        alias = "reexport-cycles"
231    )]
232    pub re_export_cycle: Severity,
233    #[serde(default, alias = "boundary-violations")]
234    pub boundary_violation: Severity,
235    #[serde(default, alias = "coverage-gap")]
236    pub coverage_gaps: Severity,
237    #[serde(default = "Severity::default_off", alias = "feature-flag")]
238    pub feature_flags: Severity,
239    #[serde(default = "Severity::default_warn", alias = "stale-suppression")]
240    pub stale_suppressions: Severity,
241    /// Opt-in suppression hygiene rule: when enabled, every `fallow-ignore-*`
242    /// comment and `@expected-unused` tag must carry a `-- <reason>` suffix.
243    #[serde(default = "Severity::default_off", alias = "suppression-reason")]
244    pub require_suppression_reason: Severity,
245    #[serde(default = "Severity::default_warn", alias = "unused-catalog-entry")]
246    pub unused_catalog_entries: Severity,
247    #[serde(default = "Severity::default_warn", alias = "empty-catalog-group")]
248    pub empty_catalog_groups: Severity,
249    #[serde(default, alias = "unresolved-catalog-reference")]
250    pub unresolved_catalog_references: Severity,
251    #[serde(
252        default = "Severity::default_warn",
253        alias = "unused-dependency-override"
254    )]
255    pub unused_dependency_overrides: Severity,
256    #[serde(default, alias = "misconfigured-dependency-override")]
257    pub misconfigured_dependency_overrides: Severity,
258    /// Opt-in (default off): a `"use client"` file that transitively imports a
259    /// module reading a non-public `process.env` secret. Surfaced only by
260    /// `fallow security`; never under bare `fallow` or the `audit` gate.
261    #[serde(default = "Severity::default_off")]
262    pub security_client_server_leak: Severity,
263    /// Opt-in (default off): a syntactic tainted-sink candidate matched against
264    /// the data-driven catalogue (`security_matchers.toml`). ONE knob gates ALL
265    /// catalogue categories. Surfaced only by `fallow security`; never under
266    /// bare `fallow` or the `audit` gate.
267    #[serde(default = "Severity::default_off")]
268    pub security_sink: Severity,
269    /// Master severity for rule-pack findings (`rulePacks` config). Defaults
270    /// to `warn` so enabling a brand-new policy pack never hard-fails CI on
271    /// its first run; individual pack rules opt up via `"severity": "error"`.
272    /// `off` is a kill switch that disables the whole evaluator (per-rule
273    /// severity cannot resurrect it).
274    #[serde(default = "Severity::default_warn", alias = "policy-violations")]
275    pub policy_violation: Severity,
276    /// A `"use client"` file that exports a Next.js server-only /
277    /// route-segment config name (e.g. `metadata`, `revalidate`, `GET`).
278    /// Next.js rejects this at build time; fallow catches it statically.
279    /// Defaults to `warn`.
280    #[serde(default = "Severity::default_warn", alias = "invalid-client-exports")]
281    pub invalid_client_export: Severity,
282    /// A barrel file that re-exports BOTH a `"use client"` origin module AND a
283    /// server-only origin module. Importing one name from such a barrel drags
284    /// the other's directive context across the React Server Components
285    /// boundary (the Next.js App Router footgun). Defaults to `warn`.
286    #[serde(
287        default = "Severity::default_warn",
288        alias = "mixed-client-server-barrels"
289    )]
290    pub mixed_client_server_barrel: Severity,
291    /// A `"use client"` / `"use server"` directive written as an expression
292    /// statement after a non-directive statement (an import, a const), so the
293    /// RSC bundler parses it as an ordinary string and silently ignores it.
294    /// The intended client/server boundary never takes effect. Defaults to
295    /// `warn`.
296    #[serde(default = "Severity::default_warn", alias = "misplaced-directives")]
297    pub misplaced_directive: Severity,
298    /// Two or more Next.js App Router route files that resolve to the same URL
299    /// within one app-root. Next.js fails the build ("You cannot have two
300    /// parallel pages that resolve to the same path"); fallow catches it
301    /// statically and names every colliding file. Defaults to `error`: the
302    /// project already fails `next build`, so flagging it as an error aligns
303    /// fallow's exit code with the build it mirrors.
304    #[serde(default, alias = "route-collisions")]
305    pub route_collision: Severity,
306    /// Sibling Next.js dynamic route segments at one tree position using
307    /// different param spellings (`[id]` vs `[slug]`). Next.js throws "You
308    /// cannot use different slug names for the same dynamic path" at dev and
309    /// production runtime when the position is hit; `next build` does NOT catch
310    /// it (the build succeeds), so CI passes while the route crashes on its
311    /// first request. fallow catches it statically. Defaults to `error`: the
312    /// route is a deterministic runtime crash on first request, so failing CI
313    /// is the honest signal even though `next build` stays green (this is the
314    /// "error-runtime" severity tier, shared with `route-collision`).
315    #[serde(default, alias = "dynamic-segment-name-conflicts")]
316    pub dynamic_segment_name_conflict: Severity,
317}
318
319impl Default for RulesConfig {
320    fn default() -> Self {
321        Self {
322            unused_files: Severity::Error,
323            unused_exports: Severity::Error,
324            unused_types: Severity::Error,
325            private_type_leaks: Severity::Off,
326            unused_dependencies: Severity::Error,
327            unused_dev_dependencies: Severity::Warn,
328            unused_optional_dependencies: Severity::Warn,
329            unused_enum_members: Severity::Error,
330            unused_class_members: Severity::Error,
331            unused_store_members: Severity::Warn,
332            unprovided_injects: Severity::Warn,
333            unrendered_components: Severity::Warn,
334            unused_component_props: Severity::Warn,
335            unused_component_emits: Severity::Warn,
336            unused_component_inputs: Severity::Warn,
337            unused_component_outputs: Severity::Warn,
338            unused_svelte_events: Severity::Warn,
339            unused_server_actions: Severity::Warn,
340            unused_load_data_keys: Severity::Warn,
341            prop_drilling: Severity::Off,
342            thin_wrapper: Severity::Off,
343            duplicate_prop_shape: Severity::Off,
344            css_token_drift: Severity::Warn,
345            css_duplicate_block: Severity::Warn,
346            css_selector_complexity: Severity::Warn,
347            css_dead_surface: Severity::Warn,
348            css_broken_reference: Severity::Warn,
349            unresolved_imports: Severity::Error,
350            unlisted_dependencies: Severity::Error,
351            duplicate_exports: Severity::Error,
352            type_only_dependencies: Severity::Warn,
353            test_only_dependencies: Severity::Warn,
354            circular_dependencies: Severity::Error,
355            re_export_cycle: Severity::Warn,
356            boundary_violation: Severity::Error,
357            coverage_gaps: Severity::Off,
358            feature_flags: Severity::Off,
359            stale_suppressions: Severity::Warn,
360            require_suppression_reason: Severity::Off,
361            unused_catalog_entries: Severity::Warn,
362            empty_catalog_groups: Severity::Warn,
363            unresolved_catalog_references: Severity::Error,
364            unused_dependency_overrides: Severity::Warn,
365            misconfigured_dependency_overrides: Severity::Error,
366            security_client_server_leak: Severity::Off,
367            security_sink: Severity::Off,
368            policy_violation: Severity::Warn,
369            invalid_client_export: Severity::Warn,
370            mixed_client_server_barrel: Severity::Warn,
371            misplaced_directive: Severity::Warn,
372            route_collision: Severity::Error,
373            dynamic_segment_name_conflict: Severity::Error,
374        }
375    }
376}
377
378macro_rules! apply_partial_rules {
379    ($target:expr, $partial:expr, [$($field:ident),+ $(,)?]) => {
380        $(
381            if let Some(severity) = $partial.$field {
382                $target.$field = severity;
383            }
384        )+
385    };
386}
387
388impl RulesConfig {
389    /// Apply a partial rules config on top. Only `Some` fields override.
390    pub const fn apply_partial(&mut self, partial: &PartialRulesConfig) {
391        apply_partial_rules!(
392            self,
393            partial,
394            [
395                unused_files,
396                unused_exports,
397                unused_types,
398                private_type_leaks,
399                unused_dependencies,
400                unused_dev_dependencies,
401                unused_optional_dependencies,
402            ]
403        );
404        apply_partial_rules!(
405            self,
406            partial,
407            [
408                unused_enum_members,
409                unused_class_members,
410                unused_store_members,
411                unprovided_injects,
412                unrendered_components,
413                unused_component_props,
414                unused_component_emits,
415                unused_component_inputs,
416                unused_component_outputs,
417                unused_svelte_events,
418                unused_server_actions,
419                unused_load_data_keys,
420                prop_drilling,
421                thin_wrapper,
422                duplicate_prop_shape,
423                css_token_drift,
424                css_duplicate_block,
425                css_selector_complexity,
426                css_dead_surface,
427                css_broken_reference,
428            ]
429        );
430        apply_partial_rules!(
431            self,
432            partial,
433            [
434                unresolved_imports,
435                unlisted_dependencies,
436                duplicate_exports,
437                type_only_dependencies,
438                test_only_dependencies,
439                circular_dependencies,
440                re_export_cycle,
441                boundary_violation,
442            ]
443        );
444        apply_partial_rules!(
445            self,
446            partial,
447            [
448                coverage_gaps,
449                feature_flags,
450                stale_suppressions,
451                require_suppression_reason,
452                unused_catalog_entries,
453                empty_catalog_groups,
454                unresolved_catalog_references,
455                unused_dependency_overrides,
456                misconfigured_dependency_overrides,
457            ]
458        );
459        apply_partial_rules!(
460            self,
461            partial,
462            [
463                security_client_server_leak,
464                security_sink,
465                policy_violation,
466                invalid_client_export,
467                mixed_client_server_barrel,
468                misplaced_directive,
469                route_collision,
470                dynamic_segment_name_conflict,
471            ]
472        );
473    }
474}
475
476/// Partial per-issue-type severity for overrides. All fields optional.
477#[derive(Debug, Default, Clone, Deserialize, Serialize, JsonSchema)]
478#[serde(rename_all = "kebab-case")]
479pub struct PartialRulesConfig {
480    #[serde(
481        default,
482        alias = "unused-file",
483        skip_serializing_if = "Option::is_none"
484    )]
485    pub unused_files: Option<Severity>,
486    #[serde(
487        default,
488        alias = "unused-export",
489        skip_serializing_if = "Option::is_none"
490    )]
491    pub unused_exports: Option<Severity>,
492    #[serde(
493        default,
494        alias = "unused-type",
495        skip_serializing_if = "Option::is_none"
496    )]
497    pub unused_types: Option<Severity>,
498    #[serde(
499        default,
500        alias = "private-type-leak",
501        skip_serializing_if = "Option::is_none"
502    )]
503    pub private_type_leaks: Option<Severity>,
504    #[serde(
505        default,
506        alias = "unused-dependency",
507        skip_serializing_if = "Option::is_none"
508    )]
509    pub unused_dependencies: Option<Severity>,
510    #[serde(
511        default,
512        alias = "unused-dev-dependency",
513        skip_serializing_if = "Option::is_none"
514    )]
515    pub unused_dev_dependencies: Option<Severity>,
516    #[serde(
517        default,
518        alias = "unused-optional-dependency",
519        skip_serializing_if = "Option::is_none"
520    )]
521    pub unused_optional_dependencies: Option<Severity>,
522    #[serde(
523        default,
524        alias = "unused-enum-member",
525        skip_serializing_if = "Option::is_none"
526    )]
527    pub unused_enum_members: Option<Severity>,
528    #[serde(
529        default,
530        alias = "unused-class-member",
531        skip_serializing_if = "Option::is_none"
532    )]
533    pub unused_class_members: Option<Severity>,
534    #[serde(
535        default,
536        alias = "unused-store-member",
537        skip_serializing_if = "Option::is_none"
538    )]
539    pub unused_store_members: Option<Severity>,
540    #[serde(
541        default,
542        alias = "unprovided-inject",
543        skip_serializing_if = "Option::is_none"
544    )]
545    pub unprovided_injects: Option<Severity>,
546    #[serde(
547        default,
548        alias = "unrendered-component",
549        skip_serializing_if = "Option::is_none"
550    )]
551    pub unrendered_components: Option<Severity>,
552    #[serde(
553        default,
554        alias = "unused-component-prop",
555        skip_serializing_if = "Option::is_none"
556    )]
557    pub unused_component_props: Option<Severity>,
558    #[serde(
559        default,
560        alias = "unused-component-emit",
561        skip_serializing_if = "Option::is_none"
562    )]
563    pub unused_component_emits: Option<Severity>,
564    #[serde(
565        default,
566        alias = "unused-component-input",
567        skip_serializing_if = "Option::is_none"
568    )]
569    pub unused_component_inputs: Option<Severity>,
570    #[serde(
571        default,
572        alias = "unused-component-output",
573        skip_serializing_if = "Option::is_none"
574    )]
575    pub unused_component_outputs: Option<Severity>,
576    #[serde(
577        default,
578        alias = "unused-svelte-event",
579        skip_serializing_if = "Option::is_none"
580    )]
581    pub unused_svelte_events: Option<Severity>,
582    #[serde(
583        default,
584        alias = "unused-server-action",
585        skip_serializing_if = "Option::is_none"
586    )]
587    pub unused_server_actions: Option<Severity>,
588    #[serde(
589        default,
590        alias = "unused-load-data-key",
591        skip_serializing_if = "Option::is_none"
592    )]
593    pub unused_load_data_keys: Option<Severity>,
594    #[serde(
595        default,
596        alias = "prop-drilling",
597        skip_serializing_if = "Option::is_none"
598    )]
599    pub prop_drilling: Option<Severity>,
600    #[serde(
601        default,
602        alias = "thin-wrapper",
603        skip_serializing_if = "Option::is_none"
604    )]
605    pub thin_wrapper: Option<Severity>,
606    #[serde(
607        default,
608        alias = "duplicate-prop-shape",
609        skip_serializing_if = "Option::is_none"
610    )]
611    pub duplicate_prop_shape: Option<Severity>,
612    #[serde(
613        default,
614        alias = "css-token-drift",
615        skip_serializing_if = "Option::is_none"
616    )]
617    pub css_token_drift: Option<Severity>,
618    #[serde(
619        default,
620        alias = "css-duplicate-block",
621        skip_serializing_if = "Option::is_none"
622    )]
623    pub css_duplicate_block: Option<Severity>,
624    #[serde(
625        default,
626        alias = "css-selector-complexity",
627        skip_serializing_if = "Option::is_none"
628    )]
629    pub css_selector_complexity: Option<Severity>,
630    #[serde(
631        default,
632        alias = "css-dead-surface",
633        skip_serializing_if = "Option::is_none"
634    )]
635    pub css_dead_surface: Option<Severity>,
636    #[serde(
637        default,
638        alias = "css-broken-reference",
639        skip_serializing_if = "Option::is_none"
640    )]
641    pub css_broken_reference: Option<Severity>,
642    #[serde(
643        default,
644        alias = "unresolved-import",
645        skip_serializing_if = "Option::is_none"
646    )]
647    pub unresolved_imports: Option<Severity>,
648    #[serde(
649        default,
650        alias = "unlisted-dependency",
651        skip_serializing_if = "Option::is_none"
652    )]
653    pub unlisted_dependencies: Option<Severity>,
654    #[serde(
655        default,
656        alias = "duplicate-export",
657        skip_serializing_if = "Option::is_none"
658    )]
659    pub duplicate_exports: Option<Severity>,
660    #[serde(
661        default,
662        alias = "type-only-dependency",
663        skip_serializing_if = "Option::is_none"
664    )]
665    pub type_only_dependencies: Option<Severity>,
666    #[serde(
667        default,
668        alias = "test-only-dependency",
669        skip_serializing_if = "Option::is_none"
670    )]
671    pub test_only_dependencies: Option<Severity>,
672    #[serde(
673        default,
674        alias = "circular-dependency",
675        skip_serializing_if = "Option::is_none"
676    )]
677    pub circular_dependencies: Option<Severity>,
678    #[serde(
679        default,
680        alias = "re-export-cycles",
681        alias = "reexport-cycle",
682        alias = "reexport-cycles",
683        skip_serializing_if = "Option::is_none"
684    )]
685    pub re_export_cycle: Option<Severity>,
686    #[serde(
687        default,
688        alias = "boundary-violations",
689        skip_serializing_if = "Option::is_none"
690    )]
691    pub boundary_violation: Option<Severity>,
692    #[serde(
693        default,
694        alias = "coverage-gap",
695        skip_serializing_if = "Option::is_none"
696    )]
697    pub coverage_gaps: Option<Severity>,
698    #[serde(
699        default,
700        alias = "feature-flag",
701        skip_serializing_if = "Option::is_none"
702    )]
703    pub feature_flags: Option<Severity>,
704    #[serde(
705        default,
706        alias = "stale-suppression",
707        skip_serializing_if = "Option::is_none"
708    )]
709    pub stale_suppressions: Option<Severity>,
710    #[serde(
711        default,
712        alias = "suppression-reason",
713        skip_serializing_if = "Option::is_none"
714    )]
715    pub require_suppression_reason: Option<Severity>,
716    #[serde(
717        default,
718        alias = "unused-catalog-entry",
719        skip_serializing_if = "Option::is_none"
720    )]
721    pub unused_catalog_entries: Option<Severity>,
722    #[serde(
723        default,
724        alias = "empty-catalog-group",
725        skip_serializing_if = "Option::is_none"
726    )]
727    pub empty_catalog_groups: Option<Severity>,
728    #[serde(
729        default,
730        alias = "unresolved-catalog-reference",
731        skip_serializing_if = "Option::is_none"
732    )]
733    pub unresolved_catalog_references: Option<Severity>,
734    #[serde(
735        default,
736        alias = "unused-dependency-override",
737        skip_serializing_if = "Option::is_none"
738    )]
739    pub unused_dependency_overrides: Option<Severity>,
740    #[serde(
741        default,
742        alias = "misconfigured-dependency-override",
743        skip_serializing_if = "Option::is_none"
744    )]
745    pub misconfigured_dependency_overrides: Option<Severity>,
746    #[serde(default, skip_serializing_if = "Option::is_none")]
747    pub security_client_server_leak: Option<Severity>,
748    #[serde(default, skip_serializing_if = "Option::is_none")]
749    pub security_sink: Option<Severity>,
750    #[serde(
751        default,
752        alias = "policy-violations",
753        skip_serializing_if = "Option::is_none"
754    )]
755    pub policy_violation: Option<Severity>,
756    #[serde(
757        default,
758        alias = "invalid-client-exports",
759        skip_serializing_if = "Option::is_none"
760    )]
761    pub invalid_client_export: Option<Severity>,
762    #[serde(
763        default,
764        alias = "mixed-client-server-barrels",
765        skip_serializing_if = "Option::is_none"
766    )]
767    pub mixed_client_server_barrel: Option<Severity>,
768    #[serde(
769        default,
770        alias = "misplaced-directives",
771        skip_serializing_if = "Option::is_none"
772    )]
773    pub misplaced_directive: Option<Severity>,
774    #[serde(
775        default,
776        alias = "route-collisions",
777        skip_serializing_if = "Option::is_none"
778    )]
779    pub route_collision: Option<Severity>,
780    #[serde(
781        default,
782        alias = "dynamic-segment-name-conflicts",
783        skip_serializing_if = "Option::is_none"
784    )]
785    pub dynamic_segment_name_conflict: Option<Severity>,
786}
787
788/// Every rule name accepted by `RulesConfig` deserialization, in kebab-case.
789///
790/// Includes both the canonical name produced by `#[serde(rename_all = "kebab-case")]`
791/// and every `#[serde(alias = ...)]` value. Used by
792/// [`find_unknown_rule_keys`] to detect typos in user-supplied configs and
793/// emit a `tracing::warn!` suggestion at config load time.
794///
795/// Keep in sync with the `#[serde]` attributes on `RulesConfig` and
796/// `PartialRulesConfig`; the `known_rule_names_count_matches_struct` test
797/// fails when the lists drift.
798pub const KNOWN_RULE_NAMES: &[&str] = &[
799    "unused-files",
800    "unused-exports",
801    "unused-types",
802    "private-type-leaks",
803    "unused-dependencies",
804    "unused-dev-dependencies",
805    "unused-optional-dependencies",
806    "unused-enum-members",
807    "unused-class-members",
808    "unused-store-members",
809    "unprovided-injects",
810    "unrendered-components",
811    "unused-component-props",
812    "unused-component-emits",
813    "unused-component-inputs",
814    "unused-component-outputs",
815    "unused-svelte-events",
816    "unused-server-actions",
817    "unused-load-data-keys",
818    "prop-drilling",
819    "thin-wrapper",
820    "duplicate-prop-shape",
821    "css-token-drift",
822    "css-duplicate-block",
823    "css-selector-complexity",
824    "css-dead-surface",
825    "css-broken-reference",
826    "unresolved-imports",
827    "unlisted-dependencies",
828    "duplicate-exports",
829    "type-only-dependencies",
830    "test-only-dependencies",
831    "circular-dependencies",
832    "re-export-cycle",
833    "boundary-violation",
834    "coverage-gaps",
835    "feature-flags",
836    "stale-suppressions",
837    "require-suppression-reason",
838    "suppression-reason",
839    "unused-catalog-entries",
840    "empty-catalog-groups",
841    "unresolved-catalog-references",
842    "unused-dependency-overrides",
843    "misconfigured-dependency-overrides",
844    "security-client-server-leak",
845    "security-sink",
846    "policy-violation",
847    "policy-violations",
848    "invalid-client-export",
849    "mixed-client-server-barrel",
850    "misplaced-directive",
851    "route-collision",
852    "dynamic-segment-name-conflict",
853    "unused-file",
854    "unused-export",
855    "unused-type",
856    "private-type-leak",
857    "unused-dependency",
858    "unused-dev-dependency",
859    "unused-optional-dependency",
860    "unused-enum-member",
861    "unused-class-member",
862    "unused-store-member",
863    "unprovided-inject",
864    "unrendered-component",
865    "unused-component-prop",
866    "unused-component-emit",
867    "unused-component-input",
868    "unused-component-output",
869    "unused-svelte-event",
870    "unused-server-action",
871    "unused-load-data-key",
872    "unresolved-import",
873    "unlisted-dependency",
874    "duplicate-export",
875    "type-only-dependency",
876    "test-only-dependency",
877    "circular-dependency",
878    "re-export-cycles",
879    "reexport-cycle",
880    "reexport-cycles",
881    "boundary-violations",
882    "coverage-gap",
883    "feature-flag",
884    "stale-suppression",
885    "unused-catalog-entry",
886    "empty-catalog-group",
887    "unresolved-catalog-reference",
888    "unused-dependency-override",
889    "misconfigured-dependency-override",
890    "invalid-client-exports",
891    "mixed-client-server-barrels",
892    "misplaced-directives",
893    "route-collisions",
894    "dynamic-segment-name-conflicts",
895];
896
897/// Find the closest known rule name to `input` when it is plausibly a typo.
898///
899/// Thin wrapper over [`crate::levenshtein::closest_match`] that scopes the
900/// candidate set to [`KNOWN_RULE_NAMES`] and returns a `'static` reference so
901/// the suggestion can be embedded in tracing warnings without allocation.
902#[must_use]
903pub fn closest_known_rule_name(input: &str) -> Option<&'static str> {
904    let input_lower = input.to_ascii_lowercase();
905    let candidates = KNOWN_RULE_NAMES.iter().copied();
906    let suggestion = crate::levenshtein::closest_match(&input_lower, candidates)?;
907    KNOWN_RULE_NAMES.iter().copied().find(|&c| c == suggestion)
908}
909
910/// An unknown key found inside a `rules` (or `overrides[].rules`) object.
911///
912/// Surfaced by [`find_unknown_rule_keys`] so the caller (config loader) can
913/// emit one `tracing::warn!` per entry without coupling the detection logic
914/// to a tracing subscriber.
915#[derive(Debug, Clone, PartialEq, Eq)]
916pub struct UnknownRuleKey {
917    /// Human-readable source label, e.g. `"rules"` or `"overrides[2].rules"`.
918    pub context: String,
919    /// The unknown key as it appeared in the user's config.
920    pub key: String,
921    /// Closest known rule name when one is within plausible-typo distance.
922    pub suggestion: Option<&'static str>,
923}
924
925/// Collect every unknown key from a `rules`-shaped JSON object.
926///
927/// Returns an empty `Vec` when `value` is not an object or every key is
928/// recognized (canonical kebab-case or a documented alias). Called from
929/// [`crate::config::parsing`] after `extends` merge and before
930/// `serde_json::from_value::<FallowConfig>`, so the warning lists keys from
931/// the final merged config rather than per-file partials.
932#[must_use]
933pub fn find_unknown_rule_keys(value: &serde_json::Value, context: &str) -> Vec<UnknownRuleKey> {
934    let Some(map) = value.as_object() else {
935        return Vec::new();
936    };
937
938    map.keys()
939        .filter(|key| !KNOWN_RULE_NAMES.contains(&key.as_str()))
940        .map(|key| UnknownRuleKey {
941            context: context.to_owned(),
942            key: key.clone(),
943            suggestion: closest_known_rule_name(key),
944        })
945        .collect()
946}
947
948#[cfg(test)]
949mod tests {
950    use super::*;
951
952    #[test]
953    fn rules_default_severities() {
954        let rules = RulesConfig::default();
955        assert_eq!(rules.unused_files, Severity::Error);
956        assert_eq!(rules.unused_exports, Severity::Error);
957        assert_eq!(rules.unused_types, Severity::Error);
958        assert_eq!(rules.private_type_leaks, Severity::Off);
959        assert_eq!(rules.unused_dependencies, Severity::Error);
960        assert_eq!(rules.unused_dev_dependencies, Severity::Warn);
961        assert_eq!(rules.unused_optional_dependencies, Severity::Warn);
962        assert_eq!(rules.unused_enum_members, Severity::Error);
963        assert_eq!(rules.unused_class_members, Severity::Error);
964        assert_eq!(rules.unresolved_imports, Severity::Error);
965        assert_eq!(rules.unlisted_dependencies, Severity::Error);
966        assert_eq!(rules.duplicate_exports, Severity::Error);
967        assert_eq!(rules.type_only_dependencies, Severity::Warn);
968        assert_eq!(rules.test_only_dependencies, Severity::Warn);
969        assert_eq!(rules.circular_dependencies, Severity::Error);
970        assert_eq!(rules.boundary_violation, Severity::Error);
971        assert_eq!(rules.coverage_gaps, Severity::Off);
972        assert_eq!(rules.feature_flags, Severity::Off);
973        assert_eq!(rules.stale_suppressions, Severity::Warn);
974        assert_eq!(rules.unused_catalog_entries, Severity::Warn);
975        assert_eq!(rules.empty_catalog_groups, Severity::Warn);
976        assert_eq!(rules.unresolved_catalog_references, Severity::Error);
977    }
978
979    #[test]
980    fn rules_deserialize_kebab_case() {
981        let json_str = r#"{
982            "unused-files": "error",
983            "unused-exports": "warn",
984            "unused-types": "off"
985        }"#;
986        let rules: RulesConfig = serde_json::from_str(json_str).unwrap();
987        assert_eq!(rules.unused_files, Severity::Error);
988        assert_eq!(rules.unused_exports, Severity::Warn);
989        assert_eq!(rules.unused_types, Severity::Off);
990        assert_eq!(rules.unresolved_imports, Severity::Error);
991    }
992
993    #[test]
994    fn rules_re_export_cycle_default_is_warn() {
995        let rules = RulesConfig::default();
996        assert_eq!(rules.re_export_cycle, Severity::Warn);
997    }
998
999    #[test]
1000    fn rules_deserialize_re_export_cycle_aliases() {
1001        for token in [
1002            "re-export-cycle",
1003            "re-export-cycles",
1004            "reexport-cycle",
1005            "reexport-cycles",
1006        ] {
1007            let json_str = format!(r#"{{ "{token}": "error" }}"#);
1008            let rules: RulesConfig = serde_json::from_str(&json_str)
1009                .unwrap_or_else(|e| panic!("alias {token} did not deserialize: {e}"));
1010            assert_eq!(
1011                rules.re_export_cycle,
1012                Severity::Error,
1013                "alias {token} should set re_export_cycle"
1014            );
1015        }
1016    }
1017
1018    #[test]
1019    fn rules_deserialize_circular_dependency_alias() {
1020        let json_str = r#"{
1021            "circular-dependency": "off"
1022        }"#;
1023        let rules: RulesConfig = serde_json::from_str(json_str).unwrap();
1024        assert_eq!(rules.circular_dependencies, Severity::Off);
1025    }
1026
1027    #[test]
1028    fn rules_deserialize_boundary_violations_alias() {
1029        let json_str = r#"{
1030            "boundary-violations": "off"
1031        }"#;
1032        let rules: RulesConfig = serde_json::from_str(json_str).unwrap();
1033        assert_eq!(rules.boundary_violation, Severity::Off);
1034
1035        let partial: PartialRulesConfig = serde_json::from_str(json_str).unwrap();
1036        assert_eq!(partial.boundary_violation, Some(Severity::Off));
1037    }
1038
1039    #[test]
1040    fn rules_deserialize_singular_aliases_for_every_plural_rule() {
1041        let json_str = r#"{
1042            "unused-file": "off",
1043            "unused-export": "off",
1044            "unused-type": "off",
1045            "private-type-leak": "warn",
1046            "unused-dependency": "off",
1047            "unused-dev-dependency": "off",
1048            "unused-optional-dependency": "off",
1049            "unused-enum-member": "off",
1050            "unused-class-member": "off",
1051            "unresolved-import": "off",
1052            "unlisted-dependency": "off",
1053            "duplicate-export": "off",
1054            "type-only-dependency": "off",
1055            "test-only-dependency": "off",
1056            "coverage-gap": "warn",
1057            "feature-flag": "warn",
1058            "stale-suppression": "off",
1059            "suppression-reason": "warn",
1060            "unused-catalog-entry": "error",
1061            "empty-catalog-group": "error",
1062            "unresolved-catalog-reference": "warn"
1063        }"#;
1064
1065        let rules: RulesConfig = serde_json::from_str(json_str).unwrap();
1066        assert_eq!(rules.unused_files, Severity::Off);
1067        assert_eq!(rules.unused_exports, Severity::Off);
1068        assert_eq!(rules.unused_types, Severity::Off);
1069        assert_eq!(rules.private_type_leaks, Severity::Warn);
1070        assert_eq!(rules.unused_dependencies, Severity::Off);
1071        assert_eq!(rules.unused_dev_dependencies, Severity::Off);
1072        assert_eq!(rules.unused_optional_dependencies, Severity::Off);
1073        assert_eq!(rules.unused_enum_members, Severity::Off);
1074        assert_eq!(rules.unused_class_members, Severity::Off);
1075        assert_eq!(rules.unresolved_imports, Severity::Off);
1076        assert_eq!(rules.unlisted_dependencies, Severity::Off);
1077        assert_eq!(rules.duplicate_exports, Severity::Off);
1078        assert_eq!(rules.type_only_dependencies, Severity::Off);
1079        assert_eq!(rules.test_only_dependencies, Severity::Off);
1080        assert_eq!(rules.coverage_gaps, Severity::Warn);
1081        assert_eq!(rules.feature_flags, Severity::Warn);
1082        assert_eq!(rules.stale_suppressions, Severity::Off);
1083        assert_eq!(rules.require_suppression_reason, Severity::Warn);
1084        assert_eq!(rules.unused_catalog_entries, Severity::Error);
1085        assert_eq!(rules.empty_catalog_groups, Severity::Error);
1086        assert_eq!(rules.unresolved_catalog_references, Severity::Warn);
1087
1088        let partial: PartialRulesConfig = serde_json::from_str(json_str).unwrap();
1089        assert_eq!(partial.unused_files, Some(Severity::Off));
1090        assert_eq!(partial.unused_exports, Some(Severity::Off));
1091        assert_eq!(partial.unused_types, Some(Severity::Off));
1092        assert_eq!(partial.private_type_leaks, Some(Severity::Warn));
1093        assert_eq!(partial.unused_dependencies, Some(Severity::Off));
1094        assert_eq!(partial.unused_dev_dependencies, Some(Severity::Off));
1095        assert_eq!(partial.unused_optional_dependencies, Some(Severity::Off));
1096        assert_eq!(partial.unused_enum_members, Some(Severity::Off));
1097        assert_eq!(partial.unused_class_members, Some(Severity::Off));
1098        assert_eq!(partial.unresolved_imports, Some(Severity::Off));
1099        assert_eq!(partial.unlisted_dependencies, Some(Severity::Off));
1100        assert_eq!(partial.duplicate_exports, Some(Severity::Off));
1101        assert_eq!(partial.type_only_dependencies, Some(Severity::Off));
1102        assert_eq!(partial.test_only_dependencies, Some(Severity::Off));
1103        assert_eq!(partial.coverage_gaps, Some(Severity::Warn));
1104        assert_eq!(partial.feature_flags, Some(Severity::Warn));
1105        assert_eq!(partial.stale_suppressions, Some(Severity::Off));
1106        assert_eq!(partial.require_suppression_reason, Some(Severity::Warn));
1107        assert_eq!(partial.unused_catalog_entries, Some(Severity::Error));
1108        assert_eq!(partial.empty_catalog_groups, Some(Severity::Error));
1109        assert_eq!(partial.unresolved_catalog_references, Some(Severity::Warn));
1110    }
1111
1112    #[test]
1113    fn severity_from_str() {
1114        assert_eq!("error".parse::<Severity>().unwrap(), Severity::Error);
1115        assert_eq!("warn".parse::<Severity>().unwrap(), Severity::Warn);
1116        assert_eq!("warning".parse::<Severity>().unwrap(), Severity::Warn);
1117        assert_eq!("off".parse::<Severity>().unwrap(), Severity::Off);
1118        assert_eq!("none".parse::<Severity>().unwrap(), Severity::Off);
1119        assert!("invalid".parse::<Severity>().is_err());
1120    }
1121
1122    #[test]
1123    fn apply_partial_only_some_fields() {
1124        let mut rules = RulesConfig::default();
1125        let partial = PartialRulesConfig {
1126            unused_files: Some(Severity::Warn),
1127            unused_exports: Some(Severity::Off),
1128            ..Default::default()
1129        };
1130        rules.apply_partial(&partial);
1131        assert_eq!(rules.unused_files, Severity::Warn);
1132        assert_eq!(rules.unused_exports, Severity::Off);
1133        assert_eq!(rules.unused_types, Severity::Error);
1134        assert_eq!(rules.unresolved_imports, Severity::Error);
1135        assert_eq!(rules.require_suppression_reason, Severity::Off);
1136    }
1137
1138    #[test]
1139    fn require_suppression_reason_deserializes_canonical_and_alias() {
1140        let rules: RulesConfig = serde_json::from_str(
1141            r#"{
1142                "require-suppression-reason": "error"
1143            }"#,
1144        )
1145        .unwrap();
1146        assert_eq!(rules.require_suppression_reason, Severity::Error);
1147
1148        let partial: PartialRulesConfig = serde_json::from_str(
1149            r#"{
1150                "suppression-reason": "warn"
1151            }"#,
1152        )
1153        .unwrap();
1154        assert_eq!(partial.require_suppression_reason, Some(Severity::Warn));
1155    }
1156
1157    #[test]
1158    fn severity_display() {
1159        assert_eq!(Severity::Error.to_string(), "error");
1160        assert_eq!(Severity::Warn.to_string(), "warn");
1161        assert_eq!(Severity::Off.to_string(), "off");
1162    }
1163
1164    #[test]
1165    fn apply_partial_all_none_changes_nothing() {
1166        let mut rules = RulesConfig::default();
1167        let original = rules.clone();
1168        let partial = PartialRulesConfig::default(); // all None
1169        rules.apply_partial(&partial);
1170        assert_eq!(rules.unused_files, original.unused_files);
1171        assert_eq!(rules.unused_exports, original.unused_exports);
1172        assert_eq!(
1173            rules.type_only_dependencies,
1174            original.type_only_dependencies
1175        );
1176    }
1177
1178    #[test]
1179    fn apply_partial_all_fields_set() {
1180        let mut rules = RulesConfig::default();
1181        let partial = PartialRulesConfig {
1182            unused_files: Some(Severity::Off),
1183            unused_exports: Some(Severity::Off),
1184            unused_types: Some(Severity::Off),
1185            private_type_leaks: Some(Severity::Off),
1186            unused_dependencies: Some(Severity::Off),
1187            unused_dev_dependencies: Some(Severity::Off),
1188            unused_optional_dependencies: Some(Severity::Off),
1189            unused_enum_members: Some(Severity::Off),
1190            unused_class_members: Some(Severity::Off),
1191            unused_store_members: Some(Severity::Off),
1192            unprovided_injects: Some(Severity::Off),
1193            unrendered_components: Some(Severity::Off),
1194            unused_component_props: Some(Severity::Off),
1195            unused_component_emits: Some(Severity::Off),
1196            unused_component_inputs: Some(Severity::Off),
1197            unused_component_outputs: Some(Severity::Off),
1198            unused_svelte_events: Some(Severity::Off),
1199            unused_server_actions: Some(Severity::Off),
1200            unused_load_data_keys: Some(Severity::Off),
1201            prop_drilling: Some(Severity::Off),
1202            thin_wrapper: Some(Severity::Off),
1203            duplicate_prop_shape: Some(Severity::Off),
1204            css_token_drift: Some(Severity::Off),
1205            css_duplicate_block: Some(Severity::Off),
1206            css_selector_complexity: Some(Severity::Off),
1207            css_dead_surface: Some(Severity::Off),
1208            css_broken_reference: Some(Severity::Off),
1209            unresolved_imports: Some(Severity::Off),
1210            unlisted_dependencies: Some(Severity::Off),
1211            duplicate_exports: Some(Severity::Off),
1212            type_only_dependencies: Some(Severity::Off),
1213            test_only_dependencies: Some(Severity::Off),
1214            circular_dependencies: Some(Severity::Off),
1215            re_export_cycle: Some(Severity::Off),
1216            boundary_violation: Some(Severity::Off),
1217            coverage_gaps: Some(Severity::Off),
1218            feature_flags: Some(Severity::Off),
1219            stale_suppressions: Some(Severity::Off),
1220            require_suppression_reason: Some(Severity::Off),
1221            unused_catalog_entries: Some(Severity::Off),
1222            empty_catalog_groups: Some(Severity::Off),
1223            unresolved_catalog_references: Some(Severity::Off),
1224            unused_dependency_overrides: Some(Severity::Off),
1225            misconfigured_dependency_overrides: Some(Severity::Off),
1226            security_client_server_leak: Some(Severity::Off),
1227            security_sink: Some(Severity::Off),
1228            policy_violation: Some(Severity::Off),
1229            invalid_client_export: Some(Severity::Off),
1230            mixed_client_server_barrel: Some(Severity::Off),
1231            misplaced_directive: Some(Severity::Off),
1232            route_collision: Some(Severity::Off),
1233            dynamic_segment_name_conflict: Some(Severity::Off),
1234        };
1235        rules.apply_partial(&partial);
1236        assert_eq!(rules.unused_files, Severity::Off);
1237        assert_eq!(rules.private_type_leaks, Severity::Off);
1238        assert_eq!(rules.circular_dependencies, Severity::Off);
1239        assert_eq!(rules.type_only_dependencies, Severity::Off);
1240        assert_eq!(rules.test_only_dependencies, Severity::Off);
1241        assert_eq!(rules.boundary_violation, Severity::Off);
1242        assert_eq!(rules.coverage_gaps, Severity::Off);
1243        assert_eq!(rules.feature_flags, Severity::Off);
1244        assert_eq!(rules.stale_suppressions, Severity::Off);
1245        assert_eq!(rules.require_suppression_reason, Severity::Off);
1246        assert_eq!(rules.security_sink, Severity::Off);
1247        assert_eq!(rules.policy_violation, Severity::Off);
1248        assert_eq!(rules.invalid_client_export, Severity::Off);
1249        assert_eq!(rules.mixed_client_server_barrel, Severity::Off);
1250        assert_eq!(rules.misplaced_directive, Severity::Off);
1251        assert_eq!(rules.unrendered_components, Severity::Off);
1252        assert_eq!(rules.unused_component_props, Severity::Off);
1253        assert_eq!(rules.unused_component_emits, Severity::Off);
1254        assert_eq!(rules.unused_component_inputs, Severity::Off);
1255        assert_eq!(rules.unused_component_outputs, Severity::Off);
1256        assert_eq!(rules.unused_svelte_events, Severity::Off);
1257        assert_eq!(rules.route_collision, Severity::Off);
1258        assert_eq!(rules.dynamic_segment_name_conflict, Severity::Off);
1259    }
1260
1261    #[test]
1262    fn rules_config_defaults_include_optional_deps() {
1263        let rules = RulesConfig::default();
1264        assert_eq!(rules.unused_optional_dependencies, Severity::Warn);
1265    }
1266
1267    #[test]
1268    fn policy_violation_defaults_to_warn() {
1269        let rules = RulesConfig::default();
1270        assert_eq!(rules.policy_violation, Severity::Warn);
1271    }
1272
1273    #[test]
1274    fn policy_violation_accepts_plural_alias() {
1275        let json = r#"{ "policy-violations": "error" }"#;
1276        let rules: RulesConfig = serde_json::from_str(json).unwrap();
1277        assert_eq!(rules.policy_violation, Severity::Error);
1278    }
1279
1280    #[test]
1281    fn severity_from_str_case_insensitive() {
1282        assert_eq!("ERROR".parse::<Severity>().unwrap(), Severity::Error);
1283        assert_eq!("Warn".parse::<Severity>().unwrap(), Severity::Warn);
1284        assert_eq!("OFF".parse::<Severity>().unwrap(), Severity::Off);
1285        assert_eq!("Warning".parse::<Severity>().unwrap(), Severity::Warn);
1286        assert_eq!("NONE".parse::<Severity>().unwrap(), Severity::Off);
1287    }
1288
1289    #[test]
1290    fn severity_from_str_invalid_returns_error() {
1291        let result = "critical".parse::<Severity>();
1292        assert!(result.is_err());
1293        let err = result.unwrap_err();
1294        assert!(
1295            err.contains("unknown severity"),
1296            "Expected descriptive error, got: {err}"
1297        );
1298    }
1299
1300    #[test]
1301    fn known_rule_names_count_matches_struct() {
1302        assert_eq!(KNOWN_RULE_NAMES.len(), 96);
1303    }
1304
1305    #[test]
1306    fn known_rule_names_has_no_duplicates() {
1307        let mut sorted: Vec<&str> = KNOWN_RULE_NAMES.to_vec();
1308        sorted.sort_unstable();
1309        let original_len = sorted.len();
1310        sorted.dedup();
1311        assert_eq!(
1312            sorted.len(),
1313            original_len,
1314            "KNOWN_RULE_NAMES contains a duplicate"
1315        );
1316    }
1317
1318    #[test]
1319    fn known_rule_names_covers_every_serde_alias_in_source() {
1320        let source = include_str!("rules.rs");
1321
1322        let mut aliases_found = Vec::new();
1323        for line in source.lines() {
1324            let trimmed = line.trim();
1325            if trimmed.starts_with("//") {
1326                continue;
1327            }
1328            let Some(after) = trimmed.split("alias = \"").nth(1) else {
1329                continue;
1330            };
1331            let Some(end) = after.find('"') else {
1332                continue;
1333            };
1334            let alias = &after[..end];
1335            if alias.is_empty() || !alias.chars().all(|c| c.is_ascii_lowercase() || c == '-') {
1336                continue;
1337            }
1338            aliases_found.push(alias.to_owned());
1339        }
1340
1341        assert_eq!(
1342            aliases_found.len(),
1343            104,
1344            "expected 104 source-level alias attrs (52 per struct); got {}: {:?}",
1345            aliases_found.len(),
1346            aliases_found
1347        );
1348
1349        for alias in &aliases_found {
1350            assert!(
1351                KNOWN_RULE_NAMES.contains(&alias.as_str()),
1352                "serde alias '{alias}' is in rules.rs source but missing from KNOWN_RULE_NAMES"
1353            );
1354        }
1355    }
1356
1357    #[test]
1358    fn re_export_cycle_aliases_all_round_trip_to_the_same_field() {
1359        for alias in [
1360            "re-export-cycle",
1361            "re-export-cycles",
1362            "reexport-cycle",
1363            "reexport-cycles",
1364        ] {
1365            let json = format!(r#"{{"{alias}": "warn"}}"#);
1366            let partial: PartialRulesConfig = serde_json::from_str(&json)
1367                .unwrap_or_else(|e| panic!("'{alias}' should deserialize: {e}"));
1368            assert_eq!(
1369                partial.re_export_cycle,
1370                Some(Severity::Warn),
1371                "'{alias}' should set re_export_cycle to Warn"
1372            );
1373            let serialized = serde_json::to_value(&partial).unwrap();
1374            let map = serialized.as_object().unwrap();
1375            assert_eq!(
1376                map.len(),
1377                1,
1378                "'{alias}' should resolve to exactly one field, got: {map:?}"
1379            );
1380        }
1381    }
1382
1383    #[test]
1384    fn every_known_rule_name_round_trips_through_partial() {
1385        for &name in KNOWN_RULE_NAMES {
1386            let json = format!(r#"{{"{name}": "warn"}}"#);
1387            let partial: PartialRulesConfig = serde_json::from_str(&json)
1388                .unwrap_or_else(|e| panic!("'{name}' should deserialize: {e}"));
1389
1390            let serialized = serde_json::to_value(&partial).unwrap();
1391            let map = serialized.as_object().unwrap();
1392            assert_eq!(
1393                map.len(),
1394                1,
1395                "'{name}' should resolve to exactly one field, got: {map:?}"
1396            );
1397        }
1398    }
1399
1400    #[test]
1401    fn known_rule_names_covers_every_struct_field() {
1402        let json = serde_json::to_value(RulesConfig::default()).unwrap();
1403        let obj = json.as_object().unwrap();
1404        for key in obj.keys() {
1405            assert!(
1406                KNOWN_RULE_NAMES.contains(&key.as_str()),
1407                "field '{key}' is serialized but missing from KNOWN_RULE_NAMES"
1408            );
1409        }
1410    }
1411
1412    #[test]
1413    fn closest_known_rule_name_suggests_for_obvious_typo() {
1414        assert_eq!(
1415            closest_known_rule_name("unsued-files"),
1416            Some("unused-files")
1417        );
1418        assert_eq!(
1419            closest_known_rule_name("circular-dependnecy"),
1420            Some("circular-dependency")
1421        );
1422        assert_eq!(
1423            closest_known_rule_name("unused-dep"),
1424            None,
1425            "too short for a confident suggestion"
1426        );
1427    }
1428
1429    #[test]
1430    fn closest_known_rule_name_returns_none_for_novel_input() {
1431        assert_eq!(closest_known_rule_name("totally-fabricated"), None);
1432        assert_eq!(closest_known_rule_name("foo"), None);
1433    }
1434
1435    #[test]
1436    fn closest_known_rule_name_is_case_insensitive() {
1437        assert_eq!(
1438            closest_known_rule_name("UNSUED-FILES"),
1439            Some("unused-files")
1440        );
1441    }
1442
1443    #[test]
1444    fn closest_known_rule_name_returns_none_for_exact_match() {
1445        assert_eq!(closest_known_rule_name("unused-files"), None);
1446    }
1447
1448    #[test]
1449    fn find_unknown_rule_keys_flags_typo() {
1450        let v = serde_json::json!({
1451            "unsued-files": "warn",
1452            "unused-exports": "off",
1453        });
1454        let unknown = find_unknown_rule_keys(&v, "rules");
1455        assert_eq!(unknown.len(), 1);
1456        assert_eq!(unknown[0].key, "unsued-files");
1457        assert_eq!(unknown[0].context, "rules");
1458        assert_eq!(unknown[0].suggestion, Some("unused-files"));
1459    }
1460
1461    #[test]
1462    fn find_unknown_rule_keys_passes_aliases() {
1463        let v = serde_json::json!({
1464            "unused-file": "warn",
1465            "circular-dependency": "off",
1466            "boundary-violations": "warn",
1467        });
1468        let unknown = find_unknown_rule_keys(&v, "rules");
1469        assert!(
1470            unknown.is_empty(),
1471            "documented aliases must not flag as unknown: {unknown:?}"
1472        );
1473    }
1474
1475    #[test]
1476    fn find_unknown_rule_keys_returns_multiple_typos() {
1477        let v = serde_json::json!({
1478            "unsued-files": "warn",
1479            "circular-dependnecy": "off",
1480        });
1481        let unknown = find_unknown_rule_keys(&v, "rules");
1482        assert_eq!(unknown.len(), 2);
1483    }
1484
1485    #[test]
1486    fn find_unknown_rule_keys_carries_context() {
1487        let v = serde_json::json!({ "unsued-files": "warn" });
1488        let unknown = find_unknown_rule_keys(&v, "overrides[2].rules");
1489        assert_eq!(unknown[0].context, "overrides[2].rules");
1490    }
1491
1492    #[test]
1493    fn find_unknown_rule_keys_empty_when_not_object() {
1494        let v = serde_json::json!(null);
1495        assert!(find_unknown_rule_keys(&v, "rules").is_empty());
1496
1497        let v = serde_json::json!([1, 2, 3]);
1498        assert!(find_unknown_rule_keys(&v, "rules").is_empty());
1499    }
1500
1501    #[test]
1502    fn find_unknown_rule_keys_no_suggestion_for_novel_name() {
1503        let v = serde_json::json!({ "totally-fabricated-rule": "warn" });
1504        let unknown = find_unknown_rule_keys(&v, "rules");
1505        assert_eq!(unknown.len(), 1);
1506        assert_eq!(unknown[0].suggestion, None);
1507    }
1508
1509    #[test]
1510    fn partial_rules_empty_json() {
1511        let partial: PartialRulesConfig = serde_json::from_str("{}").unwrap();
1512        assert!(partial.unused_files.is_none());
1513        assert!(partial.unused_exports.is_none());
1514        assert!(partial.unused_types.is_none());
1515        assert!(partial.unused_dependencies.is_none());
1516        assert!(partial.circular_dependencies.is_none());
1517        assert!(partial.boundary_violation.is_none());
1518        assert!(partial.coverage_gaps.is_none());
1519        assert!(partial.feature_flags.is_none());
1520        assert!(partial.stale_suppressions.is_none());
1521    }
1522
1523    #[test]
1524    fn partial_rules_subset_json() {
1525        let json = r#"{
1526            "unused-files": "warn",
1527            "circular-dependencies": "off"
1528        }"#;
1529        let partial: PartialRulesConfig = serde_json::from_str(json).unwrap();
1530        assert_eq!(partial.unused_files, Some(Severity::Warn));
1531        assert_eq!(partial.circular_dependencies, Some(Severity::Off));
1532        assert!(partial.unused_exports.is_none());
1533    }
1534
1535    #[test]
1536    fn partial_rules_deserialize_circular_dependency_alias() {
1537        let json = r#"{
1538            "circular-dependency": "warn"
1539        }"#;
1540        let partial: PartialRulesConfig = serde_json::from_str(json).unwrap();
1541        assert_eq!(partial.circular_dependencies, Some(Severity::Warn));
1542    }
1543
1544    #[test]
1545    fn partial_rules_all_fields_json() {
1546        let json = r#"{
1547            "unused-files": "error",
1548            "unused-exports": "warn",
1549            "unused-types": "off",
1550            "unused-dependencies": "error",
1551            "unused-dev-dependencies": "warn",
1552            "unused-optional-dependencies": "off",
1553            "unused-enum-members": "error",
1554            "unused-class-members": "warn",
1555            "unresolved-imports": "off",
1556            "unlisted-dependencies": "error",
1557            "duplicate-exports": "warn",
1558            "type-only-dependencies": "off",
1559            "test-only-dependencies": "error",
1560            "circular-dependencies": "warn",
1561            "boundary-violation": "off",
1562            "coverage-gaps": "warn",
1563            "feature-flags": "error",
1564            "stale-suppressions": "off"
1565        }"#;
1566        let partial: PartialRulesConfig = serde_json::from_str(json).unwrap();
1567        assert_eq!(partial.unused_files, Some(Severity::Error));
1568        assert_eq!(partial.unused_exports, Some(Severity::Warn));
1569        assert_eq!(partial.unused_types, Some(Severity::Off));
1570        assert_eq!(partial.unused_dependencies, Some(Severity::Error));
1571        assert_eq!(partial.unused_dev_dependencies, Some(Severity::Warn));
1572        assert_eq!(partial.unused_optional_dependencies, Some(Severity::Off));
1573        assert_eq!(partial.unused_enum_members, Some(Severity::Error));
1574        assert_eq!(partial.unused_class_members, Some(Severity::Warn));
1575        assert_eq!(partial.unresolved_imports, Some(Severity::Off));
1576        assert_eq!(partial.unlisted_dependencies, Some(Severity::Error));
1577        assert_eq!(partial.duplicate_exports, Some(Severity::Warn));
1578        assert_eq!(partial.type_only_dependencies, Some(Severity::Off));
1579        assert_eq!(partial.test_only_dependencies, Some(Severity::Error));
1580        assert_eq!(partial.circular_dependencies, Some(Severity::Warn));
1581        assert_eq!(partial.boundary_violation, Some(Severity::Off));
1582        assert_eq!(partial.coverage_gaps, Some(Severity::Warn));
1583        assert_eq!(partial.feature_flags, Some(Severity::Error));
1584        assert_eq!(partial.stale_suppressions, Some(Severity::Off));
1585    }
1586
1587    #[test]
1588    fn partial_rules_none_fields_not_serialized() {
1589        let partial = PartialRulesConfig::default();
1590        let json = serde_json::to_string(&partial).unwrap();
1591        assert_eq!(
1592            json, "{}",
1593            "all-None partial should serialize to empty object"
1594        );
1595    }
1596
1597    #[test]
1598    fn partial_rules_some_fields_serialized() {
1599        let partial = PartialRulesConfig {
1600            unused_files: Some(Severity::Warn),
1601            ..Default::default()
1602        };
1603        let json = serde_json::to_string(&partial).unwrap();
1604        assert!(json.contains("unused-files"));
1605        assert!(!json.contains("unused-exports"));
1606    }
1607
1608    #[test]
1609    fn severity_json_deserialization() {
1610        let error: Severity = serde_json::from_str(r#""error""#).unwrap();
1611        assert_eq!(error, Severity::Error);
1612
1613        let warn: Severity = serde_json::from_str(r#""warn""#).unwrap();
1614        assert_eq!(warn, Severity::Warn);
1615
1616        let off: Severity = serde_json::from_str(r#""off""#).unwrap();
1617        assert_eq!(off, Severity::Off);
1618    }
1619
1620    #[test]
1621    fn severity_invalid_json_value_rejected() {
1622        let result: Result<Severity, _> = serde_json::from_str(r#""critical""#);
1623        assert!(result.is_err());
1624    }
1625
1626    #[test]
1627    fn severity_default_is_error() {
1628        assert_eq!(Severity::default(), Severity::Error);
1629    }
1630
1631    #[test]
1632    fn rules_config_json_roundtrip() {
1633        let rules = RulesConfig {
1634            unused_files: Severity::Warn,
1635            unused_exports: Severity::Off,
1636            type_only_dependencies: Severity::Error,
1637            ..RulesConfig::default()
1638        };
1639        let json = serde_json::to_string(&rules).unwrap();
1640        let restored: RulesConfig = serde_json::from_str(&json).unwrap();
1641        assert_eq!(restored.unused_files, Severity::Warn);
1642        assert_eq!(restored.unused_exports, Severity::Off);
1643        assert_eq!(restored.type_only_dependencies, Severity::Error);
1644        assert_eq!(restored.unused_dependencies, Severity::Error); // default
1645    }
1646
1647    #[test]
1648    fn apply_partial_preserves_type_only_default() {
1649        let mut rules = RulesConfig::default();
1650        let partial = PartialRulesConfig {
1651            unused_files: Some(Severity::Off),
1652            ..Default::default()
1653        };
1654        rules.apply_partial(&partial);
1655        assert_eq!(rules.type_only_dependencies, Severity::Warn);
1656        assert_eq!(rules.test_only_dependencies, Severity::Warn);
1657    }
1658}