Skip to main content

fallow_config/config/
rules.rs

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