1pub use crate::issue_meta::{DEAD_CODE_FILTER_FLAGS, KNOWN_ISSUE_KIND_NAMES};
4
5#[derive(Debug, Clone, Copy, PartialEq, Eq)]
23pub enum IssueKind {
24 UnusedFile,
26 UnusedExport,
28 UnusedType,
30 PrivateTypeLeak,
32 UnusedDependency,
34 UnusedDevDependency,
36 UnusedEnumMember,
38 UnusedClassMember,
40 UnresolvedImport,
42 UnlistedDependency,
44 DuplicateExport,
46 CodeDuplication,
48 CircularDependency,
50 ReExportCycle,
54 TypeOnlyDependency,
56 TestOnlyDependency,
58 BoundaryViolation,
60 CoverageGaps,
62 FeatureFlag,
64 Complexity,
66 StaleSuppression,
68 PnpmCatalogEntry,
70 EmptyCatalogGroup,
72 UnresolvedCatalogReference,
75 UnusedDependencyOverride,
78 MisconfiguredDependencyOverride,
81 SecurityClientServerLeak,
84 SecuritySink,
88 PolicyViolation,
92 InvalidClientExport,
95 MixedClientServerBarrel,
99 MisplacedDirective,
104 UnusedStoreMember,
109 UnprovidedInject,
113 RouteCollision,
116 DynamicSegmentNameConflict,
120 UnrenderedComponent,
123 UnusedComponentProp,
127 UnusedComponentEmit,
131 UnusedComponentInput,
136 UnusedComponentOutput,
141 UnusedServerAction,
146 UnusedLoadDataKey,
151 PropDrilling,
156 ThinWrapper,
161 DuplicatePropShape,
167 UnusedSvelteEvent,
172 CssTokenDrift,
178 CssDuplicateBlock,
183 CssSelectorComplexity,
187 CssDeadSurface,
191 CssBrokenReference,
196}
197
198impl IssueKind {
199 pub const ALL: &'static [Self] = &[
201 Self::UnusedFile,
202 Self::UnusedExport,
203 Self::UnusedType,
204 Self::PrivateTypeLeak,
205 Self::UnusedDependency,
206 Self::UnusedDevDependency,
207 Self::UnusedEnumMember,
208 Self::UnusedClassMember,
209 Self::UnresolvedImport,
210 Self::UnlistedDependency,
211 Self::DuplicateExport,
212 Self::CodeDuplication,
213 Self::CircularDependency,
214 Self::ReExportCycle,
215 Self::TypeOnlyDependency,
216 Self::TestOnlyDependency,
217 Self::BoundaryViolation,
218 Self::CoverageGaps,
219 Self::FeatureFlag,
220 Self::Complexity,
221 Self::StaleSuppression,
222 Self::PnpmCatalogEntry,
223 Self::EmptyCatalogGroup,
224 Self::UnresolvedCatalogReference,
225 Self::UnusedDependencyOverride,
226 Self::MisconfiguredDependencyOverride,
227 Self::SecurityClientServerLeak,
228 Self::SecuritySink,
229 Self::PolicyViolation,
230 Self::InvalidClientExport,
231 Self::MixedClientServerBarrel,
232 Self::MisplacedDirective,
233 Self::UnusedStoreMember,
234 Self::UnprovidedInject,
235 Self::RouteCollision,
236 Self::DynamicSegmentNameConflict,
237 Self::UnrenderedComponent,
238 Self::UnusedComponentProp,
239 Self::UnusedComponentEmit,
240 Self::UnusedComponentInput,
241 Self::UnusedComponentOutput,
242 Self::UnusedServerAction,
243 Self::UnusedLoadDataKey,
244 Self::PropDrilling,
245 Self::ThinWrapper,
246 Self::DuplicatePropShape,
247 Self::UnusedSvelteEvent,
248 Self::CssTokenDrift,
249 Self::CssDuplicateBlock,
250 Self::CssSelectorComplexity,
251 Self::CssDeadSurface,
252 Self::CssBrokenReference,
253 ];
254
255 #[must_use]
257 pub fn parse(s: &str) -> Option<Self> {
258 crate::issue_meta::issue_meta_for_token(s).and_then(|meta| meta.kind)
259 }
260
261 #[must_use]
263 pub const fn to_discriminant(self) -> u8 {
264 match self {
265 Self::UnusedFile => 1,
266 Self::UnusedExport => 2,
267 Self::UnusedType => 3,
268 Self::PrivateTypeLeak => 4,
269 Self::UnusedDependency => 5,
270 Self::UnusedDevDependency => 6,
271 Self::UnusedEnumMember => 7,
272 Self::UnusedClassMember => 8,
273 Self::UnresolvedImport => 9,
274 Self::UnlistedDependency => 10,
275 Self::DuplicateExport => 11,
276 Self::CodeDuplication => 12,
277 Self::CircularDependency => 13,
278 Self::TypeOnlyDependency => 14,
279 Self::TestOnlyDependency => 15,
280 Self::BoundaryViolation => 16,
281 Self::CoverageGaps => 17,
282 Self::FeatureFlag => 18,
283 Self::Complexity => 19,
284 Self::StaleSuppression => 20,
285 Self::PnpmCatalogEntry => 21,
286 Self::UnresolvedCatalogReference => 22,
287 Self::UnusedDependencyOverride => 23,
288 Self::MisconfiguredDependencyOverride => 24,
289 Self::EmptyCatalogGroup => 25,
290 Self::ReExportCycle => 26,
291 Self::SecurityClientServerLeak => 27,
292 Self::SecuritySink => 28,
293 Self::PolicyViolation => 29,
294 Self::InvalidClientExport => 30,
295 Self::MixedClientServerBarrel => 31,
296 Self::MisplacedDirective => 32,
297 Self::UnusedStoreMember => 33,
298 Self::UnprovidedInject => 34,
299 Self::RouteCollision => 35,
300 Self::DynamicSegmentNameConflict => 36,
301 Self::UnrenderedComponent => 37,
302 Self::UnusedComponentProp => 38,
303 Self::UnusedComponentEmit => 39,
304 Self::UnusedServerAction => 40,
305 Self::UnusedLoadDataKey => 41,
306 Self::PropDrilling => 42,
307 Self::ThinWrapper => 43,
308 Self::DuplicatePropShape => 44,
309 Self::UnusedComponentInput => 45,
310 Self::UnusedComponentOutput => 46,
311 Self::UnusedSvelteEvent => 47,
312 Self::CssTokenDrift => 48,
313 Self::CssDuplicateBlock => 49,
314 Self::CssSelectorComplexity => 50,
315 Self::CssDeadSurface => 51,
316 Self::CssBrokenReference => 52,
317 }
318 }
319
320 #[must_use]
322 pub const fn from_discriminant(d: u8) -> Option<Self> {
323 match d {
324 1 => Some(Self::UnusedFile),
325 2 => Some(Self::UnusedExport),
326 3 => Some(Self::UnusedType),
327 4 => Some(Self::PrivateTypeLeak),
328 5 => Some(Self::UnusedDependency),
329 6 => Some(Self::UnusedDevDependency),
330 7 => Some(Self::UnusedEnumMember),
331 8 => Some(Self::UnusedClassMember),
332 9 => Some(Self::UnresolvedImport),
333 10 => Some(Self::UnlistedDependency),
334 11 => Some(Self::DuplicateExport),
335 12 => Some(Self::CodeDuplication),
336 13 => Some(Self::CircularDependency),
337 14 => Some(Self::TypeOnlyDependency),
338 15 => Some(Self::TestOnlyDependency),
339 16 => Some(Self::BoundaryViolation),
340 17 => Some(Self::CoverageGaps),
341 18 => Some(Self::FeatureFlag),
342 19 => Some(Self::Complexity),
343 20 => Some(Self::StaleSuppression),
344 21 => Some(Self::PnpmCatalogEntry),
345 22 => Some(Self::UnresolvedCatalogReference),
346 23 => Some(Self::UnusedDependencyOverride),
347 24 => Some(Self::MisconfiguredDependencyOverride),
348 25 => Some(Self::EmptyCatalogGroup),
349 26 => Some(Self::ReExportCycle),
350 27 => Some(Self::SecurityClientServerLeak),
351 28 => Some(Self::SecuritySink),
352 29 => Some(Self::PolicyViolation),
353 30 => Some(Self::InvalidClientExport),
354 31 => Some(Self::MixedClientServerBarrel),
355 32 => Some(Self::MisplacedDirective),
356 33 => Some(Self::UnusedStoreMember),
357 34 => Some(Self::UnprovidedInject),
358 35 => Some(Self::RouteCollision),
359 36 => Some(Self::DynamicSegmentNameConflict),
360 37 => Some(Self::UnrenderedComponent),
361 38 => Some(Self::UnusedComponentProp),
362 39 => Some(Self::UnusedComponentEmit),
363 40 => Some(Self::UnusedServerAction),
364 41 => Some(Self::UnusedLoadDataKey),
365 42 => Some(Self::PropDrilling),
366 43 => Some(Self::ThinWrapper),
367 44 => Some(Self::DuplicatePropShape),
368 45 => Some(Self::UnusedComponentInput),
369 46 => Some(Self::UnusedComponentOutput),
370 47 => Some(Self::UnusedSvelteEvent),
371 48 => Some(Self::CssTokenDrift),
372 49 => Some(Self::CssDuplicateBlock),
373 50 => Some(Self::CssSelectorComplexity),
374 51 => Some(Self::CssDeadSurface),
375 52 => Some(Self::CssBrokenReference),
376 _ => None,
377 }
378 }
379}
380
381#[derive(Debug, Clone, PartialEq, Eq, Hash)]
383pub struct PolicyRuleSuppression {
384 pub pack: String,
386 pub rule_id: String,
388}
389
390impl PolicyRuleSuppression {
391 #[must_use]
393 pub fn new(pack: impl Into<String>, rule_id: impl Into<String>) -> Self {
394 Self {
395 pack: pack.into(),
396 rule_id: rule_id.into(),
397 }
398 }
399
400 #[must_use]
402 pub fn token(&self) -> String {
403 format!("policy-violation:{}/{}", self.pack, self.rule_id)
404 }
405}
406
407#[derive(Debug, Clone, PartialEq, Eq)]
409pub enum SuppressionTarget {
410 Issue(IssueKind),
413 PolicyRule(PolicyRuleSuppression),
416}
417
418impl SuppressionTarget {
419 #[must_use]
422 pub const fn issue_kind(&self) -> Option<IssueKind> {
423 match self {
424 Self::Issue(kind) => Some(*kind),
425 Self::PolicyRule(_) => None,
426 }
427 }
428
429 #[must_use]
431 pub fn token(&self) -> String {
432 match self {
433 Self::Issue(kind) => issue_kind_to_kebab(*kind).to_owned(),
434 Self::PolicyRule(rule) => rule.token(),
435 }
436 }
437}
438
439#[must_use]
441pub fn issue_kind_to_kebab(kind: IssueKind) -> &'static str {
442 let Some(meta) = crate::issue_meta::issue_meta_by_kind(kind) else {
443 unreachable!("IssueKind {kind:?} has no metadata row");
444 };
445 meta.suppress_token.unwrap_or(meta.code)
446}
447
448#[must_use]
450pub fn parse_suppression_target(token: &str) -> Option<SuppressionTarget> {
451 parse_policy_rule_suppression_token(token)
452 .map(SuppressionTarget::PolicyRule)
453 .or_else(|| IssueKind::parse(token).map(SuppressionTarget::Issue))
454}
455
456#[must_use]
461pub fn parse_policy_rule_suppression_token(token: &str) -> Option<PolicyRuleSuppression> {
462 let identity = token
463 .strip_prefix("policy-violation:")
464 .or_else(|| token.strip_prefix("policy-violations:"))?;
465 let (pack, rule_id) = identity.split_once('/')?;
466 if rule_id.contains('/') {
467 return None;
468 }
469 if !is_valid_policy_identifier(pack) || !is_valid_policy_identifier(rule_id) {
470 return None;
471 }
472 Some(PolicyRuleSuppression::new(pack, rule_id))
473}
474
475#[must_use]
478pub fn is_valid_policy_identifier(value: &str) -> bool {
479 !value.is_empty()
480 && value
481 .bytes()
482 .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'_' | b'-'))
483}
484
485#[derive(Debug, Clone)]
501pub struct Suppression {
502 pub line: u32,
504 pub comment_line: u32,
508 pub target: Option<SuppressionTarget>,
510 pub reason: Option<String>,
512}
513
514impl Suppression {
515 #[must_use]
517 pub const fn all(line: u32, comment_line: u32) -> Self {
518 Self {
519 line,
520 comment_line,
521 target: None,
522 reason: None,
523 }
524 }
525
526 #[must_use]
528 pub const fn issue(line: u32, comment_line: u32, kind: IssueKind) -> Self {
529 Self {
530 line,
531 comment_line,
532 target: Some(SuppressionTarget::Issue(kind)),
533 reason: None,
534 }
535 }
536
537 #[must_use]
539 pub fn policy_rule(
540 line: u32,
541 comment_line: u32,
542 pack: impl Into<String>,
543 rule_id: impl Into<String>,
544 ) -> Self {
545 Self {
546 line,
547 comment_line,
548 target: Some(SuppressionTarget::PolicyRule(PolicyRuleSuppression::new(
549 pack, rule_id,
550 ))),
551 reason: None,
552 }
553 }
554
555 #[must_use]
557 pub fn with_reason(mut self, reason: Option<String>) -> Self {
558 self.reason = reason;
559 self
560 }
561
562 #[must_use]
564 pub const fn issue_kind_target(&self) -> Option<IssueKind> {
565 match &self.target {
566 Some(SuppressionTarget::Issue(kind)) => Some(*kind),
567 Some(SuppressionTarget::PolicyRule(_)) | None => None,
568 }
569 }
570
571 #[must_use]
573 pub const fn policy_rule_target(&self) -> Option<&PolicyRuleSuppression> {
574 match &self.target {
575 Some(SuppressionTarget::PolicyRule(rule)) => Some(rule),
576 Some(SuppressionTarget::Issue(_)) | None => None,
577 }
578 }
579
580 #[must_use]
582 pub fn target_token(&self) -> Option<String> {
583 self.target.as_ref().map(SuppressionTarget::token)
584 }
585
586 #[must_use]
588 pub const fn applies_to_line(&self, line: u32) -> bool {
589 self.line == 0 || self.line == line
590 }
591
592 #[must_use]
598 pub fn matches_issue_kind(&self, line: u32, kind: IssueKind) -> bool {
599 self.applies_to_line(line)
600 && match &self.target {
601 None => true,
602 Some(SuppressionTarget::Issue(target_kind)) => *target_kind == kind,
603 Some(SuppressionTarget::PolicyRule(_)) => false,
604 }
605 }
606
607 #[must_use]
609 pub fn matches_policy_rule(&self, line: u32, pack: &str, rule_id: &str) -> bool {
610 self.applies_to_line(line)
611 && match &self.target {
612 None | Some(SuppressionTarget::Issue(IssueKind::PolicyViolation)) => true,
613 Some(SuppressionTarget::Issue(_)) => false,
614 Some(SuppressionTarget::PolicyRule(target)) => {
615 target.pack == pack && target.rule_id == rule_id
616 }
617 }
618 }
619}
620
621#[must_use]
623pub fn is_suppressed(suppressions: &[Suppression], line: u32, kind: IssueKind) -> bool {
624 suppressions
625 .iter()
626 .any(|suppression| suppression.matches_issue_kind(line, kind))
627}
628
629#[must_use]
631pub fn is_file_suppressed(suppressions: &[Suppression], kind: IssueKind) -> bool {
632 suppressions
633 .iter()
634 .any(|suppression| suppression.line == 0 && suppression.matches_issue_kind(0, kind))
635}
636
637#[derive(Debug, Clone)]
646pub struct UnknownSuppressionKind {
647 pub comment_line: u32,
649 pub is_file_level: bool,
652 pub token: String,
654 pub reason: Option<String>,
656}
657
658fn levenshtein(a: &str, b: &str) -> usize {
666 let a_bytes = a.as_bytes();
667 let b_bytes = b.as_bytes();
668 let (a_len, b_len) = (a_bytes.len(), b_bytes.len());
669
670 if a_len == 0 {
671 return b_len;
672 }
673 if b_len == 0 {
674 return a_len;
675 }
676
677 let mut prev: Vec<usize> = (0..=b_len).collect();
678 let mut curr: Vec<usize> = vec![0; b_len + 1];
679
680 for i in 1..=a_len {
681 curr[0] = i;
682 for j in 1..=b_len {
683 let cost = usize::from(a_bytes[i - 1] != b_bytes[j - 1]);
684 curr[j] = (prev[j] + 1).min(curr[j - 1] + 1).min(prev[j - 1] + cost);
685 }
686 std::mem::swap(&mut prev, &mut curr);
687 }
688
689 prev[b_len]
690}
691
692#[must_use]
699pub fn closest_known_kind_name(input: &str) -> Option<&'static str> {
700 let input_lower = input.to_ascii_lowercase();
701 let mut best: Option<(&'static str, usize)> = None;
702
703 for &candidate in KNOWN_ISSUE_KIND_NAMES.iter() {
704 let d = levenshtein(&input_lower, candidate);
705 if best.is_none_or(|(_, b_dist)| d < b_dist) {
706 best = Some((candidate, d));
707 }
708 }
709
710 best.filter(|&(_, d)| d > 0 && d <= 2 && input_lower.len() / 2 > d)
711 .map(|(name, _)| name)
712}
713
714const _: () = assert!(std::mem::size_of::<IssueKind>() == 1);
715
716#[cfg(test)]
717mod tests {
718 use super::*;
719
720 #[test]
721 fn issue_kind_parse_accepts_registry_codes_and_aliases() {
722 for meta in crate::issue_meta::ISSUE_KIND_META
723 .iter()
724 .filter(|meta| meta.kind.is_some())
725 {
726 let expected = meta.kind;
727 assert_eq!(
728 IssueKind::parse(meta.code),
729 expected,
730 "canonical registry token {} must parse",
731 meta.code
732 );
733 for alias in meta.aliases {
734 assert_eq!(
735 IssueKind::parse(alias),
736 expected,
737 "registry alias {alias} must parse as {}",
738 meta.code
739 );
740 }
741 }
742 }
743
744 #[test]
745 fn issue_kind_parse_accepts_registry_suppression_tokens() {
746 for meta in crate::issue_meta::ISSUE_KIND_META {
747 let (Some(kind), Some(token)) = (meta.kind, meta.suppress_token) else {
748 continue;
749 };
750 assert_eq!(
751 IssueKind::parse(token),
752 Some(kind),
753 "registry suppression token {token} must parse as {}",
754 meta.code
755 );
756 }
757 }
758
759 #[test]
760 fn issue_kind_from_str_unknown() {
761 assert_eq!(IssueKind::parse("foo"), None);
762 assert_eq!(IssueKind::parse(""), None);
763 }
764
765 #[test]
766 fn issue_kind_from_str_near_misses() {
767 assert_eq!(IssueKind::parse("Unused-File"), None);
768 assert_eq!(IssueKind::parse("UNUSED-EXPORT"), None);
769 assert_eq!(IssueKind::parse("unused_file"), None);
770 assert_eq!(IssueKind::parse("unused-files"), None);
771 }
772
773 #[test]
774 fn discriminant_out_of_range() {
775 assert_eq!(IssueKind::from_discriminant(0), None);
776 assert_eq!(
777 IssueKind::from_discriminant(29),
778 Some(IssueKind::PolicyViolation)
779 );
780 assert_eq!(
781 IssueKind::from_discriminant(30),
782 Some(IssueKind::InvalidClientExport)
783 );
784 assert_eq!(
785 IssueKind::from_discriminant(31),
786 Some(IssueKind::MixedClientServerBarrel)
787 );
788 assert_eq!(
789 IssueKind::from_discriminant(32),
790 Some(IssueKind::MisplacedDirective)
791 );
792 assert_eq!(
793 IssueKind::from_discriminant(33),
794 Some(IssueKind::UnusedStoreMember)
795 );
796 assert_eq!(
797 IssueKind::from_discriminant(34),
798 Some(IssueKind::UnprovidedInject)
799 );
800 assert_eq!(
801 IssueKind::from_discriminant(35),
802 Some(IssueKind::RouteCollision)
803 );
804 assert_eq!(
805 IssueKind::from_discriminant(36),
806 Some(IssueKind::DynamicSegmentNameConflict)
807 );
808 assert_eq!(
809 IssueKind::from_discriminant(37),
810 Some(IssueKind::UnrenderedComponent)
811 );
812 assert_eq!(
813 IssueKind::from_discriminant(38),
814 Some(IssueKind::UnusedComponentProp)
815 );
816 assert_eq!(
817 IssueKind::from_discriminant(39),
818 Some(IssueKind::UnusedComponentEmit)
819 );
820 assert_eq!(
821 IssueKind::from_discriminant(40),
822 Some(IssueKind::UnusedServerAction)
823 );
824 assert_eq!(
825 IssueKind::from_discriminant(41),
826 Some(IssueKind::UnusedLoadDataKey)
827 );
828 assert_eq!(
829 IssueKind::from_discriminant(42),
830 Some(IssueKind::PropDrilling)
831 );
832 assert_eq!(
833 IssueKind::from_discriminant(43),
834 Some(IssueKind::ThinWrapper)
835 );
836 assert_eq!(
837 IssueKind::from_discriminant(44),
838 Some(IssueKind::DuplicatePropShape)
839 );
840 assert_eq!(
841 IssueKind::from_discriminant(45),
842 Some(IssueKind::UnusedComponentInput)
843 );
844 assert_eq!(
845 IssueKind::from_discriminant(46),
846 Some(IssueKind::UnusedComponentOutput)
847 );
848 assert_eq!(
849 IssueKind::from_discriminant(47),
850 Some(IssueKind::UnusedSvelteEvent)
851 );
852 let max_discriminant = IssueKind::ALL
853 .iter()
854 .map(|kind| kind.to_discriminant())
855 .max()
856 .expect("IssueKind::ALL should not be empty");
857 assert_eq!(IssueKind::from_discriminant(max_discriminant + 1), None);
858 assert_eq!(IssueKind::from_discriminant(u8::MAX), None);
859 }
860
861 #[test]
862 fn discriminant_roundtrip() {
863 for &kind in IssueKind::ALL {
864 assert_eq!(
865 IssueKind::from_discriminant(kind.to_discriminant()),
866 Some(kind)
867 );
868 }
869 assert_eq!(IssueKind::from_discriminant(0), None);
870 let max_discriminant = IssueKind::ALL
871 .iter()
872 .map(|kind| kind.to_discriminant())
873 .max()
874 .expect("IssueKind::ALL should not be empty");
875 assert_eq!(IssueKind::from_discriminant(max_discriminant + 1), None);
876 }
877
878 #[test]
879 fn discriminant_values_are_unique() {
880 let discriminants: Vec<u8> = IssueKind::ALL
881 .iter()
882 .map(|kind| kind.to_discriminant())
883 .collect();
884 let mut sorted = discriminants.clone();
885 sorted.sort_unstable();
886 sorted.dedup();
887 assert_eq!(
888 discriminants.len(),
889 sorted.len(),
890 "discriminant values must be unique"
891 );
892 }
893
894 #[test]
895 fn discriminant_starts_at_one() {
896 assert_eq!(IssueKind::UnusedFile.to_discriminant(), 1);
897 }
898
899 #[test]
900 fn issue_kind_to_kebab_uses_registry_suppression_token() {
901 for &kind in IssueKind::ALL {
902 let meta = crate::issue_meta::issue_meta_by_kind(kind)
903 .unwrap_or_else(|| panic!("IssueKind {kind:?} has no metadata row"));
904 let token = issue_kind_to_kebab(kind);
905 assert_eq!(token, meta.suppress_token.unwrap_or(meta.code));
906 assert_eq!(IssueKind::parse(token), Some(kind));
907 }
908 }
909
910 #[test]
911 fn suppression_line_zero_is_file_wide() {
912 let s = Suppression::all(0, 1);
913 assert_eq!(s.line, 0);
914 assert!(s.issue_kind_target().is_none());
915 }
916
917 #[test]
918 fn suppression_with_specific_kind_and_line() {
919 let s = Suppression::issue(42, 41, IssueKind::UnusedExport);
920 assert_eq!(s.line, 42);
921 assert_eq!(s.comment_line, 41);
922 assert_eq!(s.issue_kind_target(), Some(IssueKind::UnusedExport));
923 }
924
925 #[test]
926 fn suppression_predicates_match_lines_and_file_wide_markers() {
927 let suppressions = vec![
928 Suppression::issue(42, 41, IssueKind::UnusedExport),
929 Suppression::all(0, 1),
930 ];
931
932 assert!(is_suppressed(&suppressions, 42, IssueKind::UnusedExport));
933 assert!(is_suppressed(&suppressions, 10, IssueKind::UnusedType));
934 assert!(is_file_suppressed(&suppressions, IssueKind::UnusedFile));
935 }
936
937 #[test]
938 fn parses_scoped_policy_suppression_token() {
939 let target =
940 parse_policy_rule_suppression_token("policy-violation:team-policy/no-child-process")
941 .expect("scoped token should parse");
942 assert_eq!(target.pack, "team-policy");
943 assert_eq!(target.rule_id, "no-child-process");
944 assert_eq!(
945 target.token(),
946 "policy-violation:team-policy/no-child-process"
947 );
948 }
949
950 #[test]
951 fn rejects_malformed_scoped_policy_suppression_tokens() {
952 for token in [
953 "policy-violation:",
954 "policy-violation:team-policy",
955 "policy-violation:/no-child-process",
956 "policy-violation:team-policy/",
957 "policy-violation:team-policy/no/child-process",
958 "policy-violation:team policy/no-child-process",
959 "policy-violation:team-policy/no:child-process",
960 ] {
961 assert!(
962 parse_policy_rule_suppression_token(token).is_none(),
963 "{token} should be rejected"
964 );
965 }
966 }
967
968 #[test]
969 fn scoped_policy_suppression_matches_exact_policy_rule_only() {
970 let suppression = Suppression::policy_rule(7, 6, "team-policy", "no-child-process");
971 assert!(suppression.matches_policy_rule(7, "team-policy", "no-child-process"));
972 assert!(!suppression.matches_policy_rule(7, "team-policy", "no-fs"));
973 assert!(!suppression.matches_policy_rule(8, "team-policy", "no-child-process"));
974 assert!(!suppression.matches_issue_kind(7, IssueKind::PolicyViolation));
975 }
976
977 #[test]
978 fn known_issue_kind_names_parses_each_entry() {
979 for &name in KNOWN_ISSUE_KIND_NAMES.iter() {
980 assert!(
981 IssueKind::parse(name).is_some(),
982 "KNOWN_ISSUE_KIND_NAMES contains '{name}' but IssueKind::parse rejects it"
983 );
984 }
985 }
986
987 #[test]
988 fn closest_known_kind_name_finds_near_misses() {
989 assert_eq!(
990 closest_known_kind_name("unused-exports"),
991 Some("unused-export")
992 );
993 assert_eq!(closest_known_kind_name("unused-files"), Some("unused-file"));
994 assert_eq!(closest_known_kind_name("complxity"), Some("complexity"));
995 }
996
997 #[test]
998 fn closest_known_kind_name_rejects_novel_strings() {
999 assert_eq!(closest_known_kind_name("xyzzy"), None);
1000 assert_eq!(closest_known_kind_name("foo"), None);
1001 assert_eq!(closest_known_kind_name(""), None);
1002 }
1003
1004 #[test]
1005 fn closest_known_kind_name_skips_exact_match() {
1006 assert_eq!(closest_known_kind_name("unused-export"), None);
1007 }
1008}