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, FxHashSet};
5
6pub use fallow_types::suppress::{
7    IssueKind, PolicyRuleSuppression, Suppression, UnknownSuppressionKind, is_file_suppressed,
8    is_suppressed, issue_kind_to_kebab,
9};
10
11pub use fallow_extract::suppress::parse_suppressions_from_source;
12
13use crate::discover::FileId;
14use crate::extract::ModuleInfo;
15use crate::graph::ModuleGraph;
16use crate::results::{ActiveSuppression, StaleSuppression, SuppressionOrigin};
17
18/// Convert an [`IssueKind`] to its canonical kebab-case wire string.
19///
20/// Single source of truth for the kind-to-string mapping, shared by stale
21/// detection and active-suppression capture so the two never drift.
22#[must_use]
23pub fn kind_to_kebab(kind: IssueKind) -> &'static str {
24    issue_kind_to_kebab(kind)
25}
26
27/// Map an `IssueKind` to its corresponding severity in `RulesConfig`.
28///
29/// Thin wrapper over `RulesConfig::severity_for_kind` (the single source of
30/// truth for the kind-to-severity table, which also backs the `fallow schema`
31/// manifest's per-rule `default_severity`). Kept here as a free helper only to
32/// preserve the `(rules, kind)` call shape at the local call sites.
33fn severity_for_kind(rules: &RulesConfig, kind: IssueKind) -> Severity {
34    rules.severity_for_kind(kind)
35}
36
37/// Issue kinds whose suppression is not checked via `SuppressionContext`
38/// in `find_dead_code_full`. Excludes CLI-side kinds (checked in health/flags
39/// commands) and dependency-level kinds (not file-scoped, suppression never
40/// consumed by core detectors). Without this exclusion, these suppressions
41/// would always appear stale since no core detector checks them.
42const NON_CORE_KINDS: &[IssueKind] = &[
43    IssueKind::Complexity,
44    IssueKind::CoverageGaps,
45    IssueKind::FeatureFlag,
46    IssueKind::CodeDuplication,
47    IssueKind::CssTokenDrift,
48    IssueKind::CssDuplicateBlock,
49    IssueKind::CssSelectorComplexity,
50    IssueKind::CssDeadSurface,
51    IssueKind::CssBrokenReference,
52    IssueKind::UnusedDependency,
53    IssueKind::UnusedDevDependency,
54    IssueKind::UnlistedDependency,
55    IssueKind::TypeOnlyDependency,
56    IssueKind::TestOnlyDependency,
57    IssueKind::DevDependencyInProduction,
58    IssueKind::PnpmCatalogEntry,
59    IssueKind::EmptyCatalogGroup,
60    IssueKind::UnresolvedCatalogReference,
61    IssueKind::UnusedDependencyOverride,
62    IssueKind::MisconfiguredDependencyOverride,
63    IssueKind::StaleSuppression,
64];
65
66/// Suppression context that tracks which suppressions are consumed by detectors.
67///
68/// Wraps the per-file suppression map and records, via `AtomicBool` flags,
69/// which suppression entries actually matched an issue during detection.
70/// After all detectors run, `find_stale()` returns unmatched suppressions.
71///
72/// Uses `AtomicBool` (not `Cell<bool>`) so the context can be shared
73/// across threads if detectors ever use `rayon` internally.
74pub struct SuppressionContext<'a> {
75    by_file: FxHashMap<FileId, &'a [Suppression]>,
76    used: FxHashMap<FileId, Vec<AtomicBool>>,
77    /// Suppression tokens that did not parse to any known `IssueKind`.
78    /// Emitted as `StaleSuppression` with `kind_known: false` in `find_stale`.
79    /// See issue #449.
80    unknown_kinds: FxHashMap<FileId, &'a [UnknownSuppressionKind]>,
81}
82
83impl<'a> SuppressionContext<'a> {
84    /// Build a suppression context from parsed modules.
85    pub fn new(modules: &'a [ModuleInfo]) -> Self {
86        let by_file: FxHashMap<FileId, &[Suppression]> = modules
87            .iter()
88            .filter(|m| !m.suppressions.is_empty())
89            .map(|m| (m.file_id, m.suppressions.as_slice()))
90            .collect();
91
92        let used = by_file
93            .iter()
94            .map(|(&fid, supps)| {
95                (
96                    fid,
97                    std::iter::repeat_with(|| AtomicBool::new(false))
98                        .take(supps.len())
99                        .collect(),
100                )
101            })
102            .collect();
103
104        let unknown_kinds: FxHashMap<FileId, &[UnknownSuppressionKind]> = modules
105            .iter()
106            .filter(|m| !m.unknown_suppression_kinds.is_empty())
107            .map(|m| (m.file_id, m.unknown_suppression_kinds.as_slice()))
108            .collect();
109
110        Self {
111            by_file,
112            used,
113            unknown_kinds,
114        }
115    }
116
117    /// Build a suppression context from a pre-built map (for testing).
118    #[cfg(test)]
119    pub fn from_map(by_file: FxHashMap<FileId, &'a [Suppression]>) -> Self {
120        let used = by_file
121            .iter()
122            .map(|(&fid, supps)| {
123                (
124                    fid,
125                    std::iter::repeat_with(|| AtomicBool::new(false))
126                        .take(supps.len())
127                        .collect(),
128                )
129            })
130            .collect();
131        Self {
132            by_file,
133            used,
134            unknown_kinds: FxHashMap::default(),
135        }
136    }
137
138    /// Build an empty suppression context (for testing).
139    #[cfg(test)]
140    pub fn empty() -> Self {
141        Self {
142            by_file: FxHashMap::default(),
143            used: FxHashMap::default(),
144            unknown_kinds: FxHashMap::default(),
145        }
146    }
147
148    /// Check if a specific issue at a given line should be suppressed,
149    /// and mark the matching suppression as consumed.
150    #[must_use]
151    pub fn is_suppressed(&self, file_id: FileId, line: u32, kind: IssueKind) -> bool {
152        let Some(supps) = self.by_file.get(&file_id) else {
153            return false;
154        };
155        let Some(used) = self.used.get(&file_id) else {
156            return false;
157        };
158        for (i, s) in supps.iter().enumerate() {
159            if s.matches_issue_kind(line, kind) {
160                used[i].store(true, Ordering::Relaxed);
161                return true;
162            }
163        }
164        false
165    }
166
167    /// Check if the entire file is suppressed for the given kind,
168    /// and mark the matching suppression as consumed.
169    #[must_use]
170    pub fn is_file_suppressed(&self, file_id: FileId, kind: IssueKind) -> bool {
171        let Some(supps) = self.by_file.get(&file_id) else {
172            return false;
173        };
174        let Some(used) = self.used.get(&file_id) else {
175            return false;
176        };
177        for (i, s) in supps.iter().enumerate() {
178            if s.line == 0 && s.matches_issue_kind(0, kind) {
179                used[i].store(true, Ordering::Relaxed);
180                return true;
181            }
182        }
183        false
184    }
185
186    /// Check if a policy finding at a given line should be suppressed.
187    #[must_use]
188    pub fn is_policy_suppressed(
189        &self,
190        file_id: FileId,
191        line: u32,
192        pack: &str,
193        rule_id: &str,
194    ) -> bool {
195        let Some(supps) = self.by_file.get(&file_id) else {
196            return false;
197        };
198        let Some(used) = self.used.get(&file_id) else {
199            return false;
200        };
201        for (i, s) in supps.iter().enumerate() {
202            if s.matches_policy_rule(line, pack, rule_id) {
203                used[i].store(true, Ordering::Relaxed);
204                return true;
205            }
206        }
207        false
208    }
209
210    /// Get the raw suppressions for a file (for detectors that need direct access).
211    pub fn get(&self, file_id: FileId) -> Option<&[Suppression]> {
212        self.by_file.get(&file_id).copied()
213    }
214
215    /// Count suppression entries that matched at least one issue.
216    #[must_use]
217    pub fn used_count(&self) -> usize {
218        self.used
219            .values()
220            .flat_map(|used| used.iter())
221            .filter(|used| used.load(Ordering::Relaxed))
222            .count()
223    }
224
225    /// Collect all suppressions that were never consumed by any detector.
226    ///
227    /// Skips suppression kinds that are checked in the CLI layer
228    /// (complexity, coverage gaps, feature flags, code duplication)
229    /// to avoid false positives. Also skips suppressions whose target kind
230    /// is disabled (`Severity::Off`) under the resolved rules for the
231    /// suppression's file, including per-file `overrides.rules`: the
232    /// detector never ran, so the suppression appears unconsumed, but is
233    /// not actually stale (it documents intentional dormancy and becomes
234    /// valid again the moment the rule is re-enabled). See issue #482.
235    pub fn find_stale(
236        &self,
237        graph: &ModuleGraph,
238        config: &ResolvedConfig,
239    ) -> Vec<StaleSuppression> {
240        let mut stale = Vec::new();
241        let mut warned_unknown_policy_targets: FxHashSet<(String, String)> = FxHashSet::default();
242
243        for (&file_id, supps) in &self.by_file {
244            let used = &self.used[&file_id];
245            let path = &graph.modules[file_id.0 as usize].path;
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                if let Some(entry) = stale_entry_for_suppression(
253                    config,
254                    &file_rules,
255                    path,
256                    s,
257                    &mut warned_unknown_policy_targets,
258                ) {
259                    stale.push(entry);
260                }
261            }
262        }
263
264        for (&file_id, unknowns) in &self.unknown_kinds {
265            let path = &graph.modules[file_id.0 as usize].path;
266            for u in *unknowns {
267                stale.push(unknown_kind_stale_entry(path, u, false));
268            }
269        }
270
271        stale
272    }
273
274    /// Collect suppression comments that are missing `-- <reason>`.
275    #[must_use]
276    pub fn find_missing_reasons(&self, graph: &ModuleGraph) -> Vec<StaleSuppression> {
277        let mut findings = Vec::new();
278        let mut seen: FxHashSet<(FileId, u32)> = FxHashSet::default();
279
280        for (&file_id, supps) in &self.by_file {
281            let path = &graph.modules[file_id.0 as usize].path;
282            for s in *supps {
283                if s.reason.is_some() || !seen.insert((file_id, s.comment_line)) {
284                    continue;
285                }
286
287                findings.push(StaleSuppression {
288                    path: path.clone(),
289                    line: s.comment_line,
290                    col: 0,
291                    origin: SuppressionOrigin::Comment {
292                        issue_kind: s.target_token(),
293                        reason: None,
294                        is_file_level: s.line == 0,
295                        kind_known: true,
296                    },
297                    missing_reason: true,
298                    actions: StaleSuppression::actions_for(true),
299                });
300            }
301        }
302
303        for (&file_id, unknowns) in &self.unknown_kinds {
304            let path = &graph.modules[file_id.0 as usize].path;
305            for u in *unknowns {
306                if u.reason.is_some() || !seen.insert((file_id, u.comment_line)) {
307                    continue;
308                }
309
310                findings.push(StaleSuppression {
311                    path: path.clone(),
312                    line: u.comment_line,
313                    col: 0,
314                    origin: SuppressionOrigin::Comment {
315                        issue_kind: Some(u.token.clone()),
316                        reason: None,
317                        is_file_level: u.is_file_level,
318                        kind_known: false,
319                    },
320                    missing_reason: true,
321                    actions: StaleSuppression::actions_for(true),
322                });
323            }
324        }
325
326        findings
327    }
328
329    /// Collect every suppression comment present in the analyzed files this run,
330    /// keyed by file path and kind.
331    ///
332    /// This is the "active-suppression state" the Fallow Impact value report
333    /// needs (issue: v1.5 attribution): to tell a genuinely resolved finding
334    /// (code removed) from one merely silenced by a newly-added `fallow-ignore`,
335    /// impact records which suppressions are in play each run and looks for ones
336    /// that newly appeared covering a disappeared finding's kind.
337    ///
338    /// Unlike [`Self::find_stale`], this returns ALL present suppressions
339    /// regardless of whether a core detector consumed them, and across every
340    /// kind (dead-code, complexity, code-duplication, ...). Impact only needs to
341    /// know a suppression for `(file, kind)` exists; a present-but-stale entry is
342    /// harmless because impact's discriminator keys on a suppression that newly
343    /// appeared between two recorded runs, and a finding silenced by a present
344    /// suppression was never reported (so it never enters the resolved tally).
345    /// Complexity and code-duplication suppressions are consumed in the CLI
346    /// layer rather than through this context, so capturing presence here is the
347    /// single uniform mechanism that covers all three impact categories.
348    #[must_use]
349    pub fn all_suppressions(&self, graph: &ModuleGraph) -> Vec<ActiveSuppression> {
350        let mut active = Vec::new();
351        for (&file_id, supps) in &self.by_file {
352            let path = &graph.modules[file_id.0 as usize].path;
353            for s in *supps {
354                active.push(ActiveSuppression {
355                    path: path.clone(),
356                    kind: s.target_token(),
357                    is_file_level: s.line == 0,
358                    reason: s.reason.clone(),
359                    comment_line: s.comment_line,
360                });
361            }
362        }
363        active
364    }
365}
366
367/// Build the [`StaleSuppression`] entry for an unconsumed suppression, or `None`
368/// if the suppression is exempt (non-core kind, disabled rule, disabled policy).
369///
370/// Records unknown policy-rule targets in `warned` and emits a one-time warning.
371fn stale_entry_for_suppression(
372    config: &ResolvedConfig,
373    file_rules: &RulesConfig,
374    path: &std::path::Path,
375    s: &Suppression,
376    warned: &mut FxHashSet<(String, String)>,
377) -> Option<StaleSuppression> {
378    if let Some(kind) = s.issue_kind_target()
379        && NON_CORE_KINDS.contains(&kind)
380    {
381        return None;
382    }
383
384    if let Some(kind) = s.issue_kind_target()
385        && severity_for_kind(file_rules, kind) == Severity::Off
386    {
387        return None;
388    }
389
390    if let Some(target) = s.policy_rule_target() {
391        if file_rules.policy_violation == Severity::Off || policy_rule_is_disabled(config, target) {
392            return None;
393        }
394
395        if !policy_rule_exists(config, target) {
396            let token = target.token();
397            let key = (path.to_string_lossy().to_string(), token.clone());
398            if warned.insert(key) {
399                tracing::warn!(
400                    "{}:{}: suppression '{}' names no loaded rule-pack rule",
401                    path.display(),
402                    s.comment_line,
403                    token
404                );
405            }
406        }
407    }
408
409    Some(StaleSuppression {
410        path: path.to_path_buf(),
411        line: s.comment_line,
412        col: 0,
413        origin: SuppressionOrigin::Comment {
414            issue_kind: s.target_token(),
415            reason: s.reason.clone(),
416            is_file_level: s.line == 0,
417            kind_known: true,
418        },
419        missing_reason: false,
420        actions: StaleSuppression::actions_for(false),
421    })
422}
423
424/// Build a [`StaleSuppression`] entry for a suppression token that parsed to no
425/// known [`IssueKind`]. `missing_reason` controls the corresponding flags.
426fn unknown_kind_stale_entry(
427    path: &std::path::Path,
428    u: &UnknownSuppressionKind,
429    missing_reason: bool,
430) -> StaleSuppression {
431    StaleSuppression {
432        path: path.to_path_buf(),
433        line: u.comment_line,
434        col: 0,
435        origin: SuppressionOrigin::Comment {
436            issue_kind: Some(u.token.clone()),
437            reason: if missing_reason {
438                None
439            } else {
440                u.reason.clone()
441            },
442            is_file_level: u.is_file_level,
443            kind_known: false,
444        },
445        missing_reason,
446        actions: StaleSuppression::actions_for(missing_reason),
447    }
448}
449
450fn policy_rule_exists(config: &ResolvedConfig, target: &PolicyRuleSuppression) -> bool {
451    config.rule_packs.iter().any(|pack| {
452        pack.name == target.pack && pack.rules.iter().any(|rule| rule.id == target.rule_id)
453    })
454}
455
456fn policy_rule_is_disabled(config: &ResolvedConfig, target: &PolicyRuleSuppression) -> bool {
457    config.rule_packs.iter().any(|pack| {
458        pack.name == target.pack
459            && pack
460                .rules
461                .iter()
462                .any(|rule| rule.id == target.rule_id && rule.severity == Some(Severity::Off))
463    })
464}
465
466#[cfg(test)]
467mod tests {
468    use super::*;
469
470    #[test]
471    fn severity_for_kind_maps_every_core_kind_to_its_field() {
472        let rules = RulesConfig {
473            unused_exports: Severity::Warn,
474            unused_types: Severity::Off,
475            unresolved_imports: Severity::Error,
476            boundary_violation: Severity::Off,
477            ..RulesConfig::default()
478        };
479
480        assert_eq!(
481            severity_for_kind(&rules, IssueKind::UnusedExport),
482            Severity::Warn
483        );
484        assert_eq!(
485            severity_for_kind(&rules, IssueKind::UnusedType),
486            Severity::Off
487        );
488        assert_eq!(
489            severity_for_kind(&rules, IssueKind::UnresolvedImport),
490            Severity::Error
491        );
492        assert_eq!(
493            severity_for_kind(&rules, IssueKind::BoundaryViolation),
494            Severity::Off
495        );
496        assert_eq!(
497            severity_for_kind(&rules, IssueKind::PrivateTypeLeak),
498            Severity::Off
499        );
500    }
501
502    #[test]
503    fn issue_kind_from_str_all_variants() {
504        assert_eq!(IssueKind::parse("unused-file"), Some(IssueKind::UnusedFile));
505        assert_eq!(
506            IssueKind::parse("unused-export"),
507            Some(IssueKind::UnusedExport)
508        );
509        assert_eq!(IssueKind::parse("unused-type"), Some(IssueKind::UnusedType));
510        assert_eq!(
511            IssueKind::parse("unused-dependency"),
512            Some(IssueKind::UnusedDependency)
513        );
514        assert_eq!(
515            IssueKind::parse("unused-dev-dependency"),
516            Some(IssueKind::UnusedDevDependency)
517        );
518        assert_eq!(
519            IssueKind::parse("unused-enum-member"),
520            Some(IssueKind::UnusedEnumMember)
521        );
522        assert_eq!(
523            IssueKind::parse("unused-class-member"),
524            Some(IssueKind::UnusedClassMember)
525        );
526        assert_eq!(
527            IssueKind::parse("unresolved-import"),
528            Some(IssueKind::UnresolvedImport)
529        );
530        assert_eq!(
531            IssueKind::parse("unlisted-dependency"),
532            Some(IssueKind::UnlistedDependency)
533        );
534        assert_eq!(
535            IssueKind::parse("duplicate-export"),
536            Some(IssueKind::DuplicateExport)
537        );
538    }
539
540    #[test]
541    fn issue_kind_from_str_unknown() {
542        assert_eq!(IssueKind::parse("foo"), None);
543        assert_eq!(IssueKind::parse(""), None);
544    }
545
546    #[test]
547    fn discriminant_roundtrip() {
548        for kind in [
549            IssueKind::UnusedFile,
550            IssueKind::UnusedExport,
551            IssueKind::UnusedType,
552            IssueKind::PrivateTypeLeak,
553            IssueKind::UnusedDependency,
554            IssueKind::UnusedDevDependency,
555            IssueKind::UnusedEnumMember,
556            IssueKind::UnusedClassMember,
557            IssueKind::UnresolvedImport,
558            IssueKind::UnlistedDependency,
559            IssueKind::DuplicateExport,
560            IssueKind::CodeDuplication,
561            IssueKind::CircularDependency,
562            IssueKind::TestOnlyDependency,
563            IssueKind::BoundaryViolation,
564            IssueKind::CoverageGaps,
565            IssueKind::FeatureFlag,
566            IssueKind::Complexity,
567            IssueKind::StaleSuppression,
568            IssueKind::PnpmCatalogEntry,
569            IssueKind::EmptyCatalogGroup,
570            IssueKind::UnresolvedCatalogReference,
571            IssueKind::UnusedDependencyOverride,
572            IssueKind::MisconfiguredDependencyOverride,
573            IssueKind::ReExportCycle,
574            IssueKind::SecurityClientServerLeak,
575            IssueKind::SecuritySink,
576            IssueKind::PolicyViolation,
577            IssueKind::InvalidClientExport,
578            IssueKind::MixedClientServerBarrel,
579            IssueKind::MisplacedDirective,
580            IssueKind::UnusedStoreMember,
581            IssueKind::UnprovidedInject,
582            IssueKind::RouteCollision,
583            IssueKind::DynamicSegmentNameConflict,
584            IssueKind::UnrenderedComponent,
585            IssueKind::UnusedComponentProp,
586            IssueKind::UnusedComponentEmit,
587            IssueKind::UnusedServerAction,
588            IssueKind::UnusedLoadDataKey,
589            IssueKind::PropDrilling,
590            IssueKind::ThinWrapper,
591            IssueKind::DuplicatePropShape,
592            IssueKind::UnusedComponentInput,
593            IssueKind::UnusedComponentOutput,
594            IssueKind::UnusedSvelteEvent,
595            IssueKind::CssTokenDrift,
596            IssueKind::CssDuplicateBlock,
597            IssueKind::CssSelectorComplexity,
598            IssueKind::CssDeadSurface,
599            IssueKind::CssBrokenReference,
600            IssueKind::DevDependencyInProduction,
601        ] {
602            assert_eq!(
603                IssueKind::from_discriminant(kind.to_discriminant()),
604                Some(kind)
605            );
606        }
607        assert_eq!(IssueKind::from_discriminant(0), None);
608        let max_discriminant = IssueKind::ALL
609            .iter()
610            .map(|kind| kind.to_discriminant())
611            .max()
612            .unwrap();
613        assert_eq!(IssueKind::from_discriminant(max_discriminant + 1), None);
614    }
615
616    #[test]
617    fn parse_file_wide_suppression() {
618        let source = "// fallow-ignore-file\nexport const foo = 1;\n";
619        let suppressions = parse_suppressions_from_source(source).suppressions;
620        assert_eq!(suppressions.len(), 1);
621        assert_eq!(suppressions[0].line, 0);
622        assert!(suppressions[0].issue_kind_target().is_none());
623    }
624
625    #[test]
626    fn parse_file_wide_suppression_with_kind() {
627        let source = "// fallow-ignore-file unused-export\nexport const foo = 1;\n";
628        let suppressions = parse_suppressions_from_source(source).suppressions;
629        assert_eq!(suppressions.len(), 1);
630        assert_eq!(suppressions[0].line, 0);
631        assert_eq!(
632            suppressions[0].issue_kind_target(),
633            Some(IssueKind::UnusedExport)
634        );
635    }
636
637    #[test]
638    fn parse_next_line_suppression() {
639        let source =
640            "import { x } from './x';\n// fallow-ignore-next-line\nexport const foo = 1;\n";
641        let suppressions = parse_suppressions_from_source(source).suppressions;
642        assert_eq!(suppressions.len(), 1);
643        assert_eq!(suppressions[0].line, 3); // suppresses line 3 (the export)
644        assert!(suppressions[0].issue_kind_target().is_none());
645    }
646
647    #[test]
648    fn parse_next_line_suppression_with_kind() {
649        let source = "// fallow-ignore-next-line unused-export\nexport const foo = 1;\n";
650        let suppressions = parse_suppressions_from_source(source).suppressions;
651        assert_eq!(suppressions.len(), 1);
652        assert_eq!(suppressions[0].line, 2);
653        assert_eq!(
654            suppressions[0].issue_kind_target(),
655            Some(IssueKind::UnusedExport)
656        );
657    }
658
659    #[test]
660    fn parse_unknown_kind_surfaces_as_unknown() {
661        let source = "// fallow-ignore-next-line typo-kind\nexport const foo = 1;\n";
662        let parsed = parse_suppressions_from_source(source);
663        assert!(parsed.suppressions.is_empty());
664        assert_eq!(parsed.unknown_kinds.len(), 1);
665        assert_eq!(parsed.unknown_kinds[0].token, "typo-kind");
666    }
667
668    #[test]
669    fn is_suppressed_file_wide() {
670        let suppressions = vec![Suppression::all(0, 1)];
671        assert!(is_suppressed(&suppressions, 5, IssueKind::UnusedExport));
672        assert!(is_suppressed(&suppressions, 10, IssueKind::UnusedFile));
673    }
674
675    #[test]
676    fn is_suppressed_file_wide_specific_kind() {
677        let suppressions = vec![Suppression::issue(0, 1, IssueKind::UnusedExport)];
678        assert!(is_suppressed(&suppressions, 5, IssueKind::UnusedExport));
679        assert!(!is_suppressed(&suppressions, 5, IssueKind::UnusedType));
680    }
681
682    #[test]
683    fn is_suppressed_line_specific() {
684        let suppressions = vec![Suppression::all(5, 4)];
685        assert!(is_suppressed(&suppressions, 5, IssueKind::UnusedExport));
686        assert!(!is_suppressed(&suppressions, 6, IssueKind::UnusedExport));
687    }
688
689    #[test]
690    fn is_suppressed_line_and_kind() {
691        let suppressions = vec![Suppression::issue(5, 4, IssueKind::UnusedExport)];
692        assert!(is_suppressed(&suppressions, 5, IssueKind::UnusedExport));
693        assert!(!is_suppressed(&suppressions, 5, IssueKind::UnusedType));
694        assert!(!is_suppressed(&suppressions, 6, IssueKind::UnusedExport));
695    }
696
697    #[test]
698    fn is_suppressed_empty() {
699        assert!(!is_suppressed(&[], 5, IssueKind::UnusedExport));
700    }
701
702    #[test]
703    fn is_file_suppressed_works() {
704        let suppressions = vec![Suppression::all(0, 1)];
705        assert!(is_file_suppressed(&suppressions, IssueKind::UnusedFile));
706
707        let suppressions = vec![Suppression::issue(0, 1, IssueKind::UnusedFile)];
708        assert!(is_file_suppressed(&suppressions, IssueKind::UnusedFile));
709        assert!(!is_file_suppressed(&suppressions, IssueKind::UnusedExport));
710
711        let suppressions = vec![Suppression::all(5, 4)];
712        assert!(!is_file_suppressed(&suppressions, IssueKind::UnusedFile));
713    }
714
715    #[test]
716    fn parse_oxc_comments() {
717        use fallow_extract::suppress::parse_suppressions;
718        use oxc_allocator::Allocator;
719        use oxc_parser::Parser;
720        use oxc_span::SourceType;
721
722        let source = "// fallow-ignore-file\n// fallow-ignore-next-line unused-export\nexport const foo = 1;\nexport const bar = 2;\n";
723        let allocator = Allocator::default();
724        let parser_return = Parser::new(&allocator, source, SourceType::mjs()).parse();
725
726        let suppressions = parse_suppressions(&parser_return.program.comments, source).suppressions;
727        assert_eq!(suppressions.len(), 2);
728
729        assert_eq!(suppressions[0].line, 0);
730        assert!(suppressions[0].issue_kind_target().is_none());
731
732        assert_eq!(suppressions[1].line, 3); // suppresses line 3 (export const foo)
733        assert_eq!(
734            suppressions[1].issue_kind_target(),
735            Some(IssueKind::UnusedExport)
736        );
737    }
738
739    #[test]
740    fn parse_block_comment_suppression() {
741        let source = "/* fallow-ignore-file */\nexport const foo = 1;\n";
742        let suppressions = parse_suppressions_from_source(source).suppressions;
743        assert_eq!(suppressions.len(), 1);
744        assert_eq!(suppressions[0].line, 0);
745        assert!(suppressions[0].issue_kind_target().is_none());
746    }
747
748    #[test]
749    fn is_suppressed_multiple_suppressions_different_kinds() {
750        let suppressions = vec![
751            Suppression::issue(5, 4, IssueKind::UnusedExport),
752            Suppression::issue(5, 4, IssueKind::UnusedType),
753        ];
754        assert!(is_suppressed(&suppressions, 5, IssueKind::UnusedExport));
755        assert!(is_suppressed(&suppressions, 5, IssueKind::UnusedType));
756        assert!(!is_suppressed(&suppressions, 5, IssueKind::UnusedFile));
757    }
758
759    #[test]
760    fn is_suppressed_file_wide_blanket_and_specific_coexist() {
761        let suppressions = vec![
762            Suppression::issue(0, 1, IssueKind::UnusedExport),
763            Suppression::all(5, 4),
764        ];
765        assert!(is_suppressed(&suppressions, 10, IssueKind::UnusedExport));
766        assert!(!is_suppressed(&suppressions, 10, IssueKind::UnusedType));
767
768        assert!(is_suppressed(&suppressions, 5, IssueKind::UnusedType));
769        assert!(is_suppressed(&suppressions, 5, IssueKind::UnusedExport));
770    }
771
772    #[test]
773    fn is_file_suppressed_blanket_suppresses_all_kinds() {
774        let suppressions = vec![Suppression::all(0, 1)];
775        assert!(is_file_suppressed(&suppressions, IssueKind::UnusedFile));
776        assert!(is_file_suppressed(&suppressions, IssueKind::UnusedExport));
777        assert!(is_file_suppressed(&suppressions, IssueKind::UnusedType));
778        assert!(is_file_suppressed(
779            &suppressions,
780            IssueKind::CircularDependency
781        ));
782        assert!(is_file_suppressed(
783            &suppressions,
784            IssueKind::CodeDuplication
785        ));
786    }
787
788    #[test]
789    fn is_file_suppressed_empty_list() {
790        assert!(!is_file_suppressed(&[], IssueKind::UnusedFile));
791    }
792
793    #[test]
794    fn scoped_policy_suppression_does_not_match_generic_policy_kind() {
795        let suppressions = vec![Suppression::policy_rule(5, 4, "team-policy", "no-fs")];
796        assert!(!is_suppressed(&suppressions, 5, IssueKind::PolicyViolation));
797    }
798
799    #[test]
800    fn parse_multiple_next_line_suppressions() {
801        let source = "// fallow-ignore-next-line unused-export\nexport const foo = 1;\n// fallow-ignore-next-line unused-type\nexport type Bar = string;\n";
802        let suppressions = parse_suppressions_from_source(source).suppressions;
803        assert_eq!(suppressions.len(), 2);
804        assert_eq!(suppressions[0].line, 2);
805        assert_eq!(
806            suppressions[0].issue_kind_target(),
807            Some(IssueKind::UnusedExport)
808        );
809        assert_eq!(suppressions[1].line, 4);
810        assert_eq!(
811            suppressions[1].issue_kind_target(),
812            Some(IssueKind::UnusedType)
813        );
814    }
815
816    #[test]
817    fn parse_code_duplication_suppression() {
818        let source = "// fallow-ignore-file code-duplication\nexport const foo = 1;\n";
819        let suppressions = parse_suppressions_from_source(source).suppressions;
820        assert_eq!(suppressions.len(), 1);
821        assert_eq!(suppressions[0].line, 0);
822        assert_eq!(
823            suppressions[0].issue_kind_target(),
824            Some(IssueKind::CodeDuplication)
825        );
826    }
827
828    #[test]
829    fn parse_circular_dependency_suppression() {
830        let source = "// fallow-ignore-file circular-dependency\nimport { x } from './x';\n";
831        let suppressions = parse_suppressions_from_source(source).suppressions;
832        assert_eq!(suppressions.len(), 1);
833        assert_eq!(suppressions[0].line, 0);
834        assert_eq!(
835            suppressions[0].issue_kind_target(),
836            Some(IssueKind::CircularDependency)
837        );
838    }
839
840    /// Every `IssueKind` must be explicitly placed in either `NON_CORE_KINDS`
841    /// (not checked by core detectors) or handled by a core detector that
842    /// calls `SuppressionContext::is_suppressed` / `is_file_suppressed`.
843    /// This test fails when a new `IssueKind` variant is added without
844    /// being classified, preventing silent false-positive stale reports.
845    #[test]
846    fn all_issue_kinds_classified_for_stale_detection() {
847        let core_kinds = [
848            IssueKind::UnusedFile,
849            IssueKind::UnusedExport,
850            IssueKind::UnusedType,
851            IssueKind::UnusedEnumMember,
852            IssueKind::UnusedClassMember,
853            IssueKind::UnusedStoreMember,
854            IssueKind::UnprovidedInject,
855            IssueKind::UnresolvedImport,
856            IssueKind::DuplicateExport,
857            IssueKind::CircularDependency,
858            IssueKind::BoundaryViolation,
859            IssueKind::InvalidClientExport,
860            IssueKind::RouteCollision,
861            IssueKind::DynamicSegmentNameConflict,
862            IssueKind::UnrenderedComponent,
863            IssueKind::UnusedServerAction,
864            IssueKind::UnusedLoadDataKey,
865        ];
866
867        let all_kinds = [
868            IssueKind::UnusedFile,
869            IssueKind::UnusedExport,
870            IssueKind::UnusedType,
871            IssueKind::UnusedDependency,
872            IssueKind::UnusedDevDependency,
873            IssueKind::UnusedEnumMember,
874            IssueKind::UnusedClassMember,
875            IssueKind::UnusedStoreMember,
876            IssueKind::UnprovidedInject,
877            IssueKind::UnresolvedImport,
878            IssueKind::UnlistedDependency,
879            IssueKind::DuplicateExport,
880            IssueKind::CodeDuplication,
881            IssueKind::CircularDependency,
882            IssueKind::TypeOnlyDependency,
883            IssueKind::TestOnlyDependency,
884            IssueKind::BoundaryViolation,
885            IssueKind::CoverageGaps,
886            IssueKind::FeatureFlag,
887            IssueKind::Complexity,
888            IssueKind::StaleSuppression,
889            IssueKind::PnpmCatalogEntry,
890            IssueKind::EmptyCatalogGroup,
891            IssueKind::UnresolvedCatalogReference,
892            IssueKind::UnusedDependencyOverride,
893            IssueKind::MisconfiguredDependencyOverride,
894            IssueKind::InvalidClientExport,
895            IssueKind::RouteCollision,
896            IssueKind::DynamicSegmentNameConflict,
897            IssueKind::UnrenderedComponent,
898            IssueKind::UnusedServerAction,
899            IssueKind::UnusedLoadDataKey,
900        ];
901
902        for kind in all_kinds {
903            let in_core = core_kinds.contains(&kind);
904            let in_non_core = NON_CORE_KINDS.contains(&kind);
905            assert!(
906                in_core || in_non_core,
907                "IssueKind::{kind:?} is not classified in either core_kinds or NON_CORE_KINDS. \
908                 Add it to NON_CORE_KINDS if it is checked outside find_dead_code_full, \
909                 or to core_kinds in this test if a core detector checks it."
910            );
911            assert!(
912                !(in_core && in_non_core),
913                "IssueKind::{kind:?} is in BOTH core_kinds and NON_CORE_KINDS. Pick one."
914            );
915        }
916    }
917}