Skip to main content

fallow_types/
suppress.rs

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