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}
87
88impl IssueKind {
89    /// Parse an issue kind from the string tokens used in CLI output and suppression comments.
90    #[must_use]
91    pub fn parse(s: &str) -> Option<Self> {
92        match s {
93            "unused-file" => Some(Self::UnusedFile),
94            "unused-export" => Some(Self::UnusedExport),
95            "unused-type" => Some(Self::UnusedType),
96            "private-type-leak" => Some(Self::PrivateTypeLeak),
97            "unused-dependency" => Some(Self::UnusedDependency),
98            "unused-dev-dependency" => Some(Self::UnusedDevDependency),
99            "unused-enum-member" => Some(Self::UnusedEnumMember),
100            "unused-class-member" => Some(Self::UnusedClassMember),
101            "unresolved-import" => Some(Self::UnresolvedImport),
102            "unlisted-dependency" => Some(Self::UnlistedDependency),
103            "duplicate-export" => Some(Self::DuplicateExport),
104            "code-duplication" => Some(Self::CodeDuplication),
105            "circular-dependency" | "circular-dependencies" => Some(Self::CircularDependency),
106            "re-export-cycle" | "re-export-cycles" | "reexport-cycle" | "reexport-cycles" => {
107                Some(Self::ReExportCycle)
108            }
109            "type-only-dependency" => Some(Self::TypeOnlyDependency),
110            "test-only-dependency" => Some(Self::TestOnlyDependency),
111            "boundary-violation" => Some(Self::BoundaryViolation),
112            "coverage-gaps" => Some(Self::CoverageGaps),
113            "feature-flag" => Some(Self::FeatureFlag),
114            "complexity" => Some(Self::Complexity),
115            "stale-suppression" => Some(Self::StaleSuppression),
116            "unused-catalog-entry" | "unused-catalog-entries" => Some(Self::PnpmCatalogEntry),
117            "empty-catalog-group" | "empty-catalog-groups" => Some(Self::EmptyCatalogGroup),
118            "unresolved-catalog-reference" | "unresolved-catalog-references" => {
119                Some(Self::UnresolvedCatalogReference)
120            }
121            "unused-dependency-override" | "unused-dependency-overrides" => {
122                Some(Self::UnusedDependencyOverride)
123            }
124            "misconfigured-dependency-override" | "misconfigured-dependency-overrides" => {
125                Some(Self::MisconfiguredDependencyOverride)
126            }
127            "security-client-server-leak" => Some(Self::SecurityClientServerLeak),
128            "security-sink" => Some(Self::SecuritySink),
129            _ => None,
130        }
131    }
132
133    /// Convert to a u8 discriminant for compact cache storage.
134    #[must_use]
135    pub const fn to_discriminant(self) -> u8 {
136        match self {
137            Self::UnusedFile => 1,
138            Self::UnusedExport => 2,
139            Self::UnusedType => 3,
140            Self::PrivateTypeLeak => 4,
141            Self::UnusedDependency => 5,
142            Self::UnusedDevDependency => 6,
143            Self::UnusedEnumMember => 7,
144            Self::UnusedClassMember => 8,
145            Self::UnresolvedImport => 9,
146            Self::UnlistedDependency => 10,
147            Self::DuplicateExport => 11,
148            Self::CodeDuplication => 12,
149            Self::CircularDependency => 13,
150            Self::TypeOnlyDependency => 14,
151            Self::TestOnlyDependency => 15,
152            Self::BoundaryViolation => 16,
153            Self::CoverageGaps => 17,
154            Self::FeatureFlag => 18,
155            Self::Complexity => 19,
156            Self::StaleSuppression => 20,
157            Self::PnpmCatalogEntry => 21,
158            Self::UnresolvedCatalogReference => 22,
159            Self::UnusedDependencyOverride => 23,
160            Self::MisconfiguredDependencyOverride => 24,
161            Self::EmptyCatalogGroup => 25,
162            Self::ReExportCycle => 26,
163            Self::SecurityClientServerLeak => 27,
164            Self::SecuritySink => 28,
165        }
166    }
167
168    /// Reconstruct from a cache discriminant.
169    #[must_use]
170    pub const fn from_discriminant(d: u8) -> Option<Self> {
171        match d {
172            1 => Some(Self::UnusedFile),
173            2 => Some(Self::UnusedExport),
174            3 => Some(Self::UnusedType),
175            4 => Some(Self::PrivateTypeLeak),
176            5 => Some(Self::UnusedDependency),
177            6 => Some(Self::UnusedDevDependency),
178            7 => Some(Self::UnusedEnumMember),
179            8 => Some(Self::UnusedClassMember),
180            9 => Some(Self::UnresolvedImport),
181            10 => Some(Self::UnlistedDependency),
182            11 => Some(Self::DuplicateExport),
183            12 => Some(Self::CodeDuplication),
184            13 => Some(Self::CircularDependency),
185            14 => Some(Self::TypeOnlyDependency),
186            15 => Some(Self::TestOnlyDependency),
187            16 => Some(Self::BoundaryViolation),
188            17 => Some(Self::CoverageGaps),
189            18 => Some(Self::FeatureFlag),
190            19 => Some(Self::Complexity),
191            20 => Some(Self::StaleSuppression),
192            21 => Some(Self::PnpmCatalogEntry),
193            22 => Some(Self::UnresolvedCatalogReference),
194            23 => Some(Self::UnusedDependencyOverride),
195            24 => Some(Self::MisconfiguredDependencyOverride),
196            25 => Some(Self::EmptyCatalogGroup),
197            26 => Some(Self::ReExportCycle),
198            27 => Some(Self::SecurityClientServerLeak),
199            28 => Some(Self::SecuritySink),
200            _ => None,
201        }
202    }
203}
204
205/// A suppression directive parsed from a source comment.
206///
207/// # Examples
208///
209/// ```
210/// use fallow_types::suppress::{Suppression, IssueKind};
211///
212/// // File-wide suppression (line 0, no specific kind)
213/// let file_wide = Suppression { line: 0, comment_line: 1, kind: None };
214/// assert_eq!(file_wide.line, 0);
215///
216/// // Line-specific suppression for unused exports
217/// let line_suppress = Suppression {
218///     line: 42,
219///     comment_line: 41,
220///     kind: Some(IssueKind::UnusedExport),
221/// };
222/// assert_eq!(line_suppress.kind, Some(IssueKind::UnusedExport));
223/// ```
224#[derive(Debug, Clone)]
225pub struct Suppression {
226    /// 1-based line this suppression applies to. 0 = file-wide suppression.
227    pub line: u32,
228    /// 1-based line where the suppression comment itself appears.
229    /// For `fallow-ignore-next-line`, this is `line - 1`.
230    /// For `fallow-ignore-file`, this is the actual line of the comment in the source.
231    pub comment_line: u32,
232    /// None = suppress all issue kinds on this line.
233    pub kind: Option<IssueKind>,
234}
235
236/// A suppression token that did not parse to any known `IssueKind`.
237///
238/// Emitted alongside `Suppression` when a `// fallow-ignore-*` marker contains
239/// a typo or an obsolete issue-kind name. The known tokens on the same marker
240/// are recorded as normal `Suppression` entries; this struct preserves the
241/// unknown token so the downstream `find_stale` pass can surface it as a
242/// `StaleSuppression` finding with `kind_known: false`. Without this, the
243/// entire suppression line would be discarded silently. See issue #449.
244#[derive(Debug, Clone)]
245pub struct UnknownSuppressionKind {
246    /// 1-based line where the suppression comment itself appears.
247    pub comment_line: u32,
248    /// Whether the marker was `fallow-ignore-file` (`true`) or
249    /// `fallow-ignore-next-line` (`false`).
250    pub is_file_level: bool,
251    /// The verbatim token from the marker that did not parse.
252    pub token: String,
253}
254
255/// Canonical kebab-case names accepted by `IssueKind::parse`, including
256/// documented plural aliases.
257///
258/// Used by `closest_known_kind_name` for Levenshtein "did you mean?" hints
259/// when a suppression marker carries an unknown token. Keep in sync with the
260/// `IssueKind::parse` match table above; the
261/// `issue_kind_parse_covers_known_names` test asserts every entry round-trips.
262pub const KNOWN_ISSUE_KIND_NAMES: &[&str] = &[
263    "unused-file",
264    "unused-export",
265    "unused-type",
266    "private-type-leak",
267    "unused-dependency",
268    "unused-dev-dependency",
269    "unused-enum-member",
270    "unused-class-member",
271    "unresolved-import",
272    "unlisted-dependency",
273    "duplicate-export",
274    "code-duplication",
275    "circular-dependency",
276    "circular-dependencies",
277    "re-export-cycle",
278    "re-export-cycles",
279    "reexport-cycle",
280    "reexport-cycles",
281    "type-only-dependency",
282    "test-only-dependency",
283    "boundary-violation",
284    "coverage-gaps",
285    "feature-flag",
286    "complexity",
287    "stale-suppression",
288    "unused-catalog-entry",
289    "unused-catalog-entries",
290    "empty-catalog-group",
291    "empty-catalog-groups",
292    "unresolved-catalog-reference",
293    "unresolved-catalog-references",
294    "unused-dependency-override",
295    "unused-dependency-overrides",
296    "misconfigured-dependency-override",
297    "misconfigured-dependency-overrides",
298    "security-client-server-leak",
299    "security-sink",
300];
301
302/// Levenshtein edit distance between two ASCII-leaning strings.
303///
304/// Local duplicate of the config-crate helper (see
305/// `crates/config/src/config/rules.rs::levenshtein`) so `fallow-types` can
306/// compute "did you mean?" suggestions for unknown suppression tokens without
307/// taking a dependency on `fallow-config`. Issue-kind names are short
308/// (max ~33 chars) so allocation cost is negligible.
309fn levenshtein(a: &str, b: &str) -> usize {
310    let a_bytes = a.as_bytes();
311    let b_bytes = b.as_bytes();
312    let (a_len, b_len) = (a_bytes.len(), b_bytes.len());
313
314    if a_len == 0 {
315        return b_len;
316    }
317    if b_len == 0 {
318        return a_len;
319    }
320
321    let mut prev: Vec<usize> = (0..=b_len).collect();
322    let mut curr: Vec<usize> = vec![0; b_len + 1];
323
324    for i in 1..=a_len {
325        curr[0] = i;
326        for j in 1..=b_len {
327            let cost = usize::from(a_bytes[i - 1] != b_bytes[j - 1]);
328            curr[j] = (prev[j] + 1).min(curr[j - 1] + 1).min(prev[j - 1] + cost);
329        }
330        std::mem::swap(&mut prev, &mut curr);
331    }
332
333    prev[b_len]
334}
335
336/// Find the closest known issue-kind name to `input` when it is plausibly a typo.
337///
338/// Returns the best match when the Levenshtein distance is at most 2 AND
339/// the input is long enough that the match is not coincidental
340/// (`input.len() / 2 > distance`). Returns `None` for completely novel
341/// strings where a suggestion would be misleading.
342#[must_use]
343pub fn closest_known_kind_name(input: &str) -> Option<&'static str> {
344    let input_lower = input.to_ascii_lowercase();
345    let mut best: Option<(&'static str, usize)> = None;
346
347    for &candidate in KNOWN_ISSUE_KIND_NAMES {
348        let d = levenshtein(&input_lower, candidate);
349        if best.is_none_or(|(_, b_dist)| d < b_dist) {
350            best = Some((candidate, d));
351        }
352    }
353
354    best.filter(|&(_, d)| d > 0 && d <= 2 && input_lower.len() / 2 > d)
355        .map(|(name, _)| name)
356}
357
358const _: () = assert!(std::mem::size_of::<Suppression>() == 12);
359const _: () = assert!(std::mem::size_of::<IssueKind>() == 1);
360
361#[cfg(test)]
362mod tests {
363    use super::*;
364
365    #[test]
366    fn issue_kind_from_str_all_variants() {
367        assert_eq!(IssueKind::parse("unused-file"), Some(IssueKind::UnusedFile));
368        assert_eq!(
369            IssueKind::parse("unused-export"),
370            Some(IssueKind::UnusedExport)
371        );
372        assert_eq!(IssueKind::parse("unused-type"), Some(IssueKind::UnusedType));
373        assert_eq!(
374            IssueKind::parse("private-type-leak"),
375            Some(IssueKind::PrivateTypeLeak)
376        );
377        assert_eq!(
378            IssueKind::parse("unused-dependency"),
379            Some(IssueKind::UnusedDependency)
380        );
381        assert_eq!(
382            IssueKind::parse("unused-dev-dependency"),
383            Some(IssueKind::UnusedDevDependency)
384        );
385        assert_eq!(
386            IssueKind::parse("unused-enum-member"),
387            Some(IssueKind::UnusedEnumMember)
388        );
389        assert_eq!(
390            IssueKind::parse("unused-class-member"),
391            Some(IssueKind::UnusedClassMember)
392        );
393        assert_eq!(
394            IssueKind::parse("unresolved-import"),
395            Some(IssueKind::UnresolvedImport)
396        );
397        assert_eq!(
398            IssueKind::parse("unlisted-dependency"),
399            Some(IssueKind::UnlistedDependency)
400        );
401        assert_eq!(
402            IssueKind::parse("duplicate-export"),
403            Some(IssueKind::DuplicateExport)
404        );
405        assert_eq!(
406            IssueKind::parse("code-duplication"),
407            Some(IssueKind::CodeDuplication)
408        );
409        assert_eq!(
410            IssueKind::parse("circular-dependency"),
411            Some(IssueKind::CircularDependency)
412        );
413        assert_eq!(
414            IssueKind::parse("circular-dependencies"),
415            Some(IssueKind::CircularDependency)
416        );
417        assert_eq!(
418            IssueKind::parse("type-only-dependency"),
419            Some(IssueKind::TypeOnlyDependency)
420        );
421        assert_eq!(
422            IssueKind::parse("test-only-dependency"),
423            Some(IssueKind::TestOnlyDependency)
424        );
425        assert_eq!(
426            IssueKind::parse("boundary-violation"),
427            Some(IssueKind::BoundaryViolation)
428        );
429        assert_eq!(
430            IssueKind::parse("coverage-gaps"),
431            Some(IssueKind::CoverageGaps)
432        );
433        assert_eq!(
434            IssueKind::parse("feature-flag"),
435            Some(IssueKind::FeatureFlag)
436        );
437        assert_eq!(IssueKind::parse("complexity"), Some(IssueKind::Complexity));
438        assert_eq!(
439            IssueKind::parse("stale-suppression"),
440            Some(IssueKind::StaleSuppression)
441        );
442        assert_eq!(
443            IssueKind::parse("unused-catalog-entry"),
444            Some(IssueKind::PnpmCatalogEntry)
445        );
446        assert_eq!(
447            IssueKind::parse("unused-catalog-entries"),
448            Some(IssueKind::PnpmCatalogEntry)
449        );
450        assert_eq!(
451            IssueKind::parse("empty-catalog-group"),
452            Some(IssueKind::EmptyCatalogGroup)
453        );
454        assert_eq!(
455            IssueKind::parse("empty-catalog-groups"),
456            Some(IssueKind::EmptyCatalogGroup)
457        );
458        assert_eq!(
459            IssueKind::parse("unresolved-catalog-reference"),
460            Some(IssueKind::UnresolvedCatalogReference)
461        );
462        assert_eq!(
463            IssueKind::parse("unresolved-catalog-references"),
464            Some(IssueKind::UnresolvedCatalogReference)
465        );
466        assert_eq!(
467            IssueKind::parse("unused-dependency-override"),
468            Some(IssueKind::UnusedDependencyOverride)
469        );
470        assert_eq!(
471            IssueKind::parse("unused-dependency-overrides"),
472            Some(IssueKind::UnusedDependencyOverride)
473        );
474        assert_eq!(
475            IssueKind::parse("misconfigured-dependency-override"),
476            Some(IssueKind::MisconfiguredDependencyOverride)
477        );
478        assert_eq!(
479            IssueKind::parse("misconfigured-dependency-overrides"),
480            Some(IssueKind::MisconfiguredDependencyOverride)
481        );
482        assert_eq!(
483            IssueKind::parse("security-client-server-leak"),
484            Some(IssueKind::SecurityClientServerLeak)
485        );
486        assert_eq!(
487            IssueKind::parse("security-sink"),
488            Some(IssueKind::SecuritySink)
489        );
490    }
491
492    #[test]
493    fn issue_kind_from_str_unknown() {
494        assert_eq!(IssueKind::parse("foo"), None);
495        assert_eq!(IssueKind::parse(""), None);
496    }
497
498    #[test]
499    fn issue_kind_from_str_near_misses() {
500        assert_eq!(IssueKind::parse("Unused-File"), None);
501        assert_eq!(IssueKind::parse("UNUSED-EXPORT"), None);
502        assert_eq!(IssueKind::parse("unused_file"), None);
503        assert_eq!(IssueKind::parse("unused-files"), None);
504    }
505
506    #[test]
507    fn discriminant_out_of_range() {
508        assert_eq!(IssueKind::from_discriminant(0), None);
509        assert_eq!(
510            IssueKind::from_discriminant(28),
511            Some(IssueKind::SecuritySink)
512        );
513        assert_eq!(IssueKind::from_discriminant(29), None);
514        assert_eq!(IssueKind::from_discriminant(u8::MAX), None);
515    }
516
517    #[test]
518    fn discriminant_roundtrip() {
519        for kind in [
520            IssueKind::UnusedFile,
521            IssueKind::UnusedExport,
522            IssueKind::UnusedType,
523            IssueKind::PrivateTypeLeak,
524            IssueKind::UnusedDependency,
525            IssueKind::UnusedDevDependency,
526            IssueKind::UnusedEnumMember,
527            IssueKind::UnusedClassMember,
528            IssueKind::UnresolvedImport,
529            IssueKind::UnlistedDependency,
530            IssueKind::DuplicateExport,
531            IssueKind::CodeDuplication,
532            IssueKind::CircularDependency,
533            IssueKind::ReExportCycle,
534            IssueKind::TypeOnlyDependency,
535            IssueKind::TestOnlyDependency,
536            IssueKind::BoundaryViolation,
537            IssueKind::CoverageGaps,
538            IssueKind::FeatureFlag,
539            IssueKind::Complexity,
540            IssueKind::StaleSuppression,
541            IssueKind::PnpmCatalogEntry,
542            IssueKind::EmptyCatalogGroup,
543            IssueKind::UnresolvedCatalogReference,
544            IssueKind::UnusedDependencyOverride,
545            IssueKind::MisconfiguredDependencyOverride,
546            IssueKind::SecurityClientServerLeak,
547            IssueKind::SecuritySink,
548        ] {
549            assert_eq!(
550                IssueKind::from_discriminant(kind.to_discriminant()),
551                Some(kind)
552            );
553        }
554        assert_eq!(IssueKind::from_discriminant(0), None);
555        assert_eq!(IssueKind::from_discriminant(29), None);
556    }
557
558    #[test]
559    fn discriminant_values_are_unique() {
560        let all_kinds = [
561            IssueKind::UnusedFile,
562            IssueKind::UnusedExport,
563            IssueKind::UnusedType,
564            IssueKind::PrivateTypeLeak,
565            IssueKind::UnusedDependency,
566            IssueKind::UnusedDevDependency,
567            IssueKind::UnusedEnumMember,
568            IssueKind::UnusedClassMember,
569            IssueKind::UnresolvedImport,
570            IssueKind::UnlistedDependency,
571            IssueKind::DuplicateExport,
572            IssueKind::CodeDuplication,
573            IssueKind::CircularDependency,
574            IssueKind::ReExportCycle,
575            IssueKind::TypeOnlyDependency,
576            IssueKind::TestOnlyDependency,
577            IssueKind::BoundaryViolation,
578            IssueKind::CoverageGaps,
579            IssueKind::FeatureFlag,
580            IssueKind::Complexity,
581            IssueKind::StaleSuppression,
582            IssueKind::PnpmCatalogEntry,
583            IssueKind::EmptyCatalogGroup,
584            IssueKind::UnresolvedCatalogReference,
585            IssueKind::UnusedDependencyOverride,
586            IssueKind::MisconfiguredDependencyOverride,
587            IssueKind::SecurityClientServerLeak,
588            IssueKind::SecuritySink,
589        ];
590        let discriminants: Vec<u8> = all_kinds.iter().map(|k| k.to_discriminant()).collect();
591        let mut sorted = discriminants.clone();
592        sorted.sort_unstable();
593        sorted.dedup();
594        assert_eq!(
595            discriminants.len(),
596            sorted.len(),
597            "discriminant values must be unique"
598        );
599    }
600
601    #[test]
602    fn discriminant_starts_at_one() {
603        assert_eq!(IssueKind::UnusedFile.to_discriminant(), 1);
604    }
605
606    #[test]
607    fn suppression_line_zero_is_file_wide() {
608        let s = Suppression {
609            line: 0,
610            comment_line: 1,
611            kind: None,
612        };
613        assert_eq!(s.line, 0);
614        assert!(s.kind.is_none());
615    }
616
617    #[test]
618    fn suppression_with_specific_kind_and_line() {
619        let s = Suppression {
620            line: 42,
621            comment_line: 41,
622            kind: Some(IssueKind::UnusedExport),
623        };
624        assert_eq!(s.line, 42);
625        assert_eq!(s.comment_line, 41);
626        assert_eq!(s.kind, Some(IssueKind::UnusedExport));
627    }
628
629    #[test]
630    fn known_issue_kind_names_parses_each_entry() {
631        for &name in KNOWN_ISSUE_KIND_NAMES {
632            assert!(
633                IssueKind::parse(name).is_some(),
634                "KNOWN_ISSUE_KIND_NAMES contains '{name}' but IssueKind::parse rejects it"
635            );
636        }
637    }
638
639    #[test]
640    fn closest_known_kind_name_finds_near_misses() {
641        assert_eq!(
642            closest_known_kind_name("unused-exports"),
643            Some("unused-export")
644        );
645        assert_eq!(closest_known_kind_name("unused-files"), Some("unused-file"));
646        assert_eq!(closest_known_kind_name("complxity"), Some("complexity"));
647    }
648
649    #[test]
650    fn closest_known_kind_name_rejects_novel_strings() {
651        assert_eq!(closest_known_kind_name("xyzzy"), None);
652        assert_eq!(closest_known_kind_name("foo"), None);
653        assert_eq!(closest_known_kind_name(""), None);
654    }
655
656    #[test]
657    fn closest_known_kind_name_skips_exact_match() {
658        assert_eq!(closest_known_kind_name("unused-export"), None);
659    }
660}