1use std::path::{Path, PathBuf};
2
3pub use fallow_types::trace::{
4 ClassMemberTrace, CloneTrace, DependencyTrace, ExportReference, ExportTrace, FileTrace,
5 ImpactClosureGap, ImpactClosureTrace, PipelineTimings, ReExportChain, TracedCloneGroup,
6 TracedExport, TracedReExport,
7};
8use rustc_hash::FxHashSet;
9
10use crate::duplicates::{
11 CloneFingerprintSet, CloneGroup, CloneInstance, DuplicationReport, dominant_identifier,
12 group_refactoring_suggestion,
13};
14use crate::graph::{EffectiveExportResolution, ExportNamespace, ModuleGraph, ReferenceKind};
15
16fn path_matches(module_path: &Path, root: &Path, user_path: &str) -> bool {
21 let user_path_norm = user_path.replace('\\', "/");
22 let rel = module_path.strip_prefix(root).unwrap_or(module_path);
23 let rel_str = rel.to_string_lossy().replace('\\', "/");
24 let module_str = module_path.to_string_lossy().replace('\\', "/");
25 if rel_str == user_path_norm || module_str == user_path_norm {
26 return true;
27 }
28 if dunce::canonicalize(root).is_ok_and(|canonical_root| {
29 module_path
30 .strip_prefix(&canonical_root)
31 .is_ok_and(|rel| rel.to_string_lossy().replace('\\', "/") == user_path_norm)
32 }) {
33 return true;
34 }
35 module_str.ends_with(&format!("/{user_path_norm}"))
36}
37
38fn reference_to_export_reference(
40 graph: &ModuleGraph,
41 root: &Path,
42 r: &crate::graph::SymbolReference,
43) -> ExportReference {
44 let from_path = graph.modules.get(r.from_file.0 as usize).map_or_else(
45 || PathBuf::from(format!("<unknown:{}>", r.from_file.0)),
46 |m| m.path.strip_prefix(root).unwrap_or(&m.path).to_path_buf(),
47 );
48 ExportReference {
49 from_file: from_path,
50 kind: format_reference_kind(r.kind),
51 }
52}
53
54fn collect_re_export_chains(
56 graph: &ModuleGraph,
57 root: &Path,
58 target_file_id: crate::discover::FileId,
59 export_name: &str,
60 namespace: ExportNamespace,
61) -> Vec<ReExportChain> {
62 graph
63 .effective_re_export_routes(target_file_id, export_name, namespace)
64 .into_iter()
65 .filter_map(|route| {
66 let module = graph.modules.get(route.barrel_file().0 as usize)?;
67 Some(ReExportChain {
68 barrel_file: module
69 .path
70 .strip_prefix(root)
71 .unwrap_or(&module.path)
72 .to_path_buf(),
73 exported_as: route.exported_name().to_string(),
74 reference_count: graph
75 .effective_export_surface_references(
76 route.barrel_file(),
77 route.exported_name(),
78 namespace,
79 )
80 .len(),
81 })
82 })
83 .collect()
84}
85
86fn export_trace_reason(
88 module: &crate::graph::ModuleNode,
89 reference_count: usize,
90 is_used: bool,
91 re_export_chains: &[ReExportChain],
92) -> String {
93 if !module.is_reachable() {
94 "File is unreachable from any entry point".to_string()
95 } else if is_used {
96 format!(
97 "Used by {} file(s){}",
98 reference_count,
99 if re_export_chains.is_empty() {
100 String::new()
101 } else {
102 format!(", re-exported through {} barrel(s)", re_export_chains.len())
103 }
104 )
105 } else if module.is_entry_point() {
106 "No internal references, but file is an entry point (export is externally accessible)"
107 .to_string()
108 } else if !re_export_chains.is_empty() {
109 format!(
110 "Re-exported through {} barrel(s) but no consumer imports it through the barrel",
111 re_export_chains.len()
112 )
113 } else {
114 "No references found, export is unused".to_string()
115 }
116}
117
118#[must_use]
120pub fn trace_export(
121 graph: &ModuleGraph,
122 root: &Path,
123 file_path: &str,
124 export_name: &str,
125) -> Option<ExportTrace> {
126 let module = graph
127 .modules
128 .iter()
129 .find(|m| path_matches(&m.path, root, file_path))?;
130
131 let surface = select_export(graph, module, export_name)?;
132 let namespace = surface.namespace();
133
134 let mut referenced_files = FxHashSet::default();
135 let direct_references: Vec<ExportReference> = graph
136 .effective_export_surface_references(module.file_id, export_name, namespace)
137 .into_iter()
138 .filter(|reference| referenced_files.insert(reference.from_file))
139 .map(|r| reference_to_export_reference(graph, root, r))
140 .collect();
141
142 let re_export_chains =
143 collect_re_export_chains(graph, root, module.file_id, export_name, namespace);
144
145 let reference_count = direct_references.len();
146 let is_used = reference_count > 0;
147 let reason = export_trace_reason(module, reference_count, is_used, &re_export_chains);
148
149 Some(ExportTrace {
150 file: module
151 .path
152 .strip_prefix(root)
153 .unwrap_or(&module.path)
154 .to_path_buf(),
155 export_name: export_name.to_string(),
156 namespace: match namespace {
157 ExportNamespace::Type => fallow_types::semantic::SemanticNamespace::Type,
158 ExportNamespace::Value => fallow_types::semantic::SemanticNamespace::Value,
159 },
160 file_reachable: module.is_reachable(),
161 is_entry_point: module.is_entry_point(),
162 is_used,
163 direct_references,
164 re_export_chains,
165 reason,
166 semantic: None,
167 })
168}
169
170#[must_use]
173pub fn semantic_symbol_for_export(
174 graph: &ModuleGraph,
175 root: &Path,
176 file_path: &str,
177 export_name: &str,
178) -> Option<fallow_types::semantic::SemanticSymbol> {
179 use fallow_types::semantic::{SemanticNamespace, SemanticSymbol};
180
181 let module = graph
182 .modules
183 .iter()
184 .find(|module| path_matches(&module.path, root, file_path))?;
185 let surface = select_export(graph, module, export_name)?;
186 let namespace = surface.namespace();
187 let (identity_module, span, identity_exported_name, local_name) = if let Some(re_export) =
188 graph.effective_export_surface_re_export(module.file_id, export_name, namespace)
189 {
190 let local_name = if re_export.imported_name == "*" {
191 export_name
192 } else {
193 re_export.imported_name.as_str()
194 };
195 (module, re_export.span, export_name, local_name)
196 } else {
197 let origin = surface.origin()?;
198 let origin_module = graph.modules.get(origin.file_id().0 as usize)?;
199 let origin_export = origin.export();
200 let origin_name = match &origin_export.name {
201 fallow_types::extract::ExportName::Named(name) => name.as_str(),
202 fallow_types::extract::ExportName::Default => "default",
203 };
204 (origin_module, origin_export.span, origin_name, origin_name)
205 };
206 let source = std::fs::read_to_string(&identity_module.path).ok()?;
207 let offsets = fallow_types::extract::compute_line_offsets(&source);
208 let (line, col) = fallow_types::extract::byte_offset_to_line_col(&offsets, span.start);
209 Some(SemanticSymbol {
210 path: identity_module
211 .path
212 .strip_prefix(root)
213 .unwrap_or(&identity_module.path)
214 .to_path_buf(),
215 namespace: match namespace {
216 ExportNamespace::Type => SemanticNamespace::Type,
217 ExportNamespace::Value => SemanticNamespace::Value,
218 },
219 declaration_kind: "export".to_string(),
220 exported_name: identity_exported_name.to_string(),
221 local_name: local_name.to_string(),
222 owner: None,
223 line,
224 col,
225 })
226}
227
228#[must_use]
230pub fn semantic_symbol_for_class_member(
231 graph: &ModuleGraph,
232 root: &Path,
233 file_path: &str,
234 member_name: &str,
235) -> Option<fallow_types::semantic::SemanticSymbol> {
236 use fallow_types::extract::MemberKind;
237 use fallow_types::semantic::{SemanticNamespace, SemanticSymbol};
238
239 let module = graph
240 .modules
241 .iter()
242 .find(|module| path_matches(&module.path, root, file_path))?;
243 let (owner, member) = module
244 .exports
245 .iter()
246 .filter_map(|export| {
247 export
248 .members
249 .iter()
250 .find(|member| member.name == member_name)
251 .map(|member| (export, member))
252 })
253 .max_by_key(|(export, _)| (!export.references.is_empty(), !export.is_type_only))?;
254 let declaration_kind = match member.kind {
255 MemberKind::ClassMethod => "class_method",
256 MemberKind::ClassProperty => "class_property",
257 _ => return None,
258 };
259 let source = std::fs::read_to_string(&module.path).ok()?;
260 let offsets = fallow_types::extract::compute_line_offsets(&source);
261 let (line, col) = fallow_types::extract::byte_offset_to_line_col(&offsets, member.span.start);
262 Some(SemanticSymbol {
263 path: module
264 .path
265 .strip_prefix(root)
266 .unwrap_or(&module.path)
267 .to_path_buf(),
268 namespace: SemanticNamespace::Value,
269 declaration_kind: declaration_kind.to_string(),
270 exported_name: member_name.to_string(),
271 local_name: member_name.to_string(),
272 owner: Some(owner.name.to_string()),
273 line,
274 col,
275 })
276}
277
278#[derive(Debug, Clone, Copy, PartialEq, Eq)]
280pub enum SemanticClassMethodResolutionError {
281 FileNotFound,
283 SymbolNotFound,
285 AmbiguousSymbol,
287 UnsupportedSyntax,
289}
290
291impl std::fmt::Display for SemanticClassMethodResolutionError {
292 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
293 let reason = match self {
294 Self::FileNotFound => "file-not-found",
295 Self::SymbolNotFound => "unknown-symbol",
296 Self::AmbiguousSymbol => "ambiguous-symbol",
297 Self::UnsupportedSyntax => "unsupported-syntax",
298 };
299 formatter.write_str(reason)
300 }
301}
302
303pub fn semantic_symbol_for_exact_class_method(
305 graph: &ModuleGraph,
306 root: &Path,
307 file_path: &str,
308 owner_name: &str,
309 member_name: &str,
310) -> Result<fallow_types::semantic::SemanticSymbol, SemanticClassMethodResolutionError> {
311 use fallow_types::extract::MemberKind;
312 use fallow_types::semantic::{SemanticNamespace, SemanticSymbol};
313
314 let module = graph
315 .modules
316 .iter()
317 .find(|module| path_matches(&module.path, root, file_path))
318 .ok_or(SemanticClassMethodResolutionError::FileNotFound)?;
319 let owners = module
320 .exports
321 .iter()
322 .filter(|export| export.name.matches_str(owner_name))
323 .collect::<Vec<_>>();
324 if owners.len() != 1 {
325 return Err(if owners.is_empty() {
326 SemanticClassMethodResolutionError::SymbolNotFound
327 } else {
328 SemanticClassMethodResolutionError::AmbiguousSymbol
329 });
330 }
331 let owner = owners[0];
332 let members = owner
333 .members
334 .iter()
335 .filter(|member| member.name == member_name)
336 .collect::<Vec<_>>();
337 if members.len() != 1 {
338 return Err(if members.is_empty() {
339 SemanticClassMethodResolutionError::SymbolNotFound
340 } else {
341 SemanticClassMethodResolutionError::AmbiguousSymbol
342 });
343 }
344 let member = members[0];
345 if member.kind != MemberKind::ClassMethod {
346 return Err(SemanticClassMethodResolutionError::UnsupportedSyntax);
347 }
348 let source = std::fs::read_to_string(&module.path)
349 .map_err(|_| SemanticClassMethodResolutionError::SymbolNotFound)?;
350 let offsets = fallow_types::extract::compute_line_offsets(&source);
351 let (line, col) = fallow_types::extract::byte_offset_to_line_col(&offsets, member.span.start);
352 Ok(SemanticSymbol {
353 path: module
354 .path
355 .strip_prefix(root)
356 .unwrap_or(&module.path)
357 .to_path_buf(),
358 namespace: SemanticNamespace::Value,
359 declaration_kind: "class_method".to_string(),
360 exported_name: member_name.to_string(),
361 local_name: member_name.to_string(),
362 owner: Some(owner_name.to_string()),
363 line,
364 col,
365 })
366}
367
368#[must_use]
374pub fn trace_class_member(
375 graph: &ModuleGraph,
376 root: &Path,
377 file_path: &str,
378 member_name: &str,
379) -> Option<ClassMemberTrace> {
380 use fallow_types::extract::MemberKind;
381
382 let module = graph
383 .modules
384 .iter()
385 .find(|m| path_matches(&m.path, root, file_path))?;
386
387 let (owner, member_kind) = module
391 .exports
392 .iter()
393 .filter_map(|export| {
394 export
395 .members
396 .iter()
397 .find(|member| member.name == member_name)
398 .map(|member| (export, member.kind))
399 })
400 .max_by_key(|(export, _)| (!export.references.is_empty(), !export.is_type_only))?;
401
402 let owner_name = owner.name.to_string();
403 let owner_trace = trace_export(graph, root, file_path, &owner_name)?;
410
411 let (kind_str, filter_flag) = match member_kind {
412 MemberKind::ClassMethod => ("class-method", Some("--unused-class-members")),
413 MemberKind::ClassProperty => ("class-property", Some("--unused-class-members")),
414 MemberKind::EnumMember => ("enum-member", Some("--unused-enum-members")),
415 MemberKind::StoreMember => ("store-member", Some("--unused-store-members")),
416 MemberKind::NamespaceMember => ("namespace-member", None),
417 };
418
419 let reason = class_member_trace_reason(
420 member_name,
421 &owner_name,
422 kind_str,
423 filter_flag,
424 file_path,
425 &owner_trace,
426 );
427
428 Some(ClassMemberTrace {
429 file: owner_trace.file,
430 member_name: member_name.to_string(),
431 member_kind: kind_str.to_string(),
432 owner_export: owner_name,
433 owner_is_used: owner_trace.is_used,
434 owner_file_reachable: owner_trace.file_reachable,
435 owner_is_entry_point: owner_trace.is_entry_point,
436 owner_direct_references: owner_trace.direct_references,
437 owner_re_export_chains: owner_trace.re_export_chains,
438 reason,
439 semantic: None,
440 })
441}
442
443fn class_member_trace_reason(
446 member_name: &str,
447 owner_name: &str,
448 kind_str: &str,
449 filter_flag: Option<&str>,
450 file_path: &str,
451 owner_trace: &ExportTrace,
452) -> String {
453 let head =
454 format!("'{member_name}' is a {kind_str} of '{owner_name}', not a top-level export. ");
455 let body = if !owner_trace.file_reachable {
456 format!(
457 "The file is not reachable from any entry point, so '{owner_name}' and all its \
458 members are dead (see the unused-file finding)."
459 )
460 } else if !owner_trace.is_used {
461 format!(
462 "'{owner_name}' is reachable but referenced by no file, so it is reported as an \
463 unused export and its members are not judged individually."
464 )
465 } else {
466 let refs = owner_trace.direct_references.len();
467 match filter_flag {
468 Some(flag) => format!(
469 "'{owner_name}' is used by {refs} file(s); whether '{member_name}' itself is \
470 flagged depends on cross-file member-access resolution. Run \
471 `fallow dead-code {flag} --file {file_path}` to see the member finding."
472 ),
473 None => format!(
474 "'{owner_name}' is used by {refs} file(s); '{member_name}' is credited through \
475 its namespace export."
476 ),
477 }
478 };
479 format!("{head}{body}")
480}
481
482fn select_export<'graph>(
483 graph: &'graph ModuleGraph,
484 module: &'graph crate::graph::ModuleNode,
485 export_name: &str,
486) -> Option<crate::graph::EffectiveExportSurface<'graph>> {
487 [ExportNamespace::Value, ExportNamespace::Type]
488 .into_iter()
489 .find_map(|namespace| {
490 graph.effective_export_surface(module.file_id, export_name, namespace)
491 })
492}
493
494fn traced_exports(
496 graph: &ModuleGraph,
497 root: &Path,
498 module: &crate::graph::ModuleNode,
499) -> Vec<TracedExport> {
500 module
501 .exports
502 .iter()
503 .map(|e| {
504 let referenced_by: Vec<_> = e
505 .physical_references()
506 .map(|r| reference_to_export_reference(graph, root, r))
507 .collect();
508 TracedExport {
509 name: e.name.to_string(),
510 is_type_only: e.is_type_only,
511 reference_count: referenced_by.len(),
512 referenced_by,
513 }
514 })
515 .collect()
516}
517
518fn traced_imports_from(
520 graph: &ModuleGraph,
521 root: &Path,
522 module: &crate::graph::ModuleNode,
523) -> Vec<PathBuf> {
524 graph
525 .edges_for(module.file_id)
526 .iter()
527 .filter_map(|target_id| {
528 graph
529 .modules
530 .get(target_id.0 as usize)
531 .map(|m| m.path.strip_prefix(root).unwrap_or(&m.path).to_path_buf())
532 })
533 .collect()
534}
535
536fn traced_imported_by(
538 graph: &ModuleGraph,
539 root: &Path,
540 module: &crate::graph::ModuleNode,
541) -> Vec<PathBuf> {
542 graph
543 .reverse_deps
544 .get(module.file_id.0 as usize)
545 .map(|deps| {
546 deps.iter()
547 .filter_map(|fid| {
548 graph
549 .modules
550 .get(fid.0 as usize)
551 .map(|m| m.path.strip_prefix(root).unwrap_or(&m.path).to_path_buf())
552 })
553 .collect()
554 })
555 .unwrap_or_default()
556}
557
558fn traced_re_exports(
560 graph: &ModuleGraph,
561 root: &Path,
562 module: &crate::graph::ModuleNode,
563) -> Vec<TracedReExport> {
564 module
565 .re_exports
566 .iter()
567 .map(|re| {
568 let source_path = graph.modules.get(re.source_file.0 as usize).map_or_else(
569 || PathBuf::from(format!("<unknown:{}>", re.source_file.0)),
570 |m| m.path.strip_prefix(root).unwrap_or(&m.path).to_path_buf(),
571 );
572 TracedReExport {
573 source_file: source_path,
574 imported_name: re.imported_name.clone(),
575 exported_name: re.exported_name.clone(),
576 }
577 })
578 .collect()
579}
580
581#[must_use]
583pub fn trace_file(graph: &ModuleGraph, root: &Path, file_path: &str) -> Option<FileTrace> {
584 let module = graph
585 .modules
586 .iter()
587 .find(|m| path_matches(&m.path, root, file_path))?;
588
589 Some(FileTrace {
590 file: module
591 .path
592 .strip_prefix(root)
593 .unwrap_or(&module.path)
594 .to_path_buf(),
595 is_reachable: module.is_reachable(),
596 is_entry_point: module.is_entry_point(),
597 exports: traced_exports(graph, root, module),
598 imports_from: traced_imports_from(graph, root, module),
599 imported_by: traced_imported_by(graph, root, module),
600 re_exports: traced_re_exports(graph, root, module),
601 })
602}
603
604#[must_use]
613pub fn trace_dependency(
614 graph: &ModuleGraph,
615 root: &Path,
616 package_name: &str,
617 script_used_packages: &FxHashSet<String>,
618) -> DependencyTrace {
619 let imported_by: Vec<PathBuf> = graph
620 .package_usage
621 .get(package_name)
622 .map(|ids| {
623 ids.iter()
624 .filter_map(|fid| {
625 graph
626 .modules
627 .get(fid.0 as usize)
628 .map(|m| m.path.strip_prefix(root).unwrap_or(&m.path).to_path_buf())
629 })
630 .collect()
631 })
632 .unwrap_or_default();
633
634 let type_only_imported_by: Vec<PathBuf> = graph
635 .type_only_package_usage
636 .get(package_name)
637 .map(|ids| {
638 ids.iter()
639 .filter_map(|fid| {
640 graph
641 .modules
642 .get(fid.0 as usize)
643 .map(|m| m.path.strip_prefix(root).unwrap_or(&m.path).to_path_buf())
644 })
645 .collect()
646 })
647 .unwrap_or_default();
648
649 let import_count = imported_by.len();
650 let used_in_scripts = script_used_packages.contains(package_name);
651 DependencyTrace {
652 package_name: package_name.to_string(),
653 imported_by,
654 type_only_imported_by,
655 used_in_scripts,
656 is_used: import_count > 0 || used_in_scripts,
657 import_count,
658 }
659}
660
661fn format_reference_kind(kind: ReferenceKind) -> String {
662 match kind {
663 ReferenceKind::NamedImport => "named import".to_string(),
664 ReferenceKind::DefaultImport => "default import".to_string(),
665 ReferenceKind::NamespaceImport => "namespace import".to_string(),
666 ReferenceKind::ReExport => "re-export".to_string(),
667 ReferenceKind::DynamicImport => "dynamic import".to_string(),
668 ReferenceKind::SideEffectImport => "side-effect import".to_string(),
669 }
670}
671
672#[must_use]
679pub fn trace_impact_closure(
680 graph: &ModuleGraph,
681 root: &Path,
682 file_path: &str,
683) -> Option<ImpactClosureTrace> {
684 let module = graph
685 .modules
686 .iter()
687 .find(|m| path_matches(&m.path, root, file_path))?;
688
689 let closure = graph.impact_closure(&[module.file_id]);
690 let paths = graph.closure_with_paths(&closure, root);
691
692 let seed = paths
693 .in_diff
694 .first()
695 .cloned()
696 .unwrap_or_else(|| file_path.replace('\\', "/"));
697
698 let coordination_gap = paths
699 .coordination_gap
700 .into_iter()
701 .map(|gap| ImpactClosureGap {
702 consumer_file: gap.consumer_file,
703 consumed_symbols: gap.consumed_symbols,
704 note: "syntactic attention pointer, not a correctness proof".to_string(),
705 })
706 .collect();
707
708 Some(ImpactClosureTrace {
709 seed,
710 affected_not_shown: paths.affected_not_shown,
711 coordination_gap,
712 })
713}
714
715fn build_traced_group(
719 group: &CloneGroup,
720 root: &Path,
721 fingerprints: &CloneFingerprintSet,
722) -> TracedCloneGroup {
723 TracedCloneGroup {
724 fingerprint: fingerprints.fingerprint_for_group(group),
725 token_count: group.token_count,
726 line_count: group.line_count,
727 spread: group.spread(),
728 similarity: group.similarity,
729 instances: group
730 .instances
731 .iter()
732 .map(|inst| relativize_instance(inst, root))
733 .collect(),
734 suggestion: group_refactoring_suggestion(group),
735 suggested_name: dominant_identifier(group),
736 }
737}
738
739#[must_use]
740pub fn trace_clone(
741 report: &DuplicationReport,
742 root: &Path,
743 file_path: &str,
744 line: usize,
745) -> CloneTrace {
746 let resolved = root.join(file_path);
747 let mut matched_instance = None;
748 let mut clone_groups = Vec::new();
749 let fingerprints = CloneFingerprintSet::from_groups(&report.clone_groups);
750
751 for group in &report.clone_groups {
752 let matching = group.instances.iter().find(|inst| {
753 let inst_matches = inst.file == resolved
754 || inst.file.strip_prefix(root).unwrap_or(&inst.file) == Path::new(file_path);
755 inst_matches && inst.start_line <= line && line <= inst.end_line
756 });
757
758 if let Some(matched) = matching {
759 if matched_instance.is_none() {
760 matched_instance = Some(relativize_instance(matched, root));
761 }
762 clone_groups.push(build_traced_group(group, root, &fingerprints));
763 }
764 }
765
766 CloneTrace {
767 file: PathBuf::from(file_path),
768 line,
769 matched_instance,
770 clone_groups,
771 }
772}
773
774#[must_use]
784pub fn trace_clone_by_fingerprint(
785 report: &DuplicationReport,
786 root: &Path,
787 fingerprint: &str,
788) -> CloneTrace {
789 let fingerprints = CloneFingerprintSet::from_groups(&report.clone_groups);
790 let matched = fingerprints.find_group(&report.clone_groups, fingerprint);
791
792 let Some(group) = matched else {
793 return CloneTrace {
794 file: PathBuf::new(),
795 line: 0,
796 matched_instance: None,
797 clone_groups: Vec::new(),
798 };
799 };
800
801 let representative = group
802 .instances
803 .first()
804 .map(|inst| relativize_instance(inst, root));
805 let (file, line) = representative.as_ref().map_or_else(
806 || (PathBuf::new(), 0),
807 |inst| (inst.file.clone(), inst.start_line),
808 );
809
810 CloneTrace {
811 file,
812 line,
813 matched_instance: representative,
814 clone_groups: vec![build_traced_group(group, root, &fingerprints)],
815 }
816}
817
818fn relativize_instance(inst: &CloneInstance, root: &Path) -> CloneInstance {
822 let rel = inst.file.strip_prefix(root).map_or_else(
823 |_| inst.file.clone(),
824 |p| PathBuf::from(p.to_string_lossy().replace('\\', "/")),
825 );
826 CloneInstance {
827 file: rel,
828 ..inst.clone()
829 }
830}
831
832#[cfg(test)]
833mod tests {
834 use super::*;
835
836 use crate::discover::{DiscoveredFile, EntryPoint, EntryPointSource, FileId};
837 use crate::extract::{ExportInfo, ExportName, ImportInfo, ImportedName, VisibilityTag};
838 use crate::resolve::{ResolveResult, ResolvedImport, ResolvedModule, ResolvedReExport};
839 use fallow_types::extract::ReExportInfo;
840
841 fn resolved_re_export(
842 source: FileId,
843 imported_name: &str,
844 exported_name: &str,
845 ) -> ResolvedReExport {
846 ResolvedReExport {
847 info: ReExportInfo {
848 source: "./source".to_string(),
849 imported_name: imported_name.to_string(),
850 exported_name: exported_name.to_string(),
851 is_type_only: false,
852 span: oxc_span::Span::default(),
853 statement_span: oxc_span::Span::default(),
854 source_span: oxc_span::Span::default(),
855 },
856 target: ResolveResult::InternalModule(source),
857 }
858 }
859
860 fn build_test_graph() -> ModuleGraph {
861 let files = vec![
862 DiscoveredFile {
863 id: FileId(0),
864 path: PathBuf::from("/project/src/entry.ts"),
865 size_bytes: 100,
866 },
867 DiscoveredFile {
868 id: FileId(1),
869 path: PathBuf::from("/project/src/utils.ts"),
870 size_bytes: 50,
871 },
872 DiscoveredFile {
873 id: FileId(2),
874 path: PathBuf::from("/project/src/unused.ts"),
875 size_bytes: 30,
876 },
877 ];
878
879 let entry_points = vec![EntryPoint {
880 path: PathBuf::from("/project/src/entry.ts"),
881 source: EntryPointSource::PackageJsonMain,
882 }];
883
884 let resolved_modules = vec![
885 ResolvedModule {
886 file_id: FileId(0),
887 path: PathBuf::from("/project/src/entry.ts"),
888 resolved_imports: vec![ResolvedImport {
889 info: ImportInfo {
890 source: "./utils".to_string(),
891 imported_name: ImportedName::Named("foo".to_string()),
892 local_name: "foo".to_string(),
893 is_type_only: false,
894 from_style: false,
895 span: oxc_span::Span::new(0, 10),
896 source_span: oxc_span::Span::default(),
897 },
898 target: ResolveResult::InternalModule(FileId(1)),
899 }],
900 ..Default::default()
901 },
902 ResolvedModule {
903 file_id: FileId(1),
904 path: PathBuf::from("/project/src/utils.ts"),
905 exports: vec![
906 ExportInfo {
907 name: ExportName::Named("foo".to_string()),
908 local_name: Some("foo".to_string()),
909 is_type_only: false,
910 visibility: VisibilityTag::None,
911 expected_unused_reason: None,
912 span: oxc_span::Span::new(0, 20),
913 members: vec![],
914 is_side_effect_used: false,
915 super_class: None,
916 },
917 ExportInfo {
918 name: ExportName::Named("bar".to_string()),
919 local_name: Some("bar".to_string()),
920 is_type_only: false,
921 visibility: VisibilityTag::None,
922 expected_unused_reason: None,
923 span: oxc_span::Span::new(21, 40),
924 members: vec![],
925 is_side_effect_used: false,
926 super_class: None,
927 },
928 ]
929 .into(),
930 ..Default::default()
931 },
932 ResolvedModule {
933 file_id: FileId(2),
934 path: PathBuf::from("/project/src/unused.ts"),
935 exports: vec![ExportInfo {
936 name: ExportName::Named("baz".to_string()),
937 local_name: Some("baz".to_string()),
938 is_type_only: false,
939 visibility: VisibilityTag::None,
940 expected_unused_reason: None,
941 span: oxc_span::Span::new(0, 15),
942 members: vec![],
943 is_side_effect_used: false,
944 super_class: None,
945 }]
946 .into(),
947 ..Default::default()
948 },
949 ];
950
951 ModuleGraph::build(&resolved_modules, &entry_points, &files)
952 }
953
954 #[test]
955 fn trace_used_export() {
956 let graph = build_test_graph();
957 let root = Path::new("/project");
958
959 let trace = trace_export(&graph, root, "src/utils.ts", "foo").unwrap();
960 assert!(trace.is_used);
961 assert!(trace.file_reachable);
962 assert_eq!(trace.direct_references.len(), 1);
963 assert_eq!(
964 trace.direct_references[0].from_file,
965 PathBuf::from("src/entry.ts")
966 );
967 assert_eq!(trace.direct_references[0].kind, "named import");
968 }
969
970 #[test]
971 fn trace_unused_export() {
972 let graph = build_test_graph();
973 let root = Path::new("/project");
974
975 let trace = trace_export(&graph, root, "src/utils.ts", "bar").unwrap();
976 assert!(!trace.is_used);
977 assert!(trace.file_reachable);
978 assert!(trace.direct_references.is_empty());
979 }
980
981 #[test]
982 fn trace_unreachable_file_export() {
983 let graph = build_test_graph();
984 let root = Path::new("/project");
985
986 let trace = trace_export(&graph, root, "src/unused.ts", "baz").unwrap();
987 assert!(!trace.is_used);
988 assert!(!trace.file_reachable);
989 assert!(trace.reason.contains("unreachable"));
990 }
991
992 #[test]
993 fn trace_nonexistent_export() {
994 let graph = build_test_graph();
995 let root = Path::new("/project");
996
997 let trace = trace_export(&graph, root, "src/utils.ts", "nonexistent");
998 assert!(trace.is_none());
999 }
1000
1001 #[test]
1002 fn trace_reports_only_the_effective_re_export_origin() {
1003 let files: Vec<_> = ["entry", "barrel", "star-source", "explicit-source"]
1004 .into_iter()
1005 .enumerate()
1006 .map(|(index, name)| DiscoveredFile {
1007 id: FileId(index as u32),
1008 path: PathBuf::from(format!("/project/src/{name}.ts")),
1009 size_bytes: 10,
1010 })
1011 .collect();
1012 let entry_points = vec![EntryPoint {
1013 path: files[0].path.clone(),
1014 source: EntryPointSource::PackageJsonMain,
1015 }];
1016 let re_export = |source: FileId, imported: &str, exported: &str| ResolvedReExport {
1017 info: ReExportInfo {
1018 source: format!("./{}", source.0),
1019 imported_name: imported.to_string(),
1020 exported_name: exported.to_string(),
1021 is_type_only: false,
1022 span: oxc_span::Span::default(),
1023 statement_span: oxc_span::Span::default(),
1024 source_span: oxc_span::Span::default(),
1025 },
1026 target: ResolveResult::InternalModule(source),
1027 };
1028 let export = || ExportInfo {
1029 name: ExportName::Named("foo".to_string()),
1030 local_name: Some("foo".to_string()),
1031 is_type_only: false,
1032 visibility: VisibilityTag::None,
1033 expected_unused_reason: None,
1034 span: oxc_span::Span::new(0, 3),
1035 members: Vec::new(),
1036 is_side_effect_used: false,
1037 super_class: None,
1038 };
1039 let resolved = vec![
1040 ResolvedModule {
1041 file_id: FileId(0),
1042 path: files[0].path.clone(),
1043 resolved_imports: vec![ResolvedImport {
1044 info: ImportInfo {
1045 source: "./barrel".to_string(),
1046 imported_name: ImportedName::Named("foo".to_string()),
1047 local_name: "foo".to_string(),
1048 is_type_only: false,
1049 from_style: false,
1050 span: oxc_span::Span::default(),
1051 source_span: oxc_span::Span::default(),
1052 },
1053 target: ResolveResult::InternalModule(FileId(1)),
1054 }],
1055 ..Default::default()
1056 },
1057 ResolvedModule {
1058 file_id: FileId(1),
1059 path: files[1].path.clone(),
1060 re_exports: vec![
1061 re_export(FileId(2), "*", "*"),
1062 re_export(FileId(3), "foo", "foo"),
1063 ],
1064 ..Default::default()
1065 },
1066 ResolvedModule {
1067 file_id: FileId(2),
1068 path: files[2].path.clone(),
1069 exports: vec![export()].into(),
1070 ..Default::default()
1071 },
1072 ResolvedModule {
1073 file_id: FileId(3),
1074 path: files[3].path.clone(),
1075 exports: vec![export()].into(),
1076 ..Default::default()
1077 },
1078 ];
1079 let graph = ModuleGraph::build(&resolved, &entry_points, &files);
1080
1081 let shadowed = trace_export(&graph, Path::new("/project"), "src/star-source.ts", "foo")
1082 .expect("shadowed source export exists");
1083 let effective = trace_export(
1084 &graph,
1085 Path::new("/project"),
1086 "src/explicit-source.ts",
1087 "foo",
1088 )
1089 .expect("effective source export exists");
1090
1091 assert!(shadowed.re_export_chains.is_empty());
1092 assert_eq!(effective.re_export_chains.len(), 1);
1093 assert_eq!(effective.re_export_chains[0].exported_as, "foo");
1094 }
1095
1096 fn star_surface_trace_graph(root: &Path) -> ModuleGraph {
1097 let src = root.join("src");
1098 std::fs::create_dir_all(&src).expect("create source directory");
1099 let paths: Vec<_> = ["source", "barrel-a", "barrel-b", "outer", "entry"]
1100 .into_iter()
1101 .map(|name| src.join(format!("{name}.ts")))
1102 .collect();
1103 std::fs::write(&paths[0], "\n\nexport const foo = 1;\n").expect("write source");
1104 for path in &paths[1..] {
1105 std::fs::write(path, "export {};\n").expect("write module");
1106 }
1107 let files: Vec<_> = paths
1108 .iter()
1109 .enumerate()
1110 .map(|(index, path)| DiscoveredFile {
1111 id: FileId(index as u32),
1112 path: path.clone(),
1113 size_bytes: 20,
1114 })
1115 .collect();
1116 let resolved = vec![
1117 ResolvedModule {
1118 file_id: FileId(0),
1119 path: paths[0].clone(),
1120 exports: vec![ExportInfo {
1121 name: ExportName::Named("foo".to_string()),
1122 local_name: Some("foo".to_string()),
1123 is_type_only: false,
1124 visibility: VisibilityTag::None,
1125 expected_unused_reason: None,
1126 span: oxc_span::Span::new(2, 5),
1127 members: Vec::new(),
1128 is_side_effect_used: false,
1129 super_class: None,
1130 }]
1131 .into(),
1132 ..Default::default()
1133 },
1134 ResolvedModule {
1135 file_id: FileId(1),
1136 path: paths[1].clone(),
1137 re_exports: vec![resolved_re_export(FileId(0), "*", "*")],
1138 ..Default::default()
1139 },
1140 ResolvedModule {
1141 file_id: FileId(2),
1142 path: paths[2].clone(),
1143 re_exports: vec![resolved_re_export(FileId(0), "*", "*")],
1144 ..Default::default()
1145 },
1146 ResolvedModule {
1147 file_id: FileId(3),
1148 path: paths[3].clone(),
1149 re_exports: vec![
1150 resolved_re_export(FileId(1), "foo", "left"),
1151 resolved_re_export(FileId(1), "foo", "right"),
1152 ],
1153 ..Default::default()
1154 },
1155 ResolvedModule {
1156 file_id: FileId(4),
1157 path: paths[4].clone(),
1158 resolved_imports: vec![
1159 ResolvedImport {
1160 info: ImportInfo {
1161 source: "./outer".to_string(),
1162 imported_name: ImportedName::Named("left".to_string()),
1163 local_name: "left".to_string(),
1164 is_type_only: false,
1165 from_style: false,
1166 span: oxc_span::Span::new(10, 20),
1167 source_span: oxc_span::Span::default(),
1168 },
1169 target: ResolveResult::InternalModule(FileId(3)),
1170 },
1171 ResolvedImport {
1172 info: ImportInfo {
1173 source: "./barrel-b".to_string(),
1174 imported_name: ImportedName::Named("foo".to_string()),
1175 local_name: "otherFoo".to_string(),
1176 is_type_only: false,
1177 from_style: false,
1178 span: oxc_span::Span::new(30, 40),
1179 source_span: oxc_span::Span::default(),
1180 },
1181 target: ResolveResult::InternalModule(FileId(2)),
1182 },
1183 ],
1184 ..Default::default()
1185 },
1186 ];
1187 let entry_points = vec![EntryPoint {
1188 path: paths[4].clone(),
1189 source: EntryPointSource::PackageJsonMain,
1190 }];
1191 ModuleGraph::build(&resolved, &entry_points, &files)
1192 }
1193
1194 #[test]
1195 fn star_surface_trace_keeps_aliases_separate_and_uses_origin_identity() {
1196 let root = tempfile::tempdir().expect("temporary project");
1197 let graph = star_surface_trace_graph(root.path());
1198
1199 let used = trace_export(&graph, root.path(), "src/barrel-a.ts", "foo")
1200 .expect("aliased barrel exposes foo");
1201 let sibling = trace_export(&graph, root.path(), "src/barrel-b.ts", "foo")
1202 .expect("sibling barrel exposes foo");
1203 assert!(used.is_used);
1204 assert_eq!(used.direct_references.len(), 1);
1205 assert!(sibling.is_used);
1206 assert_eq!(sibling.direct_references.len(), 1);
1207
1208 let source_trace = trace_export(&graph, root.path(), "src/source.ts", "foo")
1209 .expect("source declaration is traceable");
1210 let chain_count = |file: &str, name: &str| {
1211 source_trace
1212 .re_export_chains
1213 .iter()
1214 .find(|chain| chain.barrel_file == Path::new(file) && chain.exported_as == name)
1215 .map(|chain| chain.reference_count)
1216 };
1217 assert_eq!(chain_count("src/barrel-a.ts", "foo"), Some(1));
1218 assert_eq!(chain_count("src/barrel-b.ts", "foo"), Some(1));
1219 assert_eq!(chain_count("src/outer.ts", "left"), Some(1));
1220 assert_eq!(chain_count("src/outer.ts", "right"), Some(0));
1221
1222 let semantic = semantic_symbol_for_export(&graph, root.path(), "src/barrel-a.ts", "foo")
1223 .expect("star surface resolves to its declaration identity");
1224 assert_eq!(semantic.path, Path::new("src/source.ts"));
1225 assert_eq!(semantic.exported_name, "foo");
1226 assert_eq!(semantic.local_name, "foo");
1227 assert_eq!((semantic.line, semantic.col), (3, 0));
1228
1229 let alias = semantic_symbol_for_export(&graph, root.path(), "src/outer.ts", "left")
1230 .expect("named re-export keeps its export-specifier identity");
1231 assert_eq!(alias.path, Path::new("src/outer.ts"));
1232 assert_eq!(alias.exported_name, "left");
1233 assert_eq!(alias.local_name, "foo");
1234 }
1235
1236 #[test]
1237 fn trace_follows_renamed_and_convergent_re_export_routes() {
1238 let names = [
1239 "source",
1240 "renamed",
1241 "final",
1242 "left",
1243 "right",
1244 "diamond-entry",
1245 ];
1246 let files: Vec<_> = names
1247 .into_iter()
1248 .enumerate()
1249 .map(|(index, name)| DiscoveredFile {
1250 id: FileId(index as u32),
1251 path: PathBuf::from(format!("/project/src/{name}.ts")),
1252 size_bytes: 10,
1253 })
1254 .collect();
1255 let re_export = |source: FileId, imported: &str, exported: &str| ResolvedReExport {
1256 info: ReExportInfo {
1257 source: format!("./{}", source.0),
1258 imported_name: imported.to_string(),
1259 exported_name: exported.to_string(),
1260 is_type_only: false,
1261 span: oxc_span::Span::default(),
1262 statement_span: oxc_span::Span::default(),
1263 source_span: oxc_span::Span::default(),
1264 },
1265 target: ResolveResult::InternalModule(source),
1266 };
1267 let mut resolved: Vec<_> = files
1268 .iter()
1269 .map(|file| ResolvedModule {
1270 file_id: file.id,
1271 path: file.path.clone(),
1272 ..Default::default()
1273 })
1274 .collect();
1275 resolved[0].exports = vec![ExportInfo {
1276 name: ExportName::Named("foo".to_string()),
1277 local_name: Some("foo".to_string()),
1278 is_type_only: false,
1279 visibility: VisibilityTag::None,
1280 expected_unused_reason: None,
1281 span: oxc_span::Span::new(0, 3),
1282 members: Vec::new(),
1283 is_side_effect_used: false,
1284 super_class: None,
1285 }]
1286 .into();
1287 resolved[1].re_exports = vec![re_export(FileId(0), "foo", "bar")];
1288 resolved[2].re_exports = vec![re_export(FileId(1), "bar", "baz")];
1289 resolved[3].re_exports = vec![re_export(FileId(0), "*", "*")];
1290 resolved[4].re_exports = vec![re_export(FileId(0), "*", "*")];
1291 resolved[5].re_exports = vec![
1292 re_export(FileId(3), "*", "*"),
1293 re_export(FileId(4), "*", "*"),
1294 ];
1295 let entry_points = vec![EntryPoint {
1296 path: files[5].path.clone(),
1297 source: EntryPointSource::PackageJsonMain,
1298 }];
1299 let graph = ModuleGraph::build(&resolved, &entry_points, &files);
1300
1301 let trace = trace_export(&graph, Path::new("/project"), "src/source.ts", "foo")
1302 .expect("source export exists");
1303 let routes: FxHashSet<_> = trace
1304 .re_export_chains
1305 .iter()
1306 .map(|route| (route.barrel_file.as_path(), route.exported_as.as_str()))
1307 .collect();
1308
1309 assert_eq!(routes.len(), 5);
1310 assert!(routes.contains(&(Path::new("src/renamed.ts"), "bar")));
1311 assert!(routes.contains(&(Path::new("src/final.ts"), "baz")));
1312 assert!(routes.contains(&(Path::new("src/left.ts"), "foo")));
1313 assert!(routes.contains(&(Path::new("src/right.ts"), "foo")));
1314 assert!(routes.contains(&(Path::new("src/diamond-entry.ts"), "foo")));
1315 }
1316
1317 #[test]
1318 fn trace_prefers_the_value_namespace_independent_of_usage() {
1319 let files = vec![
1320 DiscoveredFile {
1321 id: FileId(0),
1322 path: PathBuf::from("/project/src/entry.ts"),
1323 size_bytes: 10,
1324 },
1325 DiscoveredFile {
1326 id: FileId(1),
1327 path: PathBuf::from("/project/src/source.ts"),
1328 size_bytes: 10,
1329 },
1330 ];
1331 let export = |is_type_only| ExportInfo {
1332 name: ExportName::Named("Foo".to_string()),
1333 local_name: Some("Foo".to_string()),
1334 is_type_only,
1335 visibility: VisibilityTag::None,
1336 expected_unused_reason: None,
1337 span: oxc_span::Span::new(0, 3),
1338 members: Vec::new(),
1339 is_side_effect_used: false,
1340 super_class: None,
1341 };
1342 let resolved = vec![
1343 ResolvedModule {
1344 file_id: FileId(0),
1345 path: files[0].path.clone(),
1346 resolved_imports: vec![ResolvedImport {
1347 info: ImportInfo {
1348 source: "./source".to_string(),
1349 imported_name: ImportedName::Named("Foo".to_string()),
1350 local_name: "Foo".to_string(),
1351 is_type_only: true,
1352 from_style: false,
1353 span: oxc_span::Span::default(),
1354 source_span: oxc_span::Span::default(),
1355 },
1356 target: ResolveResult::InternalModule(FileId(1)),
1357 }],
1358 ..Default::default()
1359 },
1360 ResolvedModule {
1361 file_id: FileId(1),
1362 path: files[1].path.clone(),
1363 exports: vec![export(false), export(true)].into(),
1364 ..Default::default()
1365 },
1366 ];
1367 let entry_points = vec![EntryPoint {
1368 path: files[0].path.clone(),
1369 source: EntryPointSource::PackageJsonMain,
1370 }];
1371 let graph = ModuleGraph::build(&resolved, &entry_points, &files);
1372
1373 let trace = trace_export(&graph, Path::new("/project"), "src/source.ts", "Foo")
1374 .expect("value export exists");
1375
1376 assert_eq!(
1377 trace.namespace,
1378 fallow_types::semantic::SemanticNamespace::Value
1379 );
1380 assert!(!trace.is_used, "type usage must not select the type export");
1381 }
1382
1383 #[test]
1384 fn trace_preserves_dual_namespace_named_re_exports() {
1385 let files: Vec<_> = ["entry", "barrel", "types", "values"]
1386 .into_iter()
1387 .enumerate()
1388 .map(|(index, name)| DiscoveredFile {
1389 id: FileId(index as u32),
1390 path: PathBuf::from(format!("/project/src/{name}.ts")),
1391 size_bytes: 10,
1392 })
1393 .collect();
1394 let export = |is_type_only| ExportInfo {
1395 name: ExportName::Named("Foo".to_string()),
1396 local_name: Some("Foo".to_string()),
1397 is_type_only,
1398 visibility: VisibilityTag::None,
1399 expected_unused_reason: None,
1400 span: oxc_span::Span::new(0, 3),
1401 members: Vec::new(),
1402 is_side_effect_used: false,
1403 super_class: None,
1404 };
1405 let re_export = |source: FileId, is_type_only| ResolvedReExport {
1406 info: ReExportInfo {
1407 source: format!("./{}", source.0),
1408 imported_name: "Foo".to_string(),
1409 exported_name: "Foo".to_string(),
1410 is_type_only,
1411 span: oxc_span::Span::default(),
1412 statement_span: oxc_span::Span::default(),
1413 source_span: oxc_span::Span::default(),
1414 },
1415 target: ResolveResult::InternalModule(source),
1416 };
1417 let mut resolved = vec![
1418 ResolvedModule {
1419 file_id: FileId(0),
1420 path: files[0].path.clone(),
1421 resolved_imports: vec![ResolvedImport {
1422 info: ImportInfo {
1423 source: "./barrel".to_string(),
1424 imported_name: ImportedName::Named("Foo".to_string()),
1425 local_name: "Foo".to_string(),
1426 is_type_only: false,
1427 from_style: false,
1428 span: oxc_span::Span::new(0, 10),
1429 source_span: oxc_span::Span::default(),
1430 },
1431 target: ResolveResult::InternalModule(FileId(1)),
1432 }],
1433 ..Default::default()
1434 },
1435 ResolvedModule {
1436 file_id: FileId(1),
1437 path: files[1].path.clone(),
1438 re_exports: vec![re_export(FileId(2), true), re_export(FileId(3), false)],
1439 ..Default::default()
1440 },
1441 ResolvedModule {
1442 file_id: FileId(2),
1443 path: files[2].path.clone(),
1444 exports: vec![export(true)].into(),
1445 ..Default::default()
1446 },
1447 ResolvedModule {
1448 file_id: FileId(3),
1449 path: files[3].path.clone(),
1450 exports: vec![export(false)].into(),
1451 ..Default::default()
1452 },
1453 ];
1454 let entry_points = vec![EntryPoint {
1455 path: files[0].path.clone(),
1456 source: EntryPointSource::PackageJsonMain,
1457 }];
1458 let graph = ModuleGraph::build(&resolved, &entry_points, &files);
1459 resolved[1].re_exports.reverse();
1460 let reversed_graph = ModuleGraph::build(&resolved, &entry_points, &files);
1461
1462 let trace = trace_export(&graph, Path::new("/project"), "src/barrel.ts", "Foo")
1463 .expect("barrel exposes Foo in both namespaces");
1464
1465 assert_eq!(
1466 trace.namespace,
1467 fallow_types::semantic::SemanticNamespace::Value
1468 );
1469 assert!(
1470 trace.is_used,
1471 "the value import must credit the value surface"
1472 );
1473 assert_eq!(trace.direct_references.len(), 1);
1474 let reversed_trace = trace_export(
1475 &reversed_graph,
1476 Path::new("/project"),
1477 "src/barrel.ts",
1478 "Foo",
1479 )
1480 .expect("reversed declarations expose the same surface");
1481 assert_eq!(
1482 serde_json::to_value(trace).expect("serialize trace"),
1483 serde_json::to_value(reversed_trace).expect("serialize reversed trace")
1484 );
1485 }
1486
1487 fn build_class_member_graph() -> ModuleGraph {
1488 use fallow_types::extract::{MemberInfo, MemberKind};
1489
1490 let files = vec![
1491 DiscoveredFile {
1492 id: FileId(0),
1493 path: PathBuf::from("/project/src/entry.ts"),
1494 size_bytes: 100,
1495 },
1496 DiscoveredFile {
1497 id: FileId(1),
1498 path: PathBuf::from("/project/src/controller.ts"),
1499 size_bytes: 50,
1500 },
1501 ];
1502 let entry_points = vec![EntryPoint {
1503 path: PathBuf::from("/project/src/entry.ts"),
1504 source: EntryPointSource::PackageJsonMain,
1505 }];
1506 let method = |name: &str| MemberInfo {
1507 name: name.to_string(),
1508 kind: MemberKind::ClassMethod,
1509 span: oxc_span::Span::new(0, 4),
1510 has_decorator: false,
1511 decorator_names: vec![],
1512 is_instance_returning_static: false,
1513 is_self_returning: false,
1514 };
1515 let resolved_modules = vec![
1516 ResolvedModule {
1517 file_id: FileId(0),
1518 path: PathBuf::from("/project/src/entry.ts"),
1519 resolved_imports: vec![ResolvedImport {
1520 info: ImportInfo {
1521 source: "./controller".to_string(),
1522 imported_name: ImportedName::Named("Ctrl".to_string()),
1523 local_name: "Ctrl".to_string(),
1524 is_type_only: false,
1525 from_style: false,
1526 span: oxc_span::Span::new(0, 10),
1527 source_span: oxc_span::Span::default(),
1528 },
1529 target: ResolveResult::InternalModule(FileId(1)),
1530 }],
1531 ..Default::default()
1532 },
1533 ResolvedModule {
1534 file_id: FileId(1),
1535 path: PathBuf::from("/project/src/controller.ts"),
1536 exports: vec![ExportInfo {
1537 name: ExportName::Named("Ctrl".to_string()),
1538 local_name: Some("Ctrl".to_string()),
1539 is_type_only: false,
1540 visibility: VisibilityTag::None,
1541 expected_unused_reason: None,
1542 span: oxc_span::Span::new(0, 20),
1543 members: vec![method("used"), method("dead")],
1544 is_side_effect_used: false,
1545 super_class: None,
1546 }]
1547 .into(),
1548 ..Default::default()
1549 },
1550 ];
1551 ModuleGraph::build(&resolved_modules, &entry_points, &files)
1552 }
1553
1554 #[test]
1555 fn trace_class_member_reports_owner_class() {
1556 let graph = build_class_member_graph();
1559 let root = Path::new("/project");
1560
1561 let trace = trace_class_member(&graph, root, "src/controller.ts", "dead").unwrap();
1562 assert_eq!(trace.owner_export, "Ctrl");
1563 assert_eq!(trace.member_name, "dead");
1564 assert_eq!(trace.member_kind, "class-method");
1565 assert!(trace.owner_is_used);
1566 assert!(trace.owner_file_reachable);
1567 assert_eq!(trace.owner_direct_references.len(), 1);
1568 assert!(
1569 trace.reason.contains("--unused-class-members"),
1570 "reason should point at the member command: {}",
1571 trace.reason
1572 );
1573 }
1574
1575 #[test]
1576 fn trace_class_member_absent_name_is_none() {
1577 let graph = build_class_member_graph();
1580 let root = Path::new("/project");
1581 assert!(trace_class_member(&graph, root, "src/controller.ts", "nope").is_none());
1582 }
1583
1584 #[test]
1585 fn exact_class_method_resolution_rejects_overloads_without_guessing() {
1586 use fallow_types::extract::{MemberInfo, MemberKind};
1587
1588 let temp = tempfile::tempdir().unwrap();
1589 let root = temp.path();
1590 let path = root.join("repository.ts");
1591 let source =
1592 "export class Repository {\n save(): void;\n save(): void {}\n run(): void {}\n}\n";
1593 std::fs::write(&path, source).unwrap();
1594 let first = source.find("save").unwrap() as u32;
1595 let second = source.rfind("save").unwrap() as u32;
1596 let run = source.find("run").unwrap() as u32;
1597 let member = |name: &str, start| MemberInfo {
1598 name: name.to_string(),
1599 kind: MemberKind::ClassMethod,
1600 span: oxc_span::Span::new(start, start + 4),
1601 has_decorator: false,
1602 decorator_names: vec![],
1603 is_instance_returning_static: false,
1604 is_self_returning: false,
1605 };
1606 let files = vec![DiscoveredFile {
1607 id: FileId(0),
1608 path: path.clone(),
1609 size_bytes: source.len() as u64,
1610 }];
1611 let resolved_modules = vec![ResolvedModule {
1612 file_id: FileId(0),
1613 path,
1614 exports: vec![ExportInfo {
1615 name: ExportName::Named("Repository".to_string()),
1616 local_name: Some("Repository".to_string()),
1617 is_type_only: false,
1618 visibility: VisibilityTag::None,
1619 expected_unused_reason: None,
1620 span: oxc_span::Span::new(0, source.len() as u32),
1621 members: vec![
1622 member("save", first),
1623 member("save", second),
1624 member("run", run),
1625 ],
1626 is_side_effect_used: false,
1627 super_class: None,
1628 }]
1629 .into(),
1630 ..Default::default()
1631 }];
1632 let graph = ModuleGraph::build(&resolved_modules, &[], &files);
1633
1634 assert_eq!(
1635 semantic_symbol_for_exact_class_method(
1636 &graph,
1637 root,
1638 "repository.ts",
1639 "Repository",
1640 "save",
1641 ),
1642 Err(SemanticClassMethodResolutionError::AmbiguousSymbol)
1643 );
1644 assert_eq!(
1645 semantic_symbol_for_exact_class_method(
1646 &graph,
1647 root,
1648 "repository.ts",
1649 "OtherRepository",
1650 "save",
1651 ),
1652 Err(SemanticClassMethodResolutionError::SymbolNotFound)
1653 );
1654 let resolved = semantic_symbol_for_exact_class_method(
1655 &graph,
1656 root,
1657 "repository.ts",
1658 "Repository",
1659 "run",
1660 )
1661 .unwrap();
1662 assert_eq!(resolved.owner.as_deref(), Some("Repository"));
1663 assert_eq!(resolved.local_name, "run");
1664 }
1665
1666 fn build_unreachable_class_member_graph() -> ModuleGraph {
1669 use fallow_types::extract::{MemberInfo, MemberKind};
1670
1671 let files = vec![
1672 DiscoveredFile {
1673 id: FileId(0),
1674 path: PathBuf::from("/project/src/entry.ts"),
1675 size_bytes: 100,
1676 },
1677 DiscoveredFile {
1678 id: FileId(1),
1679 path: PathBuf::from("/project/src/controller.ts"),
1680 size_bytes: 50,
1681 },
1682 ];
1683 let entry_points = vec![EntryPoint {
1684 path: PathBuf::from("/project/src/entry.ts"),
1685 source: EntryPointSource::PackageJsonMain,
1686 }];
1687 let method = |name: &str| MemberInfo {
1688 name: name.to_string(),
1689 kind: MemberKind::ClassMethod,
1690 span: oxc_span::Span::new(0, 4),
1691 has_decorator: false,
1692 decorator_names: vec![],
1693 is_instance_returning_static: false,
1694 is_self_returning: false,
1695 };
1696 let resolved_modules = vec![
1697 ResolvedModule {
1698 file_id: FileId(0),
1699 path: PathBuf::from("/project/src/entry.ts"),
1700 ..Default::default()
1702 },
1703 ResolvedModule {
1704 file_id: FileId(1),
1705 path: PathBuf::from("/project/src/controller.ts"),
1706 exports: vec![ExportInfo {
1707 name: ExportName::Named("Ctrl".to_string()),
1708 local_name: Some("Ctrl".to_string()),
1709 is_type_only: false,
1710 visibility: VisibilityTag::None,
1711 expected_unused_reason: None,
1712 span: oxc_span::Span::new(0, 20),
1713 members: vec![method("dead")],
1714 is_side_effect_used: false,
1715 super_class: None,
1716 }]
1717 .into(),
1718 ..Default::default()
1719 },
1720 ];
1721 ModuleGraph::build(&resolved_modules, &entry_points, &files)
1722 }
1723
1724 #[test]
1725 fn trace_class_member_unreachable_owner_reports_dead_reason() {
1726 let graph = build_unreachable_class_member_graph();
1729 let root = Path::new("/project");
1730
1731 let trace = trace_class_member(&graph, root, "src/controller.ts", "dead").unwrap();
1732 assert!(!trace.owner_file_reachable);
1733 assert!(
1734 trace.reason.contains("not reachable"),
1735 "unreachable owner reason should say so: {}",
1736 trace.reason
1737 );
1738 assert!(!trace.reason.contains("--unused-class-members"));
1741 }
1742
1743 #[test]
1744 fn trace_class_member_prefers_used_owner_on_name_collision() {
1745 use fallow_types::extract::{MemberInfo, MemberKind};
1749
1750 let files = vec![
1751 DiscoveredFile {
1752 id: FileId(0),
1753 path: PathBuf::from("/project/src/entry.ts"),
1754 size_bytes: 100,
1755 },
1756 DiscoveredFile {
1757 id: FileId(1),
1758 path: PathBuf::from("/project/src/controller.ts"),
1759 size_bytes: 50,
1760 },
1761 ];
1762 let entry_points = vec![EntryPoint {
1763 path: PathBuf::from("/project/src/entry.ts"),
1764 source: EntryPointSource::PackageJsonMain,
1765 }];
1766 let method = |name: &str| MemberInfo {
1767 name: name.to_string(),
1768 kind: MemberKind::ClassMethod,
1769 span: oxc_span::Span::new(0, 4),
1770 has_decorator: false,
1771 decorator_names: vec![],
1772 is_instance_returning_static: false,
1773 is_self_returning: false,
1774 };
1775 let resolved_modules = vec![
1776 ResolvedModule {
1777 file_id: FileId(0),
1778 path: PathBuf::from("/project/src/entry.ts"),
1779 resolved_imports: vec![ResolvedImport {
1780 info: ImportInfo {
1781 source: "./controller".to_string(),
1782 imported_name: ImportedName::Named("UsedCtrl".to_string()),
1783 local_name: "UsedCtrl".to_string(),
1784 is_type_only: false,
1785 from_style: false,
1786 span: oxc_span::Span::new(0, 10),
1787 source_span: oxc_span::Span::default(),
1788 },
1789 target: ResolveResult::InternalModule(FileId(1)),
1790 }],
1791 ..Default::default()
1792 },
1793 ResolvedModule {
1794 file_id: FileId(1),
1795 path: PathBuf::from("/project/src/controller.ts"),
1796 exports: vec![
1797 ExportInfo {
1800 name: ExportName::Named("TypeCtrl".to_string()),
1801 local_name: Some("TypeCtrl".to_string()),
1802 is_type_only: true,
1803 visibility: VisibilityTag::None,
1804 expected_unused_reason: None,
1805 span: oxc_span::Span::new(0, 20),
1806 members: vec![method("shared")],
1807 is_side_effect_used: false,
1808 super_class: None,
1809 },
1810 ExportInfo {
1811 name: ExportName::Named("UsedCtrl".to_string()),
1812 local_name: Some("UsedCtrl".to_string()),
1813 is_type_only: false,
1814 visibility: VisibilityTag::None,
1815 expected_unused_reason: None,
1816 span: oxc_span::Span::new(0, 20),
1817 members: vec![method("shared")],
1818 is_side_effect_used: false,
1819 super_class: None,
1820 },
1821 ]
1822 .into(),
1823 ..Default::default()
1824 },
1825 ];
1826 let graph = ModuleGraph::build(&resolved_modules, &entry_points, &files);
1827 let root = Path::new("/project");
1828
1829 let trace = trace_class_member(&graph, root, "src/controller.ts", "shared").unwrap();
1830 assert_eq!(
1831 trace.owner_export, "UsedCtrl",
1832 "tie-break must prefer the used, non-type-only owner"
1833 );
1834 assert!(trace.owner_is_used);
1835 }
1836
1837 #[test]
1838 fn trace_nonexistent_file() {
1839 let graph = build_test_graph();
1840 let root = Path::new("/project");
1841
1842 let trace = trace_export(&graph, root, "src/nope.ts", "foo");
1843 assert!(trace.is_none());
1844 }
1845
1846 #[test]
1847 fn trace_file_edges() {
1848 let graph = build_test_graph();
1849 let root = Path::new("/project");
1850
1851 let trace = trace_file(&graph, root, "src/entry.ts").unwrap();
1852 assert!(trace.is_entry_point);
1853 assert!(trace.is_reachable);
1854 assert_eq!(trace.imports_from.len(), 1);
1855 assert_eq!(trace.imports_from[0], PathBuf::from("src/utils.ts"));
1856 assert!(trace.imported_by.is_empty());
1857 }
1858
1859 #[test]
1860 fn trace_file_imported_by() {
1861 let graph = build_test_graph();
1862 let root = Path::new("/project");
1863
1864 let trace = trace_file(&graph, root, "src/utils.ts").unwrap();
1865 assert!(!trace.is_entry_point);
1866 assert!(trace.is_reachable);
1867 assert_eq!(trace.exports.len(), 2);
1868 assert_eq!(trace.imported_by.len(), 1);
1869 assert_eq!(trace.imported_by[0], PathBuf::from("src/entry.ts"));
1870 }
1871
1872 #[test]
1873 fn trace_unreachable_file() {
1874 let graph = build_test_graph();
1875 let root = Path::new("/project");
1876
1877 let trace = trace_file(&graph, root, "src/unused.ts").unwrap();
1878 assert!(!trace.is_reachable);
1879 assert!(!trace.is_entry_point);
1880 assert!(trace.imported_by.is_empty());
1881 }
1882
1883 #[test]
1884 fn trace_dependency_used() {
1885 let files = vec![DiscoveredFile {
1886 id: FileId(0),
1887 path: PathBuf::from("/project/src/app.ts"),
1888 size_bytes: 100,
1889 }];
1890 let entry_points = vec![EntryPoint {
1891 path: PathBuf::from("/project/src/app.ts"),
1892 source: EntryPointSource::PackageJsonMain,
1893 }];
1894 let resolved_modules = vec![ResolvedModule {
1895 file_id: FileId(0),
1896 path: PathBuf::from("/project/src/app.ts"),
1897 resolved_imports: vec![ResolvedImport {
1898 info: ImportInfo {
1899 source: "lodash".to_string(),
1900 imported_name: ImportedName::Named("get".to_string()),
1901 local_name: "get".to_string(),
1902 is_type_only: false,
1903 from_style: false,
1904 span: oxc_span::Span::new(0, 10),
1905 source_span: oxc_span::Span::default(),
1906 },
1907 target: ResolveResult::NpmPackage("lodash".to_string()),
1908 }],
1909 ..Default::default()
1910 }];
1911
1912 let graph = ModuleGraph::build(&resolved_modules, &entry_points, &files);
1913 let root = Path::new("/project");
1914
1915 let trace = trace_dependency(&graph, root, "lodash", &FxHashSet::default());
1916 assert!(trace.is_used);
1917 assert!(!trace.used_in_scripts);
1918 assert_eq!(trace.import_count, 1);
1919 assert_eq!(trace.imported_by[0], PathBuf::from("src/app.ts"));
1920 }
1921
1922 #[test]
1923 fn trace_dependency_unused() {
1924 let files = vec![DiscoveredFile {
1925 id: FileId(0),
1926 path: PathBuf::from("/project/src/app.ts"),
1927 size_bytes: 100,
1928 }];
1929 let entry_points = vec![EntryPoint {
1930 path: PathBuf::from("/project/src/app.ts"),
1931 source: EntryPointSource::PackageJsonMain,
1932 }];
1933 let resolved_modules = vec![ResolvedModule {
1934 file_id: FileId(0),
1935 path: PathBuf::from("/project/src/app.ts"),
1936 ..Default::default()
1937 }];
1938
1939 let graph = ModuleGraph::build(&resolved_modules, &entry_points, &files);
1940 let root = Path::new("/project");
1941
1942 let trace = trace_dependency(&graph, root, "nonexistent-pkg", &FxHashSet::default());
1943 assert!(!trace.is_used);
1944 assert!(!trace.used_in_scripts);
1945 assert_eq!(trace.import_count, 0);
1946 assert!(trace.imported_by.is_empty());
1947 }
1948
1949 #[test]
1950 fn trace_dependency_used_only_in_scripts() {
1951 let files = vec![DiscoveredFile {
1952 id: FileId(0),
1953 path: PathBuf::from("/project/src/app.ts"),
1954 size_bytes: 100,
1955 }];
1956 let entry_points = vec![EntryPoint {
1957 path: PathBuf::from("/project/src/app.ts"),
1958 source: EntryPointSource::PackageJsonMain,
1959 }];
1960 let resolved_modules = vec![ResolvedModule {
1961 file_id: FileId(0),
1962 path: PathBuf::from("/project/src/app.ts"),
1963 ..Default::default()
1964 }];
1965
1966 let graph = ModuleGraph::build(&resolved_modules, &entry_points, &files);
1967 let root = Path::new("/project");
1968 let mut script_used = FxHashSet::default();
1969 script_used.insert("microbundle".to_string());
1970
1971 let trace = trace_dependency(&graph, root, "microbundle", &script_used);
1972 assert!(
1973 trace.is_used,
1974 "is_used must be true when the package is referenced from package.json scripts"
1975 );
1976 assert!(trace.used_in_scripts);
1977 assert_eq!(trace.import_count, 0);
1978 assert!(trace.imported_by.is_empty());
1979 }
1980
1981 #[test]
1982 fn trace_clone_finds_matching_group() {
1983 use crate::duplicates::{CloneGroup, CloneInstance, DuplicationReport, DuplicationStats};
1984 let report = DuplicationReport {
1985 clone_groups: vec![CloneGroup {
1986 instances: vec![
1987 CloneInstance {
1988 file: PathBuf::from("/project/src/a.ts"),
1989 start_line: 10,
1990 end_line: 20,
1991 start_col: 0,
1992 end_col: 0,
1993 fragment: "fn foo() {}".to_string(),
1994 },
1995 CloneInstance {
1996 file: PathBuf::from("/project/src/b.ts"),
1997 start_line: 5,
1998 end_line: 15,
1999 start_col: 0,
2000 end_col: 0,
2001 fragment: "fn foo() {}".to_string(),
2002 },
2003 ],
2004 token_count: 60,
2005 line_count: 11,
2006 similarity: None,
2007 }],
2008 clone_families: vec![],
2009 mirrored_directories: vec![],
2010 stats: DuplicationStats {
2011 total_files: 2,
2012 files_with_clones: 2,
2013 total_lines: 100,
2014 duplicated_lines: 22,
2015 total_tokens: 200,
2016 duplicated_tokens: 120,
2017 clone_groups: 1,
2018 clone_instances: 2,
2019 duplication_percentage: 22.0,
2020 clone_groups_below_min_occurrences: 0,
2021 clone_groups_ignored: 0,
2022 near_candidates_skipped: 0,
2023 },
2024 };
2025 let trace = trace_clone(&report, Path::new("/project"), "src/a.ts", 15);
2026 assert!(trace.matched_instance.is_some());
2027 assert_eq!(trace.clone_groups.len(), 1);
2028 assert_eq!(trace.clone_groups[0].instances.len(), 2);
2029 assert!(trace.clone_groups[0].fingerprint.starts_with("dup:"));
2030 assert_eq!(trace.clone_groups[0].suggestion.estimated_savings, 11);
2031 }
2032
2033 #[test]
2034 fn trace_clone_by_fingerprint_resolves_and_misses() {
2035 use crate::duplicates::{
2036 CloneGroup, CloneInstance, DuplicationReport, DuplicationStats, clone_fingerprint,
2037 };
2038 let report = DuplicationReport {
2039 clone_groups: vec![CloneGroup {
2040 instances: vec![
2041 CloneInstance {
2042 file: PathBuf::from("/project/src/a.ts"),
2043 start_line: 10,
2044 end_line: 20,
2045 start_col: 0,
2046 end_col: 0,
2047 fragment: "fn buildInvoice() {}".to_string(),
2048 },
2049 CloneInstance {
2050 file: PathBuf::from("/project/src/b.ts"),
2051 start_line: 5,
2052 end_line: 15,
2053 start_col: 0,
2054 end_col: 0,
2055 fragment: "fn buildInvoice() {}".to_string(),
2056 },
2057 ],
2058 token_count: 60,
2059 line_count: 11,
2060 similarity: None,
2061 }],
2062 clone_families: vec![],
2063 mirrored_directories: vec![],
2064 stats: DuplicationStats::default(),
2065 };
2066 let fp = clone_fingerprint(&report.clone_groups[0].instances);
2067
2068 let hit = trace_clone_by_fingerprint(&report, Path::new("/project"), &fp);
2069 assert!(hit.matched_instance.is_some());
2070 assert_eq!(hit.clone_groups.len(), 1);
2071 assert_eq!(hit.clone_groups[0].fingerprint, fp);
2072 assert_eq!(hit.line, 10);
2073
2074 let miss = trace_clone_by_fingerprint(&report, Path::new("/project"), "dup:deadbeef");
2075 assert!(miss.matched_instance.is_none());
2076 assert!(miss.clone_groups.is_empty());
2077 }
2078
2079 #[test]
2080 fn trace_clone_no_match() {
2081 use crate::duplicates::{CloneGroup, CloneInstance, DuplicationReport, DuplicationStats};
2082 let report = DuplicationReport {
2083 clone_groups: vec![CloneGroup {
2084 instances: vec![CloneInstance {
2085 file: PathBuf::from("/project/src/a.ts"),
2086 start_line: 10,
2087 end_line: 20,
2088 start_col: 0,
2089 end_col: 0,
2090 fragment: "fn foo() {}".to_string(),
2091 }],
2092 token_count: 60,
2093 line_count: 11,
2094 similarity: None,
2095 }],
2096 clone_families: vec![],
2097 mirrored_directories: vec![],
2098 stats: DuplicationStats {
2099 total_files: 1,
2100 files_with_clones: 1,
2101 total_lines: 50,
2102 duplicated_lines: 11,
2103 total_tokens: 100,
2104 duplicated_tokens: 60,
2105 clone_groups: 1,
2106 clone_instances: 1,
2107 duplication_percentage: 22.0,
2108 clone_groups_below_min_occurrences: 0,
2109 clone_groups_ignored: 0,
2110 near_candidates_skipped: 0,
2111 },
2112 };
2113 let trace = trace_clone(&report, Path::new("/project"), "src/a.ts", 25);
2114 assert!(trace.matched_instance.is_none());
2115 assert!(trace.clone_groups.is_empty());
2116 }
2117
2118 #[test]
2119 fn trace_clone_line_boundary() {
2120 use crate::duplicates::{CloneGroup, CloneInstance, DuplicationReport, DuplicationStats};
2121 let report = DuplicationReport {
2122 clone_groups: vec![CloneGroup {
2123 instances: vec![
2124 CloneInstance {
2125 file: PathBuf::from("/project/src/a.ts"),
2126 start_line: 10,
2127 end_line: 20,
2128 start_col: 0,
2129 end_col: 0,
2130 fragment: "code".to_string(),
2131 },
2132 CloneInstance {
2133 file: PathBuf::from("/project/src/b.ts"),
2134 start_line: 1,
2135 end_line: 11,
2136 start_col: 0,
2137 end_col: 0,
2138 fragment: "code".to_string(),
2139 },
2140 ],
2141 token_count: 50,
2142 line_count: 11,
2143 similarity: None,
2144 }],
2145 clone_families: vec![],
2146 mirrored_directories: vec![],
2147 stats: DuplicationStats {
2148 total_files: 2,
2149 files_with_clones: 2,
2150 total_lines: 100,
2151 duplicated_lines: 22,
2152 total_tokens: 200,
2153 duplicated_tokens: 100,
2154 clone_groups: 1,
2155 clone_instances: 2,
2156 duplication_percentage: 22.0,
2157 clone_groups_below_min_occurrences: 0,
2158 clone_groups_ignored: 0,
2159 near_candidates_skipped: 0,
2160 },
2161 };
2162 let root = Path::new("/project");
2163 assert!(
2164 trace_clone(&report, root, "src/a.ts", 10)
2165 .matched_instance
2166 .is_some()
2167 );
2168 assert!(
2169 trace_clone(&report, root, "src/a.ts", 20)
2170 .matched_instance
2171 .is_some()
2172 );
2173 assert!(
2174 trace_clone(&report, root, "src/a.ts", 21)
2175 .matched_instance
2176 .is_none()
2177 );
2178 }
2179
2180 #[test]
2181 fn trace_clone_returns_relative_instance_paths() {
2182 use crate::duplicates::{CloneGroup, CloneInstance, DuplicationReport, DuplicationStats};
2183 let report = DuplicationReport {
2184 clone_groups: vec![CloneGroup {
2185 instances: vec![
2186 CloneInstance {
2187 file: PathBuf::from("/project/src/a.ts"),
2188 start_line: 1,
2189 end_line: 10,
2190 start_col: 0,
2191 end_col: 0,
2192 fragment: "code".to_string(),
2193 },
2194 CloneInstance {
2195 file: PathBuf::from("/project/src/b.ts"),
2196 start_line: 1,
2197 end_line: 10,
2198 start_col: 0,
2199 end_col: 0,
2200 fragment: "code".to_string(),
2201 },
2202 ],
2203 token_count: 50,
2204 line_count: 10,
2205 similarity: None,
2206 }],
2207 clone_families: vec![],
2208 mirrored_directories: vec![],
2209 stats: DuplicationStats {
2210 total_files: 2,
2211 files_with_clones: 2,
2212 total_lines: 50,
2213 duplicated_lines: 20,
2214 total_tokens: 100,
2215 duplicated_tokens: 100,
2216 clone_groups: 1,
2217 clone_instances: 2,
2218 duplication_percentage: 40.0,
2219 clone_groups_below_min_occurrences: 0,
2220 clone_groups_ignored: 0,
2221 near_candidates_skipped: 0,
2222 },
2223 };
2224 let trace = trace_clone(&report, Path::new("/project"), "src/a.ts", 5);
2225 let matched = trace.matched_instance.as_ref().expect("match expected");
2226 assert_eq!(matched.file, PathBuf::from("src/a.ts"));
2227 for group in &trace.clone_groups {
2228 for inst in &group.instances {
2229 let as_str = inst.file.to_string_lossy();
2230 assert!(
2231 !as_str.starts_with('/'),
2232 "instance file should be relative, got {as_str}",
2233 );
2234 assert!(
2235 !as_str.contains(":\\") && !as_str.contains(":/"),
2236 "instance file should not have a drive letter, got {as_str}",
2237 );
2238 }
2239 }
2240
2241 let json = serde_json::to_string(&trace).expect("serializes");
2242 assert!(
2243 !json.contains("\"/project/"),
2244 "serialized trace should not leak absolute paths: {json}",
2245 );
2246 }
2247
2248 #[test]
2255 fn path_matches_normalises_windows_module_path_against_posix_user_path() {
2256 let root = Path::new(r"D:\a\fallow\fallow\tests\fixtures\basic-project");
2257 let module_path =
2258 PathBuf::from(r"D:\a\fallow\fallow\tests\fixtures\basic-project\src\utils.ts");
2259 assert!(path_matches(&module_path, root, "src/utils.ts"));
2260 assert!(path_matches(&module_path, root, r"src\utils.ts"));
2261 }
2262
2263 #[test]
2264 fn path_matches_ends_with_fallback_handles_mixed_separators() {
2265 let root = Path::new("/some/other/root");
2266 let module_path =
2267 PathBuf::from(r"D:\a\fallow\fallow\tests\fixtures\basic-project\src\utils.ts");
2268 assert!(path_matches(&module_path, root, "src/utils.ts"));
2269 }
2270
2271 #[test]
2280 fn export_trace_serializes_windows_path_with_forward_slashes() {
2281 let trace = ExportTrace {
2282 file: PathBuf::from(r"src\utils.ts"),
2283 export_name: "foo".to_string(),
2284 namespace: fallow_types::semantic::SemanticNamespace::Value,
2285 file_reachable: true,
2286 is_entry_point: false,
2287 is_used: true,
2288 direct_references: vec![ExportReference {
2289 from_file: PathBuf::from(r"src\entry.ts"),
2290 kind: "named import".to_string(),
2291 }],
2292 re_export_chains: vec![ReExportChain {
2293 barrel_file: PathBuf::from(r"src\index.ts"),
2294 exported_as: "foo".to_string(),
2295 reference_count: 1,
2296 }],
2297 reason: "ok".to_string(),
2298 semantic: None,
2299 };
2300 let json = serde_json::to_string(&trace).expect("serializes");
2301 assert!(
2302 json.contains("\"file\":\"src/utils.ts\""),
2303 "ExportTrace.file must serialize with forward slashes: {json}"
2304 );
2305 assert!(
2306 json.contains("\"from_file\":\"src/entry.ts\""),
2307 "ExportReference.from_file must serialize with forward slashes: {json}"
2308 );
2309 assert!(
2310 json.contains("\"barrel_file\":\"src/index.ts\""),
2311 "ReExportChain.barrel_file must serialize with forward slashes: {json}"
2312 );
2313 assert!(
2314 !json.contains(r"\\"),
2315 "no backslash sequence should remain anywhere in the JSON: {json}"
2316 );
2317 }
2318
2319 #[test]
2320 fn file_trace_serializes_windows_paths_with_forward_slashes() {
2321 let trace = FileTrace {
2322 file: PathBuf::from(r"src\utils.ts"),
2323 is_reachable: true,
2324 is_entry_point: false,
2325 exports: vec![],
2326 imports_from: vec![PathBuf::from(r"src\helpers.ts")],
2327 imported_by: vec![PathBuf::from(r"src\entry.ts")],
2328 re_exports: vec![TracedReExport {
2329 source_file: PathBuf::from(r"src\source.ts"),
2330 imported_name: "foo".to_string(),
2331 exported_name: "foo".to_string(),
2332 }],
2333 };
2334 let json = serde_json::to_string(&trace).expect("serializes");
2335 assert!(json.contains("\"file\":\"src/utils.ts\""), "got {json}");
2336 assert!(
2337 json.contains("\"imports_from\":[\"src/helpers.ts\"]"),
2338 "got {json}"
2339 );
2340 assert!(
2341 json.contains("\"imported_by\":[\"src/entry.ts\"]"),
2342 "got {json}"
2343 );
2344 assert!(
2345 json.contains("\"source_file\":\"src/source.ts\""),
2346 "got {json}"
2347 );
2348 assert!(!json.contains(r"\\"), "no backslash should remain: {json}");
2349 }
2350}