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 suppression_count: _,
498 unused_component_props_exempted: _,
499 active_suppressions: _,
500 feature_flags: _,
501 export_usages: _,
502 entry_point_summary: _,
503 render_fan_in: _,
504 react_component_intel: _,
505 semantic_framework_contracts: _,
506 } = results;
507
508 visitor.visit(unused_files);
509 visitor.visit(unused_exports);
510 visitor.visit(unused_types);
511 visitor.visit(private_type_leaks);
512 visitor.visit(deprecated_exports_in_use);
513 visitor.visit(unused_dependencies);
514 visitor.visit(unused_dev_dependencies);
515 visitor.visit(unused_optional_dependencies);
516 visitor.visit(unused_enum_members);
517 visitor.visit(unused_class_members);
518 visitor.visit(unused_store_members);
519 visitor.visit(unresolved_imports);
520 visitor.visit(unlisted_dependencies);
521 visitor.visit(duplicate_exports);
522 visitor.visit(type_only_dependencies);
523 visitor.visit(test_only_dependencies);
524 visitor.visit(dev_dependencies_in_production);
525 visitor.visit(circular_dependencies);
526 visitor.visit(package_cycles);
527 visitor.visit(re_export_cycles);
528 visitor.visit(boundary_violations);
529 visitor.visit(boundary_coverage_violations);
530 visitor.visit(boundary_call_violations);
531 visitor.visit(policy_violations);
532 visitor.visit(stale_suppressions);
533 visitor.visit(unused_catalog_entries);
534 visitor.visit(empty_catalog_groups);
535 visitor.visit(unresolved_catalog_references);
536 visitor.visit(unused_dependency_overrides);
537 visitor.visit(misconfigured_dependency_overrides);
538 visitor.visit(invalid_client_exports);
539 visitor.visit(mixed_client_server_barrels);
540 visitor.visit(misplaced_directives);
541 visitor.visit(unprovided_injects);
542 visitor.visit(unrendered_components);
543 visitor.visit(route_collisions);
544 visitor.visit(dynamic_segment_name_conflicts);
545 visitor.visit(unused_component_props);
546 visitor.visit(absent_component_props);
547 visitor.visit(unused_component_emits);
548 visitor.visit(unused_component_inputs);
549 visitor.visit(unused_component_outputs);
550 visitor.visit(unused_svelte_events);
551 visitor.visit(unused_server_actions);
552 visitor.visit(unused_load_data_keys);
553 visitor.visit(prop_drilling_chains);
554 visitor.visit(thin_wrappers);
555 visitor.visit(duplicate_prop_shapes);
556}
557
558fn apply<T: IdentifiedFinding>(findings: &mut [T], paths: &IdentityPaths<'_>, mode: StampMode) {
559 match mode {
560 StampMode::All => stamp(findings, paths),
561 StampMode::Missing => stamp_missing(findings, paths),
562 }
563}
564
565fn base_id<T: IdentifiedFinding>(finding: &T, paths: &IdentityPaths<'_>) -> String {
566 let parts = finding.identity_parts(paths);
567 let parts: Vec<&str> = parts.iter().map(String::as_str).collect();
568 dead_code_finding_id(finding.rule_token(), &parts)
569}
570
571fn tiebreak_key<T: IdentifiedFinding>(finding: &T) -> ((u32, u32, u32), String) {
574 (
575 finding.tiebreak_position(),
576 serde_json::to_string(finding).unwrap_or_default(),
577 )
578}
579
580fn stamp<T: IdentifiedFinding>(findings: &mut [T], paths: &IdentityPaths<'_>) {
582 let mut groups: FxHashMap<String, Vec<usize>> = FxHashMap::default();
583 for (index, finding) in findings.iter_mut().enumerate() {
584 finding.set_finding_id(None);
585 groups
586 .entry(base_id(finding, paths))
587 .or_default()
588 .push(index);
589 }
590 for (base, mut members) in groups {
591 if members.len() > 1 {
592 members.sort_by_cached_key(|&index| tiebreak_key(&findings[index]));
593 }
594 for (position, index) in members.into_iter().enumerate() {
595 let id = if position == 0 {
596 base.clone()
597 } else {
598 format!("{base}~{position}")
599 };
600 findings[index].set_finding_id(Some(id));
601 }
602 }
603}
604
605fn stamp_missing<T: IdentifiedFinding>(findings: &mut [T], paths: &IdentityPaths<'_>) {
607 let mut missing: Vec<usize> = (0..findings.len())
608 .filter(|&index| findings[index].finding_id().is_none())
609 .collect();
610 if missing.is_empty() {
611 return;
612 }
613 let mut taken: FxHashSet<String> = findings
614 .iter()
615 .filter_map(|finding| finding.finding_id().map(str::to_owned))
616 .collect();
617 missing.sort_by_cached_key(|&index| tiebreak_key(&findings[index]));
618 for index in missing {
619 let base = base_id(&findings[index], paths);
620 let mut id = base.clone();
621 let mut suffix = 0_usize;
622 while taken.contains(&id) {
623 suffix += 1;
624 id = format!("{base}~{suffix}");
625 }
626 taken.insert(id.clone());
627 findings[index].set_finding_id(Some(id));
628 }
629}
630
631macro_rules! identified {
633 (
634 $ty:ty,
635 token: |$t:ident| $token:expr,
636 parts: |$f:ident, $p:ident| $parts:expr,
637 position: |$g:ident| $position:expr $(,)?
638 ) => {
639 impl IdentifiedFinding for $ty {
640 fn rule_token(&self) -> &'static str {
641 let $t = self;
642 $token
643 }
644
645 fn identity_parts(&self, $p: &IdentityPaths<'_>) -> Vec<String> {
646 let $f = self;
647 $parts
648 }
649
650 fn tiebreak_position(&self) -> (u32, u32, u32) {
651 let $g = self;
652 $position
653 }
654
655 fn finding_id(&self) -> Option<&str> {
656 self.finding_id.as_deref()
657 }
658
659 fn set_finding_id(&mut self, id: Option<String>) {
660 self.finding_id = id;
661 }
662 }
663 };
664}
665
666identified!(
667 UnusedFileFinding,
668 token: |_t| "unused-file",
669 parts: |f, p| vec![p.key(&f.file.path)],
670 position: |_g| (0, 0, 0),
671);
672
673identified!(
674 UnusedExportFinding,
675 token: |_t| "unused-export",
676 parts: |f, p| vec![p.key(&f.export.path), f.export.export_name.clone()],
677 position: |g| (g.export.line, g.export.col, g.export.span_start),
678);
679
680identified!(
681 UnusedTypeFinding,
682 token: |_t| "unused-type",
683 parts: |f, p| vec![p.key(&f.export.path), f.export.export_name.clone()],
684 position: |g| (g.export.line, g.export.col, g.export.span_start),
685);
686
687identified!(
688 PrivateTypeLeakFinding,
689 token: |_t| "private-type-leak",
690 parts: |f, p| vec![
691 p.key(&f.leak.path),
692 f.leak.export_name.clone(),
693 f.leak.type_name.clone(),
694 ],
695 position: |g| (g.leak.line, g.leak.col, g.leak.span_start),
696);
697
698identified!(
699 DeprecatedExportInUseFinding,
700 token: |_t| "deprecated-export-in-use",
701 parts: |f, p| vec![p.key(&f.export.path), f.export.export_name.clone()],
702 position: |g| (g.export.line, g.export.col, g.export.span_start),
703);
704
705identified!(
706 UnusedDependencyFinding,
707 token: |_t| "unused-dependency",
708 parts: |f, p| vec![p.key(&f.dep.path), f.dep.package_name.clone()],
709 position: |g| (g.dep.line, 0, 0),
710);
711
712identified!(
713 UnusedDevDependencyFinding,
714 token: |_t| "unused-dev-dependency",
715 parts: |f, p| vec![p.key(&f.dep.path), f.dep.package_name.clone()],
716 position: |g| (g.dep.line, 0, 0),
717);
718
719identified!(
720 UnusedOptionalDependencyFinding,
721 token: |_t| "unused-optional-dependency",
722 parts: |f, p| vec![p.key(&f.dep.path), f.dep.package_name.clone()],
723 position: |g| (g.dep.line, 0, 0),
724);
725
726identified!(
727 TypeOnlyDependencyFinding,
728 token: |_t| "type-only-dependency",
729 parts: |f, p| vec![p.key(&f.dep.path), f.dep.package_name.clone()],
730 position: |g| (g.dep.line, 0, 0),
731);
732
733identified!(
734 TestOnlyDependencyFinding,
735 token: |_t| "test-only-dependency",
736 parts: |f, p| vec![p.key(&f.dep.path), f.dep.package_name.clone()],
737 position: |g| (g.dep.line, 0, 0),
738);
739
740identified!(
741 DevDependencyInProductionFinding,
742 token: |_t| "dev-dependency-in-production",
743 parts: |f, p| vec![p.key(&f.dep.path), f.dep.package_name.clone()],
744 position: |g| (g.dep.line, 0, 0),
745);
746
747identified!(
748 UnlistedDependencyFinding,
749 token: |_t| "unlisted-dependency",
750 parts: |f, _p| vec![f.dep.package_name.clone()],
751 position: |_g| (0, 0, 0),
752);
753
754identified!(
755 UnusedEnumMemberFinding,
756 token: |_t| "unused-enum-member",
757 parts: |f, p| member_parts(&f.member, p),
758 position: |g| (g.member.line, g.member.col, 0),
759);
760
761identified!(
762 UnusedClassMemberFinding,
763 token: |_t| "unused-class-member",
764 parts: |f, p| member_parts(&f.member, p),
765 position: |g| (g.member.line, g.member.col, 0),
766);
767
768identified!(
769 UnusedStoreMemberFinding,
770 token: |_t| "unused-store-member",
771 parts: |f, p| member_parts(&f.member, p),
772 position: |g| (g.member.line, g.member.col, 0),
773);
774
775fn member_parts(member: &crate::results::UnusedMember, paths: &IdentityPaths<'_>) -> Vec<String> {
776 vec![
777 paths.key(&member.path),
778 member.parent_name.clone(),
779 member.member_name.clone(),
780 ]
781}
782
783identified!(
784 UnresolvedImportFinding,
785 token: |_t| "unresolved-import",
786 parts: |f, p| vec![p.key(&f.import.path), f.import.specifier.clone()],
787 position: |g| (g.import.line, g.import.col, 0),
788);
789
790identified!(
791 DuplicateExportFinding,
792 token: |_t| "duplicate-export",
793 parts: |f, p| vec![
794 f.export.export_name.clone(),
795 p.set(f.export.locations.iter().map(|location| location.path.as_path())),
796 ],
797 position: |g| g
798 .export
799 .locations
800 .first()
801 .map_or((0, 0, 0), |location| (location.line, location.col, 0)),
802);
803
804identified!(
805 CircularDependencyFinding,
806 token: |_t| "circular-dependency",
807 parts: |f, p| vec![p.set(f.cycle.files.iter().map(std::path::PathBuf::as_path))],
808 position: |g| (g.cycle.line, g.cycle.col, 0),
809);
810
811identified!(
812 PackageCycleFinding,
813 token: |_t| "package-cycle",
814 parts: |f, p| vec![p.set(f.cycle.package_roots.iter().map(std::path::PathBuf::as_path))],
815 position: |_g| (0, 0, 0),
816);
817
818identified!(
819 ReExportCycleFinding,
820 token: |_t| "re-export-cycle",
821 parts: |f, p| vec![
822 re_export_cycle_kind(f.cycle.kind).to_owned(),
823 p.set(f.cycle.files.iter().map(std::path::PathBuf::as_path)),
824 ],
825 position: |_g| (0, 0, 0),
826);
827
828const fn re_export_cycle_kind(kind: ReExportCycleKind) -> &'static str {
830 match kind {
831 ReExportCycleKind::MultiNode => "multi-node",
832 ReExportCycleKind::SelfLoop => "self-loop",
833 }
834}
835
836identified!(
837 BoundaryViolationFinding,
838 token: |_t| "boundary-violation",
839 parts: |f, p| vec![p.key(&f.violation.from_path), p.key(&f.violation.to_path)],
840 position: |g| (g.violation.line, g.violation.col, 0),
841);
842
843identified!(
844 BoundaryCoverageViolationFinding,
845 token: |_t| "boundary-coverage",
846 parts: |f, p| vec![p.key(&f.violation.path)],
847 position: |g| (g.violation.line, g.violation.col, 0),
848);
849
850identified!(
851 BoundaryCallViolationFinding,
852 token: |_t| "boundary-call-violation",
853 parts: |f, p| vec![p.key(&f.violation.path), f.violation.callee.clone()],
854 position: |g| (g.violation.line, g.violation.col, 0),
855);
856
857identified!(
858 PolicyViolationFinding,
859 token: |_t| "policy-violation",
860 parts: |f, p| vec![
861 p.key(&f.violation.path),
862 f.violation.pack.clone(),
863 f.violation.rule_id.clone(),
864 f.violation.matched.clone(),
865 ],
866 position: |g| (g.violation.line, g.violation.col, 0),
867);
868
869identified!(
870 StaleSuppression,
871 token: |t| if t.missing_reason {
872 "missing-suppression-reason"
873 } else {
874 "stale-suppression"
875 },
876 parts: |f, p| suppression_parts(f, p),
877 position: |g| (g.line, g.col, 0),
878);
879
880fn suppression_parts(suppression: &StaleSuppression, paths: &IdentityPaths<'_>) -> Vec<String> {
883 let path = paths.key(&suppression.path);
884 match &suppression.origin {
885 SuppressionOrigin::Comment {
886 issue_kind,
887 is_file_level,
888 ..
889 } => vec![
890 path,
891 "comment".to_owned(),
892 issue_kind.clone().unwrap_or_else(|| ANY_KIND.to_owned()),
893 if *is_file_level { "file" } else { "line" }.to_owned(),
894 ],
895 SuppressionOrigin::JsdocTag { export_name, .. } => vec![
896 path,
897 "jsdoc_tag".to_owned(),
898 ANY_KIND.to_owned(),
899 export_name.clone(),
900 ],
901 }
902}
903
904identified!(
905 UnusedCatalogEntryFinding,
906 token: |_t| "unused-catalog-entry",
907 parts: |f, p| vec![
908 p.key(&f.entry.path),
909 f.entry.catalog_name.clone(),
910 f.entry.entry_name.clone(),
911 ],
912 position: |g| (g.entry.line, 0, 0),
913);
914
915identified!(
916 EmptyCatalogGroupFinding,
917 token: |_t| "empty-catalog-group",
918 parts: |f, p| vec![p.key(&f.group.path), f.group.catalog_name.clone()],
919 position: |g| (g.group.line, 0, 0),
920);
921
922identified!(
923 UnresolvedCatalogReferenceFinding,
924 token: |_t| "unresolved-catalog-reference",
925 parts: |f, p| vec![
926 p.key(&f.reference.path),
927 f.reference.catalog_name.clone(),
928 f.reference.entry_name.clone(),
929 ],
930 position: |g| (g.reference.line, 0, 0),
931);
932
933identified!(
934 UnusedDependencyOverrideFinding,
935 token: |_t| "unused-dependency-override",
936 parts: |f, _p| vec![override_source(f.entry.source).to_owned(), f.entry.raw_key.clone()],
937 position: |g| (g.entry.line, 0, 0),
938);
939
940identified!(
941 MisconfiguredDependencyOverrideFinding,
942 token: |_t| "misconfigured-dependency-override",
943 parts: |f, _p| vec![override_source(f.entry.source).to_owned(), f.entry.raw_key.clone()],
944 position: |g| (g.entry.line, 0, 0),
945);
946
947const fn override_source(source: DependencyOverrideSource) -> &'static str {
950 match source {
951 DependencyOverrideSource::PnpmWorkspaceYaml => "pnpm-workspace.yaml",
952 DependencyOverrideSource::PnpmPackageJson => "package.json",
953 }
954}
955
956identified!(
957 InvalidClientExportFinding,
958 token: |_t| "invalid-client-export",
959 parts: |f, p| vec![p.key(&f.export.path), f.export.export_name.clone()],
960 position: |g| (g.export.line, g.export.col, 0),
961);
962
963identified!(
964 MixedClientServerBarrelFinding,
965 token: |_t| "mixed-client-server-barrel",
966 parts: |f, p| vec![p.key(&f.barrel.path)],
967 position: |g| (g.barrel.line, g.barrel.col, 0),
968);
969
970identified!(
971 MisplacedDirectiveFinding,
972 token: |_t| "misplaced-directive",
973 parts: |f, p| vec![
974 p.key(&f.directive_site.path),
975 f.directive_site.directive.clone(),
976 ],
977 position: |g| (g.directive_site.line, g.directive_site.col, 0),
978);
979
980identified!(
981 UnprovidedInjectFinding,
982 token: |_t| "unprovided-inject",
983 parts: |f, p| vec![p.key(&f.inject.path), f.inject.key_name.clone()],
984 position: |g| (g.inject.line, g.inject.col, 0),
985);
986
987identified!(
988 UnrenderedComponentFinding,
989 token: |_t| "unrendered-component",
990 parts: |f, p| vec![p.key(&f.component.path), f.component.component_name.clone()],
991 position: |g| (g.component.line, g.component.col, 0),
992);
993
994identified!(
995 RouteCollisionFinding,
996 token: |_t| "route-collision",
997 parts: |f, p| vec![p.key(&f.collision.path), f.collision.url.clone()],
998 position: |g| (g.collision.line, g.collision.col, 0),
999);
1000
1001identified!(
1002 DynamicSegmentNameConflictFinding,
1003 token: |_t| "dynamic-segment-name-conflict",
1004 parts: |f, p| vec![p.key(&f.conflict.path), f.conflict.position.clone()],
1005 position: |g| (g.conflict.line, g.conflict.col, 0),
1006);
1007
1008identified!(
1009 UnusedComponentPropFinding,
1010 token: |_t| "unused-component-prop",
1011 parts: |f, p| vec![
1012 p.key(&f.prop.path),
1013 f.prop.component_name.clone(),
1014 f.prop.prop_name.clone(),
1015 ],
1016 position: |g| (g.prop.line, g.prop.col, 0),
1017);
1018identified!(
1019 AbsentComponentPropFinding,
1020 token: |_t| "absent-component-prop",
1021 parts: |f, p| vec![
1022 p.key(&f.prop.path),
1023 f.prop.component_name.clone(),
1024 f.prop.prop_name.clone(),
1025 ],
1026 position: |g| (g.prop.line, g.prop.col, 0),
1027);
1028
1029identified!(
1030 UnusedComponentEmitFinding,
1031 token: |_t| "unused-component-emit",
1032 parts: |f, p| vec![
1033 p.key(&f.emit.path),
1034 f.emit.component_name.clone(),
1035 f.emit.emit_name.clone(),
1036 ],
1037 position: |g| (g.emit.line, g.emit.col, 0),
1038);
1039
1040identified!(
1041 UnusedComponentInputFinding,
1042 token: |_t| "unused-component-input",
1043 parts: |f, p| vec![
1044 p.key(&f.input.path),
1045 f.input.component_name.clone(),
1046 f.input.input_name.clone(),
1047 ],
1048 position: |g| (g.input.line, g.input.col, 0),
1049);
1050
1051identified!(
1052 UnusedComponentOutputFinding,
1053 token: |_t| "unused-component-output",
1054 parts: |f, p| vec![
1055 p.key(&f.output.path),
1056 f.output.component_name.clone(),
1057 f.output.output_name.clone(),
1058 ],
1059 position: |g| (g.output.line, g.output.col, 0),
1060);
1061
1062identified!(
1063 UnusedSvelteEventFinding,
1064 token: |_t| "unused-svelte-event",
1065 parts: |f, p| vec![
1066 p.key(&f.event.path),
1067 f.event.component_name.clone(),
1068 f.event.event_name.clone(),
1069 ],
1070 position: |g| (g.event.line, g.event.col, 0),
1071);
1072
1073identified!(
1074 UnusedServerActionFinding,
1075 token: |_t| "unused-server-action",
1076 parts: |f, p| vec![p.key(&f.action.path), f.action.action_name.clone()],
1077 position: |g| (g.action.line, g.action.col, 0),
1078);
1079
1080identified!(
1081 UnusedLoadDataKeyFinding,
1082 token: |_t| "unused-load-data-key",
1083 parts: |f, p| vec![p.key(&f.key.path), f.key.key_name.clone()],
1084 position: |g| (g.key.line, g.key.col, 0),
1085);
1086
1087identified!(
1088 PropDrillingChainFinding,
1089 token: |_t| "prop-drilling",
1090 parts: |f, p| {
1091 let origin = f.chain.hops.first();
1092 vec![
1093 origin.map(|hop| p.key(&hop.file)).unwrap_or_default(),
1094 origin.map(|hop| hop.component.clone()).unwrap_or_default(),
1095 f.chain.prop.clone(),
1096 ]
1097 },
1098 position: |g| (g.chain.hops.first().map_or(0, |hop| hop.line), 0, 0),
1099);
1100
1101identified!(
1102 ThinWrapperFinding,
1103 token: |_t| "thin-wrapper",
1104 parts: |f, p| vec![p.key(&f.wrapper.file), f.wrapper.component.clone()],
1105 position: |g| (g.wrapper.line, 0, 0),
1106);
1107
1108identified!(
1109 DuplicatePropShapeFinding,
1110 token: |_t| "duplicate-prop-shape",
1111 parts: |f, p| vec![p.key(&f.shape.file), f.shape.component.clone()],
1112 position: |g| (g.shape.line, 0, 0),
1113);
1114
1115#[cfg(test)]
1116mod tests {
1117 use std::path::PathBuf;
1118
1119 use super::*;
1120 use crate::results::{UnusedExport, UnusedFile, UnusedMember};
1121
1122 #[test]
1126 fn fnv1a64_parts_golden_values() {
1127 assert_eq!(
1128 fnv1a64_parts(&["src/index.ts", "FEATURE_X", "3"]),
1129 "2278c9d9bd9d2dd2"
1130 );
1131 assert_eq!(
1132 fnv1a64_parts(&["fallow/unused-file", "src/orphan.ts"]),
1133 "c03b925ddb7d871a"
1134 );
1135 assert_eq!(fnv1a64_parts(&[]), "cbf29ce484222325");
1136 }
1137
1138 #[test]
1139 fn fnv1a64_hex_golden_value() {
1140 assert_eq!(
1141 fnv1a64_hex(b"security/client-server-leak:src/a.ts:3:7"),
1142 "89b6dece9d8b96d2"
1143 );
1144 }
1145
1146 #[test]
1147 fn finding_id_syntax_accepts_base_and_tiebreak_ids() {
1148 assert!(is_dead_code_finding_id(
1149 "dc1:unused-export:81a349a3b9ea3b15"
1150 ));
1151 assert!(is_dead_code_finding_id(
1152 "dc1:unused-class-member:0123456789abcdef~1"
1153 ));
1154 assert!(is_dead_code_finding_id(
1155 "dc1:unused-file:0123456789abcdef~12"
1156 ));
1157 }
1158
1159 #[test]
1160 fn finding_id_syntax_refuses_other_shapes() {
1161 for bad in [
1162 "",
1163 "dc1",
1164 "dc1:unused-export",
1165 "dc1::0123456789abcdef",
1166 "dc2:unused-export:0123456789abcdef",
1167 "dc1:unused-export:0123456789ABCDEF",
1168 "dc1:unused-export:0123456789abcde",
1169 "dc1:unused-export:0123456789abcdef0",
1170 "dc1:unused-export:0123456789abcdef~",
1171 "dc1:unused-export:0123456789abcdef~0",
1172 "dc1:unused-export:0123456789abcdef~01",
1173 "dc1:unused-export:0123456789abcdef~x",
1174 "dc1:Unused-Export:0123456789abcdef",
1175 "dc1:unused-export:0123456789abcdef:extra",
1176 ] {
1177 assert!(!is_dead_code_finding_id(bad), "{bad:?} was accepted");
1178 }
1179 }
1180
1181 #[test]
1182 fn retain_by_id_keeps_only_the_requested_findings() {
1183 let root = PathBuf::from("/repo");
1184 let mut results = AnalysisResults {
1185 unused_files: vec![
1186 UnusedFileFinding::with_actions(UnusedFile {
1187 path: root.join("src/a.ts"),
1188 }),
1189 UnusedFileFinding::with_actions(UnusedFile {
1190 path: root.join("src/b.ts"),
1191 }),
1192 ],
1193 ..AnalysisResults::default()
1194 };
1195 stamp_dead_code_finding_ids(&mut results, &root);
1196 let kept = results.unused_files[1]
1197 .finding_id
1198 .clone()
1199 .expect("stamped id");
1200 let ids: FxHashSet<String> = [kept.clone(), "dc1:unused-file:0000000000000000".to_owned()]
1201 .into_iter()
1202 .collect();
1203
1204 let present = present_dead_code_finding_ids(&mut results, &ids);
1205 assert_eq!(results.unused_files.len(), 2, "present only reads");
1206 let matched = retain_dead_code_findings_by_id(&mut results, &ids);
1207
1208 assert_eq!(present, matched);
1209 assert_eq!(matched, std::iter::once(kept.clone()).collect());
1210 assert_eq!(results.unused_files.len(), 1);
1211 assert_eq!(
1212 results.unused_files[0].finding_id.as_deref(),
1213 Some(kept.as_str())
1214 );
1215 }
1216
1217 #[test]
1218 fn dead_code_finding_id_golden_values() {
1219 assert_eq!(
1220 dead_code_finding_id("unused-export", &["src/utils.ts", "helper"]),
1221 "dc1:unused-export:81a349a3b9ea3b15"
1222 );
1223 assert_eq!(
1224 dead_code_finding_id("unused-file", &["src/orphan.ts"]),
1225 "dc1:unused-file:9fd2d414a2a9e611"
1226 );
1227 assert_eq!(
1228 dead_code_finding_id("unused-class-member", &["src/service.ts", "Service", "run"]),
1229 "dc1:unused-class-member:675fa79a4c2f244f"
1230 );
1231 assert_eq!(
1232 dead_code_finding_id("unused-dependency", &["package.json", "lodash"]),
1233 "dc1:unused-dependency:e2f217ff5a209568"
1234 );
1235 assert_eq!(
1236 dead_code_finding_id("duplicate-export", &["Button", "src/a.ts|src/b.ts"]),
1237 "dc1:duplicate-export:17c140e16d40660a"
1238 );
1239 }
1240
1241 fn export(root: &Path, name: &str, line: u32) -> UnusedExportFinding {
1242 UnusedExportFinding::with_actions(UnusedExport {
1243 path: root.join("src/utils.ts"),
1244 export_name: name.to_owned(),
1245 is_type_only: false,
1246 line,
1247 col: 7,
1248 span_start: line * 10,
1249 is_re_export: false,
1250 deprecated: false,
1251 deprecated_reason: None,
1252 })
1253 }
1254
1255 fn member(root: &Path, line: u32) -> UnusedClassMemberFinding {
1256 UnusedClassMemberFinding::with_actions(UnusedMember {
1257 path: root.join("src/service.ts"),
1258 parent_name: "Service".to_owned(),
1259 member_name: "run".to_owned(),
1260 kind: crate::extract::MemberKind::ClassMethod,
1261 line,
1262 col: 2,
1263 })
1264 }
1265
1266 fn ids<T: IdentifiedFinding>(findings: &[T]) -> Vec<Option<String>> {
1267 findings
1268 .iter()
1269 .map(|finding| finding.finding_id().map(str::to_owned))
1270 .collect()
1271 }
1272
1273 #[test]
1274 fn stamping_uses_root_relative_paths_and_ignores_lines() {
1275 let root = PathBuf::from("/repo");
1276 let mut results = AnalysisResults {
1277 unused_files: vec![UnusedFileFinding::with_actions(UnusedFile {
1278 path: root.join("src/orphan.ts"),
1279 })],
1280 unused_exports: vec![export(&root, "helper", 40)],
1281 ..AnalysisResults::default()
1282 };
1283
1284 stamp_dead_code_finding_ids(&mut results, &root);
1285
1286 assert_eq!(
1287 ids(&results.unused_files),
1288 vec![Some("dc1:unused-file:9fd2d414a2a9e611".to_owned())]
1289 );
1290 assert_eq!(
1291 ids(&results.unused_exports),
1292 vec![Some("dc1:unused-export:81a349a3b9ea3b15".to_owned())]
1293 );
1294 }
1295
1296 #[test]
1297 fn duplicate_subjects_get_a_tiebreak_suffix_in_line_order() {
1298 let root = PathBuf::from("/repo");
1299 let mut results = AnalysisResults {
1300 unused_class_members: vec![member(&root, 30), member(&root, 10), member(&root, 20)],
1301 ..AnalysisResults::default()
1302 };
1303
1304 stamp_dead_code_finding_ids(&mut results, &root);
1305
1306 let base = "dc1:unused-class-member:675fa79a4c2f244f";
1307 assert_eq!(
1308 ids(&results.unused_class_members),
1309 vec![
1310 Some(format!("{base}~2")),
1311 Some(base.to_owned()),
1312 Some(format!("{base}~1")),
1313 ]
1314 );
1315 }
1316
1317 #[test]
1318 fn stamping_does_not_depend_on_input_order() {
1319 let root = PathBuf::from("/repo");
1320 let forward = vec![member(&root, 10), member(&root, 20), member(&root, 30)];
1321 let mut reversed = forward.clone();
1322 reversed.reverse();
1323 let mut a = AnalysisResults {
1324 unused_class_members: forward,
1325 ..AnalysisResults::default()
1326 };
1327 let mut b = AnalysisResults {
1328 unused_class_members: reversed,
1329 ..AnalysisResults::default()
1330 };
1331
1332 stamp_dead_code_finding_ids(&mut a, &root);
1333 stamp_dead_code_finding_ids(&mut b, &root);
1334
1335 let mut a_pairs: Vec<(u32, Option<String>)> = a
1336 .unused_class_members
1337 .iter()
1338 .map(|finding| (finding.member.line, finding.finding_id.clone()))
1339 .collect();
1340 let mut b_pairs: Vec<(u32, Option<String>)> = b
1341 .unused_class_members
1342 .iter()
1343 .map(|finding| (finding.member.line, finding.finding_id.clone()))
1344 .collect();
1345 a_pairs.sort();
1346 b_pairs.sort();
1347 assert_eq!(a_pairs, b_pairs);
1348 }
1349
1350 #[test]
1351 fn stamping_twice_gives_the_same_ids() {
1352 let root = PathBuf::from("/repo");
1353 let mut results = AnalysisResults {
1354 unused_class_members: vec![member(&root, 10), member(&root, 20)],
1355 unused_exports: vec![export(&root, "helper", 3)],
1356 ..AnalysisResults::default()
1357 };
1358
1359 stamp_dead_code_finding_ids(&mut results, &root);
1360 let first = (
1361 ids(&results.unused_class_members),
1362 ids(&results.unused_exports),
1363 );
1364 stamp_dead_code_finding_ids(&mut results, &root);
1365
1366 assert_eq!(
1367 first,
1368 (
1369 ids(&results.unused_class_members),
1370 ids(&results.unused_exports),
1371 )
1372 );
1373 }
1374
1375 #[test]
1376 fn stamping_missing_ids_keeps_existing_ids_and_takes_a_free_suffix() {
1377 let root = PathBuf::from("/repo");
1378 let mut results = AnalysisResults {
1379 unused_class_members: vec![member(&root, 10), member(&root, 20)],
1380 ..AnalysisResults::default()
1381 };
1382 stamp_dead_code_finding_ids(&mut results, &root);
1383 results.unused_class_members.remove(0);
1385 results.unused_class_members.push(member(&root, 5));
1386
1387 stamp_missing_dead_code_finding_ids(&mut results, &root);
1388
1389 let base = "dc1:unused-class-member:675fa79a4c2f244f";
1390 assert_eq!(
1391 ids(&results.unused_class_members),
1392 vec![Some(format!("{base}~1")), Some(base.to_owned())]
1393 );
1394 }
1395
1396 fn duplicate_export(root: &Path, files: &[&str]) -> DuplicateExportFinding {
1397 DuplicateExportFinding::with_actions(crate::results::DuplicateExport {
1398 export_name: "Button".to_owned(),
1399 locations: files
1400 .iter()
1401 .map(|file| crate::results::DuplicateLocation {
1402 path: root.join(file),
1403 line: 1,
1404 col: 0,
1405 })
1406 .collect(),
1407 })
1408 }
1409
1410 #[test]
1411 fn a_path_set_without_special_characters_keeps_its_golden_id() {
1412 let root = PathBuf::from("/repo");
1413 let mut results = AnalysisResults {
1414 duplicate_exports: vec![duplicate_export(&root, &["src/b.ts", "src/a.ts"])],
1415 ..AnalysisResults::default()
1416 };
1417
1418 stamp_dead_code_finding_ids(&mut results, &root);
1419
1420 assert_eq!(
1421 ids(&results.duplicate_exports),
1422 vec![Some("dc1:duplicate-export:17c140e16d40660a".to_owned())]
1423 );
1424 }
1425
1426 #[test]
1427 fn a_pipe_in_a_file_name_does_not_collide_with_two_files() {
1428 let root = PathBuf::from("/repo");
1429 let paths = IdentityPaths::new(&root);
1430 let one = root.join("src/a.ts|src/b.ts");
1431 let first = root.join("src/a.ts");
1432 let second = root.join("src/b.ts");
1433
1434 assert_eq!(paths.set([one.as_path()]), "src/a.ts%7Csrc/b.ts");
1435 assert_eq!(
1436 paths.set([first.as_path(), second.as_path()]),
1437 "src/a.ts|src/b.ts"
1438 );
1439 assert_eq!(paths.set([root.join("100%.ts").as_path()]), "100%25.ts");
1440
1441 let mut results = AnalysisResults {
1442 duplicate_exports: vec![
1443 duplicate_export(&root, &["src/a.ts|src/b.ts"]),
1444 duplicate_export(&root, &["src/a.ts", "src/b.ts"]),
1445 ],
1446 ..AnalysisResults::default()
1447 };
1448 stamp_dead_code_finding_ids(&mut results, &root);
1449
1450 let stamped = ids(&results.duplicate_exports);
1451 assert_ne!(stamped[0], stamped[1]);
1452 assert!(
1453 stamped.iter().flatten().all(|id| !id.contains('~')),
1454 "the two findings must not share a base id: {stamped:?}"
1455 );
1456 }
1457
1458 #[test]
1459 fn a_package_cycle_gets_a_golden_id_from_its_sorted_package_roots() {
1460 let root = PathBuf::from("/repo");
1461 let cycle = |roots: &[&str]| {
1462 PackageCycleFinding::with_actions(crate::results::PackageCycle {
1463 packages: vec!["@x/a".to_owned(), "@x/b".to_owned()],
1464 package_roots: roots.iter().map(|dir| root.join(dir)).collect(),
1465 length: 2,
1466 edges: Vec::new(),
1467 group_truncated: false,
1468 })
1469 };
1470 let mut results = AnalysisResults {
1471 package_cycles: vec![cycle(&["packages/b", "packages/a"])],
1472 ..AnalysisResults::default()
1473 };
1474
1475 stamp_dead_code_finding_ids(&mut results, &root);
1476
1477 assert_eq!(
1478 ids(&results.package_cycles),
1479 vec![Some("dc1:package-cycle:fed127e4525389ac".to_owned())]
1480 );
1481 }
1482
1483 #[test]
1484 fn windows_separators_give_the_same_id() {
1485 let mut windows = AnalysisResults {
1486 unused_files: vec![UnusedFileFinding::with_actions(UnusedFile {
1487 path: PathBuf::from("src\\orphan.ts"),
1488 })],
1489 ..AnalysisResults::default()
1490 };
1491
1492 stamp_dead_code_finding_ids(&mut windows, Path::new(""));
1493
1494 assert_eq!(
1495 ids(&windows.unused_files),
1496 vec![Some("dc1:unused-file:9fd2d414a2a9e611".to_owned())]
1497 );
1498 }
1499
1500 #[test]
1501 fn canonical_keys_are_readable_and_escape_the_separator() {
1502 assert_eq!(
1503 dead_code_canonical_key("unused-export", &["src/utils.ts", "helper"]),
1504 "unused-export:src/utils.ts:helper"
1505 );
1506 assert_eq!(
1507 dead_code_canonical_key("unused-file", &["C:/repo/a%b.ts"]),
1508 "unused-file:C%3A/repo/a%25b.ts"
1509 );
1510 assert_ne!(
1511 dead_code_canonical_key("unused-export", &["a:b", "c"]),
1512 dead_code_canonical_key("unused-export", &["a", "b:c"])
1513 );
1514 }
1515
1516 #[test]
1517 fn the_canonical_key_and_the_id_use_the_same_parts() {
1518 let root = PathBuf::from("/repo");
1519 let paths = IdentityPaths::new(&root);
1520 let finding = member(&root, 10);
1521
1522 assert_eq!(
1523 finding.canonical_key(&paths),
1524 "unused-class-member:src/service.ts:Service:run"
1525 );
1526 assert_eq!(
1527 base_id(&finding, &paths),
1528 dead_code_finding_id("unused-class-member", &["src/service.ts", "Service", "run"])
1529 );
1530 assert_eq!(
1531 member(&root, 99).canonical_key(&paths),
1532 finding.canonical_key(&paths)
1533 );
1534 }
1535
1536 #[test]
1537 fn occurrence_keys_number_repeated_keys_in_input_order() {
1538 let root = PathBuf::from("/repo");
1539 let paths = IdentityPaths::new(&root);
1540 let findings = vec![member(&root, 10), member(&root, 20), member(&root, 30)];
1541
1542 assert_eq!(
1543 dead_code_occurrence_keys(&findings, &paths),
1544 vec![
1545 "unused-class-member:src/service.ts:Service:run".to_owned(),
1546 "unused-class-member:src/service.ts:Service:run:~1".to_owned(),
1547 "unused-class-member:src/service.ts:Service:run:~2".to_owned(),
1548 ]
1549 );
1550 }
1551
1552 #[test]
1553 fn the_suppression_reason_is_not_part_of_the_id() {
1554 let suppression = |reason: Option<&str>| StaleSuppression {
1555 path: PathBuf::from("src/a.ts"),
1556 line: 3,
1557 col: 0,
1558 origin: SuppressionOrigin::Comment {
1559 issue_kind: Some("unused-export".to_owned()),
1560 reason: reason.map(str::to_owned),
1561 is_file_level: false,
1562 kind_known: true,
1563 },
1564 missing_reason: false,
1565 finding_id: None,
1566 actions: Vec::new(),
1567 effective_severity: None,
1568 };
1569 let mut results = AnalysisResults {
1570 stale_suppressions: vec![suppression(None)],
1571 ..AnalysisResults::default()
1572 };
1573 let mut with_reason = AnalysisResults {
1574 stale_suppressions: vec![suppression(Some("kept for the plugin API"))],
1575 ..AnalysisResults::default()
1576 };
1577
1578 stamp_dead_code_finding_ids(&mut results, Path::new(""));
1579 stamp_dead_code_finding_ids(&mut with_reason, Path::new(""));
1580
1581 assert_eq!(
1582 ids(&results.stale_suppressions),
1583 ids(&with_reason.stale_suppressions)
1584 );
1585 assert!(
1586 ids(&results.stale_suppressions)[0]
1587 .as_deref()
1588 .is_some_and(|id| id.starts_with("dc1:stale-suppression:"))
1589 );
1590 }
1591}