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    /// A CSS / CSS-in-JS design-token DRIFT candidate surfaced in `fallow audit`
173    /// as an advisory styling finding: a hardcoded value where a design token
174    /// exists (a Tailwind arbitrary value like `w-[13px]`, or a near-duplicate
175    /// token). Styling-domain finding produced by the health-time css pass (not
176    /// dead-code); the rule defaults to `warn` and is verdict-neutral.
177    CssTokenDrift,
178    /// A CSS / CSS-in-JS DUPLICATE declaration block: a copy-pasted rule body
179    /// repeated across selectors, a consolidation candidate. Styling-domain
180    /// advisory (rule defaults to `warn`, verdict-neutral); the audit copy of
181    /// this is changed-file-local.
182    CssDuplicateBlock,
183    /// A CSS selector / nesting / important-density complexity finding surfaced
184    /// as advisory styling feedback. Styling-domain finding produced by the
185    /// health-time css pass; defaults to `warn` and is verdict-neutral.
186    CssSelectorComplexity,
187    /// A CSS dead-surface finding, such as unused scoped SFC classes. Styling-
188    /// domain advisory surfaced in `fallow audit`; defaults to `warn` and is
189    /// verdict-neutral.
190    CssDeadSurface,
191    /// A CSS broken-reference finding, such as a class or keyframes reference
192    /// that resolves to no stylesheet definition. Styling-domain advisory
193    /// surfaced by deep CSS audit mode; defaults to `warn` and is
194    /// verdict-neutral.
195    CssBrokenReference,
196}
197
198impl IssueKind {
199    /// Stable inventory of all issue kinds.
200    pub const ALL: &'static [Self] = &[
201        Self::UnusedFile,
202        Self::UnusedExport,
203        Self::UnusedType,
204        Self::PrivateTypeLeak,
205        Self::UnusedDependency,
206        Self::UnusedDevDependency,
207        Self::UnusedEnumMember,
208        Self::UnusedClassMember,
209        Self::UnresolvedImport,
210        Self::UnlistedDependency,
211        Self::DuplicateExport,
212        Self::CodeDuplication,
213        Self::CircularDependency,
214        Self::ReExportCycle,
215        Self::TypeOnlyDependency,
216        Self::TestOnlyDependency,
217        Self::BoundaryViolation,
218        Self::CoverageGaps,
219        Self::FeatureFlag,
220        Self::Complexity,
221        Self::StaleSuppression,
222        Self::PnpmCatalogEntry,
223        Self::EmptyCatalogGroup,
224        Self::UnresolvedCatalogReference,
225        Self::UnusedDependencyOverride,
226        Self::MisconfiguredDependencyOverride,
227        Self::SecurityClientServerLeak,
228        Self::SecuritySink,
229        Self::PolicyViolation,
230        Self::InvalidClientExport,
231        Self::MixedClientServerBarrel,
232        Self::MisplacedDirective,
233        Self::UnusedStoreMember,
234        Self::UnprovidedInject,
235        Self::RouteCollision,
236        Self::DynamicSegmentNameConflict,
237        Self::UnrenderedComponent,
238        Self::UnusedComponentProp,
239        Self::UnusedComponentEmit,
240        Self::UnusedComponentInput,
241        Self::UnusedComponentOutput,
242        Self::UnusedServerAction,
243        Self::UnusedLoadDataKey,
244        Self::PropDrilling,
245        Self::ThinWrapper,
246        Self::DuplicatePropShape,
247        Self::UnusedSvelteEvent,
248        Self::CssTokenDrift,
249        Self::CssDuplicateBlock,
250        Self::CssSelectorComplexity,
251        Self::CssDeadSurface,
252        Self::CssBrokenReference,
253    ];
254
255    /// Parse an issue kind from the string tokens used in CLI output and suppression comments.
256    #[must_use]
257    pub fn parse(s: &str) -> Option<Self> {
258        crate::issue_meta::issue_meta_for_token(s).and_then(|meta| meta.kind)
259    }
260
261    /// Convert to a u8 discriminant for compact cache storage.
262    #[must_use]
263    pub const fn to_discriminant(self) -> u8 {
264        match self {
265            Self::UnusedFile => 1,
266            Self::UnusedExport => 2,
267            Self::UnusedType => 3,
268            Self::PrivateTypeLeak => 4,
269            Self::UnusedDependency => 5,
270            Self::UnusedDevDependency => 6,
271            Self::UnusedEnumMember => 7,
272            Self::UnusedClassMember => 8,
273            Self::UnresolvedImport => 9,
274            Self::UnlistedDependency => 10,
275            Self::DuplicateExport => 11,
276            Self::CodeDuplication => 12,
277            Self::CircularDependency => 13,
278            Self::TypeOnlyDependency => 14,
279            Self::TestOnlyDependency => 15,
280            Self::BoundaryViolation => 16,
281            Self::CoverageGaps => 17,
282            Self::FeatureFlag => 18,
283            Self::Complexity => 19,
284            Self::StaleSuppression => 20,
285            Self::PnpmCatalogEntry => 21,
286            Self::UnresolvedCatalogReference => 22,
287            Self::UnusedDependencyOverride => 23,
288            Self::MisconfiguredDependencyOverride => 24,
289            Self::EmptyCatalogGroup => 25,
290            Self::ReExportCycle => 26,
291            Self::SecurityClientServerLeak => 27,
292            Self::SecuritySink => 28,
293            Self::PolicyViolation => 29,
294            Self::InvalidClientExport => 30,
295            Self::MixedClientServerBarrel => 31,
296            Self::MisplacedDirective => 32,
297            Self::UnusedStoreMember => 33,
298            Self::UnprovidedInject => 34,
299            Self::RouteCollision => 35,
300            Self::DynamicSegmentNameConflict => 36,
301            Self::UnrenderedComponent => 37,
302            Self::UnusedComponentProp => 38,
303            Self::UnusedComponentEmit => 39,
304            Self::UnusedServerAction => 40,
305            Self::UnusedLoadDataKey => 41,
306            Self::PropDrilling => 42,
307            Self::ThinWrapper => 43,
308            Self::DuplicatePropShape => 44,
309            Self::UnusedComponentInput => 45,
310            Self::UnusedComponentOutput => 46,
311            Self::UnusedSvelteEvent => 47,
312            Self::CssTokenDrift => 48,
313            Self::CssDuplicateBlock => 49,
314            Self::CssSelectorComplexity => 50,
315            Self::CssDeadSurface => 51,
316            Self::CssBrokenReference => 52,
317        }
318    }
319
320    /// Reconstruct from a cache discriminant.
321    #[must_use]
322    pub const fn from_discriminant(d: u8) -> Option<Self> {
323        match d {
324            1 => Some(Self::UnusedFile),
325            2 => Some(Self::UnusedExport),
326            3 => Some(Self::UnusedType),
327            4 => Some(Self::PrivateTypeLeak),
328            5 => Some(Self::UnusedDependency),
329            6 => Some(Self::UnusedDevDependency),
330            7 => Some(Self::UnusedEnumMember),
331            8 => Some(Self::UnusedClassMember),
332            9 => Some(Self::UnresolvedImport),
333            10 => Some(Self::UnlistedDependency),
334            11 => Some(Self::DuplicateExport),
335            12 => Some(Self::CodeDuplication),
336            13 => Some(Self::CircularDependency),
337            14 => Some(Self::TypeOnlyDependency),
338            15 => Some(Self::TestOnlyDependency),
339            16 => Some(Self::BoundaryViolation),
340            17 => Some(Self::CoverageGaps),
341            18 => Some(Self::FeatureFlag),
342            19 => Some(Self::Complexity),
343            20 => Some(Self::StaleSuppression),
344            21 => Some(Self::PnpmCatalogEntry),
345            22 => Some(Self::UnresolvedCatalogReference),
346            23 => Some(Self::UnusedDependencyOverride),
347            24 => Some(Self::MisconfiguredDependencyOverride),
348            25 => Some(Self::EmptyCatalogGroup),
349            26 => Some(Self::ReExportCycle),
350            27 => Some(Self::SecurityClientServerLeak),
351            28 => Some(Self::SecuritySink),
352            29 => Some(Self::PolicyViolation),
353            30 => Some(Self::InvalidClientExport),
354            31 => Some(Self::MixedClientServerBarrel),
355            32 => Some(Self::MisplacedDirective),
356            33 => Some(Self::UnusedStoreMember),
357            34 => Some(Self::UnprovidedInject),
358            35 => Some(Self::RouteCollision),
359            36 => Some(Self::DynamicSegmentNameConflict),
360            37 => Some(Self::UnrenderedComponent),
361            38 => Some(Self::UnusedComponentProp),
362            39 => Some(Self::UnusedComponentEmit),
363            40 => Some(Self::UnusedServerAction),
364            41 => Some(Self::UnusedLoadDataKey),
365            42 => Some(Self::PropDrilling),
366            43 => Some(Self::ThinWrapper),
367            44 => Some(Self::DuplicatePropShape),
368            45 => Some(Self::UnusedComponentInput),
369            46 => Some(Self::UnusedComponentOutput),
370            47 => Some(Self::UnusedSvelteEvent),
371            48 => Some(Self::CssTokenDrift),
372            49 => Some(Self::CssDuplicateBlock),
373            50 => Some(Self::CssSelectorComplexity),
374            51 => Some(Self::CssDeadSurface),
375            52 => Some(Self::CssBrokenReference),
376            _ => None,
377        }
378    }
379}
380
381/// One scoped rule-pack policy suppression target.
382#[derive(Debug, Clone, PartialEq, Eq, Hash)]
383pub struct PolicyRuleSuppression {
384    /// Rule-pack name.
385    pub pack: String,
386    /// Rule id within the pack.
387    pub rule_id: String,
388}
389
390impl PolicyRuleSuppression {
391    /// Build a scoped policy suppression target.
392    #[must_use]
393    pub fn new(pack: impl Into<String>, rule_id: impl Into<String>) -> Self {
394        Self {
395            pack: pack.into(),
396            rule_id: rule_id.into(),
397        }
398    }
399
400    /// Canonical suppression token.
401    #[must_use]
402    pub fn token(&self) -> String {
403        format!("policy-violation:{}/{}", self.pack, self.rule_id)
404    }
405}
406
407/// A specific suppression target parsed from a comment token.
408#[derive(Debug, Clone, PartialEq, Eq)]
409pub enum SuppressionTarget {
410    /// A regular issue-kind token such as `unused-export` or bare
411    /// `policy-violation`.
412    Issue(IssueKind),
413    /// A scoped rule-pack policy token such as
414    /// `policy-violation:team-policy/no-child-process`.
415    PolicyRule(PolicyRuleSuppression),
416}
417
418impl SuppressionTarget {
419    /// Return the regular issue kind when this target is a bare issue-kind
420    /// token.
421    #[must_use]
422    pub const fn issue_kind(&self) -> Option<IssueKind> {
423        match self {
424            Self::Issue(kind) => Some(*kind),
425            Self::PolicyRule(_) => None,
426        }
427    }
428
429    /// Canonical suppression token for output and active-suppression capture.
430    #[must_use]
431    pub fn token(&self) -> String {
432        match self {
433            Self::Issue(kind) => issue_kind_to_kebab(*kind).to_owned(),
434            Self::PolicyRule(rule) => rule.token(),
435        }
436    }
437}
438
439/// Convert an [`IssueKind`] to its canonical suppression token.
440#[must_use]
441pub fn issue_kind_to_kebab(kind: IssueKind) -> &'static str {
442    let Some(meta) = crate::issue_meta::issue_meta_by_kind(kind) else {
443        unreachable!("IssueKind {kind:?} has no metadata row");
444    };
445    meta.suppress_token.unwrap_or(meta.code)
446}
447
448/// Parse a suppression token into a structured target.
449#[must_use]
450pub fn parse_suppression_target(token: &str) -> Option<SuppressionTarget> {
451    parse_policy_rule_suppression_token(token)
452        .map(SuppressionTarget::PolicyRule)
453        .or_else(|| IssueKind::parse(token).map(SuppressionTarget::Issue))
454}
455
456/// Parse canonical scoped policy suppression tokens.
457///
458/// The plural prefix is accepted for consistency with the bare legacy alias,
459/// but output always uses singular `policy-violation:`.
460#[must_use]
461pub fn parse_policy_rule_suppression_token(token: &str) -> Option<PolicyRuleSuppression> {
462    let identity = token
463        .strip_prefix("policy-violation:")
464        .or_else(|| token.strip_prefix("policy-violations:"))?;
465    let (pack, rule_id) = identity.split_once('/')?;
466    if rule_id.contains('/') {
467        return None;
468    }
469    if !is_valid_policy_identifier(pack) || !is_valid_policy_identifier(rule_id) {
470        return None;
471    }
472    Some(PolicyRuleSuppression::new(pack, rule_id))
473}
474
475/// Whether a rule-pack name or rule id can be used inside
476/// `policy-violation:<pack>/<rule-id>` without escaping.
477#[must_use]
478pub fn is_valid_policy_identifier(value: &str) -> bool {
479    !value.is_empty()
480        && value
481            .bytes()
482            .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'_' | b'-'))
483}
484
485/// A suppression directive parsed from a source comment.
486///
487/// # Examples
488///
489/// ```
490/// use fallow_types::suppress::{Suppression, IssueKind};
491///
492/// // File-wide suppression (line 0, no specific kind)
493/// let file_wide = Suppression::all(0, 1);
494/// assert_eq!(file_wide.line, 0);
495///
496/// // Line-specific suppression for unused exports
497/// let line_suppress = Suppression::issue(42, 41, IssueKind::UnusedExport);
498/// assert_eq!(line_suppress.issue_kind_target(), Some(IssueKind::UnusedExport));
499/// ```
500#[derive(Debug, Clone)]
501pub struct Suppression {
502    /// 1-based line this suppression applies to. 0 = file-wide suppression.
503    pub line: u32,
504    /// 1-based line where the suppression comment itself appears.
505    /// For `fallow-ignore-next-line`, this is `line - 1`.
506    /// For `fallow-ignore-file`, this is the actual line of the comment in the source.
507    pub comment_line: u32,
508    /// None = suppress all issue kinds on this line or file.
509    pub target: Option<SuppressionTarget>,
510    /// Human-authored reason after `--`, when present.
511    pub reason: Option<String>,
512}
513
514impl Suppression {
515    /// Build a blanket suppression.
516    #[must_use]
517    pub const fn all(line: u32, comment_line: u32) -> Self {
518        Self {
519            line,
520            comment_line,
521            target: None,
522            reason: None,
523        }
524    }
525
526    /// Build a regular issue-kind suppression.
527    #[must_use]
528    pub const fn issue(line: u32, comment_line: u32, kind: IssueKind) -> Self {
529        Self {
530            line,
531            comment_line,
532            target: Some(SuppressionTarget::Issue(kind)),
533            reason: None,
534        }
535    }
536
537    /// Build a scoped rule-pack policy suppression.
538    #[must_use]
539    pub fn policy_rule(
540        line: u32,
541        comment_line: u32,
542        pack: impl Into<String>,
543        rule_id: impl Into<String>,
544    ) -> Self {
545        Self {
546            line,
547            comment_line,
548            target: Some(SuppressionTarget::PolicyRule(PolicyRuleSuppression::new(
549                pack, rule_id,
550            ))),
551            reason: None,
552        }
553    }
554
555    /// Return a copy with a parsed suppression reason attached.
556    #[must_use]
557    pub fn with_reason(mut self, reason: Option<String>) -> Self {
558        self.reason = reason;
559        self
560    }
561
562    /// The bare issue kind if this suppression targets one.
563    #[must_use]
564    pub const fn issue_kind_target(&self) -> Option<IssueKind> {
565        match &self.target {
566            Some(SuppressionTarget::Issue(kind)) => Some(*kind),
567            Some(SuppressionTarget::PolicyRule(_)) | None => None,
568        }
569    }
570
571    /// The scoped policy target if this suppression targets one rule-pack rule.
572    #[must_use]
573    pub const fn policy_rule_target(&self) -> Option<&PolicyRuleSuppression> {
574        match &self.target {
575            Some(SuppressionTarget::PolicyRule(rule)) => Some(rule),
576            Some(SuppressionTarget::Issue(_)) | None => None,
577        }
578    }
579
580    /// Canonical token for this suppression, or `None` for blanket comments.
581    #[must_use]
582    pub fn target_token(&self) -> Option<String> {
583        self.target.as_ref().map(SuppressionTarget::token)
584    }
585
586    /// Whether the comment applies to `line`.
587    #[must_use]
588    pub const fn applies_to_line(&self, line: u32) -> bool {
589        self.line == 0 || self.line == line
590    }
591
592    /// Whether this suppression covers a regular issue kind on a line.
593    ///
594    /// Scoped policy-rule targets intentionally do not match this generic
595    /// predicate. Policy detection uses [`Self::matches_policy_rule`] so the
596    /// exact pack and rule id are available.
597    #[must_use]
598    pub fn matches_issue_kind(&self, line: u32, kind: IssueKind) -> bool {
599        self.applies_to_line(line)
600            && match &self.target {
601                None => true,
602                Some(SuppressionTarget::Issue(target_kind)) => *target_kind == kind,
603                Some(SuppressionTarget::PolicyRule(_)) => false,
604            }
605    }
606
607    /// Whether this suppression covers a policy finding on a line.
608    #[must_use]
609    pub fn matches_policy_rule(&self, line: u32, pack: &str, rule_id: &str) -> bool {
610        self.applies_to_line(line)
611            && match &self.target {
612                None | Some(SuppressionTarget::Issue(IssueKind::PolicyViolation)) => true,
613                Some(SuppressionTarget::Issue(_)) => false,
614                Some(SuppressionTarget::PolicyRule(target)) => {
615                    target.pack == pack && target.rule_id == rule_id
616                }
617            }
618    }
619}
620
621/// Check if a specific issue at a given line should be suppressed.
622#[must_use]
623pub fn is_suppressed(suppressions: &[Suppression], line: u32, kind: IssueKind) -> bool {
624    suppressions
625        .iter()
626        .any(|suppression| suppression.matches_issue_kind(line, kind))
627}
628
629/// Check if the entire file is suppressed for issue types that do not have line numbers.
630#[must_use]
631pub fn is_file_suppressed(suppressions: &[Suppression], kind: IssueKind) -> bool {
632    suppressions
633        .iter()
634        .any(|suppression| suppression.line == 0 && suppression.matches_issue_kind(0, kind))
635}
636
637/// A suppression token that did not parse to any known `IssueKind`.
638///
639/// Emitted alongside `Suppression` when a `// fallow-ignore-*` marker contains
640/// a typo or an obsolete issue-kind name. The known tokens on the same marker
641/// are recorded as normal `Suppression` entries; this struct preserves the
642/// unknown token so the downstream `find_stale` pass can surface it as a
643/// `StaleSuppression` finding with `kind_known: false`. Without this, the
644/// entire suppression line would be discarded silently. See issue #449.
645#[derive(Debug, Clone)]
646pub struct UnknownSuppressionKind {
647    /// 1-based line where the suppression comment itself appears.
648    pub comment_line: u32,
649    /// Whether the marker was `fallow-ignore-file` (`true`) or
650    /// `fallow-ignore-next-line` (`false`).
651    pub is_file_level: bool,
652    /// The verbatim token from the marker that did not parse.
653    pub token: String,
654    /// Human-authored reason after `--`, when present.
655    pub reason: Option<String>,
656}
657
658/// Levenshtein edit distance between two ASCII-leaning strings.
659///
660/// Local duplicate of the config-crate helper (see
661/// `crates/config/src/config/rules.rs::levenshtein`) so `fallow-types` can
662/// compute "did you mean?" suggestions for unknown suppression tokens without
663/// taking a dependency on `fallow-config`. Issue-kind names are short
664/// (max ~33 chars) so allocation cost is negligible.
665fn levenshtein(a: &str, b: &str) -> usize {
666    let a_bytes = a.as_bytes();
667    let b_bytes = b.as_bytes();
668    let (a_len, b_len) = (a_bytes.len(), b_bytes.len());
669
670    if a_len == 0 {
671        return b_len;
672    }
673    if b_len == 0 {
674        return a_len;
675    }
676
677    let mut prev: Vec<usize> = (0..=b_len).collect();
678    let mut curr: Vec<usize> = vec![0; b_len + 1];
679
680    for i in 1..=a_len {
681        curr[0] = i;
682        for j in 1..=b_len {
683            let cost = usize::from(a_bytes[i - 1] != b_bytes[j - 1]);
684            curr[j] = (prev[j] + 1).min(curr[j - 1] + 1).min(prev[j - 1] + cost);
685        }
686        std::mem::swap(&mut prev, &mut curr);
687    }
688
689    prev[b_len]
690}
691
692/// Find the closest known issue-kind name to `input` when it is plausibly a typo.
693///
694/// Returns the best match when the Levenshtein distance is at most 2 AND
695/// the input is long enough that the match is not coincidental
696/// (`input.len() / 2 > distance`). Returns `None` for completely novel
697/// strings where a suggestion would be misleading.
698#[must_use]
699pub fn closest_known_kind_name(input: &str) -> Option<&'static str> {
700    let input_lower = input.to_ascii_lowercase();
701    let mut best: Option<(&'static str, usize)> = None;
702
703    for &candidate in KNOWN_ISSUE_KIND_NAMES.iter() {
704        let d = levenshtein(&input_lower, candidate);
705        if best.is_none_or(|(_, b_dist)| d < b_dist) {
706            best = Some((candidate, d));
707        }
708    }
709
710    best.filter(|&(_, d)| d > 0 && d <= 2 && input_lower.len() / 2 > d)
711        .map(|(name, _)| name)
712}
713
714const _: () = assert!(std::mem::size_of::<IssueKind>() == 1);
715
716#[cfg(test)]
717mod tests {
718    use super::*;
719
720    #[test]
721    fn issue_kind_parse_accepts_registry_codes_and_aliases() {
722        for meta in crate::issue_meta::ISSUE_KIND_META
723            .iter()
724            .filter(|meta| meta.kind.is_some())
725        {
726            let expected = meta.kind;
727            assert_eq!(
728                IssueKind::parse(meta.code),
729                expected,
730                "canonical registry token {} must parse",
731                meta.code
732            );
733            for alias in meta.aliases {
734                assert_eq!(
735                    IssueKind::parse(alias),
736                    expected,
737                    "registry alias {alias} must parse as {}",
738                    meta.code
739                );
740            }
741        }
742    }
743
744    #[test]
745    fn issue_kind_parse_accepts_registry_suppression_tokens() {
746        for meta in crate::issue_meta::ISSUE_KIND_META {
747            let (Some(kind), Some(token)) = (meta.kind, meta.suppress_token) else {
748                continue;
749            };
750            assert_eq!(
751                IssueKind::parse(token),
752                Some(kind),
753                "registry suppression token {token} must parse as {}",
754                meta.code
755            );
756        }
757    }
758
759    #[test]
760    fn issue_kind_from_str_unknown() {
761        assert_eq!(IssueKind::parse("foo"), None);
762        assert_eq!(IssueKind::parse(""), None);
763    }
764
765    #[test]
766    fn issue_kind_from_str_near_misses() {
767        assert_eq!(IssueKind::parse("Unused-File"), None);
768        assert_eq!(IssueKind::parse("UNUSED-EXPORT"), None);
769        assert_eq!(IssueKind::parse("unused_file"), None);
770        assert_eq!(IssueKind::parse("unused-files"), None);
771    }
772
773    #[test]
774    fn discriminant_out_of_range() {
775        assert_eq!(IssueKind::from_discriminant(0), None);
776        assert_eq!(
777            IssueKind::from_discriminant(29),
778            Some(IssueKind::PolicyViolation)
779        );
780        assert_eq!(
781            IssueKind::from_discriminant(30),
782            Some(IssueKind::InvalidClientExport)
783        );
784        assert_eq!(
785            IssueKind::from_discriminant(31),
786            Some(IssueKind::MixedClientServerBarrel)
787        );
788        assert_eq!(
789            IssueKind::from_discriminant(32),
790            Some(IssueKind::MisplacedDirective)
791        );
792        assert_eq!(
793            IssueKind::from_discriminant(33),
794            Some(IssueKind::UnusedStoreMember)
795        );
796        assert_eq!(
797            IssueKind::from_discriminant(34),
798            Some(IssueKind::UnprovidedInject)
799        );
800        assert_eq!(
801            IssueKind::from_discriminant(35),
802            Some(IssueKind::RouteCollision)
803        );
804        assert_eq!(
805            IssueKind::from_discriminant(36),
806            Some(IssueKind::DynamicSegmentNameConflict)
807        );
808        assert_eq!(
809            IssueKind::from_discriminant(37),
810            Some(IssueKind::UnrenderedComponent)
811        );
812        assert_eq!(
813            IssueKind::from_discriminant(38),
814            Some(IssueKind::UnusedComponentProp)
815        );
816        assert_eq!(
817            IssueKind::from_discriminant(39),
818            Some(IssueKind::UnusedComponentEmit)
819        );
820        assert_eq!(
821            IssueKind::from_discriminant(40),
822            Some(IssueKind::UnusedServerAction)
823        );
824        assert_eq!(
825            IssueKind::from_discriminant(41),
826            Some(IssueKind::UnusedLoadDataKey)
827        );
828        assert_eq!(
829            IssueKind::from_discriminant(42),
830            Some(IssueKind::PropDrilling)
831        );
832        assert_eq!(
833            IssueKind::from_discriminant(43),
834            Some(IssueKind::ThinWrapper)
835        );
836        assert_eq!(
837            IssueKind::from_discriminant(44),
838            Some(IssueKind::DuplicatePropShape)
839        );
840        assert_eq!(
841            IssueKind::from_discriminant(45),
842            Some(IssueKind::UnusedComponentInput)
843        );
844        assert_eq!(
845            IssueKind::from_discriminant(46),
846            Some(IssueKind::UnusedComponentOutput)
847        );
848        assert_eq!(
849            IssueKind::from_discriminant(47),
850            Some(IssueKind::UnusedSvelteEvent)
851        );
852        let max_discriminant = IssueKind::ALL
853            .iter()
854            .map(|kind| kind.to_discriminant())
855            .max()
856            .expect("IssueKind::ALL should not be empty");
857        assert_eq!(IssueKind::from_discriminant(max_discriminant + 1), None);
858        assert_eq!(IssueKind::from_discriminant(u8::MAX), None);
859    }
860
861    #[test]
862    fn discriminant_roundtrip() {
863        for &kind in IssueKind::ALL {
864            assert_eq!(
865                IssueKind::from_discriminant(kind.to_discriminant()),
866                Some(kind)
867            );
868        }
869        assert_eq!(IssueKind::from_discriminant(0), None);
870        let max_discriminant = IssueKind::ALL
871            .iter()
872            .map(|kind| kind.to_discriminant())
873            .max()
874            .expect("IssueKind::ALL should not be empty");
875        assert_eq!(IssueKind::from_discriminant(max_discriminant + 1), None);
876    }
877
878    #[test]
879    fn discriminant_values_are_unique() {
880        let discriminants: Vec<u8> = IssueKind::ALL
881            .iter()
882            .map(|kind| kind.to_discriminant())
883            .collect();
884        let mut sorted = discriminants.clone();
885        sorted.sort_unstable();
886        sorted.dedup();
887        assert_eq!(
888            discriminants.len(),
889            sorted.len(),
890            "discriminant values must be unique"
891        );
892    }
893
894    #[test]
895    fn discriminant_starts_at_one() {
896        assert_eq!(IssueKind::UnusedFile.to_discriminant(), 1);
897    }
898
899    #[test]
900    fn issue_kind_to_kebab_uses_registry_suppression_token() {
901        for &kind in IssueKind::ALL {
902            let meta = crate::issue_meta::issue_meta_by_kind(kind)
903                .unwrap_or_else(|| panic!("IssueKind {kind:?} has no metadata row"));
904            let token = issue_kind_to_kebab(kind);
905            assert_eq!(token, meta.suppress_token.unwrap_or(meta.code));
906            assert_eq!(IssueKind::parse(token), Some(kind));
907        }
908    }
909
910    #[test]
911    fn suppression_line_zero_is_file_wide() {
912        let s = Suppression::all(0, 1);
913        assert_eq!(s.line, 0);
914        assert!(s.issue_kind_target().is_none());
915    }
916
917    #[test]
918    fn suppression_with_specific_kind_and_line() {
919        let s = Suppression::issue(42, 41, IssueKind::UnusedExport);
920        assert_eq!(s.line, 42);
921        assert_eq!(s.comment_line, 41);
922        assert_eq!(s.issue_kind_target(), Some(IssueKind::UnusedExport));
923    }
924
925    #[test]
926    fn suppression_predicates_match_lines_and_file_wide_markers() {
927        let suppressions = vec![
928            Suppression::issue(42, 41, IssueKind::UnusedExport),
929            Suppression::all(0, 1),
930        ];
931
932        assert!(is_suppressed(&suppressions, 42, IssueKind::UnusedExport));
933        assert!(is_suppressed(&suppressions, 10, IssueKind::UnusedType));
934        assert!(is_file_suppressed(&suppressions, IssueKind::UnusedFile));
935    }
936
937    #[test]
938    fn parses_scoped_policy_suppression_token() {
939        let target =
940            parse_policy_rule_suppression_token("policy-violation:team-policy/no-child-process")
941                .expect("scoped token should parse");
942        assert_eq!(target.pack, "team-policy");
943        assert_eq!(target.rule_id, "no-child-process");
944        assert_eq!(
945            target.token(),
946            "policy-violation:team-policy/no-child-process"
947        );
948    }
949
950    #[test]
951    fn rejects_malformed_scoped_policy_suppression_tokens() {
952        for token in [
953            "policy-violation:",
954            "policy-violation:team-policy",
955            "policy-violation:/no-child-process",
956            "policy-violation:team-policy/",
957            "policy-violation:team-policy/no/child-process",
958            "policy-violation:team policy/no-child-process",
959            "policy-violation:team-policy/no:child-process",
960        ] {
961            assert!(
962                parse_policy_rule_suppression_token(token).is_none(),
963                "{token} should be rejected"
964            );
965        }
966    }
967
968    #[test]
969    fn scoped_policy_suppression_matches_exact_policy_rule_only() {
970        let suppression = Suppression::policy_rule(7, 6, "team-policy", "no-child-process");
971        assert!(suppression.matches_policy_rule(7, "team-policy", "no-child-process"));
972        assert!(!suppression.matches_policy_rule(7, "team-policy", "no-fs"));
973        assert!(!suppression.matches_policy_rule(8, "team-policy", "no-child-process"));
974        assert!(!suppression.matches_issue_kind(7, IssueKind::PolicyViolation));
975    }
976
977    #[test]
978    fn known_issue_kind_names_parses_each_entry() {
979        for &name in KNOWN_ISSUE_KIND_NAMES.iter() {
980            assert!(
981                IssueKind::parse(name).is_some(),
982                "KNOWN_ISSUE_KIND_NAMES contains '{name}' but IssueKind::parse rejects it"
983            );
984        }
985    }
986
987    #[test]
988    fn closest_known_kind_name_finds_near_misses() {
989        assert_eq!(
990            closest_known_kind_name("unused-exports"),
991            Some("unused-export")
992        );
993        assert_eq!(closest_known_kind_name("unused-files"), Some("unused-file"));
994        assert_eq!(closest_known_kind_name("complxity"), Some("complexity"));
995    }
996
997    #[test]
998    fn closest_known_kind_name_rejects_novel_strings() {
999        assert_eq!(closest_known_kind_name("xyzzy"), None);
1000        assert_eq!(closest_known_kind_name("foo"), None);
1001        assert_eq!(closest_known_kind_name(""), None);
1002    }
1003
1004    #[test]
1005    fn closest_known_kind_name_skips_exact_match() {
1006        assert_eq!(closest_known_kind_name("unused-export"), None);
1007    }
1008}