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