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