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