1use std::path::{Path, PathBuf};
2
3use fallow_types::serde_path;
4use rustc_hash::FxHashSet;
5use serde::Serialize;
6
7use crate::duplicates::{
8 CloneFingerprintSet, CloneGroup, CloneInstance, DuplicationReport, RefactoringSuggestion,
9 dominant_identifier, group_refactoring_suggestion,
10};
11use crate::graph::{ModuleGraph, ReferenceKind};
12
13fn path_matches(module_path: &Path, root: &Path, user_path: &str) -> bool {
18 let user_path_norm = user_path.replace('\\', "/");
19 let rel = module_path.strip_prefix(root).unwrap_or(module_path);
20 let rel_str = rel.to_string_lossy().replace('\\', "/");
21 let module_str = module_path.to_string_lossy().replace('\\', "/");
22 if rel_str == user_path_norm || module_str == user_path_norm {
23 return true;
24 }
25 if dunce::canonicalize(root).is_ok_and(|canonical_root| {
26 module_path
27 .strip_prefix(&canonical_root)
28 .is_ok_and(|rel| rel.to_string_lossy().replace('\\', "/") == user_path_norm)
29 }) {
30 return true;
31 }
32 module_str.ends_with(&format!("/{user_path_norm}"))
33}
34
35#[derive(Debug, Serialize)]
37pub struct ExportTrace {
38 #[serde(serialize_with = "serde_path::serialize")]
40 pub file: PathBuf,
41 pub export_name: String,
43 pub file_reachable: bool,
45 pub is_entry_point: bool,
47 pub is_used: bool,
49 pub direct_references: Vec<ExportReference>,
51 pub re_export_chains: Vec<ReExportChain>,
53 pub reason: String,
55}
56
57#[derive(Debug, Serialize)]
59pub struct ExportReference {
60 #[serde(serialize_with = "serde_path::serialize")]
61 pub from_file: PathBuf,
62 pub kind: String,
63}
64
65#[derive(Debug, Serialize)]
67pub struct ReExportChain {
68 #[serde(serialize_with = "serde_path::serialize")]
70 pub barrel_file: PathBuf,
71 pub exported_as: String,
73 pub reference_count: usize,
75}
76
77#[derive(Debug, Serialize)]
79pub struct FileTrace {
80 #[serde(serialize_with = "serde_path::serialize")]
82 pub file: PathBuf,
83 pub is_reachable: bool,
85 pub is_entry_point: bool,
87 pub exports: Vec<TracedExport>,
89 #[serde(serialize_with = "serde_path::serialize_vec")]
91 pub imports_from: Vec<PathBuf>,
92 #[serde(serialize_with = "serde_path::serialize_vec")]
94 pub imported_by: Vec<PathBuf>,
95 pub re_exports: Vec<TracedReExport>,
97}
98
99#[derive(Debug, Serialize)]
101pub struct TracedExport {
102 pub name: String,
103 pub is_type_only: bool,
104 pub reference_count: usize,
105 pub referenced_by: Vec<ExportReference>,
106}
107
108#[derive(Debug, Serialize)]
110pub struct TracedReExport {
111 #[serde(serialize_with = "serde_path::serialize")]
112 pub source_file: PathBuf,
113 pub imported_name: String,
114 pub exported_name: String,
115}
116
117#[derive(Debug, Serialize)]
119pub struct DependencyTrace {
120 pub package_name: String,
122 #[serde(serialize_with = "serde_path::serialize_vec")]
124 pub imported_by: Vec<PathBuf>,
125 #[serde(serialize_with = "serde_path::serialize_vec")]
127 pub type_only_imported_by: Vec<PathBuf>,
128 pub used_in_scripts: bool,
133 pub is_used: bool,
135 pub import_count: usize,
137}
138
139#[derive(Debug, Clone, Serialize)]
141pub struct PipelineTimings {
142 pub discover_files_ms: f64,
143 pub file_count: usize,
144 pub workspaces_ms: f64,
145 pub workspace_count: usize,
146 pub plugins_ms: f64,
147 pub script_analysis_ms: f64,
148 pub parse_extract_ms: f64,
149 pub parse_cpu_ms: f64,
155 pub module_count: usize,
156 pub cache_hits: usize,
158 pub cache_misses: usize,
160 pub cache_update_ms: f64,
161 pub entry_points_ms: f64,
162 pub entry_point_count: usize,
163 pub resolve_imports_ms: f64,
164 pub build_graph_ms: f64,
165 pub analyze_ms: f64,
166 #[serde(skip_serializing_if = "Option::is_none")]
167 pub duplication_ms: Option<f64>,
168 pub total_ms: f64,
169}
170
171fn reference_to_export_reference(
173 graph: &ModuleGraph,
174 root: &Path,
175 r: &crate::graph::SymbolReference,
176) -> ExportReference {
177 let from_path = graph.modules.get(r.from_file.0 as usize).map_or_else(
178 || PathBuf::from(format!("<unknown:{}>", r.from_file.0)),
179 |m| m.path.strip_prefix(root).unwrap_or(&m.path).to_path_buf(),
180 );
181 ExportReference {
182 from_file: from_path,
183 kind: format_reference_kind(r.kind),
184 }
185}
186
187fn collect_re_export_chains(
190 graph: &ModuleGraph,
191 root: &Path,
192 target_file_id: crate::discover::FileId,
193 export_name: &str,
194) -> Vec<ReExportChain> {
195 graph
196 .modules
197 .iter()
198 .flat_map(|m| {
199 m.re_exports
200 .iter()
201 .filter(move |re| {
202 re.source_file == target_file_id
203 && (re.imported_name == export_name || re.imported_name == "*")
204 })
205 .map(move |re| {
206 let barrel_export = m.exports.iter().find(|e| {
207 if re.exported_name == "*" {
208 e.name.to_string() == export_name
209 } else {
210 e.name.to_string() == re.exported_name
211 }
212 });
213 ReExportChain {
214 barrel_file: m.path.strip_prefix(root).unwrap_or(&m.path).to_path_buf(),
215 exported_as: re.exported_name.clone(),
216 reference_count: barrel_export.map_or(0, |e| e.references.len()),
217 }
218 })
219 })
220 .collect()
221}
222
223fn export_trace_reason(
225 module: &crate::graph::ModuleNode,
226 reference_count: usize,
227 is_used: bool,
228 re_export_chains: &[ReExportChain],
229) -> String {
230 if !module.is_reachable() {
231 "File is unreachable from any entry point".to_string()
232 } else if is_used {
233 format!(
234 "Used by {} file(s){}",
235 reference_count,
236 if re_export_chains.is_empty() {
237 String::new()
238 } else {
239 format!(", re-exported through {} barrel(s)", re_export_chains.len())
240 }
241 )
242 } else if module.is_entry_point() {
243 "No internal references, but file is an entry point (export is externally accessible)"
244 .to_string()
245 } else if !re_export_chains.is_empty() {
246 format!(
247 "Re-exported through {} barrel(s) but no consumer imports it through the barrel",
248 re_export_chains.len()
249 )
250 } else {
251 "No references found, export is unused".to_string()
252 }
253}
254
255#[must_use]
257pub fn trace_export(
258 graph: &ModuleGraph,
259 root: &Path,
260 file_path: &str,
261 export_name: &str,
262) -> Option<ExportTrace> {
263 let module = graph
264 .modules
265 .iter()
266 .find(|m| path_matches(&m.path, root, file_path))?;
267
268 let export = module.exports.iter().find(|e| {
269 let name_str = e.name.to_string();
270 name_str == export_name || (export_name == "default" && name_str == "default")
271 })?;
272
273 let direct_references: Vec<ExportReference> = export
274 .references
275 .iter()
276 .map(|r| reference_to_export_reference(graph, root, r))
277 .collect();
278
279 let re_export_chains = collect_re_export_chains(graph, root, module.file_id, export_name);
280
281 let is_used = !export.references.is_empty();
282 let reason = export_trace_reason(module, export.references.len(), is_used, &re_export_chains);
283
284 Some(ExportTrace {
285 file: module
286 .path
287 .strip_prefix(root)
288 .unwrap_or(&module.path)
289 .to_path_buf(),
290 export_name: export_name.to_string(),
291 file_reachable: module.is_reachable(),
292 is_entry_point: module.is_entry_point(),
293 is_used,
294 direct_references,
295 re_export_chains,
296 reason,
297 })
298}
299
300fn traced_exports(
302 graph: &ModuleGraph,
303 root: &Path,
304 module: &crate::graph::ModuleNode,
305) -> Vec<TracedExport> {
306 module
307 .exports
308 .iter()
309 .map(|e| TracedExport {
310 name: e.name.to_string(),
311 is_type_only: e.is_type_only,
312 reference_count: e.references.len(),
313 referenced_by: e
314 .references
315 .iter()
316 .map(|r| reference_to_export_reference(graph, root, r))
317 .collect(),
318 })
319 .collect()
320}
321
322fn traced_imports_from(
324 graph: &ModuleGraph,
325 root: &Path,
326 module: &crate::graph::ModuleNode,
327) -> Vec<PathBuf> {
328 graph
329 .edges_for(module.file_id)
330 .iter()
331 .filter_map(|target_id| {
332 graph
333 .modules
334 .get(target_id.0 as usize)
335 .map(|m| m.path.strip_prefix(root).unwrap_or(&m.path).to_path_buf())
336 })
337 .collect()
338}
339
340fn traced_imported_by(
342 graph: &ModuleGraph,
343 root: &Path,
344 module: &crate::graph::ModuleNode,
345) -> Vec<PathBuf> {
346 graph
347 .reverse_deps
348 .get(module.file_id.0 as usize)
349 .map(|deps| {
350 deps.iter()
351 .filter_map(|fid| {
352 graph
353 .modules
354 .get(fid.0 as usize)
355 .map(|m| m.path.strip_prefix(root).unwrap_or(&m.path).to_path_buf())
356 })
357 .collect()
358 })
359 .unwrap_or_default()
360}
361
362fn traced_re_exports(
364 graph: &ModuleGraph,
365 root: &Path,
366 module: &crate::graph::ModuleNode,
367) -> Vec<TracedReExport> {
368 module
369 .re_exports
370 .iter()
371 .map(|re| {
372 let source_path = graph.modules.get(re.source_file.0 as usize).map_or_else(
373 || PathBuf::from(format!("<unknown:{}>", re.source_file.0)),
374 |m| m.path.strip_prefix(root).unwrap_or(&m.path).to_path_buf(),
375 );
376 TracedReExport {
377 source_file: source_path,
378 imported_name: re.imported_name.clone(),
379 exported_name: re.exported_name.clone(),
380 }
381 })
382 .collect()
383}
384
385#[must_use]
387pub fn trace_file(graph: &ModuleGraph, root: &Path, file_path: &str) -> Option<FileTrace> {
388 let module = graph
389 .modules
390 .iter()
391 .find(|m| path_matches(&m.path, root, file_path))?;
392
393 Some(FileTrace {
394 file: module
395 .path
396 .strip_prefix(root)
397 .unwrap_or(&module.path)
398 .to_path_buf(),
399 is_reachable: module.is_reachable(),
400 is_entry_point: module.is_entry_point(),
401 exports: traced_exports(graph, root, module),
402 imports_from: traced_imports_from(graph, root, module),
403 imported_by: traced_imported_by(graph, root, module),
404 re_exports: traced_re_exports(graph, root, module),
405 })
406}
407
408#[expect(
417 clippy::implicit_hasher,
418 reason = "fallow standardizes on FxHashSet across the workspace"
419)]
420#[must_use]
421pub fn trace_dependency(
422 graph: &ModuleGraph,
423 root: &Path,
424 package_name: &str,
425 script_used_packages: &FxHashSet<String>,
426) -> DependencyTrace {
427 let imported_by: Vec<PathBuf> = graph
428 .package_usage
429 .get(package_name)
430 .map(|ids| {
431 ids.iter()
432 .filter_map(|fid| {
433 graph
434 .modules
435 .get(fid.0 as usize)
436 .map(|m| m.path.strip_prefix(root).unwrap_or(&m.path).to_path_buf())
437 })
438 .collect()
439 })
440 .unwrap_or_default();
441
442 let type_only_imported_by: Vec<PathBuf> = graph
443 .type_only_package_usage
444 .get(package_name)
445 .map(|ids| {
446 ids.iter()
447 .filter_map(|fid| {
448 graph
449 .modules
450 .get(fid.0 as usize)
451 .map(|m| m.path.strip_prefix(root).unwrap_or(&m.path).to_path_buf())
452 })
453 .collect()
454 })
455 .unwrap_or_default();
456
457 let import_count = imported_by.len();
458 let used_in_scripts = script_used_packages.contains(package_name);
459 DependencyTrace {
460 package_name: package_name.to_string(),
461 imported_by,
462 type_only_imported_by,
463 used_in_scripts,
464 is_used: import_count > 0 || used_in_scripts,
465 import_count,
466 }
467}
468
469fn format_reference_kind(kind: ReferenceKind) -> String {
470 match kind {
471 ReferenceKind::NamedImport => "named import".to_string(),
472 ReferenceKind::DefaultImport => "default import".to_string(),
473 ReferenceKind::NamespaceImport => "namespace import".to_string(),
474 ReferenceKind::ReExport => "re-export".to_string(),
475 ReferenceKind::DynamicImport => "dynamic import".to_string(),
476 ReferenceKind::SideEffectImport => "side-effect import".to_string(),
477 }
478}
479
480#[derive(Debug, Serialize)]
482pub struct CloneTrace {
483 #[serde(serialize_with = "serde_path::serialize")]
484 pub file: PathBuf,
485 pub line: usize,
486 pub matched_instance: Option<CloneInstance>,
487 pub clone_groups: Vec<TracedCloneGroup>,
488}
489
490#[derive(Debug, Serialize)]
491pub struct TracedCloneGroup {
492 pub fingerprint: String,
496 pub token_count: usize,
497 pub line_count: usize,
498 pub instances: Vec<CloneInstance>,
499 pub suggestion: RefactoringSuggestion,
501 #[serde(skip_serializing_if = "Option::is_none")]
505 pub suggested_name: Option<String>,
506}
507
508fn build_traced_group(
512 group: &CloneGroup,
513 root: &Path,
514 fingerprints: &CloneFingerprintSet,
515) -> TracedCloneGroup {
516 TracedCloneGroup {
517 fingerprint: fingerprints.fingerprint_for_group(group),
518 token_count: group.token_count,
519 line_count: group.line_count,
520 instances: group
521 .instances
522 .iter()
523 .map(|inst| relativize_instance(inst, root))
524 .collect(),
525 suggestion: group_refactoring_suggestion(group),
526 suggested_name: dominant_identifier(group),
527 }
528}
529
530#[must_use]
531pub fn trace_clone(
532 report: &DuplicationReport,
533 root: &Path,
534 file_path: &str,
535 line: usize,
536) -> CloneTrace {
537 let resolved = root.join(file_path);
538 let mut matched_instance = None;
539 let mut clone_groups = Vec::new();
540 let fingerprints = CloneFingerprintSet::from_groups(&report.clone_groups);
541
542 for group in &report.clone_groups {
543 let matching = group.instances.iter().find(|inst| {
544 let inst_matches = inst.file == resolved
545 || inst.file.strip_prefix(root).unwrap_or(&inst.file) == Path::new(file_path);
546 inst_matches && inst.start_line <= line && line <= inst.end_line
547 });
548
549 if let Some(matched) = matching {
550 if matched_instance.is_none() {
551 matched_instance = Some(relativize_instance(matched, root));
552 }
553 clone_groups.push(build_traced_group(group, root, &fingerprints));
554 }
555 }
556
557 CloneTrace {
558 file: PathBuf::from(file_path),
559 line,
560 matched_instance,
561 clone_groups,
562 }
563}
564
565#[must_use]
575pub fn trace_clone_by_fingerprint(
576 report: &DuplicationReport,
577 root: &Path,
578 fingerprint: &str,
579) -> CloneTrace {
580 let fingerprints = CloneFingerprintSet::from_groups(&report.clone_groups);
581 let matched = fingerprints.find_group(&report.clone_groups, fingerprint);
582
583 let Some(group) = matched else {
584 return CloneTrace {
585 file: PathBuf::new(),
586 line: 0,
587 matched_instance: None,
588 clone_groups: Vec::new(),
589 };
590 };
591
592 let representative = group
593 .instances
594 .first()
595 .map(|inst| relativize_instance(inst, root));
596 let (file, line) = representative.as_ref().map_or_else(
597 || (PathBuf::new(), 0),
598 |inst| (inst.file.clone(), inst.start_line),
599 );
600
601 CloneTrace {
602 file,
603 line,
604 matched_instance: representative,
605 clone_groups: vec![build_traced_group(group, root, &fingerprints)],
606 }
607}
608
609fn relativize_instance(inst: &CloneInstance, root: &Path) -> CloneInstance {
613 let rel = inst.file.strip_prefix(root).map_or_else(
614 |_| inst.file.clone(),
615 |p| PathBuf::from(p.to_string_lossy().replace('\\', "/")),
616 );
617 CloneInstance {
618 file: rel,
619 ..inst.clone()
620 }
621}
622
623#[cfg(test)]
624mod tests {
625 use super::*;
626
627 use crate::discover::{DiscoveredFile, EntryPoint, EntryPointSource, FileId};
628 use crate::extract::{ExportInfo, ExportName, ImportInfo, ImportedName, VisibilityTag};
629 use crate::resolve::{ResolveResult, ResolvedImport, ResolvedModule};
630
631 fn build_test_graph() -> ModuleGraph {
632 let files = vec![
633 DiscoveredFile {
634 id: FileId(0),
635 path: PathBuf::from("/project/src/entry.ts"),
636 size_bytes: 100,
637 },
638 DiscoveredFile {
639 id: FileId(1),
640 path: PathBuf::from("/project/src/utils.ts"),
641 size_bytes: 50,
642 },
643 DiscoveredFile {
644 id: FileId(2),
645 path: PathBuf::from("/project/src/unused.ts"),
646 size_bytes: 30,
647 },
648 ];
649
650 let entry_points = vec![EntryPoint {
651 path: PathBuf::from("/project/src/entry.ts"),
652 source: EntryPointSource::PackageJsonMain,
653 }];
654
655 let resolved_modules = vec![
656 ResolvedModule {
657 file_id: FileId(0),
658 path: PathBuf::from("/project/src/entry.ts"),
659 resolved_imports: vec![ResolvedImport {
660 info: ImportInfo {
661 source: "./utils".to_string(),
662 imported_name: ImportedName::Named("foo".to_string()),
663 local_name: "foo".to_string(),
664 is_type_only: false,
665 from_style: false,
666 span: oxc_span::Span::new(0, 10),
667 source_span: oxc_span::Span::default(),
668 },
669 target: ResolveResult::InternalModule(FileId(1)),
670 }],
671 ..Default::default()
672 },
673 ResolvedModule {
674 file_id: FileId(1),
675 path: PathBuf::from("/project/src/utils.ts"),
676 exports: vec![
677 ExportInfo {
678 name: ExportName::Named("foo".to_string()),
679 local_name: Some("foo".to_string()),
680 is_type_only: false,
681 visibility: VisibilityTag::None,
682 expected_unused_reason: None,
683 span: oxc_span::Span::new(0, 20),
684 members: vec![],
685 is_side_effect_used: false,
686 super_class: None,
687 },
688 ExportInfo {
689 name: ExportName::Named("bar".to_string()),
690 local_name: Some("bar".to_string()),
691 is_type_only: false,
692 visibility: VisibilityTag::None,
693 expected_unused_reason: None,
694 span: oxc_span::Span::new(21, 40),
695 members: vec![],
696 is_side_effect_used: false,
697 super_class: None,
698 },
699 ],
700 ..Default::default()
701 },
702 ResolvedModule {
703 file_id: FileId(2),
704 path: PathBuf::from("/project/src/unused.ts"),
705 exports: vec![ExportInfo {
706 name: ExportName::Named("baz".to_string()),
707 local_name: Some("baz".to_string()),
708 is_type_only: false,
709 visibility: VisibilityTag::None,
710 expected_unused_reason: None,
711 span: oxc_span::Span::new(0, 15),
712 members: vec![],
713 is_side_effect_used: false,
714 super_class: None,
715 }],
716 ..Default::default()
717 },
718 ];
719
720 ModuleGraph::build(&resolved_modules, &entry_points, &files)
721 }
722
723 #[test]
724 fn trace_used_export() {
725 let graph = build_test_graph();
726 let root = Path::new("/project");
727
728 let trace = trace_export(&graph, root, "src/utils.ts", "foo").unwrap();
729 assert!(trace.is_used);
730 assert!(trace.file_reachable);
731 assert_eq!(trace.direct_references.len(), 1);
732 assert_eq!(
733 trace.direct_references[0].from_file,
734 PathBuf::from("src/entry.ts")
735 );
736 assert_eq!(trace.direct_references[0].kind, "named import");
737 }
738
739 #[test]
740 fn trace_unused_export() {
741 let graph = build_test_graph();
742 let root = Path::new("/project");
743
744 let trace = trace_export(&graph, root, "src/utils.ts", "bar").unwrap();
745 assert!(!trace.is_used);
746 assert!(trace.file_reachable);
747 assert!(trace.direct_references.is_empty());
748 }
749
750 #[test]
751 fn trace_unreachable_file_export() {
752 let graph = build_test_graph();
753 let root = Path::new("/project");
754
755 let trace = trace_export(&graph, root, "src/unused.ts", "baz").unwrap();
756 assert!(!trace.is_used);
757 assert!(!trace.file_reachable);
758 assert!(trace.reason.contains("unreachable"));
759 }
760
761 #[test]
762 fn trace_nonexistent_export() {
763 let graph = build_test_graph();
764 let root = Path::new("/project");
765
766 let trace = trace_export(&graph, root, "src/utils.ts", "nonexistent");
767 assert!(trace.is_none());
768 }
769
770 #[test]
771 fn trace_nonexistent_file() {
772 let graph = build_test_graph();
773 let root = Path::new("/project");
774
775 let trace = trace_export(&graph, root, "src/nope.ts", "foo");
776 assert!(trace.is_none());
777 }
778
779 #[test]
780 fn trace_file_edges() {
781 let graph = build_test_graph();
782 let root = Path::new("/project");
783
784 let trace = trace_file(&graph, root, "src/entry.ts").unwrap();
785 assert!(trace.is_entry_point);
786 assert!(trace.is_reachable);
787 assert_eq!(trace.imports_from.len(), 1);
788 assert_eq!(trace.imports_from[0], PathBuf::from("src/utils.ts"));
789 assert!(trace.imported_by.is_empty());
790 }
791
792 #[test]
793 fn trace_file_imported_by() {
794 let graph = build_test_graph();
795 let root = Path::new("/project");
796
797 let trace = trace_file(&graph, root, "src/utils.ts").unwrap();
798 assert!(!trace.is_entry_point);
799 assert!(trace.is_reachable);
800 assert_eq!(trace.exports.len(), 2);
801 assert_eq!(trace.imported_by.len(), 1);
802 assert_eq!(trace.imported_by[0], PathBuf::from("src/entry.ts"));
803 }
804
805 #[test]
806 fn trace_unreachable_file() {
807 let graph = build_test_graph();
808 let root = Path::new("/project");
809
810 let trace = trace_file(&graph, root, "src/unused.ts").unwrap();
811 assert!(!trace.is_reachable);
812 assert!(!trace.is_entry_point);
813 assert!(trace.imported_by.is_empty());
814 }
815
816 #[test]
817 fn trace_dependency_used() {
818 let files = vec![DiscoveredFile {
819 id: FileId(0),
820 path: PathBuf::from("/project/src/app.ts"),
821 size_bytes: 100,
822 }];
823 let entry_points = vec![EntryPoint {
824 path: PathBuf::from("/project/src/app.ts"),
825 source: EntryPointSource::PackageJsonMain,
826 }];
827 let resolved_modules = vec![ResolvedModule {
828 file_id: FileId(0),
829 path: PathBuf::from("/project/src/app.ts"),
830 resolved_imports: vec![ResolvedImport {
831 info: ImportInfo {
832 source: "lodash".to_string(),
833 imported_name: ImportedName::Named("get".to_string()),
834 local_name: "get".to_string(),
835 is_type_only: false,
836 from_style: false,
837 span: oxc_span::Span::new(0, 10),
838 source_span: oxc_span::Span::default(),
839 },
840 target: ResolveResult::NpmPackage("lodash".to_string()),
841 }],
842 ..Default::default()
843 }];
844
845 let graph = ModuleGraph::build(&resolved_modules, &entry_points, &files);
846 let root = Path::new("/project");
847
848 let trace = trace_dependency(&graph, root, "lodash", &FxHashSet::default());
849 assert!(trace.is_used);
850 assert!(!trace.used_in_scripts);
851 assert_eq!(trace.import_count, 1);
852 assert_eq!(trace.imported_by[0], PathBuf::from("src/app.ts"));
853 }
854
855 #[test]
856 fn trace_dependency_unused() {
857 let files = vec![DiscoveredFile {
858 id: FileId(0),
859 path: PathBuf::from("/project/src/app.ts"),
860 size_bytes: 100,
861 }];
862 let entry_points = vec![EntryPoint {
863 path: PathBuf::from("/project/src/app.ts"),
864 source: EntryPointSource::PackageJsonMain,
865 }];
866 let resolved_modules = vec![ResolvedModule {
867 file_id: FileId(0),
868 path: PathBuf::from("/project/src/app.ts"),
869 ..Default::default()
870 }];
871
872 let graph = ModuleGraph::build(&resolved_modules, &entry_points, &files);
873 let root = Path::new("/project");
874
875 let trace = trace_dependency(&graph, root, "nonexistent-pkg", &FxHashSet::default());
876 assert!(!trace.is_used);
877 assert!(!trace.used_in_scripts);
878 assert_eq!(trace.import_count, 0);
879 assert!(trace.imported_by.is_empty());
880 }
881
882 #[test]
883 fn trace_dependency_used_only_in_scripts() {
884 let files = vec![DiscoveredFile {
885 id: FileId(0),
886 path: PathBuf::from("/project/src/app.ts"),
887 size_bytes: 100,
888 }];
889 let entry_points = vec![EntryPoint {
890 path: PathBuf::from("/project/src/app.ts"),
891 source: EntryPointSource::PackageJsonMain,
892 }];
893 let resolved_modules = vec![ResolvedModule {
894 file_id: FileId(0),
895 path: PathBuf::from("/project/src/app.ts"),
896 ..Default::default()
897 }];
898
899 let graph = ModuleGraph::build(&resolved_modules, &entry_points, &files);
900 let root = Path::new("/project");
901 let mut script_used = FxHashSet::default();
902 script_used.insert("microbundle".to_string());
903
904 let trace = trace_dependency(&graph, root, "microbundle", &script_used);
905 assert!(
906 trace.is_used,
907 "is_used must be true when the package is referenced from package.json scripts"
908 );
909 assert!(trace.used_in_scripts);
910 assert_eq!(trace.import_count, 0);
911 assert!(trace.imported_by.is_empty());
912 }
913
914 #[test]
915 fn trace_clone_finds_matching_group() {
916 use crate::duplicates::{CloneGroup, CloneInstance, DuplicationReport, DuplicationStats};
917 let report = DuplicationReport {
918 clone_groups: vec![CloneGroup {
919 instances: vec![
920 CloneInstance {
921 file: PathBuf::from("/project/src/a.ts"),
922 start_line: 10,
923 end_line: 20,
924 start_col: 0,
925 end_col: 0,
926 fragment: "fn foo() {}".to_string(),
927 },
928 CloneInstance {
929 file: PathBuf::from("/project/src/b.ts"),
930 start_line: 5,
931 end_line: 15,
932 start_col: 0,
933 end_col: 0,
934 fragment: "fn foo() {}".to_string(),
935 },
936 ],
937 token_count: 60,
938 line_count: 11,
939 }],
940 clone_families: vec![],
941 mirrored_directories: vec![],
942 stats: DuplicationStats {
943 total_files: 2,
944 files_with_clones: 2,
945 total_lines: 100,
946 duplicated_lines: 22,
947 total_tokens: 200,
948 duplicated_tokens: 120,
949 clone_groups: 1,
950 clone_instances: 2,
951 duplication_percentage: 22.0,
952 clone_groups_below_min_occurrences: 0,
953 },
954 };
955 let trace = trace_clone(&report, Path::new("/project"), "src/a.ts", 15);
956 assert!(trace.matched_instance.is_some());
957 assert_eq!(trace.clone_groups.len(), 1);
958 assert_eq!(trace.clone_groups[0].instances.len(), 2);
959 assert!(trace.clone_groups[0].fingerprint.starts_with("dup:"));
960 assert_eq!(trace.clone_groups[0].suggestion.estimated_savings, 11);
961 }
962
963 #[test]
964 fn trace_clone_by_fingerprint_resolves_and_misses() {
965 use crate::duplicates::{
966 CloneGroup, CloneInstance, DuplicationReport, DuplicationStats, clone_fingerprint,
967 };
968 let report = DuplicationReport {
969 clone_groups: vec![CloneGroup {
970 instances: vec![
971 CloneInstance {
972 file: PathBuf::from("/project/src/a.ts"),
973 start_line: 10,
974 end_line: 20,
975 start_col: 0,
976 end_col: 0,
977 fragment: "fn buildInvoice() {}".to_string(),
978 },
979 CloneInstance {
980 file: PathBuf::from("/project/src/b.ts"),
981 start_line: 5,
982 end_line: 15,
983 start_col: 0,
984 end_col: 0,
985 fragment: "fn buildInvoice() {}".to_string(),
986 },
987 ],
988 token_count: 60,
989 line_count: 11,
990 }],
991 clone_families: vec![],
992 mirrored_directories: vec![],
993 stats: DuplicationStats::default(),
994 };
995 let fp = clone_fingerprint(&report.clone_groups[0].instances);
996
997 let hit = trace_clone_by_fingerprint(&report, Path::new("/project"), &fp);
998 assert!(hit.matched_instance.is_some());
999 assert_eq!(hit.clone_groups.len(), 1);
1000 assert_eq!(hit.clone_groups[0].fingerprint, fp);
1001 assert_eq!(hit.line, 10);
1002
1003 let miss = trace_clone_by_fingerprint(&report, Path::new("/project"), "dup:deadbeef");
1004 assert!(miss.matched_instance.is_none());
1005 assert!(miss.clone_groups.is_empty());
1006 }
1007
1008 #[test]
1009 fn trace_clone_no_match() {
1010 use crate::duplicates::{CloneGroup, CloneInstance, DuplicationReport, DuplicationStats};
1011 let report = DuplicationReport {
1012 clone_groups: vec![CloneGroup {
1013 instances: vec![CloneInstance {
1014 file: PathBuf::from("/project/src/a.ts"),
1015 start_line: 10,
1016 end_line: 20,
1017 start_col: 0,
1018 end_col: 0,
1019 fragment: "fn foo() {}".to_string(),
1020 }],
1021 token_count: 60,
1022 line_count: 11,
1023 }],
1024 clone_families: vec![],
1025 mirrored_directories: vec![],
1026 stats: DuplicationStats {
1027 total_files: 1,
1028 files_with_clones: 1,
1029 total_lines: 50,
1030 duplicated_lines: 11,
1031 total_tokens: 100,
1032 duplicated_tokens: 60,
1033 clone_groups: 1,
1034 clone_instances: 1,
1035 duplication_percentage: 22.0,
1036 clone_groups_below_min_occurrences: 0,
1037 },
1038 };
1039 let trace = trace_clone(&report, Path::new("/project"), "src/a.ts", 25);
1040 assert!(trace.matched_instance.is_none());
1041 assert!(trace.clone_groups.is_empty());
1042 }
1043
1044 #[test]
1045 fn trace_clone_line_boundary() {
1046 use crate::duplicates::{CloneGroup, CloneInstance, DuplicationReport, DuplicationStats};
1047 let report = DuplicationReport {
1048 clone_groups: vec![CloneGroup {
1049 instances: vec![
1050 CloneInstance {
1051 file: PathBuf::from("/project/src/a.ts"),
1052 start_line: 10,
1053 end_line: 20,
1054 start_col: 0,
1055 end_col: 0,
1056 fragment: "code".to_string(),
1057 },
1058 CloneInstance {
1059 file: PathBuf::from("/project/src/b.ts"),
1060 start_line: 1,
1061 end_line: 11,
1062 start_col: 0,
1063 end_col: 0,
1064 fragment: "code".to_string(),
1065 },
1066 ],
1067 token_count: 50,
1068 line_count: 11,
1069 }],
1070 clone_families: vec![],
1071 mirrored_directories: vec![],
1072 stats: DuplicationStats {
1073 total_files: 2,
1074 files_with_clones: 2,
1075 total_lines: 100,
1076 duplicated_lines: 22,
1077 total_tokens: 200,
1078 duplicated_tokens: 100,
1079 clone_groups: 1,
1080 clone_instances: 2,
1081 duplication_percentage: 22.0,
1082 clone_groups_below_min_occurrences: 0,
1083 },
1084 };
1085 let root = Path::new("/project");
1086 assert!(
1087 trace_clone(&report, root, "src/a.ts", 10)
1088 .matched_instance
1089 .is_some()
1090 );
1091 assert!(
1092 trace_clone(&report, root, "src/a.ts", 20)
1093 .matched_instance
1094 .is_some()
1095 );
1096 assert!(
1097 trace_clone(&report, root, "src/a.ts", 21)
1098 .matched_instance
1099 .is_none()
1100 );
1101 }
1102
1103 #[test]
1104 fn trace_clone_returns_relative_instance_paths() {
1105 use crate::duplicates::{CloneGroup, CloneInstance, DuplicationReport, DuplicationStats};
1106 let report = DuplicationReport {
1107 clone_groups: vec![CloneGroup {
1108 instances: vec![
1109 CloneInstance {
1110 file: PathBuf::from("/project/src/a.ts"),
1111 start_line: 1,
1112 end_line: 10,
1113 start_col: 0,
1114 end_col: 0,
1115 fragment: "code".to_string(),
1116 },
1117 CloneInstance {
1118 file: PathBuf::from("/project/src/b.ts"),
1119 start_line: 1,
1120 end_line: 10,
1121 start_col: 0,
1122 end_col: 0,
1123 fragment: "code".to_string(),
1124 },
1125 ],
1126 token_count: 50,
1127 line_count: 10,
1128 }],
1129 clone_families: vec![],
1130 mirrored_directories: vec![],
1131 stats: DuplicationStats {
1132 total_files: 2,
1133 files_with_clones: 2,
1134 total_lines: 50,
1135 duplicated_lines: 20,
1136 total_tokens: 100,
1137 duplicated_tokens: 100,
1138 clone_groups: 1,
1139 clone_instances: 2,
1140 duplication_percentage: 40.0,
1141 clone_groups_below_min_occurrences: 0,
1142 },
1143 };
1144 let trace = trace_clone(&report, Path::new("/project"), "src/a.ts", 5);
1145 let matched = trace.matched_instance.as_ref().expect("match expected");
1146 assert_eq!(matched.file, PathBuf::from("src/a.ts"));
1147 for group in &trace.clone_groups {
1148 for inst in &group.instances {
1149 let as_str = inst.file.to_string_lossy();
1150 assert!(
1151 !as_str.starts_with('/'),
1152 "instance file should be relative, got {as_str}",
1153 );
1154 assert!(
1155 !as_str.contains(":\\") && !as_str.contains(":/"),
1156 "instance file should not have a drive letter, got {as_str}",
1157 );
1158 }
1159 }
1160
1161 let json = serde_json::to_string(&trace).expect("serializes");
1162 assert!(
1163 !json.contains("\"/project/"),
1164 "serialized trace should not leak absolute paths: {json}",
1165 );
1166 }
1167
1168 #[test]
1175 fn path_matches_normalises_windows_module_path_against_posix_user_path() {
1176 let root = Path::new(r"D:\a\fallow\fallow\tests\fixtures\basic-project");
1177 let module_path =
1178 PathBuf::from(r"D:\a\fallow\fallow\tests\fixtures\basic-project\src\utils.ts");
1179 assert!(path_matches(&module_path, root, "src/utils.ts"));
1180 assert!(path_matches(&module_path, root, r"src\utils.ts"));
1181 }
1182
1183 #[test]
1184 fn path_matches_ends_with_fallback_handles_mixed_separators() {
1185 let root = Path::new("/some/other/root");
1186 let module_path =
1187 PathBuf::from(r"D:\a\fallow\fallow\tests\fixtures\basic-project\src\utils.ts");
1188 assert!(path_matches(&module_path, root, "src/utils.ts"));
1189 }
1190
1191 #[test]
1200 fn export_trace_serializes_windows_path_with_forward_slashes() {
1201 let trace = ExportTrace {
1202 file: PathBuf::from(r"src\utils.ts"),
1203 export_name: "foo".to_string(),
1204 file_reachable: true,
1205 is_entry_point: false,
1206 is_used: true,
1207 direct_references: vec![ExportReference {
1208 from_file: PathBuf::from(r"src\entry.ts"),
1209 kind: "named import".to_string(),
1210 }],
1211 re_export_chains: vec![ReExportChain {
1212 barrel_file: PathBuf::from(r"src\index.ts"),
1213 exported_as: "foo".to_string(),
1214 reference_count: 1,
1215 }],
1216 reason: "ok".to_string(),
1217 };
1218 let json = serde_json::to_string(&trace).expect("serializes");
1219 assert!(
1220 json.contains("\"file\":\"src/utils.ts\""),
1221 "ExportTrace.file must serialize with forward slashes: {json}"
1222 );
1223 assert!(
1224 json.contains("\"from_file\":\"src/entry.ts\""),
1225 "ExportReference.from_file must serialize with forward slashes: {json}"
1226 );
1227 assert!(
1228 json.contains("\"barrel_file\":\"src/index.ts\""),
1229 "ReExportChain.barrel_file must serialize with forward slashes: {json}"
1230 );
1231 assert!(
1232 !json.contains(r"\\"),
1233 "no backslash sequence should remain anywhere in the JSON: {json}"
1234 );
1235 }
1236
1237 #[test]
1238 fn file_trace_serializes_windows_paths_with_forward_slashes() {
1239 let trace = FileTrace {
1240 file: PathBuf::from(r"src\utils.ts"),
1241 is_reachable: true,
1242 is_entry_point: false,
1243 exports: vec![],
1244 imports_from: vec![PathBuf::from(r"src\helpers.ts")],
1245 imported_by: vec![PathBuf::from(r"src\entry.ts")],
1246 re_exports: vec![TracedReExport {
1247 source_file: PathBuf::from(r"src\source.ts"),
1248 imported_name: "foo".to_string(),
1249 exported_name: "foo".to_string(),
1250 }],
1251 };
1252 let json = serde_json::to_string(&trace).expect("serializes");
1253 assert!(json.contains("\"file\":\"src/utils.ts\""), "got {json}");
1254 assert!(
1255 json.contains("\"imports_from\":[\"src/helpers.ts\"]"),
1256 "got {json}"
1257 );
1258 assert!(
1259 json.contains("\"imported_by\":[\"src/entry.ts\"]"),
1260 "got {json}"
1261 );
1262 assert!(
1263 json.contains("\"source_file\":\"src/source.ts\""),
1264 "got {json}"
1265 );
1266 assert!(!json.contains(r"\\"), "no backslash should remain: {json}");
1267 }
1268}