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