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