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