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