Skip to main content

fallow_core/
suppress.rs

1use std::sync::atomic::{AtomicBool, Ordering};
2
3use rustc_hash::FxHashMap;
4
5// Re-export types from fallow-types
6pub use fallow_types::suppress::{IssueKind, Suppression};
7
8// Re-export parsing functions from fallow-extract
9pub use fallow_extract::suppress::parse_suppressions_from_source;
10
11use crate::discover::FileId;
12use crate::extract::ModuleInfo;
13use crate::graph::ModuleGraph;
14use crate::results::{StaleSuppression, SuppressionOrigin};
15
16/// Issue kinds whose suppression is not checked via `SuppressionContext`
17/// in `find_dead_code_full`. Excludes CLI-side kinds (checked in health/flags
18/// commands) and dependency-level kinds (not file-scoped, suppression never
19/// consumed by core detectors). Without this exclusion, these suppressions
20/// would always appear stale since no core detector checks them.
21const NON_CORE_KINDS: &[IssueKind] = &[
22    // CLI-side: checked in health/flags/dupes commands, not in find_dead_code_full
23    IssueKind::Complexity,
24    IssueKind::CoverageGaps,
25    IssueKind::FeatureFlag,
26    IssueKind::CodeDuplication,
27    // Dep-level: not file-scoped, suppression path is via config ignoreDependencies
28    IssueKind::UnusedDependency,
29    IssueKind::UnusedDevDependency,
30    IssueKind::UnlistedDependency,
31    IssueKind::TypeOnlyDependency,
32    IssueKind::TestOnlyDependency,
33    IssueKind::PnpmCatalogEntry,
34    IssueKind::EmptyCatalogGroup,
35    IssueKind::UnresolvedCatalogReference,
36    IssueKind::UnusedDependencyOverride,
37    IssueKind::MisconfiguredDependencyOverride,
38    // Meta: stale-suppression itself is never consumed by any detector,
39    // so a `// fallow-ignore-next-line stale-suppression` comment would
40    // always appear stale. Exclude to prevent recursive confusion.
41    IssueKind::StaleSuppression,
42];
43
44/// Suppression context that tracks which suppressions are consumed by detectors.
45///
46/// Wraps the per-file suppression map and records, via `AtomicBool` flags,
47/// which suppression entries actually matched an issue during detection.
48/// After all detectors run, `find_stale()` returns unmatched suppressions.
49///
50/// Uses `AtomicBool` (not `Cell<bool>`) so the context can be shared
51/// across threads if detectors ever use `rayon` internally.
52pub struct SuppressionContext<'a> {
53    by_file: FxHashMap<FileId, &'a [Suppression]>,
54    used: FxHashMap<FileId, Vec<AtomicBool>>,
55}
56
57impl<'a> SuppressionContext<'a> {
58    /// Build a suppression context from parsed modules.
59    pub fn new(modules: &'a [ModuleInfo]) -> Self {
60        let by_file: FxHashMap<FileId, &[Suppression]> = modules
61            .iter()
62            .filter(|m| !m.suppressions.is_empty())
63            .map(|m| (m.file_id, m.suppressions.as_slice()))
64            .collect();
65
66        let used = by_file
67            .iter()
68            .map(|(&fid, supps)| {
69                (
70                    fid,
71                    std::iter::repeat_with(|| AtomicBool::new(false))
72                        .take(supps.len())
73                        .collect(),
74                )
75            })
76            .collect();
77
78        Self { by_file, used }
79    }
80
81    /// Build a suppression context from a pre-built map (for testing).
82    #[cfg(test)]
83    pub fn from_map(by_file: FxHashMap<FileId, &'a [Suppression]>) -> Self {
84        let used = by_file
85            .iter()
86            .map(|(&fid, supps)| {
87                (
88                    fid,
89                    std::iter::repeat_with(|| AtomicBool::new(false))
90                        .take(supps.len())
91                        .collect(),
92                )
93            })
94            .collect();
95        Self { by_file, used }
96    }
97
98    /// Build an empty suppression context (for testing).
99    #[cfg(test)]
100    pub fn empty() -> Self {
101        Self {
102            by_file: FxHashMap::default(),
103            used: FxHashMap::default(),
104        }
105    }
106
107    /// Check if a specific issue at a given line should be suppressed,
108    /// and mark the matching suppression as consumed.
109    #[must_use]
110    pub fn is_suppressed(&self, file_id: FileId, line: u32, kind: IssueKind) -> bool {
111        let Some(supps) = self.by_file.get(&file_id) else {
112            return false;
113        };
114        let Some(used) = self.used.get(&file_id) else {
115            return false;
116        };
117        for (i, s) in supps.iter().enumerate() {
118            let matched = if s.line == 0 {
119                s.kind.is_none() || s.kind == Some(kind)
120            } else {
121                s.line == line && (s.kind.is_none() || s.kind == Some(kind))
122            };
123            if matched {
124                used[i].store(true, Ordering::Relaxed);
125                return true;
126            }
127        }
128        false
129    }
130
131    /// Check if the entire file is suppressed for the given kind,
132    /// and mark the matching suppression as consumed.
133    #[must_use]
134    pub fn is_file_suppressed(&self, file_id: FileId, kind: IssueKind) -> bool {
135        let Some(supps) = self.by_file.get(&file_id) else {
136            return false;
137        };
138        let Some(used) = self.used.get(&file_id) else {
139            return false;
140        };
141        for (i, s) in supps.iter().enumerate() {
142            if s.line == 0 && (s.kind.is_none() || s.kind == Some(kind)) {
143                used[i].store(true, Ordering::Relaxed);
144                return true;
145            }
146        }
147        false
148    }
149
150    /// Get the raw suppressions for a file (for detectors that need direct access).
151    pub fn get(&self, file_id: FileId) -> Option<&[Suppression]> {
152        self.by_file.get(&file_id).copied()
153    }
154
155    /// Count suppression entries that matched at least one issue.
156    #[must_use]
157    pub fn used_count(&self) -> usize {
158        self.used
159            .values()
160            .flat_map(|used| used.iter())
161            .filter(|used| used.load(Ordering::Relaxed))
162            .count()
163    }
164
165    /// Collect all suppressions that were never consumed by any detector.
166    ///
167    /// Skips suppression kinds that are checked in the CLI layer
168    /// (complexity, coverage gaps, feature flags, code duplication)
169    /// to avoid false positives.
170    pub fn find_stale(&self, graph: &ModuleGraph) -> Vec<StaleSuppression> {
171        let mut stale = Vec::new();
172
173        for (&file_id, supps) in &self.by_file {
174            let used = &self.used[&file_id];
175            let path = &graph.modules[file_id.0 as usize].path;
176
177            for (i, s) in supps.iter().enumerate() {
178                if used[i].load(Ordering::Relaxed) {
179                    continue;
180                }
181
182                // Skip suppression kinds that are only checked in the CLI layer.
183                // These were never presented to the core detectors, so they
184                // appear unconsumed, but are not actually stale.
185                if let Some(kind) = s.kind
186                    && NON_CORE_KINDS.contains(&kind)
187                {
188                    continue;
189                }
190
191                let is_file_level = s.line == 0;
192                let issue_kind_str = s.kind.map(|k| {
193                    // Convert back to the kebab-case string for output
194                    match k {
195                        IssueKind::UnusedFile => "unused-file",
196                        IssueKind::UnusedExport => "unused-export",
197                        IssueKind::UnusedType => "unused-type",
198                        IssueKind::PrivateTypeLeak => "private-type-leak",
199                        IssueKind::UnusedDependency => "unused-dependency",
200                        IssueKind::UnusedDevDependency => "unused-dev-dependency",
201                        IssueKind::UnusedEnumMember => "unused-enum-member",
202                        IssueKind::UnusedClassMember => "unused-class-member",
203                        IssueKind::UnresolvedImport => "unresolved-import",
204                        IssueKind::UnlistedDependency => "unlisted-dependency",
205                        IssueKind::DuplicateExport => "duplicate-export",
206                        IssueKind::CodeDuplication => "code-duplication",
207                        IssueKind::CircularDependency => "circular-dependency",
208                        IssueKind::TypeOnlyDependency => "type-only-dependency",
209                        IssueKind::TestOnlyDependency => "test-only-dependency",
210                        IssueKind::BoundaryViolation => "boundary-violation",
211                        IssueKind::CoverageGaps => "coverage-gaps",
212                        IssueKind::FeatureFlag => "feature-flag",
213                        IssueKind::Complexity => "complexity",
214                        IssueKind::StaleSuppression => "stale-suppression",
215                        IssueKind::PnpmCatalogEntry => "unused-catalog-entry",
216                        IssueKind::EmptyCatalogGroup => "empty-catalog-group",
217                        IssueKind::UnresolvedCatalogReference => "unresolved-catalog-reference",
218                        IssueKind::UnusedDependencyOverride => "unused-dependency-override",
219                        IssueKind::MisconfiguredDependencyOverride => {
220                            "misconfigured-dependency-override"
221                        }
222                    }
223                    .to_string()
224                });
225
226                stale.push(StaleSuppression {
227                    path: path.clone(),
228                    line: s.comment_line,
229                    col: 0,
230                    origin: SuppressionOrigin::Comment {
231                        issue_kind: issue_kind_str,
232                        is_file_level,
233                    },
234                });
235            }
236        }
237
238        stale
239    }
240}
241
242/// Check if a specific issue at a given line should be suppressed.
243///
244/// Standalone predicate for callers outside `find_dead_code_full`
245/// (e.g., CLI health/flags commands) that don't need tracking.
246#[must_use]
247pub fn is_suppressed(suppressions: &[Suppression], line: u32, kind: IssueKind) -> bool {
248    suppressions.iter().any(|s| {
249        // File-wide suppression
250        if s.line == 0 {
251            return s.kind.is_none() || s.kind == Some(kind);
252        }
253        // Line-specific suppression
254        s.line == line && (s.kind.is_none() || s.kind == Some(kind))
255    })
256}
257
258/// Check if the entire file is suppressed (for issue types that don't have line numbers).
259///
260/// Standalone predicate for callers outside `find_dead_code_full`.
261#[must_use]
262pub fn is_file_suppressed(suppressions: &[Suppression], kind: IssueKind) -> bool {
263    suppressions
264        .iter()
265        .any(|s| s.line == 0 && (s.kind.is_none() || s.kind == Some(kind)))
266}
267
268#[cfg(test)]
269mod tests {
270    use super::*;
271
272    #[test]
273    fn issue_kind_from_str_all_variants() {
274        assert_eq!(IssueKind::parse("unused-file"), Some(IssueKind::UnusedFile));
275        assert_eq!(
276            IssueKind::parse("unused-export"),
277            Some(IssueKind::UnusedExport)
278        );
279        assert_eq!(IssueKind::parse("unused-type"), Some(IssueKind::UnusedType));
280        assert_eq!(
281            IssueKind::parse("unused-dependency"),
282            Some(IssueKind::UnusedDependency)
283        );
284        assert_eq!(
285            IssueKind::parse("unused-dev-dependency"),
286            Some(IssueKind::UnusedDevDependency)
287        );
288        assert_eq!(
289            IssueKind::parse("unused-enum-member"),
290            Some(IssueKind::UnusedEnumMember)
291        );
292        assert_eq!(
293            IssueKind::parse("unused-class-member"),
294            Some(IssueKind::UnusedClassMember)
295        );
296        assert_eq!(
297            IssueKind::parse("unresolved-import"),
298            Some(IssueKind::UnresolvedImport)
299        );
300        assert_eq!(
301            IssueKind::parse("unlisted-dependency"),
302            Some(IssueKind::UnlistedDependency)
303        );
304        assert_eq!(
305            IssueKind::parse("duplicate-export"),
306            Some(IssueKind::DuplicateExport)
307        );
308    }
309
310    #[test]
311    fn issue_kind_from_str_unknown() {
312        assert_eq!(IssueKind::parse("foo"), None);
313        assert_eq!(IssueKind::parse(""), None);
314    }
315
316    #[test]
317    fn discriminant_roundtrip() {
318        for kind in [
319            IssueKind::UnusedFile,
320            IssueKind::UnusedExport,
321            IssueKind::UnusedType,
322            IssueKind::PrivateTypeLeak,
323            IssueKind::UnusedDependency,
324            IssueKind::UnusedDevDependency,
325            IssueKind::UnusedEnumMember,
326            IssueKind::UnusedClassMember,
327            IssueKind::UnresolvedImport,
328            IssueKind::UnlistedDependency,
329            IssueKind::DuplicateExport,
330            IssueKind::CodeDuplication,
331            IssueKind::CircularDependency,
332            IssueKind::TestOnlyDependency,
333            IssueKind::BoundaryViolation,
334            IssueKind::CoverageGaps,
335            IssueKind::FeatureFlag,
336            IssueKind::Complexity,
337            IssueKind::StaleSuppression,
338            IssueKind::PnpmCatalogEntry,
339            IssueKind::EmptyCatalogGroup,
340            IssueKind::UnresolvedCatalogReference,
341            IssueKind::UnusedDependencyOverride,
342            IssueKind::MisconfiguredDependencyOverride,
343        ] {
344            assert_eq!(
345                IssueKind::from_discriminant(kind.to_discriminant()),
346                Some(kind)
347            );
348        }
349        assert_eq!(IssueKind::from_discriminant(0), None);
350        assert_eq!(IssueKind::from_discriminant(26), None);
351    }
352
353    #[test]
354    fn parse_file_wide_suppression() {
355        let source = "// fallow-ignore-file\nexport const foo = 1;\n";
356        let suppressions = parse_suppressions_from_source(source);
357        assert_eq!(suppressions.len(), 1);
358        assert_eq!(suppressions[0].line, 0);
359        assert!(suppressions[0].kind.is_none());
360    }
361
362    #[test]
363    fn parse_file_wide_suppression_with_kind() {
364        let source = "// fallow-ignore-file unused-export\nexport const foo = 1;\n";
365        let suppressions = parse_suppressions_from_source(source);
366        assert_eq!(suppressions.len(), 1);
367        assert_eq!(suppressions[0].line, 0);
368        assert_eq!(suppressions[0].kind, Some(IssueKind::UnusedExport));
369    }
370
371    #[test]
372    fn parse_next_line_suppression() {
373        let source =
374            "import { x } from './x';\n// fallow-ignore-next-line\nexport const foo = 1;\n";
375        let suppressions = parse_suppressions_from_source(source);
376        assert_eq!(suppressions.len(), 1);
377        assert_eq!(suppressions[0].line, 3); // suppresses line 3 (the export)
378        assert!(suppressions[0].kind.is_none());
379    }
380
381    #[test]
382    fn parse_next_line_suppression_with_kind() {
383        let source = "// fallow-ignore-next-line unused-export\nexport const foo = 1;\n";
384        let suppressions = parse_suppressions_from_source(source);
385        assert_eq!(suppressions.len(), 1);
386        assert_eq!(suppressions[0].line, 2);
387        assert_eq!(suppressions[0].kind, Some(IssueKind::UnusedExport));
388    }
389
390    #[test]
391    fn parse_unknown_kind_ignored() {
392        let source = "// fallow-ignore-next-line typo-kind\nexport const foo = 1;\n";
393        let suppressions = parse_suppressions_from_source(source);
394        assert!(suppressions.is_empty());
395    }
396
397    #[test]
398    fn is_suppressed_file_wide() {
399        let suppressions = vec![Suppression {
400            line: 0,
401            comment_line: 1,
402            kind: None,
403        }];
404        assert!(is_suppressed(&suppressions, 5, IssueKind::UnusedExport));
405        assert!(is_suppressed(&suppressions, 10, IssueKind::UnusedFile));
406    }
407
408    #[test]
409    fn is_suppressed_file_wide_specific_kind() {
410        let suppressions = vec![Suppression {
411            line: 0,
412            comment_line: 1,
413            kind: Some(IssueKind::UnusedExport),
414        }];
415        assert!(is_suppressed(&suppressions, 5, IssueKind::UnusedExport));
416        assert!(!is_suppressed(&suppressions, 5, IssueKind::UnusedType));
417    }
418
419    #[test]
420    fn is_suppressed_line_specific() {
421        let suppressions = vec![Suppression {
422            line: 5,
423            comment_line: 4,
424            kind: None,
425        }];
426        assert!(is_suppressed(&suppressions, 5, IssueKind::UnusedExport));
427        assert!(!is_suppressed(&suppressions, 6, IssueKind::UnusedExport));
428    }
429
430    #[test]
431    fn is_suppressed_line_and_kind() {
432        let suppressions = vec![Suppression {
433            line: 5,
434            comment_line: 4,
435            kind: Some(IssueKind::UnusedExport),
436        }];
437        assert!(is_suppressed(&suppressions, 5, IssueKind::UnusedExport));
438        assert!(!is_suppressed(&suppressions, 5, IssueKind::UnusedType));
439        assert!(!is_suppressed(&suppressions, 6, IssueKind::UnusedExport));
440    }
441
442    #[test]
443    fn is_suppressed_empty() {
444        assert!(!is_suppressed(&[], 5, IssueKind::UnusedExport));
445    }
446
447    #[test]
448    fn is_file_suppressed_works() {
449        let suppressions = vec![Suppression {
450            line: 0,
451            comment_line: 1,
452            kind: None,
453        }];
454        assert!(is_file_suppressed(&suppressions, IssueKind::UnusedFile));
455
456        let suppressions = vec![Suppression {
457            line: 0,
458            comment_line: 1,
459            kind: Some(IssueKind::UnusedFile),
460        }];
461        assert!(is_file_suppressed(&suppressions, IssueKind::UnusedFile));
462        assert!(!is_file_suppressed(&suppressions, IssueKind::UnusedExport));
463
464        // Line-specific suppression should not count as file-wide
465        let suppressions = vec![Suppression {
466            line: 5,
467            comment_line: 4,
468            kind: None,
469        }];
470        assert!(!is_file_suppressed(&suppressions, IssueKind::UnusedFile));
471    }
472
473    #[test]
474    fn parse_oxc_comments() {
475        use fallow_extract::suppress::parse_suppressions;
476        use oxc_allocator::Allocator;
477        use oxc_parser::Parser;
478        use oxc_span::SourceType;
479
480        let source = "// fallow-ignore-file\n// fallow-ignore-next-line unused-export\nexport const foo = 1;\nexport const bar = 2;\n";
481        let allocator = Allocator::default();
482        let parser_return = Parser::new(&allocator, source, SourceType::mjs()).parse();
483
484        let suppressions = parse_suppressions(&parser_return.program.comments, source);
485        assert_eq!(suppressions.len(), 2);
486
487        // File-wide suppression
488        assert_eq!(suppressions[0].line, 0);
489        assert!(suppressions[0].kind.is_none());
490
491        // Next-line suppression with kind
492        assert_eq!(suppressions[1].line, 3); // suppresses line 3 (export const foo)
493        assert_eq!(suppressions[1].kind, Some(IssueKind::UnusedExport));
494    }
495
496    #[test]
497    fn parse_block_comment_suppression() {
498        let source = "/* fallow-ignore-file */\nexport const foo = 1;\n";
499        let suppressions = parse_suppressions_from_source(source);
500        assert_eq!(suppressions.len(), 1);
501        assert_eq!(suppressions[0].line, 0);
502        assert!(suppressions[0].kind.is_none());
503    }
504
505    #[test]
506    fn is_suppressed_multiple_suppressions_different_kinds() {
507        let suppressions = vec![
508            Suppression {
509                line: 5,
510                comment_line: 4,
511                kind: Some(IssueKind::UnusedExport),
512            },
513            Suppression {
514                line: 5,
515                comment_line: 4,
516                kind: Some(IssueKind::UnusedType),
517            },
518        ];
519        assert!(is_suppressed(&suppressions, 5, IssueKind::UnusedExport));
520        assert!(is_suppressed(&suppressions, 5, IssueKind::UnusedType));
521        assert!(!is_suppressed(&suppressions, 5, IssueKind::UnusedFile));
522    }
523
524    #[test]
525    fn is_suppressed_file_wide_blanket_and_specific_coexist() {
526        let suppressions = vec![
527            Suppression {
528                line: 0,
529                comment_line: 1,
530                kind: Some(IssueKind::UnusedExport),
531            },
532            Suppression {
533                line: 5,
534                comment_line: 4,
535                kind: None, // blanket suppress on line 5
536            },
537        ];
538        // File-wide suppression only covers UnusedExport
539        assert!(is_suppressed(&suppressions, 10, IssueKind::UnusedExport));
540        assert!(!is_suppressed(&suppressions, 10, IssueKind::UnusedType));
541
542        // Line 5 blanket suppression covers everything on line 5
543        assert!(is_suppressed(&suppressions, 5, IssueKind::UnusedType));
544        assert!(is_suppressed(&suppressions, 5, IssueKind::UnusedExport));
545    }
546
547    #[test]
548    fn is_file_suppressed_blanket_suppresses_all_kinds() {
549        let suppressions = vec![Suppression {
550            line: 0,
551            comment_line: 1,
552            kind: None, // blanket file-wide
553        }];
554        assert!(is_file_suppressed(&suppressions, IssueKind::UnusedFile));
555        assert!(is_file_suppressed(&suppressions, IssueKind::UnusedExport));
556        assert!(is_file_suppressed(&suppressions, IssueKind::UnusedType));
557        assert!(is_file_suppressed(
558            &suppressions,
559            IssueKind::CircularDependency
560        ));
561        assert!(is_file_suppressed(
562            &suppressions,
563            IssueKind::CodeDuplication
564        ));
565    }
566
567    #[test]
568    fn is_file_suppressed_empty_list() {
569        assert!(!is_file_suppressed(&[], IssueKind::UnusedFile));
570    }
571
572    #[test]
573    fn parse_multiple_next_line_suppressions() {
574        let source = "// fallow-ignore-next-line unused-export\nexport const foo = 1;\n// fallow-ignore-next-line unused-type\nexport type Bar = string;\n";
575        let suppressions = parse_suppressions_from_source(source);
576        assert_eq!(suppressions.len(), 2);
577        assert_eq!(suppressions[0].line, 2);
578        assert_eq!(suppressions[0].kind, Some(IssueKind::UnusedExport));
579        assert_eq!(suppressions[1].line, 4);
580        assert_eq!(suppressions[1].kind, Some(IssueKind::UnusedType));
581    }
582
583    #[test]
584    fn parse_code_duplication_suppression() {
585        let source = "// fallow-ignore-file code-duplication\nexport const foo = 1;\n";
586        let suppressions = parse_suppressions_from_source(source);
587        assert_eq!(suppressions.len(), 1);
588        assert_eq!(suppressions[0].line, 0);
589        assert_eq!(suppressions[0].kind, Some(IssueKind::CodeDuplication));
590    }
591
592    #[test]
593    fn parse_circular_dependency_suppression() {
594        let source = "// fallow-ignore-file circular-dependency\nimport { x } from './x';\n";
595        let suppressions = parse_suppressions_from_source(source);
596        assert_eq!(suppressions.len(), 1);
597        assert_eq!(suppressions[0].line, 0);
598        assert_eq!(suppressions[0].kind, Some(IssueKind::CircularDependency));
599    }
600
601    /// Every `IssueKind` must be explicitly placed in either `NON_CORE_KINDS`
602    /// (not checked by core detectors) or handled by a core detector that
603    /// calls `SuppressionContext::is_suppressed` / `is_file_suppressed`.
604    /// This test fails when a new `IssueKind` variant is added without
605    /// being classified, preventing silent false-positive stale reports.
606    #[test]
607    fn all_issue_kinds_classified_for_stale_detection() {
608        // Kinds checked by core detectors via SuppressionContext
609        let core_kinds = [
610            IssueKind::UnusedFile,
611            IssueKind::UnusedExport,
612            IssueKind::UnusedType,
613            IssueKind::UnusedEnumMember,
614            IssueKind::UnusedClassMember,
615            IssueKind::UnresolvedImport,
616            IssueKind::DuplicateExport,
617            IssueKind::CircularDependency,
618            IssueKind::BoundaryViolation,
619        ];
620
621        // All variants that exist
622        let all_kinds = [
623            IssueKind::UnusedFile,
624            IssueKind::UnusedExport,
625            IssueKind::UnusedType,
626            IssueKind::UnusedDependency,
627            IssueKind::UnusedDevDependency,
628            IssueKind::UnusedEnumMember,
629            IssueKind::UnusedClassMember,
630            IssueKind::UnresolvedImport,
631            IssueKind::UnlistedDependency,
632            IssueKind::DuplicateExport,
633            IssueKind::CodeDuplication,
634            IssueKind::CircularDependency,
635            IssueKind::TypeOnlyDependency,
636            IssueKind::TestOnlyDependency,
637            IssueKind::BoundaryViolation,
638            IssueKind::CoverageGaps,
639            IssueKind::FeatureFlag,
640            IssueKind::Complexity,
641            IssueKind::StaleSuppression,
642            IssueKind::PnpmCatalogEntry,
643            IssueKind::EmptyCatalogGroup,
644            IssueKind::UnresolvedCatalogReference,
645            IssueKind::UnusedDependencyOverride,
646            IssueKind::MisconfiguredDependencyOverride,
647        ];
648
649        for kind in all_kinds {
650            let in_core = core_kinds.contains(&kind);
651            let in_non_core = NON_CORE_KINDS.contains(&kind);
652            assert!(
653                in_core || in_non_core,
654                "IssueKind::{kind:?} is not classified in either core_kinds or NON_CORE_KINDS. \
655                 Add it to NON_CORE_KINDS if it is checked outside find_dead_code_full, \
656                 or to core_kinds in this test if a core detector checks it."
657            );
658            assert!(
659                !(in_core && in_non_core),
660                "IssueKind::{kind:?} is in BOTH core_kinds and NON_CORE_KINDS. Pick one."
661            );
662        }
663    }
664}