1use std::path::{Path, PathBuf};
2
3use fallow_types::discover::FileId;
4use fallow_types::trace::{
5 ClassMemberTrace, CloneTrace, DependencyTrace, ExportReference, ExportTrace, FileTrace,
6 ImpactClosureGap, ImpactClosureTrace, ImportPathHop, ImportPathTrace,
7 ImportPathTraceSchemaVersion, NamespacedExportReferences, ReExportChain, TracedCloneGroup,
8 TracedExport, TracedReExport,
9};
10use fallow_types::trace_chain::StarExportAmbiguity;
11use rustc_hash::{FxHashMap, FxHashSet};
12
13use crate::duplicates::{
14 CloneFingerprintSet, CloneGroup, CloneInstance, DuplicationReport, dominant_identifier,
15 group_refactoring_suggestion,
16};
17use crate::graph::{
18 ExportNamespace, ImportPathHop as GraphImportPathHop, ModuleGraph, ModuleNode, ReferenceKind,
19};
20
21pub fn matching_module_indexes(graph: &ModuleGraph, root: &Path, user_path: &str) -> Vec<usize> {
27 let normalized = user_path.replace('\\', "/");
28 let exact_targets: Vec<PathBuf> = [
31 Some(root.join(&normalized)),
32 dunce::canonicalize(root)
33 .ok()
34 .map(|canonical_root| canonical_root.join(&normalized)),
35 dunce::canonicalize(root.join(&normalized)).ok(),
36 ]
37 .into_iter()
38 .flatten()
39 .collect();
40 let mut exact = Vec::new();
41 let mut suffix = Vec::new();
42 let suffix_pattern = format!("/{normalized}");
43 for (index, module) in graph.modules.iter().enumerate() {
44 let module_path = forward_slash_path(&module.path);
45 let is_exact = module_path == normalized || exact_targets.contains(&module.path);
46 if is_exact {
47 exact.push(index);
48 } else if module_path.ends_with(&suffix_pattern) {
49 suffix.push(index);
50 }
51 }
52 if exact.is_empty() { suffix } else { exact }
53}
54
55fn forward_slash_path(path: &Path) -> std::borrow::Cow<'_, str> {
58 let lossy = path.to_string_lossy();
59 if lossy.contains('\\') {
60 std::borrow::Cow::Owned(lossy.replace('\\', "/"))
61 } else {
62 lossy
63 }
64}
65
66struct ModulePathLookup<'g> {
73 graph: &'g ModuleGraph,
74 root: &'g Path,
75 canonical_root: Option<PathBuf>,
76 by_path: FxHashMap<String, Vec<usize>>,
78 by_file_name: FxHashMap<String, Vec<usize>>,
80}
81
82impl<'g> ModulePathLookup<'g> {
83 fn new(graph: &'g ModuleGraph, root: &'g Path) -> Self {
84 let mut by_path: FxHashMap<String, Vec<usize>> = FxHashMap::default();
85 let mut by_file_name: FxHashMap<String, Vec<usize>> = FxHashMap::default();
86 for (index, module) in graph.modules.iter().enumerate() {
87 by_path
88 .entry(path_key(&module.path))
89 .or_default()
90 .push(index);
91 let module_path = forward_slash_path(&module.path);
92 let file_name = module_path.rsplit('/').next().unwrap_or_default();
93 by_file_name
94 .entry(file_name.to_owned())
95 .or_default()
96 .push(index);
97 }
98 Self {
99 graph,
100 root,
101 canonical_root: dunce::canonicalize(root).ok(),
102 by_path,
103 by_file_name,
104 }
105 }
106
107 fn matching(&self, user_path: &str) -> Vec<usize> {
108 let normalized = user_path.replace('\\', "/");
109 let joined = self.root.join(&normalized);
110 let mut targets = vec![path_key(Path::new(&normalized)), path_key(&joined)];
111 if let Some(canonical_root) = &self.canonical_root {
112 targets.push(path_key(&canonical_root.join(&normalized)));
113 }
114 if let Ok(canonical_target) = dunce::canonicalize(&joined) {
115 targets.push(path_key(&canonical_target));
116 }
117 let mut exact: Vec<usize> = targets
118 .iter()
119 .filter_map(|target| self.by_path.get(target))
120 .flatten()
121 .copied()
122 .collect();
123 if !exact.is_empty() {
124 exact.sort_unstable();
125 exact.dedup();
126 return exact;
127 }
128 let suffix_pattern = format!("/{normalized}");
129 let file_name = normalized.rsplit('/').next().unwrap_or_default();
130 self.by_file_name
131 .get(file_name)
132 .into_iter()
133 .flatten()
134 .copied()
135 .filter(|&index| {
136 forward_slash_path(&self.graph.modules[index].path).ends_with(&suffix_pattern)
137 })
138 .collect()
139 }
140}
141
142fn path_key(path: &Path) -> String {
145 forward_slash_path(&path.components().collect::<PathBuf>()).into_owned()
146}
147
148pub fn find_module<'a>(
153 graph: &'a ModuleGraph,
154 root: &Path,
155 user_path: &str,
156) -> Option<&'a ModuleNode> {
157 matching_module_indexes(graph, root, user_path)
158 .first()
159 .map(|&index| &graph.modules[index])
160}
161
162pub fn reconcile_semantic_trace_reachability(
166 graph: &ModuleGraph,
167 root: &Path,
168 target_reachable: bool,
169 trace: &mut fallow_types::semantic::SemanticSymbolTrace,
170) {
171 if trace.assertion != "references-found" || trace.references.is_empty() {
172 return;
173 }
174 let has_reachable_reference = target_reachable && {
175 let lookup = ModulePathLookup::new(graph, root);
176 trace.references.iter().any(|reference| {
177 lookup
178 .matching(&reference.path.to_string_lossy())
179 .iter()
180 .any(|&index| graph.modules[index].is_reachable())
181 })
182 };
183 if has_reachable_reference {
184 return;
185 }
186
187 trace.assertion = "references-only-in-unreachable-files".to_string();
188 trace.status = fallow_types::semantic::SemanticCompleteness::Partial;
189 trace.identity.completeness = fallow_types::semantic::SemanticCompleteness::Partial;
190 let action =
191 "Review the unreachable consumer files before removing this declaration.".to_string();
192 if !trace.actions.contains(&action) {
193 trace.actions.push(action);
194 }
195}
196
197fn reference_to_export_reference(
199 graph: &ModuleGraph,
200 root: &Path,
201 r: &crate::graph::SymbolReference,
202) -> ExportReference {
203 let from_path = graph.modules.get(r.from_file.0 as usize).map_or_else(
204 || PathBuf::from(format!("<unknown:{}>", r.from_file.0)),
205 |m| m.path.strip_prefix(root).unwrap_or(&m.path).to_path_buf(),
206 );
207 ExportReference {
208 from_file: from_path,
209 kind: format_reference_kind(r.kind),
210 }
211}
212
213fn collect_re_export_chains(
215 graph: &ModuleGraph,
216 root: &Path,
217 target_file_id: crate::discover::FileId,
218 export_name: &str,
219 namespace: ExportNamespace,
220) -> Vec<ReExportChain> {
221 graph
222 .effective_re_export_routes(target_file_id, export_name, namespace)
223 .into_iter()
224 .filter_map(|route| {
225 let module = graph.modules.get(route.barrel_file().0 as usize)?;
226 Some(ReExportChain {
227 barrel_file: module
228 .path
229 .strip_prefix(root)
230 .unwrap_or(&module.path)
231 .to_path_buf(),
232 exported_as: route.exported_name().to_string(),
233 reference_count: graph
234 .effective_export_surface_references(
235 route.barrel_file(),
236 route.exported_name(),
237 namespace,
238 )
239 .into_iter()
240 .filter(|reference| {
241 graph
242 .modules
243 .get(reference.from_file.0 as usize)
244 .is_some_and(crate::graph::ModuleNode::is_reachable)
245 })
246 .count(),
247 })
248 })
249 .collect()
250}
251
252fn export_trace_reason(
254 module: &crate::graph::ModuleNode,
255 reference_count: usize,
256 is_used: bool,
257 re_export_chains: &[ReExportChain],
258) -> String {
259 if !module.is_reachable() {
260 "File is unreachable from any entry point".to_string()
261 } else if is_used {
262 format!(
263 "Used by {} file(s){}",
264 reference_count,
265 if re_export_chains.is_empty() {
266 String::new()
267 } else {
268 format!(", re-exported through {} barrel(s)", re_export_chains.len())
269 }
270 )
271 } else if module.is_entry_point() {
272 "No internal references, but file is an entry point (export is externally accessible)"
273 .to_string()
274 } else if !re_export_chains.is_empty() {
275 format!(
276 "Re-exported through {} barrel(s) but no consumer imports it through the barrel",
277 re_export_chains.len()
278 )
279 } else {
280 "No references found, export is unused".to_string()
281 }
282}
283
284#[must_use]
286pub fn trace_export(
287 graph: &ModuleGraph,
288 root: &Path,
289 file_path: &str,
290 export_name: &str,
291) -> Option<ExportTrace> {
292 let module = find_module(graph, root, file_path)?;
293
294 let star_export_ambiguity =
295 trace_star_export_ambiguity(graph, root, module.file_id, export_name);
296 let Some(surface) = select_export(graph, module, export_name) else {
297 let ambiguity = star_export_ambiguity?;
298 let namespace = ambiguity.namespaces.first().copied().unwrap_or_default();
299 return Some(ExportTrace {
300 file: module
301 .path
302 .strip_prefix(root)
303 .unwrap_or(&module.path)
304 .to_path_buf(),
305 export_name: export_name.to_string(),
306 namespace,
307 file_reachable: module.is_reachable(),
308 is_entry_point: module.is_entry_point(),
309 is_used: false,
310 direct_references: Vec::new(),
311 direct_references_by_namespace: Vec::new(),
312 star_export_ambiguity: Some(ambiguity),
313 re_export_chains: Vec::new(),
314 reason: "Star re-export collision makes this name ambiguous".to_string(),
315 semantic: None,
316 });
317 };
318 let (namespace, direct_references, direct_references_by_namespace) =
319 crediting_export_references(graph, root, module.file_id, export_name, surface);
320
321 let re_export_chains =
322 collect_re_export_chains(graph, root, module.file_id, export_name, namespace);
323
324 let reference_count = direct_references.len();
325 let is_used = module.is_reachable() && reference_count > 0;
326 let reason = if star_export_ambiguity.is_some() {
327 "Star re-export collision prevents consumers from resolving this declaration".to_string()
328 } else {
329 export_trace_reason(module, reference_count, is_used, &re_export_chains)
330 };
331
332 Some(ExportTrace {
333 file: module
334 .path
335 .strip_prefix(root)
336 .unwrap_or(&module.path)
337 .to_path_buf(),
338 export_name: export_name.to_string(),
339 namespace: match namespace {
340 ExportNamespace::Type => fallow_types::semantic::SemanticNamespace::Type,
341 ExportNamespace::Value => fallow_types::semantic::SemanticNamespace::Value,
342 },
343 file_reachable: module.is_reachable(),
344 is_entry_point: module.is_entry_point(),
345 is_used,
346 direct_references,
347 direct_references_by_namespace,
348 star_export_ambiguity,
349 re_export_chains,
350 reason,
351 semantic: None,
352 })
353}
354
355fn direct_export_references(
357 graph: &ModuleGraph,
358 root: &Path,
359 file_id: crate::discover::FileId,
360 export_name: &str,
361 namespace: ExportNamespace,
362) -> Vec<ExportReference> {
363 let mut referenced_files = FxHashSet::default();
364 graph
365 .effective_export_surface_references(file_id, export_name, namespace)
366 .into_iter()
367 .filter(|reference| {
368 graph
369 .modules
370 .get(reference.from_file.0 as usize)
371 .is_some_and(crate::graph::ModuleNode::is_reachable)
372 })
373 .filter(|reference| referenced_files.insert(reference.from_file))
374 .map(|r| reference_to_export_reference(graph, root, r))
375 .collect()
376}
377
378fn crediting_export_references(
390 graph: &ModuleGraph,
391 root: &Path,
392 file_id: crate::discover::FileId,
393 export_name: &str,
394 surface: crate::graph::EffectiveExportSurface<'_>,
395) -> (
396 ExportNamespace,
397 Vec<ExportReference>,
398 Vec<NamespacedExportReferences>,
399) {
400 let namespace = surface.namespace();
401 let references = direct_export_references(graph, root, file_id, export_name, namespace);
402 let other = match namespace {
403 ExportNamespace::Type => ExportNamespace::Value,
404 ExportNamespace::Value => ExportNamespace::Type,
405 };
406 let same_binding = graph
407 .effective_export_surface(file_id, export_name, other)
408 .is_some_and(|candidate| {
409 graph.effective_bindings_share_declaration_group(candidate.binding(), surface.binding())
410 });
411 if !same_binding {
412 return (namespace, references, Vec::new());
413 }
414 let other_references = direct_export_references(graph, root, file_id, export_name, other);
415 let by_namespace = if references.is_empty() || other_references.is_empty() {
416 Vec::new()
417 } else {
418 vec![
419 namespaced_references(namespace, references.clone()),
420 namespaced_references(other, other_references.clone()),
421 ]
422 };
423 if references.is_empty() && !other_references.is_empty() {
424 (other, other_references, by_namespace)
425 } else {
426 (namespace, references, by_namespace)
427 }
428}
429
430fn namespaced_references(
431 namespace: ExportNamespace,
432 references: Vec<ExportReference>,
433) -> NamespacedExportReferences {
434 NamespacedExportReferences {
435 namespace: match namespace {
436 ExportNamespace::Type => fallow_types::semantic::SemanticNamespace::Type,
437 ExportNamespace::Value => fallow_types::semantic::SemanticNamespace::Value,
438 },
439 reference_count: references.len(),
440 references,
441 }
442}
443
444fn trace_star_export_ambiguity(
445 graph: &ModuleGraph,
446 root: &Path,
447 file_id: crate::discover::FileId,
448 export_name: &str,
449) -> Option<StarExportAmbiguity> {
450 let collisions: Vec<_> = graph
451 .ambiguous_star_exports()
452 .into_iter()
453 .filter(|collision| {
454 collision.name.as_ref() == export_name
455 && (collision.barrel == file_id || collision.contributors.contains(&file_id))
456 })
457 .collect();
458 if collisions.is_empty() {
459 return None;
460 }
461 let mut sources: Vec<_> = collisions
462 .iter()
463 .flat_map(|collision| collision.contributors.iter())
464 .filter_map(|contributor| graph.modules.get(contributor.0 as usize))
465 .map(|module| {
466 module
467 .path
468 .strip_prefix(root)
469 .unwrap_or(&module.path)
470 .to_path_buf()
471 })
472 .collect();
473 sources.sort();
474 sources.dedup();
475 let mut namespaces: Vec<_> = collisions
476 .iter()
477 .map(|collision| match collision.namespace {
478 ExportNamespace::Type => fallow_types::semantic::SemanticNamespace::Type,
479 ExportNamespace::Value => fallow_types::semantic::SemanticNamespace::Value,
480 })
481 .collect();
482 namespaces.sort_unstable_by_key(|namespace| match namespace {
483 fallow_types::semantic::SemanticNamespace::Type => 0,
484 fallow_types::semantic::SemanticNamespace::Value => 1,
485 });
486 namespaces.dedup();
487 Some(StarExportAmbiguity {
488 sources,
489 namespaces,
490 })
491}
492
493#[must_use]
496pub fn semantic_symbol_for_export(
497 graph: &ModuleGraph,
498 root: &Path,
499 file_path: &str,
500 export_name: &str,
501) -> Option<fallow_types::semantic::SemanticSymbol> {
502 use fallow_types::semantic::{SemanticNamespace, SemanticSymbol};
503
504 let module = find_module(graph, root, file_path)?;
505 let surface = select_export(graph, module, export_name)?;
506 let namespace = surface.namespace();
507 let (identity_module, span, identity_exported_name, local_name) = if let Some(re_export) =
508 graph.effective_export_surface_re_export(module.file_id, export_name, namespace)
509 {
510 let local_name = if re_export.imported_name == "*" {
511 export_name
512 } else {
513 re_export.imported_name.as_str()
514 };
515 (module, re_export.span, export_name, local_name)
516 } else {
517 let origin = surface.origin()?;
518 let origin_module = graph.modules.get(origin.file_id().0 as usize)?;
519 let origin_export = origin.export();
520 let origin_name = match &origin_export.name {
521 fallow_types::extract::ExportName::Named(name) => name.as_str(),
522 fallow_types::extract::ExportName::Default => "default",
523 };
524 (origin_module, origin_export.span, origin_name, origin_name)
525 };
526 let source = std::fs::read_to_string(&identity_module.path).ok()?;
527 let offsets = fallow_types::extract::compute_line_offsets(&source);
528 let (line, col) = fallow_types::extract::byte_offset_to_line_col(&offsets, span.start);
529 Some(SemanticSymbol {
530 path: identity_module
531 .path
532 .strip_prefix(root)
533 .unwrap_or(&identity_module.path)
534 .to_path_buf(),
535 namespace: match namespace {
536 ExportNamespace::Type => SemanticNamespace::Type,
537 ExportNamespace::Value => SemanticNamespace::Value,
538 },
539 declaration_kind: "export".to_string(),
540 exported_name: identity_exported_name.to_string(),
541 local_name: local_name.to_string(),
542 owner: None,
543 line,
544 col,
545 })
546}
547
548#[must_use]
550pub fn semantic_symbol_for_class_member(
551 graph: &ModuleGraph,
552 root: &Path,
553 file_path: &str,
554 member_name: &str,
555) -> Option<fallow_types::semantic::SemanticSymbol> {
556 use fallow_types::extract::MemberKind;
557 use fallow_types::semantic::{SemanticNamespace, SemanticSymbol};
558
559 let module = find_module(graph, root, file_path)?;
560 let (owner, member) = module
561 .exports
562 .iter()
563 .filter_map(|export| {
564 export
565 .members
566 .iter()
567 .find(|member| member.name == member_name)
568 .map(|member| (export, member))
569 })
570 .max_by_key(|(export, _)| (!export.references.is_empty(), !export.is_type_only))?;
571 let declaration_kind = match member.kind {
572 MemberKind::ClassMethod => "class_method",
573 MemberKind::ClassProperty => "class_property",
574 _ => return None,
575 };
576 let source = std::fs::read_to_string(&module.path).ok()?;
577 let offsets = fallow_types::extract::compute_line_offsets(&source);
578 let (line, col) = fallow_types::extract::byte_offset_to_line_col(&offsets, member.span.start);
579 Some(SemanticSymbol {
580 path: module
581 .path
582 .strip_prefix(root)
583 .unwrap_or(&module.path)
584 .to_path_buf(),
585 namespace: SemanticNamespace::Value,
586 declaration_kind: declaration_kind.to_string(),
587 exported_name: member_name.to_string(),
588 local_name: member_name.to_string(),
589 owner: Some(owner.name.to_string()),
590 line,
591 col,
592 })
593}
594
595#[derive(Debug, Clone, Copy, PartialEq, Eq)]
597pub enum SemanticClassMethodResolutionError {
598 FileNotFound,
600 SymbolNotFound,
602 AmbiguousSymbol,
604 UnsupportedSyntax,
606}
607
608impl std::fmt::Display for SemanticClassMethodResolutionError {
609 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
610 let reason = match self {
611 Self::FileNotFound => "file-not-found",
612 Self::SymbolNotFound => "unknown-symbol",
613 Self::AmbiguousSymbol => "ambiguous-symbol",
614 Self::UnsupportedSyntax => "unsupported-syntax",
615 };
616 formatter.write_str(reason)
617 }
618}
619
620pub fn semantic_symbol_for_exact_class_method(
622 graph: &ModuleGraph,
623 root: &Path,
624 file_path: &str,
625 owner_name: &str,
626 member_name: &str,
627) -> Result<fallow_types::semantic::SemanticSymbol, SemanticClassMethodResolutionError> {
628 use fallow_types::extract::MemberKind;
629 use fallow_types::semantic::{SemanticNamespace, SemanticSymbol};
630
631 let module = find_module(graph, root, file_path)
632 .ok_or(SemanticClassMethodResolutionError::FileNotFound)?;
633 let mut owners = module
634 .exports
635 .iter()
636 .filter(|export| export.name.matches_str(owner_name));
637 let owner = owners
638 .next()
639 .ok_or(SemanticClassMethodResolutionError::SymbolNotFound)?;
640 if owners.next().is_some() {
641 return Err(SemanticClassMethodResolutionError::AmbiguousSymbol);
642 }
643 let mut members = owner
644 .members
645 .iter()
646 .filter(|member| member.name == member_name);
647 let member = members
648 .next()
649 .ok_or(SemanticClassMethodResolutionError::SymbolNotFound)?;
650 if members.next().is_some() {
651 return Err(SemanticClassMethodResolutionError::AmbiguousSymbol);
652 }
653 if member.kind != MemberKind::ClassMethod {
654 return Err(SemanticClassMethodResolutionError::UnsupportedSyntax);
655 }
656 let source = std::fs::read_to_string(&module.path)
657 .map_err(|_| SemanticClassMethodResolutionError::SymbolNotFound)?;
658 let offsets = fallow_types::extract::compute_line_offsets(&source);
659 let (line, col) = fallow_types::extract::byte_offset_to_line_col(&offsets, member.span.start);
660 Ok(SemanticSymbol {
661 path: module
662 .path
663 .strip_prefix(root)
664 .unwrap_or(&module.path)
665 .to_path_buf(),
666 namespace: SemanticNamespace::Value,
667 declaration_kind: "class_method".to_string(),
668 exported_name: member_name.to_string(),
669 local_name: member_name.to_string(),
670 owner: Some(owner_name.to_string()),
671 line,
672 col,
673 })
674}
675
676#[must_use]
682pub fn trace_class_member(
683 graph: &ModuleGraph,
684 root: &Path,
685 file_path: &str,
686 member_name: &str,
687) -> Option<ClassMemberTrace> {
688 use fallow_types::extract::MemberKind;
689
690 let module = find_module(graph, root, file_path)?;
691
692 let (owner, member_kind) = module
696 .exports
697 .iter()
698 .filter_map(|export| {
699 export
700 .members
701 .iter()
702 .find(|member| member.name == member_name)
703 .map(|member| (export, member.kind))
704 })
705 .max_by_key(|(export, _)| (!export.references.is_empty(), !export.is_type_only))?;
706
707 let owner_name = owner.name.to_string();
708 let owner_trace = trace_export(graph, root, file_path, &owner_name)?;
715
716 let (kind_str, filter_flag) = match member_kind {
717 MemberKind::ClassMethod => ("class-method", Some("--unused-class-members")),
718 MemberKind::ClassProperty => ("class-property", Some("--unused-class-members")),
719 MemberKind::EnumMember => ("enum-member", Some("--unused-enum-members")),
720 MemberKind::StoreMember => ("store-member", Some("--unused-store-members")),
721 MemberKind::NamespaceMember => ("namespace-member", None),
722 };
723
724 let reason = class_member_trace_reason(
725 member_name,
726 &owner_name,
727 kind_str,
728 filter_flag,
729 file_path,
730 &owner_trace,
731 );
732
733 Some(ClassMemberTrace {
734 file: owner_trace.file,
735 member_name: member_name.to_string(),
736 member_kind: kind_str.to_string(),
737 owner_export: owner_name,
738 owner_namespace: owner_trace.namespace,
739 owner_is_used: owner_trace.is_used,
740 owner_file_reachable: owner_trace.file_reachable,
741 owner_is_entry_point: owner_trace.is_entry_point,
742 owner_direct_references: owner_trace.direct_references,
743 owner_re_export_chains: owner_trace.re_export_chains,
744 reason,
745 semantic: None,
746 })
747}
748
749fn class_member_trace_reason(
752 member_name: &str,
753 owner_name: &str,
754 kind_str: &str,
755 filter_flag: Option<&str>,
756 file_path: &str,
757 owner_trace: &ExportTrace,
758) -> String {
759 let head =
760 format!("'{member_name}' is a {kind_str} of '{owner_name}', not a top-level export. ");
761 let body = if !owner_trace.file_reachable {
762 format!(
763 "The file is not reachable from any entry point, so '{owner_name}' and all its \
764 members are dead (see the unused-file finding)."
765 )
766 } else if !owner_trace.is_used {
767 format!(
768 "'{owner_name}' is reachable but referenced by no file, so it is reported as an \
769 unused export and its members are not judged individually."
770 )
771 } else {
772 let refs = owner_trace.direct_references.len();
773 match filter_flag {
774 Some(flag) => format!(
775 "'{owner_name}' is used by {refs} file(s); whether '{member_name}' itself is \
776 flagged depends on cross-file member-access resolution. Run \
777 `fallow dead-code {flag} --file {file_path}` to see the member finding."
778 ),
779 None => format!(
780 "'{owner_name}' is used by {refs} file(s); '{member_name}' is credited through \
781 its namespace export."
782 ),
783 }
784 };
785 format!("{head}{body}")
786}
787
788fn select_export<'graph>(
789 graph: &'graph ModuleGraph,
790 module: &'graph crate::graph::ModuleNode,
791 export_name: &str,
792) -> Option<crate::graph::EffectiveExportSurface<'graph>> {
793 [ExportNamespace::Value, ExportNamespace::Type]
794 .into_iter()
795 .find_map(|namespace| {
796 graph.effective_export_surface(module.file_id, export_name, namespace)
797 })
798}
799
800fn traced_exports(
802 graph: &ModuleGraph,
803 root: &Path,
804 module: &crate::graph::ModuleNode,
805) -> Vec<TracedExport> {
806 module
807 .exports
808 .iter()
809 .map(|e| {
810 let referenced_by: Vec<_> = e
811 .physical_references()
812 .map(|r| reference_to_export_reference(graph, root, r))
813 .collect();
814 TracedExport {
815 name: e.name.to_string(),
816 is_type_only: e.is_type_only,
817 reference_count: referenced_by.len(),
818 referenced_by,
819 }
820 })
821 .collect()
822}
823
824fn traced_imports_from(
826 graph: &ModuleGraph,
827 root: &Path,
828 module: &crate::graph::ModuleNode,
829) -> Vec<PathBuf> {
830 graph
831 .edges_for(module.file_id)
832 .iter()
833 .filter_map(|target_id| {
834 graph
835 .modules
836 .get(target_id.0 as usize)
837 .map(|m| m.path.strip_prefix(root).unwrap_or(&m.path).to_path_buf())
838 })
839 .collect()
840}
841
842fn traced_imported_by(
844 graph: &ModuleGraph,
845 root: &Path,
846 module: &crate::graph::ModuleNode,
847) -> Vec<PathBuf> {
848 graph
849 .reverse_deps
850 .get(module.file_id.0 as usize)
851 .map(|deps| {
852 deps.iter()
853 .filter_map(|fid| {
854 graph
855 .modules
856 .get(fid.0 as usize)
857 .map(|m| m.path.strip_prefix(root).unwrap_or(&m.path).to_path_buf())
858 })
859 .collect()
860 })
861 .unwrap_or_default()
862}
863
864fn traced_re_exports(
866 graph: &ModuleGraph,
867 root: &Path,
868 module: &crate::graph::ModuleNode,
869) -> Vec<TracedReExport> {
870 module
871 .re_exports
872 .iter()
873 .map(|re| {
874 let source_path = graph.modules.get(re.source_file.0 as usize).map_or_else(
875 || PathBuf::from(format!("<unknown:{}>", re.source_file.0)),
876 |m| m.path.strip_prefix(root).unwrap_or(&m.path).to_path_buf(),
877 );
878 TracedReExport {
879 source_file: source_path,
880 imported_name: re.imported_name.clone(),
881 exported_name: re.exported_name.clone(),
882 }
883 })
884 .collect()
885}
886
887#[must_use]
889pub fn trace_file(graph: &ModuleGraph, root: &Path, file_path: &str) -> Option<FileTrace> {
890 let module = find_module(graph, root, file_path)?;
891
892 Some(FileTrace {
893 file: module
894 .path
895 .strip_prefix(root)
896 .unwrap_or(&module.path)
897 .to_path_buf(),
898 is_reachable: module.is_reachable(),
899 is_entry_point: module.is_entry_point(),
900 exports: traced_exports(graph, root, module),
901 imports_from: traced_imports_from(graph, root, module),
902 imported_by: traced_imported_by(graph, root, module),
903 re_exports: traced_re_exports(graph, root, module),
904 sources: Vec::new(),
905 })
906}
907
908#[must_use]
917pub fn trace_dependency(
918 graph: &ModuleGraph,
919 root: &Path,
920 package_name: &str,
921 script_used_packages: &FxHashSet<String>,
922) -> DependencyTrace {
923 let imported_by: Vec<PathBuf> = graph
924 .package_usage
925 .get(package_name)
926 .map(|ids| {
927 ids.iter()
928 .filter_map(|fid| {
929 graph
930 .modules
931 .get(fid.0 as usize)
932 .map(|m| m.path.strip_prefix(root).unwrap_or(&m.path).to_path_buf())
933 })
934 .collect()
935 })
936 .unwrap_or_default();
937
938 let type_only_imported_by: Vec<PathBuf> = graph
939 .type_only_package_usage
940 .get(package_name)
941 .map(|ids| {
942 ids.iter()
943 .filter_map(|fid| {
944 graph
945 .modules
946 .get(fid.0 as usize)
947 .map(|m| m.path.strip_prefix(root).unwrap_or(&m.path).to_path_buf())
948 })
949 .collect()
950 })
951 .unwrap_or_default();
952
953 let import_count = imported_by.len();
954 let used_in_scripts = script_used_packages.contains(package_name);
955 DependencyTrace {
956 package_name: package_name.to_string(),
957 imported_by,
958 type_only_imported_by,
959 used_in_scripts,
960 is_used: import_count > 0 || used_in_scripts,
961 import_count,
962 sources: Vec::new(),
963 }
964}
965
966fn format_reference_kind(kind: ReferenceKind) -> String {
967 match kind {
968 ReferenceKind::NamedImport => "named import".to_string(),
969 ReferenceKind::DefaultImport => "default import".to_string(),
970 ReferenceKind::NamespaceImport => "namespace import".to_string(),
971 ReferenceKind::ReExport => "re-export".to_string(),
972 ReferenceKind::DynamicImport => "dynamic import".to_string(),
973 ReferenceKind::SideEffectImport => "side-effect import".to_string(),
974 }
975}
976
977#[must_use]
984pub fn trace_impact_closure(
985 graph: &ModuleGraph,
986 root: &Path,
987 file_path: &str,
988) -> Option<ImpactClosureTrace> {
989 let module = find_module(graph, root, file_path)?;
990
991 let closure = graph.impact_closure(&[module.file_id]);
992 let paths = graph.closure_with_paths(&closure, root);
993
994 let seed = paths
995 .in_diff
996 .first()
997 .cloned()
998 .unwrap_or_else(|| file_path.replace('\\', "/"));
999
1000 let coordination_gap = paths
1001 .coordination_gap
1002 .into_iter()
1003 .map(|gap| ImpactClosureGap {
1004 consumer_file: gap.consumer_file,
1005 consumed_symbols: gap.consumed_symbols,
1006 note: "syntactic attention pointer, not a correctness proof".to_string(),
1007 })
1008 .collect();
1009
1010 Some(ImpactClosureTrace {
1011 seed,
1012 affected_not_shown: paths.affected_not_shown,
1013 coordination_gap,
1014 })
1015}
1016
1017#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1019pub enum ImportPathEndpoint {
1020 From,
1022 To,
1024 AmbiguousFrom,
1026 AmbiguousTo,
1028}
1029
1030impl ImportPathEndpoint {
1031 #[must_use]
1033 pub const fn label(self) -> &'static str {
1034 match self {
1035 Self::From | Self::AmbiguousFrom => "from",
1036 Self::To | Self::AmbiguousTo => "to",
1037 }
1038 }
1039
1040 #[must_use]
1042 pub const fn is_ambiguous(self) -> bool {
1043 matches!(self, Self::AmbiguousFrom | Self::AmbiguousTo)
1044 }
1045}
1046
1047fn import_path_endpoint_index(
1048 graph: &ModuleGraph,
1049 root: &Path,
1050 path: &str,
1051 missing: ImportPathEndpoint,
1052 ambiguous: ImportPathEndpoint,
1053) -> Result<usize, ImportPathEndpoint> {
1054 match matching_module_indexes(graph, root, path).as_slice() {
1055 [index] => Ok(*index),
1056 [] => Err(missing),
1057 _ => Err(ambiguous),
1058 }
1059}
1060
1061pub fn trace_import_path(
1071 graph: &ModuleGraph,
1072 root: &Path,
1073 from_path: &str,
1074 to_path: &str,
1075 eager_only: bool,
1076) -> Result<ImportPathTrace, ImportPathEndpoint> {
1077 let from_index = import_path_endpoint_index(
1078 graph,
1079 root,
1080 from_path,
1081 ImportPathEndpoint::From,
1082 ImportPathEndpoint::AmbiguousFrom,
1083 )?;
1084 let to_index = import_path_endpoint_index(
1085 graph,
1086 root,
1087 to_path,
1088 ImportPathEndpoint::To,
1089 ImportPathEndpoint::AmbiguousTo,
1090 )?;
1091 let from = &graph.modules[from_index];
1092 let to = &graph.modules[to_index];
1093
1094 let from_rel = relativize(&from.path, root);
1095 let to_rel = relativize(&to.path, root);
1096
1097 let route = if eager_only {
1098 graph.shortest_eager_import_path(from.file_id, to.file_id)
1099 } else {
1100 graph.shortest_import_path(from.file_id, to.file_id)
1101 };
1102 let Some(hops) = route else {
1103 let kind = if eager_only {
1104 "eager import path"
1105 } else {
1106 "import path"
1107 };
1108 return Ok(ImportPathTrace {
1109 schema_version: ImportPathTraceSchemaVersion::V1,
1110 reason: format!("no {kind} from {from_rel} to {to_rel}"),
1111 from: from_rel,
1112 to: to_rel,
1113 reachable: false,
1114 hops: 0,
1115 path: Vec::new(),
1116 });
1117 };
1118
1119 if hops.is_empty() {
1120 return Ok(ImportPathTrace {
1121 schema_version: ImportPathTraceSchemaVersion::V1,
1122 reason: format!("{from_rel} is the same module as {to_rel}"),
1123 from: from_rel,
1124 to: to_rel,
1125 reachable: true,
1126 hops: 0,
1127 path: Vec::new(),
1128 });
1129 }
1130
1131 let path = resolve_import_path_hops(graph, root, &hops);
1132 let hop_count = path.len();
1133 let plural = if hop_count == 1 { "hop" } else { "hops" };
1134 let reason = if path.iter().all(|hop| hop.type_only) {
1138 format!(
1139 "{from_rel} reaches {to_rel} in {hop_count} type-only {plural}, erased at build time"
1140 )
1141 } else {
1142 format!("{from_rel} reaches {to_rel} in {hop_count} {plural}")
1143 };
1144
1145 Ok(ImportPathTrace {
1146 schema_version: ImportPathTraceSchemaVersion::V1,
1147 from: from_rel,
1148 to: to_rel,
1149 reachable: true,
1150 hops: hop_count,
1151 path,
1152 reason,
1153 })
1154}
1155
1156fn resolve_import_path_hops(
1160 graph: &ModuleGraph,
1161 root: &Path,
1162 hops: &[GraphImportPathHop],
1163) -> Vec<ImportPathHop> {
1164 let mut line_offsets: FxHashMap<FileId, Option<Vec<u32>>> = FxHashMap::default();
1165 hops.iter()
1166 .filter_map(|hop| {
1167 let from = graph.modules.get(hop.from.0 as usize)?;
1168 let to = graph.modules.get(hop.to.0 as usize)?;
1169 let import_line = hop.import_span_start.and_then(|span_start| {
1170 line_offsets
1171 .entry(hop.from)
1172 .or_insert_with(|| {
1173 std::fs::read_to_string(&from.path)
1174 .ok()
1175 .map(|source| fallow_types::extract::compute_line_offsets(&source))
1176 })
1177 .as_ref()
1178 .map(|offsets| {
1179 fallow_types::extract::byte_offset_to_line_col(offsets, span_start).0
1180 })
1181 });
1182 Some(ImportPathHop {
1183 from: relativize(&from.path, root),
1184 to: relativize(&to.path, root),
1185 type_only: hop.all_type_only,
1186 dynamic: hop.dynamic,
1187 import_line,
1188 })
1189 })
1190 .collect()
1191}
1192
1193pub fn relativize(path: &Path, root: &Path) -> String {
1195 path.strip_prefix(root)
1196 .unwrap_or(path)
1197 .to_string_lossy()
1198 .replace('\\', "/")
1199}
1200
1201fn build_traced_group(
1205 group: &CloneGroup,
1206 root: &Path,
1207 fingerprints: &CloneFingerprintSet,
1208) -> TracedCloneGroup {
1209 TracedCloneGroup {
1210 fingerprint: fingerprints.fingerprint_for_group(group),
1211 token_count: group.token_count,
1212 line_count: group.line_count,
1213 spread: group.spread(),
1214 similarity: group.similarity,
1215 instances: group
1216 .instances
1217 .iter()
1218 .map(|inst| relativize_instance(inst, root))
1219 .collect(),
1220 suggestion: group_refactoring_suggestion(group),
1221 suggested_name: dominant_identifier(group),
1222 }
1223}
1224
1225#[must_use]
1226pub fn trace_clone(
1227 report: &DuplicationReport,
1228 root: &Path,
1229 file_path: &str,
1230 line: usize,
1231) -> CloneTrace {
1232 let resolved = root.join(file_path);
1233 let mut matched_instance = None;
1234 let mut clone_groups = Vec::new();
1235 let fingerprints = CloneFingerprintSet::from_groups(&report.clone_groups);
1236
1237 for group in &report.clone_groups {
1238 let matching = group.instances.iter().find(|inst| {
1239 let inst_matches = inst.file == resolved
1240 || inst.file.strip_prefix(root).unwrap_or(&inst.file) == Path::new(file_path);
1241 inst_matches && inst.start_line <= line && line <= inst.end_line
1242 });
1243
1244 if let Some(matched) = matching {
1245 if matched_instance.is_none() {
1246 matched_instance = Some(relativize_instance(matched, root));
1247 }
1248 clone_groups.push(build_traced_group(group, root, &fingerprints));
1249 }
1250 }
1251
1252 CloneTrace {
1253 file: PathBuf::from(file_path),
1254 line,
1255 matched_instance,
1256 clone_groups,
1257 }
1258}
1259
1260#[must_use]
1270pub fn trace_clone_by_fingerprint(
1271 report: &DuplicationReport,
1272 root: &Path,
1273 fingerprint: &str,
1274) -> CloneTrace {
1275 let fingerprints = CloneFingerprintSet::from_groups(&report.clone_groups);
1276 let matched = fingerprints.find_group(&report.clone_groups, fingerprint);
1277
1278 let Some(group) = matched else {
1279 return CloneTrace {
1280 file: PathBuf::new(),
1281 line: 0,
1282 matched_instance: None,
1283 clone_groups: Vec::new(),
1284 };
1285 };
1286
1287 let representative = group
1288 .instances
1289 .first()
1290 .map(|inst| relativize_instance(inst, root));
1291 let (file, line) = representative.as_ref().map_or_else(
1292 || (PathBuf::new(), 0),
1293 |inst| (inst.file.clone(), inst.start_line),
1294 );
1295
1296 CloneTrace {
1297 file,
1298 line,
1299 matched_instance: representative,
1300 clone_groups: vec![build_traced_group(group, root, &fingerprints)],
1301 }
1302}
1303
1304fn relativize_instance(inst: &CloneInstance, root: &Path) -> CloneInstance {
1308 let rel = inst.file.strip_prefix(root).map_or_else(
1309 |_| inst.file.clone(),
1310 |p| PathBuf::from(p.to_string_lossy().replace('\\', "/")),
1311 );
1312 CloneInstance {
1313 file: rel,
1314 ..inst.clone()
1315 }
1316}
1317
1318#[cfg(test)]
1319mod tests {
1320 use super::*;
1321
1322 use crate::discover::{DiscoveredFile, EntryPoint, EntryPointSource, FileId};
1323 use fallow_graph::resolve::{ResolveResult, ResolvedImport, ResolvedModule, ResolvedReExport};
1324 use fallow_types::extract::ReExportInfo;
1325 use fallow_types::extract::{ExportInfo, ExportName, ImportInfo, ImportedName, VisibilityTag};
1326
1327 fn resolved_re_export(
1328 source: FileId,
1329 imported_name: &str,
1330 exported_name: &str,
1331 ) -> ResolvedReExport {
1332 ResolvedReExport {
1333 info: ReExportInfo {
1334 source: "./source".to_string(),
1335 imported_name: imported_name.to_string(),
1336 exported_name: exported_name.to_string(),
1337 is_type_only: false,
1338 span: oxc_span::Span::default(),
1339 statement_span: oxc_span::Span::default(),
1340 source_span: oxc_span::Span::default(),
1341 },
1342 target: ResolveResult::InternalModule(source),
1343 }
1344 }
1345
1346 fn build_test_graph() -> ModuleGraph {
1347 let files = vec![
1348 DiscoveredFile {
1349 id: FileId(0),
1350 path: PathBuf::from("/project/src/entry.ts"),
1351 size_bytes: 100,
1352 },
1353 DiscoveredFile {
1354 id: FileId(1),
1355 path: PathBuf::from("/project/src/utils.ts"),
1356 size_bytes: 50,
1357 },
1358 DiscoveredFile {
1359 id: FileId(2),
1360 path: PathBuf::from("/project/src/unused.ts"),
1361 size_bytes: 30,
1362 },
1363 ];
1364
1365 let entry_points = vec![EntryPoint {
1366 path: PathBuf::from("/project/src/entry.ts"),
1367 source: EntryPointSource::PackageJsonMain,
1368 }];
1369
1370 let resolved_modules = vec![
1371 ResolvedModule {
1372 file_id: FileId(0),
1373 path: PathBuf::from("/project/src/entry.ts"),
1374 resolved_imports: vec![ResolvedImport {
1375 info: ImportInfo {
1376 source: "./utils".to_string(),
1377 imported_name: ImportedName::Named("foo".to_string()),
1378 local_name: "foo".to_string(),
1379 is_type_only: false,
1380 is_type_only_star: false,
1381 from_style: false,
1382 span: oxc_span::Span::new(0, 10),
1383 source_span: oxc_span::Span::default(),
1384 },
1385 target: ResolveResult::InternalModule(FileId(1)),
1386 }],
1387 ..Default::default()
1388 },
1389 ResolvedModule {
1390 file_id: FileId(1),
1391 path: PathBuf::from("/project/src/utils.ts"),
1392 exports: vec![
1393 ExportInfo {
1394 name: ExportName::Named("foo".to_string()),
1395 local_name: Some("foo".to_string()),
1396 is_type_only: false,
1397 visibility: VisibilityTag::None,
1398 expected_unused_reason: None,
1399 span: oxc_span::Span::new(0, 20),
1400 members: vec![],
1401 is_side_effect_used: false,
1402 super_class: None,
1403 deprecated: false,
1404 deprecated_reason: None,
1405 },
1406 ExportInfo {
1407 name: ExportName::Named("bar".to_string()),
1408 local_name: Some("bar".to_string()),
1409 is_type_only: false,
1410 visibility: VisibilityTag::None,
1411 expected_unused_reason: None,
1412 span: oxc_span::Span::new(21, 40),
1413 members: vec![],
1414 is_side_effect_used: false,
1415 super_class: None,
1416 deprecated: false,
1417 deprecated_reason: None,
1418 },
1419 ]
1420 .into(),
1421 ..Default::default()
1422 },
1423 ResolvedModule {
1424 file_id: FileId(2),
1425 path: PathBuf::from("/project/src/unused.ts"),
1426 exports: vec![ExportInfo {
1427 name: ExportName::Named("baz".to_string()),
1428 local_name: Some("baz".to_string()),
1429 is_type_only: false,
1430 visibility: VisibilityTag::None,
1431 expected_unused_reason: None,
1432 span: oxc_span::Span::new(0, 15),
1433 members: vec![],
1434 is_side_effect_used: false,
1435 super_class: None,
1436 deprecated: false,
1437 deprecated_reason: None,
1438 }]
1439 .into(),
1440 ..Default::default()
1441 },
1442 ];
1443
1444 ModuleGraph::build(&resolved_modules, &entry_points, &files)
1445 }
1446
1447 #[test]
1448 fn trace_used_export() {
1449 let graph = build_test_graph();
1450 let root = Path::new("/project");
1451
1452 let trace = trace_export(&graph, root, "src/utils.ts", "foo").unwrap();
1453 assert!(trace.is_used);
1454 assert!(trace.file_reachable);
1455 assert_eq!(trace.direct_references.len(), 1);
1456 assert_eq!(
1457 trace.direct_references[0].from_file,
1458 PathBuf::from("src/entry.ts")
1459 );
1460 assert_eq!(trace.direct_references[0].kind, "named import");
1461 }
1462
1463 #[test]
1464 fn trace_unused_export() {
1465 let graph = build_test_graph();
1466 let root = Path::new("/project");
1467
1468 let trace = trace_export(&graph, root, "src/utils.ts", "bar").unwrap();
1469 assert!(!trace.is_used);
1470 assert!(trace.file_reachable);
1471 assert!(trace.direct_references.is_empty());
1472 assert_eq!(
1473 trace.namespace,
1474 fallow_types::semantic::SemanticNamespace::Value,
1475 "an unreferenced value export stays in the value namespace"
1476 );
1477 }
1478
1479 #[test]
1480 fn trace_unreachable_file_export() {
1481 let graph = build_test_graph();
1482 let root = Path::new("/project");
1483
1484 let trace = trace_export(&graph, root, "src/unused.ts", "baz").unwrap();
1485 assert!(!trace.is_used);
1486 assert!(!trace.file_reachable);
1487 assert!(trace.reason.contains("unreachable"));
1488 }
1489
1490 #[test]
1491 fn trace_nonexistent_export() {
1492 let graph = build_test_graph();
1493 let root = Path::new("/project");
1494
1495 let trace = trace_export(&graph, root, "src/utils.ts", "nonexistent");
1496 assert!(trace.is_none());
1497 }
1498
1499 #[test]
1500 fn trace_reports_only_the_effective_re_export_origin() {
1501 let files: Vec<_> = ["entry", "barrel", "star-source", "explicit-source"]
1502 .into_iter()
1503 .enumerate()
1504 .map(|(index, name)| DiscoveredFile {
1505 id: FileId(index as u32),
1506 path: PathBuf::from(format!("/project/src/{name}.ts")),
1507 size_bytes: 10,
1508 })
1509 .collect();
1510 let entry_points = vec![EntryPoint {
1511 path: files[0].path.clone(),
1512 source: EntryPointSource::PackageJsonMain,
1513 }];
1514 let re_export = |source: FileId, imported: &str, exported: &str| ResolvedReExport {
1515 info: ReExportInfo {
1516 source: format!("./{}", source.0),
1517 imported_name: imported.to_string(),
1518 exported_name: exported.to_string(),
1519 is_type_only: false,
1520 span: oxc_span::Span::default(),
1521 statement_span: oxc_span::Span::default(),
1522 source_span: oxc_span::Span::default(),
1523 },
1524 target: ResolveResult::InternalModule(source),
1525 };
1526 let export = || ExportInfo {
1527 name: ExportName::Named("foo".to_string()),
1528 local_name: Some("foo".to_string()),
1529 is_type_only: false,
1530 visibility: VisibilityTag::None,
1531 expected_unused_reason: None,
1532 span: oxc_span::Span::new(0, 3),
1533 members: Vec::new(),
1534 is_side_effect_used: false,
1535 super_class: None,
1536 deprecated: false,
1537 deprecated_reason: None,
1538 };
1539 let resolved = vec![
1540 ResolvedModule {
1541 file_id: FileId(0),
1542 path: files[0].path.clone(),
1543 resolved_imports: vec![ResolvedImport {
1544 info: ImportInfo {
1545 source: "./barrel".to_string(),
1546 imported_name: ImportedName::Named("foo".to_string()),
1547 local_name: "foo".to_string(),
1548 is_type_only: false,
1549 is_type_only_star: false,
1550 from_style: false,
1551 span: oxc_span::Span::default(),
1552 source_span: oxc_span::Span::default(),
1553 },
1554 target: ResolveResult::InternalModule(FileId(1)),
1555 }],
1556 ..Default::default()
1557 },
1558 ResolvedModule {
1559 file_id: FileId(1),
1560 path: files[1].path.clone(),
1561 re_exports: vec![
1562 re_export(FileId(2), "*", "*"),
1563 re_export(FileId(3), "foo", "foo"),
1564 ],
1565 ..Default::default()
1566 },
1567 ResolvedModule {
1568 file_id: FileId(2),
1569 path: files[2].path.clone(),
1570 exports: vec![export()].into(),
1571 ..Default::default()
1572 },
1573 ResolvedModule {
1574 file_id: FileId(3),
1575 path: files[3].path.clone(),
1576 exports: vec![export()].into(),
1577 ..Default::default()
1578 },
1579 ];
1580 let graph = ModuleGraph::build(&resolved, &entry_points, &files);
1581
1582 let shadowed = trace_export(&graph, Path::new("/project"), "src/star-source.ts", "foo")
1583 .expect("shadowed source export exists");
1584 let effective = trace_export(
1585 &graph,
1586 Path::new("/project"),
1587 "src/explicit-source.ts",
1588 "foo",
1589 )
1590 .expect("effective source export exists");
1591
1592 assert!(shadowed.re_export_chains.is_empty());
1593 assert_eq!(effective.re_export_chains.len(), 1);
1594 assert_eq!(effective.re_export_chains[0].exported_as, "foo");
1595 }
1596
1597 fn star_surface_trace_graph(root: &Path) -> ModuleGraph {
1598 let src = root.join("src");
1599 std::fs::create_dir_all(&src).expect("create source directory");
1600 let paths: Vec<_> = ["source", "barrel-a", "barrel-b", "outer", "entry"]
1601 .into_iter()
1602 .map(|name| src.join(format!("{name}.ts")))
1603 .collect();
1604 std::fs::write(&paths[0], "\n\nexport const foo = 1;\n").expect("write source");
1605 for path in &paths[1..] {
1606 std::fs::write(path, "export {};\n").expect("write module");
1607 }
1608 let files: Vec<_> = paths
1609 .iter()
1610 .enumerate()
1611 .map(|(index, path)| DiscoveredFile {
1612 id: FileId(index as u32),
1613 path: path.clone(),
1614 size_bytes: 20,
1615 })
1616 .collect();
1617 let resolved = vec![
1618 ResolvedModule {
1619 file_id: FileId(0),
1620 path: paths[0].clone(),
1621 exports: vec![ExportInfo {
1622 name: ExportName::Named("foo".to_string()),
1623 local_name: Some("foo".to_string()),
1624 is_type_only: false,
1625 visibility: VisibilityTag::None,
1626 expected_unused_reason: None,
1627 span: oxc_span::Span::new(2, 5),
1628 members: Vec::new(),
1629 is_side_effect_used: false,
1630 super_class: None,
1631 deprecated: false,
1632 deprecated_reason: None,
1633 }]
1634 .into(),
1635 ..Default::default()
1636 },
1637 ResolvedModule {
1638 file_id: FileId(1),
1639 path: paths[1].clone(),
1640 re_exports: vec![resolved_re_export(FileId(0), "*", "*")],
1641 ..Default::default()
1642 },
1643 ResolvedModule {
1644 file_id: FileId(2),
1645 path: paths[2].clone(),
1646 re_exports: vec![resolved_re_export(FileId(0), "*", "*")],
1647 ..Default::default()
1648 },
1649 ResolvedModule {
1650 file_id: FileId(3),
1651 path: paths[3].clone(),
1652 re_exports: vec![
1653 resolved_re_export(FileId(1), "foo", "left"),
1654 resolved_re_export(FileId(1), "foo", "right"),
1655 ],
1656 ..Default::default()
1657 },
1658 ResolvedModule {
1659 file_id: FileId(4),
1660 path: paths[4].clone(),
1661 resolved_imports: vec![
1662 ResolvedImport {
1663 info: ImportInfo {
1664 source: "./outer".to_string(),
1665 imported_name: ImportedName::Named("left".to_string()),
1666 local_name: "left".to_string(),
1667 is_type_only: false,
1668 is_type_only_star: false,
1669 from_style: false,
1670 span: oxc_span::Span::new(10, 20),
1671 source_span: oxc_span::Span::default(),
1672 },
1673 target: ResolveResult::InternalModule(FileId(3)),
1674 },
1675 ResolvedImport {
1676 info: ImportInfo {
1677 source: "./barrel-b".to_string(),
1678 imported_name: ImportedName::Named("foo".to_string()),
1679 local_name: "otherFoo".to_string(),
1680 is_type_only: false,
1681 is_type_only_star: false,
1682 from_style: false,
1683 span: oxc_span::Span::new(30, 40),
1684 source_span: oxc_span::Span::default(),
1685 },
1686 target: ResolveResult::InternalModule(FileId(2)),
1687 },
1688 ],
1689 ..Default::default()
1690 },
1691 ];
1692 let entry_points = vec![EntryPoint {
1693 path: paths[4].clone(),
1694 source: EntryPointSource::PackageJsonMain,
1695 }];
1696 ModuleGraph::build(&resolved, &entry_points, &files)
1697 }
1698
1699 #[test]
1700 fn star_surface_trace_keeps_aliases_separate_and_uses_origin_identity() {
1701 let root = tempfile::tempdir().expect("temporary project");
1702 let graph = star_surface_trace_graph(root.path());
1703
1704 let used = trace_export(&graph, root.path(), "src/barrel-a.ts", "foo")
1705 .expect("aliased barrel exposes foo");
1706 let sibling = trace_export(&graph, root.path(), "src/barrel-b.ts", "foo")
1707 .expect("sibling barrel exposes foo");
1708 assert!(used.is_used);
1709 assert_eq!(used.direct_references.len(), 1);
1710 assert!(sibling.is_used);
1711 assert_eq!(sibling.direct_references.len(), 1);
1712
1713 let source_trace = trace_export(&graph, root.path(), "src/source.ts", "foo")
1714 .expect("source declaration is traceable");
1715 let chain_count = |file: &str, name: &str| {
1716 source_trace
1717 .re_export_chains
1718 .iter()
1719 .find(|chain| chain.barrel_file == Path::new(file) && chain.exported_as == name)
1720 .map(|chain| chain.reference_count)
1721 };
1722 assert_eq!(chain_count("src/barrel-a.ts", "foo"), Some(1));
1723 assert_eq!(chain_count("src/barrel-b.ts", "foo"), Some(1));
1724 assert_eq!(chain_count("src/outer.ts", "left"), Some(1));
1725 assert_eq!(chain_count("src/outer.ts", "right"), Some(0));
1726
1727 let semantic = semantic_symbol_for_export(&graph, root.path(), "src/barrel-a.ts", "foo")
1728 .expect("star surface resolves to its declaration identity");
1729 assert_eq!(semantic.path, Path::new("src/source.ts"));
1730 assert_eq!(semantic.exported_name, "foo");
1731 assert_eq!(semantic.local_name, "foo");
1732 assert_eq!((semantic.line, semantic.col), (3, 0));
1733
1734 let alias = semantic_symbol_for_export(&graph, root.path(), "src/outer.ts", "left")
1735 .expect("named re-export keeps its export-specifier identity");
1736 assert_eq!(alias.path, Path::new("src/outer.ts"));
1737 assert_eq!(alias.exported_name, "left");
1738 assert_eq!(alias.local_name, "foo");
1739 }
1740
1741 #[test]
1742 fn trace_follows_renamed_and_convergent_re_export_routes() {
1743 let names = [
1744 "source",
1745 "renamed",
1746 "final",
1747 "left",
1748 "right",
1749 "diamond-entry",
1750 ];
1751 let files: Vec<_> = names
1752 .into_iter()
1753 .enumerate()
1754 .map(|(index, name)| DiscoveredFile {
1755 id: FileId(index as u32),
1756 path: PathBuf::from(format!("/project/src/{name}.ts")),
1757 size_bytes: 10,
1758 })
1759 .collect();
1760 let re_export = |source: FileId, imported: &str, exported: &str| ResolvedReExport {
1761 info: ReExportInfo {
1762 source: format!("./{}", source.0),
1763 imported_name: imported.to_string(),
1764 exported_name: exported.to_string(),
1765 is_type_only: false,
1766 span: oxc_span::Span::default(),
1767 statement_span: oxc_span::Span::default(),
1768 source_span: oxc_span::Span::default(),
1769 },
1770 target: ResolveResult::InternalModule(source),
1771 };
1772 let mut resolved: Vec<_> = files
1773 .iter()
1774 .map(|file| ResolvedModule {
1775 file_id: file.id,
1776 path: file.path.clone(),
1777 ..Default::default()
1778 })
1779 .collect();
1780 resolved[0].exports = vec![ExportInfo {
1781 name: ExportName::Named("foo".to_string()),
1782 local_name: Some("foo".to_string()),
1783 is_type_only: false,
1784 visibility: VisibilityTag::None,
1785 expected_unused_reason: None,
1786 span: oxc_span::Span::new(0, 3),
1787 members: Vec::new(),
1788 is_side_effect_used: false,
1789 super_class: None,
1790 deprecated: false,
1791 deprecated_reason: None,
1792 }]
1793 .into();
1794 resolved[1].re_exports = vec![re_export(FileId(0), "foo", "bar")];
1795 resolved[2].re_exports = vec![re_export(FileId(1), "bar", "baz")];
1796 resolved[3].re_exports = vec![re_export(FileId(0), "*", "*")];
1797 resolved[4].re_exports = vec![re_export(FileId(0), "*", "*")];
1798 resolved[5].re_exports = vec![
1799 re_export(FileId(3), "*", "*"),
1800 re_export(FileId(4), "*", "*"),
1801 ];
1802 let entry_points = vec![EntryPoint {
1803 path: files[5].path.clone(),
1804 source: EntryPointSource::PackageJsonMain,
1805 }];
1806 let graph = ModuleGraph::build(&resolved, &entry_points, &files);
1807
1808 let trace = trace_export(&graph, Path::new("/project"), "src/source.ts", "foo")
1809 .expect("source export exists");
1810 let routes: FxHashSet<_> = trace
1811 .re_export_chains
1812 .iter()
1813 .map(|route| (route.barrel_file.as_path(), route.exported_as.as_str()))
1814 .collect();
1815
1816 assert_eq!(routes.len(), 5);
1817 assert!(routes.contains(&(Path::new("src/renamed.ts"), "bar")));
1818 assert!(routes.contains(&(Path::new("src/final.ts"), "baz")));
1819 assert!(routes.contains(&(Path::new("src/left.ts"), "foo")));
1820 assert!(routes.contains(&(Path::new("src/right.ts"), "foo")));
1821 assert!(routes.contains(&(Path::new("src/diamond-entry.ts"), "foo")));
1822 }
1823
1824 #[test]
1825 fn trace_prefers_the_value_namespace_independent_of_usage() {
1826 let files = vec![
1827 DiscoveredFile {
1828 id: FileId(0),
1829 path: PathBuf::from("/project/src/entry.ts"),
1830 size_bytes: 10,
1831 },
1832 DiscoveredFile {
1833 id: FileId(1),
1834 path: PathBuf::from("/project/src/source.ts"),
1835 size_bytes: 10,
1836 },
1837 ];
1838 let export = |is_type_only| ExportInfo {
1839 name: ExportName::Named("Foo".to_string()),
1840 local_name: Some("Foo".to_string()),
1841 is_type_only,
1842 visibility: VisibilityTag::None,
1843 expected_unused_reason: None,
1844 span: oxc_span::Span::new(0, 3),
1845 members: Vec::new(),
1846 is_side_effect_used: false,
1847 super_class: None,
1848 deprecated: false,
1849 deprecated_reason: None,
1850 };
1851 let resolved = vec![
1852 ResolvedModule {
1853 file_id: FileId(0),
1854 path: files[0].path.clone(),
1855 resolved_imports: vec![ResolvedImport {
1856 info: ImportInfo {
1857 source: "./source".to_string(),
1858 imported_name: ImportedName::Named("Foo".to_string()),
1859 local_name: "Foo".to_string(),
1860 is_type_only: true,
1861 is_type_only_star: false,
1862 from_style: false,
1863 span: oxc_span::Span::default(),
1864 source_span: oxc_span::Span::default(),
1865 },
1866 target: ResolveResult::InternalModule(FileId(1)),
1867 }],
1868 ..Default::default()
1869 },
1870 ResolvedModule {
1871 file_id: FileId(1),
1872 path: files[1].path.clone(),
1873 exports: vec![export(false), export(true)].into(),
1874 ..Default::default()
1875 },
1876 ];
1877 let entry_points = vec![EntryPoint {
1878 path: files[0].path.clone(),
1879 source: EntryPointSource::PackageJsonMain,
1880 }];
1881 let graph = ModuleGraph::build(&resolved, &entry_points, &files);
1882
1883 let trace = trace_export(&graph, Path::new("/project"), "src/source.ts", "Foo")
1884 .expect("value export exists");
1885
1886 assert_eq!(
1894 trace.namespace,
1895 fallow_types::semantic::SemanticNamespace::Value
1896 );
1897 assert!(!trace.is_used, "type usage must not select the type export");
1898 }
1899
1900 fn source_consumer_graph(
1903 source_exports: Vec<ExportInfo>,
1904 import_is_type_only: bool,
1905 classified_usage: bool,
1906 semantic_facts: Vec<fallow_types::extract::SemanticFact>,
1907 ) -> ModuleGraph {
1908 let files = vec![
1909 DiscoveredFile {
1910 id: FileId(0),
1911 path: PathBuf::from("/project/src/entry.ts"),
1912 size_bytes: 10,
1913 },
1914 DiscoveredFile {
1915 id: FileId(1),
1916 path: PathBuf::from("/project/src/source.ts"),
1917 size_bytes: 10,
1918 },
1919 ];
1920 let classified = if classified_usage {
1921 vec!["Foo".to_string()]
1922 } else {
1923 Vec::new()
1924 };
1925 let resolved = vec![
1926 ResolvedModule {
1927 file_id: FileId(0),
1928 path: files[0].path.clone(),
1929 resolved_imports: vec![ResolvedImport {
1930 info: ImportInfo {
1931 source: "./source".to_string(),
1932 imported_name: ImportedName::Named("Foo".to_string()),
1933 local_name: "Foo".to_string(),
1934 is_type_only: import_is_type_only,
1935 is_type_only_star: false,
1936 from_style: false,
1937 span: oxc_span::Span::new(0, 10),
1938 source_span: oxc_span::Span::default(),
1939 },
1940 target: ResolveResult::InternalModule(FileId(1)),
1941 }],
1942 type_referenced_import_bindings: classified.clone(),
1943 value_referenced_import_bindings: classified,
1944 ..Default::default()
1945 },
1946 ResolvedModule {
1947 file_id: FileId(1),
1948 path: files[1].path.clone(),
1949 exports: source_exports.into(),
1950 semantic_facts: semantic_facts.into(),
1951 ..Default::default()
1952 },
1953 ];
1954 let entry_points = vec![EntryPoint {
1955 path: files[0].path.clone(),
1956 source: EntryPointSource::PackageJsonMain,
1957 }];
1958 ModuleGraph::build(&resolved, &entry_points, &files)
1959 }
1960
1961 fn dual_lane_consumer_graph(source_exports: Vec<ExportInfo>) -> ModuleGraph {
1965 let files = vec![
1966 DiscoveredFile {
1967 id: FileId(0),
1968 path: PathBuf::from("/project/src/entry.ts"),
1969 size_bytes: 10,
1970 },
1971 DiscoveredFile {
1972 id: FileId(1),
1973 path: PathBuf::from("/project/src/source.ts"),
1974 size_bytes: 10,
1975 },
1976 DiscoveredFile {
1977 id: FileId(2),
1978 path: PathBuf::from("/project/src/typed.ts"),
1979 size_bytes: 10,
1980 },
1981 ];
1982 let consumer = |file_id: FileId, path: PathBuf, is_type_only: bool| ResolvedModule {
1983 file_id,
1984 path,
1985 resolved_imports: vec![ResolvedImport {
1986 info: ImportInfo {
1987 source: "./source".to_string(),
1988 imported_name: ImportedName::Named("Foo".to_string()),
1989 local_name: "Foo".to_string(),
1990 is_type_only,
1991 is_type_only_star: false,
1992 from_style: false,
1993 span: oxc_span::Span::new(0, 10),
1994 source_span: oxc_span::Span::default(),
1995 },
1996 target: ResolveResult::InternalModule(FileId(1)),
1997 }],
1998 ..Default::default()
1999 };
2000 let resolved = vec![
2001 consumer(FileId(0), files[0].path.clone(), false),
2002 ResolvedModule {
2003 file_id: FileId(1),
2004 path: files[1].path.clone(),
2005 exports: source_exports.into(),
2006 ..Default::default()
2007 },
2008 consumer(FileId(2), files[2].path.clone(), true),
2009 ];
2010 let entry_points = vec![
2011 EntryPoint {
2012 path: files[0].path.clone(),
2013 source: EntryPointSource::PackageJsonMain,
2014 },
2015 EntryPoint {
2016 path: files[2].path.clone(),
2017 source: EntryPointSource::PackageJsonMain,
2018 },
2019 ];
2020 ModuleGraph::build(&resolved, &entry_points, &files)
2021 }
2022
2023 fn named_foo_export(is_type_only: bool) -> ExportInfo {
2024 ExportInfo {
2025 name: ExportName::Named("Foo".to_string()),
2026 local_name: Some("Foo".to_string()),
2027 is_type_only,
2028 visibility: VisibilityTag::None,
2029 expected_unused_reason: None,
2030 span: oxc_span::Span::new(0, 3),
2031 members: Vec::new(),
2032 is_side_effect_used: false,
2033 super_class: None,
2034 deprecated: false,
2035 deprecated_reason: None,
2036 }
2037 }
2038
2039 #[test]
2040 fn trace_credits_a_value_only_export_through_the_type_lane() {
2041 let graph = source_consumer_graph(vec![named_foo_export(false)], true, false, Vec::new());
2045
2046 let trace = trace_export(&graph, Path::new("/project"), "src/source.ts", "Foo")
2047 .expect("value export exists");
2048
2049 assert_eq!(
2050 trace.namespace,
2051 fallow_types::semantic::SemanticNamespace::Type,
2052 "the type lane carries the only credit"
2053 );
2054 assert!(
2055 trace.is_used,
2056 "a type-only import credits a value-only export"
2057 );
2058 assert_eq!(trace.direct_references.len(), 1);
2059 assert_eq!(
2060 trace.direct_references[0].from_file,
2061 PathBuf::from("src/entry.ts")
2062 );
2063 assert_eq!(trace.direct_references[0].kind, "named import");
2064 assert_eq!(trace.reason, "Used by 1 file(s)");
2065 }
2066
2067 #[test]
2068 fn trace_omits_redundant_single_namespace_evidence() {
2069 let graph = source_consumer_graph(vec![named_foo_export(false)], false, false, Vec::new());
2070
2071 let trace = trace_export(&graph, Path::new("/project"), "src/source.ts", "Foo")
2072 .expect("value export exists");
2073
2074 assert_eq!(
2075 trace.namespace,
2076 fallow_types::semantic::SemanticNamespace::Value
2077 );
2078 assert_eq!(trace.direct_references.len(), 1);
2079 assert!(trace.direct_references_by_namespace.is_empty());
2080 let json = serde_json::to_value(&trace).expect("serialize trace");
2081 assert!(json.get("direct_references_by_namespace").is_none());
2082 }
2083
2084 #[test]
2085 fn trace_keeps_the_value_lane_when_one_binding_carries_both_lanes() {
2086 let graph = dual_lane_consumer_graph(vec![named_foo_export(false)]);
2092
2093 let trace = trace_export(&graph, Path::new("/project"), "src/source.ts", "Foo")
2094 .expect("value export exists");
2095
2096 assert_eq!(
2097 trace.namespace,
2098 fallow_types::semantic::SemanticNamespace::Value
2099 );
2100 assert!(trace.is_used);
2101 assert_eq!(trace.direct_references.len(), 1);
2102 assert_eq!(
2103 trace.direct_references[0].from_file,
2104 PathBuf::from("src/entry.ts"),
2105 "the value consumer stays the listed reference"
2106 );
2107 assert_eq!(trace.direct_references_by_namespace.len(), 2);
2108 assert!(trace.direct_references_by_namespace.iter().any(|lane| {
2109 lane.namespace == fallow_types::semantic::SemanticNamespace::Type
2110 && lane.reference_count == 1
2111 && lane.references[0].from_file == Path::new("src/typed.ts")
2112 }));
2113 }
2114
2115 #[test]
2116 fn trace_credits_a_declaration_merge_that_stays_one_binding() {
2117 let graph = source_consumer_graph(
2121 vec![named_foo_export(false), named_foo_export(false)],
2122 true,
2123 false,
2124 Vec::new(),
2125 );
2126
2127 let trace = trace_export(&graph, Path::new("/project"), "src/source.ts", "Foo")
2128 .expect("value export exists");
2129
2130 assert_eq!(
2131 trace.namespace,
2132 fallow_types::semantic::SemanticNamespace::Type,
2133 "the merged binding is reachable from the type lane"
2134 );
2135 assert!(trace.is_used);
2136 assert_eq!(trace.direct_references.len(), 1);
2137 assert_eq!(
2138 trace.direct_references[0].from_file,
2139 PathBuf::from("src/entry.ts")
2140 );
2141 }
2142
2143 #[test]
2144 fn trace_credits_a_class_interface_declaration_merge() {
2145 let mut interface = named_foo_export(true);
2146 interface.span = oxc_span::Span::new(0, 3);
2147 let mut class = named_foo_export(false);
2148 class.span = oxc_span::Span::new(4, 7);
2149 let graph = source_consumer_graph(
2150 vec![interface, class],
2151 true,
2152 true,
2153 vec![fallow_types::extract::SemanticFact::DeclarationMerge(
2154 fallow_types::extract::DeclarationMergeFact {
2155 export_spans: vec![(0, 3), (4, 7)],
2156 },
2157 )],
2158 );
2159
2160 let trace = trace_export(&graph, Path::new("/project"), "src/source.ts", "Foo")
2161 .expect("merged class export exists");
2162
2163 assert_eq!(
2164 trace.namespace,
2165 fallow_types::semantic::SemanticNamespace::Type
2166 );
2167 assert!(trace.is_used);
2168 assert_eq!(trace.direct_references.len(), 1);
2169 }
2170
2171 #[test]
2172 fn trace_keeps_the_value_namespace_when_lanes_hold_distinct_bindings() {
2173 let graph = source_consumer_graph(
2180 vec![named_foo_export(false), named_foo_export(true)],
2181 false,
2182 true,
2183 Vec::new(),
2184 );
2185
2186 let trace = trace_export(&graph, Path::new("/project"), "src/source.ts", "Foo")
2187 .expect("value export exists");
2188
2189 assert_eq!(
2190 trace.namespace,
2191 fallow_types::semantic::SemanticNamespace::Value
2192 );
2193 assert!(trace.is_used);
2194 assert_eq!(trace.direct_references.len(), 1);
2195 assert_eq!(
2196 trace.direct_references[0].from_file,
2197 PathBuf::from("src/entry.ts")
2198 );
2199 }
2200
2201 #[test]
2202 fn class_member_trace_inherits_the_type_lane_credit_of_its_owner() {
2203 use fallow_types::extract::{MemberInfo, MemberKind};
2204
2205 let mut owner = named_foo_export(false);
2209 owner.members = vec![MemberInfo {
2210 name: "run".to_string(),
2211 kind: MemberKind::ClassMethod,
2212 span: oxc_span::Span::new(0, 3),
2213 has_decorator: false,
2214 decorator_names: vec![],
2215 is_instance_returning_static: false,
2216 is_self_returning: false,
2217 }];
2218 let graph = source_consumer_graph(vec![owner], true, false, Vec::new());
2219
2220 let trace = trace_class_member(&graph, Path::new("/project"), "src/source.ts", "run")
2221 .expect("member of the traced export");
2222
2223 assert!(trace.owner_is_used, "the type lane credits the owner");
2224 assert_eq!(
2225 trace.owner_namespace,
2226 fallow_types::semantic::SemanticNamespace::Type,
2227 "the member payload names the lane that credits its owner"
2228 );
2229 assert_eq!(trace.owner_direct_references.len(), 1);
2230 assert_eq!(
2231 trace.owner_direct_references[0].from_file,
2232 PathBuf::from("src/entry.ts")
2233 );
2234 assert!(
2235 trace.reason.contains("'Foo' is used by 1 file(s)"),
2236 "the reason must follow the owner's credit: {}",
2237 trace.reason
2238 );
2239 }
2240
2241 #[test]
2242 #[expect(
2243 clippy::too_many_lines,
2244 reason = "the fixture names every ExportInfo field so a new field is a compile error here"
2245 )]
2246 fn trace_preserves_dual_namespace_named_re_exports() {
2247 let files: Vec<_> = ["entry", "barrel", "types", "values"]
2248 .into_iter()
2249 .enumerate()
2250 .map(|(index, name)| DiscoveredFile {
2251 id: FileId(index as u32),
2252 path: PathBuf::from(format!("/project/src/{name}.ts")),
2253 size_bytes: 10,
2254 })
2255 .collect();
2256 let export = |is_type_only| ExportInfo {
2257 name: ExportName::Named("Foo".to_string()),
2258 local_name: Some("Foo".to_string()),
2259 is_type_only,
2260 visibility: VisibilityTag::None,
2261 expected_unused_reason: None,
2262 span: oxc_span::Span::new(0, 3),
2263 members: Vec::new(),
2264 is_side_effect_used: false,
2265 super_class: None,
2266 deprecated: false,
2267 deprecated_reason: None,
2268 };
2269 let re_export = |source: FileId, is_type_only| ResolvedReExport {
2270 info: ReExportInfo {
2271 source: format!("./{}", source.0),
2272 imported_name: "Foo".to_string(),
2273 exported_name: "Foo".to_string(),
2274 is_type_only,
2275 span: oxc_span::Span::default(),
2276 statement_span: oxc_span::Span::default(),
2277 source_span: oxc_span::Span::default(),
2278 },
2279 target: ResolveResult::InternalModule(source),
2280 };
2281 let mut resolved = vec![
2282 ResolvedModule {
2283 file_id: FileId(0),
2284 path: files[0].path.clone(),
2285 resolved_imports: vec![ResolvedImport {
2286 info: ImportInfo {
2287 source: "./barrel".to_string(),
2288 imported_name: ImportedName::Named("Foo".to_string()),
2289 local_name: "Foo".to_string(),
2290 is_type_only: false,
2291 is_type_only_star: false,
2292 from_style: false,
2293 span: oxc_span::Span::new(0, 10),
2294 source_span: oxc_span::Span::default(),
2295 },
2296 target: ResolveResult::InternalModule(FileId(1)),
2297 }],
2298 ..Default::default()
2299 },
2300 ResolvedModule {
2301 file_id: FileId(1),
2302 path: files[1].path.clone(),
2303 re_exports: vec![re_export(FileId(2), true), re_export(FileId(3), false)],
2304 ..Default::default()
2305 },
2306 ResolvedModule {
2307 file_id: FileId(2),
2308 path: files[2].path.clone(),
2309 exports: vec![export(true)].into(),
2310 ..Default::default()
2311 },
2312 ResolvedModule {
2313 file_id: FileId(3),
2314 path: files[3].path.clone(),
2315 exports: vec![export(false)].into(),
2316 ..Default::default()
2317 },
2318 ];
2319 let entry_points = vec![EntryPoint {
2320 path: files[0].path.clone(),
2321 source: EntryPointSource::PackageJsonMain,
2322 }];
2323 let graph = ModuleGraph::build(&resolved, &entry_points, &files);
2324 resolved[1].re_exports.reverse();
2325 let reversed_graph = ModuleGraph::build(&resolved, &entry_points, &files);
2326
2327 let trace = trace_export(&graph, Path::new("/project"), "src/barrel.ts", "Foo")
2328 .expect("barrel exposes Foo in both namespaces");
2329
2330 assert_eq!(
2331 trace.namespace,
2332 fallow_types::semantic::SemanticNamespace::Value
2333 );
2334 assert!(
2335 trace.is_used,
2336 "the value import must credit the value surface"
2337 );
2338 assert_eq!(trace.direct_references.len(), 1);
2339 let reversed_trace = trace_export(
2340 &reversed_graph,
2341 Path::new("/project"),
2342 "src/barrel.ts",
2343 "Foo",
2344 )
2345 .expect("reversed declarations expose the same surface");
2346 assert_eq!(
2347 serde_json::to_value(trace).expect("serialize trace"),
2348 serde_json::to_value(reversed_trace).expect("serialize reversed trace")
2349 );
2350 }
2351
2352 fn build_class_member_graph() -> ModuleGraph {
2353 use fallow_types::extract::{MemberInfo, MemberKind};
2354
2355 let files = vec![
2356 DiscoveredFile {
2357 id: FileId(0),
2358 path: PathBuf::from("/project/src/entry.ts"),
2359 size_bytes: 100,
2360 },
2361 DiscoveredFile {
2362 id: FileId(1),
2363 path: PathBuf::from("/project/src/controller.ts"),
2364 size_bytes: 50,
2365 },
2366 ];
2367 let entry_points = vec![EntryPoint {
2368 path: PathBuf::from("/project/src/entry.ts"),
2369 source: EntryPointSource::PackageJsonMain,
2370 }];
2371 let method = |name: &str| MemberInfo {
2372 name: name.to_string(),
2373 kind: MemberKind::ClassMethod,
2374 span: oxc_span::Span::new(0, 4),
2375 has_decorator: false,
2376 decorator_names: vec![],
2377 is_instance_returning_static: false,
2378 is_self_returning: false,
2379 };
2380 let resolved_modules = vec![
2381 ResolvedModule {
2382 file_id: FileId(0),
2383 path: PathBuf::from("/project/src/entry.ts"),
2384 resolved_imports: vec![ResolvedImport {
2385 info: ImportInfo {
2386 source: "./controller".to_string(),
2387 imported_name: ImportedName::Named("Ctrl".to_string()),
2388 local_name: "Ctrl".to_string(),
2389 is_type_only: false,
2390 is_type_only_star: false,
2391 from_style: false,
2392 span: oxc_span::Span::new(0, 10),
2393 source_span: oxc_span::Span::default(),
2394 },
2395 target: ResolveResult::InternalModule(FileId(1)),
2396 }],
2397 ..Default::default()
2398 },
2399 ResolvedModule {
2400 file_id: FileId(1),
2401 path: PathBuf::from("/project/src/controller.ts"),
2402 exports: vec![ExportInfo {
2403 name: ExportName::Named("Ctrl".to_string()),
2404 local_name: Some("Ctrl".to_string()),
2405 is_type_only: false,
2406 visibility: VisibilityTag::None,
2407 expected_unused_reason: None,
2408 span: oxc_span::Span::new(0, 20),
2409 members: vec![method("used"), method("dead")],
2410 is_side_effect_used: false,
2411 super_class: None,
2412 deprecated: false,
2413 deprecated_reason: None,
2414 }]
2415 .into(),
2416 ..Default::default()
2417 },
2418 ];
2419 ModuleGraph::build(&resolved_modules, &entry_points, &files)
2420 }
2421
2422 #[test]
2423 fn trace_class_member_reports_owner_class() {
2424 let graph = build_class_member_graph();
2427 let root = Path::new("/project");
2428
2429 let trace = trace_class_member(&graph, root, "src/controller.ts", "dead").unwrap();
2430 assert_eq!(trace.owner_export, "Ctrl");
2431 assert_eq!(trace.member_name, "dead");
2432 assert_eq!(trace.member_kind, "class-method");
2433 assert!(trace.owner_is_used);
2434 assert!(trace.owner_file_reachable);
2435 assert_eq!(trace.owner_direct_references.len(), 1);
2436 assert!(
2437 trace.reason.contains("--unused-class-members"),
2438 "reason should point at the member command: {}",
2439 trace.reason
2440 );
2441 }
2442
2443 #[test]
2444 fn trace_class_member_absent_name_is_none() {
2445 let graph = build_class_member_graph();
2448 let root = Path::new("/project");
2449 assert!(trace_class_member(&graph, root, "src/controller.ts", "nope").is_none());
2450 }
2451
2452 fn exact_class_method_fixture() -> (tempfile::TempDir, ModuleGraph) {
2453 use fallow_types::extract::{MemberInfo, MemberKind};
2454
2455 let temp = tempfile::tempdir().unwrap();
2456 let root = temp.path();
2457 let path = root.join("repository.ts");
2458 let source =
2459 "export class Repository {\n save(): void;\n save(): void {}\n run(): void {}\n}\n";
2460 std::fs::write(&path, source).unwrap();
2461 let first = source.find("save").unwrap() as u32;
2462 let second = source.rfind("save").unwrap() as u32;
2463 let run = source.find("run").unwrap() as u32;
2464 let member = |name: &str, start| MemberInfo {
2465 name: name.to_string(),
2466 kind: MemberKind::ClassMethod,
2467 span: oxc_span::Span::new(start, start + 4),
2468 has_decorator: false,
2469 decorator_names: vec![],
2470 is_instance_returning_static: false,
2471 is_self_returning: false,
2472 };
2473 let files = vec![DiscoveredFile {
2474 id: FileId(0),
2475 path: path.clone(),
2476 size_bytes: source.len() as u64,
2477 }];
2478 let resolved_modules = vec![ResolvedModule {
2479 file_id: FileId(0),
2480 path,
2481 exports: vec![ExportInfo {
2482 name: ExportName::Named("Repository".to_string()),
2483 local_name: Some("Repository".to_string()),
2484 is_type_only: false,
2485 visibility: VisibilityTag::None,
2486 expected_unused_reason: None,
2487 span: oxc_span::Span::new(0, source.len() as u32),
2488 members: vec![
2489 member("save", first),
2490 member("save", second),
2491 member("run", run),
2492 ],
2493 is_side_effect_used: false,
2494 super_class: None,
2495 deprecated: false,
2496 deprecated_reason: None,
2497 }]
2498 .into(),
2499 ..Default::default()
2500 }];
2501 let graph = ModuleGraph::build(&resolved_modules, &[], &files);
2502 (temp, graph)
2503 }
2504
2505 #[test]
2506 fn exact_class_method_resolution_rejects_overloads_without_guessing() {
2507 let (temp, graph) = exact_class_method_fixture();
2508 let root = temp.path();
2509 assert_eq!(
2510 semantic_symbol_for_exact_class_method(
2511 &graph,
2512 root,
2513 "repository.ts",
2514 "Repository",
2515 "save",
2516 ),
2517 Err(SemanticClassMethodResolutionError::AmbiguousSymbol)
2518 );
2519 assert_eq!(
2520 semantic_symbol_for_exact_class_method(
2521 &graph,
2522 root,
2523 "repository.ts",
2524 "OtherRepository",
2525 "save",
2526 ),
2527 Err(SemanticClassMethodResolutionError::SymbolNotFound)
2528 );
2529 let resolved = semantic_symbol_for_exact_class_method(
2530 &graph,
2531 root,
2532 "repository.ts",
2533 "Repository",
2534 "run",
2535 )
2536 .unwrap();
2537 assert_eq!(resolved.owner.as_deref(), Some("Repository"));
2538 assert_eq!(resolved.local_name, "run");
2539 }
2540
2541 #[test]
2542 fn exact_class_method_resolution_preserves_error_precedence() {
2543 use fallow_types::extract::MemberKind;
2544
2545 let (temp, mut graph) = exact_class_method_fixture();
2546 let root = temp.path();
2547 for (file, owner, member, expected) in [
2548 (
2549 "missing.ts",
2550 "Repository",
2551 "run",
2552 SemanticClassMethodResolutionError::FileNotFound,
2553 ),
2554 (
2555 "repository.ts",
2556 "Repository",
2557 "missing",
2558 SemanticClassMethodResolutionError::SymbolNotFound,
2559 ),
2560 ] {
2561 assert_eq!(
2562 semantic_symbol_for_exact_class_method(&graph, root, file, owner, member),
2563 Err(expected),
2564 );
2565 }
2566 graph.modules[0].exports[0].members[2].kind = MemberKind::ClassProperty;
2567 assert_eq!(
2568 semantic_symbol_for_exact_class_method(
2569 &graph,
2570 root,
2571 "repository.ts",
2572 "Repository",
2573 "run"
2574 ),
2575 Err(SemanticClassMethodResolutionError::UnsupportedSyntax),
2576 );
2577 graph.modules[0].exports[0].members[2].kind = MemberKind::ClassMethod;
2578 std::fs::remove_file(&graph.modules[0].path).expect("remove fixture source");
2579 assert_eq!(
2580 semantic_symbol_for_exact_class_method(
2581 &graph,
2582 root,
2583 "repository.ts",
2584 "Repository",
2585 "run"
2586 ),
2587 Err(SemanticClassMethodResolutionError::SymbolNotFound),
2588 );
2589 let (_other_temp, mut other_graph) = exact_class_method_fixture();
2590 let duplicate = other_graph.modules[0]
2591 .exports
2592 .pop()
2593 .expect("fixture declares an owner");
2594 graph.modules[0].exports.push(duplicate);
2595 assert_eq!(
2596 semantic_symbol_for_exact_class_method(
2597 &graph,
2598 root,
2599 "repository.ts",
2600 "Repository",
2601 "run"
2602 ),
2603 Err(SemanticClassMethodResolutionError::AmbiguousSymbol),
2604 "owner ambiguity must be reported before reading source",
2605 );
2606 }
2607
2608 fn build_unreachable_class_member_graph() -> ModuleGraph {
2611 use fallow_types::extract::{MemberInfo, MemberKind};
2612
2613 let files = vec![
2614 DiscoveredFile {
2615 id: FileId(0),
2616 path: PathBuf::from("/project/src/entry.ts"),
2617 size_bytes: 100,
2618 },
2619 DiscoveredFile {
2620 id: FileId(1),
2621 path: PathBuf::from("/project/src/controller.ts"),
2622 size_bytes: 50,
2623 },
2624 ];
2625 let entry_points = vec![EntryPoint {
2626 path: PathBuf::from("/project/src/entry.ts"),
2627 source: EntryPointSource::PackageJsonMain,
2628 }];
2629 let method = |name: &str| MemberInfo {
2630 name: name.to_string(),
2631 kind: MemberKind::ClassMethod,
2632 span: oxc_span::Span::new(0, 4),
2633 has_decorator: false,
2634 decorator_names: vec![],
2635 is_instance_returning_static: false,
2636 is_self_returning: false,
2637 };
2638 let resolved_modules = vec![
2639 ResolvedModule {
2640 file_id: FileId(0),
2641 path: PathBuf::from("/project/src/entry.ts"),
2642 ..Default::default()
2644 },
2645 ResolvedModule {
2646 file_id: FileId(1),
2647 path: PathBuf::from("/project/src/controller.ts"),
2648 exports: vec![ExportInfo {
2649 name: ExportName::Named("Ctrl".to_string()),
2650 local_name: Some("Ctrl".to_string()),
2651 is_type_only: false,
2652 visibility: VisibilityTag::None,
2653 expected_unused_reason: None,
2654 span: oxc_span::Span::new(0, 20),
2655 members: vec![method("dead")],
2656 is_side_effect_used: false,
2657 super_class: None,
2658 deprecated: false,
2659 deprecated_reason: None,
2660 }]
2661 .into(),
2662 ..Default::default()
2663 },
2664 ];
2665 ModuleGraph::build(&resolved_modules, &entry_points, &files)
2666 }
2667
2668 #[test]
2669 fn trace_class_member_unreachable_owner_reports_dead_reason() {
2670 let graph = build_unreachable_class_member_graph();
2673 let root = Path::new("/project");
2674
2675 let trace = trace_class_member(&graph, root, "src/controller.ts", "dead").unwrap();
2676 assert!(!trace.owner_file_reachable);
2677 assert!(
2678 trace.reason.contains("not reachable"),
2679 "unreachable owner reason should say so: {}",
2680 trace.reason
2681 );
2682 assert!(!trace.reason.contains("--unused-class-members"));
2685 }
2686
2687 #[test]
2688 fn trace_class_member_prefers_used_owner_on_name_collision() {
2689 use fallow_types::extract::{MemberInfo, MemberKind};
2693
2694 let files = vec![
2695 DiscoveredFile {
2696 id: FileId(0),
2697 path: PathBuf::from("/project/src/entry.ts"),
2698 size_bytes: 100,
2699 },
2700 DiscoveredFile {
2701 id: FileId(1),
2702 path: PathBuf::from("/project/src/controller.ts"),
2703 size_bytes: 50,
2704 },
2705 ];
2706 let entry_points = vec![EntryPoint {
2707 path: PathBuf::from("/project/src/entry.ts"),
2708 source: EntryPointSource::PackageJsonMain,
2709 }];
2710 let method = |name: &str| MemberInfo {
2711 name: name.to_string(),
2712 kind: MemberKind::ClassMethod,
2713 span: oxc_span::Span::new(0, 4),
2714 has_decorator: false,
2715 decorator_names: vec![],
2716 is_instance_returning_static: false,
2717 is_self_returning: false,
2718 };
2719 let resolved_modules = vec![
2720 ResolvedModule {
2721 file_id: FileId(0),
2722 path: PathBuf::from("/project/src/entry.ts"),
2723 resolved_imports: vec![ResolvedImport {
2724 info: ImportInfo {
2725 source: "./controller".to_string(),
2726 imported_name: ImportedName::Named("UsedCtrl".to_string()),
2727 local_name: "UsedCtrl".to_string(),
2728 is_type_only: false,
2729 is_type_only_star: false,
2730 from_style: false,
2731 span: oxc_span::Span::new(0, 10),
2732 source_span: oxc_span::Span::default(),
2733 },
2734 target: ResolveResult::InternalModule(FileId(1)),
2735 }],
2736 ..Default::default()
2737 },
2738 ResolvedModule {
2739 file_id: FileId(1),
2740 path: PathBuf::from("/project/src/controller.ts"),
2741 exports: vec![
2742 ExportInfo {
2745 name: ExportName::Named("TypeCtrl".to_string()),
2746 local_name: Some("TypeCtrl".to_string()),
2747 is_type_only: true,
2748 visibility: VisibilityTag::None,
2749 expected_unused_reason: None,
2750 span: oxc_span::Span::new(0, 20),
2751 members: vec![method("shared")],
2752 is_side_effect_used: false,
2753 super_class: None,
2754 deprecated: false,
2755 deprecated_reason: None,
2756 },
2757 ExportInfo {
2758 name: ExportName::Named("UsedCtrl".to_string()),
2759 local_name: Some("UsedCtrl".to_string()),
2760 is_type_only: false,
2761 visibility: VisibilityTag::None,
2762 expected_unused_reason: None,
2763 span: oxc_span::Span::new(0, 20),
2764 members: vec![method("shared")],
2765 is_side_effect_used: false,
2766 super_class: None,
2767 deprecated: false,
2768 deprecated_reason: None,
2769 },
2770 ]
2771 .into(),
2772 ..Default::default()
2773 },
2774 ];
2775 let graph = ModuleGraph::build(&resolved_modules, &entry_points, &files);
2776 let root = Path::new("/project");
2777
2778 let trace = trace_class_member(&graph, root, "src/controller.ts", "shared").unwrap();
2779 assert_eq!(
2780 trace.owner_export, "UsedCtrl",
2781 "tie-break must prefer the used, non-type-only owner"
2782 );
2783 assert!(trace.owner_is_used);
2784 }
2785
2786 #[test]
2787 fn trace_nonexistent_file() {
2788 let graph = build_test_graph();
2789 let root = Path::new("/project");
2790
2791 let trace = trace_export(&graph, root, "src/nope.ts", "foo");
2792 assert!(trace.is_none());
2793 }
2794
2795 #[test]
2796 fn trace_file_edges() {
2797 let graph = build_test_graph();
2798 let root = Path::new("/project");
2799
2800 let trace = trace_file(&graph, root, "src/entry.ts").unwrap();
2801 assert!(trace.is_entry_point);
2802 assert!(trace.is_reachable);
2803 assert_eq!(trace.imports_from.len(), 1);
2804 assert_eq!(trace.imports_from[0], PathBuf::from("src/utils.ts"));
2805 assert!(trace.imported_by.is_empty());
2806 }
2807
2808 fn build_nested_duplicate_path_graph() -> ModuleGraph {
2809 let export = |name: &str| ExportInfo {
2810 name: ExportName::Named(name.to_string()),
2811 local_name: Some(name.to_string()),
2812 is_type_only: false,
2813 visibility: VisibilityTag::None,
2814 expected_unused_reason: None,
2815 span: oxc_span::Span::new(0, 20),
2816 members: vec![],
2817 is_side_effect_used: false,
2818 super_class: None,
2819 deprecated: false,
2820 deprecated_reason: None,
2821 };
2822 let nested = PathBuf::from("/project/packages/x/src/a.ts");
2823 let root_file = PathBuf::from("/project/src/a.ts");
2824 let files = vec![
2825 DiscoveredFile {
2826 id: FileId(0),
2827 path: nested.clone(),
2828 size_bytes: 20,
2829 },
2830 DiscoveredFile {
2831 id: FileId(1),
2832 path: root_file.clone(),
2833 size_bytes: 20,
2834 },
2835 ];
2836 let entry_points = vec![EntryPoint {
2837 path: root_file.clone(),
2838 source: EntryPointSource::PackageJsonMain,
2839 }];
2840 let resolved_modules = vec![
2841 ResolvedModule {
2842 file_id: FileId(0),
2843 path: nested,
2844 exports: vec![export("nestedOnly")].into(),
2845 ..Default::default()
2846 },
2847 ResolvedModule {
2848 file_id: FileId(1),
2849 path: root_file,
2850 exports: vec![export("rootOnly")].into(),
2851 ..Default::default()
2852 },
2853 ];
2854 ModuleGraph::build(&resolved_modules, &entry_points, &files)
2855 }
2856
2857 fn graph_with_module_paths(paths: &[PathBuf]) -> ModuleGraph {
2858 let files: Vec<DiscoveredFile> = paths
2859 .iter()
2860 .enumerate()
2861 .map(|(index, path)| DiscoveredFile {
2862 id: FileId(u32::try_from(index).expect("small fixture")),
2863 path: path.clone(),
2864 size_bytes: 1,
2865 })
2866 .collect();
2867 let resolved_modules: Vec<ResolvedModule> = files
2868 .iter()
2869 .map(|file| ResolvedModule {
2870 file_id: file.id,
2871 path: file.path.clone(),
2872 ..Default::default()
2873 })
2874 .collect();
2875 ModuleGraph::build(&resolved_modules, &[], &files)
2876 }
2877
2878 fn assert_lookup_matches_single_scan(graph: &ModuleGraph, root: &Path, queries: &[&str]) {
2879 let lookup = ModulePathLookup::new(graph, root);
2880 for query in queries {
2881 assert_eq!(
2882 lookup.matching(query),
2883 matching_module_indexes(graph, root, query),
2884 "query {query:?}"
2885 );
2886 }
2887 }
2888
2889 const LOOKUP_QUERIES: &[&str] = &[
2890 "src/a.ts",
2891 "./src/a.ts",
2892 "src//a.ts",
2893 r"src\a.ts",
2894 "a.ts",
2895 "x/src/a.ts",
2896 "packages/x/src/a.ts",
2897 "sub/c.ts",
2898 "c.ts",
2899 "src/",
2900 "",
2901 "missing.ts",
2902 "/elsewhere/src/a.ts",
2903 ];
2904
2905 #[test]
2908 fn module_path_lookup_matches_single_scan_lookup() {
2909 let root = Path::new("/project");
2910 let paths: Vec<PathBuf> = [
2911 "src/a.ts",
2912 "packages/x/src/a.ts",
2913 "packages/y/a.ts",
2914 "src/sub/c.ts",
2915 "lib/b.ts",
2916 ]
2917 .iter()
2918 .map(|relative| root.join(relative))
2919 .collect();
2920 let graph = graph_with_module_paths(&paths);
2921 let mut queries = LOOKUP_QUERIES.to_vec();
2922 let absolute = root.join("src/a.ts").to_string_lossy().into_owned();
2923 queries.push(&absolute);
2924 assert_lookup_matches_single_scan(&graph, root, &queries);
2925 }
2926
2927 #[test]
2930 fn module_path_lookup_matches_single_scan_lookup_on_disk() {
2931 let dir = tempfile::tempdir().expect("tempdir");
2932 let root = dir.path();
2933 for relative in ["src/a.ts", "packages/x/src/a.ts", "src/sub/c.ts"] {
2934 let path = root.join(relative);
2935 std::fs::create_dir_all(path.parent().expect("parent")).expect("mkdir");
2936 std::fs::write(&path, "").expect("write");
2937 }
2938 let canonical_root = dunce::canonicalize(root).expect("canonical root");
2939 let paths: Vec<PathBuf> = ["src/a.ts", "packages/x/src/a.ts", "src/sub/c.ts"]
2940 .iter()
2941 .map(|relative| canonical_root.join(relative))
2942 .collect();
2943 let graph = graph_with_module_paths(&paths);
2944 assert_lookup_matches_single_scan(&graph, root, LOOKUP_QUERIES);
2945 }
2946
2947 #[test]
2948 fn root_relative_path_prefers_exact_module_over_nested_suffix() {
2949 let graph = build_nested_duplicate_path_graph();
2950 let root = Path::new("/project");
2951
2952 let file = trace_file(&graph, root, "src/a.ts").expect("root file resolves");
2953 assert_eq!(file.file, PathBuf::from("src/a.ts"));
2954 assert!(file.is_entry_point);
2955
2956 let export = trace_export(&graph, root, "src/a.ts", "rootOnly");
2957 assert!(
2958 export.is_some(),
2959 "an export of the root-level file must resolve"
2960 );
2961 }
2962
2963 #[test]
2964 fn abbreviated_path_keeps_first_suffix_match() {
2965 let graph = build_nested_duplicate_path_graph();
2966 let root = Path::new("/project");
2967
2968 let file = trace_file(&graph, root, "a.ts").expect("suffix match resolves");
2969 assert_eq!(file.file, PathBuf::from("packages/x/src/a.ts"));
2970 }
2971
2972 #[test]
2973 fn trace_file_imported_by() {
2974 let graph = build_test_graph();
2975 let root = Path::new("/project");
2976
2977 let trace = trace_file(&graph, root, "src/utils.ts").unwrap();
2978 assert!(!trace.is_entry_point);
2979 assert!(trace.is_reachable);
2980 assert_eq!(trace.exports.len(), 2);
2981 assert_eq!(trace.imported_by.len(), 1);
2982 assert_eq!(trace.imported_by[0], PathBuf::from("src/entry.ts"));
2983 }
2984
2985 #[test]
2986 fn trace_unreachable_file() {
2987 let graph = build_test_graph();
2988 let root = Path::new("/project");
2989
2990 let trace = trace_file(&graph, root, "src/unused.ts").unwrap();
2991 assert!(!trace.is_reachable);
2992 assert!(!trace.is_entry_point);
2993 assert!(trace.imported_by.is_empty());
2994 }
2995
2996 #[test]
2997 fn trace_dependency_used() {
2998 let files = vec![DiscoveredFile {
2999 id: FileId(0),
3000 path: PathBuf::from("/project/src/app.ts"),
3001 size_bytes: 100,
3002 }];
3003 let entry_points = vec![EntryPoint {
3004 path: PathBuf::from("/project/src/app.ts"),
3005 source: EntryPointSource::PackageJsonMain,
3006 }];
3007 let resolved_modules = vec![ResolvedModule {
3008 file_id: FileId(0),
3009 path: PathBuf::from("/project/src/app.ts"),
3010 resolved_imports: vec![ResolvedImport {
3011 info: ImportInfo {
3012 source: "lodash".to_string(),
3013 imported_name: ImportedName::Named("get".to_string()),
3014 local_name: "get".to_string(),
3015 is_type_only: false,
3016 is_type_only_star: false,
3017 from_style: false,
3018 span: oxc_span::Span::new(0, 10),
3019 source_span: oxc_span::Span::default(),
3020 },
3021 target: ResolveResult::NpmPackage("lodash".to_string()),
3022 }],
3023 ..Default::default()
3024 }];
3025
3026 let graph = ModuleGraph::build(&resolved_modules, &entry_points, &files);
3027 let root = Path::new("/project");
3028
3029 let trace = trace_dependency(&graph, root, "lodash", &FxHashSet::default());
3030 assert!(trace.is_used);
3031 assert!(!trace.used_in_scripts);
3032 assert_eq!(trace.import_count, 1);
3033 assert_eq!(trace.imported_by[0], PathBuf::from("src/app.ts"));
3034 }
3035
3036 #[test]
3037 fn trace_dependency_unused() {
3038 let files = vec![DiscoveredFile {
3039 id: FileId(0),
3040 path: PathBuf::from("/project/src/app.ts"),
3041 size_bytes: 100,
3042 }];
3043 let entry_points = vec![EntryPoint {
3044 path: PathBuf::from("/project/src/app.ts"),
3045 source: EntryPointSource::PackageJsonMain,
3046 }];
3047 let resolved_modules = vec![ResolvedModule {
3048 file_id: FileId(0),
3049 path: PathBuf::from("/project/src/app.ts"),
3050 ..Default::default()
3051 }];
3052
3053 let graph = ModuleGraph::build(&resolved_modules, &entry_points, &files);
3054 let root = Path::new("/project");
3055
3056 let trace = trace_dependency(&graph, root, "nonexistent-pkg", &FxHashSet::default());
3057 assert!(!trace.is_used);
3058 assert!(!trace.used_in_scripts);
3059 assert_eq!(trace.import_count, 0);
3060 assert!(trace.imported_by.is_empty());
3061 }
3062
3063 #[test]
3064 fn trace_dependency_used_only_in_scripts() {
3065 let files = vec![DiscoveredFile {
3066 id: FileId(0),
3067 path: PathBuf::from("/project/src/app.ts"),
3068 size_bytes: 100,
3069 }];
3070 let entry_points = vec![EntryPoint {
3071 path: PathBuf::from("/project/src/app.ts"),
3072 source: EntryPointSource::PackageJsonMain,
3073 }];
3074 let resolved_modules = vec![ResolvedModule {
3075 file_id: FileId(0),
3076 path: PathBuf::from("/project/src/app.ts"),
3077 ..Default::default()
3078 }];
3079
3080 let graph = ModuleGraph::build(&resolved_modules, &entry_points, &files);
3081 let root = Path::new("/project");
3082 let mut script_used = FxHashSet::default();
3083 script_used.insert("microbundle".to_string());
3084
3085 let trace = trace_dependency(&graph, root, "microbundle", &script_used);
3086 assert!(
3087 trace.is_used,
3088 "is_used must be true when the package is referenced from package.json scripts"
3089 );
3090 assert!(trace.used_in_scripts);
3091 assert_eq!(trace.import_count, 0);
3092 assert!(trace.imported_by.is_empty());
3093 }
3094
3095 #[test]
3096 fn trace_clone_finds_matching_group() {
3097 use crate::duplicates::{CloneGroup, CloneInstance, DuplicationReport, DuplicationStats};
3098 let report = DuplicationReport {
3099 clone_groups: vec![CloneGroup {
3100 instances: vec![
3101 CloneInstance {
3102 file: PathBuf::from("/project/src/a.ts"),
3103 start_line: 10,
3104 end_line: 20,
3105 start_col: 0,
3106 end_col: 0,
3107 fragment: "fn foo() {}".to_string(),
3108 },
3109 CloneInstance {
3110 file: PathBuf::from("/project/src/b.ts"),
3111 start_line: 5,
3112 end_line: 15,
3113 start_col: 0,
3114 end_col: 0,
3115 fragment: "fn foo() {}".to_string(),
3116 },
3117 ],
3118 token_count: 60,
3119 line_count: 11,
3120 similarity: None,
3121 }],
3122 clone_families: vec![],
3123 mirrored_directories: vec![],
3124 stats: DuplicationStats {
3125 total_files: 2,
3126 files_with_clones: 2,
3127 total_lines: 100,
3128 duplicated_lines: 22,
3129 total_tokens: 200,
3130 duplicated_tokens: 120,
3131 clone_groups: 1,
3132 clone_families: 0,
3133 clone_instances: 2,
3134 duplication_percentage: 22.0,
3135 clone_groups_below_min_occurrences: 0,
3136 clone_groups_ignored: 0,
3137 near_candidates_skipped: 0,
3138 },
3139 };
3140 let trace = trace_clone(&report, Path::new("/project"), "src/a.ts", 15);
3141 assert!(trace.matched_instance.is_some());
3142 assert_eq!(trace.clone_groups.len(), 1);
3143 assert_eq!(trace.clone_groups[0].instances.len(), 2);
3144 assert!(trace.clone_groups[0].fingerprint.starts_with("dup:"));
3145 assert_eq!(trace.clone_groups[0].suggestion.estimated_savings, 11);
3146 }
3147
3148 #[test]
3149 fn trace_clone_by_fingerprint_resolves_and_misses() {
3150 use crate::duplicates::{
3151 CloneGroup, CloneInstance, DuplicationReport, DuplicationStats, clone_fingerprint,
3152 };
3153 let report = DuplicationReport {
3154 clone_groups: vec![CloneGroup {
3155 instances: vec![
3156 CloneInstance {
3157 file: PathBuf::from("/project/src/a.ts"),
3158 start_line: 10,
3159 end_line: 20,
3160 start_col: 0,
3161 end_col: 0,
3162 fragment: "fn buildInvoice() {}".to_string(),
3163 },
3164 CloneInstance {
3165 file: PathBuf::from("/project/src/b.ts"),
3166 start_line: 5,
3167 end_line: 15,
3168 start_col: 0,
3169 end_col: 0,
3170 fragment: "fn buildInvoice() {}".to_string(),
3171 },
3172 ],
3173 token_count: 60,
3174 line_count: 11,
3175 similarity: None,
3176 }],
3177 clone_families: vec![],
3178 mirrored_directories: vec![],
3179 stats: DuplicationStats::default(),
3180 };
3181 let fp = clone_fingerprint(&report.clone_groups[0].instances);
3182
3183 let hit = trace_clone_by_fingerprint(&report, Path::new("/project"), &fp);
3184 assert!(hit.matched_instance.is_some());
3185 assert_eq!(hit.clone_groups.len(), 1);
3186 assert_eq!(hit.clone_groups[0].fingerprint, fp);
3187 assert_eq!(hit.line, 10);
3188
3189 let miss = trace_clone_by_fingerprint(&report, Path::new("/project"), "dup:deadbeef");
3190 assert!(miss.matched_instance.is_none());
3191 assert!(miss.clone_groups.is_empty());
3192 }
3193
3194 #[test]
3195 fn trace_clone_no_match() {
3196 use crate::duplicates::{CloneGroup, CloneInstance, DuplicationReport, DuplicationStats};
3197 let report = DuplicationReport {
3198 clone_groups: vec![CloneGroup {
3199 instances: vec![CloneInstance {
3200 file: PathBuf::from("/project/src/a.ts"),
3201 start_line: 10,
3202 end_line: 20,
3203 start_col: 0,
3204 end_col: 0,
3205 fragment: "fn foo() {}".to_string(),
3206 }],
3207 token_count: 60,
3208 line_count: 11,
3209 similarity: None,
3210 }],
3211 clone_families: vec![],
3212 mirrored_directories: vec![],
3213 stats: DuplicationStats {
3214 total_files: 1,
3215 files_with_clones: 1,
3216 total_lines: 50,
3217 duplicated_lines: 11,
3218 total_tokens: 100,
3219 duplicated_tokens: 60,
3220 clone_groups: 1,
3221 clone_families: 0,
3222 clone_instances: 1,
3223 duplication_percentage: 22.0,
3224 clone_groups_below_min_occurrences: 0,
3225 clone_groups_ignored: 0,
3226 near_candidates_skipped: 0,
3227 },
3228 };
3229 let trace = trace_clone(&report, Path::new("/project"), "src/a.ts", 25);
3230 assert!(trace.matched_instance.is_none());
3231 assert!(trace.clone_groups.is_empty());
3232 }
3233
3234 #[test]
3235 fn trace_clone_line_boundary() {
3236 use crate::duplicates::{CloneGroup, CloneInstance, DuplicationReport, DuplicationStats};
3237 let report = DuplicationReport {
3238 clone_groups: vec![CloneGroup {
3239 instances: vec![
3240 CloneInstance {
3241 file: PathBuf::from("/project/src/a.ts"),
3242 start_line: 10,
3243 end_line: 20,
3244 start_col: 0,
3245 end_col: 0,
3246 fragment: "code".to_string(),
3247 },
3248 CloneInstance {
3249 file: PathBuf::from("/project/src/b.ts"),
3250 start_line: 1,
3251 end_line: 11,
3252 start_col: 0,
3253 end_col: 0,
3254 fragment: "code".to_string(),
3255 },
3256 ],
3257 token_count: 50,
3258 line_count: 11,
3259 similarity: None,
3260 }],
3261 clone_families: vec![],
3262 mirrored_directories: vec![],
3263 stats: DuplicationStats {
3264 total_files: 2,
3265 files_with_clones: 2,
3266 total_lines: 100,
3267 duplicated_lines: 22,
3268 total_tokens: 200,
3269 duplicated_tokens: 100,
3270 clone_groups: 1,
3271 clone_families: 0,
3272 clone_instances: 2,
3273 duplication_percentage: 22.0,
3274 clone_groups_below_min_occurrences: 0,
3275 clone_groups_ignored: 0,
3276 near_candidates_skipped: 0,
3277 },
3278 };
3279 let root = Path::new("/project");
3280 assert!(
3281 trace_clone(&report, root, "src/a.ts", 10)
3282 .matched_instance
3283 .is_some()
3284 );
3285 assert!(
3286 trace_clone(&report, root, "src/a.ts", 20)
3287 .matched_instance
3288 .is_some()
3289 );
3290 assert!(
3291 trace_clone(&report, root, "src/a.ts", 21)
3292 .matched_instance
3293 .is_none()
3294 );
3295 }
3296
3297 #[test]
3298 fn trace_clone_returns_relative_instance_paths() {
3299 use crate::duplicates::{CloneGroup, CloneInstance, DuplicationReport, DuplicationStats};
3300 let report = DuplicationReport {
3301 clone_groups: vec![CloneGroup {
3302 instances: vec![
3303 CloneInstance {
3304 file: PathBuf::from("/project/src/a.ts"),
3305 start_line: 1,
3306 end_line: 10,
3307 start_col: 0,
3308 end_col: 0,
3309 fragment: "code".to_string(),
3310 },
3311 CloneInstance {
3312 file: PathBuf::from("/project/src/b.ts"),
3313 start_line: 1,
3314 end_line: 10,
3315 start_col: 0,
3316 end_col: 0,
3317 fragment: "code".to_string(),
3318 },
3319 ],
3320 token_count: 50,
3321 line_count: 10,
3322 similarity: None,
3323 }],
3324 clone_families: vec![],
3325 mirrored_directories: vec![],
3326 stats: DuplicationStats {
3327 total_files: 2,
3328 files_with_clones: 2,
3329 total_lines: 50,
3330 duplicated_lines: 20,
3331 total_tokens: 100,
3332 duplicated_tokens: 100,
3333 clone_groups: 1,
3334 clone_families: 0,
3335 clone_instances: 2,
3336 duplication_percentage: 40.0,
3337 clone_groups_below_min_occurrences: 0,
3338 clone_groups_ignored: 0,
3339 near_candidates_skipped: 0,
3340 },
3341 };
3342 let trace = trace_clone(&report, Path::new("/project"), "src/a.ts", 5);
3343 let matched = trace.matched_instance.as_ref().expect("match expected");
3344 assert_eq!(matched.file, PathBuf::from("src/a.ts"));
3345 for group in &trace.clone_groups {
3346 for inst in &group.instances {
3347 let as_str = inst.file.to_string_lossy();
3348 assert!(
3349 !as_str.starts_with('/'),
3350 "instance file should be relative, got {as_str}",
3351 );
3352 assert!(
3353 !as_str.contains(":\\") && !as_str.contains(":/"),
3354 "instance file should not have a drive letter, got {as_str}",
3355 );
3356 }
3357 }
3358
3359 let json = serde_json::to_string(&trace).expect("serializes");
3360 assert!(
3361 !json.contains("\"/project/"),
3362 "serialized trace should not leak absolute paths: {json}",
3363 );
3364 }
3365
3366 fn single_module_graph(path: &str) -> ModuleGraph {
3373 let path = PathBuf::from(path);
3374 let files = vec![DiscoveredFile {
3375 id: FileId(0),
3376 path: path.clone(),
3377 size_bytes: 10,
3378 }];
3379 let resolved_modules = vec![ResolvedModule {
3380 file_id: FileId(0),
3381 path,
3382 ..Default::default()
3383 }];
3384 ModuleGraph::build(&resolved_modules, &[], &files)
3385 }
3386
3387 #[test]
3388 fn find_module_normalises_windows_module_path_against_posix_user_path() {
3389 let root = Path::new(r"D:\a\fallow\fallow\tests\fixtures\basic-project");
3390 let graph =
3391 single_module_graph(r"D:\a\fallow\fallow\tests\fixtures\basic-project\src\utils.ts");
3392 assert!(find_module(&graph, root, "src/utils.ts").is_some());
3393 assert!(find_module(&graph, root, r"src\utils.ts").is_some());
3394 }
3395
3396 #[test]
3397 fn find_module_suffix_fallback_handles_mixed_separators() {
3398 let root = Path::new("/some/other/root");
3399 let graph =
3400 single_module_graph(r"D:\a\fallow\fallow\tests\fixtures\basic-project\src\utils.ts");
3401 assert!(find_module(&graph, root, "src/utils.ts").is_some());
3402 }
3403
3404 #[test]
3413 fn export_trace_serializes_windows_path_with_forward_slashes() {
3414 let trace = ExportTrace {
3415 file: PathBuf::from(r"src\utils.ts"),
3416 export_name: "foo".to_string(),
3417 namespace: fallow_types::semantic::SemanticNamespace::Value,
3418 file_reachable: true,
3419 is_entry_point: false,
3420 is_used: true,
3421 direct_references: vec![ExportReference {
3422 from_file: PathBuf::from(r"src\entry.ts"),
3423 kind: "named import".to_string(),
3424 }],
3425 direct_references_by_namespace: Vec::new(),
3426 star_export_ambiguity: None,
3427 re_export_chains: vec![ReExportChain {
3428 barrel_file: PathBuf::from(r"src\index.ts"),
3429 exported_as: "foo".to_string(),
3430 reference_count: 1,
3431 }],
3432 reason: "ok".to_string(),
3433 semantic: None,
3434 };
3435 let json = serde_json::to_string(&trace).expect("serializes");
3436 assert!(
3437 json.contains("\"file\":\"src/utils.ts\""),
3438 "ExportTrace.file must serialize with forward slashes: {json}"
3439 );
3440 assert!(
3441 json.contains("\"from_file\":\"src/entry.ts\""),
3442 "ExportReference.from_file must serialize with forward slashes: {json}"
3443 );
3444 assert!(
3445 json.contains("\"barrel_file\":\"src/index.ts\""),
3446 "ReExportChain.barrel_file must serialize with forward slashes: {json}"
3447 );
3448 assert!(
3449 !json.contains(r"\\"),
3450 "no backslash sequence should remain anywhere in the JSON: {json}"
3451 );
3452 }
3453
3454 #[test]
3455 fn file_trace_serializes_windows_paths_with_forward_slashes() {
3456 let trace = FileTrace {
3457 file: PathBuf::from(r"src\utils.ts"),
3458 is_reachable: true,
3459 is_entry_point: false,
3460 exports: vec![],
3461 imports_from: vec![PathBuf::from(r"src\helpers.ts")],
3462 imported_by: vec![PathBuf::from(r"src\entry.ts")],
3463 re_exports: vec![TracedReExport {
3464 source_file: PathBuf::from(r"src\source.ts"),
3465 imported_name: "foo".to_string(),
3466 exported_name: "foo".to_string(),
3467 }],
3468 sources: Vec::new(),
3469 };
3470 let json = serde_json::to_string(&trace).expect("serializes");
3471 assert!(json.contains("\"file\":\"src/utils.ts\""), "got {json}");
3472 assert!(
3473 json.contains("\"imports_from\":[\"src/helpers.ts\"]"),
3474 "got {json}"
3475 );
3476 assert!(
3477 json.contains("\"imported_by\":[\"src/entry.ts\"]"),
3478 "got {json}"
3479 );
3480 assert!(
3481 json.contains("\"source_file\":\"src/source.ts\""),
3482 "got {json}"
3483 );
3484 assert!(!json.contains(r"\\"), "no backslash should remain: {json}");
3485 }
3486}