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