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