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