1use std::path::{Path, PathBuf};
2
3pub use fallow_types::trace::{
4 ClassMemberTrace, CloneTrace, DependencyTrace, ExportReference, ExportTrace, FileTrace,
5 ImpactClosureGap, ImpactClosureTrace, PipelineTimings, ReExportChain, TracedCloneGroup,
6 TracedExport, TracedReExport,
7};
8use rustc_hash::FxHashSet;
9
10use crate::duplicates::{
11 CloneFingerprintSet, CloneGroup, CloneInstance, DuplicationReport, dominant_identifier,
12 group_refactoring_suggestion,
13};
14use crate::graph::{ModuleGraph, ReferenceKind};
15
16fn path_matches(module_path: &Path, root: &Path, user_path: &str) -> bool {
21 let user_path_norm = user_path.replace('\\', "/");
22 let rel = module_path.strip_prefix(root).unwrap_or(module_path);
23 let rel_str = rel.to_string_lossy().replace('\\', "/");
24 let module_str = module_path.to_string_lossy().replace('\\', "/");
25 if rel_str == user_path_norm || module_str == user_path_norm {
26 return true;
27 }
28 if dunce::canonicalize(root).is_ok_and(|canonical_root| {
29 module_path
30 .strip_prefix(&canonical_root)
31 .is_ok_and(|rel| rel.to_string_lossy().replace('\\', "/") == user_path_norm)
32 }) {
33 return true;
34 }
35 module_str.ends_with(&format!("/{user_path_norm}"))
36}
37
38fn reference_to_export_reference(
40 graph: &ModuleGraph,
41 root: &Path,
42 r: &crate::graph::SymbolReference,
43) -> ExportReference {
44 let from_path = graph.modules.get(r.from_file.0 as usize).map_or_else(
45 || PathBuf::from(format!("<unknown:{}>", r.from_file.0)),
46 |m| m.path.strip_prefix(root).unwrap_or(&m.path).to_path_buf(),
47 );
48 ExportReference {
49 from_file: from_path,
50 kind: format_reference_kind(r.kind),
51 }
52}
53
54fn collect_re_export_chains(
57 graph: &ModuleGraph,
58 root: &Path,
59 target_file_id: crate::discover::FileId,
60 export_name: &str,
61) -> Vec<ReExportChain> {
62 graph
63 .modules
64 .iter()
65 .flat_map(|m| {
66 m.re_exports
67 .iter()
68 .filter(move |re| {
69 re.source_file == target_file_id
70 && (re.imported_name == export_name || re.imported_name == "*")
71 })
72 .map(move |re| {
73 let barrel_export = m.exports.iter().find(|e| {
74 if re.exported_name == "*" {
75 e.name.to_string() == export_name
76 } else {
77 e.name.to_string() == re.exported_name
78 }
79 });
80 ReExportChain {
81 barrel_file: m.path.strip_prefix(root).unwrap_or(&m.path).to_path_buf(),
82 exported_as: re.exported_name.clone(),
83 reference_count: barrel_export.map_or(0, |e| e.references.len()),
84 }
85 })
86 })
87 .collect()
88}
89
90fn export_trace_reason(
92 module: &crate::graph::ModuleNode,
93 reference_count: usize,
94 is_used: bool,
95 re_export_chains: &[ReExportChain],
96) -> String {
97 if !module.is_reachable() {
98 "File is unreachable from any entry point".to_string()
99 } else if is_used {
100 format!(
101 "Used by {} file(s){}",
102 reference_count,
103 if re_export_chains.is_empty() {
104 String::new()
105 } else {
106 format!(", re-exported through {} barrel(s)", re_export_chains.len())
107 }
108 )
109 } else if module.is_entry_point() {
110 "No internal references, but file is an entry point (export is externally accessible)"
111 .to_string()
112 } else if !re_export_chains.is_empty() {
113 format!(
114 "Re-exported through {} barrel(s) but no consumer imports it through the barrel",
115 re_export_chains.len()
116 )
117 } else {
118 "No references found, export is unused".to_string()
119 }
120}
121
122#[must_use]
124pub fn trace_export(
125 graph: &ModuleGraph,
126 root: &Path,
127 file_path: &str,
128 export_name: &str,
129) -> Option<ExportTrace> {
130 let module = graph
131 .modules
132 .iter()
133 .find(|m| path_matches(&m.path, root, file_path))?;
134
135 let export = module
136 .exports
137 .iter()
138 .filter(|e| export_name_matches(e, export_name))
139 .max_by_key(|e| (!e.references.is_empty(), !e.is_type_only))?;
140
141 let direct_references: Vec<ExportReference> = export
142 .references
143 .iter()
144 .map(|r| reference_to_export_reference(graph, root, r))
145 .collect();
146
147 let re_export_chains = collect_re_export_chains(graph, root, module.file_id, export_name);
148
149 let is_used = !export.references.is_empty();
150 let reason = export_trace_reason(module, export.references.len(), is_used, &re_export_chains);
151
152 Some(ExportTrace {
153 file: module
154 .path
155 .strip_prefix(root)
156 .unwrap_or(&module.path)
157 .to_path_buf(),
158 export_name: export_name.to_string(),
159 file_reachable: module.is_reachable(),
160 is_entry_point: module.is_entry_point(),
161 is_used,
162 direct_references,
163 re_export_chains,
164 reason,
165 })
166}
167
168#[must_use]
174pub fn trace_class_member(
175 graph: &ModuleGraph,
176 root: &Path,
177 file_path: &str,
178 member_name: &str,
179) -> Option<ClassMemberTrace> {
180 use fallow_types::extract::MemberKind;
181
182 let module = graph
183 .modules
184 .iter()
185 .find(|m| path_matches(&m.path, root, file_path))?;
186
187 let (owner, member_kind) = module
191 .exports
192 .iter()
193 .filter_map(|export| {
194 export
195 .members
196 .iter()
197 .find(|member| member.name == member_name)
198 .map(|member| (export, member.kind))
199 })
200 .max_by_key(|(export, _)| (!export.references.is_empty(), !export.is_type_only))?;
201
202 let owner_name = owner.name.to_string();
203 let owner_trace = trace_export(graph, root, file_path, &owner_name)?;
210
211 let (kind_str, filter_flag) = match member_kind {
212 MemberKind::ClassMethod => ("class-method", Some("--unused-class-members")),
213 MemberKind::ClassProperty => ("class-property", Some("--unused-class-members")),
214 MemberKind::EnumMember => ("enum-member", Some("--unused-enum-members")),
215 MemberKind::StoreMember => ("store-member", Some("--unused-store-members")),
216 MemberKind::NamespaceMember => ("namespace-member", None),
217 };
218
219 let reason = class_member_trace_reason(
220 member_name,
221 &owner_name,
222 kind_str,
223 filter_flag,
224 file_path,
225 &owner_trace,
226 );
227
228 Some(ClassMemberTrace {
229 file: owner_trace.file,
230 member_name: member_name.to_string(),
231 member_kind: kind_str.to_string(),
232 owner_export: owner_name,
233 owner_is_used: owner_trace.is_used,
234 owner_file_reachable: owner_trace.file_reachable,
235 owner_is_entry_point: owner_trace.is_entry_point,
236 owner_direct_references: owner_trace.direct_references,
237 owner_re_export_chains: owner_trace.re_export_chains,
238 reason,
239 })
240}
241
242fn class_member_trace_reason(
245 member_name: &str,
246 owner_name: &str,
247 kind_str: &str,
248 filter_flag: Option<&str>,
249 file_path: &str,
250 owner_trace: &ExportTrace,
251) -> String {
252 let head =
253 format!("'{member_name}' is a {kind_str} of '{owner_name}', not a top-level export. ");
254 let body = if !owner_trace.file_reachable {
255 format!(
256 "The file is not reachable from any entry point, so '{owner_name}' and all its \
257 members are dead (see the unused-file finding)."
258 )
259 } else if !owner_trace.is_used {
260 format!(
261 "'{owner_name}' is reachable but referenced by no file, so it is reported as an \
262 unused export and its members are not judged individually."
263 )
264 } else {
265 let refs = owner_trace.direct_references.len();
266 match filter_flag {
267 Some(flag) => format!(
268 "'{owner_name}' is used by {refs} file(s); whether '{member_name}' itself is \
269 flagged depends on cross-file member-access resolution. Run \
270 `fallow dead-code {flag} --file {file_path}` to see the member finding."
271 ),
272 None => format!(
273 "'{owner_name}' is used by {refs} file(s); '{member_name}' is credited through \
274 its namespace export."
275 ),
276 }
277 };
278 format!("{head}{body}")
279}
280
281fn export_name_matches(export: &crate::graph::ExportSymbol, export_name: &str) -> bool {
282 let name_str = export.name.to_string();
283 name_str == export_name || (export_name == "default" && name_str == "default")
284}
285
286fn traced_exports(
288 graph: &ModuleGraph,
289 root: &Path,
290 module: &crate::graph::ModuleNode,
291) -> Vec<TracedExport> {
292 module
293 .exports
294 .iter()
295 .map(|e| TracedExport {
296 name: e.name.to_string(),
297 is_type_only: e.is_type_only,
298 reference_count: e.references.len(),
299 referenced_by: e
300 .references
301 .iter()
302 .map(|r| reference_to_export_reference(graph, root, r))
303 .collect(),
304 })
305 .collect()
306}
307
308fn traced_imports_from(
310 graph: &ModuleGraph,
311 root: &Path,
312 module: &crate::graph::ModuleNode,
313) -> Vec<PathBuf> {
314 graph
315 .edges_for(module.file_id)
316 .iter()
317 .filter_map(|target_id| {
318 graph
319 .modules
320 .get(target_id.0 as usize)
321 .map(|m| m.path.strip_prefix(root).unwrap_or(&m.path).to_path_buf())
322 })
323 .collect()
324}
325
326fn traced_imported_by(
328 graph: &ModuleGraph,
329 root: &Path,
330 module: &crate::graph::ModuleNode,
331) -> Vec<PathBuf> {
332 graph
333 .reverse_deps
334 .get(module.file_id.0 as usize)
335 .map(|deps| {
336 deps.iter()
337 .filter_map(|fid| {
338 graph
339 .modules
340 .get(fid.0 as usize)
341 .map(|m| m.path.strip_prefix(root).unwrap_or(&m.path).to_path_buf())
342 })
343 .collect()
344 })
345 .unwrap_or_default()
346}
347
348fn traced_re_exports(
350 graph: &ModuleGraph,
351 root: &Path,
352 module: &crate::graph::ModuleNode,
353) -> Vec<TracedReExport> {
354 module
355 .re_exports
356 .iter()
357 .map(|re| {
358 let source_path = graph.modules.get(re.source_file.0 as usize).map_or_else(
359 || PathBuf::from(format!("<unknown:{}>", re.source_file.0)),
360 |m| m.path.strip_prefix(root).unwrap_or(&m.path).to_path_buf(),
361 );
362 TracedReExport {
363 source_file: source_path,
364 imported_name: re.imported_name.clone(),
365 exported_name: re.exported_name.clone(),
366 }
367 })
368 .collect()
369}
370
371#[must_use]
373pub fn trace_file(graph: &ModuleGraph, root: &Path, file_path: &str) -> Option<FileTrace> {
374 let module = graph
375 .modules
376 .iter()
377 .find(|m| path_matches(&m.path, root, file_path))?;
378
379 Some(FileTrace {
380 file: module
381 .path
382 .strip_prefix(root)
383 .unwrap_or(&module.path)
384 .to_path_buf(),
385 is_reachable: module.is_reachable(),
386 is_entry_point: module.is_entry_point(),
387 exports: traced_exports(graph, root, module),
388 imports_from: traced_imports_from(graph, root, module),
389 imported_by: traced_imported_by(graph, root, module),
390 re_exports: traced_re_exports(graph, root, module),
391 })
392}
393
394#[expect(
403 clippy::implicit_hasher,
404 reason = "fallow standardizes on FxHashSet across the workspace"
405)]
406#[must_use]
407pub fn trace_dependency(
408 graph: &ModuleGraph,
409 root: &Path,
410 package_name: &str,
411 script_used_packages: &FxHashSet<String>,
412) -> DependencyTrace {
413 let imported_by: Vec<PathBuf> = graph
414 .package_usage
415 .get(package_name)
416 .map(|ids| {
417 ids.iter()
418 .filter_map(|fid| {
419 graph
420 .modules
421 .get(fid.0 as usize)
422 .map(|m| m.path.strip_prefix(root).unwrap_or(&m.path).to_path_buf())
423 })
424 .collect()
425 })
426 .unwrap_or_default();
427
428 let type_only_imported_by: Vec<PathBuf> = graph
429 .type_only_package_usage
430 .get(package_name)
431 .map(|ids| {
432 ids.iter()
433 .filter_map(|fid| {
434 graph
435 .modules
436 .get(fid.0 as usize)
437 .map(|m| m.path.strip_prefix(root).unwrap_or(&m.path).to_path_buf())
438 })
439 .collect()
440 })
441 .unwrap_or_default();
442
443 let import_count = imported_by.len();
444 let used_in_scripts = script_used_packages.contains(package_name);
445 DependencyTrace {
446 package_name: package_name.to_string(),
447 imported_by,
448 type_only_imported_by,
449 used_in_scripts,
450 is_used: import_count > 0 || used_in_scripts,
451 import_count,
452 }
453}
454
455fn format_reference_kind(kind: ReferenceKind) -> String {
456 match kind {
457 ReferenceKind::NamedImport => "named import".to_string(),
458 ReferenceKind::DefaultImport => "default import".to_string(),
459 ReferenceKind::NamespaceImport => "namespace import".to_string(),
460 ReferenceKind::ReExport => "re-export".to_string(),
461 ReferenceKind::DynamicImport => "dynamic import".to_string(),
462 ReferenceKind::SideEffectImport => "side-effect import".to_string(),
463 }
464}
465
466#[must_use]
473pub fn trace_impact_closure(
474 graph: &ModuleGraph,
475 root: &Path,
476 file_path: &str,
477) -> Option<ImpactClosureTrace> {
478 let module = graph
479 .modules
480 .iter()
481 .find(|m| path_matches(&m.path, root, file_path))?;
482
483 let closure = graph.impact_closure(&[module.file_id]);
484 let paths = graph.closure_with_paths(&closure, root);
485
486 let seed = paths
487 .in_diff
488 .first()
489 .cloned()
490 .unwrap_or_else(|| file_path.replace('\\', "/"));
491
492 let coordination_gap = paths
493 .coordination_gap
494 .into_iter()
495 .map(|gap| ImpactClosureGap {
496 consumer_file: gap.consumer_file,
497 consumed_symbols: gap.consumed_symbols,
498 note: "syntactic attention pointer, not a correctness proof".to_string(),
499 })
500 .collect();
501
502 Some(ImpactClosureTrace {
503 seed,
504 affected_not_shown: paths.affected_not_shown,
505 coordination_gap,
506 })
507}
508
509fn build_traced_group(
513 group: &CloneGroup,
514 root: &Path,
515 fingerprints: &CloneFingerprintSet,
516) -> TracedCloneGroup {
517 TracedCloneGroup {
518 fingerprint: fingerprints.fingerprint_for_group(group),
519 token_count: group.token_count,
520 line_count: group.line_count,
521 instances: group
522 .instances
523 .iter()
524 .map(|inst| relativize_instance(inst, root))
525 .collect(),
526 suggestion: group_refactoring_suggestion(group),
527 suggested_name: dominant_identifier(group),
528 }
529}
530
531#[must_use]
532pub fn trace_clone(
533 report: &DuplicationReport,
534 root: &Path,
535 file_path: &str,
536 line: usize,
537) -> CloneTrace {
538 let resolved = root.join(file_path);
539 let mut matched_instance = None;
540 let mut clone_groups = Vec::new();
541 let fingerprints = CloneFingerprintSet::from_groups(&report.clone_groups);
542
543 for group in &report.clone_groups {
544 let matching = group.instances.iter().find(|inst| {
545 let inst_matches = inst.file == resolved
546 || inst.file.strip_prefix(root).unwrap_or(&inst.file) == Path::new(file_path);
547 inst_matches && inst.start_line <= line && line <= inst.end_line
548 });
549
550 if let Some(matched) = matching {
551 if matched_instance.is_none() {
552 matched_instance = Some(relativize_instance(matched, root));
553 }
554 clone_groups.push(build_traced_group(group, root, &fingerprints));
555 }
556 }
557
558 CloneTrace {
559 file: PathBuf::from(file_path),
560 line,
561 matched_instance,
562 clone_groups,
563 }
564}
565
566#[must_use]
576pub fn trace_clone_by_fingerprint(
577 report: &DuplicationReport,
578 root: &Path,
579 fingerprint: &str,
580) -> CloneTrace {
581 let fingerprints = CloneFingerprintSet::from_groups(&report.clone_groups);
582 let matched = fingerprints.find_group(&report.clone_groups, fingerprint);
583
584 let Some(group) = matched else {
585 return CloneTrace {
586 file: PathBuf::new(),
587 line: 0,
588 matched_instance: None,
589 clone_groups: Vec::new(),
590 };
591 };
592
593 let representative = group
594 .instances
595 .first()
596 .map(|inst| relativize_instance(inst, root));
597 let (file, line) = representative.as_ref().map_or_else(
598 || (PathBuf::new(), 0),
599 |inst| (inst.file.clone(), inst.start_line),
600 );
601
602 CloneTrace {
603 file,
604 line,
605 matched_instance: representative,
606 clone_groups: vec![build_traced_group(group, root, &fingerprints)],
607 }
608}
609
610fn relativize_instance(inst: &CloneInstance, root: &Path) -> CloneInstance {
614 let rel = inst.file.strip_prefix(root).map_or_else(
615 |_| inst.file.clone(),
616 |p| PathBuf::from(p.to_string_lossy().replace('\\', "/")),
617 );
618 CloneInstance {
619 file: rel,
620 ..inst.clone()
621 }
622}
623
624#[cfg(test)]
625mod tests {
626 use super::*;
627
628 use crate::discover::{DiscoveredFile, EntryPoint, EntryPointSource, FileId};
629 use crate::extract::{ExportInfo, ExportName, ImportInfo, ImportedName, VisibilityTag};
630 use crate::resolve::{ResolveResult, ResolvedImport, ResolvedModule};
631
632 fn build_test_graph() -> ModuleGraph {
633 let files = vec![
634 DiscoveredFile {
635 id: FileId(0),
636 path: PathBuf::from("/project/src/entry.ts"),
637 size_bytes: 100,
638 },
639 DiscoveredFile {
640 id: FileId(1),
641 path: PathBuf::from("/project/src/utils.ts"),
642 size_bytes: 50,
643 },
644 DiscoveredFile {
645 id: FileId(2),
646 path: PathBuf::from("/project/src/unused.ts"),
647 size_bytes: 30,
648 },
649 ];
650
651 let entry_points = vec![EntryPoint {
652 path: PathBuf::from("/project/src/entry.ts"),
653 source: EntryPointSource::PackageJsonMain,
654 }];
655
656 let resolved_modules = vec![
657 ResolvedModule {
658 file_id: FileId(0),
659 path: PathBuf::from("/project/src/entry.ts"),
660 resolved_imports: vec![ResolvedImport {
661 info: ImportInfo {
662 source: "./utils".to_string(),
663 imported_name: ImportedName::Named("foo".to_string()),
664 local_name: "foo".to_string(),
665 is_type_only: false,
666 from_style: false,
667 span: oxc_span::Span::new(0, 10),
668 source_span: oxc_span::Span::default(),
669 },
670 target: ResolveResult::InternalModule(FileId(1)),
671 }],
672 ..Default::default()
673 },
674 ResolvedModule {
675 file_id: FileId(1),
676 path: PathBuf::from("/project/src/utils.ts"),
677 exports: vec![
678 ExportInfo {
679 name: ExportName::Named("foo".to_string()),
680 local_name: Some("foo".to_string()),
681 is_type_only: false,
682 visibility: VisibilityTag::None,
683 expected_unused_reason: None,
684 span: oxc_span::Span::new(0, 20),
685 members: vec![],
686 is_side_effect_used: false,
687 super_class: None,
688 },
689 ExportInfo {
690 name: ExportName::Named("bar".to_string()),
691 local_name: Some("bar".to_string()),
692 is_type_only: false,
693 visibility: VisibilityTag::None,
694 expected_unused_reason: None,
695 span: oxc_span::Span::new(21, 40),
696 members: vec![],
697 is_side_effect_used: false,
698 super_class: None,
699 },
700 ],
701 ..Default::default()
702 },
703 ResolvedModule {
704 file_id: FileId(2),
705 path: PathBuf::from("/project/src/unused.ts"),
706 exports: vec![ExportInfo {
707 name: ExportName::Named("baz".to_string()),
708 local_name: Some("baz".to_string()),
709 is_type_only: false,
710 visibility: VisibilityTag::None,
711 expected_unused_reason: None,
712 span: oxc_span::Span::new(0, 15),
713 members: vec![],
714 is_side_effect_used: false,
715 super_class: None,
716 }],
717 ..Default::default()
718 },
719 ];
720
721 ModuleGraph::build(&resolved_modules, &entry_points, &files)
722 }
723
724 #[test]
725 fn trace_used_export() {
726 let graph = build_test_graph();
727 let root = Path::new("/project");
728
729 let trace = trace_export(&graph, root, "src/utils.ts", "foo").unwrap();
730 assert!(trace.is_used);
731 assert!(trace.file_reachable);
732 assert_eq!(trace.direct_references.len(), 1);
733 assert_eq!(
734 trace.direct_references[0].from_file,
735 PathBuf::from("src/entry.ts")
736 );
737 assert_eq!(trace.direct_references[0].kind, "named import");
738 }
739
740 #[test]
741 fn trace_unused_export() {
742 let graph = build_test_graph();
743 let root = Path::new("/project");
744
745 let trace = trace_export(&graph, root, "src/utils.ts", "bar").unwrap();
746 assert!(!trace.is_used);
747 assert!(trace.file_reachable);
748 assert!(trace.direct_references.is_empty());
749 }
750
751 #[test]
752 fn trace_unreachable_file_export() {
753 let graph = build_test_graph();
754 let root = Path::new("/project");
755
756 let trace = trace_export(&graph, root, "src/unused.ts", "baz").unwrap();
757 assert!(!trace.is_used);
758 assert!(!trace.file_reachable);
759 assert!(trace.reason.contains("unreachable"));
760 }
761
762 #[test]
763 fn trace_nonexistent_export() {
764 let graph = build_test_graph();
765 let root = Path::new("/project");
766
767 let trace = trace_export(&graph, root, "src/utils.ts", "nonexistent");
768 assert!(trace.is_none());
769 }
770
771 fn build_class_member_graph() -> ModuleGraph {
772 use fallow_types::extract::{MemberInfo, MemberKind};
773
774 let files = vec![
775 DiscoveredFile {
776 id: FileId(0),
777 path: PathBuf::from("/project/src/entry.ts"),
778 size_bytes: 100,
779 },
780 DiscoveredFile {
781 id: FileId(1),
782 path: PathBuf::from("/project/src/controller.ts"),
783 size_bytes: 50,
784 },
785 ];
786 let entry_points = vec![EntryPoint {
787 path: PathBuf::from("/project/src/entry.ts"),
788 source: EntryPointSource::PackageJsonMain,
789 }];
790 let method = |name: &str| MemberInfo {
791 name: name.to_string(),
792 kind: MemberKind::ClassMethod,
793 span: oxc_span::Span::new(0, 4),
794 has_decorator: false,
795 decorator_names: vec![],
796 is_instance_returning_static: false,
797 is_self_returning: false,
798 };
799 let resolved_modules = vec![
800 ResolvedModule {
801 file_id: FileId(0),
802 path: PathBuf::from("/project/src/entry.ts"),
803 resolved_imports: vec![ResolvedImport {
804 info: ImportInfo {
805 source: "./controller".to_string(),
806 imported_name: ImportedName::Named("Ctrl".to_string()),
807 local_name: "Ctrl".to_string(),
808 is_type_only: false,
809 from_style: false,
810 span: oxc_span::Span::new(0, 10),
811 source_span: oxc_span::Span::default(),
812 },
813 target: ResolveResult::InternalModule(FileId(1)),
814 }],
815 ..Default::default()
816 },
817 ResolvedModule {
818 file_id: FileId(1),
819 path: PathBuf::from("/project/src/controller.ts"),
820 exports: vec![ExportInfo {
821 name: ExportName::Named("Ctrl".to_string()),
822 local_name: Some("Ctrl".to_string()),
823 is_type_only: false,
824 visibility: VisibilityTag::None,
825 expected_unused_reason: None,
826 span: oxc_span::Span::new(0, 20),
827 members: vec![method("used"), method("dead")],
828 is_side_effect_used: false,
829 super_class: None,
830 }],
831 ..Default::default()
832 },
833 ];
834 ModuleGraph::build(&resolved_modules, &entry_points, &files)
835 }
836
837 #[test]
838 fn trace_class_member_reports_owner_class() {
839 let graph = build_class_member_graph();
842 let root = Path::new("/project");
843
844 let trace = trace_class_member(&graph, root, "src/controller.ts", "dead").unwrap();
845 assert_eq!(trace.owner_export, "Ctrl");
846 assert_eq!(trace.member_name, "dead");
847 assert_eq!(trace.member_kind, "class-method");
848 assert!(trace.owner_is_used);
849 assert!(trace.owner_file_reachable);
850 assert_eq!(trace.owner_direct_references.len(), 1);
851 assert!(
852 trace.reason.contains("--unused-class-members"),
853 "reason should point at the member command: {}",
854 trace.reason
855 );
856 }
857
858 #[test]
859 fn trace_class_member_absent_name_is_none() {
860 let graph = build_class_member_graph();
863 let root = Path::new("/project");
864 assert!(trace_class_member(&graph, root, "src/controller.ts", "nope").is_none());
865 }
866
867 fn build_unreachable_class_member_graph() -> ModuleGraph {
870 use fallow_types::extract::{MemberInfo, MemberKind};
871
872 let files = vec![
873 DiscoveredFile {
874 id: FileId(0),
875 path: PathBuf::from("/project/src/entry.ts"),
876 size_bytes: 100,
877 },
878 DiscoveredFile {
879 id: FileId(1),
880 path: PathBuf::from("/project/src/controller.ts"),
881 size_bytes: 50,
882 },
883 ];
884 let entry_points = vec![EntryPoint {
885 path: PathBuf::from("/project/src/entry.ts"),
886 source: EntryPointSource::PackageJsonMain,
887 }];
888 let method = |name: &str| MemberInfo {
889 name: name.to_string(),
890 kind: MemberKind::ClassMethod,
891 span: oxc_span::Span::new(0, 4),
892 has_decorator: false,
893 decorator_names: vec![],
894 is_instance_returning_static: false,
895 is_self_returning: false,
896 };
897 let resolved_modules = vec![
898 ResolvedModule {
899 file_id: FileId(0),
900 path: PathBuf::from("/project/src/entry.ts"),
901 ..Default::default()
903 },
904 ResolvedModule {
905 file_id: FileId(1),
906 path: PathBuf::from("/project/src/controller.ts"),
907 exports: vec![ExportInfo {
908 name: ExportName::Named("Ctrl".to_string()),
909 local_name: Some("Ctrl".to_string()),
910 is_type_only: false,
911 visibility: VisibilityTag::None,
912 expected_unused_reason: None,
913 span: oxc_span::Span::new(0, 20),
914 members: vec![method("dead")],
915 is_side_effect_used: false,
916 super_class: None,
917 }],
918 ..Default::default()
919 },
920 ];
921 ModuleGraph::build(&resolved_modules, &entry_points, &files)
922 }
923
924 #[test]
925 fn trace_class_member_unreachable_owner_reports_dead_reason() {
926 let graph = build_unreachable_class_member_graph();
929 let root = Path::new("/project");
930
931 let trace = trace_class_member(&graph, root, "src/controller.ts", "dead").unwrap();
932 assert!(!trace.owner_file_reachable);
933 assert!(
934 trace.reason.contains("not reachable"),
935 "unreachable owner reason should say so: {}",
936 trace.reason
937 );
938 assert!(!trace.reason.contains("--unused-class-members"));
941 }
942
943 #[test]
944 fn trace_class_member_prefers_used_owner_on_name_collision() {
945 use fallow_types::extract::{MemberInfo, MemberKind};
949
950 let files = vec![
951 DiscoveredFile {
952 id: FileId(0),
953 path: PathBuf::from("/project/src/entry.ts"),
954 size_bytes: 100,
955 },
956 DiscoveredFile {
957 id: FileId(1),
958 path: PathBuf::from("/project/src/controller.ts"),
959 size_bytes: 50,
960 },
961 ];
962 let entry_points = vec![EntryPoint {
963 path: PathBuf::from("/project/src/entry.ts"),
964 source: EntryPointSource::PackageJsonMain,
965 }];
966 let method = |name: &str| MemberInfo {
967 name: name.to_string(),
968 kind: MemberKind::ClassMethod,
969 span: oxc_span::Span::new(0, 4),
970 has_decorator: false,
971 decorator_names: vec![],
972 is_instance_returning_static: false,
973 is_self_returning: false,
974 };
975 let resolved_modules = vec![
976 ResolvedModule {
977 file_id: FileId(0),
978 path: PathBuf::from("/project/src/entry.ts"),
979 resolved_imports: vec![ResolvedImport {
980 info: ImportInfo {
981 source: "./controller".to_string(),
982 imported_name: ImportedName::Named("UsedCtrl".to_string()),
983 local_name: "UsedCtrl".to_string(),
984 is_type_only: false,
985 from_style: false,
986 span: oxc_span::Span::new(0, 10),
987 source_span: oxc_span::Span::default(),
988 },
989 target: ResolveResult::InternalModule(FileId(1)),
990 }],
991 ..Default::default()
992 },
993 ResolvedModule {
994 file_id: FileId(1),
995 path: PathBuf::from("/project/src/controller.ts"),
996 exports: vec![
997 ExportInfo {
1000 name: ExportName::Named("TypeCtrl".to_string()),
1001 local_name: Some("TypeCtrl".to_string()),
1002 is_type_only: true,
1003 visibility: VisibilityTag::None,
1004 expected_unused_reason: None,
1005 span: oxc_span::Span::new(0, 20),
1006 members: vec![method("shared")],
1007 is_side_effect_used: false,
1008 super_class: None,
1009 },
1010 ExportInfo {
1011 name: ExportName::Named("UsedCtrl".to_string()),
1012 local_name: Some("UsedCtrl".to_string()),
1013 is_type_only: false,
1014 visibility: VisibilityTag::None,
1015 expected_unused_reason: None,
1016 span: oxc_span::Span::new(0, 20),
1017 members: vec![method("shared")],
1018 is_side_effect_used: false,
1019 super_class: None,
1020 },
1021 ],
1022 ..Default::default()
1023 },
1024 ];
1025 let graph = ModuleGraph::build(&resolved_modules, &entry_points, &files);
1026 let root = Path::new("/project");
1027
1028 let trace = trace_class_member(&graph, root, "src/controller.ts", "shared").unwrap();
1029 assert_eq!(
1030 trace.owner_export, "UsedCtrl",
1031 "tie-break must prefer the used, non-type-only owner"
1032 );
1033 assert!(trace.owner_is_used);
1034 }
1035
1036 #[test]
1037 fn trace_nonexistent_file() {
1038 let graph = build_test_graph();
1039 let root = Path::new("/project");
1040
1041 let trace = trace_export(&graph, root, "src/nope.ts", "foo");
1042 assert!(trace.is_none());
1043 }
1044
1045 #[test]
1046 fn trace_file_edges() {
1047 let graph = build_test_graph();
1048 let root = Path::new("/project");
1049
1050 let trace = trace_file(&graph, root, "src/entry.ts").unwrap();
1051 assert!(trace.is_entry_point);
1052 assert!(trace.is_reachable);
1053 assert_eq!(trace.imports_from.len(), 1);
1054 assert_eq!(trace.imports_from[0], PathBuf::from("src/utils.ts"));
1055 assert!(trace.imported_by.is_empty());
1056 }
1057
1058 #[test]
1059 fn trace_file_imported_by() {
1060 let graph = build_test_graph();
1061 let root = Path::new("/project");
1062
1063 let trace = trace_file(&graph, root, "src/utils.ts").unwrap();
1064 assert!(!trace.is_entry_point);
1065 assert!(trace.is_reachable);
1066 assert_eq!(trace.exports.len(), 2);
1067 assert_eq!(trace.imported_by.len(), 1);
1068 assert_eq!(trace.imported_by[0], PathBuf::from("src/entry.ts"));
1069 }
1070
1071 #[test]
1072 fn trace_unreachable_file() {
1073 let graph = build_test_graph();
1074 let root = Path::new("/project");
1075
1076 let trace = trace_file(&graph, root, "src/unused.ts").unwrap();
1077 assert!(!trace.is_reachable);
1078 assert!(!trace.is_entry_point);
1079 assert!(trace.imported_by.is_empty());
1080 }
1081
1082 #[test]
1083 fn trace_dependency_used() {
1084 let files = vec![DiscoveredFile {
1085 id: FileId(0),
1086 path: PathBuf::from("/project/src/app.ts"),
1087 size_bytes: 100,
1088 }];
1089 let entry_points = vec![EntryPoint {
1090 path: PathBuf::from("/project/src/app.ts"),
1091 source: EntryPointSource::PackageJsonMain,
1092 }];
1093 let resolved_modules = vec![ResolvedModule {
1094 file_id: FileId(0),
1095 path: PathBuf::from("/project/src/app.ts"),
1096 resolved_imports: vec![ResolvedImport {
1097 info: ImportInfo {
1098 source: "lodash".to_string(),
1099 imported_name: ImportedName::Named("get".to_string()),
1100 local_name: "get".to_string(),
1101 is_type_only: false,
1102 from_style: false,
1103 span: oxc_span::Span::new(0, 10),
1104 source_span: oxc_span::Span::default(),
1105 },
1106 target: ResolveResult::NpmPackage("lodash".to_string()),
1107 }],
1108 ..Default::default()
1109 }];
1110
1111 let graph = ModuleGraph::build(&resolved_modules, &entry_points, &files);
1112 let root = Path::new("/project");
1113
1114 let trace = trace_dependency(&graph, root, "lodash", &FxHashSet::default());
1115 assert!(trace.is_used);
1116 assert!(!trace.used_in_scripts);
1117 assert_eq!(trace.import_count, 1);
1118 assert_eq!(trace.imported_by[0], PathBuf::from("src/app.ts"));
1119 }
1120
1121 #[test]
1122 fn trace_dependency_unused() {
1123 let files = vec![DiscoveredFile {
1124 id: FileId(0),
1125 path: PathBuf::from("/project/src/app.ts"),
1126 size_bytes: 100,
1127 }];
1128 let entry_points = vec![EntryPoint {
1129 path: PathBuf::from("/project/src/app.ts"),
1130 source: EntryPointSource::PackageJsonMain,
1131 }];
1132 let resolved_modules = vec![ResolvedModule {
1133 file_id: FileId(0),
1134 path: PathBuf::from("/project/src/app.ts"),
1135 ..Default::default()
1136 }];
1137
1138 let graph = ModuleGraph::build(&resolved_modules, &entry_points, &files);
1139 let root = Path::new("/project");
1140
1141 let trace = trace_dependency(&graph, root, "nonexistent-pkg", &FxHashSet::default());
1142 assert!(!trace.is_used);
1143 assert!(!trace.used_in_scripts);
1144 assert_eq!(trace.import_count, 0);
1145 assert!(trace.imported_by.is_empty());
1146 }
1147
1148 #[test]
1149 fn trace_dependency_used_only_in_scripts() {
1150 let files = vec![DiscoveredFile {
1151 id: FileId(0),
1152 path: PathBuf::from("/project/src/app.ts"),
1153 size_bytes: 100,
1154 }];
1155 let entry_points = vec![EntryPoint {
1156 path: PathBuf::from("/project/src/app.ts"),
1157 source: EntryPointSource::PackageJsonMain,
1158 }];
1159 let resolved_modules = vec![ResolvedModule {
1160 file_id: FileId(0),
1161 path: PathBuf::from("/project/src/app.ts"),
1162 ..Default::default()
1163 }];
1164
1165 let graph = ModuleGraph::build(&resolved_modules, &entry_points, &files);
1166 let root = Path::new("/project");
1167 let mut script_used = FxHashSet::default();
1168 script_used.insert("microbundle".to_string());
1169
1170 let trace = trace_dependency(&graph, root, "microbundle", &script_used);
1171 assert!(
1172 trace.is_used,
1173 "is_used must be true when the package is referenced from package.json scripts"
1174 );
1175 assert!(trace.used_in_scripts);
1176 assert_eq!(trace.import_count, 0);
1177 assert!(trace.imported_by.is_empty());
1178 }
1179
1180 #[test]
1181 fn trace_clone_finds_matching_group() {
1182 use crate::duplicates::{CloneGroup, CloneInstance, DuplicationReport, DuplicationStats};
1183 let report = DuplicationReport {
1184 clone_groups: vec![CloneGroup {
1185 instances: vec![
1186 CloneInstance {
1187 file: PathBuf::from("/project/src/a.ts"),
1188 start_line: 10,
1189 end_line: 20,
1190 start_col: 0,
1191 end_col: 0,
1192 fragment: "fn foo() {}".to_string(),
1193 },
1194 CloneInstance {
1195 file: PathBuf::from("/project/src/b.ts"),
1196 start_line: 5,
1197 end_line: 15,
1198 start_col: 0,
1199 end_col: 0,
1200 fragment: "fn foo() {}".to_string(),
1201 },
1202 ],
1203 token_count: 60,
1204 line_count: 11,
1205 }],
1206 clone_families: vec![],
1207 mirrored_directories: vec![],
1208 stats: DuplicationStats {
1209 total_files: 2,
1210 files_with_clones: 2,
1211 total_lines: 100,
1212 duplicated_lines: 22,
1213 total_tokens: 200,
1214 duplicated_tokens: 120,
1215 clone_groups: 1,
1216 clone_instances: 2,
1217 duplication_percentage: 22.0,
1218 clone_groups_below_min_occurrences: 0,
1219 },
1220 };
1221 let trace = trace_clone(&report, Path::new("/project"), "src/a.ts", 15);
1222 assert!(trace.matched_instance.is_some());
1223 assert_eq!(trace.clone_groups.len(), 1);
1224 assert_eq!(trace.clone_groups[0].instances.len(), 2);
1225 assert!(trace.clone_groups[0].fingerprint.starts_with("dup:"));
1226 assert_eq!(trace.clone_groups[0].suggestion.estimated_savings, 11);
1227 }
1228
1229 #[test]
1230 fn trace_clone_by_fingerprint_resolves_and_misses() {
1231 use crate::duplicates::{
1232 CloneGroup, CloneInstance, DuplicationReport, DuplicationStats, clone_fingerprint,
1233 };
1234 let report = DuplicationReport {
1235 clone_groups: vec![CloneGroup {
1236 instances: vec![
1237 CloneInstance {
1238 file: PathBuf::from("/project/src/a.ts"),
1239 start_line: 10,
1240 end_line: 20,
1241 start_col: 0,
1242 end_col: 0,
1243 fragment: "fn buildInvoice() {}".to_string(),
1244 },
1245 CloneInstance {
1246 file: PathBuf::from("/project/src/b.ts"),
1247 start_line: 5,
1248 end_line: 15,
1249 start_col: 0,
1250 end_col: 0,
1251 fragment: "fn buildInvoice() {}".to_string(),
1252 },
1253 ],
1254 token_count: 60,
1255 line_count: 11,
1256 }],
1257 clone_families: vec![],
1258 mirrored_directories: vec![],
1259 stats: DuplicationStats::default(),
1260 };
1261 let fp = clone_fingerprint(&report.clone_groups[0].instances);
1262
1263 let hit = trace_clone_by_fingerprint(&report, Path::new("/project"), &fp);
1264 assert!(hit.matched_instance.is_some());
1265 assert_eq!(hit.clone_groups.len(), 1);
1266 assert_eq!(hit.clone_groups[0].fingerprint, fp);
1267 assert_eq!(hit.line, 10);
1268
1269 let miss = trace_clone_by_fingerprint(&report, Path::new("/project"), "dup:deadbeef");
1270 assert!(miss.matched_instance.is_none());
1271 assert!(miss.clone_groups.is_empty());
1272 }
1273
1274 #[test]
1275 fn trace_clone_no_match() {
1276 use crate::duplicates::{CloneGroup, CloneInstance, DuplicationReport, DuplicationStats};
1277 let report = DuplicationReport {
1278 clone_groups: vec![CloneGroup {
1279 instances: vec![CloneInstance {
1280 file: PathBuf::from("/project/src/a.ts"),
1281 start_line: 10,
1282 end_line: 20,
1283 start_col: 0,
1284 end_col: 0,
1285 fragment: "fn foo() {}".to_string(),
1286 }],
1287 token_count: 60,
1288 line_count: 11,
1289 }],
1290 clone_families: vec![],
1291 mirrored_directories: vec![],
1292 stats: DuplicationStats {
1293 total_files: 1,
1294 files_with_clones: 1,
1295 total_lines: 50,
1296 duplicated_lines: 11,
1297 total_tokens: 100,
1298 duplicated_tokens: 60,
1299 clone_groups: 1,
1300 clone_instances: 1,
1301 duplication_percentage: 22.0,
1302 clone_groups_below_min_occurrences: 0,
1303 },
1304 };
1305 let trace = trace_clone(&report, Path::new("/project"), "src/a.ts", 25);
1306 assert!(trace.matched_instance.is_none());
1307 assert!(trace.clone_groups.is_empty());
1308 }
1309
1310 #[test]
1311 fn trace_clone_line_boundary() {
1312 use crate::duplicates::{CloneGroup, CloneInstance, DuplicationReport, DuplicationStats};
1313 let report = DuplicationReport {
1314 clone_groups: vec![CloneGroup {
1315 instances: vec![
1316 CloneInstance {
1317 file: PathBuf::from("/project/src/a.ts"),
1318 start_line: 10,
1319 end_line: 20,
1320 start_col: 0,
1321 end_col: 0,
1322 fragment: "code".to_string(),
1323 },
1324 CloneInstance {
1325 file: PathBuf::from("/project/src/b.ts"),
1326 start_line: 1,
1327 end_line: 11,
1328 start_col: 0,
1329 end_col: 0,
1330 fragment: "code".to_string(),
1331 },
1332 ],
1333 token_count: 50,
1334 line_count: 11,
1335 }],
1336 clone_families: vec![],
1337 mirrored_directories: vec![],
1338 stats: DuplicationStats {
1339 total_files: 2,
1340 files_with_clones: 2,
1341 total_lines: 100,
1342 duplicated_lines: 22,
1343 total_tokens: 200,
1344 duplicated_tokens: 100,
1345 clone_groups: 1,
1346 clone_instances: 2,
1347 duplication_percentage: 22.0,
1348 clone_groups_below_min_occurrences: 0,
1349 },
1350 };
1351 let root = Path::new("/project");
1352 assert!(
1353 trace_clone(&report, root, "src/a.ts", 10)
1354 .matched_instance
1355 .is_some()
1356 );
1357 assert!(
1358 trace_clone(&report, root, "src/a.ts", 20)
1359 .matched_instance
1360 .is_some()
1361 );
1362 assert!(
1363 trace_clone(&report, root, "src/a.ts", 21)
1364 .matched_instance
1365 .is_none()
1366 );
1367 }
1368
1369 #[test]
1370 fn trace_clone_returns_relative_instance_paths() {
1371 use crate::duplicates::{CloneGroup, CloneInstance, DuplicationReport, DuplicationStats};
1372 let report = DuplicationReport {
1373 clone_groups: vec![CloneGroup {
1374 instances: vec![
1375 CloneInstance {
1376 file: PathBuf::from("/project/src/a.ts"),
1377 start_line: 1,
1378 end_line: 10,
1379 start_col: 0,
1380 end_col: 0,
1381 fragment: "code".to_string(),
1382 },
1383 CloneInstance {
1384 file: PathBuf::from("/project/src/b.ts"),
1385 start_line: 1,
1386 end_line: 10,
1387 start_col: 0,
1388 end_col: 0,
1389 fragment: "code".to_string(),
1390 },
1391 ],
1392 token_count: 50,
1393 line_count: 10,
1394 }],
1395 clone_families: vec![],
1396 mirrored_directories: vec![],
1397 stats: DuplicationStats {
1398 total_files: 2,
1399 files_with_clones: 2,
1400 total_lines: 50,
1401 duplicated_lines: 20,
1402 total_tokens: 100,
1403 duplicated_tokens: 100,
1404 clone_groups: 1,
1405 clone_instances: 2,
1406 duplication_percentage: 40.0,
1407 clone_groups_below_min_occurrences: 0,
1408 },
1409 };
1410 let trace = trace_clone(&report, Path::new("/project"), "src/a.ts", 5);
1411 let matched = trace.matched_instance.as_ref().expect("match expected");
1412 assert_eq!(matched.file, PathBuf::from("src/a.ts"));
1413 for group in &trace.clone_groups {
1414 for inst in &group.instances {
1415 let as_str = inst.file.to_string_lossy();
1416 assert!(
1417 !as_str.starts_with('/'),
1418 "instance file should be relative, got {as_str}",
1419 );
1420 assert!(
1421 !as_str.contains(":\\") && !as_str.contains(":/"),
1422 "instance file should not have a drive letter, got {as_str}",
1423 );
1424 }
1425 }
1426
1427 let json = serde_json::to_string(&trace).expect("serializes");
1428 assert!(
1429 !json.contains("\"/project/"),
1430 "serialized trace should not leak absolute paths: {json}",
1431 );
1432 }
1433
1434 #[test]
1441 fn path_matches_normalises_windows_module_path_against_posix_user_path() {
1442 let root = Path::new(r"D:\a\fallow\fallow\tests\fixtures\basic-project");
1443 let module_path =
1444 PathBuf::from(r"D:\a\fallow\fallow\tests\fixtures\basic-project\src\utils.ts");
1445 assert!(path_matches(&module_path, root, "src/utils.ts"));
1446 assert!(path_matches(&module_path, root, r"src\utils.ts"));
1447 }
1448
1449 #[test]
1450 fn path_matches_ends_with_fallback_handles_mixed_separators() {
1451 let root = Path::new("/some/other/root");
1452 let module_path =
1453 PathBuf::from(r"D:\a\fallow\fallow\tests\fixtures\basic-project\src\utils.ts");
1454 assert!(path_matches(&module_path, root, "src/utils.ts"));
1455 }
1456
1457 #[test]
1466 fn export_trace_serializes_windows_path_with_forward_slashes() {
1467 let trace = ExportTrace {
1468 file: PathBuf::from(r"src\utils.ts"),
1469 export_name: "foo".to_string(),
1470 file_reachable: true,
1471 is_entry_point: false,
1472 is_used: true,
1473 direct_references: vec![ExportReference {
1474 from_file: PathBuf::from(r"src\entry.ts"),
1475 kind: "named import".to_string(),
1476 }],
1477 re_export_chains: vec![ReExportChain {
1478 barrel_file: PathBuf::from(r"src\index.ts"),
1479 exported_as: "foo".to_string(),
1480 reference_count: 1,
1481 }],
1482 reason: "ok".to_string(),
1483 };
1484 let json = serde_json::to_string(&trace).expect("serializes");
1485 assert!(
1486 json.contains("\"file\":\"src/utils.ts\""),
1487 "ExportTrace.file must serialize with forward slashes: {json}"
1488 );
1489 assert!(
1490 json.contains("\"from_file\":\"src/entry.ts\""),
1491 "ExportReference.from_file must serialize with forward slashes: {json}"
1492 );
1493 assert!(
1494 json.contains("\"barrel_file\":\"src/index.ts\""),
1495 "ReExportChain.barrel_file must serialize with forward slashes: {json}"
1496 );
1497 assert!(
1498 !json.contains(r"\\"),
1499 "no backslash sequence should remain anywhere in the JSON: {json}"
1500 );
1501 }
1502
1503 #[test]
1504 fn file_trace_serializes_windows_paths_with_forward_slashes() {
1505 let trace = FileTrace {
1506 file: PathBuf::from(r"src\utils.ts"),
1507 is_reachable: true,
1508 is_entry_point: false,
1509 exports: vec![],
1510 imports_from: vec![PathBuf::from(r"src\helpers.ts")],
1511 imported_by: vec![PathBuf::from(r"src\entry.ts")],
1512 re_exports: vec![TracedReExport {
1513 source_file: PathBuf::from(r"src\source.ts"),
1514 imported_name: "foo".to_string(),
1515 exported_name: "foo".to_string(),
1516 }],
1517 };
1518 let json = serde_json::to_string(&trace).expect("serializes");
1519 assert!(json.contains("\"file\":\"src/utils.ts\""), "got {json}");
1520 assert!(
1521 json.contains("\"imports_from\":[\"src/helpers.ts\"]"),
1522 "got {json}"
1523 );
1524 assert!(
1525 json.contains("\"imported_by\":[\"src/entry.ts\"]"),
1526 "got {json}"
1527 );
1528 assert!(
1529 json.contains("\"source_file\":\"src/source.ts\""),
1530 "got {json}"
1531 );
1532 assert!(!json.contains(r"\\"), "no backslash should remain: {json}");
1533 }
1534}