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    /// Collect all suppressions that were never consumed by any detector.
151    ///
152    /// Skips suppression kinds that are checked in the CLI layer
153    /// (complexity, coverage gaps, feature flags, code duplication)
154    /// to avoid false positives.
155    pub fn find_stale(&self, graph: &ModuleGraph) -> Vec<StaleSuppression> {
156        let mut stale = Vec::new();
157
158        for (&file_id, supps) in &self.by_file {
159            let used = &self.used[&file_id];
160            let path = &graph.modules[file_id.0 as usize].path;
161
162            for (i, s) in supps.iter().enumerate() {
163                if used[i].load(Ordering::Relaxed) {
164                    continue;
165                }
166
167                // Skip suppression kinds that are only checked in the CLI layer.
168                // These were never presented to the core detectors, so they
169                // appear unconsumed, but are not actually stale.
170                if let Some(kind) = s.kind
171                    && NON_CORE_KINDS.contains(&kind)
172                {
173                    continue;
174                }
175
176                let is_file_level = s.line == 0;
177                let issue_kind_str = s.kind.map(|k| {
178                    // Convert back to the kebab-case string for output
179                    match k {
180                        IssueKind::UnusedFile => "unused-file",
181                        IssueKind::UnusedExport => "unused-export",
182                        IssueKind::UnusedType => "unused-type",
183                        IssueKind::UnusedDependency => "unused-dependency",
184                        IssueKind::UnusedDevDependency => "unused-dev-dependency",
185                        IssueKind::UnusedEnumMember => "unused-enum-member",
186                        IssueKind::UnusedClassMember => "unused-class-member",
187                        IssueKind::UnresolvedImport => "unresolved-import",
188                        IssueKind::UnlistedDependency => "unlisted-dependency",
189                        IssueKind::DuplicateExport => "duplicate-export",
190                        IssueKind::CodeDuplication => "code-duplication",
191                        IssueKind::CircularDependency => "circular-dependency",
192                        IssueKind::TypeOnlyDependency => "type-only-dependency",
193                        IssueKind::TestOnlyDependency => "test-only-dependency",
194                        IssueKind::BoundaryViolation => "boundary-violation",
195                        IssueKind::CoverageGaps => "coverage-gaps",
196                        IssueKind::FeatureFlag => "feature-flag",
197                        IssueKind::Complexity => "complexity",
198                        IssueKind::StaleSuppression => "stale-suppression",
199                    }
200                    .to_string()
201                });
202
203                stale.push(StaleSuppression {
204                    path: path.clone(),
205                    line: s.comment_line,
206                    col: 0,
207                    origin: SuppressionOrigin::Comment {
208                        issue_kind: issue_kind_str,
209                        is_file_level,
210                    },
211                });
212            }
213        }
214
215        stale
216    }
217}
218
219/// Check if a specific issue at a given line should be suppressed.
220///
221/// Standalone predicate for callers outside `find_dead_code_full`
222/// (e.g., CLI health/flags commands) that don't need tracking.
223#[must_use]
224pub fn is_suppressed(suppressions: &[Suppression], line: u32, kind: IssueKind) -> bool {
225    suppressions.iter().any(|s| {
226        // File-wide suppression
227        if s.line == 0 {
228            return s.kind.is_none() || s.kind == Some(kind);
229        }
230        // Line-specific suppression
231        s.line == line && (s.kind.is_none() || s.kind == Some(kind))
232    })
233}
234
235/// Check if the entire file is suppressed (for issue types that don't have line numbers).
236///
237/// Standalone predicate for callers outside `find_dead_code_full`.
238#[must_use]
239pub fn is_file_suppressed(suppressions: &[Suppression], kind: IssueKind) -> bool {
240    suppressions
241        .iter()
242        .any(|s| s.line == 0 && (s.kind.is_none() || s.kind == Some(kind)))
243}
244
245#[cfg(test)]
246mod tests {
247    use super::*;
248
249    #[test]
250    fn issue_kind_from_str_all_variants() {
251        assert_eq!(IssueKind::parse("unused-file"), Some(IssueKind::UnusedFile));
252        assert_eq!(
253            IssueKind::parse("unused-export"),
254            Some(IssueKind::UnusedExport)
255        );
256        assert_eq!(IssueKind::parse("unused-type"), Some(IssueKind::UnusedType));
257        assert_eq!(
258            IssueKind::parse("unused-dependency"),
259            Some(IssueKind::UnusedDependency)
260        );
261        assert_eq!(
262            IssueKind::parse("unused-dev-dependency"),
263            Some(IssueKind::UnusedDevDependency)
264        );
265        assert_eq!(
266            IssueKind::parse("unused-enum-member"),
267            Some(IssueKind::UnusedEnumMember)
268        );
269        assert_eq!(
270            IssueKind::parse("unused-class-member"),
271            Some(IssueKind::UnusedClassMember)
272        );
273        assert_eq!(
274            IssueKind::parse("unresolved-import"),
275            Some(IssueKind::UnresolvedImport)
276        );
277        assert_eq!(
278            IssueKind::parse("unlisted-dependency"),
279            Some(IssueKind::UnlistedDependency)
280        );
281        assert_eq!(
282            IssueKind::parse("duplicate-export"),
283            Some(IssueKind::DuplicateExport)
284        );
285    }
286
287    #[test]
288    fn issue_kind_from_str_unknown() {
289        assert_eq!(IssueKind::parse("foo"), None);
290        assert_eq!(IssueKind::parse(""), None);
291    }
292
293    #[test]
294    fn discriminant_roundtrip() {
295        for kind in [
296            IssueKind::UnusedFile,
297            IssueKind::UnusedExport,
298            IssueKind::UnusedType,
299            IssueKind::UnusedDependency,
300            IssueKind::UnusedDevDependency,
301            IssueKind::UnusedEnumMember,
302            IssueKind::UnusedClassMember,
303            IssueKind::UnresolvedImport,
304            IssueKind::UnlistedDependency,
305            IssueKind::DuplicateExport,
306            IssueKind::CodeDuplication,
307            IssueKind::CircularDependency,
308            IssueKind::TestOnlyDependency,
309            IssueKind::BoundaryViolation,
310            IssueKind::CoverageGaps,
311            IssueKind::FeatureFlag,
312            IssueKind::Complexity,
313            IssueKind::StaleSuppression,
314        ] {
315            assert_eq!(
316                IssueKind::from_discriminant(kind.to_discriminant()),
317                Some(kind)
318            );
319        }
320        assert_eq!(IssueKind::from_discriminant(0), None);
321        assert_eq!(IssueKind::from_discriminant(20), None);
322    }
323
324    #[test]
325    fn parse_file_wide_suppression() {
326        let source = "// fallow-ignore-file\nexport const foo = 1;\n";
327        let suppressions = parse_suppressions_from_source(source);
328        assert_eq!(suppressions.len(), 1);
329        assert_eq!(suppressions[0].line, 0);
330        assert!(suppressions[0].kind.is_none());
331    }
332
333    #[test]
334    fn parse_file_wide_suppression_with_kind() {
335        let source = "// fallow-ignore-file unused-export\nexport const foo = 1;\n";
336        let suppressions = parse_suppressions_from_source(source);
337        assert_eq!(suppressions.len(), 1);
338        assert_eq!(suppressions[0].line, 0);
339        assert_eq!(suppressions[0].kind, Some(IssueKind::UnusedExport));
340    }
341
342    #[test]
343    fn parse_next_line_suppression() {
344        let source =
345            "import { x } from './x';\n// fallow-ignore-next-line\nexport const foo = 1;\n";
346        let suppressions = parse_suppressions_from_source(source);
347        assert_eq!(suppressions.len(), 1);
348        assert_eq!(suppressions[0].line, 3); // suppresses line 3 (the export)
349        assert!(suppressions[0].kind.is_none());
350    }
351
352    #[test]
353    fn parse_next_line_suppression_with_kind() {
354        let source = "// fallow-ignore-next-line unused-export\nexport const foo = 1;\n";
355        let suppressions = parse_suppressions_from_source(source);
356        assert_eq!(suppressions.len(), 1);
357        assert_eq!(suppressions[0].line, 2);
358        assert_eq!(suppressions[0].kind, Some(IssueKind::UnusedExport));
359    }
360
361    #[test]
362    fn parse_unknown_kind_ignored() {
363        let source = "// fallow-ignore-next-line typo-kind\nexport const foo = 1;\n";
364        let suppressions = parse_suppressions_from_source(source);
365        assert!(suppressions.is_empty());
366    }
367
368    #[test]
369    fn is_suppressed_file_wide() {
370        let suppressions = vec![Suppression {
371            line: 0,
372            comment_line: 1,
373            kind: None,
374        }];
375        assert!(is_suppressed(&suppressions, 5, IssueKind::UnusedExport));
376        assert!(is_suppressed(&suppressions, 10, IssueKind::UnusedFile));
377    }
378
379    #[test]
380    fn is_suppressed_file_wide_specific_kind() {
381        let suppressions = vec![Suppression {
382            line: 0,
383            comment_line: 1,
384            kind: Some(IssueKind::UnusedExport),
385        }];
386        assert!(is_suppressed(&suppressions, 5, IssueKind::UnusedExport));
387        assert!(!is_suppressed(&suppressions, 5, IssueKind::UnusedType));
388    }
389
390    #[test]
391    fn is_suppressed_line_specific() {
392        let suppressions = vec![Suppression {
393            line: 5,
394            comment_line: 4,
395            kind: None,
396        }];
397        assert!(is_suppressed(&suppressions, 5, IssueKind::UnusedExport));
398        assert!(!is_suppressed(&suppressions, 6, IssueKind::UnusedExport));
399    }
400
401    #[test]
402    fn is_suppressed_line_and_kind() {
403        let suppressions = vec![Suppression {
404            line: 5,
405            comment_line: 4,
406            kind: Some(IssueKind::UnusedExport),
407        }];
408        assert!(is_suppressed(&suppressions, 5, IssueKind::UnusedExport));
409        assert!(!is_suppressed(&suppressions, 5, IssueKind::UnusedType));
410        assert!(!is_suppressed(&suppressions, 6, IssueKind::UnusedExport));
411    }
412
413    #[test]
414    fn is_suppressed_empty() {
415        assert!(!is_suppressed(&[], 5, IssueKind::UnusedExport));
416    }
417
418    #[test]
419    fn is_file_suppressed_works() {
420        let suppressions = vec![Suppression {
421            line: 0,
422            comment_line: 1,
423            kind: None,
424        }];
425        assert!(is_file_suppressed(&suppressions, IssueKind::UnusedFile));
426
427        let suppressions = vec![Suppression {
428            line: 0,
429            comment_line: 1,
430            kind: Some(IssueKind::UnusedFile),
431        }];
432        assert!(is_file_suppressed(&suppressions, IssueKind::UnusedFile));
433        assert!(!is_file_suppressed(&suppressions, IssueKind::UnusedExport));
434
435        // Line-specific suppression should not count as file-wide
436        let suppressions = vec![Suppression {
437            line: 5,
438            comment_line: 4,
439            kind: None,
440        }];
441        assert!(!is_file_suppressed(&suppressions, IssueKind::UnusedFile));
442    }
443
444    #[test]
445    fn parse_oxc_comments() {
446        use fallow_extract::suppress::parse_suppressions;
447        use oxc_allocator::Allocator;
448        use oxc_parser::Parser;
449        use oxc_span::SourceType;
450
451        let source = "// fallow-ignore-file\n// fallow-ignore-next-line unused-export\nexport const foo = 1;\nexport const bar = 2;\n";
452        let allocator = Allocator::default();
453        let parser_return = Parser::new(&allocator, source, SourceType::mjs()).parse();
454
455        let suppressions = parse_suppressions(&parser_return.program.comments, source);
456        assert_eq!(suppressions.len(), 2);
457
458        // File-wide suppression
459        assert_eq!(suppressions[0].line, 0);
460        assert!(suppressions[0].kind.is_none());
461
462        // Next-line suppression with kind
463        assert_eq!(suppressions[1].line, 3); // suppresses line 3 (export const foo)
464        assert_eq!(suppressions[1].kind, Some(IssueKind::UnusedExport));
465    }
466
467    #[test]
468    fn parse_block_comment_suppression() {
469        let source = "/* fallow-ignore-file */\nexport const foo = 1;\n";
470        let suppressions = parse_suppressions_from_source(source);
471        assert_eq!(suppressions.len(), 1);
472        assert_eq!(suppressions[0].line, 0);
473        assert!(suppressions[0].kind.is_none());
474    }
475
476    #[test]
477    fn is_suppressed_multiple_suppressions_different_kinds() {
478        let suppressions = vec![
479            Suppression {
480                line: 5,
481                comment_line: 4,
482                kind: Some(IssueKind::UnusedExport),
483            },
484            Suppression {
485                line: 5,
486                comment_line: 4,
487                kind: Some(IssueKind::UnusedType),
488            },
489        ];
490        assert!(is_suppressed(&suppressions, 5, IssueKind::UnusedExport));
491        assert!(is_suppressed(&suppressions, 5, IssueKind::UnusedType));
492        assert!(!is_suppressed(&suppressions, 5, IssueKind::UnusedFile));
493    }
494
495    #[test]
496    fn is_suppressed_file_wide_blanket_and_specific_coexist() {
497        let suppressions = vec![
498            Suppression {
499                line: 0,
500                comment_line: 1,
501                kind: Some(IssueKind::UnusedExport),
502            },
503            Suppression {
504                line: 5,
505                comment_line: 4,
506                kind: None, // blanket suppress on line 5
507            },
508        ];
509        // File-wide suppression only covers UnusedExport
510        assert!(is_suppressed(&suppressions, 10, IssueKind::UnusedExport));
511        assert!(!is_suppressed(&suppressions, 10, IssueKind::UnusedType));
512
513        // Line 5 blanket suppression covers everything on line 5
514        assert!(is_suppressed(&suppressions, 5, IssueKind::UnusedType));
515        assert!(is_suppressed(&suppressions, 5, IssueKind::UnusedExport));
516    }
517
518    #[test]
519    fn is_file_suppressed_blanket_suppresses_all_kinds() {
520        let suppressions = vec![Suppression {
521            line: 0,
522            comment_line: 1,
523            kind: None, // blanket file-wide
524        }];
525        assert!(is_file_suppressed(&suppressions, IssueKind::UnusedFile));
526        assert!(is_file_suppressed(&suppressions, IssueKind::UnusedExport));
527        assert!(is_file_suppressed(&suppressions, IssueKind::UnusedType));
528        assert!(is_file_suppressed(
529            &suppressions,
530            IssueKind::CircularDependency
531        ));
532        assert!(is_file_suppressed(
533            &suppressions,
534            IssueKind::CodeDuplication
535        ));
536    }
537
538    #[test]
539    fn is_file_suppressed_empty_list() {
540        assert!(!is_file_suppressed(&[], IssueKind::UnusedFile));
541    }
542
543    #[test]
544    fn parse_multiple_next_line_suppressions() {
545        let source = "// fallow-ignore-next-line unused-export\nexport const foo = 1;\n// fallow-ignore-next-line unused-type\nexport type Bar = string;\n";
546        let suppressions = parse_suppressions_from_source(source);
547        assert_eq!(suppressions.len(), 2);
548        assert_eq!(suppressions[0].line, 2);
549        assert_eq!(suppressions[0].kind, Some(IssueKind::UnusedExport));
550        assert_eq!(suppressions[1].line, 4);
551        assert_eq!(suppressions[1].kind, Some(IssueKind::UnusedType));
552    }
553
554    #[test]
555    fn parse_code_duplication_suppression() {
556        let source = "// fallow-ignore-file code-duplication\nexport const foo = 1;\n";
557        let suppressions = parse_suppressions_from_source(source);
558        assert_eq!(suppressions.len(), 1);
559        assert_eq!(suppressions[0].line, 0);
560        assert_eq!(suppressions[0].kind, Some(IssueKind::CodeDuplication));
561    }
562
563    #[test]
564    fn parse_circular_dependency_suppression() {
565        let source = "// fallow-ignore-file circular-dependency\nimport { x } from './x';\n";
566        let suppressions = parse_suppressions_from_source(source);
567        assert_eq!(suppressions.len(), 1);
568        assert_eq!(suppressions[0].line, 0);
569        assert_eq!(suppressions[0].kind, Some(IssueKind::CircularDependency));
570    }
571
572    /// Every `IssueKind` must be explicitly placed in either `NON_CORE_KINDS`
573    /// (not checked by core detectors) or handled by a core detector that
574    /// calls `SuppressionContext::is_suppressed` / `is_file_suppressed`.
575    /// This test fails when a new `IssueKind` variant is added without
576    /// being classified, preventing silent false-positive stale reports.
577    #[test]
578    fn all_issue_kinds_classified_for_stale_detection() {
579        // Kinds checked by core detectors via SuppressionContext
580        let core_kinds = [
581            IssueKind::UnusedFile,
582            IssueKind::UnusedExport,
583            IssueKind::UnusedType,
584            IssueKind::UnusedEnumMember,
585            IssueKind::UnusedClassMember,
586            IssueKind::UnresolvedImport,
587            IssueKind::DuplicateExport,
588            IssueKind::CircularDependency,
589            IssueKind::BoundaryViolation,
590        ];
591
592        // All variants that exist
593        let all_kinds = [
594            IssueKind::UnusedFile,
595            IssueKind::UnusedExport,
596            IssueKind::UnusedType,
597            IssueKind::UnusedDependency,
598            IssueKind::UnusedDevDependency,
599            IssueKind::UnusedEnumMember,
600            IssueKind::UnusedClassMember,
601            IssueKind::UnresolvedImport,
602            IssueKind::UnlistedDependency,
603            IssueKind::DuplicateExport,
604            IssueKind::CodeDuplication,
605            IssueKind::CircularDependency,
606            IssueKind::TypeOnlyDependency,
607            IssueKind::TestOnlyDependency,
608            IssueKind::BoundaryViolation,
609            IssueKind::CoverageGaps,
610            IssueKind::FeatureFlag,
611            IssueKind::Complexity,
612            IssueKind::StaleSuppression,
613        ];
614
615        for kind in all_kinds {
616            let in_core = core_kinds.contains(&kind);
617            let in_non_core = NON_CORE_KINDS.contains(&kind);
618            assert!(
619                in_core || in_non_core,
620                "IssueKind::{kind:?} is not classified in either core_kinds or NON_CORE_KINDS. \
621                 Add it to NON_CORE_KINDS if it is checked outside find_dead_code_full, \
622                 or to core_kinds in this test if a core detector checks it."
623            );
624            assert!(
625                !(in_core && in_non_core),
626                "IssueKind::{kind:?} is in BOTH core_kinds and NON_CORE_KINDS. Pick one."
627            );
628        }
629    }
630}