Skip to main content

fallow_types/
suppress.rs

1//! Inline suppression comment types and issue kind definitions.
2
3/// Issue kind for suppression matching.
4///
5/// # Examples
6///
7/// ```
8/// use fallow_types::suppress::IssueKind;
9///
10/// let kind = IssueKind::parse("unused-export");
11/// assert_eq!(kind, Some(IssueKind::UnusedExport));
12///
13/// // Round-trip through discriminant
14/// let d = IssueKind::UnusedFile.to_discriminant();
15/// assert_eq!(IssueKind::from_discriminant(d), Some(IssueKind::UnusedFile));
16///
17/// // Unknown strings return None
18/// assert_eq!(IssueKind::parse("not-a-kind"), None);
19/// ```
20#[derive(Debug, Clone, Copy, PartialEq, Eq)]
21pub enum IssueKind {
22    /// An unused file.
23    UnusedFile,
24    /// An unused export.
25    UnusedExport,
26    /// An unused type export.
27    UnusedType,
28    /// An exported signature that references a same-file private type.
29    PrivateTypeLeak,
30    /// An unused dependency.
31    UnusedDependency,
32    /// An unused dev dependency.
33    UnusedDevDependency,
34    /// An unused enum member.
35    UnusedEnumMember,
36    /// An unused class member.
37    UnusedClassMember,
38    /// An unresolved import.
39    UnresolvedImport,
40    /// An unlisted dependency.
41    UnlistedDependency,
42    /// A duplicate export name across modules.
43    DuplicateExport,
44    /// Code duplication.
45    CodeDuplication,
46    /// A circular dependency chain.
47    CircularDependency,
48    /// A cycle or self-loop in the re-export edge subgraph (barrel files
49    /// re-exporting from each other in a loop). Structurally always a bug:
50    /// chain propagation through the cycle is a no-op.
51    ReExportCycle,
52    /// A production dependency only imported via type-only imports.
53    TypeOnlyDependency,
54    /// A production dependency only imported by test files.
55    TestOnlyDependency,
56    /// An import that crosses an architecture boundary.
57    BoundaryViolation,
58    /// A runtime file or export with no test dependency path.
59    CoverageGaps,
60    /// A detected feature flag pattern.
61    FeatureFlag,
62    /// A function exceeding complexity thresholds (health command).
63    Complexity,
64    /// A suppression comment or JSDoc tag that no longer matches any issue.
65    StaleSuppression,
66    /// A pnpm catalog entry in pnpm-workspace.yaml not referenced by any workspace package.
67    PnpmCatalogEntry,
68    /// A named pnpm catalog group in pnpm-workspace.yaml with no entries.
69    EmptyCatalogGroup,
70    /// A workspace package.json reference (`catalog:` / `catalog:<name>`) pointing at
71    /// a catalog that does not declare the consumed package.
72    UnresolvedCatalogReference,
73    /// An entry in pnpm's `overrides:` / `pnpm.overrides` whose target package
74    /// is not declared in any workspace `package.json`.
75    UnusedDependencyOverride,
76    /// An entry in pnpm's `overrides:` / `pnpm.overrides` whose key or value
77    /// cannot be parsed into a valid pnpm shape.
78    MisconfiguredDependencyOverride,
79    /// A `"use client"` file that transitively imports a module reading a
80    /// non-public `process.env` secret (security candidate).
81    SecurityClientServerLeak,
82    /// A syntactic tainted-sink candidate matched against the data-driven
83    /// security matcher catalogue (`security_matchers.toml`). ONE suppression
84    /// token covers all catalogue categories.
85    SecuritySink,
86    /// A banned call or banned import matched by a declarative rule pack
87    /// (`rulePacks` config). The bare token covers every pack rule; scoped
88    /// tokens can target one `<pack>/<rule-id>` identity.
89    PolicyViolation,
90    /// A `"use client"` file that exports a Next.js server-only /
91    /// route-segment config name (e.g. `metadata`, `revalidate`, `GET`).
92    InvalidClientExport,
93    /// A barrel file that re-exports BOTH a `"use client"` origin module AND a
94    /// server-only origin module (Next.js App Router footgun: one import drags
95    /// the other's directive context across the boundary).
96    MixedClientServerBarrel,
97    /// A `"use client"` / `"use server"` directive string written as an
98    /// expression statement after a non-directive statement (an import, a
99    /// const). It is no longer in the leading prologue, so the RSC bundler
100    /// parses it as an ordinary string and silently ignores it.
101    MisplacedDirective,
102    /// A store member (Pinia `state` / `getters` / `actions` key, or a
103    /// setup-store returned key) declared but never accessed by any consumer
104    /// project-wide. Cross-graph: the store binding is imported (the module is
105    /// reachable) yet a specific member is dead.
106    UnusedStoreMember,
107    /// A Vue `inject(KEY)` or Svelte `getContext(KEY)` whose symbol KEY is
108    /// `provide`/`setContext`'d nowhere in the analyzed project. Cross-graph
109    /// dead-half DI link: at runtime the inject returns `undefined`.
110    UnprovidedInject,
111    /// Two or more Next.js App Router route files that resolve to the same URL
112    /// within one app-root (a guaranteed `next build` failure).
113    RouteCollision,
114    /// Sibling Next.js dynamic route segments at one tree position using
115    /// different param spellings (`[id]` vs `[slug]`; a dev / runtime error
116    /// that `next build` does NOT catch).
117    DynamicSegmentNameConflict,
118    /// A component defined in the project that is exported but never rendered
119    /// (no JSX usage) anywhere across the analyzed project.
120    UnrenderedComponent,
121    /// A Vue `<script setup>` `defineProps` declared prop that is referenced
122    /// NOWHERE inside its own single-file component (neither `<script>` nor
123    /// `<template>`). Single-file dead-input direction.
124    UnusedComponentProp,
125    /// A Vue `<script setup>` `defineEmits` declared event that is EMITTED
126    /// nowhere inside its own single-file component (no `emit('<name>')` call).
127    /// Single-file dead-input direction.
128    UnusedComponentEmit,
129    /// An Angular `@Input()` / signal `input()` / `model()` declared input that
130    /// is read NOWHERE inside its own component (neither the inline/external
131    /// template nor the class body). Single-file dead-input direction; the
132    /// Angular analogue of `unused-component-prop`.
133    UnusedComponentInput,
134    /// An Angular `@Output()` / signal `output()` declared output that is
135    /// EMITTED nowhere inside its own component (no `this.<output>.emit(...)`).
136    /// Single-file dead-output direction; the Angular analogue of
137    /// `unused-component-emit`.
138    UnusedComponentOutput,
139    /// A Next.js Server Action (an export of a `"use server"` file) that no code
140    /// in the project references (no import-and-call, no `action={fn}` binding,
141    /// no `<form action={fn}>`). Cross-graph dead-export direction, reclassified
142    /// from `unused-export` for `"use server"` files.
143    UnusedServerAction,
144    /// A SvelteKit `+page.{ts,server.ts,js,server.js}` `load()` return-object key
145    /// that no consumer reads: not off the sibling `+page.svelte`'s `data.<key>`,
146    /// nor project-wide via `page.data.<key>` / `$page.data.<key>`. A dead load
147    /// key runs a real server/DB fetch cost for data nothing renders.
148    UnusedLoadDataKey,
149    /// A React/Preact prop forwarded unchanged through `>= N` intermediate
150    /// pass-through components until a component that substantively consumes it.
151    /// Health signal, rule defaults to `off` (opt-in). Cross-graph: the chain
152    /// spans multiple components / files.
153    PropDrilling,
154    /// A React/Preact component whose entire body is `return <Child {...props}/>`
155    /// (a single spread-forwarded child render, no own value-add): pure
156    /// structural indirection, a candidate for inlining. Health signal, rule
157    /// defaults to `off` (opt-in).
158    ThinWrapper,
159    /// Three or more React/Preact components across two or more files whose
160    /// statically-harvested prop NAME set is identical after stripping ubiquitous
161    /// DOM / passthrough names (a missing shared `Props` type). Health signal,
162    /// rule defaults to `off` (opt-in). Cross-graph: the group spans multiple
163    /// components / files.
164    DuplicatePropShape,
165    /// A Svelte component dispatching a custom event via
166    /// `createEventDispatcher()` whose event name is listened to NOWHERE in the
167    /// analyzed project. Cross-file dead-output direction: the component fires an
168    /// event nothing handles.
169    UnusedSvelteEvent,
170}
171
172impl IssueKind {
173    /// Parse an issue kind from the string tokens used in CLI output and suppression comments.
174    #[must_use]
175    pub fn parse(s: &str) -> Option<Self> {
176        match s {
177            "unused-file" => Some(Self::UnusedFile),
178            "unused-export" => Some(Self::UnusedExport),
179            "unused-type" => Some(Self::UnusedType),
180            "private-type-leak" => Some(Self::PrivateTypeLeak),
181            "unused-dependency" => Some(Self::UnusedDependency),
182            "unused-dev-dependency" => Some(Self::UnusedDevDependency),
183            "unused-enum-member" => Some(Self::UnusedEnumMember),
184            "unused-class-member" => Some(Self::UnusedClassMember),
185            "unresolved-import" => Some(Self::UnresolvedImport),
186            "unlisted-dependency" => Some(Self::UnlistedDependency),
187            "duplicate-export" => Some(Self::DuplicateExport),
188            "code-duplication" => Some(Self::CodeDuplication),
189            "circular-dependency" | "circular-dependencies" => Some(Self::CircularDependency),
190            "re-export-cycle" | "re-export-cycles" | "reexport-cycle" | "reexport-cycles" => {
191                Some(Self::ReExportCycle)
192            }
193            "type-only-dependency" => Some(Self::TypeOnlyDependency),
194            "test-only-dependency" => Some(Self::TestOnlyDependency),
195            "boundary-violation" | "boundary-call-violation" | "boundary-call-violations" => {
196                Some(Self::BoundaryViolation)
197            }
198            "coverage-gaps" => Some(Self::CoverageGaps),
199            "feature-flag" => Some(Self::FeatureFlag),
200            "complexity" => Some(Self::Complexity),
201            "stale-suppression" => Some(Self::StaleSuppression),
202            "unused-catalog-entry" | "unused-catalog-entries" => Some(Self::PnpmCatalogEntry),
203            "empty-catalog-group" | "empty-catalog-groups" => Some(Self::EmptyCatalogGroup),
204            "unresolved-catalog-reference" | "unresolved-catalog-references" => {
205                Some(Self::UnresolvedCatalogReference)
206            }
207            "unused-dependency-override" | "unused-dependency-overrides" => {
208                Some(Self::UnusedDependencyOverride)
209            }
210            "misconfigured-dependency-override" | "misconfigured-dependency-overrides" => {
211                Some(Self::MisconfiguredDependencyOverride)
212            }
213            "security-client-server-leak" => Some(Self::SecurityClientServerLeak),
214            "security-sink" => Some(Self::SecuritySink),
215            "policy-violation" | "policy-violations" => Some(Self::PolicyViolation),
216            "invalid-client-export" | "invalid-client-exports" => Some(Self::InvalidClientExport),
217            "mixed-client-server-barrel" | "mixed-client-server-barrels" => {
218                Some(Self::MixedClientServerBarrel)
219            }
220            "misplaced-directive" | "misplaced-directives" => Some(Self::MisplacedDirective),
221            "unused-store-member" | "unused-store-members" => Some(Self::UnusedStoreMember),
222            "unprovided-inject" | "unprovided-injects" => Some(Self::UnprovidedInject),
223            "route-collision" | "route-collisions" => Some(Self::RouteCollision),
224            "dynamic-segment-name-conflict" | "dynamic-segment-name-conflicts" => {
225                Some(Self::DynamicSegmentNameConflict)
226            }
227            "unrendered-component" | "unrendered-components" => Some(Self::UnrenderedComponent),
228            "unused-component-prop" | "unused-component-props" => Some(Self::UnusedComponentProp),
229            "unused-component-emit" | "unused-component-emits" => Some(Self::UnusedComponentEmit),
230            "unused-component-input" | "unused-component-inputs" => {
231                Some(Self::UnusedComponentInput)
232            }
233            "unused-component-output" | "unused-component-outputs" => {
234                Some(Self::UnusedComponentOutput)
235            }
236            "unused-server-action" | "unused-server-actions" => Some(Self::UnusedServerAction),
237            "unused-load-data-key" | "unused-load-data-keys" => Some(Self::UnusedLoadDataKey),
238            "prop-drilling" => Some(Self::PropDrilling),
239            "thin-wrapper" | "thin-wrappers" => Some(Self::ThinWrapper),
240            "duplicate-prop-shape" | "duplicate-prop-shapes" => Some(Self::DuplicatePropShape),
241            "unused-svelte-event" | "unused-svelte-events" => Some(Self::UnusedSvelteEvent),
242            _ => None,
243        }
244    }
245
246    /// Convert to a u8 discriminant for compact cache storage.
247    #[must_use]
248    pub const fn to_discriminant(self) -> u8 {
249        match self {
250            Self::UnusedFile => 1,
251            Self::UnusedExport => 2,
252            Self::UnusedType => 3,
253            Self::PrivateTypeLeak => 4,
254            Self::UnusedDependency => 5,
255            Self::UnusedDevDependency => 6,
256            Self::UnusedEnumMember => 7,
257            Self::UnusedClassMember => 8,
258            Self::UnresolvedImport => 9,
259            Self::UnlistedDependency => 10,
260            Self::DuplicateExport => 11,
261            Self::CodeDuplication => 12,
262            Self::CircularDependency => 13,
263            Self::TypeOnlyDependency => 14,
264            Self::TestOnlyDependency => 15,
265            Self::BoundaryViolation => 16,
266            Self::CoverageGaps => 17,
267            Self::FeatureFlag => 18,
268            Self::Complexity => 19,
269            Self::StaleSuppression => 20,
270            Self::PnpmCatalogEntry => 21,
271            Self::UnresolvedCatalogReference => 22,
272            Self::UnusedDependencyOverride => 23,
273            Self::MisconfiguredDependencyOverride => 24,
274            Self::EmptyCatalogGroup => 25,
275            Self::ReExportCycle => 26,
276            Self::SecurityClientServerLeak => 27,
277            Self::SecuritySink => 28,
278            Self::PolicyViolation => 29,
279            Self::InvalidClientExport => 30,
280            Self::MixedClientServerBarrel => 31,
281            Self::MisplacedDirective => 32,
282            Self::UnusedStoreMember => 33,
283            Self::UnprovidedInject => 34,
284            Self::RouteCollision => 35,
285            Self::DynamicSegmentNameConflict => 36,
286            Self::UnrenderedComponent => 37,
287            Self::UnusedComponentProp => 38,
288            Self::UnusedComponentEmit => 39,
289            Self::UnusedServerAction => 40,
290            Self::UnusedLoadDataKey => 41,
291            Self::PropDrilling => 42,
292            Self::ThinWrapper => 43,
293            Self::DuplicatePropShape => 44,
294            Self::UnusedComponentInput => 45,
295            Self::UnusedComponentOutput => 46,
296            Self::UnusedSvelteEvent => 47,
297        }
298    }
299
300    /// Reconstruct from a cache discriminant.
301    #[must_use]
302    pub const fn from_discriminant(d: u8) -> Option<Self> {
303        match d {
304            1 => Some(Self::UnusedFile),
305            2 => Some(Self::UnusedExport),
306            3 => Some(Self::UnusedType),
307            4 => Some(Self::PrivateTypeLeak),
308            5 => Some(Self::UnusedDependency),
309            6 => Some(Self::UnusedDevDependency),
310            7 => Some(Self::UnusedEnumMember),
311            8 => Some(Self::UnusedClassMember),
312            9 => Some(Self::UnresolvedImport),
313            10 => Some(Self::UnlistedDependency),
314            11 => Some(Self::DuplicateExport),
315            12 => Some(Self::CodeDuplication),
316            13 => Some(Self::CircularDependency),
317            14 => Some(Self::TypeOnlyDependency),
318            15 => Some(Self::TestOnlyDependency),
319            16 => Some(Self::BoundaryViolation),
320            17 => Some(Self::CoverageGaps),
321            18 => Some(Self::FeatureFlag),
322            19 => Some(Self::Complexity),
323            20 => Some(Self::StaleSuppression),
324            21 => Some(Self::PnpmCatalogEntry),
325            22 => Some(Self::UnresolvedCatalogReference),
326            23 => Some(Self::UnusedDependencyOverride),
327            24 => Some(Self::MisconfiguredDependencyOverride),
328            25 => Some(Self::EmptyCatalogGroup),
329            26 => Some(Self::ReExportCycle),
330            27 => Some(Self::SecurityClientServerLeak),
331            28 => Some(Self::SecuritySink),
332            29 => Some(Self::PolicyViolation),
333            30 => Some(Self::InvalidClientExport),
334            31 => Some(Self::MixedClientServerBarrel),
335            32 => Some(Self::MisplacedDirective),
336            33 => Some(Self::UnusedStoreMember),
337            34 => Some(Self::UnprovidedInject),
338            35 => Some(Self::RouteCollision),
339            36 => Some(Self::DynamicSegmentNameConflict),
340            37 => Some(Self::UnrenderedComponent),
341            38 => Some(Self::UnusedComponentProp),
342            39 => Some(Self::UnusedComponentEmit),
343            40 => Some(Self::UnusedServerAction),
344            41 => Some(Self::UnusedLoadDataKey),
345            42 => Some(Self::PropDrilling),
346            43 => Some(Self::ThinWrapper),
347            44 => Some(Self::DuplicatePropShape),
348            45 => Some(Self::UnusedComponentInput),
349            46 => Some(Self::UnusedComponentOutput),
350            47 => Some(Self::UnusedSvelteEvent),
351            _ => None,
352        }
353    }
354}
355
356/// One scoped rule-pack policy suppression target.
357#[derive(Debug, Clone, PartialEq, Eq, Hash)]
358pub struct PolicyRuleSuppression {
359    /// Rule-pack name.
360    pub pack: String,
361    /// Rule id within the pack.
362    pub rule_id: String,
363}
364
365impl PolicyRuleSuppression {
366    /// Build a scoped policy suppression target.
367    #[must_use]
368    pub fn new(pack: impl Into<String>, rule_id: impl Into<String>) -> Self {
369        Self {
370            pack: pack.into(),
371            rule_id: rule_id.into(),
372        }
373    }
374
375    /// Canonical suppression token.
376    #[must_use]
377    pub fn token(&self) -> String {
378        format!("policy-violation:{}/{}", self.pack, self.rule_id)
379    }
380}
381
382/// A specific suppression target parsed from a comment token.
383#[derive(Debug, Clone, PartialEq, Eq)]
384pub enum SuppressionTarget {
385    /// A regular issue-kind token such as `unused-export` or bare
386    /// `policy-violation`.
387    Issue(IssueKind),
388    /// A scoped rule-pack policy token such as
389    /// `policy-violation:team-policy/no-child-process`.
390    PolicyRule(PolicyRuleSuppression),
391}
392
393impl SuppressionTarget {
394    /// Return the regular issue kind when this target is a bare issue-kind
395    /// token.
396    #[must_use]
397    pub const fn issue_kind(&self) -> Option<IssueKind> {
398        match self {
399            Self::Issue(kind) => Some(*kind),
400            Self::PolicyRule(_) => None,
401        }
402    }
403
404    /// Canonical suppression token for output and active-suppression capture.
405    #[must_use]
406    pub fn token(&self) -> String {
407        match self {
408            Self::Issue(kind) => issue_kind_to_kebab(*kind).to_owned(),
409            Self::PolicyRule(rule) => rule.token(),
410        }
411    }
412}
413
414/// Convert an [`IssueKind`] to its canonical suppression token.
415#[must_use]
416pub const fn issue_kind_to_kebab(kind: IssueKind) -> &'static str {
417    match kind {
418        IssueKind::UnusedFile => "unused-file",
419        IssueKind::UnusedExport => "unused-export",
420        IssueKind::UnusedType => "unused-type",
421        IssueKind::PrivateTypeLeak => "private-type-leak",
422        IssueKind::UnusedDependency => "unused-dependency",
423        IssueKind::UnusedDevDependency => "unused-dev-dependency",
424        IssueKind::UnusedEnumMember => "unused-enum-member",
425        IssueKind::UnusedClassMember => "unused-class-member",
426        IssueKind::UnresolvedImport => "unresolved-import",
427        IssueKind::UnlistedDependency => "unlisted-dependency",
428        IssueKind::DuplicateExport => "duplicate-export",
429        IssueKind::CodeDuplication => "code-duplication",
430        IssueKind::CircularDependency => "circular-dependency",
431        IssueKind::ReExportCycle => "re-export-cycle",
432        IssueKind::TypeOnlyDependency => "type-only-dependency",
433        IssueKind::TestOnlyDependency => "test-only-dependency",
434        IssueKind::BoundaryViolation => "boundary-violation",
435        IssueKind::CoverageGaps => "coverage-gaps",
436        IssueKind::FeatureFlag => "feature-flag",
437        IssueKind::Complexity => "complexity",
438        IssueKind::StaleSuppression => "stale-suppression",
439        IssueKind::PnpmCatalogEntry => "unused-catalog-entry",
440        IssueKind::EmptyCatalogGroup => "empty-catalog-group",
441        IssueKind::UnresolvedCatalogReference => "unresolved-catalog-reference",
442        IssueKind::UnusedDependencyOverride => "unused-dependency-override",
443        IssueKind::MisconfiguredDependencyOverride => "misconfigured-dependency-override",
444        IssueKind::SecurityClientServerLeak => "security-client-server-leak",
445        IssueKind::SecuritySink => "security-sink",
446        IssueKind::PolicyViolation => "policy-violation",
447        IssueKind::InvalidClientExport => "invalid-client-export",
448        IssueKind::MixedClientServerBarrel => "mixed-client-server-barrel",
449        IssueKind::MisplacedDirective => "misplaced-directive",
450        IssueKind::UnusedStoreMember => "unused-store-member",
451        IssueKind::UnprovidedInject => "unprovided-inject",
452        IssueKind::RouteCollision => "route-collision",
453        IssueKind::DynamicSegmentNameConflict => "dynamic-segment-name-conflict",
454        IssueKind::UnrenderedComponent => "unrendered-component",
455        IssueKind::UnusedComponentProp => "unused-component-prop",
456        IssueKind::UnusedComponentEmit => "unused-component-emit",
457        IssueKind::UnusedComponentInput => "unused-component-input",
458        IssueKind::UnusedComponentOutput => "unused-component-output",
459        IssueKind::UnusedServerAction => "unused-server-action",
460        IssueKind::UnusedLoadDataKey => "unused-load-data-key",
461        IssueKind::PropDrilling => "prop-drilling",
462        IssueKind::ThinWrapper => "thin-wrapper",
463        IssueKind::DuplicatePropShape => "duplicate-prop-shape",
464        IssueKind::UnusedSvelteEvent => "unused-svelte-event",
465    }
466}
467
468/// Parse a suppression token into a structured target.
469#[must_use]
470pub fn parse_suppression_target(token: &str) -> Option<SuppressionTarget> {
471    parse_policy_rule_suppression_token(token)
472        .map(SuppressionTarget::PolicyRule)
473        .or_else(|| IssueKind::parse(token).map(SuppressionTarget::Issue))
474}
475
476/// Parse canonical scoped policy suppression tokens.
477///
478/// The plural prefix is accepted for consistency with the bare legacy alias,
479/// but output always uses singular `policy-violation:`.
480#[must_use]
481pub fn parse_policy_rule_suppression_token(token: &str) -> Option<PolicyRuleSuppression> {
482    let identity = token
483        .strip_prefix("policy-violation:")
484        .or_else(|| token.strip_prefix("policy-violations:"))?;
485    let (pack, rule_id) = identity.split_once('/')?;
486    if rule_id.contains('/') {
487        return None;
488    }
489    if !is_valid_policy_identifier(pack) || !is_valid_policy_identifier(rule_id) {
490        return None;
491    }
492    Some(PolicyRuleSuppression::new(pack, rule_id))
493}
494
495/// Whether a rule-pack name or rule id can be used inside
496/// `policy-violation:<pack>/<rule-id>` without escaping.
497#[must_use]
498pub fn is_valid_policy_identifier(value: &str) -> bool {
499    !value.is_empty()
500        && value
501            .bytes()
502            .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'_' | b'-'))
503}
504
505/// A suppression directive parsed from a source comment.
506///
507/// # Examples
508///
509/// ```
510/// use fallow_types::suppress::{Suppression, IssueKind};
511///
512/// // File-wide suppression (line 0, no specific kind)
513/// let file_wide = Suppression::all(0, 1);
514/// assert_eq!(file_wide.line, 0);
515///
516/// // Line-specific suppression for unused exports
517/// let line_suppress = Suppression::issue(42, 41, IssueKind::UnusedExport);
518/// assert_eq!(line_suppress.issue_kind_target(), Some(IssueKind::UnusedExport));
519/// ```
520#[derive(Debug, Clone)]
521pub struct Suppression {
522    /// 1-based line this suppression applies to. 0 = file-wide suppression.
523    pub line: u32,
524    /// 1-based line where the suppression comment itself appears.
525    /// For `fallow-ignore-next-line`, this is `line - 1`.
526    /// For `fallow-ignore-file`, this is the actual line of the comment in the source.
527    pub comment_line: u32,
528    /// None = suppress all issue kinds on this line or file.
529    pub target: Option<SuppressionTarget>,
530}
531
532impl Suppression {
533    /// Build a blanket suppression.
534    #[must_use]
535    pub const fn all(line: u32, comment_line: u32) -> Self {
536        Self {
537            line,
538            comment_line,
539            target: None,
540        }
541    }
542
543    /// Build a regular issue-kind suppression.
544    #[must_use]
545    pub const fn issue(line: u32, comment_line: u32, kind: IssueKind) -> Self {
546        Self {
547            line,
548            comment_line,
549            target: Some(SuppressionTarget::Issue(kind)),
550        }
551    }
552
553    /// Build a scoped rule-pack policy suppression.
554    #[must_use]
555    pub fn policy_rule(
556        line: u32,
557        comment_line: u32,
558        pack: impl Into<String>,
559        rule_id: impl Into<String>,
560    ) -> Self {
561        Self {
562            line,
563            comment_line,
564            target: Some(SuppressionTarget::PolicyRule(PolicyRuleSuppression::new(
565                pack, rule_id,
566            ))),
567        }
568    }
569
570    /// The bare issue kind if this suppression targets one.
571    #[must_use]
572    pub const fn issue_kind_target(&self) -> Option<IssueKind> {
573        match &self.target {
574            Some(SuppressionTarget::Issue(kind)) => Some(*kind),
575            Some(SuppressionTarget::PolicyRule(_)) | None => None,
576        }
577    }
578
579    /// The scoped policy target if this suppression targets one rule-pack rule.
580    #[must_use]
581    pub const fn policy_rule_target(&self) -> Option<&PolicyRuleSuppression> {
582        match &self.target {
583            Some(SuppressionTarget::PolicyRule(rule)) => Some(rule),
584            Some(SuppressionTarget::Issue(_)) | None => None,
585        }
586    }
587
588    /// Canonical token for this suppression, or `None` for blanket comments.
589    #[must_use]
590    pub fn target_token(&self) -> Option<String> {
591        self.target.as_ref().map(SuppressionTarget::token)
592    }
593
594    /// Whether the comment applies to `line`.
595    #[must_use]
596    pub const fn applies_to_line(&self, line: u32) -> bool {
597        self.line == 0 || self.line == line
598    }
599
600    /// Whether this suppression covers a regular issue kind on a line.
601    ///
602    /// Scoped policy-rule targets intentionally do not match this generic
603    /// predicate. Policy detection uses [`Self::matches_policy_rule`] so the
604    /// exact pack and rule id are available.
605    #[must_use]
606    pub fn matches_issue_kind(&self, line: u32, kind: IssueKind) -> bool {
607        self.applies_to_line(line)
608            && match &self.target {
609                None => true,
610                Some(SuppressionTarget::Issue(target_kind)) => *target_kind == kind,
611                Some(SuppressionTarget::PolicyRule(_)) => false,
612            }
613    }
614
615    /// Whether this suppression covers a policy finding on a line.
616    #[must_use]
617    pub fn matches_policy_rule(&self, line: u32, pack: &str, rule_id: &str) -> bool {
618        self.applies_to_line(line)
619            && match &self.target {
620                None | Some(SuppressionTarget::Issue(IssueKind::PolicyViolation)) => true,
621                Some(SuppressionTarget::Issue(_)) => false,
622                Some(SuppressionTarget::PolicyRule(target)) => {
623                    target.pack == pack && target.rule_id == rule_id
624                }
625            }
626    }
627}
628
629/// A suppression token that did not parse to any known `IssueKind`.
630///
631/// Emitted alongside `Suppression` when a `// fallow-ignore-*` marker contains
632/// a typo or an obsolete issue-kind name. The known tokens on the same marker
633/// are recorded as normal `Suppression` entries; this struct preserves the
634/// unknown token so the downstream `find_stale` pass can surface it as a
635/// `StaleSuppression` finding with `kind_known: false`. Without this, the
636/// entire suppression line would be discarded silently. See issue #449.
637#[derive(Debug, Clone)]
638pub struct UnknownSuppressionKind {
639    /// 1-based line where the suppression comment itself appears.
640    pub comment_line: u32,
641    /// Whether the marker was `fallow-ignore-file` (`true`) or
642    /// `fallow-ignore-next-line` (`false`).
643    pub is_file_level: bool,
644    /// The verbatim token from the marker that did not parse.
645    pub token: String,
646}
647
648/// Canonical kebab-case names accepted by `IssueKind::parse`, including
649/// documented plural aliases.
650///
651/// Used by `closest_known_kind_name` for Levenshtein "did you mean?" hints
652/// when a suppression marker carries an unknown token. Keep in sync with the
653/// `IssueKind::parse` match table above; the
654/// `issue_kind_parse_covers_known_names` test asserts every entry round-trips.
655pub const KNOWN_ISSUE_KIND_NAMES: &[&str] = &[
656    "unused-file",
657    "unused-export",
658    "unused-type",
659    "private-type-leak",
660    "unused-dependency",
661    "unused-dev-dependency",
662    "unused-enum-member",
663    "unused-class-member",
664    "unresolved-import",
665    "unlisted-dependency",
666    "duplicate-export",
667    "code-duplication",
668    "circular-dependency",
669    "circular-dependencies",
670    "re-export-cycle",
671    "re-export-cycles",
672    "reexport-cycle",
673    "reexport-cycles",
674    "type-only-dependency",
675    "test-only-dependency",
676    "boundary-violation",
677    "boundary-call-violation",
678    "boundary-call-violations",
679    "coverage-gaps",
680    "feature-flag",
681    "complexity",
682    "stale-suppression",
683    "unused-catalog-entry",
684    "unused-catalog-entries",
685    "empty-catalog-group",
686    "empty-catalog-groups",
687    "unresolved-catalog-reference",
688    "unresolved-catalog-references",
689    "unused-dependency-override",
690    "unused-dependency-overrides",
691    "misconfigured-dependency-override",
692    "misconfigured-dependency-overrides",
693    "security-client-server-leak",
694    "security-sink",
695    "policy-violation",
696    "policy-violations",
697    "invalid-client-export",
698    "invalid-client-exports",
699    "mixed-client-server-barrel",
700    "mixed-client-server-barrels",
701    "misplaced-directive",
702    "misplaced-directives",
703    "unused-store-member",
704    "unused-store-members",
705    "unprovided-inject",
706    "unprovided-injects",
707    "route-collision",
708    "route-collisions",
709    "dynamic-segment-name-conflict",
710    "dynamic-segment-name-conflicts",
711    "unrendered-component",
712    "unrendered-components",
713    "unused-component-prop",
714    "unused-component-props",
715    "unused-component-emit",
716    "unused-component-emits",
717    "unused-component-input",
718    "unused-component-inputs",
719    "unused-component-output",
720    "unused-component-outputs",
721    "unused-server-action",
722    "unused-server-actions",
723    "unused-load-data-key",
724    "unused-load-data-keys",
725    "prop-drilling",
726    "thin-wrapper",
727    "thin-wrappers",
728    "duplicate-prop-shape",
729    "duplicate-prop-shapes",
730    "unused-svelte-event",
731    "unused-svelte-events",
732];
733
734/// CLI filter flags on `fallow dead-code` that scope output to a single
735/// issue type.
736///
737/// Shared home so the agent capability manifest (`fallow schema` in
738/// `crates/cli`), the MCP server's `issue_types` allowlist
739/// (`ISSUE_TYPE_FLAGS` in `crates/mcp`), and the clap flag definitions stay
740/// in sync: each crate carries a drift test asserting its own list against
741/// this one.
742pub const DEAD_CODE_FILTER_FLAGS: &[&str] = &[
743    "--unused-files",
744    "--unused-exports",
745    "--unused-types",
746    "--private-type-leaks",
747    "--unused-deps",
748    "--unused-enum-members",
749    "--unused-class-members",
750    "--unused-store-members",
751    "--unprovided-injects",
752    "--unrendered-components",
753    "--unused-component-props",
754    "--unused-component-emits",
755    "--unused-component-inputs",
756    "--unused-component-outputs",
757    "--unused-svelte-events",
758    "--unused-server-actions",
759    "--unused-load-data-keys",
760    "--unresolved-imports",
761    "--unlisted-deps",
762    "--duplicate-exports",
763    "--circular-deps",
764    "--re-export-cycles",
765    "--boundary-violations",
766    "--policy-violations",
767    "--stale-suppressions",
768    "--unused-catalog-entries",
769    "--empty-catalog-groups",
770    "--unresolved-catalog-references",
771    "--unused-dependency-overrides",
772    "--misconfigured-dependency-overrides",
773];
774
775/// Levenshtein edit distance between two ASCII-leaning strings.
776///
777/// Local duplicate of the config-crate helper (see
778/// `crates/config/src/config/rules.rs::levenshtein`) so `fallow-types` can
779/// compute "did you mean?" suggestions for unknown suppression tokens without
780/// taking a dependency on `fallow-config`. Issue-kind names are short
781/// (max ~33 chars) so allocation cost is negligible.
782fn levenshtein(a: &str, b: &str) -> usize {
783    let a_bytes = a.as_bytes();
784    let b_bytes = b.as_bytes();
785    let (a_len, b_len) = (a_bytes.len(), b_bytes.len());
786
787    if a_len == 0 {
788        return b_len;
789    }
790    if b_len == 0 {
791        return a_len;
792    }
793
794    let mut prev: Vec<usize> = (0..=b_len).collect();
795    let mut curr: Vec<usize> = vec![0; b_len + 1];
796
797    for i in 1..=a_len {
798        curr[0] = i;
799        for j in 1..=b_len {
800            let cost = usize::from(a_bytes[i - 1] != b_bytes[j - 1]);
801            curr[j] = (prev[j] + 1).min(curr[j - 1] + 1).min(prev[j - 1] + cost);
802        }
803        std::mem::swap(&mut prev, &mut curr);
804    }
805
806    prev[b_len]
807}
808
809/// Find the closest known issue-kind name to `input` when it is plausibly a typo.
810///
811/// Returns the best match when the Levenshtein distance is at most 2 AND
812/// the input is long enough that the match is not coincidental
813/// (`input.len() / 2 > distance`). Returns `None` for completely novel
814/// strings where a suggestion would be misleading.
815#[must_use]
816pub fn closest_known_kind_name(input: &str) -> Option<&'static str> {
817    let input_lower = input.to_ascii_lowercase();
818    let mut best: Option<(&'static str, usize)> = None;
819
820    for &candidate in KNOWN_ISSUE_KIND_NAMES {
821        let d = levenshtein(&input_lower, candidate);
822        if best.is_none_or(|(_, b_dist)| d < b_dist) {
823            best = Some((candidate, d));
824        }
825    }
826
827    best.filter(|&(_, d)| d > 0 && d <= 2 && input_lower.len() / 2 > d)
828        .map(|(name, _)| name)
829}
830
831const _: () = assert!(std::mem::size_of::<IssueKind>() == 1);
832
833#[cfg(test)]
834mod tests {
835    use super::*;
836
837    #[test]
838    #[expect(
839        clippy::too_many_lines,
840        reason = "exhaustive per-variant parse assertions; one block per issue kind"
841    )]
842    fn issue_kind_from_str_all_variants() {
843        assert_eq!(IssueKind::parse("unused-file"), Some(IssueKind::UnusedFile));
844        assert_eq!(
845            IssueKind::parse("unused-export"),
846            Some(IssueKind::UnusedExport)
847        );
848        assert_eq!(IssueKind::parse("unused-type"), Some(IssueKind::UnusedType));
849        assert_eq!(
850            IssueKind::parse("private-type-leak"),
851            Some(IssueKind::PrivateTypeLeak)
852        );
853        assert_eq!(
854            IssueKind::parse("unused-dependency"),
855            Some(IssueKind::UnusedDependency)
856        );
857        assert_eq!(
858            IssueKind::parse("unused-dev-dependency"),
859            Some(IssueKind::UnusedDevDependency)
860        );
861        assert_eq!(
862            IssueKind::parse("unused-enum-member"),
863            Some(IssueKind::UnusedEnumMember)
864        );
865        assert_eq!(
866            IssueKind::parse("unused-class-member"),
867            Some(IssueKind::UnusedClassMember)
868        );
869        assert_eq!(
870            IssueKind::parse("unresolved-import"),
871            Some(IssueKind::UnresolvedImport)
872        );
873        assert_eq!(
874            IssueKind::parse("unlisted-dependency"),
875            Some(IssueKind::UnlistedDependency)
876        );
877        assert_eq!(
878            IssueKind::parse("duplicate-export"),
879            Some(IssueKind::DuplicateExport)
880        );
881        assert_eq!(
882            IssueKind::parse("code-duplication"),
883            Some(IssueKind::CodeDuplication)
884        );
885        assert_eq!(
886            IssueKind::parse("circular-dependency"),
887            Some(IssueKind::CircularDependency)
888        );
889        assert_eq!(
890            IssueKind::parse("circular-dependencies"),
891            Some(IssueKind::CircularDependency)
892        );
893        assert_eq!(
894            IssueKind::parse("type-only-dependency"),
895            Some(IssueKind::TypeOnlyDependency)
896        );
897        assert_eq!(
898            IssueKind::parse("test-only-dependency"),
899            Some(IssueKind::TestOnlyDependency)
900        );
901        assert_eq!(
902            IssueKind::parse("boundary-violation"),
903            Some(IssueKind::BoundaryViolation)
904        );
905        // The boundary family token also accepts the rule-id-shaped alias so
906        // users who derive the token from the `boundary-call-violation` rule
907        // id by analogy get a working suppression instead of a silent no-op.
908        assert_eq!(
909            IssueKind::parse("boundary-call-violation"),
910            Some(IssueKind::BoundaryViolation)
911        );
912        assert_eq!(
913            IssueKind::parse("boundary-call-violations"),
914            Some(IssueKind::BoundaryViolation)
915        );
916        assert_eq!(
917            IssueKind::parse("coverage-gaps"),
918            Some(IssueKind::CoverageGaps)
919        );
920        assert_eq!(
921            IssueKind::parse("feature-flag"),
922            Some(IssueKind::FeatureFlag)
923        );
924        assert_eq!(IssueKind::parse("complexity"), Some(IssueKind::Complexity));
925        assert_eq!(
926            IssueKind::parse("stale-suppression"),
927            Some(IssueKind::StaleSuppression)
928        );
929        assert_eq!(
930            IssueKind::parse("unused-catalog-entry"),
931            Some(IssueKind::PnpmCatalogEntry)
932        );
933        assert_eq!(
934            IssueKind::parse("unused-catalog-entries"),
935            Some(IssueKind::PnpmCatalogEntry)
936        );
937        assert_eq!(
938            IssueKind::parse("empty-catalog-group"),
939            Some(IssueKind::EmptyCatalogGroup)
940        );
941        assert_eq!(
942            IssueKind::parse("empty-catalog-groups"),
943            Some(IssueKind::EmptyCatalogGroup)
944        );
945        assert_eq!(
946            IssueKind::parse("unresolved-catalog-reference"),
947            Some(IssueKind::UnresolvedCatalogReference)
948        );
949        assert_eq!(
950            IssueKind::parse("unresolved-catalog-references"),
951            Some(IssueKind::UnresolvedCatalogReference)
952        );
953        assert_eq!(
954            IssueKind::parse("unused-dependency-override"),
955            Some(IssueKind::UnusedDependencyOverride)
956        );
957        assert_eq!(
958            IssueKind::parse("unused-dependency-overrides"),
959            Some(IssueKind::UnusedDependencyOverride)
960        );
961        assert_eq!(
962            IssueKind::parse("misconfigured-dependency-override"),
963            Some(IssueKind::MisconfiguredDependencyOverride)
964        );
965        assert_eq!(
966            IssueKind::parse("misconfigured-dependency-overrides"),
967            Some(IssueKind::MisconfiguredDependencyOverride)
968        );
969        assert_eq!(
970            IssueKind::parse("security-client-server-leak"),
971            Some(IssueKind::SecurityClientServerLeak)
972        );
973        assert_eq!(
974            IssueKind::parse("security-sink"),
975            Some(IssueKind::SecuritySink)
976        );
977        assert_eq!(
978            IssueKind::parse("policy-violation"),
979            Some(IssueKind::PolicyViolation)
980        );
981        assert_eq!(
982            IssueKind::parse("policy-violations"),
983            Some(IssueKind::PolicyViolation)
984        );
985        assert_eq!(
986            IssueKind::parse("invalid-client-export"),
987            Some(IssueKind::InvalidClientExport)
988        );
989        assert_eq!(
990            IssueKind::parse("invalid-client-exports"),
991            Some(IssueKind::InvalidClientExport)
992        );
993        assert_eq!(
994            IssueKind::parse("mixed-client-server-barrel"),
995            Some(IssueKind::MixedClientServerBarrel)
996        );
997        assert_eq!(
998            IssueKind::parse("mixed-client-server-barrels"),
999            Some(IssueKind::MixedClientServerBarrel)
1000        );
1001        assert_eq!(
1002            IssueKind::parse("misplaced-directive"),
1003            Some(IssueKind::MisplacedDirective)
1004        );
1005        assert_eq!(
1006            IssueKind::parse("misplaced-directives"),
1007            Some(IssueKind::MisplacedDirective)
1008        );
1009        assert_eq!(
1010            IssueKind::parse("route-collision"),
1011            Some(IssueKind::RouteCollision)
1012        );
1013        assert_eq!(
1014            IssueKind::parse("route-collisions"),
1015            Some(IssueKind::RouteCollision)
1016        );
1017        assert_eq!(
1018            IssueKind::parse("dynamic-segment-name-conflict"),
1019            Some(IssueKind::DynamicSegmentNameConflict)
1020        );
1021        assert_eq!(
1022            IssueKind::parse("dynamic-segment-name-conflicts"),
1023            Some(IssueKind::DynamicSegmentNameConflict)
1024        );
1025        assert_eq!(
1026            IssueKind::parse("unrendered-component"),
1027            Some(IssueKind::UnrenderedComponent)
1028        );
1029        assert_eq!(
1030            IssueKind::parse("unrendered-components"),
1031            Some(IssueKind::UnrenderedComponent)
1032        );
1033        assert_eq!(
1034            IssueKind::parse("unused-component-prop"),
1035            Some(IssueKind::UnusedComponentProp)
1036        );
1037        assert_eq!(
1038            IssueKind::parse("unused-component-props"),
1039            Some(IssueKind::UnusedComponentProp)
1040        );
1041        assert_eq!(
1042            IssueKind::parse("unused-component-emit"),
1043            Some(IssueKind::UnusedComponentEmit)
1044        );
1045        assert_eq!(
1046            IssueKind::parse("unused-component-emits"),
1047            Some(IssueKind::UnusedComponentEmit)
1048        );
1049        assert_eq!(
1050            IssueKind::parse("unused-component-input"),
1051            Some(IssueKind::UnusedComponentInput)
1052        );
1053        assert_eq!(
1054            IssueKind::parse("unused-component-inputs"),
1055            Some(IssueKind::UnusedComponentInput)
1056        );
1057        assert_eq!(
1058            IssueKind::parse("unused-component-output"),
1059            Some(IssueKind::UnusedComponentOutput)
1060        );
1061        assert_eq!(
1062            IssueKind::parse("unused-component-outputs"),
1063            Some(IssueKind::UnusedComponentOutput)
1064        );
1065        assert_eq!(
1066            IssueKind::parse("unused-load-data-key"),
1067            Some(IssueKind::UnusedLoadDataKey)
1068        );
1069        assert_eq!(
1070            IssueKind::parse("unused-load-data-keys"),
1071            Some(IssueKind::UnusedLoadDataKey)
1072        );
1073        assert_eq!(
1074            IssueKind::parse("prop-drilling"),
1075            Some(IssueKind::PropDrilling)
1076        );
1077        assert_eq!(
1078            IssueKind::parse("unused-svelte-event"),
1079            Some(IssueKind::UnusedSvelteEvent)
1080        );
1081        assert_eq!(
1082            IssueKind::parse("unused-svelte-events"),
1083            Some(IssueKind::UnusedSvelteEvent)
1084        );
1085    }
1086
1087    #[test]
1088    fn issue_kind_from_str_unknown() {
1089        assert_eq!(IssueKind::parse("foo"), None);
1090        assert_eq!(IssueKind::parse(""), None);
1091    }
1092
1093    #[test]
1094    fn issue_kind_from_str_near_misses() {
1095        assert_eq!(IssueKind::parse("Unused-File"), None);
1096        assert_eq!(IssueKind::parse("UNUSED-EXPORT"), None);
1097        assert_eq!(IssueKind::parse("unused_file"), None);
1098        assert_eq!(IssueKind::parse("unused-files"), None);
1099    }
1100
1101    #[test]
1102    fn discriminant_out_of_range() {
1103        assert_eq!(IssueKind::from_discriminant(0), None);
1104        assert_eq!(
1105            IssueKind::from_discriminant(29),
1106            Some(IssueKind::PolicyViolation)
1107        );
1108        assert_eq!(
1109            IssueKind::from_discriminant(30),
1110            Some(IssueKind::InvalidClientExport)
1111        );
1112        assert_eq!(
1113            IssueKind::from_discriminant(31),
1114            Some(IssueKind::MixedClientServerBarrel)
1115        );
1116        assert_eq!(
1117            IssueKind::from_discriminant(32),
1118            Some(IssueKind::MisplacedDirective)
1119        );
1120        assert_eq!(
1121            IssueKind::from_discriminant(33),
1122            Some(IssueKind::UnusedStoreMember)
1123        );
1124        assert_eq!(
1125            IssueKind::from_discriminant(34),
1126            Some(IssueKind::UnprovidedInject)
1127        );
1128        assert_eq!(
1129            IssueKind::from_discriminant(35),
1130            Some(IssueKind::RouteCollision)
1131        );
1132        assert_eq!(
1133            IssueKind::from_discriminant(36),
1134            Some(IssueKind::DynamicSegmentNameConflict)
1135        );
1136        assert_eq!(
1137            IssueKind::from_discriminant(37),
1138            Some(IssueKind::UnrenderedComponent)
1139        );
1140        assert_eq!(
1141            IssueKind::from_discriminant(38),
1142            Some(IssueKind::UnusedComponentProp)
1143        );
1144        assert_eq!(
1145            IssueKind::from_discriminant(39),
1146            Some(IssueKind::UnusedComponentEmit)
1147        );
1148        assert_eq!(
1149            IssueKind::from_discriminant(40),
1150            Some(IssueKind::UnusedServerAction)
1151        );
1152        assert_eq!(
1153            IssueKind::from_discriminant(41),
1154            Some(IssueKind::UnusedLoadDataKey)
1155        );
1156        assert_eq!(
1157            IssueKind::from_discriminant(42),
1158            Some(IssueKind::PropDrilling)
1159        );
1160        assert_eq!(
1161            IssueKind::from_discriminant(43),
1162            Some(IssueKind::ThinWrapper)
1163        );
1164        assert_eq!(
1165            IssueKind::from_discriminant(44),
1166            Some(IssueKind::DuplicatePropShape)
1167        );
1168        assert_eq!(
1169            IssueKind::from_discriminant(45),
1170            Some(IssueKind::UnusedComponentInput)
1171        );
1172        assert_eq!(
1173            IssueKind::from_discriminant(46),
1174            Some(IssueKind::UnusedComponentOutput)
1175        );
1176        assert_eq!(
1177            IssueKind::from_discriminant(47),
1178            Some(IssueKind::UnusedSvelteEvent)
1179        );
1180        assert_eq!(IssueKind::from_discriminant(48), None);
1181        assert_eq!(IssueKind::from_discriminant(u8::MAX), None);
1182    }
1183
1184    #[test]
1185    fn discriminant_roundtrip() {
1186        for kind in [
1187            IssueKind::UnusedFile,
1188            IssueKind::UnusedExport,
1189            IssueKind::UnusedType,
1190            IssueKind::PrivateTypeLeak,
1191            IssueKind::UnusedDependency,
1192            IssueKind::UnusedDevDependency,
1193            IssueKind::UnusedEnumMember,
1194            IssueKind::UnusedClassMember,
1195            IssueKind::UnresolvedImport,
1196            IssueKind::UnlistedDependency,
1197            IssueKind::DuplicateExport,
1198            IssueKind::CodeDuplication,
1199            IssueKind::CircularDependency,
1200            IssueKind::ReExportCycle,
1201            IssueKind::TypeOnlyDependency,
1202            IssueKind::TestOnlyDependency,
1203            IssueKind::BoundaryViolation,
1204            IssueKind::CoverageGaps,
1205            IssueKind::FeatureFlag,
1206            IssueKind::Complexity,
1207            IssueKind::StaleSuppression,
1208            IssueKind::PnpmCatalogEntry,
1209            IssueKind::EmptyCatalogGroup,
1210            IssueKind::UnresolvedCatalogReference,
1211            IssueKind::UnusedDependencyOverride,
1212            IssueKind::MisconfiguredDependencyOverride,
1213            IssueKind::SecurityClientServerLeak,
1214            IssueKind::SecuritySink,
1215            IssueKind::PolicyViolation,
1216            IssueKind::InvalidClientExport,
1217            IssueKind::MixedClientServerBarrel,
1218            IssueKind::MisplacedDirective,
1219            IssueKind::UnusedStoreMember,
1220            IssueKind::UnprovidedInject,
1221            IssueKind::RouteCollision,
1222            IssueKind::DynamicSegmentNameConflict,
1223            IssueKind::UnrenderedComponent,
1224            IssueKind::UnusedComponentProp,
1225            IssueKind::UnusedComponentEmit,
1226            IssueKind::UnusedServerAction,
1227            IssueKind::UnusedLoadDataKey,
1228            IssueKind::PropDrilling,
1229            IssueKind::ThinWrapper,
1230            IssueKind::DuplicatePropShape,
1231            IssueKind::UnusedComponentInput,
1232            IssueKind::UnusedComponentOutput,
1233            IssueKind::UnusedSvelteEvent,
1234        ] {
1235            assert_eq!(
1236                IssueKind::from_discriminant(kind.to_discriminant()),
1237                Some(kind)
1238            );
1239        }
1240        assert_eq!(IssueKind::from_discriminant(0), None);
1241        assert_eq!(IssueKind::from_discriminant(48), None);
1242    }
1243
1244    #[test]
1245    fn discriminant_values_are_unique() {
1246        let all_kinds = [
1247            IssueKind::UnusedFile,
1248            IssueKind::UnusedExport,
1249            IssueKind::UnusedType,
1250            IssueKind::PrivateTypeLeak,
1251            IssueKind::UnusedDependency,
1252            IssueKind::UnusedDevDependency,
1253            IssueKind::UnusedEnumMember,
1254            IssueKind::UnusedClassMember,
1255            IssueKind::UnresolvedImport,
1256            IssueKind::UnlistedDependency,
1257            IssueKind::DuplicateExport,
1258            IssueKind::CodeDuplication,
1259            IssueKind::CircularDependency,
1260            IssueKind::ReExportCycle,
1261            IssueKind::TypeOnlyDependency,
1262            IssueKind::TestOnlyDependency,
1263            IssueKind::BoundaryViolation,
1264            IssueKind::CoverageGaps,
1265            IssueKind::FeatureFlag,
1266            IssueKind::Complexity,
1267            IssueKind::StaleSuppression,
1268            IssueKind::PnpmCatalogEntry,
1269            IssueKind::EmptyCatalogGroup,
1270            IssueKind::UnresolvedCatalogReference,
1271            IssueKind::UnusedDependencyOverride,
1272            IssueKind::MisconfiguredDependencyOverride,
1273            IssueKind::SecurityClientServerLeak,
1274            IssueKind::SecuritySink,
1275            IssueKind::PolicyViolation,
1276            IssueKind::InvalidClientExport,
1277            IssueKind::MixedClientServerBarrel,
1278            IssueKind::MisplacedDirective,
1279            IssueKind::UnusedStoreMember,
1280            IssueKind::UnprovidedInject,
1281            IssueKind::RouteCollision,
1282            IssueKind::DynamicSegmentNameConflict,
1283            IssueKind::UnrenderedComponent,
1284            IssueKind::UnusedComponentProp,
1285            IssueKind::UnusedComponentEmit,
1286            IssueKind::UnusedServerAction,
1287            IssueKind::UnusedLoadDataKey,
1288            IssueKind::PropDrilling,
1289            IssueKind::ThinWrapper,
1290            IssueKind::DuplicatePropShape,
1291            IssueKind::UnusedComponentInput,
1292            IssueKind::UnusedComponentOutput,
1293            IssueKind::UnusedSvelteEvent,
1294        ];
1295        let discriminants: Vec<u8> = all_kinds.iter().map(|k| k.to_discriminant()).collect();
1296        let mut sorted = discriminants.clone();
1297        sorted.sort_unstable();
1298        sorted.dedup();
1299        assert_eq!(
1300            discriminants.len(),
1301            sorted.len(),
1302            "discriminant values must be unique"
1303        );
1304    }
1305
1306    #[test]
1307    fn discriminant_starts_at_one() {
1308        assert_eq!(IssueKind::UnusedFile.to_discriminant(), 1);
1309    }
1310
1311    #[test]
1312    fn suppression_line_zero_is_file_wide() {
1313        let s = Suppression::all(0, 1);
1314        assert_eq!(s.line, 0);
1315        assert!(s.issue_kind_target().is_none());
1316    }
1317
1318    #[test]
1319    fn suppression_with_specific_kind_and_line() {
1320        let s = Suppression::issue(42, 41, IssueKind::UnusedExport);
1321        assert_eq!(s.line, 42);
1322        assert_eq!(s.comment_line, 41);
1323        assert_eq!(s.issue_kind_target(), Some(IssueKind::UnusedExport));
1324    }
1325
1326    #[test]
1327    fn parses_scoped_policy_suppression_token() {
1328        let target =
1329            parse_policy_rule_suppression_token("policy-violation:team-policy/no-child-process")
1330                .expect("scoped token should parse");
1331        assert_eq!(target.pack, "team-policy");
1332        assert_eq!(target.rule_id, "no-child-process");
1333        assert_eq!(
1334            target.token(),
1335            "policy-violation:team-policy/no-child-process"
1336        );
1337    }
1338
1339    #[test]
1340    fn rejects_malformed_scoped_policy_suppression_tokens() {
1341        for token in [
1342            "policy-violation:",
1343            "policy-violation:team-policy",
1344            "policy-violation:/no-child-process",
1345            "policy-violation:team-policy/",
1346            "policy-violation:team-policy/no/child-process",
1347            "policy-violation:team policy/no-child-process",
1348            "policy-violation:team-policy/no:child-process",
1349        ] {
1350            assert!(
1351                parse_policy_rule_suppression_token(token).is_none(),
1352                "{token} should be rejected"
1353            );
1354        }
1355    }
1356
1357    #[test]
1358    fn scoped_policy_suppression_matches_exact_policy_rule_only() {
1359        let suppression = Suppression::policy_rule(7, 6, "team-policy", "no-child-process");
1360        assert!(suppression.matches_policy_rule(7, "team-policy", "no-child-process"));
1361        assert!(!suppression.matches_policy_rule(7, "team-policy", "no-fs"));
1362        assert!(!suppression.matches_policy_rule(8, "team-policy", "no-child-process"));
1363        assert!(!suppression.matches_issue_kind(7, IssueKind::PolicyViolation));
1364    }
1365
1366    #[test]
1367    fn known_issue_kind_names_parses_each_entry() {
1368        for &name in KNOWN_ISSUE_KIND_NAMES {
1369            assert!(
1370                IssueKind::parse(name).is_some(),
1371                "KNOWN_ISSUE_KIND_NAMES contains '{name}' but IssueKind::parse rejects it"
1372            );
1373        }
1374    }
1375
1376    #[test]
1377    fn closest_known_kind_name_finds_near_misses() {
1378        assert_eq!(
1379            closest_known_kind_name("unused-exports"),
1380            Some("unused-export")
1381        );
1382        assert_eq!(closest_known_kind_name("unused-files"), Some("unused-file"));
1383        assert_eq!(closest_known_kind_name("complxity"), Some("complexity"));
1384    }
1385
1386    #[test]
1387    fn closest_known_kind_name_rejects_novel_strings() {
1388        assert_eq!(closest_known_kind_name("xyzzy"), None);
1389        assert_eq!(closest_known_kind_name("foo"), None);
1390        assert_eq!(closest_known_kind_name(""), None);
1391    }
1392
1393    #[test]
1394    fn closest_known_kind_name_skips_exact_match() {
1395        assert_eq!(closest_known_kind_name("unused-export"), None);
1396    }
1397}