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 expected_unused_reason: None,
636 span: oxc_span::Span::new(0, 20),
637 members: vec![],
638 is_side_effect_used: false,
639 super_class: None,
640 },
641 ExportInfo {
642 name: ExportName::Named("bar".to_string()),
643 local_name: Some("bar".to_string()),
644 is_type_only: false,
645 visibility: VisibilityTag::None,
646 expected_unused_reason: None,
647 span: oxc_span::Span::new(21, 40),
648 members: vec![],
649 is_side_effect_used: false,
650 super_class: None,
651 },
652 ],
653 ..Default::default()
654 },
655 ResolvedModule {
656 file_id: FileId(2),
657 path: PathBuf::from("/project/src/unused.ts"),
658 exports: vec![ExportInfo {
659 name: ExportName::Named("baz".to_string()),
660 local_name: Some("baz".to_string()),
661 is_type_only: false,
662 visibility: VisibilityTag::None,
663 expected_unused_reason: None,
664 span: oxc_span::Span::new(0, 15),
665 members: vec![],
666 is_side_effect_used: false,
667 super_class: None,
668 }],
669 ..Default::default()
670 },
671 ];
672
673 ModuleGraph::build(&resolved_modules, &entry_points, &files)
674 }
675
676 #[test]
677 fn trace_used_export() {
678 let graph = build_test_graph();
679 let root = Path::new("/project");
680
681 let trace = trace_export(&graph, root, "src/utils.ts", "foo").unwrap();
682 assert!(trace.is_used);
683 assert!(trace.file_reachable);
684 assert_eq!(trace.direct_references.len(), 1);
685 assert_eq!(
686 trace.direct_references[0].from_file,
687 PathBuf::from("src/entry.ts")
688 );
689 assert_eq!(trace.direct_references[0].kind, "named import");
690 }
691
692 #[test]
693 fn trace_unused_export() {
694 let graph = build_test_graph();
695 let root = Path::new("/project");
696
697 let trace = trace_export(&graph, root, "src/utils.ts", "bar").unwrap();
698 assert!(!trace.is_used);
699 assert!(trace.file_reachable);
700 assert!(trace.direct_references.is_empty());
701 }
702
703 #[test]
704 fn trace_unreachable_file_export() {
705 let graph = build_test_graph();
706 let root = Path::new("/project");
707
708 let trace = trace_export(&graph, root, "src/unused.ts", "baz").unwrap();
709 assert!(!trace.is_used);
710 assert!(!trace.file_reachable);
711 assert!(trace.reason.contains("unreachable"));
712 }
713
714 #[test]
715 fn trace_nonexistent_export() {
716 let graph = build_test_graph();
717 let root = Path::new("/project");
718
719 let trace = trace_export(&graph, root, "src/utils.ts", "nonexistent");
720 assert!(trace.is_none());
721 }
722
723 #[test]
724 fn trace_nonexistent_file() {
725 let graph = build_test_graph();
726 let root = Path::new("/project");
727
728 let trace = trace_export(&graph, root, "src/nope.ts", "foo");
729 assert!(trace.is_none());
730 }
731
732 #[test]
733 fn trace_file_edges() {
734 let graph = build_test_graph();
735 let root = Path::new("/project");
736
737 let trace = trace_file(&graph, root, "src/entry.ts").unwrap();
738 assert!(trace.is_entry_point);
739 assert!(trace.is_reachable);
740 assert_eq!(trace.imports_from.len(), 1);
741 assert_eq!(trace.imports_from[0], PathBuf::from("src/utils.ts"));
742 assert!(trace.imported_by.is_empty());
743 }
744
745 #[test]
746 fn trace_file_imported_by() {
747 let graph = build_test_graph();
748 let root = Path::new("/project");
749
750 let trace = trace_file(&graph, root, "src/utils.ts").unwrap();
751 assert!(!trace.is_entry_point);
752 assert!(trace.is_reachable);
753 assert_eq!(trace.exports.len(), 2);
754 assert_eq!(trace.imported_by.len(), 1);
755 assert_eq!(trace.imported_by[0], PathBuf::from("src/entry.ts"));
756 }
757
758 #[test]
759 fn trace_unreachable_file() {
760 let graph = build_test_graph();
761 let root = Path::new("/project");
762
763 let trace = trace_file(&graph, root, "src/unused.ts").unwrap();
764 assert!(!trace.is_reachable);
765 assert!(!trace.is_entry_point);
766 assert!(trace.imported_by.is_empty());
767 }
768
769 #[test]
770 fn trace_dependency_used() {
771 let files = vec![DiscoveredFile {
772 id: FileId(0),
773 path: PathBuf::from("/project/src/app.ts"),
774 size_bytes: 100,
775 }];
776 let entry_points = vec![EntryPoint {
777 path: PathBuf::from("/project/src/app.ts"),
778 source: EntryPointSource::PackageJsonMain,
779 }];
780 let resolved_modules = vec![ResolvedModule {
781 file_id: FileId(0),
782 path: PathBuf::from("/project/src/app.ts"),
783 resolved_imports: vec![ResolvedImport {
784 info: ImportInfo {
785 source: "lodash".to_string(),
786 imported_name: ImportedName::Named("get".to_string()),
787 local_name: "get".to_string(),
788 is_type_only: false,
789 from_style: false,
790 span: oxc_span::Span::new(0, 10),
791 source_span: oxc_span::Span::default(),
792 },
793 target: ResolveResult::NpmPackage("lodash".to_string()),
794 }],
795 ..Default::default()
796 }];
797
798 let graph = ModuleGraph::build(&resolved_modules, &entry_points, &files);
799 let root = Path::new("/project");
800
801 let trace = trace_dependency(&graph, root, "lodash", &FxHashSet::default());
802 assert!(trace.is_used);
803 assert!(!trace.used_in_scripts);
804 assert_eq!(trace.import_count, 1);
805 assert_eq!(trace.imported_by[0], PathBuf::from("src/app.ts"));
806 }
807
808 #[test]
809 fn trace_dependency_unused() {
810 let files = vec![DiscoveredFile {
811 id: FileId(0),
812 path: PathBuf::from("/project/src/app.ts"),
813 size_bytes: 100,
814 }];
815 let entry_points = vec![EntryPoint {
816 path: PathBuf::from("/project/src/app.ts"),
817 source: EntryPointSource::PackageJsonMain,
818 }];
819 let resolved_modules = vec![ResolvedModule {
820 file_id: FileId(0),
821 path: PathBuf::from("/project/src/app.ts"),
822 ..Default::default()
823 }];
824
825 let graph = ModuleGraph::build(&resolved_modules, &entry_points, &files);
826 let root = Path::new("/project");
827
828 let trace = trace_dependency(&graph, root, "nonexistent-pkg", &FxHashSet::default());
829 assert!(!trace.is_used);
830 assert!(!trace.used_in_scripts);
831 assert_eq!(trace.import_count, 0);
832 assert!(trace.imported_by.is_empty());
833 }
834
835 #[test]
836 fn trace_dependency_used_only_in_scripts() {
837 let files = vec![DiscoveredFile {
838 id: FileId(0),
839 path: PathBuf::from("/project/src/app.ts"),
840 size_bytes: 100,
841 }];
842 let entry_points = vec![EntryPoint {
843 path: PathBuf::from("/project/src/app.ts"),
844 source: EntryPointSource::PackageJsonMain,
845 }];
846 let resolved_modules = vec![ResolvedModule {
847 file_id: FileId(0),
848 path: PathBuf::from("/project/src/app.ts"),
849 ..Default::default()
850 }];
851
852 let graph = ModuleGraph::build(&resolved_modules, &entry_points, &files);
853 let root = Path::new("/project");
854 let mut script_used = FxHashSet::default();
855 script_used.insert("microbundle".to_string());
856
857 let trace = trace_dependency(&graph, root, "microbundle", &script_used);
858 assert!(
859 trace.is_used,
860 "is_used must be true when the package is referenced from package.json scripts"
861 );
862 assert!(trace.used_in_scripts);
863 assert_eq!(trace.import_count, 0);
864 assert!(trace.imported_by.is_empty());
865 }
866
867 #[test]
868 fn trace_clone_finds_matching_group() {
869 use crate::duplicates::{CloneGroup, CloneInstance, DuplicationReport, DuplicationStats};
870 let report = DuplicationReport {
871 clone_groups: vec![CloneGroup {
872 instances: vec![
873 CloneInstance {
874 file: PathBuf::from("/project/src/a.ts"),
875 start_line: 10,
876 end_line: 20,
877 start_col: 0,
878 end_col: 0,
879 fragment: "fn foo() {}".to_string(),
880 },
881 CloneInstance {
882 file: PathBuf::from("/project/src/b.ts"),
883 start_line: 5,
884 end_line: 15,
885 start_col: 0,
886 end_col: 0,
887 fragment: "fn foo() {}".to_string(),
888 },
889 ],
890 token_count: 60,
891 line_count: 11,
892 }],
893 clone_families: vec![],
894 mirrored_directories: vec![],
895 stats: DuplicationStats {
896 total_files: 2,
897 files_with_clones: 2,
898 total_lines: 100,
899 duplicated_lines: 22,
900 total_tokens: 200,
901 duplicated_tokens: 120,
902 clone_groups: 1,
903 clone_instances: 2,
904 duplication_percentage: 22.0,
905 clone_groups_below_min_occurrences: 0,
906 },
907 };
908 let trace = trace_clone(&report, Path::new("/project"), "src/a.ts", 15);
909 assert!(trace.matched_instance.is_some());
910 assert_eq!(trace.clone_groups.len(), 1);
911 assert_eq!(trace.clone_groups[0].instances.len(), 2);
912 assert!(trace.clone_groups[0].fingerprint.starts_with("dup:"));
913 assert_eq!(trace.clone_groups[0].suggestion.estimated_savings, 11);
914 }
915
916 #[test]
917 fn trace_clone_by_fingerprint_resolves_and_misses() {
918 use crate::duplicates::{
919 CloneGroup, CloneInstance, DuplicationReport, DuplicationStats, clone_fingerprint,
920 };
921 let report = DuplicationReport {
922 clone_groups: vec![CloneGroup {
923 instances: vec![
924 CloneInstance {
925 file: PathBuf::from("/project/src/a.ts"),
926 start_line: 10,
927 end_line: 20,
928 start_col: 0,
929 end_col: 0,
930 fragment: "fn buildInvoice() {}".to_string(),
931 },
932 CloneInstance {
933 file: PathBuf::from("/project/src/b.ts"),
934 start_line: 5,
935 end_line: 15,
936 start_col: 0,
937 end_col: 0,
938 fragment: "fn buildInvoice() {}".to_string(),
939 },
940 ],
941 token_count: 60,
942 line_count: 11,
943 }],
944 clone_families: vec![],
945 mirrored_directories: vec![],
946 stats: DuplicationStats::default(),
947 };
948 let fp = clone_fingerprint(&report.clone_groups[0].instances);
949
950 let hit = trace_clone_by_fingerprint(&report, Path::new("/project"), &fp);
951 assert!(hit.matched_instance.is_some());
952 assert_eq!(hit.clone_groups.len(), 1);
953 assert_eq!(hit.clone_groups[0].fingerprint, fp);
954 assert_eq!(hit.line, 10);
955
956 let miss = trace_clone_by_fingerprint(&report, Path::new("/project"), "dup:deadbeef");
957 assert!(miss.matched_instance.is_none());
958 assert!(miss.clone_groups.is_empty());
959 }
960
961 #[test]
962 fn trace_clone_no_match() {
963 use crate::duplicates::{CloneGroup, CloneInstance, DuplicationReport, DuplicationStats};
964 let report = DuplicationReport {
965 clone_groups: vec![CloneGroup {
966 instances: vec![CloneInstance {
967 file: PathBuf::from("/project/src/a.ts"),
968 start_line: 10,
969 end_line: 20,
970 start_col: 0,
971 end_col: 0,
972 fragment: "fn foo() {}".to_string(),
973 }],
974 token_count: 60,
975 line_count: 11,
976 }],
977 clone_families: vec![],
978 mirrored_directories: vec![],
979 stats: DuplicationStats {
980 total_files: 1,
981 files_with_clones: 1,
982 total_lines: 50,
983 duplicated_lines: 11,
984 total_tokens: 100,
985 duplicated_tokens: 60,
986 clone_groups: 1,
987 clone_instances: 1,
988 duplication_percentage: 22.0,
989 clone_groups_below_min_occurrences: 0,
990 },
991 };
992 let trace = trace_clone(&report, Path::new("/project"), "src/a.ts", 25);
993 assert!(trace.matched_instance.is_none());
994 assert!(trace.clone_groups.is_empty());
995 }
996
997 #[test]
998 fn trace_clone_line_boundary() {
999 use crate::duplicates::{CloneGroup, CloneInstance, DuplicationReport, DuplicationStats};
1000 let report = DuplicationReport {
1001 clone_groups: vec![CloneGroup {
1002 instances: vec![
1003 CloneInstance {
1004 file: PathBuf::from("/project/src/a.ts"),
1005 start_line: 10,
1006 end_line: 20,
1007 start_col: 0,
1008 end_col: 0,
1009 fragment: "code".to_string(),
1010 },
1011 CloneInstance {
1012 file: PathBuf::from("/project/src/b.ts"),
1013 start_line: 1,
1014 end_line: 11,
1015 start_col: 0,
1016 end_col: 0,
1017 fragment: "code".to_string(),
1018 },
1019 ],
1020 token_count: 50,
1021 line_count: 11,
1022 }],
1023 clone_families: vec![],
1024 mirrored_directories: vec![],
1025 stats: DuplicationStats {
1026 total_files: 2,
1027 files_with_clones: 2,
1028 total_lines: 100,
1029 duplicated_lines: 22,
1030 total_tokens: 200,
1031 duplicated_tokens: 100,
1032 clone_groups: 1,
1033 clone_instances: 2,
1034 duplication_percentage: 22.0,
1035 clone_groups_below_min_occurrences: 0,
1036 },
1037 };
1038 let root = Path::new("/project");
1039 assert!(
1040 trace_clone(&report, root, "src/a.ts", 10)
1041 .matched_instance
1042 .is_some()
1043 );
1044 assert!(
1045 trace_clone(&report, root, "src/a.ts", 20)
1046 .matched_instance
1047 .is_some()
1048 );
1049 assert!(
1050 trace_clone(&report, root, "src/a.ts", 21)
1051 .matched_instance
1052 .is_none()
1053 );
1054 }
1055
1056 #[test]
1057 fn trace_clone_returns_relative_instance_paths() {
1058 use crate::duplicates::{CloneGroup, CloneInstance, DuplicationReport, DuplicationStats};
1059 let report = DuplicationReport {
1060 clone_groups: vec![CloneGroup {
1061 instances: vec![
1062 CloneInstance {
1063 file: PathBuf::from("/project/src/a.ts"),
1064 start_line: 1,
1065 end_line: 10,
1066 start_col: 0,
1067 end_col: 0,
1068 fragment: "code".to_string(),
1069 },
1070 CloneInstance {
1071 file: PathBuf::from("/project/src/b.ts"),
1072 start_line: 1,
1073 end_line: 10,
1074 start_col: 0,
1075 end_col: 0,
1076 fragment: "code".to_string(),
1077 },
1078 ],
1079 token_count: 50,
1080 line_count: 10,
1081 }],
1082 clone_families: vec![],
1083 mirrored_directories: vec![],
1084 stats: DuplicationStats {
1085 total_files: 2,
1086 files_with_clones: 2,
1087 total_lines: 50,
1088 duplicated_lines: 20,
1089 total_tokens: 100,
1090 duplicated_tokens: 100,
1091 clone_groups: 1,
1092 clone_instances: 2,
1093 duplication_percentage: 40.0,
1094 clone_groups_below_min_occurrences: 0,
1095 },
1096 };
1097 let trace = trace_clone(&report, Path::new("/project"), "src/a.ts", 5);
1098 let matched = trace.matched_instance.as_ref().expect("match expected");
1099 assert_eq!(matched.file, PathBuf::from("src/a.ts"));
1100 for group in &trace.clone_groups {
1101 for inst in &group.instances {
1102 let as_str = inst.file.to_string_lossy();
1103 assert!(
1104 !as_str.starts_with('/'),
1105 "instance file should be relative, got {as_str}",
1106 );
1107 assert!(
1108 !as_str.contains(":\\") && !as_str.contains(":/"),
1109 "instance file should not have a drive letter, got {as_str}",
1110 );
1111 }
1112 }
1113
1114 let json = serde_json::to_string(&trace).expect("serializes");
1115 assert!(
1116 !json.contains("\"/project/"),
1117 "serialized trace should not leak absolute paths: {json}",
1118 );
1119 }
1120
1121 #[test]
1128 fn path_matches_normalises_windows_module_path_against_posix_user_path() {
1129 let root = Path::new(r"D:\a\fallow\fallow\tests\fixtures\basic-project");
1130 let module_path =
1131 PathBuf::from(r"D:\a\fallow\fallow\tests\fixtures\basic-project\src\utils.ts");
1132 assert!(path_matches(&module_path, root, "src/utils.ts"));
1133 assert!(path_matches(&module_path, root, r"src\utils.ts"));
1134 }
1135
1136 #[test]
1137 fn path_matches_ends_with_fallback_handles_mixed_separators() {
1138 let root = Path::new("/some/other/root");
1139 let module_path =
1140 PathBuf::from(r"D:\a\fallow\fallow\tests\fixtures\basic-project\src\utils.ts");
1141 assert!(path_matches(&module_path, root, "src/utils.ts"));
1142 }
1143
1144 #[test]
1153 fn export_trace_serializes_windows_path_with_forward_slashes() {
1154 let trace = ExportTrace {
1155 file: PathBuf::from(r"src\utils.ts"),
1156 export_name: "foo".to_string(),
1157 file_reachable: true,
1158 is_entry_point: false,
1159 is_used: true,
1160 direct_references: vec![ExportReference {
1161 from_file: PathBuf::from(r"src\entry.ts"),
1162 kind: "named import".to_string(),
1163 }],
1164 re_export_chains: vec![ReExportChain {
1165 barrel_file: PathBuf::from(r"src\index.ts"),
1166 exported_as: "foo".to_string(),
1167 reference_count: 1,
1168 }],
1169 reason: "ok".to_string(),
1170 };
1171 let json = serde_json::to_string(&trace).expect("serializes");
1172 assert!(
1173 json.contains("\"file\":\"src/utils.ts\""),
1174 "ExportTrace.file must serialize with forward slashes: {json}"
1175 );
1176 assert!(
1177 json.contains("\"from_file\":\"src/entry.ts\""),
1178 "ExportReference.from_file must serialize with forward slashes: {json}"
1179 );
1180 assert!(
1181 json.contains("\"barrel_file\":\"src/index.ts\""),
1182 "ReExportChain.barrel_file must serialize with forward slashes: {json}"
1183 );
1184 assert!(
1185 !json.contains(r"\\"),
1186 "no backslash sequence should remain anywhere in the JSON: {json}"
1187 );
1188 }
1189
1190 #[test]
1191 fn file_trace_serializes_windows_paths_with_forward_slashes() {
1192 let trace = FileTrace {
1193 file: PathBuf::from(r"src\utils.ts"),
1194 is_reachable: true,
1195 is_entry_point: false,
1196 exports: vec![],
1197 imports_from: vec![PathBuf::from(r"src\helpers.ts")],
1198 imported_by: vec![PathBuf::from(r"src\entry.ts")],
1199 re_exports: vec![TracedReExport {
1200 source_file: PathBuf::from(r"src\source.ts"),
1201 imported_name: "foo".to_string(),
1202 exported_name: "foo".to_string(),
1203 }],
1204 };
1205 let json = serde_json::to_string(&trace).expect("serializes");
1206 assert!(json.contains("\"file\":\"src/utils.ts\""), "got {json}");
1207 assert!(
1208 json.contains("\"imports_from\":[\"src/helpers.ts\"]"),
1209 "got {json}"
1210 );
1211 assert!(
1212 json.contains("\"imported_by\":[\"src/entry.ts\"]"),
1213 "got {json}"
1214 );
1215 assert!(
1216 json.contains("\"source_file\":\"src/source.ts\""),
1217 "got {json}"
1218 );
1219 assert!(!json.contains(r"\\"), "no backslash should remain: {json}");
1220 }
1221}