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