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 production dependency only imported via type-only imports.
49    TypeOnlyDependency,
50    /// A production dependency only imported by test files.
51    TestOnlyDependency,
52    /// An import that crosses an architecture boundary.
53    BoundaryViolation,
54    /// A runtime file or export with no test dependency path.
55    CoverageGaps,
56    /// A detected feature flag pattern.
57    FeatureFlag,
58    /// A function exceeding complexity thresholds (health command).
59    Complexity,
60    /// A suppression comment or JSDoc tag that no longer matches any issue.
61    StaleSuppression,
62    /// A pnpm catalog entry in pnpm-workspace.yaml not referenced by any workspace package.
63    PnpmCatalogEntry,
64    /// A named pnpm catalog group in pnpm-workspace.yaml with no entries.
65    EmptyCatalogGroup,
66    /// A workspace package.json reference (`catalog:` / `catalog:<name>`) pointing at
67    /// a catalog that does not declare the consumed package.
68    UnresolvedCatalogReference,
69    /// An entry in pnpm's `overrides:` / `pnpm.overrides` whose target package
70    /// is not declared in any workspace `package.json`.
71    UnusedDependencyOverride,
72    /// An entry in pnpm's `overrides:` / `pnpm.overrides` whose key or value
73    /// cannot be parsed into a valid pnpm shape.
74    MisconfiguredDependencyOverride,
75}
76
77impl IssueKind {
78    /// Parse an issue kind from the string tokens used in CLI output and suppression comments.
79    #[must_use]
80    pub fn parse(s: &str) -> Option<Self> {
81        match s {
82            "unused-file" => Some(Self::UnusedFile),
83            "unused-export" => Some(Self::UnusedExport),
84            "unused-type" => Some(Self::UnusedType),
85            "private-type-leak" => Some(Self::PrivateTypeLeak),
86            "unused-dependency" => Some(Self::UnusedDependency),
87            "unused-dev-dependency" => Some(Self::UnusedDevDependency),
88            "unused-enum-member" => Some(Self::UnusedEnumMember),
89            "unused-class-member" => Some(Self::UnusedClassMember),
90            "unresolved-import" => Some(Self::UnresolvedImport),
91            "unlisted-dependency" => Some(Self::UnlistedDependency),
92            "duplicate-export" => Some(Self::DuplicateExport),
93            "code-duplication" => Some(Self::CodeDuplication),
94            "circular-dependency" | "circular-dependencies" => Some(Self::CircularDependency),
95            "type-only-dependency" => Some(Self::TypeOnlyDependency),
96            "test-only-dependency" => Some(Self::TestOnlyDependency),
97            "boundary-violation" => Some(Self::BoundaryViolation),
98            "coverage-gaps" => Some(Self::CoverageGaps),
99            "feature-flag" => Some(Self::FeatureFlag),
100            "complexity" => Some(Self::Complexity),
101            "stale-suppression" => Some(Self::StaleSuppression),
102            "unused-catalog-entry" | "unused-catalog-entries" => Some(Self::PnpmCatalogEntry),
103            "empty-catalog-group" | "empty-catalog-groups" => Some(Self::EmptyCatalogGroup),
104            "unresolved-catalog-reference" | "unresolved-catalog-references" => {
105                Some(Self::UnresolvedCatalogReference)
106            }
107            "unused-dependency-override" | "unused-dependency-overrides" => {
108                Some(Self::UnusedDependencyOverride)
109            }
110            "misconfigured-dependency-override" | "misconfigured-dependency-overrides" => {
111                Some(Self::MisconfiguredDependencyOverride)
112            }
113            _ => None,
114        }
115    }
116
117    /// Convert to a u8 discriminant for compact cache storage.
118    #[must_use]
119    pub const fn to_discriminant(self) -> u8 {
120        match self {
121            Self::UnusedFile => 1,
122            Self::UnusedExport => 2,
123            Self::UnusedType => 3,
124            Self::PrivateTypeLeak => 4,
125            Self::UnusedDependency => 5,
126            Self::UnusedDevDependency => 6,
127            Self::UnusedEnumMember => 7,
128            Self::UnusedClassMember => 8,
129            Self::UnresolvedImport => 9,
130            Self::UnlistedDependency => 10,
131            Self::DuplicateExport => 11,
132            Self::CodeDuplication => 12,
133            Self::CircularDependency => 13,
134            Self::TypeOnlyDependency => 14,
135            Self::TestOnlyDependency => 15,
136            Self::BoundaryViolation => 16,
137            Self::CoverageGaps => 17,
138            Self::FeatureFlag => 18,
139            Self::Complexity => 19,
140            Self::StaleSuppression => 20,
141            Self::PnpmCatalogEntry => 21,
142            Self::UnresolvedCatalogReference => 22,
143            Self::UnusedDependencyOverride => 23,
144            Self::MisconfiguredDependencyOverride => 24,
145            Self::EmptyCatalogGroup => 25,
146        }
147    }
148
149    /// Reconstruct from a cache discriminant.
150    #[must_use]
151    pub const fn from_discriminant(d: u8) -> Option<Self> {
152        match d {
153            1 => Some(Self::UnusedFile),
154            2 => Some(Self::UnusedExport),
155            3 => Some(Self::UnusedType),
156            4 => Some(Self::PrivateTypeLeak),
157            5 => Some(Self::UnusedDependency),
158            6 => Some(Self::UnusedDevDependency),
159            7 => Some(Self::UnusedEnumMember),
160            8 => Some(Self::UnusedClassMember),
161            9 => Some(Self::UnresolvedImport),
162            10 => Some(Self::UnlistedDependency),
163            11 => Some(Self::DuplicateExport),
164            12 => Some(Self::CodeDuplication),
165            13 => Some(Self::CircularDependency),
166            14 => Some(Self::TypeOnlyDependency),
167            15 => Some(Self::TestOnlyDependency),
168            16 => Some(Self::BoundaryViolation),
169            17 => Some(Self::CoverageGaps),
170            18 => Some(Self::FeatureFlag),
171            19 => Some(Self::Complexity),
172            20 => Some(Self::StaleSuppression),
173            21 => Some(Self::PnpmCatalogEntry),
174            22 => Some(Self::UnresolvedCatalogReference),
175            23 => Some(Self::UnusedDependencyOverride),
176            24 => Some(Self::MisconfiguredDependencyOverride),
177            25 => Some(Self::EmptyCatalogGroup),
178            _ => None,
179        }
180    }
181}
182
183/// A suppression directive parsed from a source comment.
184///
185/// # Examples
186///
187/// ```
188/// use fallow_types::suppress::{Suppression, IssueKind};
189///
190/// // File-wide suppression (line 0, no specific kind)
191/// let file_wide = Suppression { line: 0, comment_line: 1, kind: None };
192/// assert_eq!(file_wide.line, 0);
193///
194/// // Line-specific suppression for unused exports
195/// let line_suppress = Suppression {
196///     line: 42,
197///     comment_line: 41,
198///     kind: Some(IssueKind::UnusedExport),
199/// };
200/// assert_eq!(line_suppress.kind, Some(IssueKind::UnusedExport));
201/// ```
202#[derive(Debug, Clone)]
203pub struct Suppression {
204    /// 1-based line this suppression applies to. 0 = file-wide suppression.
205    pub line: u32,
206    /// 1-based line where the suppression comment itself appears.
207    /// For `fallow-ignore-next-line`, this is `line - 1`.
208    /// For `fallow-ignore-file`, this is the actual line of the comment in the source.
209    pub comment_line: u32,
210    /// None = suppress all issue kinds on this line.
211    pub kind: Option<IssueKind>,
212}
213
214// Size assertions to prevent memory regressions.
215// `Suppression` is stored in a Vec per file; `IssueKind` appears in every suppression.
216const _: () = assert!(std::mem::size_of::<Suppression>() == 12);
217const _: () = assert!(std::mem::size_of::<IssueKind>() == 1);
218
219#[cfg(test)]
220mod tests {
221    use super::*;
222
223    #[test]
224    fn issue_kind_from_str_all_variants() {
225        assert_eq!(IssueKind::parse("unused-file"), Some(IssueKind::UnusedFile));
226        assert_eq!(
227            IssueKind::parse("unused-export"),
228            Some(IssueKind::UnusedExport)
229        );
230        assert_eq!(IssueKind::parse("unused-type"), Some(IssueKind::UnusedType));
231        assert_eq!(
232            IssueKind::parse("private-type-leak"),
233            Some(IssueKind::PrivateTypeLeak)
234        );
235        assert_eq!(
236            IssueKind::parse("unused-dependency"),
237            Some(IssueKind::UnusedDependency)
238        );
239        assert_eq!(
240            IssueKind::parse("unused-dev-dependency"),
241            Some(IssueKind::UnusedDevDependency)
242        );
243        assert_eq!(
244            IssueKind::parse("unused-enum-member"),
245            Some(IssueKind::UnusedEnumMember)
246        );
247        assert_eq!(
248            IssueKind::parse("unused-class-member"),
249            Some(IssueKind::UnusedClassMember)
250        );
251        assert_eq!(
252            IssueKind::parse("unresolved-import"),
253            Some(IssueKind::UnresolvedImport)
254        );
255        assert_eq!(
256            IssueKind::parse("unlisted-dependency"),
257            Some(IssueKind::UnlistedDependency)
258        );
259        assert_eq!(
260            IssueKind::parse("duplicate-export"),
261            Some(IssueKind::DuplicateExport)
262        );
263        assert_eq!(
264            IssueKind::parse("code-duplication"),
265            Some(IssueKind::CodeDuplication)
266        );
267        assert_eq!(
268            IssueKind::parse("circular-dependency"),
269            Some(IssueKind::CircularDependency)
270        );
271        assert_eq!(
272            IssueKind::parse("circular-dependencies"),
273            Some(IssueKind::CircularDependency)
274        );
275        assert_eq!(
276            IssueKind::parse("type-only-dependency"),
277            Some(IssueKind::TypeOnlyDependency)
278        );
279        assert_eq!(
280            IssueKind::parse("test-only-dependency"),
281            Some(IssueKind::TestOnlyDependency)
282        );
283        assert_eq!(
284            IssueKind::parse("boundary-violation"),
285            Some(IssueKind::BoundaryViolation)
286        );
287        assert_eq!(
288            IssueKind::parse("coverage-gaps"),
289            Some(IssueKind::CoverageGaps)
290        );
291        assert_eq!(
292            IssueKind::parse("feature-flag"),
293            Some(IssueKind::FeatureFlag)
294        );
295        assert_eq!(IssueKind::parse("complexity"), Some(IssueKind::Complexity));
296        assert_eq!(
297            IssueKind::parse("stale-suppression"),
298            Some(IssueKind::StaleSuppression)
299        );
300        assert_eq!(
301            IssueKind::parse("unused-catalog-entry"),
302            Some(IssueKind::PnpmCatalogEntry)
303        );
304        assert_eq!(
305            IssueKind::parse("unused-catalog-entries"),
306            Some(IssueKind::PnpmCatalogEntry)
307        );
308        assert_eq!(
309            IssueKind::parse("empty-catalog-group"),
310            Some(IssueKind::EmptyCatalogGroup)
311        );
312        assert_eq!(
313            IssueKind::parse("empty-catalog-groups"),
314            Some(IssueKind::EmptyCatalogGroup)
315        );
316        assert_eq!(
317            IssueKind::parse("unresolved-catalog-reference"),
318            Some(IssueKind::UnresolvedCatalogReference)
319        );
320        assert_eq!(
321            IssueKind::parse("unresolved-catalog-references"),
322            Some(IssueKind::UnresolvedCatalogReference)
323        );
324        assert_eq!(
325            IssueKind::parse("unused-dependency-override"),
326            Some(IssueKind::UnusedDependencyOverride)
327        );
328        assert_eq!(
329            IssueKind::parse("unused-dependency-overrides"),
330            Some(IssueKind::UnusedDependencyOverride)
331        );
332        assert_eq!(
333            IssueKind::parse("misconfigured-dependency-override"),
334            Some(IssueKind::MisconfiguredDependencyOverride)
335        );
336        assert_eq!(
337            IssueKind::parse("misconfigured-dependency-overrides"),
338            Some(IssueKind::MisconfiguredDependencyOverride)
339        );
340    }
341
342    #[test]
343    fn issue_kind_from_str_unknown() {
344        assert_eq!(IssueKind::parse("foo"), None);
345        assert_eq!(IssueKind::parse(""), None);
346    }
347
348    #[test]
349    fn issue_kind_from_str_near_misses() {
350        // Case sensitivity — these should NOT match
351        assert_eq!(IssueKind::parse("Unused-File"), None);
352        assert_eq!(IssueKind::parse("UNUSED-EXPORT"), None);
353        // Typos / near-misses
354        assert_eq!(IssueKind::parse("unused_file"), None);
355        assert_eq!(IssueKind::parse("unused-files"), None);
356    }
357
358    #[test]
359    fn discriminant_out_of_range() {
360        assert_eq!(IssueKind::from_discriminant(0), None);
361        assert_eq!(IssueKind::from_discriminant(26), None);
362        assert_eq!(IssueKind::from_discriminant(u8::MAX), None);
363    }
364
365    #[test]
366    fn discriminant_roundtrip() {
367        for kind in [
368            IssueKind::UnusedFile,
369            IssueKind::UnusedExport,
370            IssueKind::UnusedType,
371            IssueKind::PrivateTypeLeak,
372            IssueKind::UnusedDependency,
373            IssueKind::UnusedDevDependency,
374            IssueKind::UnusedEnumMember,
375            IssueKind::UnusedClassMember,
376            IssueKind::UnresolvedImport,
377            IssueKind::UnlistedDependency,
378            IssueKind::DuplicateExport,
379            IssueKind::CodeDuplication,
380            IssueKind::CircularDependency,
381            IssueKind::TypeOnlyDependency,
382            IssueKind::TestOnlyDependency,
383            IssueKind::BoundaryViolation,
384            IssueKind::CoverageGaps,
385            IssueKind::FeatureFlag,
386            IssueKind::Complexity,
387            IssueKind::StaleSuppression,
388            IssueKind::PnpmCatalogEntry,
389            IssueKind::EmptyCatalogGroup,
390            IssueKind::UnresolvedCatalogReference,
391            IssueKind::UnusedDependencyOverride,
392            IssueKind::MisconfiguredDependencyOverride,
393        ] {
394            assert_eq!(
395                IssueKind::from_discriminant(kind.to_discriminant()),
396                Some(kind)
397            );
398        }
399        assert_eq!(IssueKind::from_discriminant(0), None);
400        assert_eq!(IssueKind::from_discriminant(26), None);
401    }
402
403    // ── Discriminant uniqueness ─────────────────────────────────
404
405    #[test]
406    fn discriminant_values_are_unique() {
407        let all_kinds = [
408            IssueKind::UnusedFile,
409            IssueKind::UnusedExport,
410            IssueKind::UnusedType,
411            IssueKind::PrivateTypeLeak,
412            IssueKind::UnusedDependency,
413            IssueKind::UnusedDevDependency,
414            IssueKind::UnusedEnumMember,
415            IssueKind::UnusedClassMember,
416            IssueKind::UnresolvedImport,
417            IssueKind::UnlistedDependency,
418            IssueKind::DuplicateExport,
419            IssueKind::CodeDuplication,
420            IssueKind::CircularDependency,
421            IssueKind::TypeOnlyDependency,
422            IssueKind::TestOnlyDependency,
423            IssueKind::BoundaryViolation,
424            IssueKind::CoverageGaps,
425            IssueKind::FeatureFlag,
426            IssueKind::Complexity,
427            IssueKind::StaleSuppression,
428            IssueKind::PnpmCatalogEntry,
429            IssueKind::EmptyCatalogGroup,
430            IssueKind::UnresolvedCatalogReference,
431            IssueKind::UnusedDependencyOverride,
432            IssueKind::MisconfiguredDependencyOverride,
433        ];
434        let discriminants: Vec<u8> = all_kinds.iter().map(|k| k.to_discriminant()).collect();
435        let mut sorted = discriminants.clone();
436        sorted.sort_unstable();
437        sorted.dedup();
438        assert_eq!(
439            discriminants.len(),
440            sorted.len(),
441            "discriminant values must be unique"
442        );
443    }
444
445    // ── Discriminant starts at 1 ────────────────────────────────
446
447    #[test]
448    fn discriminant_starts_at_one() {
449        assert_eq!(IssueKind::UnusedFile.to_discriminant(), 1);
450    }
451
452    // ── Suppression struct ──────────────────────────────────────
453
454    #[test]
455    fn suppression_line_zero_is_file_wide() {
456        let s = Suppression {
457            line: 0,
458            comment_line: 1,
459            kind: None,
460        };
461        assert_eq!(s.line, 0);
462        assert!(s.kind.is_none());
463    }
464
465    #[test]
466    fn suppression_with_specific_kind_and_line() {
467        let s = Suppression {
468            line: 42,
469            comment_line: 41,
470            kind: Some(IssueKind::UnusedExport),
471        };
472        assert_eq!(s.line, 42);
473        assert_eq!(s.comment_line, 41);
474        assert_eq!(s.kind, Some(IssueKind::UnusedExport));
475    }
476}