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). The bare token covers every pack rule; scoped
88    /// tokens can target one `<pack>/<rule-id>` identity.
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/// One scoped rule-pack policy suppression target.
215#[derive(Debug, Clone, PartialEq, Eq, Hash)]
216pub struct PolicyRuleSuppression {
217    /// Rule-pack name.
218    pub pack: String,
219    /// Rule id within the pack.
220    pub rule_id: String,
221}
222
223impl PolicyRuleSuppression {
224    /// Build a scoped policy suppression target.
225    #[must_use]
226    pub fn new(pack: impl Into<String>, rule_id: impl Into<String>) -> Self {
227        Self {
228            pack: pack.into(),
229            rule_id: rule_id.into(),
230        }
231    }
232
233    /// Canonical suppression token.
234    #[must_use]
235    pub fn token(&self) -> String {
236        format!("policy-violation:{}/{}", self.pack, self.rule_id)
237    }
238}
239
240/// A specific suppression target parsed from a comment token.
241#[derive(Debug, Clone, PartialEq, Eq)]
242pub enum SuppressionTarget {
243    /// A regular issue-kind token such as `unused-export` or bare
244    /// `policy-violation`.
245    Issue(IssueKind),
246    /// A scoped rule-pack policy token such as
247    /// `policy-violation:team-policy/no-child-process`.
248    PolicyRule(PolicyRuleSuppression),
249}
250
251impl SuppressionTarget {
252    /// Return the regular issue kind when this target is a bare issue-kind
253    /// token.
254    #[must_use]
255    pub const fn issue_kind(&self) -> Option<IssueKind> {
256        match self {
257            Self::Issue(kind) => Some(*kind),
258            Self::PolicyRule(_) => None,
259        }
260    }
261
262    /// Canonical suppression token for output and active-suppression capture.
263    #[must_use]
264    pub fn token(&self) -> String {
265        match self {
266            Self::Issue(kind) => issue_kind_to_kebab(*kind).to_owned(),
267            Self::PolicyRule(rule) => rule.token(),
268        }
269    }
270}
271
272/// Convert an [`IssueKind`] to its canonical suppression token.
273#[must_use]
274pub const fn issue_kind_to_kebab(kind: IssueKind) -> &'static str {
275    match kind {
276        IssueKind::UnusedFile => "unused-file",
277        IssueKind::UnusedExport => "unused-export",
278        IssueKind::UnusedType => "unused-type",
279        IssueKind::PrivateTypeLeak => "private-type-leak",
280        IssueKind::UnusedDependency => "unused-dependency",
281        IssueKind::UnusedDevDependency => "unused-dev-dependency",
282        IssueKind::UnusedEnumMember => "unused-enum-member",
283        IssueKind::UnusedClassMember => "unused-class-member",
284        IssueKind::UnresolvedImport => "unresolved-import",
285        IssueKind::UnlistedDependency => "unlisted-dependency",
286        IssueKind::DuplicateExport => "duplicate-export",
287        IssueKind::CodeDuplication => "code-duplication",
288        IssueKind::CircularDependency => "circular-dependency",
289        IssueKind::ReExportCycle => "re-export-cycle",
290        IssueKind::TypeOnlyDependency => "type-only-dependency",
291        IssueKind::TestOnlyDependency => "test-only-dependency",
292        IssueKind::BoundaryViolation => "boundary-violation",
293        IssueKind::CoverageGaps => "coverage-gaps",
294        IssueKind::FeatureFlag => "feature-flag",
295        IssueKind::Complexity => "complexity",
296        IssueKind::StaleSuppression => "stale-suppression",
297        IssueKind::PnpmCatalogEntry => "unused-catalog-entry",
298        IssueKind::EmptyCatalogGroup => "empty-catalog-group",
299        IssueKind::UnresolvedCatalogReference => "unresolved-catalog-reference",
300        IssueKind::UnusedDependencyOverride => "unused-dependency-override",
301        IssueKind::MisconfiguredDependencyOverride => "misconfigured-dependency-override",
302        IssueKind::SecurityClientServerLeak => "security-client-server-leak",
303        IssueKind::SecuritySink => "security-sink",
304        IssueKind::PolicyViolation => "policy-violation",
305    }
306}
307
308/// Parse a suppression token into a structured target.
309#[must_use]
310pub fn parse_suppression_target(token: &str) -> Option<SuppressionTarget> {
311    parse_policy_rule_suppression_token(token)
312        .map(SuppressionTarget::PolicyRule)
313        .or_else(|| IssueKind::parse(token).map(SuppressionTarget::Issue))
314}
315
316/// Parse canonical scoped policy suppression tokens.
317///
318/// The plural prefix is accepted for consistency with the bare legacy alias,
319/// but output always uses singular `policy-violation:`.
320#[must_use]
321pub fn parse_policy_rule_suppression_token(token: &str) -> Option<PolicyRuleSuppression> {
322    let identity = token
323        .strip_prefix("policy-violation:")
324        .or_else(|| token.strip_prefix("policy-violations:"))?;
325    let (pack, rule_id) = identity.split_once('/')?;
326    if rule_id.contains('/') {
327        return None;
328    }
329    if !is_valid_policy_identifier(pack) || !is_valid_policy_identifier(rule_id) {
330        return None;
331    }
332    Some(PolicyRuleSuppression::new(pack, rule_id))
333}
334
335/// Whether a rule-pack name or rule id can be used inside
336/// `policy-violation:<pack>/<rule-id>` without escaping.
337#[must_use]
338pub fn is_valid_policy_identifier(value: &str) -> bool {
339    !value.is_empty()
340        && value
341            .bytes()
342            .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'_' | b'-'))
343}
344
345/// A suppression directive parsed from a source comment.
346///
347/// # Examples
348///
349/// ```
350/// use fallow_types::suppress::{Suppression, IssueKind};
351///
352/// // File-wide suppression (line 0, no specific kind)
353/// let file_wide = Suppression::all(0, 1);
354/// assert_eq!(file_wide.line, 0);
355///
356/// // Line-specific suppression for unused exports
357/// let line_suppress = Suppression::issue(42, 41, IssueKind::UnusedExport);
358/// assert_eq!(line_suppress.issue_kind_target(), Some(IssueKind::UnusedExport));
359/// ```
360#[derive(Debug, Clone)]
361pub struct Suppression {
362    /// 1-based line this suppression applies to. 0 = file-wide suppression.
363    pub line: u32,
364    /// 1-based line where the suppression comment itself appears.
365    /// For `fallow-ignore-next-line`, this is `line - 1`.
366    /// For `fallow-ignore-file`, this is the actual line of the comment in the source.
367    pub comment_line: u32,
368    /// None = suppress all issue kinds on this line or file.
369    pub target: Option<SuppressionTarget>,
370}
371
372impl Suppression {
373    /// Build a blanket suppression.
374    #[must_use]
375    pub const fn all(line: u32, comment_line: u32) -> Self {
376        Self {
377            line,
378            comment_line,
379            target: None,
380        }
381    }
382
383    /// Build a regular issue-kind suppression.
384    #[must_use]
385    pub const fn issue(line: u32, comment_line: u32, kind: IssueKind) -> Self {
386        Self {
387            line,
388            comment_line,
389            target: Some(SuppressionTarget::Issue(kind)),
390        }
391    }
392
393    /// Build a scoped rule-pack policy suppression.
394    #[must_use]
395    pub fn policy_rule(
396        line: u32,
397        comment_line: u32,
398        pack: impl Into<String>,
399        rule_id: impl Into<String>,
400    ) -> Self {
401        Self {
402            line,
403            comment_line,
404            target: Some(SuppressionTarget::PolicyRule(PolicyRuleSuppression::new(
405                pack, rule_id,
406            ))),
407        }
408    }
409
410    /// The bare issue kind if this suppression targets one.
411    #[must_use]
412    pub const fn issue_kind_target(&self) -> Option<IssueKind> {
413        match &self.target {
414            Some(SuppressionTarget::Issue(kind)) => Some(*kind),
415            Some(SuppressionTarget::PolicyRule(_)) | None => None,
416        }
417    }
418
419    /// The scoped policy target if this suppression targets one rule-pack rule.
420    #[must_use]
421    pub const fn policy_rule_target(&self) -> Option<&PolicyRuleSuppression> {
422        match &self.target {
423            Some(SuppressionTarget::PolicyRule(rule)) => Some(rule),
424            Some(SuppressionTarget::Issue(_)) | None => None,
425        }
426    }
427
428    /// Canonical token for this suppression, or `None` for blanket comments.
429    #[must_use]
430    pub fn target_token(&self) -> Option<String> {
431        self.target.as_ref().map(SuppressionTarget::token)
432    }
433
434    /// Whether the comment applies to `line`.
435    #[must_use]
436    pub const fn applies_to_line(&self, line: u32) -> bool {
437        self.line == 0 || self.line == line
438    }
439
440    /// Whether this suppression covers a regular issue kind on a line.
441    ///
442    /// Scoped policy-rule targets intentionally do not match this generic
443    /// predicate. Policy detection uses [`Self::matches_policy_rule`] so the
444    /// exact pack and rule id are available.
445    #[must_use]
446    pub fn matches_issue_kind(&self, line: u32, kind: IssueKind) -> bool {
447        self.applies_to_line(line)
448            && match &self.target {
449                None => true,
450                Some(SuppressionTarget::Issue(target_kind)) => *target_kind == kind,
451                Some(SuppressionTarget::PolicyRule(_)) => false,
452            }
453    }
454
455    /// Whether this suppression covers a policy finding on a line.
456    #[must_use]
457    pub fn matches_policy_rule(&self, line: u32, pack: &str, rule_id: &str) -> bool {
458        self.applies_to_line(line)
459            && match &self.target {
460                None | Some(SuppressionTarget::Issue(IssueKind::PolicyViolation)) => true,
461                Some(SuppressionTarget::Issue(_)) => false,
462                Some(SuppressionTarget::PolicyRule(target)) => {
463                    target.pack == pack && target.rule_id == rule_id
464                }
465            }
466    }
467}
468
469/// A suppression token that did not parse to any known `IssueKind`.
470///
471/// Emitted alongside `Suppression` when a `// fallow-ignore-*` marker contains
472/// a typo or an obsolete issue-kind name. The known tokens on the same marker
473/// are recorded as normal `Suppression` entries; this struct preserves the
474/// unknown token so the downstream `find_stale` pass can surface it as a
475/// `StaleSuppression` finding with `kind_known: false`. Without this, the
476/// entire suppression line would be discarded silently. See issue #449.
477#[derive(Debug, Clone)]
478pub struct UnknownSuppressionKind {
479    /// 1-based line where the suppression comment itself appears.
480    pub comment_line: u32,
481    /// Whether the marker was `fallow-ignore-file` (`true`) or
482    /// `fallow-ignore-next-line` (`false`).
483    pub is_file_level: bool,
484    /// The verbatim token from the marker that did not parse.
485    pub token: String,
486}
487
488/// Canonical kebab-case names accepted by `IssueKind::parse`, including
489/// documented plural aliases.
490///
491/// Used by `closest_known_kind_name` for Levenshtein "did you mean?" hints
492/// when a suppression marker carries an unknown token. Keep in sync with the
493/// `IssueKind::parse` match table above; the
494/// `issue_kind_parse_covers_known_names` test asserts every entry round-trips.
495pub const KNOWN_ISSUE_KIND_NAMES: &[&str] = &[
496    "unused-file",
497    "unused-export",
498    "unused-type",
499    "private-type-leak",
500    "unused-dependency",
501    "unused-dev-dependency",
502    "unused-enum-member",
503    "unused-class-member",
504    "unresolved-import",
505    "unlisted-dependency",
506    "duplicate-export",
507    "code-duplication",
508    "circular-dependency",
509    "circular-dependencies",
510    "re-export-cycle",
511    "re-export-cycles",
512    "reexport-cycle",
513    "reexport-cycles",
514    "type-only-dependency",
515    "test-only-dependency",
516    "boundary-violation",
517    "boundary-call-violation",
518    "boundary-call-violations",
519    "coverage-gaps",
520    "feature-flag",
521    "complexity",
522    "stale-suppression",
523    "unused-catalog-entry",
524    "unused-catalog-entries",
525    "empty-catalog-group",
526    "empty-catalog-groups",
527    "unresolved-catalog-reference",
528    "unresolved-catalog-references",
529    "unused-dependency-override",
530    "unused-dependency-overrides",
531    "misconfigured-dependency-override",
532    "misconfigured-dependency-overrides",
533    "security-client-server-leak",
534    "security-sink",
535    "policy-violation",
536    "policy-violations",
537];
538
539/// CLI filter flags on `fallow dead-code` that scope output to a single
540/// issue type.
541///
542/// Shared home so the agent capability manifest (`fallow schema` in
543/// `crates/cli`), the MCP server's `issue_types` allowlist
544/// (`ISSUE_TYPE_FLAGS` in `crates/mcp`), and the clap flag definitions stay
545/// in sync: each crate carries a drift test asserting its own list against
546/// this one.
547pub const DEAD_CODE_FILTER_FLAGS: &[&str] = &[
548    "--unused-files",
549    "--unused-exports",
550    "--unused-types",
551    "--private-type-leaks",
552    "--unused-deps",
553    "--unused-enum-members",
554    "--unused-class-members",
555    "--unresolved-imports",
556    "--unlisted-deps",
557    "--duplicate-exports",
558    "--circular-deps",
559    "--re-export-cycles",
560    "--boundary-violations",
561    "--policy-violations",
562    "--stale-suppressions",
563    "--unused-catalog-entries",
564    "--empty-catalog-groups",
565    "--unresolved-catalog-references",
566    "--unused-dependency-overrides",
567    "--misconfigured-dependency-overrides",
568];
569
570/// Levenshtein edit distance between two ASCII-leaning strings.
571///
572/// Local duplicate of the config-crate helper (see
573/// `crates/config/src/config/rules.rs::levenshtein`) so `fallow-types` can
574/// compute "did you mean?" suggestions for unknown suppression tokens without
575/// taking a dependency on `fallow-config`. Issue-kind names are short
576/// (max ~33 chars) so allocation cost is negligible.
577fn levenshtein(a: &str, b: &str) -> usize {
578    let a_bytes = a.as_bytes();
579    let b_bytes = b.as_bytes();
580    let (a_len, b_len) = (a_bytes.len(), b_bytes.len());
581
582    if a_len == 0 {
583        return b_len;
584    }
585    if b_len == 0 {
586        return a_len;
587    }
588
589    let mut prev: Vec<usize> = (0..=b_len).collect();
590    let mut curr: Vec<usize> = vec![0; b_len + 1];
591
592    for i in 1..=a_len {
593        curr[0] = i;
594        for j in 1..=b_len {
595            let cost = usize::from(a_bytes[i - 1] != b_bytes[j - 1]);
596            curr[j] = (prev[j] + 1).min(curr[j - 1] + 1).min(prev[j - 1] + cost);
597        }
598        std::mem::swap(&mut prev, &mut curr);
599    }
600
601    prev[b_len]
602}
603
604/// Find the closest known issue-kind name to `input` when it is plausibly a typo.
605///
606/// Returns the best match when the Levenshtein distance is at most 2 AND
607/// the input is long enough that the match is not coincidental
608/// (`input.len() / 2 > distance`). Returns `None` for completely novel
609/// strings where a suggestion would be misleading.
610#[must_use]
611pub fn closest_known_kind_name(input: &str) -> Option<&'static str> {
612    let input_lower = input.to_ascii_lowercase();
613    let mut best: Option<(&'static str, usize)> = None;
614
615    for &candidate in KNOWN_ISSUE_KIND_NAMES {
616        let d = levenshtein(&input_lower, candidate);
617        if best.is_none_or(|(_, b_dist)| d < b_dist) {
618            best = Some((candidate, d));
619        }
620    }
621
622    best.filter(|&(_, d)| d > 0 && d <= 2 && input_lower.len() / 2 > d)
623        .map(|(name, _)| name)
624}
625
626const _: () = assert!(std::mem::size_of::<IssueKind>() == 1);
627
628#[cfg(test)]
629mod tests {
630    use super::*;
631
632    #[test]
633    fn issue_kind_from_str_all_variants() {
634        assert_eq!(IssueKind::parse("unused-file"), Some(IssueKind::UnusedFile));
635        assert_eq!(
636            IssueKind::parse("unused-export"),
637            Some(IssueKind::UnusedExport)
638        );
639        assert_eq!(IssueKind::parse("unused-type"), Some(IssueKind::UnusedType));
640        assert_eq!(
641            IssueKind::parse("private-type-leak"),
642            Some(IssueKind::PrivateTypeLeak)
643        );
644        assert_eq!(
645            IssueKind::parse("unused-dependency"),
646            Some(IssueKind::UnusedDependency)
647        );
648        assert_eq!(
649            IssueKind::parse("unused-dev-dependency"),
650            Some(IssueKind::UnusedDevDependency)
651        );
652        assert_eq!(
653            IssueKind::parse("unused-enum-member"),
654            Some(IssueKind::UnusedEnumMember)
655        );
656        assert_eq!(
657            IssueKind::parse("unused-class-member"),
658            Some(IssueKind::UnusedClassMember)
659        );
660        assert_eq!(
661            IssueKind::parse("unresolved-import"),
662            Some(IssueKind::UnresolvedImport)
663        );
664        assert_eq!(
665            IssueKind::parse("unlisted-dependency"),
666            Some(IssueKind::UnlistedDependency)
667        );
668        assert_eq!(
669            IssueKind::parse("duplicate-export"),
670            Some(IssueKind::DuplicateExport)
671        );
672        assert_eq!(
673            IssueKind::parse("code-duplication"),
674            Some(IssueKind::CodeDuplication)
675        );
676        assert_eq!(
677            IssueKind::parse("circular-dependency"),
678            Some(IssueKind::CircularDependency)
679        );
680        assert_eq!(
681            IssueKind::parse("circular-dependencies"),
682            Some(IssueKind::CircularDependency)
683        );
684        assert_eq!(
685            IssueKind::parse("type-only-dependency"),
686            Some(IssueKind::TypeOnlyDependency)
687        );
688        assert_eq!(
689            IssueKind::parse("test-only-dependency"),
690            Some(IssueKind::TestOnlyDependency)
691        );
692        assert_eq!(
693            IssueKind::parse("boundary-violation"),
694            Some(IssueKind::BoundaryViolation)
695        );
696        // The boundary family token also accepts the rule-id-shaped alias so
697        // users who derive the token from the `boundary-call-violation` rule
698        // id by analogy get a working suppression instead of a silent no-op.
699        assert_eq!(
700            IssueKind::parse("boundary-call-violation"),
701            Some(IssueKind::BoundaryViolation)
702        );
703        assert_eq!(
704            IssueKind::parse("boundary-call-violations"),
705            Some(IssueKind::BoundaryViolation)
706        );
707        assert_eq!(
708            IssueKind::parse("coverage-gaps"),
709            Some(IssueKind::CoverageGaps)
710        );
711        assert_eq!(
712            IssueKind::parse("feature-flag"),
713            Some(IssueKind::FeatureFlag)
714        );
715        assert_eq!(IssueKind::parse("complexity"), Some(IssueKind::Complexity));
716        assert_eq!(
717            IssueKind::parse("stale-suppression"),
718            Some(IssueKind::StaleSuppression)
719        );
720        assert_eq!(
721            IssueKind::parse("unused-catalog-entry"),
722            Some(IssueKind::PnpmCatalogEntry)
723        );
724        assert_eq!(
725            IssueKind::parse("unused-catalog-entries"),
726            Some(IssueKind::PnpmCatalogEntry)
727        );
728        assert_eq!(
729            IssueKind::parse("empty-catalog-group"),
730            Some(IssueKind::EmptyCatalogGroup)
731        );
732        assert_eq!(
733            IssueKind::parse("empty-catalog-groups"),
734            Some(IssueKind::EmptyCatalogGroup)
735        );
736        assert_eq!(
737            IssueKind::parse("unresolved-catalog-reference"),
738            Some(IssueKind::UnresolvedCatalogReference)
739        );
740        assert_eq!(
741            IssueKind::parse("unresolved-catalog-references"),
742            Some(IssueKind::UnresolvedCatalogReference)
743        );
744        assert_eq!(
745            IssueKind::parse("unused-dependency-override"),
746            Some(IssueKind::UnusedDependencyOverride)
747        );
748        assert_eq!(
749            IssueKind::parse("unused-dependency-overrides"),
750            Some(IssueKind::UnusedDependencyOverride)
751        );
752        assert_eq!(
753            IssueKind::parse("misconfigured-dependency-override"),
754            Some(IssueKind::MisconfiguredDependencyOverride)
755        );
756        assert_eq!(
757            IssueKind::parse("misconfigured-dependency-overrides"),
758            Some(IssueKind::MisconfiguredDependencyOverride)
759        );
760        assert_eq!(
761            IssueKind::parse("security-client-server-leak"),
762            Some(IssueKind::SecurityClientServerLeak)
763        );
764        assert_eq!(
765            IssueKind::parse("security-sink"),
766            Some(IssueKind::SecuritySink)
767        );
768        assert_eq!(
769            IssueKind::parse("policy-violation"),
770            Some(IssueKind::PolicyViolation)
771        );
772        assert_eq!(
773            IssueKind::parse("policy-violations"),
774            Some(IssueKind::PolicyViolation)
775        );
776    }
777
778    #[test]
779    fn issue_kind_from_str_unknown() {
780        assert_eq!(IssueKind::parse("foo"), None);
781        assert_eq!(IssueKind::parse(""), None);
782    }
783
784    #[test]
785    fn issue_kind_from_str_near_misses() {
786        assert_eq!(IssueKind::parse("Unused-File"), None);
787        assert_eq!(IssueKind::parse("UNUSED-EXPORT"), None);
788        assert_eq!(IssueKind::parse("unused_file"), None);
789        assert_eq!(IssueKind::parse("unused-files"), None);
790    }
791
792    #[test]
793    fn discriminant_out_of_range() {
794        assert_eq!(IssueKind::from_discriminant(0), None);
795        assert_eq!(
796            IssueKind::from_discriminant(29),
797            Some(IssueKind::PolicyViolation)
798        );
799        assert_eq!(IssueKind::from_discriminant(30), None);
800        assert_eq!(IssueKind::from_discriminant(u8::MAX), None);
801    }
802
803    #[test]
804    fn discriminant_roundtrip() {
805        for kind in [
806            IssueKind::UnusedFile,
807            IssueKind::UnusedExport,
808            IssueKind::UnusedType,
809            IssueKind::PrivateTypeLeak,
810            IssueKind::UnusedDependency,
811            IssueKind::UnusedDevDependency,
812            IssueKind::UnusedEnumMember,
813            IssueKind::UnusedClassMember,
814            IssueKind::UnresolvedImport,
815            IssueKind::UnlistedDependency,
816            IssueKind::DuplicateExport,
817            IssueKind::CodeDuplication,
818            IssueKind::CircularDependency,
819            IssueKind::ReExportCycle,
820            IssueKind::TypeOnlyDependency,
821            IssueKind::TestOnlyDependency,
822            IssueKind::BoundaryViolation,
823            IssueKind::CoverageGaps,
824            IssueKind::FeatureFlag,
825            IssueKind::Complexity,
826            IssueKind::StaleSuppression,
827            IssueKind::PnpmCatalogEntry,
828            IssueKind::EmptyCatalogGroup,
829            IssueKind::UnresolvedCatalogReference,
830            IssueKind::UnusedDependencyOverride,
831            IssueKind::MisconfiguredDependencyOverride,
832            IssueKind::SecurityClientServerLeak,
833            IssueKind::SecuritySink,
834            IssueKind::PolicyViolation,
835        ] {
836            assert_eq!(
837                IssueKind::from_discriminant(kind.to_discriminant()),
838                Some(kind)
839            );
840        }
841        assert_eq!(IssueKind::from_discriminant(0), None);
842        assert_eq!(IssueKind::from_discriminant(30), None);
843    }
844
845    #[test]
846    fn discriminant_values_are_unique() {
847        let all_kinds = [
848            IssueKind::UnusedFile,
849            IssueKind::UnusedExport,
850            IssueKind::UnusedType,
851            IssueKind::PrivateTypeLeak,
852            IssueKind::UnusedDependency,
853            IssueKind::UnusedDevDependency,
854            IssueKind::UnusedEnumMember,
855            IssueKind::UnusedClassMember,
856            IssueKind::UnresolvedImport,
857            IssueKind::UnlistedDependency,
858            IssueKind::DuplicateExport,
859            IssueKind::CodeDuplication,
860            IssueKind::CircularDependency,
861            IssueKind::ReExportCycle,
862            IssueKind::TypeOnlyDependency,
863            IssueKind::TestOnlyDependency,
864            IssueKind::BoundaryViolation,
865            IssueKind::CoverageGaps,
866            IssueKind::FeatureFlag,
867            IssueKind::Complexity,
868            IssueKind::StaleSuppression,
869            IssueKind::PnpmCatalogEntry,
870            IssueKind::EmptyCatalogGroup,
871            IssueKind::UnresolvedCatalogReference,
872            IssueKind::UnusedDependencyOverride,
873            IssueKind::MisconfiguredDependencyOverride,
874            IssueKind::SecurityClientServerLeak,
875            IssueKind::SecuritySink,
876            IssueKind::PolicyViolation,
877        ];
878        let discriminants: Vec<u8> = all_kinds.iter().map(|k| k.to_discriminant()).collect();
879        let mut sorted = discriminants.clone();
880        sorted.sort_unstable();
881        sorted.dedup();
882        assert_eq!(
883            discriminants.len(),
884            sorted.len(),
885            "discriminant values must be unique"
886        );
887    }
888
889    #[test]
890    fn discriminant_starts_at_one() {
891        assert_eq!(IssueKind::UnusedFile.to_discriminant(), 1);
892    }
893
894    #[test]
895    fn suppression_line_zero_is_file_wide() {
896        let s = Suppression::all(0, 1);
897        assert_eq!(s.line, 0);
898        assert!(s.issue_kind_target().is_none());
899    }
900
901    #[test]
902    fn suppression_with_specific_kind_and_line() {
903        let s = Suppression::issue(42, 41, IssueKind::UnusedExport);
904        assert_eq!(s.line, 42);
905        assert_eq!(s.comment_line, 41);
906        assert_eq!(s.issue_kind_target(), Some(IssueKind::UnusedExport));
907    }
908
909    #[test]
910    fn parses_scoped_policy_suppression_token() {
911        let target =
912            parse_policy_rule_suppression_token("policy-violation:team-policy/no-child-process")
913                .expect("scoped token should parse");
914        assert_eq!(target.pack, "team-policy");
915        assert_eq!(target.rule_id, "no-child-process");
916        assert_eq!(
917            target.token(),
918            "policy-violation:team-policy/no-child-process"
919        );
920    }
921
922    #[test]
923    fn rejects_malformed_scoped_policy_suppression_tokens() {
924        for token in [
925            "policy-violation:",
926            "policy-violation:team-policy",
927            "policy-violation:/no-child-process",
928            "policy-violation:team-policy/",
929            "policy-violation:team-policy/no/child-process",
930            "policy-violation:team policy/no-child-process",
931            "policy-violation:team-policy/no:child-process",
932        ] {
933            assert!(
934                parse_policy_rule_suppression_token(token).is_none(),
935                "{token} should be rejected"
936            );
937        }
938    }
939
940    #[test]
941    fn scoped_policy_suppression_matches_exact_policy_rule_only() {
942        let suppression = Suppression::policy_rule(7, 6, "team-policy", "no-child-process");
943        assert!(suppression.matches_policy_rule(7, "team-policy", "no-child-process"));
944        assert!(!suppression.matches_policy_rule(7, "team-policy", "no-fs"));
945        assert!(!suppression.matches_policy_rule(8, "team-policy", "no-child-process"));
946        assert!(!suppression.matches_issue_kind(7, IssueKind::PolicyViolation));
947    }
948
949    #[test]
950    fn known_issue_kind_names_parses_each_entry() {
951        for &name in KNOWN_ISSUE_KIND_NAMES {
952            assert!(
953                IssueKind::parse(name).is_some(),
954                "KNOWN_ISSUE_KIND_NAMES contains '{name}' but IssueKind::parse rejects it"
955            );
956        }
957    }
958
959    #[test]
960    fn closest_known_kind_name_finds_near_misses() {
961        assert_eq!(
962            closest_known_kind_name("unused-exports"),
963            Some("unused-export")
964        );
965        assert_eq!(closest_known_kind_name("unused-files"), Some("unused-file"));
966        assert_eq!(closest_known_kind_name("complxity"), Some("complexity"));
967    }
968
969    #[test]
970    fn closest_known_kind_name_rejects_novel_strings() {
971        assert_eq!(closest_known_kind_name("xyzzy"), None);
972        assert_eq!(closest_known_kind_name("foo"), None);
973        assert_eq!(closest_known_kind_name(""), None);
974    }
975
976    #[test]
977    fn closest_known_kind_name_skips_exact_match() {
978        assert_eq!(closest_known_kind_name("unused-export"), None);
979    }
980}