Skip to main content

fallow_core/
suppress.rs

1use std::sync::atomic::{AtomicBool, Ordering};
2
3use fallow_config::{ResolvedConfig, RulesConfig, Severity};
4use rustc_hash::FxHashMap;
5
6// Re-export types from fallow-types
7pub use fallow_types::suppress::{IssueKind, Suppression, UnknownSuppressionKind};
8
9// Re-export parsing functions from fallow-extract
10pub use fallow_extract::suppress::parse_suppressions_from_source;
11
12use crate::discover::FileId;
13use crate::extract::ModuleInfo;
14use crate::graph::ModuleGraph;
15use crate::results::{StaleSuppression, SuppressionOrigin};
16
17/// Map an `IssueKind` to its corresponding severity in `RulesConfig`.
18///
19/// Exhaustive match by design: a new `IssueKind` variant triggers a compile
20/// error here, forcing the implementer to decide which `RulesConfig` field
21/// (if any) gates emission. Kinds that have no matching field
22/// (`CodeDuplication`, gated by the dupes command itself) return the
23/// non-Off value `Severity::Error`; in practice these kinds short-circuit
24/// earlier via `NON_CORE_KINDS` so the returned value is unobservable.
25fn severity_for_kind(rules: &RulesConfig, kind: IssueKind) -> Severity {
26    match kind {
27        IssueKind::UnusedFile => rules.unused_files,
28        IssueKind::UnusedExport => rules.unused_exports,
29        IssueKind::UnusedType => rules.unused_types,
30        IssueKind::PrivateTypeLeak => rules.private_type_leaks,
31        IssueKind::UnusedDependency => rules.unused_dependencies,
32        IssueKind::UnusedDevDependency => rules.unused_dev_dependencies,
33        IssueKind::UnusedEnumMember => rules.unused_enum_members,
34        IssueKind::UnusedClassMember => rules.unused_class_members,
35        IssueKind::UnresolvedImport => rules.unresolved_imports,
36        IssueKind::UnlistedDependency => rules.unlisted_dependencies,
37        IssueKind::DuplicateExport => rules.duplicate_exports,
38        IssueKind::CircularDependency => rules.circular_dependencies,
39        IssueKind::ReExportCycle => rules.re_export_cycle,
40        IssueKind::TypeOnlyDependency => rules.type_only_dependencies,
41        IssueKind::TestOnlyDependency => rules.test_only_dependencies,
42        IssueKind::BoundaryViolation => rules.boundary_violation,
43        IssueKind::CoverageGaps => rules.coverage_gaps,
44        IssueKind::FeatureFlag => rules.feature_flags,
45        IssueKind::StaleSuppression => rules.stale_suppressions,
46        IssueKind::PnpmCatalogEntry => rules.unused_catalog_entries,
47        IssueKind::EmptyCatalogGroup => rules.empty_catalog_groups,
48        IssueKind::UnresolvedCatalogReference => rules.unresolved_catalog_references,
49        IssueKind::UnusedDependencyOverride => rules.unused_dependency_overrides,
50        IssueKind::MisconfiguredDependencyOverride => rules.misconfigured_dependency_overrides,
51        // No corresponding rule. Short-circuited earlier via `NON_CORE_KINDS`.
52        IssueKind::Complexity | IssueKind::CodeDuplication => Severity::Error,
53    }
54}
55
56/// Issue kinds whose suppression is not checked via `SuppressionContext`
57/// in `find_dead_code_full`. Excludes CLI-side kinds (checked in health/flags
58/// commands) and dependency-level kinds (not file-scoped, suppression never
59/// consumed by core detectors). Without this exclusion, these suppressions
60/// would always appear stale since no core detector checks them.
61const NON_CORE_KINDS: &[IssueKind] = &[
62    // CLI-side: checked in health/flags/dupes commands, not in find_dead_code_full
63    IssueKind::Complexity,
64    IssueKind::CoverageGaps,
65    IssueKind::FeatureFlag,
66    IssueKind::CodeDuplication,
67    // Dep-level: not file-scoped, suppression path is via config ignoreDependencies
68    IssueKind::UnusedDependency,
69    IssueKind::UnusedDevDependency,
70    IssueKind::UnlistedDependency,
71    IssueKind::TypeOnlyDependency,
72    IssueKind::TestOnlyDependency,
73    IssueKind::PnpmCatalogEntry,
74    IssueKind::EmptyCatalogGroup,
75    IssueKind::UnresolvedCatalogReference,
76    IssueKind::UnusedDependencyOverride,
77    IssueKind::MisconfiguredDependencyOverride,
78    // Meta: stale-suppression itself is never consumed by any detector,
79    // so a `// fallow-ignore-next-line stale-suppression` comment would
80    // always appear stale. Exclude to prevent recursive confusion.
81    IssueKind::StaleSuppression,
82];
83
84/// Suppression context that tracks which suppressions are consumed by detectors.
85///
86/// Wraps the per-file suppression map and records, via `AtomicBool` flags,
87/// which suppression entries actually matched an issue during detection.
88/// After all detectors run, `find_stale()` returns unmatched suppressions.
89///
90/// Uses `AtomicBool` (not `Cell<bool>`) so the context can be shared
91/// across threads if detectors ever use `rayon` internally.
92pub struct SuppressionContext<'a> {
93    by_file: FxHashMap<FileId, &'a [Suppression]>,
94    used: FxHashMap<FileId, Vec<AtomicBool>>,
95    /// Suppression tokens that did not parse to any known `IssueKind`.
96    /// Emitted as `StaleSuppression` with `kind_known: false` in `find_stale`.
97    /// See issue #449.
98    unknown_kinds: FxHashMap<FileId, &'a [UnknownSuppressionKind]>,
99}
100
101impl<'a> SuppressionContext<'a> {
102    /// Build a suppression context from parsed modules.
103    pub fn new(modules: &'a [ModuleInfo]) -> Self {
104        let by_file: FxHashMap<FileId, &[Suppression]> = modules
105            .iter()
106            .filter(|m| !m.suppressions.is_empty())
107            .map(|m| (m.file_id, m.suppressions.as_slice()))
108            .collect();
109
110        let used = by_file
111            .iter()
112            .map(|(&fid, supps)| {
113                (
114                    fid,
115                    std::iter::repeat_with(|| AtomicBool::new(false))
116                        .take(supps.len())
117                        .collect(),
118                )
119            })
120            .collect();
121
122        let unknown_kinds: FxHashMap<FileId, &[UnknownSuppressionKind]> = modules
123            .iter()
124            .filter(|m| !m.unknown_suppression_kinds.is_empty())
125            .map(|m| (m.file_id, m.unknown_suppression_kinds.as_slice()))
126            .collect();
127
128        Self {
129            by_file,
130            used,
131            unknown_kinds,
132        }
133    }
134
135    /// Build a suppression context from a pre-built map (for testing).
136    #[cfg(test)]
137    pub fn from_map(by_file: FxHashMap<FileId, &'a [Suppression]>) -> Self {
138        let used = by_file
139            .iter()
140            .map(|(&fid, supps)| {
141                (
142                    fid,
143                    std::iter::repeat_with(|| AtomicBool::new(false))
144                        .take(supps.len())
145                        .collect(),
146                )
147            })
148            .collect();
149        Self {
150            by_file,
151            used,
152            unknown_kinds: FxHashMap::default(),
153        }
154    }
155
156    /// Build an empty suppression context (for testing).
157    #[cfg(test)]
158    pub fn empty() -> Self {
159        Self {
160            by_file: FxHashMap::default(),
161            used: FxHashMap::default(),
162            unknown_kinds: FxHashMap::default(),
163        }
164    }
165
166    /// Check if a specific issue at a given line should be suppressed,
167    /// and mark the matching suppression as consumed.
168    #[must_use]
169    pub fn is_suppressed(&self, file_id: FileId, line: u32, kind: IssueKind) -> bool {
170        let Some(supps) = self.by_file.get(&file_id) else {
171            return false;
172        };
173        let Some(used) = self.used.get(&file_id) else {
174            return false;
175        };
176        for (i, s) in supps.iter().enumerate() {
177            let matched = if s.line == 0 {
178                s.kind.is_none() || s.kind == Some(kind)
179            } else {
180                s.line == line && (s.kind.is_none() || s.kind == Some(kind))
181            };
182            if matched {
183                used[i].store(true, Ordering::Relaxed);
184                return true;
185            }
186        }
187        false
188    }
189
190    /// Check if the entire file is suppressed for the given kind,
191    /// and mark the matching suppression as consumed.
192    #[must_use]
193    pub fn is_file_suppressed(&self, file_id: FileId, kind: IssueKind) -> bool {
194        let Some(supps) = self.by_file.get(&file_id) else {
195            return false;
196        };
197        let Some(used) = self.used.get(&file_id) else {
198            return false;
199        };
200        for (i, s) in supps.iter().enumerate() {
201            if s.line == 0 && (s.kind.is_none() || s.kind == Some(kind)) {
202                used[i].store(true, Ordering::Relaxed);
203                return true;
204            }
205        }
206        false
207    }
208
209    /// Get the raw suppressions for a file (for detectors that need direct access).
210    pub fn get(&self, file_id: FileId) -> Option<&[Suppression]> {
211        self.by_file.get(&file_id).copied()
212    }
213
214    /// Count suppression entries that matched at least one issue.
215    #[must_use]
216    pub fn used_count(&self) -> usize {
217        self.used
218            .values()
219            .flat_map(|used| used.iter())
220            .filter(|used| used.load(Ordering::Relaxed))
221            .count()
222    }
223
224    /// Collect all suppressions that were never consumed by any detector.
225    ///
226    /// Skips suppression kinds that are checked in the CLI layer
227    /// (complexity, coverage gaps, feature flags, code duplication)
228    /// to avoid false positives. Also skips suppressions whose target kind
229    /// is disabled (`Severity::Off`) under the resolved rules for the
230    /// suppression's file, including per-file `overrides.rules`: the
231    /// detector never ran, so the suppression appears unconsumed, but is
232    /// not actually stale (it documents intentional dormancy and becomes
233    /// valid again the moment the rule is re-enabled). See issue #482.
234    pub fn find_stale(
235        &self,
236        graph: &ModuleGraph,
237        config: &ResolvedConfig,
238    ) -> Vec<StaleSuppression> {
239        let mut stale = Vec::new();
240
241        for (&file_id, supps) in &self.by_file {
242            let used = &self.used[&file_id];
243            let path = &graph.modules[file_id.0 as usize].path;
244            // Resolve rules once per file so per-file `overrides.rules`
245            // apply uniformly to all suppressions in this module.
246            let file_rules = config.resolve_rules_for_path(path);
247
248            for (i, s) in supps.iter().enumerate() {
249                if used[i].load(Ordering::Relaxed) {
250                    continue;
251                }
252
253                // Skip suppression kinds that are only checked in the CLI layer.
254                // These were never presented to the core detectors, so they
255                // appear unconsumed, but are not actually stale.
256                if let Some(kind) = s.kind
257                    && NON_CORE_KINDS.contains(&kind)
258                {
259                    continue;
260                }
261
262                // Skip suppressions whose target kind is disabled in the
263                // resolved rules for this file. Blanket suppressions
264                // (`s.kind == None`) are never skipped on this basis: the
265                // marker is not anchored to any specific dormant kind, so
266                // "nothing matched" still means genuinely stale.
267                if let Some(kind) = s.kind
268                    && severity_for_kind(&file_rules, kind) == Severity::Off
269                {
270                    continue;
271                }
272
273                let is_file_level = s.line == 0;
274                let issue_kind_str = s.kind.map(|k| {
275                    // Convert back to the kebab-case string for output
276                    match k {
277                        IssueKind::UnusedFile => "unused-file",
278                        IssueKind::UnusedExport => "unused-export",
279                        IssueKind::UnusedType => "unused-type",
280                        IssueKind::PrivateTypeLeak => "private-type-leak",
281                        IssueKind::UnusedDependency => "unused-dependency",
282                        IssueKind::UnusedDevDependency => "unused-dev-dependency",
283                        IssueKind::UnusedEnumMember => "unused-enum-member",
284                        IssueKind::UnusedClassMember => "unused-class-member",
285                        IssueKind::UnresolvedImport => "unresolved-import",
286                        IssueKind::UnlistedDependency => "unlisted-dependency",
287                        IssueKind::DuplicateExport => "duplicate-export",
288                        IssueKind::CodeDuplication => "code-duplication",
289                        IssueKind::CircularDependency => "circular-dependency",
290                        IssueKind::ReExportCycle => "re-export-cycle",
291                        IssueKind::TypeOnlyDependency => "type-only-dependency",
292                        IssueKind::TestOnlyDependency => "test-only-dependency",
293                        IssueKind::BoundaryViolation => "boundary-violation",
294                        IssueKind::CoverageGaps => "coverage-gaps",
295                        IssueKind::FeatureFlag => "feature-flag",
296                        IssueKind::Complexity => "complexity",
297                        IssueKind::StaleSuppression => "stale-suppression",
298                        IssueKind::PnpmCatalogEntry => "unused-catalog-entry",
299                        IssueKind::EmptyCatalogGroup => "empty-catalog-group",
300                        IssueKind::UnresolvedCatalogReference => "unresolved-catalog-reference",
301                        IssueKind::UnusedDependencyOverride => "unused-dependency-override",
302                        IssueKind::MisconfiguredDependencyOverride => {
303                            "misconfigured-dependency-override"
304                        }
305                    }
306                    .to_string()
307                });
308
309                stale.push(StaleSuppression {
310                    path: path.clone(),
311                    line: s.comment_line,
312                    col: 0,
313                    origin: SuppressionOrigin::Comment {
314                        issue_kind: issue_kind_str,
315                        is_file_level,
316                        kind_known: true,
317                    },
318                });
319            }
320        }
321
322        // Surface every unknown suppression token (typo, obsolete kind name, kind
323        // renamed in a newer fallow release) as a stale suppression. Without
324        // this, the entire marker would be silently discarded and the user
325        // would never learn the suppression was rejected. See issue #449.
326        for (&file_id, unknowns) in &self.unknown_kinds {
327            let path = &graph.modules[file_id.0 as usize].path;
328            for u in *unknowns {
329                stale.push(StaleSuppression {
330                    path: path.clone(),
331                    line: u.comment_line,
332                    col: 0,
333                    origin: SuppressionOrigin::Comment {
334                        issue_kind: Some(u.token.clone()),
335                        is_file_level: u.is_file_level,
336                        kind_known: false,
337                    },
338                });
339            }
340        }
341
342        stale
343    }
344}
345
346/// Check if a specific issue at a given line should be suppressed.
347///
348/// Standalone predicate for callers outside `find_dead_code_full`
349/// (e.g., CLI health/flags commands) that don't need tracking.
350#[must_use]
351pub fn is_suppressed(suppressions: &[Suppression], line: u32, kind: IssueKind) -> bool {
352    suppressions.iter().any(|s| {
353        // File-wide suppression
354        if s.line == 0 {
355            return s.kind.is_none() || s.kind == Some(kind);
356        }
357        // Line-specific suppression
358        s.line == line && (s.kind.is_none() || s.kind == Some(kind))
359    })
360}
361
362/// Check if the entire file is suppressed (for issue types that don't have line numbers).
363///
364/// Standalone predicate for callers outside `find_dead_code_full`.
365#[must_use]
366pub fn is_file_suppressed(suppressions: &[Suppression], kind: IssueKind) -> bool {
367    suppressions
368        .iter()
369        .any(|s| s.line == 0 && (s.kind.is_none() || s.kind == Some(kind)))
370}
371
372#[cfg(test)]
373mod tests {
374    use super::*;
375
376    #[test]
377    fn severity_for_kind_maps_every_core_kind_to_its_field() {
378        let rules = RulesConfig {
379            unused_exports: Severity::Warn,
380            unused_types: Severity::Off,
381            unresolved_imports: Severity::Error,
382            boundary_violation: Severity::Off,
383            ..RulesConfig::default()
384        };
385
386        assert_eq!(
387            severity_for_kind(&rules, IssueKind::UnusedExport),
388            Severity::Warn
389        );
390        assert_eq!(
391            severity_for_kind(&rules, IssueKind::UnusedType),
392            Severity::Off
393        );
394        assert_eq!(
395            severity_for_kind(&rules, IssueKind::UnresolvedImport),
396            Severity::Error
397        );
398        assert_eq!(
399            severity_for_kind(&rules, IssueKind::BoundaryViolation),
400            Severity::Off
401        );
402        // PrivateTypeLeak defaults to Off across the project.
403        assert_eq!(
404            severity_for_kind(&rules, IssueKind::PrivateTypeLeak),
405            Severity::Off
406        );
407    }
408
409    #[test]
410    fn issue_kind_from_str_all_variants() {
411        assert_eq!(IssueKind::parse("unused-file"), Some(IssueKind::UnusedFile));
412        assert_eq!(
413            IssueKind::parse("unused-export"),
414            Some(IssueKind::UnusedExport)
415        );
416        assert_eq!(IssueKind::parse("unused-type"), Some(IssueKind::UnusedType));
417        assert_eq!(
418            IssueKind::parse("unused-dependency"),
419            Some(IssueKind::UnusedDependency)
420        );
421        assert_eq!(
422            IssueKind::parse("unused-dev-dependency"),
423            Some(IssueKind::UnusedDevDependency)
424        );
425        assert_eq!(
426            IssueKind::parse("unused-enum-member"),
427            Some(IssueKind::UnusedEnumMember)
428        );
429        assert_eq!(
430            IssueKind::parse("unused-class-member"),
431            Some(IssueKind::UnusedClassMember)
432        );
433        assert_eq!(
434            IssueKind::parse("unresolved-import"),
435            Some(IssueKind::UnresolvedImport)
436        );
437        assert_eq!(
438            IssueKind::parse("unlisted-dependency"),
439            Some(IssueKind::UnlistedDependency)
440        );
441        assert_eq!(
442            IssueKind::parse("duplicate-export"),
443            Some(IssueKind::DuplicateExport)
444        );
445    }
446
447    #[test]
448    fn issue_kind_from_str_unknown() {
449        assert_eq!(IssueKind::parse("foo"), None);
450        assert_eq!(IssueKind::parse(""), None);
451    }
452
453    #[test]
454    fn discriminant_roundtrip() {
455        for kind in [
456            IssueKind::UnusedFile,
457            IssueKind::UnusedExport,
458            IssueKind::UnusedType,
459            IssueKind::PrivateTypeLeak,
460            IssueKind::UnusedDependency,
461            IssueKind::UnusedDevDependency,
462            IssueKind::UnusedEnumMember,
463            IssueKind::UnusedClassMember,
464            IssueKind::UnresolvedImport,
465            IssueKind::UnlistedDependency,
466            IssueKind::DuplicateExport,
467            IssueKind::CodeDuplication,
468            IssueKind::CircularDependency,
469            IssueKind::TestOnlyDependency,
470            IssueKind::BoundaryViolation,
471            IssueKind::CoverageGaps,
472            IssueKind::FeatureFlag,
473            IssueKind::Complexity,
474            IssueKind::StaleSuppression,
475            IssueKind::PnpmCatalogEntry,
476            IssueKind::EmptyCatalogGroup,
477            IssueKind::UnresolvedCatalogReference,
478            IssueKind::UnusedDependencyOverride,
479            IssueKind::MisconfiguredDependencyOverride,
480            IssueKind::ReExportCycle,
481        ] {
482            assert_eq!(
483                IssueKind::from_discriminant(kind.to_discriminant()),
484                Some(kind)
485            );
486        }
487        assert_eq!(IssueKind::from_discriminant(0), None);
488        assert_eq!(IssueKind::from_discriminant(27), None);
489    }
490
491    #[test]
492    fn parse_file_wide_suppression() {
493        let source = "// fallow-ignore-file\nexport const foo = 1;\n";
494        let suppressions = parse_suppressions_from_source(source).suppressions;
495        assert_eq!(suppressions.len(), 1);
496        assert_eq!(suppressions[0].line, 0);
497        assert!(suppressions[0].kind.is_none());
498    }
499
500    #[test]
501    fn parse_file_wide_suppression_with_kind() {
502        let source = "// fallow-ignore-file unused-export\nexport const foo = 1;\n";
503        let suppressions = parse_suppressions_from_source(source).suppressions;
504        assert_eq!(suppressions.len(), 1);
505        assert_eq!(suppressions[0].line, 0);
506        assert_eq!(suppressions[0].kind, Some(IssueKind::UnusedExport));
507    }
508
509    #[test]
510    fn parse_next_line_suppression() {
511        let source =
512            "import { x } from './x';\n// fallow-ignore-next-line\nexport const foo = 1;\n";
513        let suppressions = parse_suppressions_from_source(source).suppressions;
514        assert_eq!(suppressions.len(), 1);
515        assert_eq!(suppressions[0].line, 3); // suppresses line 3 (the export)
516        assert!(suppressions[0].kind.is_none());
517    }
518
519    #[test]
520    fn parse_next_line_suppression_with_kind() {
521        let source = "// fallow-ignore-next-line unused-export\nexport const foo = 1;\n";
522        let suppressions = parse_suppressions_from_source(source).suppressions;
523        assert_eq!(suppressions.len(), 1);
524        assert_eq!(suppressions[0].line, 2);
525        assert_eq!(suppressions[0].kind, Some(IssueKind::UnusedExport));
526    }
527
528    #[test]
529    fn parse_unknown_kind_surfaces_as_unknown() {
530        let source = "// fallow-ignore-next-line typo-kind\nexport const foo = 1;\n";
531        let parsed = parse_suppressions_from_source(source);
532        // No known kinds on the marker, so no suppression is recorded.
533        assert!(parsed.suppressions.is_empty());
534        // The unknown token is preserved for downstream stale-suppression
535        // reporting (see issue #449).
536        assert_eq!(parsed.unknown_kinds.len(), 1);
537        assert_eq!(parsed.unknown_kinds[0].token, "typo-kind");
538    }
539
540    #[test]
541    fn is_suppressed_file_wide() {
542        let suppressions = vec![Suppression {
543            line: 0,
544            comment_line: 1,
545            kind: None,
546        }];
547        assert!(is_suppressed(&suppressions, 5, IssueKind::UnusedExport));
548        assert!(is_suppressed(&suppressions, 10, IssueKind::UnusedFile));
549    }
550
551    #[test]
552    fn is_suppressed_file_wide_specific_kind() {
553        let suppressions = vec![Suppression {
554            line: 0,
555            comment_line: 1,
556            kind: Some(IssueKind::UnusedExport),
557        }];
558        assert!(is_suppressed(&suppressions, 5, IssueKind::UnusedExport));
559        assert!(!is_suppressed(&suppressions, 5, IssueKind::UnusedType));
560    }
561
562    #[test]
563    fn is_suppressed_line_specific() {
564        let suppressions = vec![Suppression {
565            line: 5,
566            comment_line: 4,
567            kind: None,
568        }];
569        assert!(is_suppressed(&suppressions, 5, IssueKind::UnusedExport));
570        assert!(!is_suppressed(&suppressions, 6, IssueKind::UnusedExport));
571    }
572
573    #[test]
574    fn is_suppressed_line_and_kind() {
575        let suppressions = vec![Suppression {
576            line: 5,
577            comment_line: 4,
578            kind: Some(IssueKind::UnusedExport),
579        }];
580        assert!(is_suppressed(&suppressions, 5, IssueKind::UnusedExport));
581        assert!(!is_suppressed(&suppressions, 5, IssueKind::UnusedType));
582        assert!(!is_suppressed(&suppressions, 6, IssueKind::UnusedExport));
583    }
584
585    #[test]
586    fn is_suppressed_empty() {
587        assert!(!is_suppressed(&[], 5, IssueKind::UnusedExport));
588    }
589
590    #[test]
591    fn is_file_suppressed_works() {
592        let suppressions = vec![Suppression {
593            line: 0,
594            comment_line: 1,
595            kind: None,
596        }];
597        assert!(is_file_suppressed(&suppressions, IssueKind::UnusedFile));
598
599        let suppressions = vec![Suppression {
600            line: 0,
601            comment_line: 1,
602            kind: Some(IssueKind::UnusedFile),
603        }];
604        assert!(is_file_suppressed(&suppressions, IssueKind::UnusedFile));
605        assert!(!is_file_suppressed(&suppressions, IssueKind::UnusedExport));
606
607        // Line-specific suppression should not count as file-wide
608        let suppressions = vec![Suppression {
609            line: 5,
610            comment_line: 4,
611            kind: None,
612        }];
613        assert!(!is_file_suppressed(&suppressions, IssueKind::UnusedFile));
614    }
615
616    #[test]
617    fn parse_oxc_comments() {
618        use fallow_extract::suppress::parse_suppressions;
619        use oxc_allocator::Allocator;
620        use oxc_parser::Parser;
621        use oxc_span::SourceType;
622
623        let source = "// fallow-ignore-file\n// fallow-ignore-next-line unused-export\nexport const foo = 1;\nexport const bar = 2;\n";
624        let allocator = Allocator::default();
625        let parser_return = Parser::new(&allocator, source, SourceType::mjs()).parse();
626
627        let suppressions = parse_suppressions(&parser_return.program.comments, source).suppressions;
628        assert_eq!(suppressions.len(), 2);
629
630        // File-wide suppression
631        assert_eq!(suppressions[0].line, 0);
632        assert!(suppressions[0].kind.is_none());
633
634        // Next-line suppression with kind
635        assert_eq!(suppressions[1].line, 3); // suppresses line 3 (export const foo)
636        assert_eq!(suppressions[1].kind, Some(IssueKind::UnusedExport));
637    }
638
639    #[test]
640    fn parse_block_comment_suppression() {
641        let source = "/* fallow-ignore-file */\nexport const foo = 1;\n";
642        let suppressions = parse_suppressions_from_source(source).suppressions;
643        assert_eq!(suppressions.len(), 1);
644        assert_eq!(suppressions[0].line, 0);
645        assert!(suppressions[0].kind.is_none());
646    }
647
648    #[test]
649    fn is_suppressed_multiple_suppressions_different_kinds() {
650        let suppressions = vec![
651            Suppression {
652                line: 5,
653                comment_line: 4,
654                kind: Some(IssueKind::UnusedExport),
655            },
656            Suppression {
657                line: 5,
658                comment_line: 4,
659                kind: Some(IssueKind::UnusedType),
660            },
661        ];
662        assert!(is_suppressed(&suppressions, 5, IssueKind::UnusedExport));
663        assert!(is_suppressed(&suppressions, 5, IssueKind::UnusedType));
664        assert!(!is_suppressed(&suppressions, 5, IssueKind::UnusedFile));
665    }
666
667    #[test]
668    fn is_suppressed_file_wide_blanket_and_specific_coexist() {
669        let suppressions = vec![
670            Suppression {
671                line: 0,
672                comment_line: 1,
673                kind: Some(IssueKind::UnusedExport),
674            },
675            Suppression {
676                line: 5,
677                comment_line: 4,
678                kind: None, // blanket suppress on line 5
679            },
680        ];
681        // File-wide suppression only covers UnusedExport
682        assert!(is_suppressed(&suppressions, 10, IssueKind::UnusedExport));
683        assert!(!is_suppressed(&suppressions, 10, IssueKind::UnusedType));
684
685        // Line 5 blanket suppression covers everything on line 5
686        assert!(is_suppressed(&suppressions, 5, IssueKind::UnusedType));
687        assert!(is_suppressed(&suppressions, 5, IssueKind::UnusedExport));
688    }
689
690    #[test]
691    fn is_file_suppressed_blanket_suppresses_all_kinds() {
692        let suppressions = vec![Suppression {
693            line: 0,
694            comment_line: 1,
695            kind: None, // blanket file-wide
696        }];
697        assert!(is_file_suppressed(&suppressions, IssueKind::UnusedFile));
698        assert!(is_file_suppressed(&suppressions, IssueKind::UnusedExport));
699        assert!(is_file_suppressed(&suppressions, IssueKind::UnusedType));
700        assert!(is_file_suppressed(
701            &suppressions,
702            IssueKind::CircularDependency
703        ));
704        assert!(is_file_suppressed(
705            &suppressions,
706            IssueKind::CodeDuplication
707        ));
708    }
709
710    #[test]
711    fn is_file_suppressed_empty_list() {
712        assert!(!is_file_suppressed(&[], IssueKind::UnusedFile));
713    }
714
715    #[test]
716    fn parse_multiple_next_line_suppressions() {
717        let source = "// fallow-ignore-next-line unused-export\nexport const foo = 1;\n// fallow-ignore-next-line unused-type\nexport type Bar = string;\n";
718        let suppressions = parse_suppressions_from_source(source).suppressions;
719        assert_eq!(suppressions.len(), 2);
720        assert_eq!(suppressions[0].line, 2);
721        assert_eq!(suppressions[0].kind, Some(IssueKind::UnusedExport));
722        assert_eq!(suppressions[1].line, 4);
723        assert_eq!(suppressions[1].kind, Some(IssueKind::UnusedType));
724    }
725
726    #[test]
727    fn parse_code_duplication_suppression() {
728        let source = "// fallow-ignore-file code-duplication\nexport const foo = 1;\n";
729        let suppressions = parse_suppressions_from_source(source).suppressions;
730        assert_eq!(suppressions.len(), 1);
731        assert_eq!(suppressions[0].line, 0);
732        assert_eq!(suppressions[0].kind, Some(IssueKind::CodeDuplication));
733    }
734
735    #[test]
736    fn parse_circular_dependency_suppression() {
737        let source = "// fallow-ignore-file circular-dependency\nimport { x } from './x';\n";
738        let suppressions = parse_suppressions_from_source(source).suppressions;
739        assert_eq!(suppressions.len(), 1);
740        assert_eq!(suppressions[0].line, 0);
741        assert_eq!(suppressions[0].kind, Some(IssueKind::CircularDependency));
742    }
743
744    /// Every `IssueKind` must be explicitly placed in either `NON_CORE_KINDS`
745    /// (not checked by core detectors) or handled by a core detector that
746    /// calls `SuppressionContext::is_suppressed` / `is_file_suppressed`.
747    /// This test fails when a new `IssueKind` variant is added without
748    /// being classified, preventing silent false-positive stale reports.
749    #[test]
750    fn all_issue_kinds_classified_for_stale_detection() {
751        // Kinds checked by core detectors via SuppressionContext
752        let core_kinds = [
753            IssueKind::UnusedFile,
754            IssueKind::UnusedExport,
755            IssueKind::UnusedType,
756            IssueKind::UnusedEnumMember,
757            IssueKind::UnusedClassMember,
758            IssueKind::UnresolvedImport,
759            IssueKind::DuplicateExport,
760            IssueKind::CircularDependency,
761            IssueKind::BoundaryViolation,
762        ];
763
764        // All variants that exist
765        let all_kinds = [
766            IssueKind::UnusedFile,
767            IssueKind::UnusedExport,
768            IssueKind::UnusedType,
769            IssueKind::UnusedDependency,
770            IssueKind::UnusedDevDependency,
771            IssueKind::UnusedEnumMember,
772            IssueKind::UnusedClassMember,
773            IssueKind::UnresolvedImport,
774            IssueKind::UnlistedDependency,
775            IssueKind::DuplicateExport,
776            IssueKind::CodeDuplication,
777            IssueKind::CircularDependency,
778            IssueKind::TypeOnlyDependency,
779            IssueKind::TestOnlyDependency,
780            IssueKind::BoundaryViolation,
781            IssueKind::CoverageGaps,
782            IssueKind::FeatureFlag,
783            IssueKind::Complexity,
784            IssueKind::StaleSuppression,
785            IssueKind::PnpmCatalogEntry,
786            IssueKind::EmptyCatalogGroup,
787            IssueKind::UnresolvedCatalogReference,
788            IssueKind::UnusedDependencyOverride,
789            IssueKind::MisconfiguredDependencyOverride,
790        ];
791
792        for kind in all_kinds {
793            let in_core = core_kinds.contains(&kind);
794            let in_non_core = NON_CORE_KINDS.contains(&kind);
795            assert!(
796                in_core || in_non_core,
797                "IssueKind::{kind:?} is not classified in either core_kinds or NON_CORE_KINDS. \
798                 Add it to NON_CORE_KINDS if it is checked outside find_dead_code_full, \
799                 or to core_kinds in this test if a core detector checks it."
800            );
801            assert!(
802                !(in_core && in_non_core),
803                "IssueKind::{kind:?} is in BOTH core_kinds and NON_CORE_KINDS. Pick one."
804            );
805        }
806    }
807}