Skip to main content

fallow_config/config/
rules.rs

1use fallow_types::suppress::IssueKind;
2use schemars::JsonSchema;
3use serde::{Deserialize, Serialize};
4
5/// Severity level for rules.
6///
7/// Controls whether an issue type causes CI failure (`error`), is reported
8/// without failing (`warn`), or is suppressed entirely (`off`).
9#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, JsonSchema)]
10#[serde(rename_all = "lowercase")]
11pub enum Severity {
12    /// Report and fail CI (non-zero exit code).
13    #[default]
14    Error,
15    /// Report but don't fail CI.
16    Warn,
17    /// Don't detect or report.
18    Off,
19}
20
21impl Severity {
22    /// Default value for fields that should default to `Warn` instead of `Error`.
23    const fn default_warn() -> Self {
24        Self::Warn
25    }
26
27    /// Default value for fields that should default to `Off`.
28    const fn default_off() -> Self {
29        Self::Off
30    }
31}
32
33impl std::fmt::Display for Severity {
34    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
35        match self {
36            Self::Error => write!(f, "error"),
37            Self::Warn => write!(f, "warn"),
38            Self::Off => write!(f, "off"),
39        }
40    }
41}
42
43impl std::str::FromStr for Severity {
44    type Err = String;
45
46    fn from_str(s: &str) -> Result<Self, Self::Err> {
47        match s.to_lowercase().as_str() {
48            "error" => Ok(Self::Error),
49            "warn" | "warning" => Ok(Self::Warn),
50            "off" | "none" => Ok(Self::Off),
51            other => Err(format!(
52                "unknown severity: '{other}' (expected error, warn, or off)"
53            )),
54        }
55    }
56}
57
58/// Per-issue-type severity configuration.
59///
60/// Controls which issue types cause CI failure, are reported as warnings,
61/// or are suppressed entirely. Most fields default to `Severity::Error`.
62///
63/// Rule names use kebab-case in config files (e.g., `"unused-files": "error"`).
64#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize, JsonSchema)]
65#[serde(rename_all = "kebab-case")]
66pub struct RulesConfig {
67    #[serde(default, alias = "unused-file")]
68    pub unused_files: Severity,
69    #[serde(default, alias = "unused-export")]
70    pub unused_exports: Severity,
71    #[serde(default, alias = "unused-type")]
72    pub unused_types: Severity,
73    #[serde(default = "Severity::default_off", alias = "private-type-leak")]
74    pub private_type_leaks: Severity,
75    #[serde(default, alias = "unused-dependency")]
76    pub unused_dependencies: Severity,
77    #[serde(default = "Severity::default_warn", alias = "unused-dev-dependency")]
78    pub unused_dev_dependencies: Severity,
79    #[serde(
80        default = "Severity::default_warn",
81        alias = "unused-optional-dependency"
82    )]
83    pub unused_optional_dependencies: Severity,
84    #[serde(default, alias = "unused-enum-member")]
85    pub unused_enum_members: Severity,
86    #[serde(default, alias = "unused-class-member")]
87    pub unused_class_members: Severity,
88    /// Store members (Pinia `state` / `getters` / `actions` key, or a
89    /// setup-store returned key) declared but never accessed by any consumer
90    /// project-wide. Defaults to `warn`, not `error` like the closed-set
91    /// class/enum member rules: a store has an OPEN declaration surface
92    /// (plugins, `$onAction`, dynamic dispatch) so analyzer confidence is
93    /// genuinely lower; warn encodes that without failing CI. Promotable to
94    /// `error` once validated on a codebase.
95    #[serde(default = "Severity::default_warn", alias = "unused-store-member")]
96    pub unused_store_members: Severity,
97    /// Vue `inject(KEY)` / Svelte `getContext(KEY)` whose symbol KEY is
98    /// `provide`/`setContext`'d nowhere in the project (the
99    /// injected-never-provided dead-half). Defaults to `warn`, not `error`:
100    /// a DI key has an open provide surface (plugins, app-level provide) so
101    /// analyzer confidence is lower; warn encodes that without failing CI.
102    #[serde(default = "Severity::default_warn", alias = "unprovided-inject")]
103    pub unprovided_injects: Severity,
104    /// Vue/Svelte single-file component reachable in the module graph but
105    /// rendered nowhere in the project (the imported-but-never-rendered
106    /// dead-half). Defaults to `warn`, not `error`: a component can be rendered
107    /// reflectively (dynamic `<component :is>`), so analyzer confidence is
108    /// lower; warn encodes that without failing CI.
109    #[serde(default = "Severity::default_warn", alias = "unrendered-component")]
110    pub unrendered_components: Severity,
111    /// Vue `<script setup>` `defineProps`, Svelte 5 `$props()`, or React
112    /// declared prop referenced nowhere inside its own component. The
113    /// single-component dead-input direction. Defaults to `warn`, not `error`: a
114    /// prop can be part of a deliberately-stable public component API, so
115    /// analyzer confidence is lower; warn encodes that without failing CI.
116    #[serde(default = "Severity::default_warn", 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 = "Severity::default_warn", 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 = "Severity::default_warn", 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 = "Severity::default_warn", 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 = "Severity::default_warn", 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    /// A CSS / CSS-in-JS design-token DRIFT finding (a hardcoded value where a
190    /// design token exists, e.g. a Tailwind arbitrary value). A styling-domain
191    /// advisory surfaced in `fallow audit`; defaults to `warn` (verdict-neutral).
192    /// Set to `error` to gate CI on styling drift, or `off` to silence.
193    #[serde(default = "Severity::default_warn", alias = "css-token-drift")]
194    pub css_token_drift: Severity,
195    /// A CSS / CSS-in-JS DUPLICATE declaration block (copy-pasted rule body).
196    /// A styling-domain advisory surfaced in `fallow audit`; defaults to `warn`
197    /// (verdict-neutral). Set to `error` to gate, or `off` to silence.
198    #[serde(default = "Severity::default_warn", alias = "css-duplicate-block")]
199    pub css_duplicate_block: Severity,
200    /// CSS selector / nesting / important-density complexity. A styling-domain
201    /// advisory surfaced in `fallow audit`; defaults to `warn`
202    /// (verdict-neutral). Set to `error` to gate, or `off` to silence.
203    #[serde(default = "Severity::default_warn", alias = "css-selector-complexity")]
204    pub css_selector_complexity: Severity,
205    /// CSS dead surface, such as unused scoped SFC classes. A styling-domain
206    /// advisory surfaced in `fallow audit`; defaults to `warn`
207    /// (verdict-neutral). Set to `error` to gate, or `off` to silence.
208    #[serde(default = "Severity::default_warn", alias = "css-dead-surface")]
209    pub css_dead_surface: Severity,
210    /// CSS broken references, such as missing classes or keyframes. A
211    /// styling-domain advisory surfaced by deep CSS audit mode; defaults
212    /// to `warn` (verdict-neutral). Set to `error` to gate, or `off` to silence.
213    #[serde(default = "Severity::default_warn", alias = "css-broken-reference")]
214    pub css_broken_reference: Severity,
215    #[serde(default, alias = "unresolved-import")]
216    pub unresolved_imports: Severity,
217    #[serde(default, alias = "unlisted-dependency")]
218    pub unlisted_dependencies: Severity,
219    #[serde(default, alias = "duplicate-export")]
220    pub duplicate_exports: Severity,
221    #[serde(default = "Severity::default_warn", alias = "type-only-dependency")]
222    pub type_only_dependencies: Severity,
223    #[serde(default = "Severity::default_warn", alias = "test-only-dependency")]
224    pub test_only_dependencies: Severity,
225    #[serde(
226        default = "Severity::default_warn",
227        alias = "dev-dependency-in-production"
228    )]
229    pub dev_dependencies_in_production: Severity,
230    #[serde(default, alias = "circular-dependency")]
231    pub circular_dependencies: Severity,
232    #[serde(
233        default = "Severity::default_warn",
234        alias = "re-export-cycles",
235        alias = "reexport-cycle",
236        alias = "reexport-cycles"
237    )]
238    pub re_export_cycle: Severity,
239    #[serde(default, alias = "boundary-violations")]
240    pub boundary_violation: Severity,
241    #[serde(default = "Severity::default_off", alias = "coverage-gap")]
242    pub coverage_gaps: Severity,
243    #[serde(default = "Severity::default_off", alias = "feature-flag")]
244    pub feature_flags: Severity,
245    #[serde(default = "Severity::default_warn", alias = "stale-suppression")]
246    pub stale_suppressions: Severity,
247    /// Opt-in suppression hygiene rule: when enabled, every `fallow-ignore-*`
248    /// comment and `@expected-unused` tag must carry a `-- <reason>` suffix.
249    #[serde(default = "Severity::default_off", alias = "suppression-reason")]
250    pub require_suppression_reason: Severity,
251    #[serde(default = "Severity::default_warn", alias = "unused-catalog-entry")]
252    pub unused_catalog_entries: Severity,
253    #[serde(default = "Severity::default_warn", alias = "empty-catalog-group")]
254    pub empty_catalog_groups: Severity,
255    #[serde(default, alias = "unresolved-catalog-reference")]
256    pub unresolved_catalog_references: Severity,
257    #[serde(
258        default = "Severity::default_warn",
259        alias = "unused-dependency-override"
260    )]
261    pub unused_dependency_overrides: Severity,
262    #[serde(default, alias = "misconfigured-dependency-override")]
263    pub misconfigured_dependency_overrides: Severity,
264    /// Opt-in (default off): a `"use client"` file that transitively imports a
265    /// module reading a non-public `process.env` secret. Surfaced only by
266    /// `fallow security`; never under bare `fallow` or the `audit` gate.
267    #[serde(default = "Severity::default_off")]
268    pub security_client_server_leak: Severity,
269    /// Opt-in (default off): a syntactic tainted-sink candidate matched against
270    /// the data-driven catalogue (`security_matchers.toml`). ONE knob gates ALL
271    /// catalogue categories. Surfaced only by `fallow security`; never under
272    /// bare `fallow` or the `audit` gate.
273    #[serde(default = "Severity::default_off")]
274    pub security_sink: Severity,
275    /// Master severity for rule-pack findings (`rulePacks` config). Defaults
276    /// to `warn` so enabling a brand-new policy pack never hard-fails CI on
277    /// its first run; individual pack rules opt up via `"severity": "error"`.
278    /// `off` is a kill switch that disables the whole evaluator (per-rule
279    /// severity cannot resurrect it).
280    #[serde(default = "Severity::default_warn", alias = "policy-violations")]
281    pub policy_violation: Severity,
282    /// A `"use client"` file that exports a Next.js server-only /
283    /// route-segment config name (e.g. `metadata`, `revalidate`, `GET`).
284    /// Next.js rejects this at build time; fallow catches it statically.
285    /// Defaults to `warn`.
286    #[serde(default = "Severity::default_warn", alias = "invalid-client-exports")]
287    pub invalid_client_export: Severity,
288    /// A barrel file that re-exports BOTH a `"use client"` origin module AND a
289    /// server-only origin module. Importing one name from such a barrel drags
290    /// the other's directive context across the React Server Components
291    /// boundary (the Next.js App Router footgun). Defaults to `warn`.
292    #[serde(
293        default = "Severity::default_warn",
294        alias = "mixed-client-server-barrels"
295    )]
296    pub mixed_client_server_barrel: Severity,
297    /// A `"use client"` / `"use server"` directive written as an expression
298    /// statement after a non-directive statement (an import, a const), so the
299    /// RSC bundler parses it as an ordinary string and silently ignores it.
300    /// The intended client/server boundary never takes effect. Defaults to
301    /// `warn`.
302    #[serde(default = "Severity::default_warn", alias = "misplaced-directives")]
303    pub misplaced_directive: Severity,
304    /// Two or more Next.js App Router route files that resolve to the same URL
305    /// within one app-root. Next.js fails the build ("You cannot have two
306    /// parallel pages that resolve to the same path"); fallow catches it
307    /// statically and names every colliding file. Defaults to `error`: the
308    /// project already fails `next build`, so flagging it as an error aligns
309    /// fallow's exit code with the build it mirrors.
310    #[serde(default, alias = "route-collisions")]
311    pub route_collision: Severity,
312    /// Sibling Next.js dynamic route segments at one tree position using
313    /// different param spellings (`[id]` vs `[slug]`). Next.js throws "You
314    /// cannot use different slug names for the same dynamic path" at dev and
315    /// production runtime when the position is hit; `next build` does NOT catch
316    /// it (the build succeeds), so CI passes while the route crashes on its
317    /// first request. fallow catches it statically. Defaults to `error`: the
318    /// route is a deterministic runtime crash on first request, so failing CI
319    /// is the honest signal even though `next build` stays green (this is the
320    /// "error-runtime" severity tier, shared with `route-collision`).
321    #[serde(default, alias = "dynamic-segment-name-conflicts")]
322    pub dynamic_segment_name_conflict: Severity,
323}
324
325impl Default for RulesConfig {
326    fn default() -> Self {
327        Self {
328            unused_files: Severity::Error,
329            unused_exports: Severity::Error,
330            unused_types: Severity::Error,
331            private_type_leaks: Severity::Off,
332            unused_dependencies: Severity::Error,
333            unused_dev_dependencies: Severity::Warn,
334            unused_optional_dependencies: Severity::Warn,
335            unused_enum_members: Severity::Error,
336            unused_class_members: Severity::Error,
337            unused_store_members: Severity::Warn,
338            unprovided_injects: Severity::Warn,
339            unrendered_components: Severity::Warn,
340            unused_component_props: Severity::Warn,
341            unused_component_emits: Severity::Warn,
342            unused_component_inputs: Severity::Warn,
343            unused_component_outputs: Severity::Warn,
344            unused_svelte_events: Severity::Warn,
345            unused_server_actions: Severity::Warn,
346            unused_load_data_keys: Severity::Warn,
347            prop_drilling: Severity::Off,
348            thin_wrapper: Severity::Off,
349            duplicate_prop_shape: Severity::Off,
350            css_token_drift: Severity::Warn,
351            css_duplicate_block: Severity::Warn,
352            css_selector_complexity: Severity::Warn,
353            css_dead_surface: Severity::Warn,
354            css_broken_reference: Severity::Warn,
355            unresolved_imports: Severity::Error,
356            unlisted_dependencies: Severity::Error,
357            duplicate_exports: Severity::Error,
358            type_only_dependencies: Severity::Warn,
359            test_only_dependencies: Severity::Warn,
360            dev_dependencies_in_production: Severity::Warn,
361            circular_dependencies: Severity::Error,
362            re_export_cycle: Severity::Warn,
363            boundary_violation: Severity::Error,
364            coverage_gaps: Severity::Off,
365            feature_flags: Severity::Off,
366            stale_suppressions: Severity::Warn,
367            require_suppression_reason: Severity::Off,
368            unused_catalog_entries: Severity::Warn,
369            empty_catalog_groups: Severity::Warn,
370            unresolved_catalog_references: Severity::Error,
371            unused_dependency_overrides: Severity::Warn,
372            misconfigured_dependency_overrides: Severity::Error,
373            security_client_server_leak: Severity::Off,
374            security_sink: Severity::Off,
375            policy_violation: Severity::Warn,
376            invalid_client_export: Severity::Warn,
377            mixed_client_server_barrel: Severity::Warn,
378            misplaced_directive: Severity::Warn,
379            route_collision: Severity::Error,
380            dynamic_segment_name_conflict: Severity::Error,
381        }
382    }
383}
384
385macro_rules! apply_partial_rules {
386    ($target:expr, $partial:expr, [$($field:ident),+ $(,)?]) => {
387        $(
388            if let Some(severity) = $partial.$field {
389                $target.$field = severity;
390            }
391        )+
392    };
393}
394
395impl RulesConfig {
396    /// Map an [`IssueKind`] to its configured [`Severity`] in this config.
397    ///
398    /// Single source of truth for the kind-to-severity mapping, shared by core
399    /// suppression gating (`severity_for_kind`) and the agent capability
400    /// manifest (`fallow schema`'s per-rule `default_severity`). Exhaustive by
401    /// design: a new `IssueKind` variant is a compile error here, forcing the
402    /// implementer to decide which `RulesConfig` field (if any) gates emission.
403    /// Kinds with no matching field (`Complexity`, `CodeDuplication`, gated by
404    /// their own command rather than a rule) return the non-`Off`
405    /// `Severity::Error`; in core these short-circuit earlier via
406    /// `NON_CORE_KINDS` so the value is unobservable there.
407    #[must_use]
408    pub const fn severity_for_kind(&self, kind: IssueKind) -> Severity {
409        match kind {
410            IssueKind::UnusedFile => self.unused_files,
411            IssueKind::UnusedExport => self.unused_exports,
412            IssueKind::UnusedType => self.unused_types,
413            IssueKind::PrivateTypeLeak => self.private_type_leaks,
414            IssueKind::UnusedDependency => self.unused_dependencies,
415            IssueKind::UnusedDevDependency => self.unused_dev_dependencies,
416            IssueKind::UnusedEnumMember => self.unused_enum_members,
417            IssueKind::UnusedClassMember => self.unused_class_members,
418            IssueKind::UnusedStoreMember => self.unused_store_members,
419            IssueKind::UnprovidedInject => self.unprovided_injects,
420            IssueKind::UnresolvedImport => self.unresolved_imports,
421            IssueKind::UnlistedDependency => self.unlisted_dependencies,
422            IssueKind::DuplicateExport => self.duplicate_exports,
423            IssueKind::CircularDependency => self.circular_dependencies,
424            IssueKind::ReExportCycle => self.re_export_cycle,
425            IssueKind::TypeOnlyDependency => self.type_only_dependencies,
426            IssueKind::TestOnlyDependency => self.test_only_dependencies,
427            IssueKind::DevDependencyInProduction => self.dev_dependencies_in_production,
428            IssueKind::BoundaryViolation => self.boundary_violation,
429            IssueKind::CoverageGaps => self.coverage_gaps,
430            IssueKind::FeatureFlag => self.feature_flags,
431            IssueKind::StaleSuppression => self.stale_suppressions,
432            IssueKind::PnpmCatalogEntry => self.unused_catalog_entries,
433            IssueKind::EmptyCatalogGroup => self.empty_catalog_groups,
434            IssueKind::UnresolvedCatalogReference => self.unresolved_catalog_references,
435            IssueKind::UnusedDependencyOverride => self.unused_dependency_overrides,
436            IssueKind::MisconfiguredDependencyOverride => self.misconfigured_dependency_overrides,
437            IssueKind::SecurityClientServerLeak => self.security_client_server_leak,
438            IssueKind::SecuritySink => self.security_sink,
439            IssueKind::PolicyViolation => self.policy_violation,
440            IssueKind::InvalidClientExport => self.invalid_client_export,
441            IssueKind::MixedClientServerBarrel => self.mixed_client_server_barrel,
442            IssueKind::MisplacedDirective => self.misplaced_directive,
443            IssueKind::RouteCollision => self.route_collision,
444            IssueKind::DynamicSegmentNameConflict => self.dynamic_segment_name_conflict,
445            IssueKind::UnrenderedComponent => self.unrendered_components,
446            IssueKind::UnusedComponentProp => self.unused_component_props,
447            IssueKind::UnusedComponentEmit => self.unused_component_emits,
448            IssueKind::UnusedComponentInput => self.unused_component_inputs,
449            IssueKind::UnusedComponentOutput => self.unused_component_outputs,
450            IssueKind::UnusedSvelteEvent => self.unused_svelte_events,
451            IssueKind::UnusedServerAction => self.unused_server_actions,
452            IssueKind::UnusedLoadDataKey => self.unused_load_data_keys,
453            IssueKind::PropDrilling => self.prop_drilling,
454            IssueKind::ThinWrapper => self.thin_wrapper,
455            IssueKind::DuplicatePropShape => self.duplicate_prop_shape,
456            IssueKind::CssTokenDrift => self.css_token_drift,
457            IssueKind::CssDuplicateBlock => self.css_duplicate_block,
458            IssueKind::CssSelectorComplexity => self.css_selector_complexity,
459            IssueKind::CssDeadSurface => self.css_dead_surface,
460            IssueKind::CssBrokenReference => self.css_broken_reference,
461            IssueKind::Complexity | IssueKind::CodeDuplication => Severity::Error,
462        }
463    }
464
465    /// Apply a partial rules config on top. Only `Some` fields override.
466    pub const fn apply_partial(&mut self, partial: &PartialRulesConfig) {
467        apply_partial_rules!(
468            self,
469            partial,
470            [
471                unused_files,
472                unused_exports,
473                unused_types,
474                private_type_leaks,
475                unused_dependencies,
476                unused_dev_dependencies,
477                unused_optional_dependencies,
478            ]
479        );
480        apply_partial_rules!(
481            self,
482            partial,
483            [
484                unused_enum_members,
485                unused_class_members,
486                unused_store_members,
487                unprovided_injects,
488                unrendered_components,
489                unused_component_props,
490                unused_component_emits,
491                unused_component_inputs,
492                unused_component_outputs,
493                unused_svelte_events,
494                unused_server_actions,
495                unused_load_data_keys,
496                prop_drilling,
497                thin_wrapper,
498                duplicate_prop_shape,
499                css_token_drift,
500                css_duplicate_block,
501                css_selector_complexity,
502                css_dead_surface,
503                css_broken_reference,
504            ]
505        );
506        apply_partial_rules!(
507            self,
508            partial,
509            [
510                unresolved_imports,
511                unlisted_dependencies,
512                duplicate_exports,
513                type_only_dependencies,
514                test_only_dependencies,
515                dev_dependencies_in_production,
516                circular_dependencies,
517                re_export_cycle,
518                boundary_violation,
519            ]
520        );
521        apply_partial_rules!(
522            self,
523            partial,
524            [
525                coverage_gaps,
526                feature_flags,
527                stale_suppressions,
528                require_suppression_reason,
529                unused_catalog_entries,
530                empty_catalog_groups,
531                unresolved_catalog_references,
532                unused_dependency_overrides,
533                misconfigured_dependency_overrides,
534            ]
535        );
536        apply_partial_rules!(
537            self,
538            partial,
539            [
540                security_client_server_leak,
541                security_sink,
542                policy_violation,
543                invalid_client_export,
544                mixed_client_server_barrel,
545                misplaced_directive,
546                route_collision,
547                dynamic_segment_name_conflict,
548            ]
549        );
550    }
551}
552
553/// The default [`Severity`] for an [`IssueKind`] under zero config.
554///
555/// Equivalent to `RulesConfig::default().severity_for_kind(kind)`, exposed as a
556/// free function so the agent capability manifest (`fallow schema`) can publish
557/// each rule's out-of-the-box severity without threading a config through. There
558/// is exactly ONE kind-to-severity table (`RulesConfig::severity_for_kind`); this
559/// reuses it rather than duplicating the mapping.
560#[must_use]
561pub fn default_severity_for_kind(kind: IssueKind) -> Severity {
562    RulesConfig::default().severity_for_kind(kind)
563}
564
565/// Whether a rule is opt-in: its default severity is `Off`, so it detects and
566/// reports nothing until a user explicitly enables it.
567///
568/// This is the single most load-bearing manifest signal for agents: enabling an
569/// opt-in rule blindly (`private-type-leak`, `security-*`) is the most common way
570/// to flood a repo with findings, so the manifest exposes it as a first-class bool.
571#[must_use]
572pub fn is_opt_in_kind(kind: IssueKind) -> bool {
573    matches!(default_severity_for_kind(kind), Severity::Off)
574}
575
576/// Partial per-issue-type severity for overrides. All fields optional.
577#[derive(Debug, Default, Clone, Deserialize, Serialize, JsonSchema)]
578#[serde(rename_all = "kebab-case")]
579pub struct PartialRulesConfig {
580    #[serde(
581        default,
582        alias = "unused-file",
583        skip_serializing_if = "Option::is_none"
584    )]
585    pub unused_files: Option<Severity>,
586    #[serde(
587        default,
588        alias = "unused-export",
589        skip_serializing_if = "Option::is_none"
590    )]
591    pub unused_exports: Option<Severity>,
592    #[serde(
593        default,
594        alias = "unused-type",
595        skip_serializing_if = "Option::is_none"
596    )]
597    pub unused_types: Option<Severity>,
598    #[serde(
599        default,
600        alias = "private-type-leak",
601        skip_serializing_if = "Option::is_none"
602    )]
603    pub private_type_leaks: Option<Severity>,
604    #[serde(
605        default,
606        alias = "unused-dependency",
607        skip_serializing_if = "Option::is_none"
608    )]
609    pub unused_dependencies: Option<Severity>,
610    #[serde(
611        default,
612        alias = "unused-dev-dependency",
613        skip_serializing_if = "Option::is_none"
614    )]
615    pub unused_dev_dependencies: Option<Severity>,
616    #[serde(
617        default,
618        alias = "unused-optional-dependency",
619        skip_serializing_if = "Option::is_none"
620    )]
621    pub unused_optional_dependencies: Option<Severity>,
622    #[serde(
623        default,
624        alias = "unused-enum-member",
625        skip_serializing_if = "Option::is_none"
626    )]
627    pub unused_enum_members: Option<Severity>,
628    #[serde(
629        default,
630        alias = "unused-class-member",
631        skip_serializing_if = "Option::is_none"
632    )]
633    pub unused_class_members: Option<Severity>,
634    #[serde(
635        default,
636        alias = "unused-store-member",
637        skip_serializing_if = "Option::is_none"
638    )]
639    pub unused_store_members: Option<Severity>,
640    #[serde(
641        default,
642        alias = "unprovided-inject",
643        skip_serializing_if = "Option::is_none"
644    )]
645    pub unprovided_injects: Option<Severity>,
646    #[serde(
647        default,
648        alias = "unrendered-component",
649        skip_serializing_if = "Option::is_none"
650    )]
651    pub unrendered_components: Option<Severity>,
652    #[serde(
653        default,
654        alias = "unused-component-prop",
655        skip_serializing_if = "Option::is_none"
656    )]
657    pub unused_component_props: Option<Severity>,
658    #[serde(
659        default,
660        alias = "unused-component-emit",
661        skip_serializing_if = "Option::is_none"
662    )]
663    pub unused_component_emits: Option<Severity>,
664    #[serde(
665        default,
666        alias = "unused-component-input",
667        skip_serializing_if = "Option::is_none"
668    )]
669    pub unused_component_inputs: Option<Severity>,
670    #[serde(
671        default,
672        alias = "unused-component-output",
673        skip_serializing_if = "Option::is_none"
674    )]
675    pub unused_component_outputs: Option<Severity>,
676    #[serde(
677        default,
678        alias = "unused-svelte-event",
679        skip_serializing_if = "Option::is_none"
680    )]
681    pub unused_svelte_events: Option<Severity>,
682    #[serde(
683        default,
684        alias = "unused-server-action",
685        skip_serializing_if = "Option::is_none"
686    )]
687    pub unused_server_actions: Option<Severity>,
688    #[serde(
689        default,
690        alias = "unused-load-data-key",
691        skip_serializing_if = "Option::is_none"
692    )]
693    pub unused_load_data_keys: Option<Severity>,
694    #[serde(
695        default,
696        alias = "prop-drilling",
697        skip_serializing_if = "Option::is_none"
698    )]
699    pub prop_drilling: Option<Severity>,
700    #[serde(
701        default,
702        alias = "thin-wrapper",
703        skip_serializing_if = "Option::is_none"
704    )]
705    pub thin_wrapper: Option<Severity>,
706    #[serde(
707        default,
708        alias = "duplicate-prop-shape",
709        skip_serializing_if = "Option::is_none"
710    )]
711    pub duplicate_prop_shape: Option<Severity>,
712    #[serde(
713        default,
714        alias = "css-token-drift",
715        skip_serializing_if = "Option::is_none"
716    )]
717    pub css_token_drift: Option<Severity>,
718    #[serde(
719        default,
720        alias = "css-duplicate-block",
721        skip_serializing_if = "Option::is_none"
722    )]
723    pub css_duplicate_block: Option<Severity>,
724    #[serde(
725        default,
726        alias = "css-selector-complexity",
727        skip_serializing_if = "Option::is_none"
728    )]
729    pub css_selector_complexity: Option<Severity>,
730    #[serde(
731        default,
732        alias = "css-dead-surface",
733        skip_serializing_if = "Option::is_none"
734    )]
735    pub css_dead_surface: Option<Severity>,
736    #[serde(
737        default,
738        alias = "css-broken-reference",
739        skip_serializing_if = "Option::is_none"
740    )]
741    pub css_broken_reference: Option<Severity>,
742    #[serde(
743        default,
744        alias = "unresolved-import",
745        skip_serializing_if = "Option::is_none"
746    )]
747    pub unresolved_imports: Option<Severity>,
748    #[serde(
749        default,
750        alias = "unlisted-dependency",
751        skip_serializing_if = "Option::is_none"
752    )]
753    pub unlisted_dependencies: Option<Severity>,
754    #[serde(
755        default,
756        alias = "duplicate-export",
757        skip_serializing_if = "Option::is_none"
758    )]
759    pub duplicate_exports: Option<Severity>,
760    #[serde(
761        default,
762        alias = "type-only-dependency",
763        skip_serializing_if = "Option::is_none"
764    )]
765    pub type_only_dependencies: Option<Severity>,
766    #[serde(
767        default,
768        alias = "test-only-dependency",
769        skip_serializing_if = "Option::is_none"
770    )]
771    pub test_only_dependencies: Option<Severity>,
772    #[serde(
773        default,
774        alias = "dev-dependency-in-production",
775        skip_serializing_if = "Option::is_none"
776    )]
777    pub dev_dependencies_in_production: Option<Severity>,
778    #[serde(
779        default,
780        alias = "circular-dependency",
781        skip_serializing_if = "Option::is_none"
782    )]
783    pub circular_dependencies: Option<Severity>,
784    #[serde(
785        default,
786        alias = "re-export-cycles",
787        alias = "reexport-cycle",
788        alias = "reexport-cycles",
789        skip_serializing_if = "Option::is_none"
790    )]
791    pub re_export_cycle: Option<Severity>,
792    #[serde(
793        default,
794        alias = "boundary-violations",
795        skip_serializing_if = "Option::is_none"
796    )]
797    pub boundary_violation: Option<Severity>,
798    #[serde(
799        default,
800        alias = "coverage-gap",
801        skip_serializing_if = "Option::is_none"
802    )]
803    pub coverage_gaps: Option<Severity>,
804    #[serde(
805        default,
806        alias = "feature-flag",
807        skip_serializing_if = "Option::is_none"
808    )]
809    pub feature_flags: Option<Severity>,
810    #[serde(
811        default,
812        alias = "stale-suppression",
813        skip_serializing_if = "Option::is_none"
814    )]
815    pub stale_suppressions: Option<Severity>,
816    #[serde(
817        default,
818        alias = "suppression-reason",
819        skip_serializing_if = "Option::is_none"
820    )]
821    pub require_suppression_reason: Option<Severity>,
822    #[serde(
823        default,
824        alias = "unused-catalog-entry",
825        skip_serializing_if = "Option::is_none"
826    )]
827    pub unused_catalog_entries: Option<Severity>,
828    #[serde(
829        default,
830        alias = "empty-catalog-group",
831        skip_serializing_if = "Option::is_none"
832    )]
833    pub empty_catalog_groups: Option<Severity>,
834    #[serde(
835        default,
836        alias = "unresolved-catalog-reference",
837        skip_serializing_if = "Option::is_none"
838    )]
839    pub unresolved_catalog_references: Option<Severity>,
840    #[serde(
841        default,
842        alias = "unused-dependency-override",
843        skip_serializing_if = "Option::is_none"
844    )]
845    pub unused_dependency_overrides: Option<Severity>,
846    #[serde(
847        default,
848        alias = "misconfigured-dependency-override",
849        skip_serializing_if = "Option::is_none"
850    )]
851    pub misconfigured_dependency_overrides: Option<Severity>,
852    #[serde(default, skip_serializing_if = "Option::is_none")]
853    pub security_client_server_leak: Option<Severity>,
854    #[serde(default, skip_serializing_if = "Option::is_none")]
855    pub security_sink: Option<Severity>,
856    #[serde(
857        default,
858        alias = "policy-violations",
859        skip_serializing_if = "Option::is_none"
860    )]
861    pub policy_violation: Option<Severity>,
862    #[serde(
863        default,
864        alias = "invalid-client-exports",
865        skip_serializing_if = "Option::is_none"
866    )]
867    pub invalid_client_export: Option<Severity>,
868    #[serde(
869        default,
870        alias = "mixed-client-server-barrels",
871        skip_serializing_if = "Option::is_none"
872    )]
873    pub mixed_client_server_barrel: Option<Severity>,
874    #[serde(
875        default,
876        alias = "misplaced-directives",
877        skip_serializing_if = "Option::is_none"
878    )]
879    pub misplaced_directive: Option<Severity>,
880    #[serde(
881        default,
882        alias = "route-collisions",
883        skip_serializing_if = "Option::is_none"
884    )]
885    pub route_collision: Option<Severity>,
886    #[serde(
887        default,
888        alias = "dynamic-segment-name-conflicts",
889        skip_serializing_if = "Option::is_none"
890    )]
891    pub dynamic_segment_name_conflict: Option<Severity>,
892}
893
894/// Every rule name accepted by `RulesConfig` deserialization, in kebab-case.
895///
896/// Includes both the canonical name produced by `#[serde(rename_all = "kebab-case")]`
897/// and every `#[serde(alias = ...)]` value. Used by
898/// `find_unknown_rule_keys` to detect typos in user-supplied configs and
899/// emit a `tracing::warn!` suggestion at config load time.
900///
901/// Keep in sync with the `#[serde]` attributes on `RulesConfig` and
902/// `PartialRulesConfig`; the `known_rule_names_count_matches_struct` test
903/// fails when the lists drift.
904pub const KNOWN_RULE_NAMES: &[&str] = &[
905    "unused-files",
906    "unused-exports",
907    "unused-types",
908    "private-type-leaks",
909    "unused-dependencies",
910    "unused-dev-dependencies",
911    "unused-optional-dependencies",
912    "unused-enum-members",
913    "unused-class-members",
914    "unused-store-members",
915    "unprovided-injects",
916    "unrendered-components",
917    "unused-component-props",
918    "unused-component-emits",
919    "unused-component-inputs",
920    "unused-component-outputs",
921    "unused-svelte-events",
922    "unused-server-actions",
923    "unused-load-data-keys",
924    "prop-drilling",
925    "thin-wrapper",
926    "duplicate-prop-shape",
927    "css-token-drift",
928    "css-duplicate-block",
929    "css-selector-complexity",
930    "css-dead-surface",
931    "css-broken-reference",
932    "unresolved-imports",
933    "unlisted-dependencies",
934    "duplicate-exports",
935    "type-only-dependencies",
936    "test-only-dependencies",
937    "dev-dependencies-in-production",
938    "circular-dependencies",
939    "re-export-cycle",
940    "boundary-violation",
941    "coverage-gaps",
942    "feature-flags",
943    "stale-suppressions",
944    "require-suppression-reason",
945    "suppression-reason",
946    "unused-catalog-entries",
947    "empty-catalog-groups",
948    "unresolved-catalog-references",
949    "unused-dependency-overrides",
950    "misconfigured-dependency-overrides",
951    "security-client-server-leak",
952    "security-sink",
953    "policy-violation",
954    "policy-violations",
955    "invalid-client-export",
956    "mixed-client-server-barrel",
957    "misplaced-directive",
958    "route-collision",
959    "dynamic-segment-name-conflict",
960    "unused-file",
961    "unused-export",
962    "unused-type",
963    "private-type-leak",
964    "unused-dependency",
965    "unused-dev-dependency",
966    "unused-optional-dependency",
967    "unused-enum-member",
968    "unused-class-member",
969    "unused-store-member",
970    "unprovided-inject",
971    "unrendered-component",
972    "unused-component-prop",
973    "unused-component-emit",
974    "unused-component-input",
975    "unused-component-output",
976    "unused-svelte-event",
977    "unused-server-action",
978    "unused-load-data-key",
979    "unresolved-import",
980    "unlisted-dependency",
981    "duplicate-export",
982    "type-only-dependency",
983    "test-only-dependency",
984    "dev-dependency-in-production",
985    "circular-dependency",
986    "re-export-cycles",
987    "reexport-cycle",
988    "reexport-cycles",
989    "boundary-violations",
990    "coverage-gap",
991    "feature-flag",
992    "stale-suppression",
993    "unused-catalog-entry",
994    "empty-catalog-group",
995    "unresolved-catalog-reference",
996    "unused-dependency-override",
997    "misconfigured-dependency-override",
998    "invalid-client-exports",
999    "mixed-client-server-barrels",
1000    "misplaced-directives",
1001    "route-collisions",
1002    "dynamic-segment-name-conflicts",
1003];
1004
1005/// Find the closest known rule name to `input` when it is plausibly a typo.
1006///
1007/// Thin wrapper over [`crate::levenshtein::closest_match`] that scopes the
1008/// candidate set to [`KNOWN_RULE_NAMES`] and returns a `'static` reference so
1009/// the suggestion can be embedded in tracing warnings without allocation.
1010#[must_use]
1011pub fn closest_known_rule_name(input: &str) -> Option<&'static str> {
1012    let input_lower = input.to_ascii_lowercase();
1013    let candidates = KNOWN_RULE_NAMES.iter().copied();
1014    let suggestion = crate::levenshtein::closest_match(&input_lower, candidates)?;
1015    KNOWN_RULE_NAMES.iter().copied().find(|&c| c == suggestion)
1016}
1017
1018/// An unknown key found inside a `rules` (or `overrides[].rules`) object.
1019///
1020/// Surfaced by [`find_unknown_rule_keys`] so the caller (config loader) can
1021/// emit one `tracing::warn!` per entry without coupling the detection logic
1022/// to a tracing subscriber.
1023#[derive(Debug, Clone, PartialEq, Eq)]
1024pub struct UnknownRuleKey {
1025    /// Human-readable source label, e.g. `"rules"` or `"overrides[2].rules"`.
1026    pub context: String,
1027    /// The unknown key as it appeared in the user's config.
1028    pub key: String,
1029    /// Closest known rule name when one is within plausible-typo distance.
1030    pub suggestion: Option<&'static str>,
1031}
1032
1033/// Collect every unknown key from a `rules`-shaped JSON object.
1034///
1035/// Returns an empty `Vec` when `value` is not an object or every key is
1036/// recognized (canonical kebab-case or a documented alias). Called from
1037/// [`crate::config::parsing`] after `extends` merge and before
1038/// `serde_json::from_value::<FallowConfig>`, so the warning lists keys from
1039/// the final merged config rather than per-file partials.
1040#[must_use]
1041pub fn find_unknown_rule_keys(value: &serde_json::Value, context: &str) -> Vec<UnknownRuleKey> {
1042    let Some(map) = value.as_object() else {
1043        return Vec::new();
1044    };
1045
1046    map.keys()
1047        .filter(|key| !KNOWN_RULE_NAMES.contains(&key.as_str()))
1048        .map(|key| UnknownRuleKey {
1049            context: context.to_owned(),
1050            key: key.clone(),
1051            suggestion: closest_known_rule_name(key),
1052        })
1053        .collect()
1054}
1055
1056#[cfg(test)]
1057mod tests {
1058    use super::*;
1059
1060    #[test]
1061    fn rules_default_severities() {
1062        let rules = RulesConfig::default();
1063        assert_eq!(rules.unused_files, Severity::Error);
1064        assert_eq!(rules.unused_exports, Severity::Error);
1065        assert_eq!(rules.unused_types, Severity::Error);
1066        assert_eq!(rules.private_type_leaks, Severity::Off);
1067        assert_eq!(rules.unused_dependencies, Severity::Error);
1068        assert_eq!(rules.unused_dev_dependencies, Severity::Warn);
1069        assert_eq!(rules.unused_optional_dependencies, Severity::Warn);
1070        assert_eq!(rules.unused_enum_members, Severity::Error);
1071        assert_eq!(rules.unused_class_members, Severity::Error);
1072        assert_eq!(rules.unresolved_imports, Severity::Error);
1073        assert_eq!(rules.unlisted_dependencies, Severity::Error);
1074        assert_eq!(rules.duplicate_exports, Severity::Error);
1075        assert_eq!(rules.type_only_dependencies, Severity::Warn);
1076        assert_eq!(rules.test_only_dependencies, Severity::Warn);
1077        assert_eq!(rules.circular_dependencies, Severity::Error);
1078        assert_eq!(rules.boundary_violation, Severity::Error);
1079        assert_eq!(rules.coverage_gaps, Severity::Off);
1080        assert_eq!(rules.feature_flags, Severity::Off);
1081        assert_eq!(rules.stale_suppressions, Severity::Warn);
1082        assert_eq!(rules.unused_catalog_entries, Severity::Warn);
1083        assert_eq!(rules.empty_catalog_groups, Severity::Warn);
1084        assert_eq!(rules.unresolved_catalog_references, Severity::Error);
1085    }
1086
1087    #[test]
1088    fn rules_deserialize_kebab_case() {
1089        let json_str = r#"{
1090            "unused-files": "error",
1091            "unused-exports": "warn",
1092            "unused-types": "off"
1093        }"#;
1094        let rules: RulesConfig = serde_json::from_str(json_str).unwrap();
1095        assert_eq!(rules.unused_files, Severity::Error);
1096        assert_eq!(rules.unused_exports, Severity::Warn);
1097        assert_eq!(rules.unused_types, Severity::Off);
1098        assert_eq!(rules.unresolved_imports, Severity::Error);
1099    }
1100
1101    #[test]
1102    fn empty_rules_object_matches_default_impl() {
1103        // Regression for issue #1745: a present-but-empty `rules` object must
1104        // deserialize field-by-field to exactly the same severities as an
1105        // absent `rules` key (which fills from `RulesConfig::default()`).
1106        // A bare `#[serde(default)]` resolves to `Severity::default()` (Error),
1107        // so any field whose Default-impl value is Warn/Off silently promoted
1108        // to Error, flipping exit codes (coverage-gaps gated `fallow health`,
1109        // the component/store/inject rules gated CI on Vue/Svelte/Angular).
1110        let from_empty: RulesConfig = serde_json::from_str("{}").unwrap();
1111        assert_eq!(
1112            from_empty,
1113            RulesConfig::default(),
1114            "empty `rules` object must equal RulesConfig::default(); a field whose \
1115             Default is Warn/Off needs an explicit #[serde(default = ...)]"
1116        );
1117    }
1118
1119    #[test]
1120    fn empty_rules_object_preserves_non_error_defaults() {
1121        // Spot-check the nine fields fixed by #1745 so the intent is explicit
1122        // even if the wholesale equality assertion above is later relaxed.
1123        let rules: RulesConfig = serde_json::from_str("{}").unwrap();
1124        assert_eq!(rules.coverage_gaps, Severity::Off);
1125        assert_eq!(rules.unused_store_members, Severity::Warn);
1126        assert_eq!(rules.unprovided_injects, Severity::Warn);
1127        assert_eq!(rules.unrendered_components, Severity::Warn);
1128        assert_eq!(rules.unused_component_props, Severity::Warn);
1129        assert_eq!(rules.unused_component_emits, Severity::Warn);
1130        assert_eq!(rules.unused_component_inputs, Severity::Warn);
1131        assert_eq!(rules.unused_component_outputs, Severity::Warn);
1132        assert_eq!(rules.unused_server_actions, Severity::Warn);
1133    }
1134
1135    #[test]
1136    fn explicit_rule_severity_still_overrides_default() {
1137        // Control: the fix must not break explicit opt-in. Setting a value
1138        // still wins over the (now correct) default.
1139        let rules: RulesConfig =
1140            serde_json::from_str(r#"{ "coverage-gaps": "error", "unused-component-prop": "off" }"#)
1141                .unwrap();
1142        assert_eq!(rules.coverage_gaps, Severity::Error);
1143        assert_eq!(rules.unused_component_props, Severity::Off);
1144    }
1145
1146    #[test]
1147    fn rules_re_export_cycle_default_is_warn() {
1148        let rules = RulesConfig::default();
1149        assert_eq!(rules.re_export_cycle, Severity::Warn);
1150    }
1151
1152    #[test]
1153    fn rules_deserialize_re_export_cycle_aliases() {
1154        for token in [
1155            "re-export-cycle",
1156            "re-export-cycles",
1157            "reexport-cycle",
1158            "reexport-cycles",
1159        ] {
1160            let json_str = format!(r#"{{ "{token}": "error" }}"#);
1161            let rules: RulesConfig = serde_json::from_str(&json_str)
1162                .unwrap_or_else(|e| panic!("alias {token} did not deserialize: {e}"));
1163            assert_eq!(
1164                rules.re_export_cycle,
1165                Severity::Error,
1166                "alias {token} should set re_export_cycle"
1167            );
1168        }
1169    }
1170
1171    #[test]
1172    fn rules_deserialize_circular_dependency_alias() {
1173        let json_str = r#"{
1174            "circular-dependency": "off"
1175        }"#;
1176        let rules: RulesConfig = serde_json::from_str(json_str).unwrap();
1177        assert_eq!(rules.circular_dependencies, Severity::Off);
1178    }
1179
1180    #[test]
1181    fn rules_deserialize_boundary_violations_alias() {
1182        let json_str = r#"{
1183            "boundary-violations": "off"
1184        }"#;
1185        let rules: RulesConfig = serde_json::from_str(json_str).unwrap();
1186        assert_eq!(rules.boundary_violation, Severity::Off);
1187
1188        let partial: PartialRulesConfig = serde_json::from_str(json_str).unwrap();
1189        assert_eq!(partial.boundary_violation, Some(Severity::Off));
1190    }
1191
1192    #[test]
1193    fn rules_deserialize_singular_aliases_for_every_plural_rule() {
1194        let json_str = r#"{
1195            "unused-file": "off",
1196            "unused-export": "off",
1197            "unused-type": "off",
1198            "private-type-leak": "warn",
1199            "unused-dependency": "off",
1200            "unused-dev-dependency": "off",
1201            "unused-optional-dependency": "off",
1202            "unused-enum-member": "off",
1203            "unused-class-member": "off",
1204            "unresolved-import": "off",
1205            "unlisted-dependency": "off",
1206            "duplicate-export": "off",
1207            "type-only-dependency": "off",
1208            "test-only-dependency": "off",
1209            "coverage-gap": "warn",
1210            "feature-flag": "warn",
1211            "stale-suppression": "off",
1212            "suppression-reason": "warn",
1213            "unused-catalog-entry": "error",
1214            "empty-catalog-group": "error",
1215            "unresolved-catalog-reference": "warn"
1216        }"#;
1217
1218        let rules: RulesConfig = serde_json::from_str(json_str).unwrap();
1219        assert_eq!(rules.unused_files, Severity::Off);
1220        assert_eq!(rules.unused_exports, Severity::Off);
1221        assert_eq!(rules.unused_types, Severity::Off);
1222        assert_eq!(rules.private_type_leaks, Severity::Warn);
1223        assert_eq!(rules.unused_dependencies, Severity::Off);
1224        assert_eq!(rules.unused_dev_dependencies, Severity::Off);
1225        assert_eq!(rules.unused_optional_dependencies, Severity::Off);
1226        assert_eq!(rules.unused_enum_members, Severity::Off);
1227        assert_eq!(rules.unused_class_members, Severity::Off);
1228        assert_eq!(rules.unresolved_imports, Severity::Off);
1229        assert_eq!(rules.unlisted_dependencies, Severity::Off);
1230        assert_eq!(rules.duplicate_exports, Severity::Off);
1231        assert_eq!(rules.type_only_dependencies, Severity::Off);
1232        assert_eq!(rules.test_only_dependencies, Severity::Off);
1233        assert_eq!(rules.coverage_gaps, Severity::Warn);
1234        assert_eq!(rules.feature_flags, Severity::Warn);
1235        assert_eq!(rules.stale_suppressions, Severity::Off);
1236        assert_eq!(rules.require_suppression_reason, Severity::Warn);
1237        assert_eq!(rules.unused_catalog_entries, Severity::Error);
1238        assert_eq!(rules.empty_catalog_groups, Severity::Error);
1239        assert_eq!(rules.unresolved_catalog_references, Severity::Warn);
1240
1241        let partial: PartialRulesConfig = serde_json::from_str(json_str).unwrap();
1242        assert_eq!(partial.unused_files, Some(Severity::Off));
1243        assert_eq!(partial.unused_exports, Some(Severity::Off));
1244        assert_eq!(partial.unused_types, Some(Severity::Off));
1245        assert_eq!(partial.private_type_leaks, Some(Severity::Warn));
1246        assert_eq!(partial.unused_dependencies, Some(Severity::Off));
1247        assert_eq!(partial.unused_dev_dependencies, Some(Severity::Off));
1248        assert_eq!(partial.unused_optional_dependencies, Some(Severity::Off));
1249        assert_eq!(partial.unused_enum_members, Some(Severity::Off));
1250        assert_eq!(partial.unused_class_members, Some(Severity::Off));
1251        assert_eq!(partial.unresolved_imports, Some(Severity::Off));
1252        assert_eq!(partial.unlisted_dependencies, Some(Severity::Off));
1253        assert_eq!(partial.duplicate_exports, Some(Severity::Off));
1254        assert_eq!(partial.type_only_dependencies, Some(Severity::Off));
1255        assert_eq!(partial.test_only_dependencies, Some(Severity::Off));
1256        assert_eq!(partial.coverage_gaps, Some(Severity::Warn));
1257        assert_eq!(partial.feature_flags, Some(Severity::Warn));
1258        assert_eq!(partial.stale_suppressions, Some(Severity::Off));
1259        assert_eq!(partial.require_suppression_reason, Some(Severity::Warn));
1260        assert_eq!(partial.unused_catalog_entries, Some(Severity::Error));
1261        assert_eq!(partial.empty_catalog_groups, Some(Severity::Error));
1262        assert_eq!(partial.unresolved_catalog_references, Some(Severity::Warn));
1263    }
1264
1265    #[test]
1266    fn severity_from_str() {
1267        assert_eq!("error".parse::<Severity>().unwrap(), Severity::Error);
1268        assert_eq!("warn".parse::<Severity>().unwrap(), Severity::Warn);
1269        assert_eq!("warning".parse::<Severity>().unwrap(), Severity::Warn);
1270        assert_eq!("off".parse::<Severity>().unwrap(), Severity::Off);
1271        assert_eq!("none".parse::<Severity>().unwrap(), Severity::Off);
1272        assert!("invalid".parse::<Severity>().is_err());
1273    }
1274
1275    #[test]
1276    fn apply_partial_only_some_fields() {
1277        let mut rules = RulesConfig::default();
1278        let partial = PartialRulesConfig {
1279            unused_files: Some(Severity::Warn),
1280            unused_exports: Some(Severity::Off),
1281            ..Default::default()
1282        };
1283        rules.apply_partial(&partial);
1284        assert_eq!(rules.unused_files, Severity::Warn);
1285        assert_eq!(rules.unused_exports, Severity::Off);
1286        assert_eq!(rules.unused_types, Severity::Error);
1287        assert_eq!(rules.unresolved_imports, Severity::Error);
1288        assert_eq!(rules.require_suppression_reason, Severity::Off);
1289    }
1290
1291    #[test]
1292    fn require_suppression_reason_deserializes_canonical_and_alias() {
1293        let rules: RulesConfig = serde_json::from_str(
1294            r#"{
1295                "require-suppression-reason": "error"
1296            }"#,
1297        )
1298        .unwrap();
1299        assert_eq!(rules.require_suppression_reason, Severity::Error);
1300
1301        let partial: PartialRulesConfig = serde_json::from_str(
1302            r#"{
1303                "suppression-reason": "warn"
1304            }"#,
1305        )
1306        .unwrap();
1307        assert_eq!(partial.require_suppression_reason, Some(Severity::Warn));
1308    }
1309
1310    #[test]
1311    fn severity_display() {
1312        assert_eq!(Severity::Error.to_string(), "error");
1313        assert_eq!(Severity::Warn.to_string(), "warn");
1314        assert_eq!(Severity::Off.to_string(), "off");
1315    }
1316
1317    #[test]
1318    fn apply_partial_all_none_changes_nothing() {
1319        let mut rules = RulesConfig::default();
1320        let original = rules.clone();
1321        let partial = PartialRulesConfig::default(); // all None
1322        rules.apply_partial(&partial);
1323        assert_eq!(rules.unused_files, original.unused_files);
1324        assert_eq!(rules.unused_exports, original.unused_exports);
1325        assert_eq!(
1326            rules.type_only_dependencies,
1327            original.type_only_dependencies
1328        );
1329    }
1330
1331    #[test]
1332    fn apply_partial_all_fields_set() {
1333        let mut rules = RulesConfig::default();
1334        let partial = PartialRulesConfig {
1335            unused_files: Some(Severity::Off),
1336            unused_exports: Some(Severity::Off),
1337            unused_types: Some(Severity::Off),
1338            private_type_leaks: Some(Severity::Off),
1339            unused_dependencies: Some(Severity::Off),
1340            unused_dev_dependencies: Some(Severity::Off),
1341            unused_optional_dependencies: Some(Severity::Off),
1342            unused_enum_members: Some(Severity::Off),
1343            unused_class_members: Some(Severity::Off),
1344            unused_store_members: Some(Severity::Off),
1345            unprovided_injects: Some(Severity::Off),
1346            unrendered_components: Some(Severity::Off),
1347            unused_component_props: Some(Severity::Off),
1348            unused_component_emits: Some(Severity::Off),
1349            unused_component_inputs: Some(Severity::Off),
1350            unused_component_outputs: Some(Severity::Off),
1351            unused_svelte_events: Some(Severity::Off),
1352            unused_server_actions: Some(Severity::Off),
1353            unused_load_data_keys: Some(Severity::Off),
1354            prop_drilling: Some(Severity::Off),
1355            thin_wrapper: Some(Severity::Off),
1356            duplicate_prop_shape: Some(Severity::Off),
1357            css_token_drift: Some(Severity::Off),
1358            css_duplicate_block: Some(Severity::Off),
1359            css_selector_complexity: Some(Severity::Off),
1360            css_dead_surface: Some(Severity::Off),
1361            css_broken_reference: Some(Severity::Off),
1362            unresolved_imports: Some(Severity::Off),
1363            unlisted_dependencies: Some(Severity::Off),
1364            duplicate_exports: Some(Severity::Off),
1365            type_only_dependencies: Some(Severity::Off),
1366            test_only_dependencies: Some(Severity::Off),
1367            dev_dependencies_in_production: Some(Severity::Off),
1368            circular_dependencies: Some(Severity::Off),
1369            re_export_cycle: Some(Severity::Off),
1370            boundary_violation: Some(Severity::Off),
1371            coverage_gaps: Some(Severity::Off),
1372            feature_flags: Some(Severity::Off),
1373            stale_suppressions: Some(Severity::Off),
1374            require_suppression_reason: Some(Severity::Off),
1375            unused_catalog_entries: Some(Severity::Off),
1376            empty_catalog_groups: Some(Severity::Off),
1377            unresolved_catalog_references: Some(Severity::Off),
1378            unused_dependency_overrides: Some(Severity::Off),
1379            misconfigured_dependency_overrides: Some(Severity::Off),
1380            security_client_server_leak: Some(Severity::Off),
1381            security_sink: Some(Severity::Off),
1382            policy_violation: Some(Severity::Off),
1383            invalid_client_export: Some(Severity::Off),
1384            mixed_client_server_barrel: Some(Severity::Off),
1385            misplaced_directive: Some(Severity::Off),
1386            route_collision: Some(Severity::Off),
1387            dynamic_segment_name_conflict: Some(Severity::Off),
1388        };
1389        rules.apply_partial(&partial);
1390        assert_eq!(rules.unused_files, Severity::Off);
1391        assert_eq!(rules.private_type_leaks, Severity::Off);
1392        assert_eq!(rules.circular_dependencies, Severity::Off);
1393        assert_eq!(rules.type_only_dependencies, Severity::Off);
1394        assert_eq!(rules.test_only_dependencies, Severity::Off);
1395        assert_eq!(rules.boundary_violation, Severity::Off);
1396        assert_eq!(rules.coverage_gaps, Severity::Off);
1397        assert_eq!(rules.feature_flags, Severity::Off);
1398        assert_eq!(rules.stale_suppressions, Severity::Off);
1399        assert_eq!(rules.require_suppression_reason, Severity::Off);
1400        assert_eq!(rules.security_sink, Severity::Off);
1401        assert_eq!(rules.policy_violation, Severity::Off);
1402        assert_eq!(rules.invalid_client_export, Severity::Off);
1403        assert_eq!(rules.mixed_client_server_barrel, Severity::Off);
1404        assert_eq!(rules.misplaced_directive, Severity::Off);
1405        assert_eq!(rules.unrendered_components, Severity::Off);
1406        assert_eq!(rules.unused_component_props, Severity::Off);
1407        assert_eq!(rules.unused_component_emits, Severity::Off);
1408        assert_eq!(rules.unused_component_inputs, Severity::Off);
1409        assert_eq!(rules.unused_component_outputs, Severity::Off);
1410        assert_eq!(rules.unused_svelte_events, Severity::Off);
1411        assert_eq!(rules.route_collision, Severity::Off);
1412        assert_eq!(rules.dynamic_segment_name_conflict, Severity::Off);
1413    }
1414
1415    #[test]
1416    fn rules_config_defaults_include_optional_deps() {
1417        let rules = RulesConfig::default();
1418        assert_eq!(rules.unused_optional_dependencies, Severity::Warn);
1419    }
1420
1421    #[test]
1422    fn policy_violation_defaults_to_warn() {
1423        let rules = RulesConfig::default();
1424        assert_eq!(rules.policy_violation, Severity::Warn);
1425    }
1426
1427    #[test]
1428    fn policy_violation_accepts_plural_alias() {
1429        let json = r#"{ "policy-violations": "error" }"#;
1430        let rules: RulesConfig = serde_json::from_str(json).unwrap();
1431        assert_eq!(rules.policy_violation, Severity::Error);
1432    }
1433
1434    #[test]
1435    fn severity_from_str_case_insensitive() {
1436        assert_eq!("ERROR".parse::<Severity>().unwrap(), Severity::Error);
1437        assert_eq!("Warn".parse::<Severity>().unwrap(), Severity::Warn);
1438        assert_eq!("OFF".parse::<Severity>().unwrap(), Severity::Off);
1439        assert_eq!("Warning".parse::<Severity>().unwrap(), Severity::Warn);
1440        assert_eq!("NONE".parse::<Severity>().unwrap(), Severity::Off);
1441    }
1442
1443    #[test]
1444    fn severity_from_str_invalid_returns_error() {
1445        let result = "critical".parse::<Severity>();
1446        assert!(result.is_err());
1447        let err = result.unwrap_err();
1448        assert!(
1449            err.contains("unknown severity"),
1450            "Expected descriptive error, got: {err}"
1451        );
1452    }
1453
1454    #[test]
1455    fn known_rule_names_count_matches_struct() {
1456        assert_eq!(KNOWN_RULE_NAMES.len(), 98);
1457    }
1458
1459    #[test]
1460    fn known_rule_names_has_no_duplicates() {
1461        let mut sorted: Vec<&str> = KNOWN_RULE_NAMES.to_vec();
1462        sorted.sort_unstable();
1463        let original_len = sorted.len();
1464        sorted.dedup();
1465        assert_eq!(
1466            sorted.len(),
1467            original_len,
1468            "KNOWN_RULE_NAMES contains a duplicate"
1469        );
1470    }
1471
1472    #[test]
1473    fn known_rule_names_covers_every_serde_alias_in_source() {
1474        let source = include_str!("rules.rs");
1475
1476        let mut aliases_found = Vec::new();
1477        for line in source.lines() {
1478            let trimmed = line.trim();
1479            if trimmed.starts_with("//") {
1480                continue;
1481            }
1482            let Some(after) = trimmed.split("alias = \"").nth(1) else {
1483                continue;
1484            };
1485            let Some(end) = after.find('"') else {
1486                continue;
1487            };
1488            let alias = &after[..end];
1489            if alias.is_empty() || !alias.chars().all(|c| c.is_ascii_lowercase() || c == '-') {
1490                continue;
1491            }
1492            aliases_found.push(alias.to_owned());
1493        }
1494
1495        assert_eq!(
1496            aliases_found.len(),
1497            106,
1498            "expected 106 source-level alias attrs (53 per struct); got {}: {:?}",
1499            aliases_found.len(),
1500            aliases_found
1501        );
1502
1503        for alias in &aliases_found {
1504            assert!(
1505                KNOWN_RULE_NAMES.contains(&alias.as_str()),
1506                "serde alias '{alias}' is in rules.rs source but missing from KNOWN_RULE_NAMES"
1507            );
1508        }
1509    }
1510
1511    #[test]
1512    fn re_export_cycle_aliases_all_round_trip_to_the_same_field() {
1513        for alias in [
1514            "re-export-cycle",
1515            "re-export-cycles",
1516            "reexport-cycle",
1517            "reexport-cycles",
1518        ] {
1519            let json = format!(r#"{{"{alias}": "warn"}}"#);
1520            let partial: PartialRulesConfig = serde_json::from_str(&json)
1521                .unwrap_or_else(|e| panic!("'{alias}' should deserialize: {e}"));
1522            assert_eq!(
1523                partial.re_export_cycle,
1524                Some(Severity::Warn),
1525                "'{alias}' should set re_export_cycle to Warn"
1526            );
1527            let serialized = serde_json::to_value(&partial).unwrap();
1528            let map = serialized.as_object().unwrap();
1529            assert_eq!(
1530                map.len(),
1531                1,
1532                "'{alias}' should resolve to exactly one field, got: {map:?}"
1533            );
1534        }
1535    }
1536
1537    #[test]
1538    fn every_known_rule_name_round_trips_through_partial() {
1539        for &name in KNOWN_RULE_NAMES {
1540            let json = format!(r#"{{"{name}": "warn"}}"#);
1541            let partial: PartialRulesConfig = serde_json::from_str(&json)
1542                .unwrap_or_else(|e| panic!("'{name}' should deserialize: {e}"));
1543
1544            let serialized = serde_json::to_value(&partial).unwrap();
1545            let map = serialized.as_object().unwrap();
1546            assert_eq!(
1547                map.len(),
1548                1,
1549                "'{name}' should resolve to exactly one field, got: {map:?}"
1550            );
1551        }
1552    }
1553
1554    #[test]
1555    fn known_rule_names_covers_every_struct_field() {
1556        let json = serde_json::to_value(RulesConfig::default()).unwrap();
1557        let obj = json.as_object().unwrap();
1558        for key in obj.keys() {
1559            assert!(
1560                KNOWN_RULE_NAMES.contains(&key.as_str()),
1561                "field '{key}' is serialized but missing from KNOWN_RULE_NAMES"
1562            );
1563        }
1564    }
1565
1566    #[test]
1567    fn closest_known_rule_name_suggests_for_obvious_typo() {
1568        assert_eq!(
1569            closest_known_rule_name("unsued-files"),
1570            Some("unused-files")
1571        );
1572        assert_eq!(
1573            closest_known_rule_name("circular-dependnecy"),
1574            Some("circular-dependency")
1575        );
1576        assert_eq!(
1577            closest_known_rule_name("unused-dep"),
1578            None,
1579            "too short for a confident suggestion"
1580        );
1581    }
1582
1583    #[test]
1584    fn closest_known_rule_name_returns_none_for_novel_input() {
1585        assert_eq!(closest_known_rule_name("totally-fabricated"), None);
1586        assert_eq!(closest_known_rule_name("foo"), None);
1587    }
1588
1589    #[test]
1590    fn closest_known_rule_name_is_case_insensitive() {
1591        assert_eq!(
1592            closest_known_rule_name("UNSUED-FILES"),
1593            Some("unused-files")
1594        );
1595    }
1596
1597    #[test]
1598    fn closest_known_rule_name_returns_none_for_exact_match() {
1599        assert_eq!(closest_known_rule_name("unused-files"), None);
1600    }
1601
1602    #[test]
1603    fn find_unknown_rule_keys_flags_typo() {
1604        let v = serde_json::json!({
1605            "unsued-files": "warn",
1606            "unused-exports": "off",
1607        });
1608        let unknown = find_unknown_rule_keys(&v, "rules");
1609        assert_eq!(unknown.len(), 1);
1610        assert_eq!(unknown[0].key, "unsued-files");
1611        assert_eq!(unknown[0].context, "rules");
1612        assert_eq!(unknown[0].suggestion, Some("unused-files"));
1613    }
1614
1615    #[test]
1616    fn find_unknown_rule_keys_passes_aliases() {
1617        let v = serde_json::json!({
1618            "unused-file": "warn",
1619            "circular-dependency": "off",
1620            "boundary-violations": "warn",
1621        });
1622        let unknown = find_unknown_rule_keys(&v, "rules");
1623        assert!(
1624            unknown.is_empty(),
1625            "documented aliases must not flag as unknown: {unknown:?}"
1626        );
1627    }
1628
1629    #[test]
1630    fn find_unknown_rule_keys_returns_multiple_typos() {
1631        let v = serde_json::json!({
1632            "unsued-files": "warn",
1633            "circular-dependnecy": "off",
1634        });
1635        let unknown = find_unknown_rule_keys(&v, "rules");
1636        assert_eq!(unknown.len(), 2);
1637    }
1638
1639    #[test]
1640    fn find_unknown_rule_keys_carries_context() {
1641        let v = serde_json::json!({ "unsued-files": "warn" });
1642        let unknown = find_unknown_rule_keys(&v, "overrides[2].rules");
1643        assert_eq!(unknown[0].context, "overrides[2].rules");
1644    }
1645
1646    #[test]
1647    fn find_unknown_rule_keys_empty_when_not_object() {
1648        let v = serde_json::json!(null);
1649        assert!(find_unknown_rule_keys(&v, "rules").is_empty());
1650
1651        let v = serde_json::json!([1, 2, 3]);
1652        assert!(find_unknown_rule_keys(&v, "rules").is_empty());
1653    }
1654
1655    #[test]
1656    fn find_unknown_rule_keys_no_suggestion_for_novel_name() {
1657        let v = serde_json::json!({ "totally-fabricated-rule": "warn" });
1658        let unknown = find_unknown_rule_keys(&v, "rules");
1659        assert_eq!(unknown.len(), 1);
1660        assert_eq!(unknown[0].suggestion, None);
1661    }
1662
1663    #[test]
1664    fn partial_rules_empty_json() {
1665        let partial: PartialRulesConfig = serde_json::from_str("{}").unwrap();
1666        assert!(partial.unused_files.is_none());
1667        assert!(partial.unused_exports.is_none());
1668        assert!(partial.unused_types.is_none());
1669        assert!(partial.unused_dependencies.is_none());
1670        assert!(partial.circular_dependencies.is_none());
1671        assert!(partial.boundary_violation.is_none());
1672        assert!(partial.coverage_gaps.is_none());
1673        assert!(partial.feature_flags.is_none());
1674        assert!(partial.stale_suppressions.is_none());
1675    }
1676
1677    #[test]
1678    fn partial_rules_subset_json() {
1679        let json = r#"{
1680            "unused-files": "warn",
1681            "circular-dependencies": "off"
1682        }"#;
1683        let partial: PartialRulesConfig = serde_json::from_str(json).unwrap();
1684        assert_eq!(partial.unused_files, Some(Severity::Warn));
1685        assert_eq!(partial.circular_dependencies, Some(Severity::Off));
1686        assert!(partial.unused_exports.is_none());
1687    }
1688
1689    #[test]
1690    fn partial_rules_deserialize_circular_dependency_alias() {
1691        let json = r#"{
1692            "circular-dependency": "warn"
1693        }"#;
1694        let partial: PartialRulesConfig = serde_json::from_str(json).unwrap();
1695        assert_eq!(partial.circular_dependencies, Some(Severity::Warn));
1696    }
1697
1698    #[test]
1699    fn partial_rules_all_fields_json() {
1700        let json = r#"{
1701            "unused-files": "error",
1702            "unused-exports": "warn",
1703            "unused-types": "off",
1704            "unused-dependencies": "error",
1705            "unused-dev-dependencies": "warn",
1706            "unused-optional-dependencies": "off",
1707            "unused-enum-members": "error",
1708            "unused-class-members": "warn",
1709            "unresolved-imports": "off",
1710            "unlisted-dependencies": "error",
1711            "duplicate-exports": "warn",
1712            "type-only-dependencies": "off",
1713            "test-only-dependencies": "error",
1714            "circular-dependencies": "warn",
1715            "boundary-violation": "off",
1716            "coverage-gaps": "warn",
1717            "feature-flags": "error",
1718            "stale-suppressions": "off"
1719        }"#;
1720        let partial: PartialRulesConfig = serde_json::from_str(json).unwrap();
1721        assert_eq!(partial.unused_files, Some(Severity::Error));
1722        assert_eq!(partial.unused_exports, Some(Severity::Warn));
1723        assert_eq!(partial.unused_types, Some(Severity::Off));
1724        assert_eq!(partial.unused_dependencies, Some(Severity::Error));
1725        assert_eq!(partial.unused_dev_dependencies, Some(Severity::Warn));
1726        assert_eq!(partial.unused_optional_dependencies, Some(Severity::Off));
1727        assert_eq!(partial.unused_enum_members, Some(Severity::Error));
1728        assert_eq!(partial.unused_class_members, Some(Severity::Warn));
1729        assert_eq!(partial.unresolved_imports, Some(Severity::Off));
1730        assert_eq!(partial.unlisted_dependencies, Some(Severity::Error));
1731        assert_eq!(partial.duplicate_exports, Some(Severity::Warn));
1732        assert_eq!(partial.type_only_dependencies, Some(Severity::Off));
1733        assert_eq!(partial.test_only_dependencies, Some(Severity::Error));
1734        assert_eq!(partial.circular_dependencies, Some(Severity::Warn));
1735        assert_eq!(partial.boundary_violation, Some(Severity::Off));
1736        assert_eq!(partial.coverage_gaps, Some(Severity::Warn));
1737        assert_eq!(partial.feature_flags, Some(Severity::Error));
1738        assert_eq!(partial.stale_suppressions, Some(Severity::Off));
1739    }
1740
1741    #[test]
1742    fn partial_rules_none_fields_not_serialized() {
1743        let partial = PartialRulesConfig::default();
1744        let json = serde_json::to_string(&partial).unwrap();
1745        assert_eq!(
1746            json, "{}",
1747            "all-None partial should serialize to empty object"
1748        );
1749    }
1750
1751    #[test]
1752    fn partial_rules_some_fields_serialized() {
1753        let partial = PartialRulesConfig {
1754            unused_files: Some(Severity::Warn),
1755            ..Default::default()
1756        };
1757        let json = serde_json::to_string(&partial).unwrap();
1758        assert!(json.contains("unused-files"));
1759        assert!(!json.contains("unused-exports"));
1760    }
1761
1762    #[test]
1763    fn severity_json_deserialization() {
1764        let error: Severity = serde_json::from_str(r#""error""#).unwrap();
1765        assert_eq!(error, Severity::Error);
1766
1767        let warn: Severity = serde_json::from_str(r#""warn""#).unwrap();
1768        assert_eq!(warn, Severity::Warn);
1769
1770        let off: Severity = serde_json::from_str(r#""off""#).unwrap();
1771        assert_eq!(off, Severity::Off);
1772    }
1773
1774    #[test]
1775    fn severity_invalid_json_value_rejected() {
1776        let result: Result<Severity, _> = serde_json::from_str(r#""critical""#);
1777        assert!(result.is_err());
1778    }
1779
1780    #[test]
1781    fn severity_default_is_error() {
1782        assert_eq!(Severity::default(), Severity::Error);
1783    }
1784
1785    #[test]
1786    fn rules_config_json_roundtrip() {
1787        let rules = RulesConfig {
1788            unused_files: Severity::Warn,
1789            unused_exports: Severity::Off,
1790            type_only_dependencies: Severity::Error,
1791            ..RulesConfig::default()
1792        };
1793        let json = serde_json::to_string(&rules).unwrap();
1794        let restored: RulesConfig = serde_json::from_str(&json).unwrap();
1795        assert_eq!(restored.unused_files, Severity::Warn);
1796        assert_eq!(restored.unused_exports, Severity::Off);
1797        assert_eq!(restored.type_only_dependencies, Severity::Error);
1798        assert_eq!(restored.unused_dependencies, Severity::Error); // default
1799    }
1800
1801    #[test]
1802    fn apply_partial_preserves_type_only_default() {
1803        let mut rules = RulesConfig::default();
1804        let partial = PartialRulesConfig {
1805            unused_files: Some(Severity::Off),
1806            ..Default::default()
1807        };
1808        rules.apply_partial(&partial);
1809        assert_eq!(rules.type_only_dependencies, Severity::Warn);
1810        assert_eq!(rules.test_only_dependencies, Severity::Warn);
1811    }
1812}