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