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    /// Used optional input absent from inspected reachable callers.
128    AbsentComponentProp,
129    /// A Vue `<script setup>` `defineEmits` declared event that is EMITTED
130    /// nowhere inside its own single-file component (no `emit('<name>')` call).
131    /// Single-file dead-input direction.
132    UnusedComponentEmit,
133    /// An Angular `@Input()` / signal `input()` / `model()` declared input that
134    /// is read NOWHERE inside its own component (neither the inline/external
135    /// template nor the class body). Single-file dead-input direction; the
136    /// Angular analogue of `unused-component-prop`.
137    UnusedComponentInput,
138    /// An Angular `@Output()` / signal `output()` declared output that is
139    /// EMITTED nowhere inside its own component (no `this.<output>.emit(...)`).
140    /// Single-file dead-output direction; the Angular analogue of
141    /// `unused-component-emit`.
142    UnusedComponentOutput,
143    /// A Next.js Server Action (an export of a `"use server"` file) that no code
144    /// in the project references (no import-and-call, no `action={fn}` binding,
145    /// no `<form action={fn}>`). Cross-graph dead-export direction, reclassified
146    /// from `unused-export` for `"use server"` files.
147    UnusedServerAction,
148    /// A SvelteKit `+page.{ts,server.ts,js,server.js}` `load()` return-object key
149    /// that no consumer reads: not off the sibling `+page.svelte`'s `data.<key>`,
150    /// nor project-wide via `page.data.<key>` / `$page.data.<key>`. A dead load
151    /// key runs a real server/DB fetch cost for data nothing renders.
152    UnusedLoadDataKey,
153    /// A React/Preact prop forwarded unchanged through `>= N` intermediate
154    /// pass-through components until a component that substantively consumes it.
155    /// Health signal, rule defaults to `off` (opt-in). Cross-graph: the chain
156    /// spans multiple components / files.
157    PropDrilling,
158    /// A React/Preact component whose entire body is `return <Child {...props}/>`
159    /// (a single spread-forwarded child render, no own value-add): pure
160    /// structural indirection, a candidate for inlining. Health signal, rule
161    /// defaults to `off` (opt-in).
162    ThinWrapper,
163    /// Three or more React/Preact components across two or more files whose
164    /// statically-harvested prop NAME set is identical after stripping ubiquitous
165    /// DOM / passthrough names (a missing shared `Props` type). Health signal,
166    /// rule defaults to `off` (opt-in). Cross-graph: the group spans multiple
167    /// components / files.
168    DuplicatePropShape,
169    /// A Svelte component dispatching a custom event via
170    /// `createEventDispatcher()` whose event name is listened to NOWHERE in the
171    /// analyzed project. Cross-file dead-output direction: the component fires an
172    /// event nothing handles.
173    UnusedSvelteEvent,
174    /// A CSS / CSS-in-JS design-token DRIFT candidate surfaced in `fallow audit`
175    /// as an advisory styling finding: a hardcoded value where a design token
176    /// exists (a Tailwind arbitrary value like `w-[13px]`, or a near-duplicate
177    /// token). Styling-domain finding produced by the health-time css pass (not
178    /// dead-code); the rule defaults to `warn` and is verdict-neutral.
179    CssTokenDrift,
180    /// A CSS / CSS-in-JS DUPLICATE declaration block: a copy-pasted rule body
181    /// repeated across selectors, a consolidation candidate. Styling-domain
182    /// advisory (rule defaults to `warn`, verdict-neutral); the audit copy of
183    /// this is changed-file-local.
184    CssDuplicateBlock,
185    /// A CSS selector / nesting / important-density complexity finding surfaced
186    /// as advisory styling feedback. Styling-domain finding produced by the
187    /// health-time css pass; defaults to `warn` and is verdict-neutral.
188    CssSelectorComplexity,
189    /// A CSS dead-surface finding, such as unused scoped SFC classes. Styling-
190    /// domain advisory surfaced in `fallow audit`; defaults to `warn` and is
191    /// verdict-neutral.
192    CssDeadSurface,
193    /// A CSS broken-reference finding, such as a class or keyframes reference
194    /// that resolves to no stylesheet definition. Styling-domain advisory
195    /// surfaced by deep CSS audit mode; defaults to `warn` and is
196    /// verdict-neutral.
197    CssBrokenReference,
198    /// A `devDependencies` package imported by production (non-test, non-config)
199    /// source code via a runtime/value import. It should be promoted to
200    /// `dependencies` because a production-only install (`pnpm install --prod`)
201    /// would omit it and break at runtime. The promote-side mirror of
202    /// `test-only-dependency` / `type-only-dependency`.
203    DevDependencyInProduction,
204    /// An export whose leading JSDoc carries `@deprecated` and that still has
205    /// at least one reachable reference. Reported at the export site with the
206    /// consumer count and a capped consumer sample.
207    DeprecatedExportInUse,
208    /// A dependency cycle between workspace packages, built from resolved
209    /// cross-package imports. Reported even when no file-level cycle exists.
210    PackageCycle,
211    /// An Effect layer whose every reference sits inside other layers that
212    /// nothing uses. A layer with zero references stays an `unused-export` or
213    /// an `unused-class-member`.
214    UnusedEffectLayer,
215    /// An Effect tagged error class that type positions or `catchTag` handlers
216    /// reference, but that no code constructs, fails with, or reads as a value.
217    UnusedTaggedError,
218    /// A `catchTag("X")` / `catchTags({ X })` handler whose project tag `X` no
219    /// code produces, when no `unused-tagged-error` finding already covers it.
220    UnproducedCatchTag,
221    /// An audit diff that weakens tests: a new skip or focus marker, removed
222    /// test cases, fewer assertions, or a lowered coverage threshold.
223    /// Audit-only, computed from the base and head text of changed files.
224    WeakenedTests,
225    /// An audit diff that adds a lint, type-check, coverage or Fallow
226    /// suppression comment. Audit-only.
227    AddedSuppression,
228    /// An audit diff that adds a test-environment branch, such as
229    /// `process.env.NODE_ENV === 'test'`, to a non-test source file.
230    /// Audit-only.
231    TestOnlyBranch,
232    /// An audit diff that edits one instance of a base clone group so that it
233    /// no longer matches its siblings, while no sibling changed. Audit-only.
234    DivergedCloneSibling,
235    /// An audit diff that adds a dependency to a `package.json`. Audit-only
236    /// and off by default.
237    AddedDependency,
238}
239
240impl IssueKind {
241    /// Stable inventory of all issue kinds.
242    pub const ALL: &'static [Self] = &[
243        Self::UnusedFile,
244        Self::UnusedExport,
245        Self::UnusedType,
246        Self::PrivateTypeLeak,
247        Self::UnusedDependency,
248        Self::UnusedDevDependency,
249        Self::UnusedEnumMember,
250        Self::UnusedClassMember,
251        Self::UnresolvedImport,
252        Self::UnlistedDependency,
253        Self::DuplicateExport,
254        Self::CodeDuplication,
255        Self::CircularDependency,
256        Self::ReExportCycle,
257        Self::TypeOnlyDependency,
258        Self::TestOnlyDependency,
259        Self::BoundaryViolation,
260        Self::CoverageGaps,
261        Self::FeatureFlag,
262        Self::Complexity,
263        Self::StaleSuppression,
264        Self::PnpmCatalogEntry,
265        Self::EmptyCatalogGroup,
266        Self::UnresolvedCatalogReference,
267        Self::UnusedDependencyOverride,
268        Self::MisconfiguredDependencyOverride,
269        Self::SecurityClientServerLeak,
270        Self::SecuritySink,
271        Self::PolicyViolation,
272        Self::InvalidClientExport,
273        Self::MixedClientServerBarrel,
274        Self::MisplacedDirective,
275        Self::UnusedStoreMember,
276        Self::UnprovidedInject,
277        Self::RouteCollision,
278        Self::DynamicSegmentNameConflict,
279        Self::UnrenderedComponent,
280        Self::UnusedComponentProp,
281        Self::AbsentComponentProp,
282        Self::UnusedComponentEmit,
283        Self::UnusedComponentInput,
284        Self::UnusedComponentOutput,
285        Self::UnusedServerAction,
286        Self::UnusedLoadDataKey,
287        Self::PropDrilling,
288        Self::ThinWrapper,
289        Self::DuplicatePropShape,
290        Self::UnusedSvelteEvent,
291        Self::CssTokenDrift,
292        Self::CssDuplicateBlock,
293        Self::CssSelectorComplexity,
294        Self::CssDeadSurface,
295        Self::CssBrokenReference,
296        Self::DevDependencyInProduction,
297        Self::DeprecatedExportInUse,
298        Self::PackageCycle,
299        Self::UnusedEffectLayer,
300        Self::UnusedTaggedError,
301        Self::UnproducedCatchTag,
302        Self::WeakenedTests,
303        Self::AddedSuppression,
304        Self::TestOnlyBranch,
305        Self::DivergedCloneSibling,
306        Self::AddedDependency,
307    ];
308
309    /// Parse an issue kind from the string tokens used in CLI output and suppression comments.
310    #[must_use]
311    pub fn parse(s: &str) -> Option<Self> {
312        crate::issue_meta::issue_meta_for_token(s).and_then(|meta| meta.kind)
313    }
314
315    /// Convert to a u8 discriminant for compact cache storage.
316    #[must_use]
317    pub const fn to_discriminant(self) -> u8 {
318        match self {
319            Self::UnusedFile => 1,
320            Self::UnusedExport => 2,
321            Self::UnusedType => 3,
322            Self::PrivateTypeLeak => 4,
323            Self::UnusedDependency => 5,
324            Self::UnusedDevDependency => 6,
325            Self::UnusedEnumMember => 7,
326            Self::UnusedClassMember => 8,
327            Self::UnresolvedImport => 9,
328            Self::UnlistedDependency => 10,
329            Self::DuplicateExport => 11,
330            Self::CodeDuplication => 12,
331            Self::CircularDependency => 13,
332            Self::TypeOnlyDependency => 14,
333            Self::TestOnlyDependency => 15,
334            Self::BoundaryViolation => 16,
335            Self::CoverageGaps => 17,
336            Self::FeatureFlag => 18,
337            Self::Complexity => 19,
338            Self::StaleSuppression => 20,
339            Self::PnpmCatalogEntry => 21,
340            Self::UnresolvedCatalogReference => 22,
341            Self::UnusedDependencyOverride => 23,
342            Self::MisconfiguredDependencyOverride => 24,
343            Self::EmptyCatalogGroup => 25,
344            Self::ReExportCycle => 26,
345            Self::SecurityClientServerLeak => 27,
346            Self::SecuritySink => 28,
347            Self::PolicyViolation => 29,
348            Self::InvalidClientExport => 30,
349            Self::MixedClientServerBarrel => 31,
350            Self::MisplacedDirective => 32,
351            Self::UnusedStoreMember => 33,
352            Self::UnprovidedInject => 34,
353            Self::RouteCollision => 35,
354            Self::DynamicSegmentNameConflict => 36,
355            Self::UnrenderedComponent => 37,
356            Self::UnusedComponentProp => 38,
357            Self::UnusedComponentEmit => 39,
358            Self::UnusedServerAction => 40,
359            Self::UnusedLoadDataKey => 41,
360            Self::PropDrilling => 42,
361            Self::ThinWrapper => 43,
362            Self::DuplicatePropShape => 44,
363            Self::UnusedComponentInput => 45,
364            Self::UnusedComponentOutput => 46,
365            Self::UnusedSvelteEvent => 47,
366            Self::CssTokenDrift => 48,
367            Self::CssDuplicateBlock => 49,
368            Self::CssSelectorComplexity => 50,
369            Self::CssDeadSurface => 51,
370            Self::CssBrokenReference => 52,
371            Self::DevDependencyInProduction => 53,
372            Self::DeprecatedExportInUse => 54,
373            Self::PackageCycle => 55,
374            Self::AbsentComponentProp => 56,
375            Self::UnusedEffectLayer => 57,
376            Self::UnusedTaggedError => 58,
377            Self::UnproducedCatchTag => 59,
378            Self::WeakenedTests => 60,
379            Self::AddedSuppression => 61,
380            Self::TestOnlyBranch => 62,
381            Self::DivergedCloneSibling => 63,
382            Self::AddedDependency => 64,
383        }
384    }
385
386    /// Reconstruct from a cache discriminant.
387    #[must_use]
388    pub const fn from_discriminant(d: u8) -> Option<Self> {
389        match d {
390            1 => Some(Self::UnusedFile),
391            2 => Some(Self::UnusedExport),
392            3 => Some(Self::UnusedType),
393            4 => Some(Self::PrivateTypeLeak),
394            5 => Some(Self::UnusedDependency),
395            6 => Some(Self::UnusedDevDependency),
396            7 => Some(Self::UnusedEnumMember),
397            8 => Some(Self::UnusedClassMember),
398            9 => Some(Self::UnresolvedImport),
399            10 => Some(Self::UnlistedDependency),
400            11 => Some(Self::DuplicateExport),
401            12 => Some(Self::CodeDuplication),
402            13 => Some(Self::CircularDependency),
403            14 => Some(Self::TypeOnlyDependency),
404            15 => Some(Self::TestOnlyDependency),
405            16 => Some(Self::BoundaryViolation),
406            17 => Some(Self::CoverageGaps),
407            18 => Some(Self::FeatureFlag),
408            19 => Some(Self::Complexity),
409            20 => Some(Self::StaleSuppression),
410            21 => Some(Self::PnpmCatalogEntry),
411            22 => Some(Self::UnresolvedCatalogReference),
412            23 => Some(Self::UnusedDependencyOverride),
413            24 => Some(Self::MisconfiguredDependencyOverride),
414            25 => Some(Self::EmptyCatalogGroup),
415            26 => Some(Self::ReExportCycle),
416            27 => Some(Self::SecurityClientServerLeak),
417            28 => Some(Self::SecuritySink),
418            29 => Some(Self::PolicyViolation),
419            30 => Some(Self::InvalidClientExport),
420            31 => Some(Self::MixedClientServerBarrel),
421            32 => Some(Self::MisplacedDirective),
422            33 => Some(Self::UnusedStoreMember),
423            34 => Some(Self::UnprovidedInject),
424            35 => Some(Self::RouteCollision),
425            36 => Some(Self::DynamicSegmentNameConflict),
426            37 => Some(Self::UnrenderedComponent),
427            38 => Some(Self::UnusedComponentProp),
428            39 => Some(Self::UnusedComponentEmit),
429            40 => Some(Self::UnusedServerAction),
430            41 => Some(Self::UnusedLoadDataKey),
431            42 => Some(Self::PropDrilling),
432            43 => Some(Self::ThinWrapper),
433            44 => Some(Self::DuplicatePropShape),
434            45 => Some(Self::UnusedComponentInput),
435            46 => Some(Self::UnusedComponentOutput),
436            47 => Some(Self::UnusedSvelteEvent),
437            48 => Some(Self::CssTokenDrift),
438            49 => Some(Self::CssDuplicateBlock),
439            50 => Some(Self::CssSelectorComplexity),
440            51 => Some(Self::CssDeadSurface),
441            52 => Some(Self::CssBrokenReference),
442            53 => Some(Self::DevDependencyInProduction),
443            54 => Some(Self::DeprecatedExportInUse),
444            55 => Some(Self::PackageCycle),
445            56 => Some(Self::AbsentComponentProp),
446            57 => Some(Self::UnusedEffectLayer),
447            58 => Some(Self::UnusedTaggedError),
448            59 => Some(Self::UnproducedCatchTag),
449            60 => Some(Self::WeakenedTests),
450            61 => Some(Self::AddedSuppression),
451            62 => Some(Self::TestOnlyBranch),
452            63 => Some(Self::DivergedCloneSibling),
453            64 => Some(Self::AddedDependency),
454            _ => None,
455        }
456    }
457}
458
459/// One scoped rule-pack policy suppression target.
460#[derive(Debug, Clone, PartialEq, Eq, Hash)]
461pub struct PolicyRuleSuppression {
462    /// Rule-pack name.
463    pub pack: String,
464    /// Rule id within the pack.
465    pub rule_id: String,
466}
467
468impl PolicyRuleSuppression {
469    /// Build a scoped policy suppression target.
470    #[must_use]
471    pub fn new(pack: impl Into<String>, rule_id: impl Into<String>) -> Self {
472        Self {
473            pack: pack.into(),
474            rule_id: rule_id.into(),
475        }
476    }
477
478    /// Canonical suppression token.
479    #[must_use]
480    pub fn token(&self) -> String {
481        format!("policy-violation:{}/{}", self.pack, self.rule_id)
482    }
483}
484
485/// A specific suppression target parsed from a comment token.
486#[derive(Debug, Clone, PartialEq, Eq)]
487pub enum SuppressionTarget {
488    /// A regular issue-kind token such as `unused-export` or bare
489    /// `policy-violation`.
490    Issue(IssueKind),
491    /// A scoped rule-pack policy token such as
492    /// `policy-violation:team-policy/no-child-process`.
493    PolicyRule(PolicyRuleSuppression),
494}
495
496impl SuppressionTarget {
497    /// Return the regular issue kind when this target is a bare issue-kind
498    /// token.
499    #[must_use]
500    pub const fn issue_kind(&self) -> Option<IssueKind> {
501        match self {
502            Self::Issue(kind) => Some(*kind),
503            Self::PolicyRule(_) => None,
504        }
505    }
506
507    /// Canonical suppression token for output and active-suppression capture.
508    #[must_use]
509    pub fn token(&self) -> String {
510        match self {
511            Self::Issue(kind) => issue_kind_to_kebab(*kind).to_owned(),
512            Self::PolicyRule(rule) => rule.token(),
513        }
514    }
515}
516
517/// Convert an [`IssueKind`] to its canonical suppression token.
518#[must_use]
519pub fn issue_kind_to_kebab(kind: IssueKind) -> &'static str {
520    let Some(meta) = crate::issue_meta::issue_meta_by_kind(kind) else {
521        unreachable!("IssueKind {kind:?} has no metadata row");
522    };
523    meta.suppress_token.unwrap_or(meta.code)
524}
525
526/// Parse a suppression token into a structured target.
527#[must_use]
528pub fn parse_suppression_target(token: &str) -> Option<SuppressionTarget> {
529    parse_policy_rule_suppression_token(token)
530        .map(SuppressionTarget::PolicyRule)
531        .or_else(|| IssueKind::parse(token).map(SuppressionTarget::Issue))
532}
533
534/// Parse canonical scoped policy suppression tokens.
535///
536/// The plural prefix is accepted for consistency with the bare legacy alias,
537/// but output always uses singular `policy-violation:`.
538#[must_use]
539pub fn parse_policy_rule_suppression_token(token: &str) -> Option<PolicyRuleSuppression> {
540    let identity = token
541        .strip_prefix("policy-violation:")
542        .or_else(|| token.strip_prefix("policy-violations:"))?;
543    let (pack, rule_id) = identity.split_once('/')?;
544    if rule_id.contains('/') {
545        return None;
546    }
547    if !is_valid_policy_identifier(pack) || !is_valid_policy_identifier(rule_id) {
548        return None;
549    }
550    Some(PolicyRuleSuppression::new(pack, rule_id))
551}
552
553/// Whether a rule-pack name or rule id can be used inside
554/// `policy-violation:<pack>/<rule-id>` without escaping.
555#[must_use]
556pub fn is_valid_policy_identifier(value: &str) -> bool {
557    !value.is_empty()
558        && value
559            .bytes()
560            .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'_' | b'-'))
561}
562
563/// A suppression directive parsed from a source comment.
564///
565/// # Examples
566///
567/// ```
568/// use fallow_types::suppress::{Suppression, IssueKind};
569///
570/// // File-wide suppression (line 0, no specific kind)
571/// let file_wide = Suppression::all(0, 1);
572/// assert_eq!(file_wide.line, 0);
573///
574/// // Line-specific suppression for unused exports
575/// let line_suppress = Suppression::issue(42, 41, IssueKind::UnusedExport);
576/// assert_eq!(line_suppress.issue_kind_target(), Some(IssueKind::UnusedExport));
577/// ```
578#[derive(Debug, Clone)]
579pub struct Suppression {
580    /// 1-based line this suppression applies to. 0 = file-wide suppression.
581    pub line: u32,
582    /// 1-based line where the suppression comment itself appears.
583    /// For `fallow-ignore-next-line`, this is `line - 1`.
584    /// For `fallow-ignore-file`, this is the actual line of the comment in the source.
585    pub comment_line: u32,
586    /// None = suppress all issue kinds on this line or file.
587    pub target: Option<SuppressionTarget>,
588    /// Human-authored reason after `--`, when present.
589    pub reason: Option<String>,
590}
591
592impl Suppression {
593    /// Build a blanket suppression.
594    #[must_use]
595    pub const fn all(line: u32, comment_line: u32) -> Self {
596        Self {
597            line,
598            comment_line,
599            target: None,
600            reason: None,
601        }
602    }
603
604    /// Build a regular issue-kind suppression.
605    #[must_use]
606    pub const fn issue(line: u32, comment_line: u32, kind: IssueKind) -> Self {
607        Self {
608            line,
609            comment_line,
610            target: Some(SuppressionTarget::Issue(kind)),
611            reason: None,
612        }
613    }
614
615    /// Build a scoped rule-pack policy suppression.
616    #[must_use]
617    pub fn policy_rule(
618        line: u32,
619        comment_line: u32,
620        pack: impl Into<String>,
621        rule_id: impl Into<String>,
622    ) -> Self {
623        Self {
624            line,
625            comment_line,
626            target: Some(SuppressionTarget::PolicyRule(PolicyRuleSuppression::new(
627                pack, rule_id,
628            ))),
629            reason: None,
630        }
631    }
632
633    /// Return a copy with a parsed suppression reason attached.
634    #[must_use]
635    pub fn with_reason(mut self, reason: Option<String>) -> Self {
636        self.reason = reason;
637        self
638    }
639
640    /// The bare issue kind if this suppression targets one.
641    #[must_use]
642    pub const fn issue_kind_target(&self) -> Option<IssueKind> {
643        match &self.target {
644            Some(SuppressionTarget::Issue(kind)) => Some(*kind),
645            Some(SuppressionTarget::PolicyRule(_)) | None => None,
646        }
647    }
648
649    /// The scoped policy target if this suppression targets one rule-pack rule.
650    #[must_use]
651    pub const fn policy_rule_target(&self) -> Option<&PolicyRuleSuppression> {
652        match &self.target {
653            Some(SuppressionTarget::PolicyRule(rule)) => Some(rule),
654            Some(SuppressionTarget::Issue(_)) | None => None,
655        }
656    }
657
658    /// Canonical token for this suppression, or `None` for blanket comments.
659    #[must_use]
660    pub fn target_token(&self) -> Option<String> {
661        self.target.as_ref().map(SuppressionTarget::token)
662    }
663
664    /// Whether the comment applies to `line`.
665    #[must_use]
666    pub const fn applies_to_line(&self, line: u32) -> bool {
667        self.line == 0 || self.line == line
668    }
669
670    /// Whether this suppression covers a regular issue kind on a line.
671    ///
672    /// Scoped policy-rule targets intentionally do not match this generic
673    /// predicate. Policy detection uses [`Self::matches_policy_rule`] so the
674    /// exact pack and rule id are available.
675    #[must_use]
676    pub fn matches_issue_kind(&self, line: u32, kind: IssueKind) -> bool {
677        self.applies_to_line(line)
678            && match &self.target {
679                None => true,
680                Some(SuppressionTarget::Issue(target_kind)) => *target_kind == kind,
681                Some(SuppressionTarget::PolicyRule(_)) => false,
682            }
683    }
684
685    /// Whether this suppression covers a policy finding on a line.
686    #[must_use]
687    pub fn matches_policy_rule(&self, line: u32, pack: &str, rule_id: &str) -> bool {
688        self.applies_to_line(line)
689            && match &self.target {
690                None | Some(SuppressionTarget::Issue(IssueKind::PolicyViolation)) => true,
691                Some(SuppressionTarget::Issue(_)) => false,
692                Some(SuppressionTarget::PolicyRule(target)) => {
693                    target.pack == pack && target.rule_id == rule_id
694                }
695            }
696    }
697}
698
699/// Check if a specific issue at a given line should be suppressed.
700#[must_use]
701pub fn is_suppressed(suppressions: &[Suppression], line: u32, kind: IssueKind) -> bool {
702    suppressions
703        .iter()
704        .any(|suppression| suppression.matches_issue_kind(line, kind))
705}
706
707/// Check if the entire file is suppressed for issue types that do not have line numbers.
708#[must_use]
709pub fn is_file_suppressed(suppressions: &[Suppression], kind: IssueKind) -> bool {
710    suppressions
711        .iter()
712        .any(|suppression| suppression.line == 0 && suppression.matches_issue_kind(0, kind))
713}
714
715/// A suppression token that did not parse to any known `IssueKind`.
716///
717/// Emitted alongside `Suppression` when a `// fallow-ignore-*` marker contains
718/// a typo or an obsolete issue-kind name. The known tokens on the same marker
719/// are recorded as normal `Suppression` entries; this struct preserves the
720/// unknown token so the downstream `find_stale` pass can surface it as a
721/// `StaleSuppression` finding with `kind_known: false`. Without this, the
722/// entire suppression line would be discarded silently. See issue #449.
723#[derive(Debug, Clone)]
724pub struct UnknownSuppressionKind {
725    /// 1-based line where the suppression comment itself appears.
726    pub comment_line: u32,
727    /// Whether the marker was `fallow-ignore-file` (`true`) or
728    /// `fallow-ignore-next-line` (`false`).
729    pub is_file_level: bool,
730    /// The verbatim token from the marker that did not parse.
731    pub token: String,
732    /// Human-authored reason after `--`, when present.
733    pub reason: Option<String>,
734}
735
736/// Find the closest known issue-kind name to `input` when it is plausibly a typo.
737///
738/// Applies the policy of [`crate::levenshtein::closest_match`].
739#[must_use]
740pub fn closest_known_kind_name(input: &str) -> Option<&'static str> {
741    crate::levenshtein::closest_match(input, KNOWN_ISSUE_KIND_NAMES.iter().copied())
742}
743
744const _: () = assert!(std::mem::size_of::<IssueKind>() == 1);
745
746#[cfg(test)]
747mod tests {
748    use super::*;
749
750    #[test]
751    fn issue_kind_parse_accepts_registry_codes_and_aliases() {
752        for meta in crate::issue_meta::ISSUE_KIND_META
753            .iter()
754            .filter(|meta| meta.kind.is_some())
755        {
756            let expected = meta.kind;
757            assert_eq!(
758                IssueKind::parse(meta.code),
759                expected,
760                "canonical registry token {} must parse",
761                meta.code
762            );
763            for alias in meta.aliases {
764                assert_eq!(
765                    IssueKind::parse(alias),
766                    expected,
767                    "registry alias {alias} must parse as {}",
768                    meta.code
769                );
770            }
771        }
772    }
773
774    #[test]
775    fn issue_kind_parse_accepts_registry_suppression_tokens() {
776        for meta in crate::issue_meta::ISSUE_KIND_META {
777            let (Some(kind), Some(token)) = (meta.kind, meta.suppress_token) else {
778                continue;
779            };
780            assert_eq!(
781                IssueKind::parse(token),
782                Some(kind),
783                "registry suppression token {token} must parse as {}",
784                meta.code
785            );
786        }
787    }
788
789    #[test]
790    fn issue_kind_parse_accepts_diff_risk_tokens() {
791        let cases = [
792            ("weakened-tests", IssueKind::WeakenedTests),
793            ("added-suppression", IssueKind::AddedSuppression),
794            ("test-only-branch", IssueKind::TestOnlyBranch),
795            ("diverged-clone-sibling", IssueKind::DivergedCloneSibling),
796            ("added-dependency", IssueKind::AddedDependency),
797        ];
798        for (token, kind) in cases {
799            assert_eq!(IssueKind::parse(token), Some(kind), "{token}");
800            assert_eq!(issue_kind_to_kebab(kind), token);
801        }
802    }
803
804    #[test]
805    fn issue_kind_from_str_unknown() {
806        assert_eq!(IssueKind::parse("foo"), None);
807        assert_eq!(IssueKind::parse(""), None);
808    }
809
810    #[test]
811    fn issue_kind_from_str_near_misses() {
812        assert_eq!(IssueKind::parse("Unused-File"), None);
813        assert_eq!(IssueKind::parse("UNUSED-EXPORT"), None);
814        assert_eq!(IssueKind::parse("unused_file"), None);
815        assert_eq!(IssueKind::parse("unused-files"), None);
816    }
817
818    #[test]
819    fn discriminant_out_of_range() {
820        // Pin exact discriminants so an inserted or reordered variant that
821        // shifts wire values is caught. `ALL` is not discriminant-ordered, so
822        // the mapping is spelled out explicitly, and the row count is checked
823        // against `ALL` below so a new variant cannot be added without a row.
824        let cases: &[(u8, IssueKind)] = &[
825            (1, IssueKind::UnusedFile),
826            (2, IssueKind::UnusedExport),
827            (3, IssueKind::UnusedType),
828            (4, IssueKind::PrivateTypeLeak),
829            (5, IssueKind::UnusedDependency),
830            (6, IssueKind::UnusedDevDependency),
831            (7, IssueKind::UnusedEnumMember),
832            (8, IssueKind::UnusedClassMember),
833            (9, IssueKind::UnresolvedImport),
834            (10, IssueKind::UnlistedDependency),
835            (11, IssueKind::DuplicateExport),
836            (12, IssueKind::CodeDuplication),
837            (13, IssueKind::CircularDependency),
838            (14, IssueKind::TypeOnlyDependency),
839            (15, IssueKind::TestOnlyDependency),
840            (16, IssueKind::BoundaryViolation),
841            (17, IssueKind::CoverageGaps),
842            (18, IssueKind::FeatureFlag),
843            (19, IssueKind::Complexity),
844            (20, IssueKind::StaleSuppression),
845            (21, IssueKind::PnpmCatalogEntry),
846            (22, IssueKind::UnresolvedCatalogReference),
847            (23, IssueKind::UnusedDependencyOverride),
848            (24, IssueKind::MisconfiguredDependencyOverride),
849            (25, IssueKind::EmptyCatalogGroup),
850            (26, IssueKind::ReExportCycle),
851            (27, IssueKind::SecurityClientServerLeak),
852            (28, IssueKind::SecuritySink),
853            (29, IssueKind::PolicyViolation),
854            (30, IssueKind::InvalidClientExport),
855            (31, IssueKind::MixedClientServerBarrel),
856            (32, IssueKind::MisplacedDirective),
857            (33, IssueKind::UnusedStoreMember),
858            (34, IssueKind::UnprovidedInject),
859            (35, IssueKind::RouteCollision),
860            (36, IssueKind::DynamicSegmentNameConflict),
861            (37, IssueKind::UnrenderedComponent),
862            (38, IssueKind::UnusedComponentProp),
863            (39, IssueKind::UnusedComponentEmit),
864            (40, IssueKind::UnusedServerAction),
865            (41, IssueKind::UnusedLoadDataKey),
866            (42, IssueKind::PropDrilling),
867            (43, IssueKind::ThinWrapper),
868            (44, IssueKind::DuplicatePropShape),
869            (45, IssueKind::UnusedComponentInput),
870            (46, IssueKind::UnusedComponentOutput),
871            (47, IssueKind::UnusedSvelteEvent),
872            (48, IssueKind::CssTokenDrift),
873            (49, IssueKind::CssDuplicateBlock),
874            (50, IssueKind::CssSelectorComplexity),
875            (51, IssueKind::CssDeadSurface),
876            (52, IssueKind::CssBrokenReference),
877            (53, IssueKind::DevDependencyInProduction),
878            (54, IssueKind::DeprecatedExportInUse),
879            (55, IssueKind::PackageCycle),
880            (56, IssueKind::AbsentComponentProp),
881            (57, IssueKind::UnusedEffectLayer),
882            (58, IssueKind::UnusedTaggedError),
883            (59, IssueKind::UnproducedCatchTag),
884            (60, IssueKind::WeakenedTests),
885            (61, IssueKind::AddedSuppression),
886            (62, IssueKind::TestOnlyBranch),
887            (63, IssueKind::DivergedCloneSibling),
888            (64, IssueKind::AddedDependency),
889        ];
890        for &(discriminant, kind) in cases {
891            assert_eq!(kind.to_discriminant(), discriminant, "{kind:?} drifted");
892            assert_eq!(IssueKind::from_discriminant(discriminant), Some(kind));
893        }
894        assert_eq!(IssueKind::from_discriminant(0), None);
895        let max_discriminant = IssueKind::ALL
896            .iter()
897            .map(|kind| kind.to_discriminant())
898            .max()
899            .expect("IssueKind::ALL should not be empty");
900        assert_eq!(IssueKind::from_discriminant(max_discriminant + 1), None);
901        assert_eq!(IssueKind::from_discriminant(u8::MAX), None);
902    }
903
904    #[test]
905    fn discriminant_roundtrip() {
906        for &kind in IssueKind::ALL {
907            assert_eq!(
908                IssueKind::from_discriminant(kind.to_discriminant()),
909                Some(kind)
910            );
911        }
912        assert_eq!(IssueKind::from_discriminant(0), None);
913        let max_discriminant = IssueKind::ALL
914            .iter()
915            .map(|kind| kind.to_discriminant())
916            .max()
917            .expect("IssueKind::ALL should not be empty");
918        assert_eq!(IssueKind::from_discriminant(max_discriminant + 1), None);
919    }
920
921    #[test]
922    fn discriminant_values_are_unique() {
923        let discriminants: Vec<u8> = IssueKind::ALL
924            .iter()
925            .map(|kind| kind.to_discriminant())
926            .collect();
927        let mut sorted = discriminants.clone();
928        sorted.sort_unstable();
929        sorted.dedup();
930        assert_eq!(
931            discriminants.len(),
932            sorted.len(),
933            "discriminant values must be unique"
934        );
935    }
936
937    #[test]
938    fn discriminant_starts_at_one() {
939        assert_eq!(IssueKind::UnusedFile.to_discriminant(), 1);
940    }
941
942    #[test]
943    fn issue_kind_to_kebab_uses_registry_suppression_token() {
944        for &kind in IssueKind::ALL {
945            let meta = crate::issue_meta::issue_meta_by_kind(kind)
946                .unwrap_or_else(|| panic!("IssueKind {kind:?} has no metadata row"));
947            let token = issue_kind_to_kebab(kind);
948            assert_eq!(token, meta.suppress_token.unwrap_or(meta.code));
949            assert_eq!(IssueKind::parse(token), Some(kind));
950        }
951    }
952
953    #[test]
954    fn suppression_line_zero_is_file_wide() {
955        let s = Suppression::all(0, 1);
956        assert_eq!(s.line, 0);
957        assert!(s.issue_kind_target().is_none());
958    }
959
960    #[test]
961    fn suppression_with_specific_kind_and_line() {
962        let s = Suppression::issue(42, 41, IssueKind::UnusedExport);
963        assert_eq!(s.line, 42);
964        assert_eq!(s.comment_line, 41);
965        assert_eq!(s.issue_kind_target(), Some(IssueKind::UnusedExport));
966    }
967
968    #[test]
969    fn suppression_predicates_match_lines_and_file_wide_markers() {
970        let suppressions = vec![
971            Suppression::issue(42, 41, IssueKind::UnusedExport),
972            Suppression::all(0, 1),
973        ];
974
975        assert!(is_suppressed(&suppressions, 42, IssueKind::UnusedExport));
976        assert!(is_suppressed(&suppressions, 10, IssueKind::UnusedType));
977        assert!(is_file_suppressed(&suppressions, IssueKind::UnusedFile));
978    }
979
980    #[test]
981    fn parses_scoped_policy_suppression_token() {
982        let target =
983            parse_policy_rule_suppression_token("policy-violation:team-policy/no-child-process")
984                .expect("scoped token should parse");
985        assert_eq!(target.pack, "team-policy");
986        assert_eq!(target.rule_id, "no-child-process");
987        assert_eq!(
988            target.token(),
989            "policy-violation:team-policy/no-child-process"
990        );
991    }
992
993    #[test]
994    fn rejects_malformed_scoped_policy_suppression_tokens() {
995        for token in [
996            "policy-violation:",
997            "policy-violation:team-policy",
998            "policy-violation:/no-child-process",
999            "policy-violation:team-policy/",
1000            "policy-violation:team-policy/no/child-process",
1001            "policy-violation:team policy/no-child-process",
1002            "policy-violation:team-policy/no:child-process",
1003        ] {
1004            assert!(
1005                parse_policy_rule_suppression_token(token).is_none(),
1006                "{token} should be rejected"
1007            );
1008        }
1009    }
1010
1011    #[test]
1012    fn scoped_policy_suppression_matches_exact_policy_rule_only() {
1013        let suppression = Suppression::policy_rule(7, 6, "team-policy", "no-child-process");
1014        assert!(suppression.matches_policy_rule(7, "team-policy", "no-child-process"));
1015        assert!(!suppression.matches_policy_rule(7, "team-policy", "no-fs"));
1016        assert!(!suppression.matches_policy_rule(8, "team-policy", "no-child-process"));
1017        assert!(!suppression.matches_issue_kind(7, IssueKind::PolicyViolation));
1018    }
1019
1020    #[test]
1021    fn known_issue_kind_names_parses_each_entry() {
1022        for &name in KNOWN_ISSUE_KIND_NAMES.iter() {
1023            assert!(
1024                IssueKind::parse(name).is_some(),
1025                "KNOWN_ISSUE_KIND_NAMES contains '{name}' but IssueKind::parse rejects it"
1026            );
1027        }
1028    }
1029
1030    #[test]
1031    fn closest_known_kind_name_finds_near_misses() {
1032        assert_eq!(
1033            closest_known_kind_name("unused-exports"),
1034            Some("unused-export")
1035        );
1036        assert_eq!(closest_known_kind_name("unused-files"), Some("unused-file"));
1037        assert_eq!(closest_known_kind_name("complxity"), Some("complexity"));
1038    }
1039
1040    #[test]
1041    fn closest_known_kind_name_rejects_novel_strings() {
1042        assert_eq!(closest_known_kind_name("xyzzy"), None);
1043        assert_eq!(closest_known_kind_name("foo"), None);
1044        assert_eq!(closest_known_kind_name(""), None);
1045    }
1046
1047    #[test]
1048    fn closest_known_kind_name_skips_exact_match() {
1049        assert_eq!(closest_known_kind_name("unused-export"), None);
1050    }
1051}