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#[must_use]
23pub fn kind_to_kebab(kind: IssueKind) -> &'static str {
24 issue_kind_to_kebab(kind)
25}
26
27fn severity_for_kind(rules: &RulesConfig, kind: IssueKind) -> Severity {
34 rules.severity_for_kind(kind)
35}
36
37const NON_CORE_KINDS: &[IssueKind] = &[
43 IssueKind::Complexity,
44 IssueKind::CoverageGaps,
45 IssueKind::FeatureFlag,
46 IssueKind::CodeDuplication,
47 IssueKind::CssTokenDrift,
48 IssueKind::CssDuplicateBlock,
49 IssueKind::CssSelectorComplexity,
50 IssueKind::CssDeadSurface,
51 IssueKind::CssBrokenReference,
52 IssueKind::UnusedDependency,
53 IssueKind::UnusedDevDependency,
54 IssueKind::UnlistedDependency,
55 IssueKind::TypeOnlyDependency,
56 IssueKind::TestOnlyDependency,
57 IssueKind::DevDependencyInProduction,
58 IssueKind::PnpmCatalogEntry,
59 IssueKind::EmptyCatalogGroup,
60 IssueKind::UnresolvedCatalogReference,
61 IssueKind::UnusedDependencyOverride,
62 IssueKind::MisconfiguredDependencyOverride,
63 IssueKind::StaleSuppression,
64];
65
66pub struct SuppressionContext<'a> {
75 by_file: FxHashMap<FileId, &'a [Suppression]>,
76 used: FxHashMap<FileId, Vec<AtomicBool>>,
77 unknown_kinds: FxHashMap<FileId, &'a [UnknownSuppressionKind]>,
81}
82
83impl<'a> SuppressionContext<'a> {
84 pub fn new(modules: &'a [ModuleInfo]) -> Self {
86 let by_file: FxHashMap<FileId, &[Suppression]> = modules
87 .iter()
88 .filter(|m| !m.suppressions.is_empty())
89 .map(|m| (m.file_id, m.suppressions.as_slice()))
90 .collect();
91
92 let used = by_file
93 .iter()
94 .map(|(&fid, supps)| {
95 (
96 fid,
97 std::iter::repeat_with(|| AtomicBool::new(false))
98 .take(supps.len())
99 .collect(),
100 )
101 })
102 .collect();
103
104 let unknown_kinds: FxHashMap<FileId, &[UnknownSuppressionKind]> = modules
105 .iter()
106 .filter(|m| !m.unknown_suppression_kinds.is_empty())
107 .map(|m| (m.file_id, m.unknown_suppression_kinds.as_slice()))
108 .collect();
109
110 Self {
111 by_file,
112 used,
113 unknown_kinds,
114 }
115 }
116
117 #[cfg(test)]
119 pub fn from_map(by_file: FxHashMap<FileId, &'a [Suppression]>) -> Self {
120 let used = by_file
121 .iter()
122 .map(|(&fid, supps)| {
123 (
124 fid,
125 std::iter::repeat_with(|| AtomicBool::new(false))
126 .take(supps.len())
127 .collect(),
128 )
129 })
130 .collect();
131 Self {
132 by_file,
133 used,
134 unknown_kinds: FxHashMap::default(),
135 }
136 }
137
138 #[cfg(test)]
140 pub fn empty() -> Self {
141 Self {
142 by_file: FxHashMap::default(),
143 used: FxHashMap::default(),
144 unknown_kinds: FxHashMap::default(),
145 }
146 }
147
148 #[must_use]
151 pub fn is_suppressed(&self, file_id: FileId, line: u32, kind: IssueKind) -> bool {
152 let Some(supps) = self.by_file.get(&file_id) else {
153 return false;
154 };
155 let Some(used) = self.used.get(&file_id) else {
156 return false;
157 };
158 for (i, s) in supps.iter().enumerate() {
159 if s.matches_issue_kind(line, kind) {
160 used[i].store(true, Ordering::Relaxed);
161 return true;
162 }
163 }
164 false
165 }
166
167 #[must_use]
170 pub fn is_file_suppressed(&self, file_id: FileId, kind: IssueKind) -> bool {
171 let Some(supps) = self.by_file.get(&file_id) else {
172 return false;
173 };
174 let Some(used) = self.used.get(&file_id) else {
175 return false;
176 };
177 for (i, s) in supps.iter().enumerate() {
178 if s.line == 0 && s.matches_issue_kind(0, kind) {
179 used[i].store(true, Ordering::Relaxed);
180 return true;
181 }
182 }
183 false
184 }
185
186 #[must_use]
188 pub fn is_policy_suppressed(
189 &self,
190 file_id: FileId,
191 line: u32,
192 pack: &str,
193 rule_id: &str,
194 ) -> bool {
195 let Some(supps) = self.by_file.get(&file_id) else {
196 return false;
197 };
198 let Some(used) = self.used.get(&file_id) else {
199 return false;
200 };
201 for (i, s) in supps.iter().enumerate() {
202 if s.matches_policy_rule(line, pack, rule_id) {
203 used[i].store(true, Ordering::Relaxed);
204 return true;
205 }
206 }
207 false
208 }
209
210 pub fn get(&self, file_id: FileId) -> Option<&[Suppression]> {
212 self.by_file.get(&file_id).copied()
213 }
214
215 #[must_use]
217 pub fn used_count(&self) -> usize {
218 self.used
219 .values()
220 .flat_map(|used| used.iter())
221 .filter(|used| used.load(Ordering::Relaxed))
222 .count()
223 }
224
225 pub fn find_stale(
236 &self,
237 graph: &ModuleGraph,
238 config: &ResolvedConfig,
239 ) -> Vec<StaleSuppression> {
240 let mut stale = Vec::new();
241 let mut warned_unknown_policy_targets: FxHashSet<(String, String)> = FxHashSet::default();
242
243 for (&file_id, supps) in &self.by_file {
244 let used = &self.used[&file_id];
245 let path = &graph.modules[file_id.0 as usize].path;
246 let file_rules = config.resolve_rules_for_path(path);
247
248 for (i, s) in supps.iter().enumerate() {
249 if used[i].load(Ordering::Relaxed) {
250 continue;
251 }
252 if let Some(entry) = stale_entry_for_suppression(
253 config,
254 &file_rules,
255 path,
256 s,
257 &mut warned_unknown_policy_targets,
258 ) {
259 stale.push(entry);
260 }
261 }
262 }
263
264 for (&file_id, unknowns) in &self.unknown_kinds {
265 let path = &graph.modules[file_id.0 as usize].path;
266 for u in *unknowns {
267 stale.push(unknown_kind_stale_entry(path, u, false));
268 }
269 }
270
271 stale
272 }
273
274 #[must_use]
276 pub fn find_missing_reasons(&self, graph: &ModuleGraph) -> Vec<StaleSuppression> {
277 let mut findings = Vec::new();
278 let mut seen: FxHashSet<(FileId, u32)> = FxHashSet::default();
279
280 for (&file_id, supps) in &self.by_file {
281 let path = &graph.modules[file_id.0 as usize].path;
282 for s in *supps {
283 if s.reason.is_some() || !seen.insert((file_id, s.comment_line)) {
284 continue;
285 }
286
287 findings.push(StaleSuppression {
288 path: path.clone(),
289 line: s.comment_line,
290 col: 0,
291 origin: SuppressionOrigin::Comment {
292 issue_kind: s.target_token(),
293 reason: None,
294 is_file_level: s.line == 0,
295 kind_known: true,
296 },
297 missing_reason: true,
298 actions: StaleSuppression::actions_for(true),
299 });
300 }
301 }
302
303 for (&file_id, unknowns) in &self.unknown_kinds {
304 let path = &graph.modules[file_id.0 as usize].path;
305 for u in *unknowns {
306 if u.reason.is_some() || !seen.insert((file_id, u.comment_line)) {
307 continue;
308 }
309
310 findings.push(StaleSuppression {
311 path: path.clone(),
312 line: u.comment_line,
313 col: 0,
314 origin: SuppressionOrigin::Comment {
315 issue_kind: Some(u.token.clone()),
316 reason: None,
317 is_file_level: u.is_file_level,
318 kind_known: false,
319 },
320 missing_reason: true,
321 actions: StaleSuppression::actions_for(true),
322 });
323 }
324 }
325
326 findings
327 }
328
329 #[must_use]
349 pub fn all_suppressions(&self, graph: &ModuleGraph) -> Vec<ActiveSuppression> {
350 let mut active = Vec::new();
351 for (&file_id, supps) in &self.by_file {
352 let path = &graph.modules[file_id.0 as usize].path;
353 for s in *supps {
354 active.push(ActiveSuppression {
355 path: path.clone(),
356 kind: s.target_token(),
357 is_file_level: s.line == 0,
358 reason: s.reason.clone(),
359 comment_line: s.comment_line,
360 });
361 }
362 }
363 active
364 }
365}
366
367fn stale_entry_for_suppression(
372 config: &ResolvedConfig,
373 file_rules: &RulesConfig,
374 path: &std::path::Path,
375 s: &Suppression,
376 warned: &mut FxHashSet<(String, String)>,
377) -> Option<StaleSuppression> {
378 if let Some(kind) = s.issue_kind_target()
379 && NON_CORE_KINDS.contains(&kind)
380 {
381 return None;
382 }
383
384 if let Some(kind) = s.issue_kind_target()
385 && severity_for_kind(file_rules, kind) == Severity::Off
386 {
387 return None;
388 }
389
390 if let Some(target) = s.policy_rule_target() {
391 if file_rules.policy_violation == Severity::Off || policy_rule_is_disabled(config, target) {
392 return None;
393 }
394
395 if !policy_rule_exists(config, target) {
396 let token = target.token();
397 let key = (path.to_string_lossy().to_string(), token.clone());
398 if warned.insert(key) {
399 tracing::warn!(
400 "{}:{}: suppression '{}' names no loaded rule-pack rule",
401 path.display(),
402 s.comment_line,
403 token
404 );
405 }
406 }
407 }
408
409 Some(StaleSuppression {
410 path: path.to_path_buf(),
411 line: s.comment_line,
412 col: 0,
413 origin: SuppressionOrigin::Comment {
414 issue_kind: s.target_token(),
415 reason: s.reason.clone(),
416 is_file_level: s.line == 0,
417 kind_known: true,
418 },
419 missing_reason: false,
420 actions: StaleSuppression::actions_for(false),
421 })
422}
423
424fn unknown_kind_stale_entry(
427 path: &std::path::Path,
428 u: &UnknownSuppressionKind,
429 missing_reason: bool,
430) -> StaleSuppression {
431 StaleSuppression {
432 path: path.to_path_buf(),
433 line: u.comment_line,
434 col: 0,
435 origin: SuppressionOrigin::Comment {
436 issue_kind: Some(u.token.clone()),
437 reason: if missing_reason {
438 None
439 } else {
440 u.reason.clone()
441 },
442 is_file_level: u.is_file_level,
443 kind_known: false,
444 },
445 missing_reason,
446 actions: StaleSuppression::actions_for(missing_reason),
447 }
448}
449
450fn policy_rule_exists(config: &ResolvedConfig, target: &PolicyRuleSuppression) -> bool {
451 config.rule_packs.iter().any(|pack| {
452 pack.name == target.pack && pack.rules.iter().any(|rule| rule.id == target.rule_id)
453 })
454}
455
456fn policy_rule_is_disabled(config: &ResolvedConfig, target: &PolicyRuleSuppression) -> bool {
457 config.rule_packs.iter().any(|pack| {
458 pack.name == target.pack
459 && pack
460 .rules
461 .iter()
462 .any(|rule| rule.id == target.rule_id && rule.severity == Some(Severity::Off))
463 })
464}
465
466#[cfg(test)]
467mod tests {
468 use super::*;
469
470 #[test]
471 fn severity_for_kind_maps_every_core_kind_to_its_field() {
472 let rules = RulesConfig {
473 unused_exports: Severity::Warn,
474 unused_types: Severity::Off,
475 unresolved_imports: Severity::Error,
476 boundary_violation: Severity::Off,
477 ..RulesConfig::default()
478 };
479
480 assert_eq!(
481 severity_for_kind(&rules, IssueKind::UnusedExport),
482 Severity::Warn
483 );
484 assert_eq!(
485 severity_for_kind(&rules, IssueKind::UnusedType),
486 Severity::Off
487 );
488 assert_eq!(
489 severity_for_kind(&rules, IssueKind::UnresolvedImport),
490 Severity::Error
491 );
492 assert_eq!(
493 severity_for_kind(&rules, IssueKind::BoundaryViolation),
494 Severity::Off
495 );
496 assert_eq!(
497 severity_for_kind(&rules, IssueKind::PrivateTypeLeak),
498 Severity::Off
499 );
500 }
501
502 #[test]
503 fn issue_kind_from_str_all_variants() {
504 assert_eq!(IssueKind::parse("unused-file"), Some(IssueKind::UnusedFile));
505 assert_eq!(
506 IssueKind::parse("unused-export"),
507 Some(IssueKind::UnusedExport)
508 );
509 assert_eq!(IssueKind::parse("unused-type"), Some(IssueKind::UnusedType));
510 assert_eq!(
511 IssueKind::parse("unused-dependency"),
512 Some(IssueKind::UnusedDependency)
513 );
514 assert_eq!(
515 IssueKind::parse("unused-dev-dependency"),
516 Some(IssueKind::UnusedDevDependency)
517 );
518 assert_eq!(
519 IssueKind::parse("unused-enum-member"),
520 Some(IssueKind::UnusedEnumMember)
521 );
522 assert_eq!(
523 IssueKind::parse("unused-class-member"),
524 Some(IssueKind::UnusedClassMember)
525 );
526 assert_eq!(
527 IssueKind::parse("unresolved-import"),
528 Some(IssueKind::UnresolvedImport)
529 );
530 assert_eq!(
531 IssueKind::parse("unlisted-dependency"),
532 Some(IssueKind::UnlistedDependency)
533 );
534 assert_eq!(
535 IssueKind::parse("duplicate-export"),
536 Some(IssueKind::DuplicateExport)
537 );
538 }
539
540 #[test]
541 fn issue_kind_from_str_unknown() {
542 assert_eq!(IssueKind::parse("foo"), None);
543 assert_eq!(IssueKind::parse(""), None);
544 }
545
546 #[test]
547 fn discriminant_roundtrip() {
548 for kind in [
549 IssueKind::UnusedFile,
550 IssueKind::UnusedExport,
551 IssueKind::UnusedType,
552 IssueKind::PrivateTypeLeak,
553 IssueKind::UnusedDependency,
554 IssueKind::UnusedDevDependency,
555 IssueKind::UnusedEnumMember,
556 IssueKind::UnusedClassMember,
557 IssueKind::UnresolvedImport,
558 IssueKind::UnlistedDependency,
559 IssueKind::DuplicateExport,
560 IssueKind::CodeDuplication,
561 IssueKind::CircularDependency,
562 IssueKind::TestOnlyDependency,
563 IssueKind::BoundaryViolation,
564 IssueKind::CoverageGaps,
565 IssueKind::FeatureFlag,
566 IssueKind::Complexity,
567 IssueKind::StaleSuppression,
568 IssueKind::PnpmCatalogEntry,
569 IssueKind::EmptyCatalogGroup,
570 IssueKind::UnresolvedCatalogReference,
571 IssueKind::UnusedDependencyOverride,
572 IssueKind::MisconfiguredDependencyOverride,
573 IssueKind::ReExportCycle,
574 IssueKind::SecurityClientServerLeak,
575 IssueKind::SecuritySink,
576 IssueKind::PolicyViolation,
577 IssueKind::InvalidClientExport,
578 IssueKind::MixedClientServerBarrel,
579 IssueKind::MisplacedDirective,
580 IssueKind::UnusedStoreMember,
581 IssueKind::UnprovidedInject,
582 IssueKind::RouteCollision,
583 IssueKind::DynamicSegmentNameConflict,
584 IssueKind::UnrenderedComponent,
585 IssueKind::UnusedComponentProp,
586 IssueKind::UnusedComponentEmit,
587 IssueKind::UnusedServerAction,
588 IssueKind::UnusedLoadDataKey,
589 IssueKind::PropDrilling,
590 IssueKind::ThinWrapper,
591 IssueKind::DuplicatePropShape,
592 IssueKind::UnusedComponentInput,
593 IssueKind::UnusedComponentOutput,
594 IssueKind::UnusedSvelteEvent,
595 IssueKind::CssTokenDrift,
596 IssueKind::CssDuplicateBlock,
597 IssueKind::CssSelectorComplexity,
598 IssueKind::CssDeadSurface,
599 IssueKind::CssBrokenReference,
600 IssueKind::DevDependencyInProduction,
601 ] {
602 assert_eq!(
603 IssueKind::from_discriminant(kind.to_discriminant()),
604 Some(kind)
605 );
606 }
607 assert_eq!(IssueKind::from_discriminant(0), None);
608 let max_discriminant = IssueKind::ALL
609 .iter()
610 .map(|kind| kind.to_discriminant())
611 .max()
612 .unwrap();
613 assert_eq!(IssueKind::from_discriminant(max_discriminant + 1), None);
614 }
615
616 #[test]
617 fn parse_file_wide_suppression() {
618 let source = "// fallow-ignore-file\nexport const foo = 1;\n";
619 let suppressions = parse_suppressions_from_source(source).suppressions;
620 assert_eq!(suppressions.len(), 1);
621 assert_eq!(suppressions[0].line, 0);
622 assert!(suppressions[0].issue_kind_target().is_none());
623 }
624
625 #[test]
626 fn parse_file_wide_suppression_with_kind() {
627 let source = "// fallow-ignore-file unused-export\nexport const foo = 1;\n";
628 let suppressions = parse_suppressions_from_source(source).suppressions;
629 assert_eq!(suppressions.len(), 1);
630 assert_eq!(suppressions[0].line, 0);
631 assert_eq!(
632 suppressions[0].issue_kind_target(),
633 Some(IssueKind::UnusedExport)
634 );
635 }
636
637 #[test]
638 fn parse_next_line_suppression() {
639 let source =
640 "import { x } from './x';\n// fallow-ignore-next-line\nexport const foo = 1;\n";
641 let suppressions = parse_suppressions_from_source(source).suppressions;
642 assert_eq!(suppressions.len(), 1);
643 assert_eq!(suppressions[0].line, 3); assert!(suppressions[0].issue_kind_target().is_none());
645 }
646
647 #[test]
648 fn parse_next_line_suppression_with_kind() {
649 let source = "// fallow-ignore-next-line unused-export\nexport const foo = 1;\n";
650 let suppressions = parse_suppressions_from_source(source).suppressions;
651 assert_eq!(suppressions.len(), 1);
652 assert_eq!(suppressions[0].line, 2);
653 assert_eq!(
654 suppressions[0].issue_kind_target(),
655 Some(IssueKind::UnusedExport)
656 );
657 }
658
659 #[test]
660 fn parse_unknown_kind_surfaces_as_unknown() {
661 let source = "// fallow-ignore-next-line typo-kind\nexport const foo = 1;\n";
662 let parsed = parse_suppressions_from_source(source);
663 assert!(parsed.suppressions.is_empty());
664 assert_eq!(parsed.unknown_kinds.len(), 1);
665 assert_eq!(parsed.unknown_kinds[0].token, "typo-kind");
666 }
667
668 #[test]
669 fn is_suppressed_file_wide() {
670 let suppressions = vec![Suppression::all(0, 1)];
671 assert!(is_suppressed(&suppressions, 5, IssueKind::UnusedExport));
672 assert!(is_suppressed(&suppressions, 10, IssueKind::UnusedFile));
673 }
674
675 #[test]
676 fn is_suppressed_file_wide_specific_kind() {
677 let suppressions = vec![Suppression::issue(0, 1, IssueKind::UnusedExport)];
678 assert!(is_suppressed(&suppressions, 5, IssueKind::UnusedExport));
679 assert!(!is_suppressed(&suppressions, 5, IssueKind::UnusedType));
680 }
681
682 #[test]
683 fn is_suppressed_line_specific() {
684 let suppressions = vec![Suppression::all(5, 4)];
685 assert!(is_suppressed(&suppressions, 5, IssueKind::UnusedExport));
686 assert!(!is_suppressed(&suppressions, 6, IssueKind::UnusedExport));
687 }
688
689 #[test]
690 fn is_suppressed_line_and_kind() {
691 let suppressions = vec![Suppression::issue(5, 4, IssueKind::UnusedExport)];
692 assert!(is_suppressed(&suppressions, 5, IssueKind::UnusedExport));
693 assert!(!is_suppressed(&suppressions, 5, IssueKind::UnusedType));
694 assert!(!is_suppressed(&suppressions, 6, IssueKind::UnusedExport));
695 }
696
697 #[test]
698 fn is_suppressed_empty() {
699 assert!(!is_suppressed(&[], 5, IssueKind::UnusedExport));
700 }
701
702 #[test]
703 fn is_file_suppressed_works() {
704 let suppressions = vec![Suppression::all(0, 1)];
705 assert!(is_file_suppressed(&suppressions, IssueKind::UnusedFile));
706
707 let suppressions = vec![Suppression::issue(0, 1, IssueKind::UnusedFile)];
708 assert!(is_file_suppressed(&suppressions, IssueKind::UnusedFile));
709 assert!(!is_file_suppressed(&suppressions, IssueKind::UnusedExport));
710
711 let suppressions = vec![Suppression::all(5, 4)];
712 assert!(!is_file_suppressed(&suppressions, IssueKind::UnusedFile));
713 }
714
715 #[test]
716 fn parse_oxc_comments() {
717 use fallow_extract::suppress::parse_suppressions;
718 use oxc_allocator::Allocator;
719 use oxc_parser::Parser;
720 use oxc_span::SourceType;
721
722 let source = "// fallow-ignore-file\n// fallow-ignore-next-line unused-export\nexport const foo = 1;\nexport const bar = 2;\n";
723 let allocator = Allocator::default();
724 let parser_return = Parser::new(&allocator, source, SourceType::mjs()).parse();
725
726 let suppressions = parse_suppressions(&parser_return.program.comments, source).suppressions;
727 assert_eq!(suppressions.len(), 2);
728
729 assert_eq!(suppressions[0].line, 0);
730 assert!(suppressions[0].issue_kind_target().is_none());
731
732 assert_eq!(suppressions[1].line, 3); assert_eq!(
734 suppressions[1].issue_kind_target(),
735 Some(IssueKind::UnusedExport)
736 );
737 }
738
739 #[test]
740 fn parse_block_comment_suppression() {
741 let source = "/* fallow-ignore-file */\nexport const foo = 1;\n";
742 let suppressions = parse_suppressions_from_source(source).suppressions;
743 assert_eq!(suppressions.len(), 1);
744 assert_eq!(suppressions[0].line, 0);
745 assert!(suppressions[0].issue_kind_target().is_none());
746 }
747
748 #[test]
749 fn is_suppressed_multiple_suppressions_different_kinds() {
750 let suppressions = vec![
751 Suppression::issue(5, 4, IssueKind::UnusedExport),
752 Suppression::issue(5, 4, IssueKind::UnusedType),
753 ];
754 assert!(is_suppressed(&suppressions, 5, IssueKind::UnusedExport));
755 assert!(is_suppressed(&suppressions, 5, IssueKind::UnusedType));
756 assert!(!is_suppressed(&suppressions, 5, IssueKind::UnusedFile));
757 }
758
759 #[test]
760 fn is_suppressed_file_wide_blanket_and_specific_coexist() {
761 let suppressions = vec![
762 Suppression::issue(0, 1, IssueKind::UnusedExport),
763 Suppression::all(5, 4),
764 ];
765 assert!(is_suppressed(&suppressions, 10, IssueKind::UnusedExport));
766 assert!(!is_suppressed(&suppressions, 10, IssueKind::UnusedType));
767
768 assert!(is_suppressed(&suppressions, 5, IssueKind::UnusedType));
769 assert!(is_suppressed(&suppressions, 5, IssueKind::UnusedExport));
770 }
771
772 #[test]
773 fn is_file_suppressed_blanket_suppresses_all_kinds() {
774 let suppressions = vec![Suppression::all(0, 1)];
775 assert!(is_file_suppressed(&suppressions, IssueKind::UnusedFile));
776 assert!(is_file_suppressed(&suppressions, IssueKind::UnusedExport));
777 assert!(is_file_suppressed(&suppressions, IssueKind::UnusedType));
778 assert!(is_file_suppressed(
779 &suppressions,
780 IssueKind::CircularDependency
781 ));
782 assert!(is_file_suppressed(
783 &suppressions,
784 IssueKind::CodeDuplication
785 ));
786 }
787
788 #[test]
789 fn is_file_suppressed_empty_list() {
790 assert!(!is_file_suppressed(&[], IssueKind::UnusedFile));
791 }
792
793 #[test]
794 fn scoped_policy_suppression_does_not_match_generic_policy_kind() {
795 let suppressions = vec![Suppression::policy_rule(5, 4, "team-policy", "no-fs")];
796 assert!(!is_suppressed(&suppressions, 5, IssueKind::PolicyViolation));
797 }
798
799 #[test]
800 fn parse_multiple_next_line_suppressions() {
801 let source = "// fallow-ignore-next-line unused-export\nexport const foo = 1;\n// fallow-ignore-next-line unused-type\nexport type Bar = string;\n";
802 let suppressions = parse_suppressions_from_source(source).suppressions;
803 assert_eq!(suppressions.len(), 2);
804 assert_eq!(suppressions[0].line, 2);
805 assert_eq!(
806 suppressions[0].issue_kind_target(),
807 Some(IssueKind::UnusedExport)
808 );
809 assert_eq!(suppressions[1].line, 4);
810 assert_eq!(
811 suppressions[1].issue_kind_target(),
812 Some(IssueKind::UnusedType)
813 );
814 }
815
816 #[test]
817 fn parse_code_duplication_suppression() {
818 let source = "// fallow-ignore-file code-duplication\nexport const foo = 1;\n";
819 let suppressions = parse_suppressions_from_source(source).suppressions;
820 assert_eq!(suppressions.len(), 1);
821 assert_eq!(suppressions[0].line, 0);
822 assert_eq!(
823 suppressions[0].issue_kind_target(),
824 Some(IssueKind::CodeDuplication)
825 );
826 }
827
828 #[test]
829 fn parse_circular_dependency_suppression() {
830 let source = "// fallow-ignore-file circular-dependency\nimport { x } from './x';\n";
831 let suppressions = parse_suppressions_from_source(source).suppressions;
832 assert_eq!(suppressions.len(), 1);
833 assert_eq!(suppressions[0].line, 0);
834 assert_eq!(
835 suppressions[0].issue_kind_target(),
836 Some(IssueKind::CircularDependency)
837 );
838 }
839
840 #[test]
846 fn all_issue_kinds_classified_for_stale_detection() {
847 let core_kinds = [
848 IssueKind::UnusedFile,
849 IssueKind::UnusedExport,
850 IssueKind::UnusedType,
851 IssueKind::UnusedEnumMember,
852 IssueKind::UnusedClassMember,
853 IssueKind::UnusedStoreMember,
854 IssueKind::UnprovidedInject,
855 IssueKind::UnresolvedImport,
856 IssueKind::DuplicateExport,
857 IssueKind::CircularDependency,
858 IssueKind::BoundaryViolation,
859 IssueKind::InvalidClientExport,
860 IssueKind::RouteCollision,
861 IssueKind::DynamicSegmentNameConflict,
862 IssueKind::UnrenderedComponent,
863 IssueKind::UnusedServerAction,
864 IssueKind::UnusedLoadDataKey,
865 ];
866
867 let all_kinds = [
868 IssueKind::UnusedFile,
869 IssueKind::UnusedExport,
870 IssueKind::UnusedType,
871 IssueKind::UnusedDependency,
872 IssueKind::UnusedDevDependency,
873 IssueKind::UnusedEnumMember,
874 IssueKind::UnusedClassMember,
875 IssueKind::UnusedStoreMember,
876 IssueKind::UnprovidedInject,
877 IssueKind::UnresolvedImport,
878 IssueKind::UnlistedDependency,
879 IssueKind::DuplicateExport,
880 IssueKind::CodeDuplication,
881 IssueKind::CircularDependency,
882 IssueKind::TypeOnlyDependency,
883 IssueKind::TestOnlyDependency,
884 IssueKind::BoundaryViolation,
885 IssueKind::CoverageGaps,
886 IssueKind::FeatureFlag,
887 IssueKind::Complexity,
888 IssueKind::StaleSuppression,
889 IssueKind::PnpmCatalogEntry,
890 IssueKind::EmptyCatalogGroup,
891 IssueKind::UnresolvedCatalogReference,
892 IssueKind::UnusedDependencyOverride,
893 IssueKind::MisconfiguredDependencyOverride,
894 IssueKind::InvalidClientExport,
895 IssueKind::RouteCollision,
896 IssueKind::DynamicSegmentNameConflict,
897 IssueKind::UnrenderedComponent,
898 IssueKind::UnusedServerAction,
899 IssueKind::UnusedLoadDataKey,
900 ];
901
902 for kind in all_kinds {
903 let in_core = core_kinds.contains(&kind);
904 let in_non_core = NON_CORE_KINDS.contains(&kind);
905 assert!(
906 in_core || in_non_core,
907 "IssueKind::{kind:?} is not classified in either core_kinds or NON_CORE_KINDS. \
908 Add it to NON_CORE_KINDS if it is checked outside find_dead_code_full, \
909 or to core_kinds in this test if a core detector checks it."
910 );
911 assert!(
912 !(in_core && in_non_core),
913 "IssueKind::{kind:?} is in BOTH core_kinds and NON_CORE_KINDS. Pick one."
914 );
915 }
916 }
917}