Skip to main content

fallow_types/
suppress.rs

1//! Inline suppression comment types and issue kind definitions.
2
3/// Issue kind for suppression matching.
4///
5/// # Examples
6///
7/// ```
8/// use fallow_types::suppress::IssueKind;
9///
10/// let kind = IssueKind::parse("unused-export");
11/// assert_eq!(kind, Some(IssueKind::UnusedExport));
12///
13/// // Round-trip through discriminant
14/// let d = IssueKind::UnusedFile.to_discriminant();
15/// assert_eq!(IssueKind::from_discriminant(d), Some(IssueKind::UnusedFile));
16///
17/// // Unknown strings return None
18/// assert_eq!(IssueKind::parse("not-a-kind"), None);
19/// ```
20#[derive(Debug, Clone, Copy, PartialEq, Eq)]
21pub enum IssueKind {
22    /// An unused file.
23    UnusedFile,
24    /// An unused export.
25    UnusedExport,
26    /// An unused type export.
27    UnusedType,
28    /// An exported signature that references a same-file private type.
29    PrivateTypeLeak,
30    /// An unused dependency.
31    UnusedDependency,
32    /// An unused dev dependency.
33    UnusedDevDependency,
34    /// An unused enum member.
35    UnusedEnumMember,
36    /// An unused class member.
37    UnusedClassMember,
38    /// An unresolved import.
39    UnresolvedImport,
40    /// An unlisted dependency.
41    UnlistedDependency,
42    /// A duplicate export name across modules.
43    DuplicateExport,
44    /// Code duplication.
45    CodeDuplication,
46    /// A circular dependency chain.
47    CircularDependency,
48    /// A cycle or self-loop in the re-export edge subgraph (barrel files
49    /// re-exporting from each other in a loop). Structurally always a bug:
50    /// chain propagation through the cycle is a no-op.
51    ReExportCycle,
52    /// A production dependency only imported via type-only imports.
53    TypeOnlyDependency,
54    /// A production dependency only imported by test files.
55    TestOnlyDependency,
56    /// An import that crosses an architecture boundary.
57    BoundaryViolation,
58    /// A runtime file or export with no test dependency path.
59    CoverageGaps,
60    /// A detected feature flag pattern.
61    FeatureFlag,
62    /// A function exceeding complexity thresholds (health command).
63    Complexity,
64    /// A suppression comment or JSDoc tag that no longer matches any issue.
65    StaleSuppression,
66    /// A pnpm catalog entry in pnpm-workspace.yaml not referenced by any workspace package.
67    PnpmCatalogEntry,
68    /// A named pnpm catalog group in pnpm-workspace.yaml with no entries.
69    EmptyCatalogGroup,
70    /// A workspace package.json reference (`catalog:` / `catalog:<name>`) pointing at
71    /// a catalog that does not declare the consumed package.
72    UnresolvedCatalogReference,
73    /// An entry in pnpm's `overrides:` / `pnpm.overrides` whose target package
74    /// is not declared in any workspace `package.json`.
75    UnusedDependencyOverride,
76    /// An entry in pnpm's `overrides:` / `pnpm.overrides` whose key or value
77    /// cannot be parsed into a valid pnpm shape.
78    MisconfiguredDependencyOverride,
79    /// A `"use client"` file that transitively imports a module reading a
80    /// non-public `process.env` secret (security candidate).
81    SecurityClientServerLeak,
82    /// A syntactic tainted-sink candidate matched against the data-driven
83    /// security matcher catalogue (`security_matchers.toml`). ONE suppression
84    /// token covers all catalogue categories.
85    SecuritySink,
86    /// A banned call or banned import matched by a declarative rule pack
87    /// (`rulePacks` config). ONE suppression token covers every pack rule;
88    /// per-rule suppression is a tracked follow-up.
89    PolicyViolation,
90}
91
92impl IssueKind {
93    /// Parse an issue kind from the string tokens used in CLI output and suppression comments.
94    #[must_use]
95    pub fn parse(s: &str) -> Option<Self> {
96        match s {
97            "unused-file" => Some(Self::UnusedFile),
98            "unused-export" => Some(Self::UnusedExport),
99            "unused-type" => Some(Self::UnusedType),
100            "private-type-leak" => Some(Self::PrivateTypeLeak),
101            "unused-dependency" => Some(Self::UnusedDependency),
102            "unused-dev-dependency" => Some(Self::UnusedDevDependency),
103            "unused-enum-member" => Some(Self::UnusedEnumMember),
104            "unused-class-member" => Some(Self::UnusedClassMember),
105            "unresolved-import" => Some(Self::UnresolvedImport),
106            "unlisted-dependency" => Some(Self::UnlistedDependency),
107            "duplicate-export" => Some(Self::DuplicateExport),
108            "code-duplication" => Some(Self::CodeDuplication),
109            "circular-dependency" | "circular-dependencies" => Some(Self::CircularDependency),
110            "re-export-cycle" | "re-export-cycles" | "reexport-cycle" | "reexport-cycles" => {
111                Some(Self::ReExportCycle)
112            }
113            "type-only-dependency" => Some(Self::TypeOnlyDependency),
114            "test-only-dependency" => Some(Self::TestOnlyDependency),
115            "boundary-violation" | "boundary-call-violation" | "boundary-call-violations" => {
116                Some(Self::BoundaryViolation)
117            }
118            "coverage-gaps" => Some(Self::CoverageGaps),
119            "feature-flag" => Some(Self::FeatureFlag),
120            "complexity" => Some(Self::Complexity),
121            "stale-suppression" => Some(Self::StaleSuppression),
122            "unused-catalog-entry" | "unused-catalog-entries" => Some(Self::PnpmCatalogEntry),
123            "empty-catalog-group" | "empty-catalog-groups" => Some(Self::EmptyCatalogGroup),
124            "unresolved-catalog-reference" | "unresolved-catalog-references" => {
125                Some(Self::UnresolvedCatalogReference)
126            }
127            "unused-dependency-override" | "unused-dependency-overrides" => {
128                Some(Self::UnusedDependencyOverride)
129            }
130            "misconfigured-dependency-override" | "misconfigured-dependency-overrides" => {
131                Some(Self::MisconfiguredDependencyOverride)
132            }
133            "security-client-server-leak" => Some(Self::SecurityClientServerLeak),
134            "security-sink" => Some(Self::SecuritySink),
135            "policy-violation" | "policy-violations" => Some(Self::PolicyViolation),
136            _ => None,
137        }
138    }
139
140    /// Convert to a u8 discriminant for compact cache storage.
141    #[must_use]
142    pub const fn to_discriminant(self) -> u8 {
143        match self {
144            Self::UnusedFile => 1,
145            Self::UnusedExport => 2,
146            Self::UnusedType => 3,
147            Self::PrivateTypeLeak => 4,
148            Self::UnusedDependency => 5,
149            Self::UnusedDevDependency => 6,
150            Self::UnusedEnumMember => 7,
151            Self::UnusedClassMember => 8,
152            Self::UnresolvedImport => 9,
153            Self::UnlistedDependency => 10,
154            Self::DuplicateExport => 11,
155            Self::CodeDuplication => 12,
156            Self::CircularDependency => 13,
157            Self::TypeOnlyDependency => 14,
158            Self::TestOnlyDependency => 15,
159            Self::BoundaryViolation => 16,
160            Self::CoverageGaps => 17,
161            Self::FeatureFlag => 18,
162            Self::Complexity => 19,
163            Self::StaleSuppression => 20,
164            Self::PnpmCatalogEntry => 21,
165            Self::UnresolvedCatalogReference => 22,
166            Self::UnusedDependencyOverride => 23,
167            Self::MisconfiguredDependencyOverride => 24,
168            Self::EmptyCatalogGroup => 25,
169            Self::ReExportCycle => 26,
170            Self::SecurityClientServerLeak => 27,
171            Self::SecuritySink => 28,
172            Self::PolicyViolation => 29,
173        }
174    }
175
176    /// Reconstruct from a cache discriminant.
177    #[must_use]
178    pub const fn from_discriminant(d: u8) -> Option<Self> {
179        match d {
180            1 => Some(Self::UnusedFile),
181            2 => Some(Self::UnusedExport),
182            3 => Some(Self::UnusedType),
183            4 => Some(Self::PrivateTypeLeak),
184            5 => Some(Self::UnusedDependency),
185            6 => Some(Self::UnusedDevDependency),
186            7 => Some(Self::UnusedEnumMember),
187            8 => Some(Self::UnusedClassMember),
188            9 => Some(Self::UnresolvedImport),
189            10 => Some(Self::UnlistedDependency),
190            11 => Some(Self::DuplicateExport),
191            12 => Some(Self::CodeDuplication),
192            13 => Some(Self::CircularDependency),
193            14 => Some(Self::TypeOnlyDependency),
194            15 => Some(Self::TestOnlyDependency),
195            16 => Some(Self::BoundaryViolation),
196            17 => Some(Self::CoverageGaps),
197            18 => Some(Self::FeatureFlag),
198            19 => Some(Self::Complexity),
199            20 => Some(Self::StaleSuppression),
200            21 => Some(Self::PnpmCatalogEntry),
201            22 => Some(Self::UnresolvedCatalogReference),
202            23 => Some(Self::UnusedDependencyOverride),
203            24 => Some(Self::MisconfiguredDependencyOverride),
204            25 => Some(Self::EmptyCatalogGroup),
205            26 => Some(Self::ReExportCycle),
206            27 => Some(Self::SecurityClientServerLeak),
207            28 => Some(Self::SecuritySink),
208            29 => Some(Self::PolicyViolation),
209            _ => None,
210        }
211    }
212}
213
214/// A suppression directive parsed from a source comment.
215///
216/// # Examples
217///
218/// ```
219/// use fallow_types::suppress::{Suppression, IssueKind};
220///
221/// // File-wide suppression (line 0, no specific kind)
222/// let file_wide = Suppression { line: 0, comment_line: 1, kind: None };
223/// assert_eq!(file_wide.line, 0);
224///
225/// // Line-specific suppression for unused exports
226/// let line_suppress = Suppression {
227///     line: 42,
228///     comment_line: 41,
229///     kind: Some(IssueKind::UnusedExport),
230/// };
231/// assert_eq!(line_suppress.kind, Some(IssueKind::UnusedExport));
232/// ```
233#[derive(Debug, Clone)]
234pub struct Suppression {
235    /// 1-based line this suppression applies to. 0 = file-wide suppression.
236    pub line: u32,
237    /// 1-based line where the suppression comment itself appears.
238    /// For `fallow-ignore-next-line`, this is `line - 1`.
239    /// For `fallow-ignore-file`, this is the actual line of the comment in the source.
240    pub comment_line: u32,
241    /// None = suppress all issue kinds on this line.
242    pub kind: Option<IssueKind>,
243}
244
245/// A suppression token that did not parse to any known `IssueKind`.
246///
247/// Emitted alongside `Suppression` when a `// fallow-ignore-*` marker contains
248/// a typo or an obsolete issue-kind name. The known tokens on the same marker
249/// are recorded as normal `Suppression` entries; this struct preserves the
250/// unknown token so the downstream `find_stale` pass can surface it as a
251/// `StaleSuppression` finding with `kind_known: false`. Without this, the
252/// entire suppression line would be discarded silently. See issue #449.
253#[derive(Debug, Clone)]
254pub struct UnknownSuppressionKind {
255    /// 1-based line where the suppression comment itself appears.
256    pub comment_line: u32,
257    /// Whether the marker was `fallow-ignore-file` (`true`) or
258    /// `fallow-ignore-next-line` (`false`).
259    pub is_file_level: bool,
260    /// The verbatim token from the marker that did not parse.
261    pub token: String,
262}
263
264/// Canonical kebab-case names accepted by `IssueKind::parse`, including
265/// documented plural aliases.
266///
267/// Used by `closest_known_kind_name` for Levenshtein "did you mean?" hints
268/// when a suppression marker carries an unknown token. Keep in sync with the
269/// `IssueKind::parse` match table above; the
270/// `issue_kind_parse_covers_known_names` test asserts every entry round-trips.
271pub const KNOWN_ISSUE_KIND_NAMES: &[&str] = &[
272    "unused-file",
273    "unused-export",
274    "unused-type",
275    "private-type-leak",
276    "unused-dependency",
277    "unused-dev-dependency",
278    "unused-enum-member",
279    "unused-class-member",
280    "unresolved-import",
281    "unlisted-dependency",
282    "duplicate-export",
283    "code-duplication",
284    "circular-dependency",
285    "circular-dependencies",
286    "re-export-cycle",
287    "re-export-cycles",
288    "reexport-cycle",
289    "reexport-cycles",
290    "type-only-dependency",
291    "test-only-dependency",
292    "boundary-violation",
293    "boundary-call-violation",
294    "boundary-call-violations",
295    "coverage-gaps",
296    "feature-flag",
297    "complexity",
298    "stale-suppression",
299    "unused-catalog-entry",
300    "unused-catalog-entries",
301    "empty-catalog-group",
302    "empty-catalog-groups",
303    "unresolved-catalog-reference",
304    "unresolved-catalog-references",
305    "unused-dependency-override",
306    "unused-dependency-overrides",
307    "misconfigured-dependency-override",
308    "misconfigured-dependency-overrides",
309    "security-client-server-leak",
310    "security-sink",
311    "policy-violation",
312    "policy-violations",
313];
314
315/// CLI filter flags on `fallow dead-code` that scope output to a single
316/// issue type.
317///
318/// Shared home so the agent capability manifest (`fallow schema` in
319/// `crates/cli`), the MCP server's `issue_types` allowlist
320/// (`ISSUE_TYPE_FLAGS` in `crates/mcp`), and the clap flag definitions stay
321/// in sync: each crate carries a drift test asserting its own list against
322/// this one.
323pub const DEAD_CODE_FILTER_FLAGS: &[&str] = &[
324    "--unused-files",
325    "--unused-exports",
326    "--unused-types",
327    "--private-type-leaks",
328    "--unused-deps",
329    "--unused-enum-members",
330    "--unused-class-members",
331    "--unresolved-imports",
332    "--unlisted-deps",
333    "--duplicate-exports",
334    "--circular-deps",
335    "--re-export-cycles",
336    "--boundary-violations",
337    "--policy-violations",
338    "--stale-suppressions",
339    "--unused-catalog-entries",
340    "--empty-catalog-groups",
341    "--unresolved-catalog-references",
342    "--unused-dependency-overrides",
343    "--misconfigured-dependency-overrides",
344];
345
346/// Levenshtein edit distance between two ASCII-leaning strings.
347///
348/// Local duplicate of the config-crate helper (see
349/// `crates/config/src/config/rules.rs::levenshtein`) so `fallow-types` can
350/// compute "did you mean?" suggestions for unknown suppression tokens without
351/// taking a dependency on `fallow-config`. Issue-kind names are short
352/// (max ~33 chars) so allocation cost is negligible.
353fn levenshtein(a: &str, b: &str) -> usize {
354    let a_bytes = a.as_bytes();
355    let b_bytes = b.as_bytes();
356    let (a_len, b_len) = (a_bytes.len(), b_bytes.len());
357
358    if a_len == 0 {
359        return b_len;
360    }
361    if b_len == 0 {
362        return a_len;
363    }
364
365    let mut prev: Vec<usize> = (0..=b_len).collect();
366    let mut curr: Vec<usize> = vec![0; b_len + 1];
367
368    for i in 1..=a_len {
369        curr[0] = i;
370        for j in 1..=b_len {
371            let cost = usize::from(a_bytes[i - 1] != b_bytes[j - 1]);
372            curr[j] = (prev[j] + 1).min(curr[j - 1] + 1).min(prev[j - 1] + cost);
373        }
374        std::mem::swap(&mut prev, &mut curr);
375    }
376
377    prev[b_len]
378}
379
380/// Find the closest known issue-kind name to `input` when it is plausibly a typo.
381///
382/// Returns the best match when the Levenshtein distance is at most 2 AND
383/// the input is long enough that the match is not coincidental
384/// (`input.len() / 2 > distance`). Returns `None` for completely novel
385/// strings where a suggestion would be misleading.
386#[must_use]
387pub fn closest_known_kind_name(input: &str) -> Option<&'static str> {
388    let input_lower = input.to_ascii_lowercase();
389    let mut best: Option<(&'static str, usize)> = None;
390
391    for &candidate in KNOWN_ISSUE_KIND_NAMES {
392        let d = levenshtein(&input_lower, candidate);
393        if best.is_none_or(|(_, b_dist)| d < b_dist) {
394            best = Some((candidate, d));
395        }
396    }
397
398    best.filter(|&(_, d)| d > 0 && d <= 2 && input_lower.len() / 2 > d)
399        .map(|(name, _)| name)
400}
401
402const _: () = assert!(std::mem::size_of::<Suppression>() == 12);
403const _: () = assert!(std::mem::size_of::<IssueKind>() == 1);
404
405#[cfg(test)]
406mod tests {
407    use super::*;
408
409    #[test]
410    fn issue_kind_from_str_all_variants() {
411        assert_eq!(IssueKind::parse("unused-file"), Some(IssueKind::UnusedFile));
412        assert_eq!(
413            IssueKind::parse("unused-export"),
414            Some(IssueKind::UnusedExport)
415        );
416        assert_eq!(IssueKind::parse("unused-type"), Some(IssueKind::UnusedType));
417        assert_eq!(
418            IssueKind::parse("private-type-leak"),
419            Some(IssueKind::PrivateTypeLeak)
420        );
421        assert_eq!(
422            IssueKind::parse("unused-dependency"),
423            Some(IssueKind::UnusedDependency)
424        );
425        assert_eq!(
426            IssueKind::parse("unused-dev-dependency"),
427            Some(IssueKind::UnusedDevDependency)
428        );
429        assert_eq!(
430            IssueKind::parse("unused-enum-member"),
431            Some(IssueKind::UnusedEnumMember)
432        );
433        assert_eq!(
434            IssueKind::parse("unused-class-member"),
435            Some(IssueKind::UnusedClassMember)
436        );
437        assert_eq!(
438            IssueKind::parse("unresolved-import"),
439            Some(IssueKind::UnresolvedImport)
440        );
441        assert_eq!(
442            IssueKind::parse("unlisted-dependency"),
443            Some(IssueKind::UnlistedDependency)
444        );
445        assert_eq!(
446            IssueKind::parse("duplicate-export"),
447            Some(IssueKind::DuplicateExport)
448        );
449        assert_eq!(
450            IssueKind::parse("code-duplication"),
451            Some(IssueKind::CodeDuplication)
452        );
453        assert_eq!(
454            IssueKind::parse("circular-dependency"),
455            Some(IssueKind::CircularDependency)
456        );
457        assert_eq!(
458            IssueKind::parse("circular-dependencies"),
459            Some(IssueKind::CircularDependency)
460        );
461        assert_eq!(
462            IssueKind::parse("type-only-dependency"),
463            Some(IssueKind::TypeOnlyDependency)
464        );
465        assert_eq!(
466            IssueKind::parse("test-only-dependency"),
467            Some(IssueKind::TestOnlyDependency)
468        );
469        assert_eq!(
470            IssueKind::parse("boundary-violation"),
471            Some(IssueKind::BoundaryViolation)
472        );
473        // The boundary family token also accepts the rule-id-shaped alias so
474        // users who derive the token from the `boundary-call-violation` rule
475        // id by analogy get a working suppression instead of a silent no-op.
476        assert_eq!(
477            IssueKind::parse("boundary-call-violation"),
478            Some(IssueKind::BoundaryViolation)
479        );
480        assert_eq!(
481            IssueKind::parse("boundary-call-violations"),
482            Some(IssueKind::BoundaryViolation)
483        );
484        assert_eq!(
485            IssueKind::parse("coverage-gaps"),
486            Some(IssueKind::CoverageGaps)
487        );
488        assert_eq!(
489            IssueKind::parse("feature-flag"),
490            Some(IssueKind::FeatureFlag)
491        );
492        assert_eq!(IssueKind::parse("complexity"), Some(IssueKind::Complexity));
493        assert_eq!(
494            IssueKind::parse("stale-suppression"),
495            Some(IssueKind::StaleSuppression)
496        );
497        assert_eq!(
498            IssueKind::parse("unused-catalog-entry"),
499            Some(IssueKind::PnpmCatalogEntry)
500        );
501        assert_eq!(
502            IssueKind::parse("unused-catalog-entries"),
503            Some(IssueKind::PnpmCatalogEntry)
504        );
505        assert_eq!(
506            IssueKind::parse("empty-catalog-group"),
507            Some(IssueKind::EmptyCatalogGroup)
508        );
509        assert_eq!(
510            IssueKind::parse("empty-catalog-groups"),
511            Some(IssueKind::EmptyCatalogGroup)
512        );
513        assert_eq!(
514            IssueKind::parse("unresolved-catalog-reference"),
515            Some(IssueKind::UnresolvedCatalogReference)
516        );
517        assert_eq!(
518            IssueKind::parse("unresolved-catalog-references"),
519            Some(IssueKind::UnresolvedCatalogReference)
520        );
521        assert_eq!(
522            IssueKind::parse("unused-dependency-override"),
523            Some(IssueKind::UnusedDependencyOverride)
524        );
525        assert_eq!(
526            IssueKind::parse("unused-dependency-overrides"),
527            Some(IssueKind::UnusedDependencyOverride)
528        );
529        assert_eq!(
530            IssueKind::parse("misconfigured-dependency-override"),
531            Some(IssueKind::MisconfiguredDependencyOverride)
532        );
533        assert_eq!(
534            IssueKind::parse("misconfigured-dependency-overrides"),
535            Some(IssueKind::MisconfiguredDependencyOverride)
536        );
537        assert_eq!(
538            IssueKind::parse("security-client-server-leak"),
539            Some(IssueKind::SecurityClientServerLeak)
540        );
541        assert_eq!(
542            IssueKind::parse("security-sink"),
543            Some(IssueKind::SecuritySink)
544        );
545        assert_eq!(
546            IssueKind::parse("policy-violation"),
547            Some(IssueKind::PolicyViolation)
548        );
549        assert_eq!(
550            IssueKind::parse("policy-violations"),
551            Some(IssueKind::PolicyViolation)
552        );
553    }
554
555    #[test]
556    fn issue_kind_from_str_unknown() {
557        assert_eq!(IssueKind::parse("foo"), None);
558        assert_eq!(IssueKind::parse(""), None);
559    }
560
561    #[test]
562    fn issue_kind_from_str_near_misses() {
563        assert_eq!(IssueKind::parse("Unused-File"), None);
564        assert_eq!(IssueKind::parse("UNUSED-EXPORT"), None);
565        assert_eq!(IssueKind::parse("unused_file"), None);
566        assert_eq!(IssueKind::parse("unused-files"), None);
567    }
568
569    #[test]
570    fn discriminant_out_of_range() {
571        assert_eq!(IssueKind::from_discriminant(0), None);
572        assert_eq!(
573            IssueKind::from_discriminant(29),
574            Some(IssueKind::PolicyViolation)
575        );
576        assert_eq!(IssueKind::from_discriminant(30), None);
577        assert_eq!(IssueKind::from_discriminant(u8::MAX), None);
578    }
579
580    #[test]
581    fn discriminant_roundtrip() {
582        for kind in [
583            IssueKind::UnusedFile,
584            IssueKind::UnusedExport,
585            IssueKind::UnusedType,
586            IssueKind::PrivateTypeLeak,
587            IssueKind::UnusedDependency,
588            IssueKind::UnusedDevDependency,
589            IssueKind::UnusedEnumMember,
590            IssueKind::UnusedClassMember,
591            IssueKind::UnresolvedImport,
592            IssueKind::UnlistedDependency,
593            IssueKind::DuplicateExport,
594            IssueKind::CodeDuplication,
595            IssueKind::CircularDependency,
596            IssueKind::ReExportCycle,
597            IssueKind::TypeOnlyDependency,
598            IssueKind::TestOnlyDependency,
599            IssueKind::BoundaryViolation,
600            IssueKind::CoverageGaps,
601            IssueKind::FeatureFlag,
602            IssueKind::Complexity,
603            IssueKind::StaleSuppression,
604            IssueKind::PnpmCatalogEntry,
605            IssueKind::EmptyCatalogGroup,
606            IssueKind::UnresolvedCatalogReference,
607            IssueKind::UnusedDependencyOverride,
608            IssueKind::MisconfiguredDependencyOverride,
609            IssueKind::SecurityClientServerLeak,
610            IssueKind::SecuritySink,
611            IssueKind::PolicyViolation,
612        ] {
613            assert_eq!(
614                IssueKind::from_discriminant(kind.to_discriminant()),
615                Some(kind)
616            );
617        }
618        assert_eq!(IssueKind::from_discriminant(0), None);
619        assert_eq!(IssueKind::from_discriminant(30), None);
620    }
621
622    #[test]
623    fn discriminant_values_are_unique() {
624        let all_kinds = [
625            IssueKind::UnusedFile,
626            IssueKind::UnusedExport,
627            IssueKind::UnusedType,
628            IssueKind::PrivateTypeLeak,
629            IssueKind::UnusedDependency,
630            IssueKind::UnusedDevDependency,
631            IssueKind::UnusedEnumMember,
632            IssueKind::UnusedClassMember,
633            IssueKind::UnresolvedImport,
634            IssueKind::UnlistedDependency,
635            IssueKind::DuplicateExport,
636            IssueKind::CodeDuplication,
637            IssueKind::CircularDependency,
638            IssueKind::ReExportCycle,
639            IssueKind::TypeOnlyDependency,
640            IssueKind::TestOnlyDependency,
641            IssueKind::BoundaryViolation,
642            IssueKind::CoverageGaps,
643            IssueKind::FeatureFlag,
644            IssueKind::Complexity,
645            IssueKind::StaleSuppression,
646            IssueKind::PnpmCatalogEntry,
647            IssueKind::EmptyCatalogGroup,
648            IssueKind::UnresolvedCatalogReference,
649            IssueKind::UnusedDependencyOverride,
650            IssueKind::MisconfiguredDependencyOverride,
651            IssueKind::SecurityClientServerLeak,
652            IssueKind::SecuritySink,
653            IssueKind::PolicyViolation,
654        ];
655        let discriminants: Vec<u8> = all_kinds.iter().map(|k| k.to_discriminant()).collect();
656        let mut sorted = discriminants.clone();
657        sorted.sort_unstable();
658        sorted.dedup();
659        assert_eq!(
660            discriminants.len(),
661            sorted.len(),
662            "discriminant values must be unique"
663        );
664    }
665
666    #[test]
667    fn discriminant_starts_at_one() {
668        assert_eq!(IssueKind::UnusedFile.to_discriminant(), 1);
669    }
670
671    #[test]
672    fn suppression_line_zero_is_file_wide() {
673        let s = Suppression {
674            line: 0,
675            comment_line: 1,
676            kind: None,
677        };
678        assert_eq!(s.line, 0);
679        assert!(s.kind.is_none());
680    }
681
682    #[test]
683    fn suppression_with_specific_kind_and_line() {
684        let s = Suppression {
685            line: 42,
686            comment_line: 41,
687            kind: Some(IssueKind::UnusedExport),
688        };
689        assert_eq!(s.line, 42);
690        assert_eq!(s.comment_line, 41);
691        assert_eq!(s.kind, Some(IssueKind::UnusedExport));
692    }
693
694    #[test]
695    fn known_issue_kind_names_parses_each_entry() {
696        for &name in KNOWN_ISSUE_KIND_NAMES {
697            assert!(
698                IssueKind::parse(name).is_some(),
699                "KNOWN_ISSUE_KIND_NAMES contains '{name}' but IssueKind::parse rejects it"
700            );
701        }
702    }
703
704    #[test]
705    fn closest_known_kind_name_finds_near_misses() {
706        assert_eq!(
707            closest_known_kind_name("unused-exports"),
708            Some("unused-export")
709        );
710        assert_eq!(closest_known_kind_name("unused-files"), Some("unused-file"));
711        assert_eq!(closest_known_kind_name("complxity"), Some("complexity"));
712    }
713
714    #[test]
715    fn closest_known_kind_name_rejects_novel_strings() {
716        assert_eq!(closest_known_kind_name("xyzzy"), None);
717        assert_eq!(closest_known_kind_name("foo"), None);
718        assert_eq!(closest_known_kind_name(""), None);
719    }
720
721    #[test]
722    fn closest_known_kind_name_skips_exact_match() {
723        assert_eq!(closest_known_kind_name("unused-export"), None);
724    }
725}