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