1use std::path::Path;
30
31use rustc_hash::{FxHashMap, FxHashSet};
32use serde::Serialize;
33
34use crate::discover::StableFileKey;
35use crate::output_dead_code::{
36 AbsentComponentPropFinding, BoundaryCallViolationFinding, BoundaryCoverageViolationFinding,
37 BoundaryViolationFinding, CircularDependencyFinding, DeprecatedExportInUseFinding,
38 DevDependencyInProductionFinding, DuplicateExportFinding, DuplicatePropShapeFinding,
39 DynamicSegmentNameConflictFinding, EmptyCatalogGroupFinding, InvalidClientExportFinding,
40 MisconfiguredDependencyOverrideFinding, MisplacedDirectiveFinding,
41 MixedClientServerBarrelFinding, PackageCycleFinding, PolicyViolationFinding,
42 PrivateTypeLeakFinding, PropDrillingChainFinding, ReExportCycleFinding, RouteCollisionFinding,
43 TestOnlyDependencyFinding, ThinWrapperFinding, TypeOnlyDependencyFinding,
44 UnlistedDependencyFinding, UnprovidedInjectFinding, UnrenderedComponentFinding,
45 UnresolvedCatalogReferenceFinding, UnresolvedImportFinding, UnusedCatalogEntryFinding,
46 UnusedClassMemberFinding, UnusedComponentEmitFinding, UnusedComponentInputFinding,
47 UnusedComponentOutputFinding, UnusedComponentPropFinding, UnusedDependencyFinding,
48 UnusedDependencyOverrideFinding, UnusedDevDependencyFinding, UnusedEnumMemberFinding,
49 UnusedExportFinding, UnusedFileFinding, UnusedLoadDataKeyFinding,
50 UnusedOptionalDependencyFinding, UnusedServerActionFinding, UnusedStoreMemberFinding,
51 UnusedSvelteEventFinding, UnusedTypeFinding,
52};
53use crate::results::{
54 AnalysisResults, DependencyOverrideSource, ReExportCycleKind, StaleSuppression,
55 SuppressionOrigin,
56};
57
58pub const DEAD_CODE_ID_SCHEME: &str = "dc1";
61
62const FNV_OFFSET_BASIS: u64 = 0xcbf2_9ce4_8422_2325;
63const FNV_PRIME: u64 = 0x0100_0000_01b3;
64const PART_SEPARATOR: u8 = 0xff;
67const SET_SEPARATOR: &str = "|";
69const ANY_KIND: &str = "*";
71
72fn fnv1a64_update(mut hash: u64, bytes: &[u8]) -> u64 {
73 for byte in bytes {
74 hash ^= u64::from(*byte);
75 hash = hash.wrapping_mul(FNV_PRIME);
76 }
77 hash
78}
79
80#[must_use]
84pub fn fnv1a64_hex(bytes: &[u8]) -> String {
85 format!("{:016x}", fnv1a64_update(FNV_OFFSET_BASIS, bytes))
86}
87
88#[must_use]
95pub fn fnv1a64_parts(parts: &[&str]) -> String {
96 let hash = parts.iter().fold(FNV_OFFSET_BASIS, |hash, part| {
97 fnv1a64_update(fnv1a64_update(hash, part.as_bytes()), &[PART_SEPARATOR])
98 });
99 format!("{hash:016x}")
100}
101
102#[must_use]
109pub fn dead_code_finding_id(rule_token: &str, parts: &[&str]) -> String {
110 let mut input = Vec::with_capacity(parts.len() + 2);
111 input.push(DEAD_CODE_ID_SCHEME);
112 input.push(rule_token);
113 input.extend_from_slice(parts);
114 format!(
115 "{DEAD_CODE_ID_SCHEME}:{rule_token}:{}",
116 fnv1a64_parts(&input)
117 )
118}
119
120const KEY_SEPARATOR: char = ':';
122const OCCURRENCE_MARKER: char = '~';
124
125fn escape_key_part(part: &str, key: &mut String) {
128 for character in part.chars() {
129 match character {
130 '%' => key.push_str("%25"),
131 KEY_SEPARATOR => key.push_str("%3A"),
132 other => key.push(other),
133 }
134 }
135}
136
137#[must_use]
143pub fn dead_code_canonical_key(rule_token: &str, parts: &[&str]) -> String {
144 let mut key = String::with_capacity(
145 rule_token.len() + parts.iter().map(|part| part.len() + 1).sum::<usize>(),
146 );
147 key.push_str(rule_token);
148 for part in parts {
149 key.push(KEY_SEPARATOR);
150 escape_key_part(part, &mut key);
151 }
152 key
153}
154
155#[must_use]
164pub fn dead_code_occurrence_keys<T: IdentifiedFinding>(
165 findings: &[T],
166 paths: &IdentityPaths<'_>,
167) -> Vec<String> {
168 let mut seen: FxHashMap<String, usize> = FxHashMap::default();
169 findings
170 .iter()
171 .map(|finding| {
172 let key = finding.canonical_key(paths);
173 let occurrence = seen.entry(key.clone()).or_default();
174 let numbered = if *occurrence == 0 {
175 key
176 } else {
177 format!("{key}{KEY_SEPARATOR}{OCCURRENCE_MARKER}{occurrence}")
178 };
179 *occurrence += 1;
180 numbered
181 })
182 .collect()
183}
184
185#[derive(Debug, Clone, Copy)]
187pub struct IdentityPaths<'a> {
188 root: &'a Path,
189}
190
191impl<'a> IdentityPaths<'a> {
192 #[must_use]
194 pub const fn new(root: &'a Path) -> Self {
195 Self { root }
196 }
197
198 #[must_use]
200 pub fn key(&self, path: &Path) -> String {
201 StableFileKey::from_root_relative(self.root, path)
202 .as_str()
203 .to_owned()
204 }
205
206 #[must_use]
212 pub fn set<'p>(&self, paths: impl IntoIterator<Item = &'p Path>) -> String {
213 let mut keys: Vec<String> = paths
214 .into_iter()
215 .map(|path| escape_set_member(&self.key(path)))
216 .collect();
217 keys.sort_unstable();
218 keys.dedup();
219 keys.join(SET_SEPARATOR)
220 }
221}
222
223fn escape_set_member(key: &str) -> String {
225 if !key.contains(['%', '|']) {
226 return key.to_owned();
227 }
228 key.replace('%', "%25").replace('|', "%7C")
229}
230
231pub trait IdentifiedFinding: Serialize {
233 fn rule_token(&self) -> &'static str;
235
236 fn identity_parts(&self, paths: &IdentityPaths<'_>) -> Vec<String>;
239
240 fn canonical_key(&self, paths: &IdentityPaths<'_>) -> String {
243 let parts = self.identity_parts(paths);
244 let parts: Vec<&str> = parts.iter().map(String::as_str).collect();
245 dead_code_canonical_key(self.rule_token(), &parts)
246 }
247
248 fn tiebreak_position(&self) -> (u32, u32, u32);
251
252 fn finding_id(&self) -> Option<&str>;
254
255 fn set_finding_id(&mut self, id: Option<String>);
257}
258
259#[derive(Debug, Clone, Copy, PartialEq, Eq)]
261enum StampMode {
262 All,
264 Missing,
266}
267
268pub fn stamp_dead_code_finding_ids(results: &mut AnalysisResults, root: &Path) {
275 visit_families(
276 results,
277 &mut StampPass {
278 paths: IdentityPaths::new(root),
279 mode: StampMode::All,
280 },
281 );
282}
283
284pub fn stamp_missing_dead_code_finding_ids(results: &mut AnalysisResults, root: &Path) {
292 visit_families(
293 results,
294 &mut StampPass {
295 paths: IdentityPaths::new(root),
296 mode: StampMode::Missing,
297 },
298 );
299}
300
301#[expect(
307 clippy::implicit_hasher,
308 reason = "fallow standardizes on FxHashSet across the workspace"
309)]
310pub fn retain_dead_code_findings_by_id(
311 results: &mut AnalysisResults,
312 ids: &FxHashSet<String>,
313) -> FxHashSet<String> {
314 let mut pass = RetainPass {
315 ids,
316 matched: FxHashSet::default(),
317 keep_all: false,
318 };
319 visit_families(results, &mut pass);
320 results.security_findings.retain(|finding| {
321 let keep = ids.contains(&finding.finding_id);
322 if keep {
323 pass.matched.insert(finding.finding_id.clone());
324 }
325 keep
326 });
327 pass.matched
328}
329
330#[expect(
335 clippy::implicit_hasher,
336 reason = "fallow standardizes on FxHashSet across the workspace"
337)]
338pub fn present_dead_code_finding_ids(
339 results: &mut AnalysisResults,
340 ids: &FxHashSet<String>,
341) -> FxHashSet<String> {
342 let mut pass = RetainPass {
343 ids,
344 matched: FxHashSet::default(),
345 keep_all: true,
346 };
347 visit_families(results, &mut pass);
348 pass.matched.extend(
349 results
350 .security_findings
351 .iter()
352 .filter(|finding| ids.contains(&finding.finding_id))
353 .map(|finding| finding.finding_id.clone()),
354 );
355 pass.matched
356}
357
358#[must_use]
362pub fn is_dead_code_finding_id(id: &str) -> bool {
363 let Some(rest) = id
364 .strip_prefix(DEAD_CODE_ID_SCHEME)
365 .and_then(|rest| rest.strip_prefix(':'))
366 else {
367 return false;
368 };
369 let Some((rule, tail)) = rest.split_once(':') else {
370 return false;
371 };
372 let rule_ok = !rule.is_empty()
373 && rule
374 .bytes()
375 .all(|byte| byte.is_ascii_lowercase() || byte == b'-');
376 let (hash, suffix) = match tail.split_once('~') {
377 Some((hash, suffix)) => (hash, Some(suffix)),
378 None => (tail, None),
379 };
380 let hash_ok = hash.len() == HASH_HEX_DIGITS
381 && hash
382 .bytes()
383 .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte));
384 let suffix_ok = suffix.is_none_or(|suffix| {
385 !suffix.is_empty()
386 && !suffix.starts_with('0')
387 && suffix.bytes().all(|byte| byte.is_ascii_digit())
388 });
389 rule_ok && hash_ok && suffix_ok
390}
391
392const HASH_HEX_DIGITS: usize = 16;
394
395trait FamilyVisitor {
397 fn visit<T: IdentifiedFinding>(&mut self, findings: &mut Vec<T>);
398}
399
400struct StampPass<'a> {
402 paths: IdentityPaths<'a>,
403 mode: StampMode,
404}
405
406impl FamilyVisitor for StampPass<'_> {
407 fn visit<T: IdentifiedFinding>(&mut self, findings: &mut Vec<T>) {
408 apply(findings, &self.paths, self.mode);
409 }
410}
411
412struct RetainPass<'a> {
415 ids: &'a FxHashSet<String>,
416 matched: FxHashSet<String>,
417 keep_all: bool,
418}
419
420impl FamilyVisitor for RetainPass<'_> {
421 fn visit<T: IdentifiedFinding>(&mut self, findings: &mut Vec<T>) {
422 let keep_all = self.keep_all;
423 findings.retain(|finding| {
424 let matched = finding
425 .finding_id()
426 .filter(|id| self.ids.contains(*id))
427 .map(|id| self.matched.insert(id.to_owned()))
428 .is_some();
429 matched || keep_all
430 });
431 }
432}
433
434#[expect(
435 clippy::too_many_lines,
436 reason = "one exhaustive list of finding families; splitting it would lose the compile-time guard"
437)]
438fn visit_families<V: FamilyVisitor>(results: &mut AnalysisResults, visitor: &mut V) {
439 let AnalysisResults {
442 unused_files,
443 unused_exports,
444 unused_types,
445 private_type_leaks,
446 deprecated_exports_in_use,
447 unused_dependencies,
448 unused_dev_dependencies,
449 unused_optional_dependencies,
450 unused_enum_members,
451 unused_class_members,
452 unused_store_members,
453 unresolved_imports,
454 unlisted_dependencies,
455 duplicate_exports,
456 type_only_dependencies,
457 test_only_dependencies,
458 dev_dependencies_in_production,
459 circular_dependencies,
460 package_cycles,
461 re_export_cycles,
462 boundary_violations,
463 boundary_coverage_violations,
464 boundary_call_violations,
465 policy_violations,
466 stale_suppressions,
467 unused_catalog_entries,
468 empty_catalog_groups,
469 unresolved_catalog_references,
470 unused_dependency_overrides,
471 misconfigured_dependency_overrides,
472 invalid_client_exports,
473 mixed_client_server_barrels,
474 misplaced_directives,
475 unprovided_injects,
476 unrendered_components,
477 route_collisions,
478 dynamic_segment_name_conflicts,
479 unused_component_props,
480 absent_component_props,
481 unused_component_emits,
482 unused_component_inputs,
483 unused_component_outputs,
484 unused_svelte_events,
485 unused_server_actions,
486 unused_load_data_keys,
487 prop_drilling_chains,
488 thin_wrappers,
489 duplicate_prop_shapes,
490 security_findings: _,
493 security_unresolved_edge_files: _,
494 security_unresolved_callee_sites: _,
495 security_unresolved_callee_diagnostics: _,
496 unused_load_data_keys_global_abstain: _,
497 cascade_hidden: _,
500 cascade: _,
501 suppression_count: _,
502 unused_component_props_exempted: _,
503 active_suppressions: _,
504 feature_flags: _,
505 export_usages: _,
506 entry_point_summary: _,
507 render_fan_in: _,
508 react_component_intel: _,
509 semantic_framework_contracts: _,
510 } = results;
511
512 visitor.visit(unused_files);
513 visitor.visit(unused_exports);
514 visitor.visit(unused_types);
515 visitor.visit(private_type_leaks);
516 visitor.visit(deprecated_exports_in_use);
517 visitor.visit(unused_dependencies);
518 visitor.visit(unused_dev_dependencies);
519 visitor.visit(unused_optional_dependencies);
520 visitor.visit(unused_enum_members);
521 visitor.visit(unused_class_members);
522 visitor.visit(unused_store_members);
523 visitor.visit(unresolved_imports);
524 visitor.visit(unlisted_dependencies);
525 visitor.visit(duplicate_exports);
526 visitor.visit(type_only_dependencies);
527 visitor.visit(test_only_dependencies);
528 visitor.visit(dev_dependencies_in_production);
529 visitor.visit(circular_dependencies);
530 visitor.visit(package_cycles);
531 visitor.visit(re_export_cycles);
532 visitor.visit(boundary_violations);
533 visitor.visit(boundary_coverage_violations);
534 visitor.visit(boundary_call_violations);
535 visitor.visit(policy_violations);
536 visitor.visit(stale_suppressions);
537 visitor.visit(unused_catalog_entries);
538 visitor.visit(empty_catalog_groups);
539 visitor.visit(unresolved_catalog_references);
540 visitor.visit(unused_dependency_overrides);
541 visitor.visit(misconfigured_dependency_overrides);
542 visitor.visit(invalid_client_exports);
543 visitor.visit(mixed_client_server_barrels);
544 visitor.visit(misplaced_directives);
545 visitor.visit(unprovided_injects);
546 visitor.visit(unrendered_components);
547 visitor.visit(route_collisions);
548 visitor.visit(dynamic_segment_name_conflicts);
549 visitor.visit(unused_component_props);
550 visitor.visit(absent_component_props);
551 visitor.visit(unused_component_emits);
552 visitor.visit(unused_component_inputs);
553 visitor.visit(unused_component_outputs);
554 visitor.visit(unused_svelte_events);
555 visitor.visit(unused_server_actions);
556 visitor.visit(unused_load_data_keys);
557 visitor.visit(prop_drilling_chains);
558 visitor.visit(thin_wrappers);
559 visitor.visit(duplicate_prop_shapes);
560}
561
562fn apply<T: IdentifiedFinding>(findings: &mut [T], paths: &IdentityPaths<'_>, mode: StampMode) {
563 match mode {
564 StampMode::All => stamp(findings, paths),
565 StampMode::Missing => stamp_missing(findings, paths),
566 }
567}
568
569fn base_id<T: IdentifiedFinding>(finding: &T, paths: &IdentityPaths<'_>) -> String {
570 let parts = finding.identity_parts(paths);
571 let parts: Vec<&str> = parts.iter().map(String::as_str).collect();
572 dead_code_finding_id(finding.rule_token(), &parts)
573}
574
575fn tiebreak_key<T: IdentifiedFinding>(finding: &T) -> ((u32, u32, u32), String) {
578 (
579 finding.tiebreak_position(),
580 serde_json::to_string(finding).unwrap_or_default(),
581 )
582}
583
584fn stamp<T: IdentifiedFinding>(findings: &mut [T], paths: &IdentityPaths<'_>) {
586 let mut groups: FxHashMap<String, Vec<usize>> = FxHashMap::default();
587 for (index, finding) in findings.iter_mut().enumerate() {
588 finding.set_finding_id(None);
589 groups
590 .entry(base_id(finding, paths))
591 .or_default()
592 .push(index);
593 }
594 for (base, mut members) in groups {
595 if members.len() > 1 {
596 members.sort_by_cached_key(|&index| tiebreak_key(&findings[index]));
597 }
598 for (position, index) in members.into_iter().enumerate() {
599 let id = if position == 0 {
600 base.clone()
601 } else {
602 format!("{base}~{position}")
603 };
604 findings[index].set_finding_id(Some(id));
605 }
606 }
607}
608
609fn stamp_missing<T: IdentifiedFinding>(findings: &mut [T], paths: &IdentityPaths<'_>) {
611 let mut missing: Vec<usize> = (0..findings.len())
612 .filter(|&index| findings[index].finding_id().is_none())
613 .collect();
614 if missing.is_empty() {
615 return;
616 }
617 let mut taken: FxHashSet<String> = findings
618 .iter()
619 .filter_map(|finding| finding.finding_id().map(str::to_owned))
620 .collect();
621 missing.sort_by_cached_key(|&index| tiebreak_key(&findings[index]));
622 for index in missing {
623 let base = base_id(&findings[index], paths);
624 let mut id = base.clone();
625 let mut suffix = 0_usize;
626 while taken.contains(&id) {
627 suffix += 1;
628 id = format!("{base}~{suffix}");
629 }
630 taken.insert(id.clone());
631 findings[index].set_finding_id(Some(id));
632 }
633}
634
635macro_rules! identified {
637 (
638 $ty:ty,
639 token: |$t:ident| $token:expr,
640 parts: |$f:ident, $p:ident| $parts:expr,
641 position: |$g:ident| $position:expr $(,)?
642 ) => {
643 impl IdentifiedFinding for $ty {
644 fn rule_token(&self) -> &'static str {
645 let $t = self;
646 $token
647 }
648
649 fn identity_parts(&self, $p: &IdentityPaths<'_>) -> Vec<String> {
650 let $f = self;
651 $parts
652 }
653
654 fn tiebreak_position(&self) -> (u32, u32, u32) {
655 let $g = self;
656 $position
657 }
658
659 fn finding_id(&self) -> Option<&str> {
660 self.finding_id.as_deref()
661 }
662
663 fn set_finding_id(&mut self, id: Option<String>) {
664 self.finding_id = id;
665 }
666 }
667 };
668}
669
670identified!(
671 UnusedFileFinding,
672 token: |_t| "unused-file",
673 parts: |f, p| vec![p.key(&f.file.path)],
674 position: |_g| (0, 0, 0),
675);
676
677identified!(
678 UnusedExportFinding,
679 token: |_t| "unused-export",
680 parts: |f, p| vec![p.key(&f.export.path), f.export.export_name.clone()],
681 position: |g| (g.export.line, g.export.col, g.export.span_start),
682);
683
684identified!(
685 UnusedTypeFinding,
686 token: |_t| "unused-type",
687 parts: |f, p| vec![p.key(&f.export.path), f.export.export_name.clone()],
688 position: |g| (g.export.line, g.export.col, g.export.span_start),
689);
690
691identified!(
692 PrivateTypeLeakFinding,
693 token: |_t| "private-type-leak",
694 parts: |f, p| vec![
695 p.key(&f.leak.path),
696 f.leak.export_name.clone(),
697 f.leak.type_name.clone(),
698 ],
699 position: |g| (g.leak.line, g.leak.col, g.leak.span_start),
700);
701
702identified!(
703 DeprecatedExportInUseFinding,
704 token: |_t| "deprecated-export-in-use",
705 parts: |f, p| vec![p.key(&f.export.path), f.export.export_name.clone()],
706 position: |g| (g.export.line, g.export.col, g.export.span_start),
707);
708
709identified!(
710 UnusedDependencyFinding,
711 token: |_t| "unused-dependency",
712 parts: |f, p| vec![p.key(&f.dep.path), f.dep.package_name.clone()],
713 position: |g| (g.dep.line, 0, 0),
714);
715
716identified!(
717 UnusedDevDependencyFinding,
718 token: |_t| "unused-dev-dependency",
719 parts: |f, p| vec![p.key(&f.dep.path), f.dep.package_name.clone()],
720 position: |g| (g.dep.line, 0, 0),
721);
722
723identified!(
724 UnusedOptionalDependencyFinding,
725 token: |_t| "unused-optional-dependency",
726 parts: |f, p| vec![p.key(&f.dep.path), f.dep.package_name.clone()],
727 position: |g| (g.dep.line, 0, 0),
728);
729
730identified!(
731 TypeOnlyDependencyFinding,
732 token: |_t| "type-only-dependency",
733 parts: |f, p| vec![p.key(&f.dep.path), f.dep.package_name.clone()],
734 position: |g| (g.dep.line, 0, 0),
735);
736
737identified!(
738 TestOnlyDependencyFinding,
739 token: |_t| "test-only-dependency",
740 parts: |f, p| vec![p.key(&f.dep.path), f.dep.package_name.clone()],
741 position: |g| (g.dep.line, 0, 0),
742);
743
744identified!(
745 DevDependencyInProductionFinding,
746 token: |_t| "dev-dependency-in-production",
747 parts: |f, p| vec![p.key(&f.dep.path), f.dep.package_name.clone()],
748 position: |g| (g.dep.line, 0, 0),
749);
750
751identified!(
752 UnlistedDependencyFinding,
753 token: |_t| "unlisted-dependency",
754 parts: |f, _p| vec![f.dep.package_name.clone()],
755 position: |_g| (0, 0, 0),
756);
757
758identified!(
759 UnusedEnumMemberFinding,
760 token: |_t| "unused-enum-member",
761 parts: |f, p| member_parts(&f.member, p),
762 position: |g| (g.member.line, g.member.col, 0),
763);
764
765identified!(
766 UnusedClassMemberFinding,
767 token: |_t| "unused-class-member",
768 parts: |f, p| member_parts(&f.member, p),
769 position: |g| (g.member.line, g.member.col, 0),
770);
771
772identified!(
773 UnusedStoreMemberFinding,
774 token: |_t| "unused-store-member",
775 parts: |f, p| member_parts(&f.member, p),
776 position: |g| (g.member.line, g.member.col, 0),
777);
778
779fn member_parts(member: &crate::results::UnusedMember, paths: &IdentityPaths<'_>) -> Vec<String> {
780 vec![
781 paths.key(&member.path),
782 member.parent_name.clone(),
783 member.member_name.clone(),
784 ]
785}
786
787identified!(
788 UnresolvedImportFinding,
789 token: |_t| "unresolved-import",
790 parts: |f, p| vec![p.key(&f.import.path), f.import.specifier.clone()],
791 position: |g| (g.import.line, g.import.col, 0),
792);
793
794identified!(
795 DuplicateExportFinding,
796 token: |_t| "duplicate-export",
797 parts: |f, p| vec![
798 f.export.export_name.clone(),
799 p.set(f.export.locations.iter().map(|location| location.path.as_path())),
800 ],
801 position: |g| g
802 .export
803 .locations
804 .first()
805 .map_or((0, 0, 0), |location| (location.line, location.col, 0)),
806);
807
808identified!(
809 CircularDependencyFinding,
810 token: |_t| "circular-dependency",
811 parts: |f, p| vec![p.set(f.cycle.files.iter().map(std::path::PathBuf::as_path))],
812 position: |g| (g.cycle.line, g.cycle.col, 0),
813);
814
815identified!(
816 PackageCycleFinding,
817 token: |_t| "package-cycle",
818 parts: |f, p| vec![p.set(f.cycle.package_roots.iter().map(std::path::PathBuf::as_path))],
819 position: |_g| (0, 0, 0),
820);
821
822identified!(
823 ReExportCycleFinding,
824 token: |_t| "re-export-cycle",
825 parts: |f, p| vec![
826 re_export_cycle_kind(f.cycle.kind).to_owned(),
827 p.set(f.cycle.files.iter().map(std::path::PathBuf::as_path)),
828 ],
829 position: |_g| (0, 0, 0),
830);
831
832const fn re_export_cycle_kind(kind: ReExportCycleKind) -> &'static str {
834 match kind {
835 ReExportCycleKind::MultiNode => "multi-node",
836 ReExportCycleKind::SelfLoop => "self-loop",
837 }
838}
839
840identified!(
841 BoundaryViolationFinding,
842 token: |_t| "boundary-violation",
843 parts: |f, p| vec![p.key(&f.violation.from_path), p.key(&f.violation.to_path)],
844 position: |g| (g.violation.line, g.violation.col, 0),
845);
846
847identified!(
848 BoundaryCoverageViolationFinding,
849 token: |_t| "boundary-coverage",
850 parts: |f, p| vec![p.key(&f.violation.path)],
851 position: |g| (g.violation.line, g.violation.col, 0),
852);
853
854identified!(
855 BoundaryCallViolationFinding,
856 token: |_t| "boundary-call-violation",
857 parts: |f, p| vec![p.key(&f.violation.path), f.violation.callee.clone()],
858 position: |g| (g.violation.line, g.violation.col, 0),
859);
860
861identified!(
862 PolicyViolationFinding,
863 token: |_t| "policy-violation",
864 parts: |f, p| vec![
865 p.key(&f.violation.path),
866 f.violation.pack.clone(),
867 f.violation.rule_id.clone(),
868 f.violation.matched.clone(),
869 ],
870 position: |g| (g.violation.line, g.violation.col, 0),
871);
872
873identified!(
874 StaleSuppression,
875 token: |t| if t.missing_reason {
876 "missing-suppression-reason"
877 } else {
878 "stale-suppression"
879 },
880 parts: |f, p| suppression_parts(f, p),
881 position: |g| (g.line, g.col, 0),
882);
883
884fn suppression_parts(suppression: &StaleSuppression, paths: &IdentityPaths<'_>) -> Vec<String> {
887 let path = paths.key(&suppression.path);
888 match &suppression.origin {
889 SuppressionOrigin::Comment {
890 issue_kind,
891 is_file_level,
892 ..
893 } => vec![
894 path,
895 "comment".to_owned(),
896 issue_kind.clone().unwrap_or_else(|| ANY_KIND.to_owned()),
897 if *is_file_level { "file" } else { "line" }.to_owned(),
898 ],
899 SuppressionOrigin::JsdocTag { export_name, .. } => vec![
900 path,
901 "jsdoc_tag".to_owned(),
902 ANY_KIND.to_owned(),
903 export_name.clone(),
904 ],
905 }
906}
907
908identified!(
909 UnusedCatalogEntryFinding,
910 token: |_t| "unused-catalog-entry",
911 parts: |f, p| vec![
912 p.key(&f.entry.path),
913 f.entry.catalog_name.clone(),
914 f.entry.entry_name.clone(),
915 ],
916 position: |g| (g.entry.line, 0, 0),
917);
918
919identified!(
920 EmptyCatalogGroupFinding,
921 token: |_t| "empty-catalog-group",
922 parts: |f, p| vec![p.key(&f.group.path), f.group.catalog_name.clone()],
923 position: |g| (g.group.line, 0, 0),
924);
925
926identified!(
927 UnresolvedCatalogReferenceFinding,
928 token: |_t| "unresolved-catalog-reference",
929 parts: |f, p| vec![
930 p.key(&f.reference.path),
931 f.reference.catalog_name.clone(),
932 f.reference.entry_name.clone(),
933 ],
934 position: |g| (g.reference.line, 0, 0),
935);
936
937identified!(
938 UnusedDependencyOverrideFinding,
939 token: |_t| "unused-dependency-override",
940 parts: |f, _p| vec![override_source(f.entry.source).to_owned(), f.entry.raw_key.clone()],
941 position: |g| (g.entry.line, 0, 0),
942);
943
944identified!(
945 MisconfiguredDependencyOverrideFinding,
946 token: |_t| "misconfigured-dependency-override",
947 parts: |f, _p| vec![override_source(f.entry.source).to_owned(), f.entry.raw_key.clone()],
948 position: |g| (g.entry.line, 0, 0),
949);
950
951const fn override_source(source: DependencyOverrideSource) -> &'static str {
954 match source {
955 DependencyOverrideSource::PnpmWorkspaceYaml => "pnpm-workspace.yaml",
956 DependencyOverrideSource::PnpmPackageJson => "package.json",
957 }
958}
959
960identified!(
961 InvalidClientExportFinding,
962 token: |_t| "invalid-client-export",
963 parts: |f, p| vec![p.key(&f.export.path), f.export.export_name.clone()],
964 position: |g| (g.export.line, g.export.col, 0),
965);
966
967identified!(
968 MixedClientServerBarrelFinding,
969 token: |_t| "mixed-client-server-barrel",
970 parts: |f, p| vec![p.key(&f.barrel.path)],
971 position: |g| (g.barrel.line, g.barrel.col, 0),
972);
973
974identified!(
975 MisplacedDirectiveFinding,
976 token: |_t| "misplaced-directive",
977 parts: |f, p| vec![
978 p.key(&f.directive_site.path),
979 f.directive_site.directive.clone(),
980 ],
981 position: |g| (g.directive_site.line, g.directive_site.col, 0),
982);
983
984identified!(
985 UnprovidedInjectFinding,
986 token: |_t| "unprovided-inject",
987 parts: |f, p| vec![p.key(&f.inject.path), f.inject.key_name.clone()],
988 position: |g| (g.inject.line, g.inject.col, 0),
989);
990
991identified!(
992 UnrenderedComponentFinding,
993 token: |_t| "unrendered-component",
994 parts: |f, p| vec![p.key(&f.component.path), f.component.component_name.clone()],
995 position: |g| (g.component.line, g.component.col, 0),
996);
997
998identified!(
999 RouteCollisionFinding,
1000 token: |_t| "route-collision",
1001 parts: |f, p| vec![p.key(&f.collision.path), f.collision.url.clone()],
1002 position: |g| (g.collision.line, g.collision.col, 0),
1003);
1004
1005identified!(
1006 DynamicSegmentNameConflictFinding,
1007 token: |_t| "dynamic-segment-name-conflict",
1008 parts: |f, p| vec![p.key(&f.conflict.path), f.conflict.position.clone()],
1009 position: |g| (g.conflict.line, g.conflict.col, 0),
1010);
1011
1012identified!(
1013 UnusedComponentPropFinding,
1014 token: |_t| "unused-component-prop",
1015 parts: |f, p| vec![
1016 p.key(&f.prop.path),
1017 f.prop.component_name.clone(),
1018 f.prop.prop_name.clone(),
1019 ],
1020 position: |g| (g.prop.line, g.prop.col, 0),
1021);
1022identified!(
1023 AbsentComponentPropFinding,
1024 token: |_t| "absent-component-prop",
1025 parts: |f, p| vec![
1026 p.key(&f.prop.path),
1027 f.prop.component_name.clone(),
1028 f.prop.prop_name.clone(),
1029 ],
1030 position: |g| (g.prop.line, g.prop.col, 0),
1031);
1032
1033identified!(
1034 UnusedComponentEmitFinding,
1035 token: |_t| "unused-component-emit",
1036 parts: |f, p| vec![
1037 p.key(&f.emit.path),
1038 f.emit.component_name.clone(),
1039 f.emit.emit_name.clone(),
1040 ],
1041 position: |g| (g.emit.line, g.emit.col, 0),
1042);
1043
1044identified!(
1045 UnusedComponentInputFinding,
1046 token: |_t| "unused-component-input",
1047 parts: |f, p| vec![
1048 p.key(&f.input.path),
1049 f.input.component_name.clone(),
1050 f.input.input_name.clone(),
1051 ],
1052 position: |g| (g.input.line, g.input.col, 0),
1053);
1054
1055identified!(
1056 UnusedComponentOutputFinding,
1057 token: |_t| "unused-component-output",
1058 parts: |f, p| vec![
1059 p.key(&f.output.path),
1060 f.output.component_name.clone(),
1061 f.output.output_name.clone(),
1062 ],
1063 position: |g| (g.output.line, g.output.col, 0),
1064);
1065
1066identified!(
1067 UnusedSvelteEventFinding,
1068 token: |_t| "unused-svelte-event",
1069 parts: |f, p| vec![
1070 p.key(&f.event.path),
1071 f.event.component_name.clone(),
1072 f.event.event_name.clone(),
1073 ],
1074 position: |g| (g.event.line, g.event.col, 0),
1075);
1076
1077identified!(
1078 UnusedServerActionFinding,
1079 token: |_t| "unused-server-action",
1080 parts: |f, p| vec![p.key(&f.action.path), f.action.action_name.clone()],
1081 position: |g| (g.action.line, g.action.col, 0),
1082);
1083
1084identified!(
1085 UnusedLoadDataKeyFinding,
1086 token: |_t| "unused-load-data-key",
1087 parts: |f, p| vec![p.key(&f.key.path), f.key.key_name.clone()],
1088 position: |g| (g.key.line, g.key.col, 0),
1089);
1090
1091identified!(
1092 PropDrillingChainFinding,
1093 token: |_t| "prop-drilling",
1094 parts: |f, p| {
1095 let origin = f.chain.hops.first();
1096 vec![
1097 origin.map(|hop| p.key(&hop.file)).unwrap_or_default(),
1098 origin.map(|hop| hop.component.clone()).unwrap_or_default(),
1099 f.chain.prop.clone(),
1100 ]
1101 },
1102 position: |g| (g.chain.hops.first().map_or(0, |hop| hop.line), 0, 0),
1103);
1104
1105identified!(
1106 ThinWrapperFinding,
1107 token: |_t| "thin-wrapper",
1108 parts: |f, p| vec![p.key(&f.wrapper.file), f.wrapper.component.clone()],
1109 position: |g| (g.wrapper.line, 0, 0),
1110);
1111
1112identified!(
1113 DuplicatePropShapeFinding,
1114 token: |_t| "duplicate-prop-shape",
1115 parts: |f, p| vec![p.key(&f.shape.file), f.shape.component.clone()],
1116 position: |g| (g.shape.line, 0, 0),
1117);
1118
1119#[cfg(test)]
1120mod tests {
1121 use std::path::PathBuf;
1122
1123 use super::*;
1124 use crate::results::{UnusedExport, UnusedFile, UnusedMember};
1125
1126 #[test]
1130 fn fnv1a64_parts_golden_values() {
1131 assert_eq!(
1132 fnv1a64_parts(&["src/index.ts", "FEATURE_X", "3"]),
1133 "2278c9d9bd9d2dd2"
1134 );
1135 assert_eq!(
1136 fnv1a64_parts(&["fallow/unused-file", "src/orphan.ts"]),
1137 "c03b925ddb7d871a"
1138 );
1139 assert_eq!(fnv1a64_parts(&[]), "cbf29ce484222325");
1140 }
1141
1142 #[test]
1143 fn fnv1a64_hex_golden_value() {
1144 assert_eq!(
1145 fnv1a64_hex(b"security/client-server-leak:src/a.ts:3:7"),
1146 "89b6dece9d8b96d2"
1147 );
1148 }
1149
1150 #[test]
1151 fn finding_id_syntax_accepts_base_and_tiebreak_ids() {
1152 assert!(is_dead_code_finding_id(
1153 "dc1:unused-export:81a349a3b9ea3b15"
1154 ));
1155 assert!(is_dead_code_finding_id(
1156 "dc1:unused-class-member:0123456789abcdef~1"
1157 ));
1158 assert!(is_dead_code_finding_id(
1159 "dc1:unused-file:0123456789abcdef~12"
1160 ));
1161 }
1162
1163 #[test]
1164 fn finding_id_syntax_refuses_other_shapes() {
1165 for bad in [
1166 "",
1167 "dc1",
1168 "dc1:unused-export",
1169 "dc1::0123456789abcdef",
1170 "dc2:unused-export:0123456789abcdef",
1171 "dc1:unused-export:0123456789ABCDEF",
1172 "dc1:unused-export:0123456789abcde",
1173 "dc1:unused-export:0123456789abcdef0",
1174 "dc1:unused-export:0123456789abcdef~",
1175 "dc1:unused-export:0123456789abcdef~0",
1176 "dc1:unused-export:0123456789abcdef~01",
1177 "dc1:unused-export:0123456789abcdef~x",
1178 "dc1:Unused-Export:0123456789abcdef",
1179 "dc1:unused-export:0123456789abcdef:extra",
1180 ] {
1181 assert!(!is_dead_code_finding_id(bad), "{bad:?} was accepted");
1182 }
1183 }
1184
1185 #[test]
1186 fn retain_by_id_keeps_only_the_requested_findings() {
1187 let root = PathBuf::from("/repo");
1188 let mut results = AnalysisResults {
1189 unused_files: vec![
1190 UnusedFileFinding::with_actions(UnusedFile {
1191 path: root.join("src/a.ts"),
1192 }),
1193 UnusedFileFinding::with_actions(UnusedFile {
1194 path: root.join("src/b.ts"),
1195 }),
1196 ],
1197 ..AnalysisResults::default()
1198 };
1199 stamp_dead_code_finding_ids(&mut results, &root);
1200 let kept = results.unused_files[1]
1201 .finding_id
1202 .clone()
1203 .expect("stamped id");
1204 let ids: FxHashSet<String> = [kept.clone(), "dc1:unused-file:0000000000000000".to_owned()]
1205 .into_iter()
1206 .collect();
1207
1208 let present = present_dead_code_finding_ids(&mut results, &ids);
1209 assert_eq!(results.unused_files.len(), 2, "present only reads");
1210 let matched = retain_dead_code_findings_by_id(&mut results, &ids);
1211
1212 assert_eq!(present, matched);
1213 assert_eq!(matched, std::iter::once(kept.clone()).collect());
1214 assert_eq!(results.unused_files.len(), 1);
1215 assert_eq!(
1216 results.unused_files[0].finding_id.as_deref(),
1217 Some(kept.as_str())
1218 );
1219 }
1220
1221 #[test]
1222 fn dead_code_finding_id_golden_values() {
1223 assert_eq!(
1224 dead_code_finding_id("unused-export", &["src/utils.ts", "helper"]),
1225 "dc1:unused-export:81a349a3b9ea3b15"
1226 );
1227 assert_eq!(
1228 dead_code_finding_id("unused-file", &["src/orphan.ts"]),
1229 "dc1:unused-file:9fd2d414a2a9e611"
1230 );
1231 assert_eq!(
1232 dead_code_finding_id("unused-class-member", &["src/service.ts", "Service", "run"]),
1233 "dc1:unused-class-member:675fa79a4c2f244f"
1234 );
1235 assert_eq!(
1236 dead_code_finding_id("unused-dependency", &["package.json", "lodash"]),
1237 "dc1:unused-dependency:e2f217ff5a209568"
1238 );
1239 assert_eq!(
1240 dead_code_finding_id("duplicate-export", &["Button", "src/a.ts|src/b.ts"]),
1241 "dc1:duplicate-export:17c140e16d40660a"
1242 );
1243 }
1244
1245 fn export(root: &Path, name: &str, line: u32) -> UnusedExportFinding {
1246 UnusedExportFinding::with_actions(UnusedExport {
1247 path: root.join("src/utils.ts"),
1248 export_name: name.to_owned(),
1249 is_type_only: false,
1250 line,
1251 col: 7,
1252 span_start: line * 10,
1253 is_re_export: false,
1254 deprecated: false,
1255 deprecated_reason: None,
1256 })
1257 }
1258
1259 fn member(root: &Path, line: u32) -> UnusedClassMemberFinding {
1260 UnusedClassMemberFinding::with_actions(UnusedMember {
1261 path: root.join("src/service.ts"),
1262 parent_name: "Service".to_owned(),
1263 member_name: "run".to_owned(),
1264 kind: crate::extract::MemberKind::ClassMethod,
1265 line,
1266 col: 2,
1267 })
1268 }
1269
1270 fn ids<T: IdentifiedFinding>(findings: &[T]) -> Vec<Option<String>> {
1271 findings
1272 .iter()
1273 .map(|finding| finding.finding_id().map(str::to_owned))
1274 .collect()
1275 }
1276
1277 #[test]
1278 fn stamping_uses_root_relative_paths_and_ignores_lines() {
1279 let root = PathBuf::from("/repo");
1280 let mut results = AnalysisResults {
1281 unused_files: vec![UnusedFileFinding::with_actions(UnusedFile {
1282 path: root.join("src/orphan.ts"),
1283 })],
1284 unused_exports: vec![export(&root, "helper", 40)],
1285 ..AnalysisResults::default()
1286 };
1287
1288 stamp_dead_code_finding_ids(&mut results, &root);
1289
1290 assert_eq!(
1291 ids(&results.unused_files),
1292 vec![Some("dc1:unused-file:9fd2d414a2a9e611".to_owned())]
1293 );
1294 assert_eq!(
1295 ids(&results.unused_exports),
1296 vec![Some("dc1:unused-export:81a349a3b9ea3b15".to_owned())]
1297 );
1298 }
1299
1300 #[test]
1301 fn duplicate_subjects_get_a_tiebreak_suffix_in_line_order() {
1302 let root = PathBuf::from("/repo");
1303 let mut results = AnalysisResults {
1304 unused_class_members: vec![member(&root, 30), member(&root, 10), member(&root, 20)],
1305 ..AnalysisResults::default()
1306 };
1307
1308 stamp_dead_code_finding_ids(&mut results, &root);
1309
1310 let base = "dc1:unused-class-member:675fa79a4c2f244f";
1311 assert_eq!(
1312 ids(&results.unused_class_members),
1313 vec![
1314 Some(format!("{base}~2")),
1315 Some(base.to_owned()),
1316 Some(format!("{base}~1")),
1317 ]
1318 );
1319 }
1320
1321 #[test]
1322 fn stamping_does_not_depend_on_input_order() {
1323 let root = PathBuf::from("/repo");
1324 let forward = vec![member(&root, 10), member(&root, 20), member(&root, 30)];
1325 let mut reversed = forward.clone();
1326 reversed.reverse();
1327 let mut a = AnalysisResults {
1328 unused_class_members: forward,
1329 ..AnalysisResults::default()
1330 };
1331 let mut b = AnalysisResults {
1332 unused_class_members: reversed,
1333 ..AnalysisResults::default()
1334 };
1335
1336 stamp_dead_code_finding_ids(&mut a, &root);
1337 stamp_dead_code_finding_ids(&mut b, &root);
1338
1339 let mut a_pairs: Vec<(u32, Option<String>)> = a
1340 .unused_class_members
1341 .iter()
1342 .map(|finding| (finding.member.line, finding.finding_id.clone()))
1343 .collect();
1344 let mut b_pairs: Vec<(u32, Option<String>)> = b
1345 .unused_class_members
1346 .iter()
1347 .map(|finding| (finding.member.line, finding.finding_id.clone()))
1348 .collect();
1349 a_pairs.sort();
1350 b_pairs.sort();
1351 assert_eq!(a_pairs, b_pairs);
1352 }
1353
1354 #[test]
1355 fn stamping_twice_gives_the_same_ids() {
1356 let root = PathBuf::from("/repo");
1357 let mut results = AnalysisResults {
1358 unused_class_members: vec![member(&root, 10), member(&root, 20)],
1359 unused_exports: vec![export(&root, "helper", 3)],
1360 ..AnalysisResults::default()
1361 };
1362
1363 stamp_dead_code_finding_ids(&mut results, &root);
1364 let first = (
1365 ids(&results.unused_class_members),
1366 ids(&results.unused_exports),
1367 );
1368 stamp_dead_code_finding_ids(&mut results, &root);
1369
1370 assert_eq!(
1371 first,
1372 (
1373 ids(&results.unused_class_members),
1374 ids(&results.unused_exports),
1375 )
1376 );
1377 }
1378
1379 #[test]
1380 fn stamping_missing_ids_keeps_existing_ids_and_takes_a_free_suffix() {
1381 let root = PathBuf::from("/repo");
1382 let mut results = AnalysisResults {
1383 unused_class_members: vec![member(&root, 10), member(&root, 20)],
1384 ..AnalysisResults::default()
1385 };
1386 stamp_dead_code_finding_ids(&mut results, &root);
1387 results.unused_class_members.remove(0);
1389 results.unused_class_members.push(member(&root, 5));
1390
1391 stamp_missing_dead_code_finding_ids(&mut results, &root);
1392
1393 let base = "dc1:unused-class-member:675fa79a4c2f244f";
1394 assert_eq!(
1395 ids(&results.unused_class_members),
1396 vec![Some(format!("{base}~1")), Some(base.to_owned())]
1397 );
1398 }
1399
1400 fn duplicate_export(root: &Path, files: &[&str]) -> DuplicateExportFinding {
1401 DuplicateExportFinding::with_actions(crate::results::DuplicateExport {
1402 export_name: "Button".to_owned(),
1403 locations: files
1404 .iter()
1405 .map(|file| crate::results::DuplicateLocation {
1406 path: root.join(file),
1407 line: 1,
1408 col: 0,
1409 })
1410 .collect(),
1411 })
1412 }
1413
1414 #[test]
1415 fn a_path_set_without_special_characters_keeps_its_golden_id() {
1416 let root = PathBuf::from("/repo");
1417 let mut results = AnalysisResults {
1418 duplicate_exports: vec![duplicate_export(&root, &["src/b.ts", "src/a.ts"])],
1419 ..AnalysisResults::default()
1420 };
1421
1422 stamp_dead_code_finding_ids(&mut results, &root);
1423
1424 assert_eq!(
1425 ids(&results.duplicate_exports),
1426 vec![Some("dc1:duplicate-export:17c140e16d40660a".to_owned())]
1427 );
1428 }
1429
1430 #[test]
1431 fn a_pipe_in_a_file_name_does_not_collide_with_two_files() {
1432 let root = PathBuf::from("/repo");
1433 let paths = IdentityPaths::new(&root);
1434 let one = root.join("src/a.ts|src/b.ts");
1435 let first = root.join("src/a.ts");
1436 let second = root.join("src/b.ts");
1437
1438 assert_eq!(paths.set([one.as_path()]), "src/a.ts%7Csrc/b.ts");
1439 assert_eq!(
1440 paths.set([first.as_path(), second.as_path()]),
1441 "src/a.ts|src/b.ts"
1442 );
1443 assert_eq!(paths.set([root.join("100%.ts").as_path()]), "100%25.ts");
1444
1445 let mut results = AnalysisResults {
1446 duplicate_exports: vec![
1447 duplicate_export(&root, &["src/a.ts|src/b.ts"]),
1448 duplicate_export(&root, &["src/a.ts", "src/b.ts"]),
1449 ],
1450 ..AnalysisResults::default()
1451 };
1452 stamp_dead_code_finding_ids(&mut results, &root);
1453
1454 let stamped = ids(&results.duplicate_exports);
1455 assert_ne!(stamped[0], stamped[1]);
1456 assert!(
1457 stamped.iter().flatten().all(|id| !id.contains('~')),
1458 "the two findings must not share a base id: {stamped:?}"
1459 );
1460 }
1461
1462 #[test]
1463 fn a_package_cycle_gets_a_golden_id_from_its_sorted_package_roots() {
1464 let root = PathBuf::from("/repo");
1465 let cycle = |roots: &[&str]| {
1466 PackageCycleFinding::with_actions(crate::results::PackageCycle {
1467 packages: vec!["@x/a".to_owned(), "@x/b".to_owned()],
1468 package_roots: roots.iter().map(|dir| root.join(dir)).collect(),
1469 length: 2,
1470 edges: Vec::new(),
1471 group_truncated: false,
1472 })
1473 };
1474 let mut results = AnalysisResults {
1475 package_cycles: vec![cycle(&["packages/b", "packages/a"])],
1476 ..AnalysisResults::default()
1477 };
1478
1479 stamp_dead_code_finding_ids(&mut results, &root);
1480
1481 assert_eq!(
1482 ids(&results.package_cycles),
1483 vec![Some("dc1:package-cycle:fed127e4525389ac".to_owned())]
1484 );
1485 }
1486
1487 #[test]
1488 fn windows_separators_give_the_same_id() {
1489 let mut windows = AnalysisResults {
1490 unused_files: vec![UnusedFileFinding::with_actions(UnusedFile {
1491 path: PathBuf::from("src\\orphan.ts"),
1492 })],
1493 ..AnalysisResults::default()
1494 };
1495
1496 stamp_dead_code_finding_ids(&mut windows, Path::new(""));
1497
1498 assert_eq!(
1499 ids(&windows.unused_files),
1500 vec![Some("dc1:unused-file:9fd2d414a2a9e611".to_owned())]
1501 );
1502 }
1503
1504 #[test]
1505 fn canonical_keys_are_readable_and_escape_the_separator() {
1506 assert_eq!(
1507 dead_code_canonical_key("unused-export", &["src/utils.ts", "helper"]),
1508 "unused-export:src/utils.ts:helper"
1509 );
1510 assert_eq!(
1511 dead_code_canonical_key("unused-file", &["C:/repo/a%b.ts"]),
1512 "unused-file:C%3A/repo/a%25b.ts"
1513 );
1514 assert_ne!(
1515 dead_code_canonical_key("unused-export", &["a:b", "c"]),
1516 dead_code_canonical_key("unused-export", &["a", "b:c"])
1517 );
1518 }
1519
1520 #[test]
1521 fn the_canonical_key_and_the_id_use_the_same_parts() {
1522 let root = PathBuf::from("/repo");
1523 let paths = IdentityPaths::new(&root);
1524 let finding = member(&root, 10);
1525
1526 assert_eq!(
1527 finding.canonical_key(&paths),
1528 "unused-class-member:src/service.ts:Service:run"
1529 );
1530 assert_eq!(
1531 base_id(&finding, &paths),
1532 dead_code_finding_id("unused-class-member", &["src/service.ts", "Service", "run"])
1533 );
1534 assert_eq!(
1535 member(&root, 99).canonical_key(&paths),
1536 finding.canonical_key(&paths)
1537 );
1538 }
1539
1540 #[test]
1541 fn occurrence_keys_number_repeated_keys_in_input_order() {
1542 let root = PathBuf::from("/repo");
1543 let paths = IdentityPaths::new(&root);
1544 let findings = vec![member(&root, 10), member(&root, 20), member(&root, 30)];
1545
1546 assert_eq!(
1547 dead_code_occurrence_keys(&findings, &paths),
1548 vec![
1549 "unused-class-member:src/service.ts:Service:run".to_owned(),
1550 "unused-class-member:src/service.ts:Service:run:~1".to_owned(),
1551 "unused-class-member:src/service.ts:Service:run:~2".to_owned(),
1552 ]
1553 );
1554 }
1555
1556 #[test]
1557 fn the_suppression_reason_is_not_part_of_the_id() {
1558 let suppression = |reason: Option<&str>| StaleSuppression {
1559 path: PathBuf::from("src/a.ts"),
1560 line: 3,
1561 col: 0,
1562 origin: SuppressionOrigin::Comment {
1563 issue_kind: Some("unused-export".to_owned()),
1564 reason: reason.map(str::to_owned),
1565 is_file_level: false,
1566 kind_known: true,
1567 },
1568 missing_reason: false,
1569 finding_id: None,
1570 actions: Vec::new(),
1571 effective_severity: None,
1572 };
1573 let mut results = AnalysisResults {
1574 stale_suppressions: vec![suppression(None)],
1575 ..AnalysisResults::default()
1576 };
1577 let mut with_reason = AnalysisResults {
1578 stale_suppressions: vec![suppression(Some("kept for the plugin API"))],
1579 ..AnalysisResults::default()
1580 };
1581
1582 stamp_dead_code_finding_ids(&mut results, Path::new(""));
1583 stamp_dead_code_finding_ids(&mut with_reason, Path::new(""));
1584
1585 assert_eq!(
1586 ids(&results.stale_suppressions),
1587 ids(&with_reason.stale_suppressions)
1588 );
1589 assert!(
1590 ids(&results.stale_suppressions)[0]
1591 .as_deref()
1592 .is_some_and(|id| id.starts_with("dc1:stale-suppression:"))
1593 );
1594 }
1595}