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