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