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) -> Result<ImportPathTrace, ImportPathEndpoint> {
1076 let from_index = import_path_endpoint_index(
1077 graph,
1078 root,
1079 from_path,
1080 ImportPathEndpoint::From,
1081 ImportPathEndpoint::AmbiguousFrom,
1082 )?;
1083 let to_index = import_path_endpoint_index(
1084 graph,
1085 root,
1086 to_path,
1087 ImportPathEndpoint::To,
1088 ImportPathEndpoint::AmbiguousTo,
1089 )?;
1090 let from = &graph.modules[from_index];
1091 let to = &graph.modules[to_index];
1092
1093 let from_rel = relativize(&from.path, root);
1094 let to_rel = relativize(&to.path, root);
1095
1096 let Some(hops) = graph.shortest_import_path(from.file_id, to.file_id) else {
1097 return Ok(ImportPathTrace {
1098 schema_version: ImportPathTraceSchemaVersion::V1,
1099 reason: format!("no import path from {from_rel} to {to_rel}"),
1100 from: from_rel,
1101 to: to_rel,
1102 reachable: false,
1103 hops: 0,
1104 path: Vec::new(),
1105 });
1106 };
1107
1108 if hops.is_empty() {
1109 return Ok(ImportPathTrace {
1110 schema_version: ImportPathTraceSchemaVersion::V1,
1111 reason: format!("{from_rel} is the same module as {to_rel}"),
1112 from: from_rel,
1113 to: to_rel,
1114 reachable: true,
1115 hops: 0,
1116 path: Vec::new(),
1117 });
1118 }
1119
1120 let path = resolve_import_path_hops(graph, root, &hops);
1121 let hop_count = path.len();
1122 let plural = if hop_count == 1 { "hop" } else { "hops" };
1123 let reason = if path.iter().all(|hop| hop.type_only) {
1127 format!(
1128 "{from_rel} reaches {to_rel} in {hop_count} type-only {plural}, erased at build time"
1129 )
1130 } else {
1131 format!("{from_rel} reaches {to_rel} in {hop_count} {plural}")
1132 };
1133
1134 Ok(ImportPathTrace {
1135 schema_version: ImportPathTraceSchemaVersion::V1,
1136 from: from_rel,
1137 to: to_rel,
1138 reachable: true,
1139 hops: hop_count,
1140 path,
1141 reason,
1142 })
1143}
1144
1145fn resolve_import_path_hops(
1149 graph: &ModuleGraph,
1150 root: &Path,
1151 hops: &[GraphImportPathHop],
1152) -> Vec<ImportPathHop> {
1153 let mut line_offsets: FxHashMap<FileId, Option<Vec<u32>>> = FxHashMap::default();
1154 hops.iter()
1155 .filter_map(|hop| {
1156 let from = graph.modules.get(hop.from.0 as usize)?;
1157 let to = graph.modules.get(hop.to.0 as usize)?;
1158 let import_line = hop.import_span_start.and_then(|span_start| {
1159 line_offsets
1160 .entry(hop.from)
1161 .or_insert_with(|| {
1162 std::fs::read_to_string(&from.path)
1163 .ok()
1164 .map(|source| fallow_types::extract::compute_line_offsets(&source))
1165 })
1166 .as_ref()
1167 .map(|offsets| {
1168 fallow_types::extract::byte_offset_to_line_col(offsets, span_start).0
1169 })
1170 });
1171 Some(ImportPathHop {
1172 from: relativize(&from.path, root),
1173 to: relativize(&to.path, root),
1174 type_only: hop.all_type_only,
1175 import_line,
1176 })
1177 })
1178 .collect()
1179}
1180
1181pub fn relativize(path: &Path, root: &Path) -> String {
1183 path.strip_prefix(root)
1184 .unwrap_or(path)
1185 .to_string_lossy()
1186 .replace('\\', "/")
1187}
1188
1189fn build_traced_group(
1193 group: &CloneGroup,
1194 root: &Path,
1195 fingerprints: &CloneFingerprintSet,
1196) -> TracedCloneGroup {
1197 TracedCloneGroup {
1198 fingerprint: fingerprints.fingerprint_for_group(group),
1199 token_count: group.token_count,
1200 line_count: group.line_count,
1201 spread: group.spread(),
1202 similarity: group.similarity,
1203 instances: group
1204 .instances
1205 .iter()
1206 .map(|inst| relativize_instance(inst, root))
1207 .collect(),
1208 suggestion: group_refactoring_suggestion(group),
1209 suggested_name: dominant_identifier(group),
1210 }
1211}
1212
1213#[must_use]
1214pub fn trace_clone(
1215 report: &DuplicationReport,
1216 root: &Path,
1217 file_path: &str,
1218 line: usize,
1219) -> CloneTrace {
1220 let resolved = root.join(file_path);
1221 let mut matched_instance = None;
1222 let mut clone_groups = Vec::new();
1223 let fingerprints = CloneFingerprintSet::from_groups(&report.clone_groups);
1224
1225 for group in &report.clone_groups {
1226 let matching = group.instances.iter().find(|inst| {
1227 let inst_matches = inst.file == resolved
1228 || inst.file.strip_prefix(root).unwrap_or(&inst.file) == Path::new(file_path);
1229 inst_matches && inst.start_line <= line && line <= inst.end_line
1230 });
1231
1232 if let Some(matched) = matching {
1233 if matched_instance.is_none() {
1234 matched_instance = Some(relativize_instance(matched, root));
1235 }
1236 clone_groups.push(build_traced_group(group, root, &fingerprints));
1237 }
1238 }
1239
1240 CloneTrace {
1241 file: PathBuf::from(file_path),
1242 line,
1243 matched_instance,
1244 clone_groups,
1245 }
1246}
1247
1248#[must_use]
1258pub fn trace_clone_by_fingerprint(
1259 report: &DuplicationReport,
1260 root: &Path,
1261 fingerprint: &str,
1262) -> CloneTrace {
1263 let fingerprints = CloneFingerprintSet::from_groups(&report.clone_groups);
1264 let matched = fingerprints.find_group(&report.clone_groups, fingerprint);
1265
1266 let Some(group) = matched else {
1267 return CloneTrace {
1268 file: PathBuf::new(),
1269 line: 0,
1270 matched_instance: None,
1271 clone_groups: Vec::new(),
1272 };
1273 };
1274
1275 let representative = group
1276 .instances
1277 .first()
1278 .map(|inst| relativize_instance(inst, root));
1279 let (file, line) = representative.as_ref().map_or_else(
1280 || (PathBuf::new(), 0),
1281 |inst| (inst.file.clone(), inst.start_line),
1282 );
1283
1284 CloneTrace {
1285 file,
1286 line,
1287 matched_instance: representative,
1288 clone_groups: vec![build_traced_group(group, root, &fingerprints)],
1289 }
1290}
1291
1292fn relativize_instance(inst: &CloneInstance, root: &Path) -> CloneInstance {
1296 let rel = inst.file.strip_prefix(root).map_or_else(
1297 |_| inst.file.clone(),
1298 |p| PathBuf::from(p.to_string_lossy().replace('\\', "/")),
1299 );
1300 CloneInstance {
1301 file: rel,
1302 ..inst.clone()
1303 }
1304}
1305
1306#[cfg(test)]
1307mod tests {
1308 use super::*;
1309
1310 use crate::discover::{DiscoveredFile, EntryPoint, EntryPointSource, FileId};
1311 use fallow_graph::resolve::{ResolveResult, ResolvedImport, ResolvedModule, ResolvedReExport};
1312 use fallow_types::extract::ReExportInfo;
1313 use fallow_types::extract::{ExportInfo, ExportName, ImportInfo, ImportedName, VisibilityTag};
1314
1315 fn resolved_re_export(
1316 source: FileId,
1317 imported_name: &str,
1318 exported_name: &str,
1319 ) -> ResolvedReExport {
1320 ResolvedReExport {
1321 info: ReExportInfo {
1322 source: "./source".to_string(),
1323 imported_name: imported_name.to_string(),
1324 exported_name: exported_name.to_string(),
1325 is_type_only: false,
1326 span: oxc_span::Span::default(),
1327 statement_span: oxc_span::Span::default(),
1328 source_span: oxc_span::Span::default(),
1329 },
1330 target: ResolveResult::InternalModule(source),
1331 }
1332 }
1333
1334 fn build_test_graph() -> ModuleGraph {
1335 let files = vec![
1336 DiscoveredFile {
1337 id: FileId(0),
1338 path: PathBuf::from("/project/src/entry.ts"),
1339 size_bytes: 100,
1340 },
1341 DiscoveredFile {
1342 id: FileId(1),
1343 path: PathBuf::from("/project/src/utils.ts"),
1344 size_bytes: 50,
1345 },
1346 DiscoveredFile {
1347 id: FileId(2),
1348 path: PathBuf::from("/project/src/unused.ts"),
1349 size_bytes: 30,
1350 },
1351 ];
1352
1353 let entry_points = vec![EntryPoint {
1354 path: PathBuf::from("/project/src/entry.ts"),
1355 source: EntryPointSource::PackageJsonMain,
1356 }];
1357
1358 let resolved_modules = vec![
1359 ResolvedModule {
1360 file_id: FileId(0),
1361 path: PathBuf::from("/project/src/entry.ts"),
1362 resolved_imports: vec![ResolvedImport {
1363 info: ImportInfo {
1364 source: "./utils".to_string(),
1365 imported_name: ImportedName::Named("foo".to_string()),
1366 local_name: "foo".to_string(),
1367 is_type_only: false,
1368 is_type_only_star: false,
1369 from_style: false,
1370 span: oxc_span::Span::new(0, 10),
1371 source_span: oxc_span::Span::default(),
1372 },
1373 target: ResolveResult::InternalModule(FileId(1)),
1374 }],
1375 ..Default::default()
1376 },
1377 ResolvedModule {
1378 file_id: FileId(1),
1379 path: PathBuf::from("/project/src/utils.ts"),
1380 exports: vec![
1381 ExportInfo {
1382 name: ExportName::Named("foo".to_string()),
1383 local_name: Some("foo".to_string()),
1384 is_type_only: false,
1385 visibility: VisibilityTag::None,
1386 expected_unused_reason: None,
1387 span: oxc_span::Span::new(0, 20),
1388 members: vec![],
1389 is_side_effect_used: false,
1390 super_class: None,
1391 deprecated: false,
1392 deprecated_reason: None,
1393 },
1394 ExportInfo {
1395 name: ExportName::Named("bar".to_string()),
1396 local_name: Some("bar".to_string()),
1397 is_type_only: false,
1398 visibility: VisibilityTag::None,
1399 expected_unused_reason: None,
1400 span: oxc_span::Span::new(21, 40),
1401 members: vec![],
1402 is_side_effect_used: false,
1403 super_class: None,
1404 deprecated: false,
1405 deprecated_reason: None,
1406 },
1407 ]
1408 .into(),
1409 ..Default::default()
1410 },
1411 ResolvedModule {
1412 file_id: FileId(2),
1413 path: PathBuf::from("/project/src/unused.ts"),
1414 exports: vec![ExportInfo {
1415 name: ExportName::Named("baz".to_string()),
1416 local_name: Some("baz".to_string()),
1417 is_type_only: false,
1418 visibility: VisibilityTag::None,
1419 expected_unused_reason: None,
1420 span: oxc_span::Span::new(0, 15),
1421 members: vec![],
1422 is_side_effect_used: false,
1423 super_class: None,
1424 deprecated: false,
1425 deprecated_reason: None,
1426 }]
1427 .into(),
1428 ..Default::default()
1429 },
1430 ];
1431
1432 ModuleGraph::build(&resolved_modules, &entry_points, &files)
1433 }
1434
1435 #[test]
1436 fn trace_used_export() {
1437 let graph = build_test_graph();
1438 let root = Path::new("/project");
1439
1440 let trace = trace_export(&graph, root, "src/utils.ts", "foo").unwrap();
1441 assert!(trace.is_used);
1442 assert!(trace.file_reachable);
1443 assert_eq!(trace.direct_references.len(), 1);
1444 assert_eq!(
1445 trace.direct_references[0].from_file,
1446 PathBuf::from("src/entry.ts")
1447 );
1448 assert_eq!(trace.direct_references[0].kind, "named import");
1449 }
1450
1451 #[test]
1452 fn trace_unused_export() {
1453 let graph = build_test_graph();
1454 let root = Path::new("/project");
1455
1456 let trace = trace_export(&graph, root, "src/utils.ts", "bar").unwrap();
1457 assert!(!trace.is_used);
1458 assert!(trace.file_reachable);
1459 assert!(trace.direct_references.is_empty());
1460 assert_eq!(
1461 trace.namespace,
1462 fallow_types::semantic::SemanticNamespace::Value,
1463 "an unreferenced value export stays in the value namespace"
1464 );
1465 }
1466
1467 #[test]
1468 fn trace_unreachable_file_export() {
1469 let graph = build_test_graph();
1470 let root = Path::new("/project");
1471
1472 let trace = trace_export(&graph, root, "src/unused.ts", "baz").unwrap();
1473 assert!(!trace.is_used);
1474 assert!(!trace.file_reachable);
1475 assert!(trace.reason.contains("unreachable"));
1476 }
1477
1478 #[test]
1479 fn trace_nonexistent_export() {
1480 let graph = build_test_graph();
1481 let root = Path::new("/project");
1482
1483 let trace = trace_export(&graph, root, "src/utils.ts", "nonexistent");
1484 assert!(trace.is_none());
1485 }
1486
1487 #[test]
1488 fn trace_reports_only_the_effective_re_export_origin() {
1489 let files: Vec<_> = ["entry", "barrel", "star-source", "explicit-source"]
1490 .into_iter()
1491 .enumerate()
1492 .map(|(index, name)| DiscoveredFile {
1493 id: FileId(index as u32),
1494 path: PathBuf::from(format!("/project/src/{name}.ts")),
1495 size_bytes: 10,
1496 })
1497 .collect();
1498 let entry_points = vec![EntryPoint {
1499 path: files[0].path.clone(),
1500 source: EntryPointSource::PackageJsonMain,
1501 }];
1502 let re_export = |source: FileId, imported: &str, exported: &str| ResolvedReExport {
1503 info: ReExportInfo {
1504 source: format!("./{}", source.0),
1505 imported_name: imported.to_string(),
1506 exported_name: exported.to_string(),
1507 is_type_only: false,
1508 span: oxc_span::Span::default(),
1509 statement_span: oxc_span::Span::default(),
1510 source_span: oxc_span::Span::default(),
1511 },
1512 target: ResolveResult::InternalModule(source),
1513 };
1514 let export = || ExportInfo {
1515 name: ExportName::Named("foo".to_string()),
1516 local_name: Some("foo".to_string()),
1517 is_type_only: false,
1518 visibility: VisibilityTag::None,
1519 expected_unused_reason: None,
1520 span: oxc_span::Span::new(0, 3),
1521 members: Vec::new(),
1522 is_side_effect_used: false,
1523 super_class: None,
1524 deprecated: false,
1525 deprecated_reason: None,
1526 };
1527 let resolved = vec![
1528 ResolvedModule {
1529 file_id: FileId(0),
1530 path: files[0].path.clone(),
1531 resolved_imports: vec![ResolvedImport {
1532 info: ImportInfo {
1533 source: "./barrel".to_string(),
1534 imported_name: ImportedName::Named("foo".to_string()),
1535 local_name: "foo".to_string(),
1536 is_type_only: false,
1537 is_type_only_star: false,
1538 from_style: false,
1539 span: oxc_span::Span::default(),
1540 source_span: oxc_span::Span::default(),
1541 },
1542 target: ResolveResult::InternalModule(FileId(1)),
1543 }],
1544 ..Default::default()
1545 },
1546 ResolvedModule {
1547 file_id: FileId(1),
1548 path: files[1].path.clone(),
1549 re_exports: vec![
1550 re_export(FileId(2), "*", "*"),
1551 re_export(FileId(3), "foo", "foo"),
1552 ],
1553 ..Default::default()
1554 },
1555 ResolvedModule {
1556 file_id: FileId(2),
1557 path: files[2].path.clone(),
1558 exports: vec![export()].into(),
1559 ..Default::default()
1560 },
1561 ResolvedModule {
1562 file_id: FileId(3),
1563 path: files[3].path.clone(),
1564 exports: vec![export()].into(),
1565 ..Default::default()
1566 },
1567 ];
1568 let graph = ModuleGraph::build(&resolved, &entry_points, &files);
1569
1570 let shadowed = trace_export(&graph, Path::new("/project"), "src/star-source.ts", "foo")
1571 .expect("shadowed source export exists");
1572 let effective = trace_export(
1573 &graph,
1574 Path::new("/project"),
1575 "src/explicit-source.ts",
1576 "foo",
1577 )
1578 .expect("effective source export exists");
1579
1580 assert!(shadowed.re_export_chains.is_empty());
1581 assert_eq!(effective.re_export_chains.len(), 1);
1582 assert_eq!(effective.re_export_chains[0].exported_as, "foo");
1583 }
1584
1585 fn star_surface_trace_graph(root: &Path) -> ModuleGraph {
1586 let src = root.join("src");
1587 std::fs::create_dir_all(&src).expect("create source directory");
1588 let paths: Vec<_> = ["source", "barrel-a", "barrel-b", "outer", "entry"]
1589 .into_iter()
1590 .map(|name| src.join(format!("{name}.ts")))
1591 .collect();
1592 std::fs::write(&paths[0], "\n\nexport const foo = 1;\n").expect("write source");
1593 for path in &paths[1..] {
1594 std::fs::write(path, "export {};\n").expect("write module");
1595 }
1596 let files: Vec<_> = paths
1597 .iter()
1598 .enumerate()
1599 .map(|(index, path)| DiscoveredFile {
1600 id: FileId(index as u32),
1601 path: path.clone(),
1602 size_bytes: 20,
1603 })
1604 .collect();
1605 let resolved = vec![
1606 ResolvedModule {
1607 file_id: FileId(0),
1608 path: paths[0].clone(),
1609 exports: vec![ExportInfo {
1610 name: ExportName::Named("foo".to_string()),
1611 local_name: Some("foo".to_string()),
1612 is_type_only: false,
1613 visibility: VisibilityTag::None,
1614 expected_unused_reason: None,
1615 span: oxc_span::Span::new(2, 5),
1616 members: Vec::new(),
1617 is_side_effect_used: false,
1618 super_class: None,
1619 deprecated: false,
1620 deprecated_reason: None,
1621 }]
1622 .into(),
1623 ..Default::default()
1624 },
1625 ResolvedModule {
1626 file_id: FileId(1),
1627 path: paths[1].clone(),
1628 re_exports: vec![resolved_re_export(FileId(0), "*", "*")],
1629 ..Default::default()
1630 },
1631 ResolvedModule {
1632 file_id: FileId(2),
1633 path: paths[2].clone(),
1634 re_exports: vec![resolved_re_export(FileId(0), "*", "*")],
1635 ..Default::default()
1636 },
1637 ResolvedModule {
1638 file_id: FileId(3),
1639 path: paths[3].clone(),
1640 re_exports: vec![
1641 resolved_re_export(FileId(1), "foo", "left"),
1642 resolved_re_export(FileId(1), "foo", "right"),
1643 ],
1644 ..Default::default()
1645 },
1646 ResolvedModule {
1647 file_id: FileId(4),
1648 path: paths[4].clone(),
1649 resolved_imports: vec![
1650 ResolvedImport {
1651 info: ImportInfo {
1652 source: "./outer".to_string(),
1653 imported_name: ImportedName::Named("left".to_string()),
1654 local_name: "left".to_string(),
1655 is_type_only: false,
1656 is_type_only_star: false,
1657 from_style: false,
1658 span: oxc_span::Span::new(10, 20),
1659 source_span: oxc_span::Span::default(),
1660 },
1661 target: ResolveResult::InternalModule(FileId(3)),
1662 },
1663 ResolvedImport {
1664 info: ImportInfo {
1665 source: "./barrel-b".to_string(),
1666 imported_name: ImportedName::Named("foo".to_string()),
1667 local_name: "otherFoo".to_string(),
1668 is_type_only: false,
1669 is_type_only_star: false,
1670 from_style: false,
1671 span: oxc_span::Span::new(30, 40),
1672 source_span: oxc_span::Span::default(),
1673 },
1674 target: ResolveResult::InternalModule(FileId(2)),
1675 },
1676 ],
1677 ..Default::default()
1678 },
1679 ];
1680 let entry_points = vec![EntryPoint {
1681 path: paths[4].clone(),
1682 source: EntryPointSource::PackageJsonMain,
1683 }];
1684 ModuleGraph::build(&resolved, &entry_points, &files)
1685 }
1686
1687 #[test]
1688 fn star_surface_trace_keeps_aliases_separate_and_uses_origin_identity() {
1689 let root = tempfile::tempdir().expect("temporary project");
1690 let graph = star_surface_trace_graph(root.path());
1691
1692 let used = trace_export(&graph, root.path(), "src/barrel-a.ts", "foo")
1693 .expect("aliased barrel exposes foo");
1694 let sibling = trace_export(&graph, root.path(), "src/barrel-b.ts", "foo")
1695 .expect("sibling barrel exposes foo");
1696 assert!(used.is_used);
1697 assert_eq!(used.direct_references.len(), 1);
1698 assert!(sibling.is_used);
1699 assert_eq!(sibling.direct_references.len(), 1);
1700
1701 let source_trace = trace_export(&graph, root.path(), "src/source.ts", "foo")
1702 .expect("source declaration is traceable");
1703 let chain_count = |file: &str, name: &str| {
1704 source_trace
1705 .re_export_chains
1706 .iter()
1707 .find(|chain| chain.barrel_file == Path::new(file) && chain.exported_as == name)
1708 .map(|chain| chain.reference_count)
1709 };
1710 assert_eq!(chain_count("src/barrel-a.ts", "foo"), Some(1));
1711 assert_eq!(chain_count("src/barrel-b.ts", "foo"), Some(1));
1712 assert_eq!(chain_count("src/outer.ts", "left"), Some(1));
1713 assert_eq!(chain_count("src/outer.ts", "right"), Some(0));
1714
1715 let semantic = semantic_symbol_for_export(&graph, root.path(), "src/barrel-a.ts", "foo")
1716 .expect("star surface resolves to its declaration identity");
1717 assert_eq!(semantic.path, Path::new("src/source.ts"));
1718 assert_eq!(semantic.exported_name, "foo");
1719 assert_eq!(semantic.local_name, "foo");
1720 assert_eq!((semantic.line, semantic.col), (3, 0));
1721
1722 let alias = semantic_symbol_for_export(&graph, root.path(), "src/outer.ts", "left")
1723 .expect("named re-export keeps its export-specifier identity");
1724 assert_eq!(alias.path, Path::new("src/outer.ts"));
1725 assert_eq!(alias.exported_name, "left");
1726 assert_eq!(alias.local_name, "foo");
1727 }
1728
1729 #[test]
1730 fn trace_follows_renamed_and_convergent_re_export_routes() {
1731 let names = [
1732 "source",
1733 "renamed",
1734 "final",
1735 "left",
1736 "right",
1737 "diamond-entry",
1738 ];
1739 let files: Vec<_> = names
1740 .into_iter()
1741 .enumerate()
1742 .map(|(index, name)| DiscoveredFile {
1743 id: FileId(index as u32),
1744 path: PathBuf::from(format!("/project/src/{name}.ts")),
1745 size_bytes: 10,
1746 })
1747 .collect();
1748 let re_export = |source: FileId, imported: &str, exported: &str| ResolvedReExport {
1749 info: ReExportInfo {
1750 source: format!("./{}", source.0),
1751 imported_name: imported.to_string(),
1752 exported_name: exported.to_string(),
1753 is_type_only: false,
1754 span: oxc_span::Span::default(),
1755 statement_span: oxc_span::Span::default(),
1756 source_span: oxc_span::Span::default(),
1757 },
1758 target: ResolveResult::InternalModule(source),
1759 };
1760 let mut resolved: Vec<_> = files
1761 .iter()
1762 .map(|file| ResolvedModule {
1763 file_id: file.id,
1764 path: file.path.clone(),
1765 ..Default::default()
1766 })
1767 .collect();
1768 resolved[0].exports = vec![ExportInfo {
1769 name: ExportName::Named("foo".to_string()),
1770 local_name: Some("foo".to_string()),
1771 is_type_only: false,
1772 visibility: VisibilityTag::None,
1773 expected_unused_reason: None,
1774 span: oxc_span::Span::new(0, 3),
1775 members: Vec::new(),
1776 is_side_effect_used: false,
1777 super_class: None,
1778 deprecated: false,
1779 deprecated_reason: None,
1780 }]
1781 .into();
1782 resolved[1].re_exports = vec![re_export(FileId(0), "foo", "bar")];
1783 resolved[2].re_exports = vec![re_export(FileId(1), "bar", "baz")];
1784 resolved[3].re_exports = vec![re_export(FileId(0), "*", "*")];
1785 resolved[4].re_exports = vec![re_export(FileId(0), "*", "*")];
1786 resolved[5].re_exports = vec![
1787 re_export(FileId(3), "*", "*"),
1788 re_export(FileId(4), "*", "*"),
1789 ];
1790 let entry_points = vec![EntryPoint {
1791 path: files[5].path.clone(),
1792 source: EntryPointSource::PackageJsonMain,
1793 }];
1794 let graph = ModuleGraph::build(&resolved, &entry_points, &files);
1795
1796 let trace = trace_export(&graph, Path::new("/project"), "src/source.ts", "foo")
1797 .expect("source export exists");
1798 let routes: FxHashSet<_> = trace
1799 .re_export_chains
1800 .iter()
1801 .map(|route| (route.barrel_file.as_path(), route.exported_as.as_str()))
1802 .collect();
1803
1804 assert_eq!(routes.len(), 5);
1805 assert!(routes.contains(&(Path::new("src/renamed.ts"), "bar")));
1806 assert!(routes.contains(&(Path::new("src/final.ts"), "baz")));
1807 assert!(routes.contains(&(Path::new("src/left.ts"), "foo")));
1808 assert!(routes.contains(&(Path::new("src/right.ts"), "foo")));
1809 assert!(routes.contains(&(Path::new("src/diamond-entry.ts"), "foo")));
1810 }
1811
1812 #[test]
1813 fn trace_prefers_the_value_namespace_independent_of_usage() {
1814 let files = vec![
1815 DiscoveredFile {
1816 id: FileId(0),
1817 path: PathBuf::from("/project/src/entry.ts"),
1818 size_bytes: 10,
1819 },
1820 DiscoveredFile {
1821 id: FileId(1),
1822 path: PathBuf::from("/project/src/source.ts"),
1823 size_bytes: 10,
1824 },
1825 ];
1826 let export = |is_type_only| ExportInfo {
1827 name: ExportName::Named("Foo".to_string()),
1828 local_name: Some("Foo".to_string()),
1829 is_type_only,
1830 visibility: VisibilityTag::None,
1831 expected_unused_reason: None,
1832 span: oxc_span::Span::new(0, 3),
1833 members: Vec::new(),
1834 is_side_effect_used: false,
1835 super_class: None,
1836 deprecated: false,
1837 deprecated_reason: None,
1838 };
1839 let resolved = vec![
1840 ResolvedModule {
1841 file_id: FileId(0),
1842 path: files[0].path.clone(),
1843 resolved_imports: vec![ResolvedImport {
1844 info: ImportInfo {
1845 source: "./source".to_string(),
1846 imported_name: ImportedName::Named("Foo".to_string()),
1847 local_name: "Foo".to_string(),
1848 is_type_only: true,
1849 is_type_only_star: false,
1850 from_style: false,
1851 span: oxc_span::Span::default(),
1852 source_span: oxc_span::Span::default(),
1853 },
1854 target: ResolveResult::InternalModule(FileId(1)),
1855 }],
1856 ..Default::default()
1857 },
1858 ResolvedModule {
1859 file_id: FileId(1),
1860 path: files[1].path.clone(),
1861 exports: vec![export(false), export(true)].into(),
1862 ..Default::default()
1863 },
1864 ];
1865 let entry_points = vec![EntryPoint {
1866 path: files[0].path.clone(),
1867 source: EntryPointSource::PackageJsonMain,
1868 }];
1869 let graph = ModuleGraph::build(&resolved, &entry_points, &files);
1870
1871 let trace = trace_export(&graph, Path::new("/project"), "src/source.ts", "Foo")
1872 .expect("value export exists");
1873
1874 assert_eq!(
1882 trace.namespace,
1883 fallow_types::semantic::SemanticNamespace::Value
1884 );
1885 assert!(!trace.is_used, "type usage must not select the type export");
1886 }
1887
1888 fn source_consumer_graph(
1891 source_exports: Vec<ExportInfo>,
1892 import_is_type_only: bool,
1893 classified_usage: bool,
1894 semantic_facts: Vec<fallow_types::extract::SemanticFact>,
1895 ) -> ModuleGraph {
1896 let files = vec![
1897 DiscoveredFile {
1898 id: FileId(0),
1899 path: PathBuf::from("/project/src/entry.ts"),
1900 size_bytes: 10,
1901 },
1902 DiscoveredFile {
1903 id: FileId(1),
1904 path: PathBuf::from("/project/src/source.ts"),
1905 size_bytes: 10,
1906 },
1907 ];
1908 let classified = if classified_usage {
1909 vec!["Foo".to_string()]
1910 } else {
1911 Vec::new()
1912 };
1913 let resolved = vec![
1914 ResolvedModule {
1915 file_id: FileId(0),
1916 path: files[0].path.clone(),
1917 resolved_imports: vec![ResolvedImport {
1918 info: ImportInfo {
1919 source: "./source".to_string(),
1920 imported_name: ImportedName::Named("Foo".to_string()),
1921 local_name: "Foo".to_string(),
1922 is_type_only: import_is_type_only,
1923 is_type_only_star: false,
1924 from_style: false,
1925 span: oxc_span::Span::new(0, 10),
1926 source_span: oxc_span::Span::default(),
1927 },
1928 target: ResolveResult::InternalModule(FileId(1)),
1929 }],
1930 type_referenced_import_bindings: classified.clone(),
1931 value_referenced_import_bindings: classified,
1932 ..Default::default()
1933 },
1934 ResolvedModule {
1935 file_id: FileId(1),
1936 path: files[1].path.clone(),
1937 exports: source_exports.into(),
1938 semantic_facts: semantic_facts.into(),
1939 ..Default::default()
1940 },
1941 ];
1942 let entry_points = vec![EntryPoint {
1943 path: files[0].path.clone(),
1944 source: EntryPointSource::PackageJsonMain,
1945 }];
1946 ModuleGraph::build(&resolved, &entry_points, &files)
1947 }
1948
1949 fn dual_lane_consumer_graph(source_exports: Vec<ExportInfo>) -> ModuleGraph {
1953 let files = vec![
1954 DiscoveredFile {
1955 id: FileId(0),
1956 path: PathBuf::from("/project/src/entry.ts"),
1957 size_bytes: 10,
1958 },
1959 DiscoveredFile {
1960 id: FileId(1),
1961 path: PathBuf::from("/project/src/source.ts"),
1962 size_bytes: 10,
1963 },
1964 DiscoveredFile {
1965 id: FileId(2),
1966 path: PathBuf::from("/project/src/typed.ts"),
1967 size_bytes: 10,
1968 },
1969 ];
1970 let consumer = |file_id: FileId, path: PathBuf, is_type_only: bool| ResolvedModule {
1971 file_id,
1972 path,
1973 resolved_imports: vec![ResolvedImport {
1974 info: ImportInfo {
1975 source: "./source".to_string(),
1976 imported_name: ImportedName::Named("Foo".to_string()),
1977 local_name: "Foo".to_string(),
1978 is_type_only,
1979 is_type_only_star: false,
1980 from_style: false,
1981 span: oxc_span::Span::new(0, 10),
1982 source_span: oxc_span::Span::default(),
1983 },
1984 target: ResolveResult::InternalModule(FileId(1)),
1985 }],
1986 ..Default::default()
1987 };
1988 let resolved = vec![
1989 consumer(FileId(0), files[0].path.clone(), false),
1990 ResolvedModule {
1991 file_id: FileId(1),
1992 path: files[1].path.clone(),
1993 exports: source_exports.into(),
1994 ..Default::default()
1995 },
1996 consumer(FileId(2), files[2].path.clone(), true),
1997 ];
1998 let entry_points = vec![
1999 EntryPoint {
2000 path: files[0].path.clone(),
2001 source: EntryPointSource::PackageJsonMain,
2002 },
2003 EntryPoint {
2004 path: files[2].path.clone(),
2005 source: EntryPointSource::PackageJsonMain,
2006 },
2007 ];
2008 ModuleGraph::build(&resolved, &entry_points, &files)
2009 }
2010
2011 fn named_foo_export(is_type_only: bool) -> ExportInfo {
2012 ExportInfo {
2013 name: ExportName::Named("Foo".to_string()),
2014 local_name: Some("Foo".to_string()),
2015 is_type_only,
2016 visibility: VisibilityTag::None,
2017 expected_unused_reason: None,
2018 span: oxc_span::Span::new(0, 3),
2019 members: Vec::new(),
2020 is_side_effect_used: false,
2021 super_class: None,
2022 deprecated: false,
2023 deprecated_reason: None,
2024 }
2025 }
2026
2027 #[test]
2028 fn trace_credits_a_value_only_export_through_the_type_lane() {
2029 let graph = source_consumer_graph(vec![named_foo_export(false)], true, false, Vec::new());
2033
2034 let trace = trace_export(&graph, Path::new("/project"), "src/source.ts", "Foo")
2035 .expect("value export exists");
2036
2037 assert_eq!(
2038 trace.namespace,
2039 fallow_types::semantic::SemanticNamespace::Type,
2040 "the type lane carries the only credit"
2041 );
2042 assert!(
2043 trace.is_used,
2044 "a type-only import credits a value-only export"
2045 );
2046 assert_eq!(trace.direct_references.len(), 1);
2047 assert_eq!(
2048 trace.direct_references[0].from_file,
2049 PathBuf::from("src/entry.ts")
2050 );
2051 assert_eq!(trace.direct_references[0].kind, "named import");
2052 assert_eq!(trace.reason, "Used by 1 file(s)");
2053 }
2054
2055 #[test]
2056 fn trace_omits_redundant_single_namespace_evidence() {
2057 let graph = source_consumer_graph(vec![named_foo_export(false)], false, false, Vec::new());
2058
2059 let trace = trace_export(&graph, Path::new("/project"), "src/source.ts", "Foo")
2060 .expect("value export exists");
2061
2062 assert_eq!(
2063 trace.namespace,
2064 fallow_types::semantic::SemanticNamespace::Value
2065 );
2066 assert_eq!(trace.direct_references.len(), 1);
2067 assert!(trace.direct_references_by_namespace.is_empty());
2068 let json = serde_json::to_value(&trace).expect("serialize trace");
2069 assert!(json.get("direct_references_by_namespace").is_none());
2070 }
2071
2072 #[test]
2073 fn trace_keeps_the_value_lane_when_one_binding_carries_both_lanes() {
2074 let graph = dual_lane_consumer_graph(vec![named_foo_export(false)]);
2080
2081 let trace = trace_export(&graph, Path::new("/project"), "src/source.ts", "Foo")
2082 .expect("value export exists");
2083
2084 assert_eq!(
2085 trace.namespace,
2086 fallow_types::semantic::SemanticNamespace::Value
2087 );
2088 assert!(trace.is_used);
2089 assert_eq!(trace.direct_references.len(), 1);
2090 assert_eq!(
2091 trace.direct_references[0].from_file,
2092 PathBuf::from("src/entry.ts"),
2093 "the value consumer stays the listed reference"
2094 );
2095 assert_eq!(trace.direct_references_by_namespace.len(), 2);
2096 assert!(trace.direct_references_by_namespace.iter().any(|lane| {
2097 lane.namespace == fallow_types::semantic::SemanticNamespace::Type
2098 && lane.reference_count == 1
2099 && lane.references[0].from_file == Path::new("src/typed.ts")
2100 }));
2101 }
2102
2103 #[test]
2104 fn trace_credits_a_declaration_merge_that_stays_one_binding() {
2105 let graph = source_consumer_graph(
2109 vec![named_foo_export(false), named_foo_export(false)],
2110 true,
2111 false,
2112 Vec::new(),
2113 );
2114
2115 let trace = trace_export(&graph, Path::new("/project"), "src/source.ts", "Foo")
2116 .expect("value export exists");
2117
2118 assert_eq!(
2119 trace.namespace,
2120 fallow_types::semantic::SemanticNamespace::Type,
2121 "the merged binding is reachable from the type lane"
2122 );
2123 assert!(trace.is_used);
2124 assert_eq!(trace.direct_references.len(), 1);
2125 assert_eq!(
2126 trace.direct_references[0].from_file,
2127 PathBuf::from("src/entry.ts")
2128 );
2129 }
2130
2131 #[test]
2132 fn trace_credits_a_class_interface_declaration_merge() {
2133 let mut interface = named_foo_export(true);
2134 interface.span = oxc_span::Span::new(0, 3);
2135 let mut class = named_foo_export(false);
2136 class.span = oxc_span::Span::new(4, 7);
2137 let graph = source_consumer_graph(
2138 vec![interface, class],
2139 true,
2140 true,
2141 vec![fallow_types::extract::SemanticFact::DeclarationMerge(
2142 fallow_types::extract::DeclarationMergeFact {
2143 export_spans: vec![(0, 3), (4, 7)],
2144 },
2145 )],
2146 );
2147
2148 let trace = trace_export(&graph, Path::new("/project"), "src/source.ts", "Foo")
2149 .expect("merged class export exists");
2150
2151 assert_eq!(
2152 trace.namespace,
2153 fallow_types::semantic::SemanticNamespace::Type
2154 );
2155 assert!(trace.is_used);
2156 assert_eq!(trace.direct_references.len(), 1);
2157 }
2158
2159 #[test]
2160 fn trace_keeps_the_value_namespace_when_lanes_hold_distinct_bindings() {
2161 let graph = source_consumer_graph(
2168 vec![named_foo_export(false), named_foo_export(true)],
2169 false,
2170 true,
2171 Vec::new(),
2172 );
2173
2174 let trace = trace_export(&graph, Path::new("/project"), "src/source.ts", "Foo")
2175 .expect("value export exists");
2176
2177 assert_eq!(
2178 trace.namespace,
2179 fallow_types::semantic::SemanticNamespace::Value
2180 );
2181 assert!(trace.is_used);
2182 assert_eq!(trace.direct_references.len(), 1);
2183 assert_eq!(
2184 trace.direct_references[0].from_file,
2185 PathBuf::from("src/entry.ts")
2186 );
2187 }
2188
2189 #[test]
2190 fn class_member_trace_inherits_the_type_lane_credit_of_its_owner() {
2191 use fallow_types::extract::{MemberInfo, MemberKind};
2192
2193 let mut owner = named_foo_export(false);
2197 owner.members = vec![MemberInfo {
2198 name: "run".to_string(),
2199 kind: MemberKind::ClassMethod,
2200 span: oxc_span::Span::new(0, 3),
2201 has_decorator: false,
2202 decorator_names: vec![],
2203 is_instance_returning_static: false,
2204 is_self_returning: false,
2205 }];
2206 let graph = source_consumer_graph(vec![owner], true, false, Vec::new());
2207
2208 let trace = trace_class_member(&graph, Path::new("/project"), "src/source.ts", "run")
2209 .expect("member of the traced export");
2210
2211 assert!(trace.owner_is_used, "the type lane credits the owner");
2212 assert_eq!(
2213 trace.owner_namespace,
2214 fallow_types::semantic::SemanticNamespace::Type,
2215 "the member payload names the lane that credits its owner"
2216 );
2217 assert_eq!(trace.owner_direct_references.len(), 1);
2218 assert_eq!(
2219 trace.owner_direct_references[0].from_file,
2220 PathBuf::from("src/entry.ts")
2221 );
2222 assert!(
2223 trace.reason.contains("'Foo' is used by 1 file(s)"),
2224 "the reason must follow the owner's credit: {}",
2225 trace.reason
2226 );
2227 }
2228
2229 #[test]
2230 #[expect(
2231 clippy::too_many_lines,
2232 reason = "the fixture names every ExportInfo field so a new field is a compile error here"
2233 )]
2234 fn trace_preserves_dual_namespace_named_re_exports() {
2235 let files: Vec<_> = ["entry", "barrel", "types", "values"]
2236 .into_iter()
2237 .enumerate()
2238 .map(|(index, name)| DiscoveredFile {
2239 id: FileId(index as u32),
2240 path: PathBuf::from(format!("/project/src/{name}.ts")),
2241 size_bytes: 10,
2242 })
2243 .collect();
2244 let export = |is_type_only| ExportInfo {
2245 name: ExportName::Named("Foo".to_string()),
2246 local_name: Some("Foo".to_string()),
2247 is_type_only,
2248 visibility: VisibilityTag::None,
2249 expected_unused_reason: None,
2250 span: oxc_span::Span::new(0, 3),
2251 members: Vec::new(),
2252 is_side_effect_used: false,
2253 super_class: None,
2254 deprecated: false,
2255 deprecated_reason: None,
2256 };
2257 let re_export = |source: FileId, is_type_only| ResolvedReExport {
2258 info: ReExportInfo {
2259 source: format!("./{}", source.0),
2260 imported_name: "Foo".to_string(),
2261 exported_name: "Foo".to_string(),
2262 is_type_only,
2263 span: oxc_span::Span::default(),
2264 statement_span: oxc_span::Span::default(),
2265 source_span: oxc_span::Span::default(),
2266 },
2267 target: ResolveResult::InternalModule(source),
2268 };
2269 let mut resolved = vec![
2270 ResolvedModule {
2271 file_id: FileId(0),
2272 path: files[0].path.clone(),
2273 resolved_imports: vec![ResolvedImport {
2274 info: ImportInfo {
2275 source: "./barrel".to_string(),
2276 imported_name: ImportedName::Named("Foo".to_string()),
2277 local_name: "Foo".to_string(),
2278 is_type_only: false,
2279 is_type_only_star: false,
2280 from_style: false,
2281 span: oxc_span::Span::new(0, 10),
2282 source_span: oxc_span::Span::default(),
2283 },
2284 target: ResolveResult::InternalModule(FileId(1)),
2285 }],
2286 ..Default::default()
2287 },
2288 ResolvedModule {
2289 file_id: FileId(1),
2290 path: files[1].path.clone(),
2291 re_exports: vec![re_export(FileId(2), true), re_export(FileId(3), false)],
2292 ..Default::default()
2293 },
2294 ResolvedModule {
2295 file_id: FileId(2),
2296 path: files[2].path.clone(),
2297 exports: vec![export(true)].into(),
2298 ..Default::default()
2299 },
2300 ResolvedModule {
2301 file_id: FileId(3),
2302 path: files[3].path.clone(),
2303 exports: vec![export(false)].into(),
2304 ..Default::default()
2305 },
2306 ];
2307 let entry_points = vec![EntryPoint {
2308 path: files[0].path.clone(),
2309 source: EntryPointSource::PackageJsonMain,
2310 }];
2311 let graph = ModuleGraph::build(&resolved, &entry_points, &files);
2312 resolved[1].re_exports.reverse();
2313 let reversed_graph = ModuleGraph::build(&resolved, &entry_points, &files);
2314
2315 let trace = trace_export(&graph, Path::new("/project"), "src/barrel.ts", "Foo")
2316 .expect("barrel exposes Foo in both namespaces");
2317
2318 assert_eq!(
2319 trace.namespace,
2320 fallow_types::semantic::SemanticNamespace::Value
2321 );
2322 assert!(
2323 trace.is_used,
2324 "the value import must credit the value surface"
2325 );
2326 assert_eq!(trace.direct_references.len(), 1);
2327 let reversed_trace = trace_export(
2328 &reversed_graph,
2329 Path::new("/project"),
2330 "src/barrel.ts",
2331 "Foo",
2332 )
2333 .expect("reversed declarations expose the same surface");
2334 assert_eq!(
2335 serde_json::to_value(trace).expect("serialize trace"),
2336 serde_json::to_value(reversed_trace).expect("serialize reversed trace")
2337 );
2338 }
2339
2340 fn build_class_member_graph() -> ModuleGraph {
2341 use fallow_types::extract::{MemberInfo, MemberKind};
2342
2343 let files = vec![
2344 DiscoveredFile {
2345 id: FileId(0),
2346 path: PathBuf::from("/project/src/entry.ts"),
2347 size_bytes: 100,
2348 },
2349 DiscoveredFile {
2350 id: FileId(1),
2351 path: PathBuf::from("/project/src/controller.ts"),
2352 size_bytes: 50,
2353 },
2354 ];
2355 let entry_points = vec![EntryPoint {
2356 path: PathBuf::from("/project/src/entry.ts"),
2357 source: EntryPointSource::PackageJsonMain,
2358 }];
2359 let method = |name: &str| MemberInfo {
2360 name: name.to_string(),
2361 kind: MemberKind::ClassMethod,
2362 span: oxc_span::Span::new(0, 4),
2363 has_decorator: false,
2364 decorator_names: vec![],
2365 is_instance_returning_static: false,
2366 is_self_returning: false,
2367 };
2368 let resolved_modules = vec![
2369 ResolvedModule {
2370 file_id: FileId(0),
2371 path: PathBuf::from("/project/src/entry.ts"),
2372 resolved_imports: vec![ResolvedImport {
2373 info: ImportInfo {
2374 source: "./controller".to_string(),
2375 imported_name: ImportedName::Named("Ctrl".to_string()),
2376 local_name: "Ctrl".to_string(),
2377 is_type_only: false,
2378 is_type_only_star: false,
2379 from_style: false,
2380 span: oxc_span::Span::new(0, 10),
2381 source_span: oxc_span::Span::default(),
2382 },
2383 target: ResolveResult::InternalModule(FileId(1)),
2384 }],
2385 ..Default::default()
2386 },
2387 ResolvedModule {
2388 file_id: FileId(1),
2389 path: PathBuf::from("/project/src/controller.ts"),
2390 exports: vec![ExportInfo {
2391 name: ExportName::Named("Ctrl".to_string()),
2392 local_name: Some("Ctrl".to_string()),
2393 is_type_only: false,
2394 visibility: VisibilityTag::None,
2395 expected_unused_reason: None,
2396 span: oxc_span::Span::new(0, 20),
2397 members: vec![method("used"), method("dead")],
2398 is_side_effect_used: false,
2399 super_class: None,
2400 deprecated: false,
2401 deprecated_reason: None,
2402 }]
2403 .into(),
2404 ..Default::default()
2405 },
2406 ];
2407 ModuleGraph::build(&resolved_modules, &entry_points, &files)
2408 }
2409
2410 #[test]
2411 fn trace_class_member_reports_owner_class() {
2412 let graph = build_class_member_graph();
2415 let root = Path::new("/project");
2416
2417 let trace = trace_class_member(&graph, root, "src/controller.ts", "dead").unwrap();
2418 assert_eq!(trace.owner_export, "Ctrl");
2419 assert_eq!(trace.member_name, "dead");
2420 assert_eq!(trace.member_kind, "class-method");
2421 assert!(trace.owner_is_used);
2422 assert!(trace.owner_file_reachable);
2423 assert_eq!(trace.owner_direct_references.len(), 1);
2424 assert!(
2425 trace.reason.contains("--unused-class-members"),
2426 "reason should point at the member command: {}",
2427 trace.reason
2428 );
2429 }
2430
2431 #[test]
2432 fn trace_class_member_absent_name_is_none() {
2433 let graph = build_class_member_graph();
2436 let root = Path::new("/project");
2437 assert!(trace_class_member(&graph, root, "src/controller.ts", "nope").is_none());
2438 }
2439
2440 fn exact_class_method_fixture() -> (tempfile::TempDir, ModuleGraph) {
2441 use fallow_types::extract::{MemberInfo, MemberKind};
2442
2443 let temp = tempfile::tempdir().unwrap();
2444 let root = temp.path();
2445 let path = root.join("repository.ts");
2446 let source =
2447 "export class Repository {\n save(): void;\n save(): void {}\n run(): void {}\n}\n";
2448 std::fs::write(&path, source).unwrap();
2449 let first = source.find("save").unwrap() as u32;
2450 let second = source.rfind("save").unwrap() as u32;
2451 let run = source.find("run").unwrap() as u32;
2452 let member = |name: &str, start| MemberInfo {
2453 name: name.to_string(),
2454 kind: MemberKind::ClassMethod,
2455 span: oxc_span::Span::new(start, start + 4),
2456 has_decorator: false,
2457 decorator_names: vec![],
2458 is_instance_returning_static: false,
2459 is_self_returning: false,
2460 };
2461 let files = vec![DiscoveredFile {
2462 id: FileId(0),
2463 path: path.clone(),
2464 size_bytes: source.len() as u64,
2465 }];
2466 let resolved_modules = vec![ResolvedModule {
2467 file_id: FileId(0),
2468 path,
2469 exports: vec![ExportInfo {
2470 name: ExportName::Named("Repository".to_string()),
2471 local_name: Some("Repository".to_string()),
2472 is_type_only: false,
2473 visibility: VisibilityTag::None,
2474 expected_unused_reason: None,
2475 span: oxc_span::Span::new(0, source.len() as u32),
2476 members: vec![
2477 member("save", first),
2478 member("save", second),
2479 member("run", run),
2480 ],
2481 is_side_effect_used: false,
2482 super_class: None,
2483 deprecated: false,
2484 deprecated_reason: None,
2485 }]
2486 .into(),
2487 ..Default::default()
2488 }];
2489 let graph = ModuleGraph::build(&resolved_modules, &[], &files);
2490 (temp, graph)
2491 }
2492
2493 #[test]
2494 fn exact_class_method_resolution_rejects_overloads_without_guessing() {
2495 let (temp, graph) = exact_class_method_fixture();
2496 let root = temp.path();
2497 assert_eq!(
2498 semantic_symbol_for_exact_class_method(
2499 &graph,
2500 root,
2501 "repository.ts",
2502 "Repository",
2503 "save",
2504 ),
2505 Err(SemanticClassMethodResolutionError::AmbiguousSymbol)
2506 );
2507 assert_eq!(
2508 semantic_symbol_for_exact_class_method(
2509 &graph,
2510 root,
2511 "repository.ts",
2512 "OtherRepository",
2513 "save",
2514 ),
2515 Err(SemanticClassMethodResolutionError::SymbolNotFound)
2516 );
2517 let resolved = semantic_symbol_for_exact_class_method(
2518 &graph,
2519 root,
2520 "repository.ts",
2521 "Repository",
2522 "run",
2523 )
2524 .unwrap();
2525 assert_eq!(resolved.owner.as_deref(), Some("Repository"));
2526 assert_eq!(resolved.local_name, "run");
2527 }
2528
2529 #[test]
2530 fn exact_class_method_resolution_preserves_error_precedence() {
2531 use fallow_types::extract::MemberKind;
2532
2533 let (temp, mut graph) = exact_class_method_fixture();
2534 let root = temp.path();
2535 for (file, owner, member, expected) in [
2536 (
2537 "missing.ts",
2538 "Repository",
2539 "run",
2540 SemanticClassMethodResolutionError::FileNotFound,
2541 ),
2542 (
2543 "repository.ts",
2544 "Repository",
2545 "missing",
2546 SemanticClassMethodResolutionError::SymbolNotFound,
2547 ),
2548 ] {
2549 assert_eq!(
2550 semantic_symbol_for_exact_class_method(&graph, root, file, owner, member),
2551 Err(expected),
2552 );
2553 }
2554 graph.modules[0].exports[0].members[2].kind = MemberKind::ClassProperty;
2555 assert_eq!(
2556 semantic_symbol_for_exact_class_method(
2557 &graph,
2558 root,
2559 "repository.ts",
2560 "Repository",
2561 "run"
2562 ),
2563 Err(SemanticClassMethodResolutionError::UnsupportedSyntax),
2564 );
2565 graph.modules[0].exports[0].members[2].kind = MemberKind::ClassMethod;
2566 std::fs::remove_file(&graph.modules[0].path).expect("remove fixture source");
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::SymbolNotFound),
2576 );
2577 let (_other_temp, mut other_graph) = exact_class_method_fixture();
2578 let duplicate = other_graph.modules[0]
2579 .exports
2580 .pop()
2581 .expect("fixture declares an owner");
2582 graph.modules[0].exports.push(duplicate);
2583 assert_eq!(
2584 semantic_symbol_for_exact_class_method(
2585 &graph,
2586 root,
2587 "repository.ts",
2588 "Repository",
2589 "run"
2590 ),
2591 Err(SemanticClassMethodResolutionError::AmbiguousSymbol),
2592 "owner ambiguity must be reported before reading source",
2593 );
2594 }
2595
2596 fn build_unreachable_class_member_graph() -> ModuleGraph {
2599 use fallow_types::extract::{MemberInfo, MemberKind};
2600
2601 let files = vec![
2602 DiscoveredFile {
2603 id: FileId(0),
2604 path: PathBuf::from("/project/src/entry.ts"),
2605 size_bytes: 100,
2606 },
2607 DiscoveredFile {
2608 id: FileId(1),
2609 path: PathBuf::from("/project/src/controller.ts"),
2610 size_bytes: 50,
2611 },
2612 ];
2613 let entry_points = vec![EntryPoint {
2614 path: PathBuf::from("/project/src/entry.ts"),
2615 source: EntryPointSource::PackageJsonMain,
2616 }];
2617 let method = |name: &str| MemberInfo {
2618 name: name.to_string(),
2619 kind: MemberKind::ClassMethod,
2620 span: oxc_span::Span::new(0, 4),
2621 has_decorator: false,
2622 decorator_names: vec![],
2623 is_instance_returning_static: false,
2624 is_self_returning: false,
2625 };
2626 let resolved_modules = vec![
2627 ResolvedModule {
2628 file_id: FileId(0),
2629 path: PathBuf::from("/project/src/entry.ts"),
2630 ..Default::default()
2632 },
2633 ResolvedModule {
2634 file_id: FileId(1),
2635 path: PathBuf::from("/project/src/controller.ts"),
2636 exports: vec![ExportInfo {
2637 name: ExportName::Named("Ctrl".to_string()),
2638 local_name: Some("Ctrl".to_string()),
2639 is_type_only: false,
2640 visibility: VisibilityTag::None,
2641 expected_unused_reason: None,
2642 span: oxc_span::Span::new(0, 20),
2643 members: vec![method("dead")],
2644 is_side_effect_used: false,
2645 super_class: None,
2646 deprecated: false,
2647 deprecated_reason: None,
2648 }]
2649 .into(),
2650 ..Default::default()
2651 },
2652 ];
2653 ModuleGraph::build(&resolved_modules, &entry_points, &files)
2654 }
2655
2656 #[test]
2657 fn trace_class_member_unreachable_owner_reports_dead_reason() {
2658 let graph = build_unreachable_class_member_graph();
2661 let root = Path::new("/project");
2662
2663 let trace = trace_class_member(&graph, root, "src/controller.ts", "dead").unwrap();
2664 assert!(!trace.owner_file_reachable);
2665 assert!(
2666 trace.reason.contains("not reachable"),
2667 "unreachable owner reason should say so: {}",
2668 trace.reason
2669 );
2670 assert!(!trace.reason.contains("--unused-class-members"));
2673 }
2674
2675 #[test]
2676 fn trace_class_member_prefers_used_owner_on_name_collision() {
2677 use fallow_types::extract::{MemberInfo, MemberKind};
2681
2682 let files = vec![
2683 DiscoveredFile {
2684 id: FileId(0),
2685 path: PathBuf::from("/project/src/entry.ts"),
2686 size_bytes: 100,
2687 },
2688 DiscoveredFile {
2689 id: FileId(1),
2690 path: PathBuf::from("/project/src/controller.ts"),
2691 size_bytes: 50,
2692 },
2693 ];
2694 let entry_points = vec![EntryPoint {
2695 path: PathBuf::from("/project/src/entry.ts"),
2696 source: EntryPointSource::PackageJsonMain,
2697 }];
2698 let method = |name: &str| MemberInfo {
2699 name: name.to_string(),
2700 kind: MemberKind::ClassMethod,
2701 span: oxc_span::Span::new(0, 4),
2702 has_decorator: false,
2703 decorator_names: vec![],
2704 is_instance_returning_static: false,
2705 is_self_returning: false,
2706 };
2707 let resolved_modules = vec![
2708 ResolvedModule {
2709 file_id: FileId(0),
2710 path: PathBuf::from("/project/src/entry.ts"),
2711 resolved_imports: vec![ResolvedImport {
2712 info: ImportInfo {
2713 source: "./controller".to_string(),
2714 imported_name: ImportedName::Named("UsedCtrl".to_string()),
2715 local_name: "UsedCtrl".to_string(),
2716 is_type_only: false,
2717 is_type_only_star: false,
2718 from_style: false,
2719 span: oxc_span::Span::new(0, 10),
2720 source_span: oxc_span::Span::default(),
2721 },
2722 target: ResolveResult::InternalModule(FileId(1)),
2723 }],
2724 ..Default::default()
2725 },
2726 ResolvedModule {
2727 file_id: FileId(1),
2728 path: PathBuf::from("/project/src/controller.ts"),
2729 exports: vec![
2730 ExportInfo {
2733 name: ExportName::Named("TypeCtrl".to_string()),
2734 local_name: Some("TypeCtrl".to_string()),
2735 is_type_only: true,
2736 visibility: VisibilityTag::None,
2737 expected_unused_reason: None,
2738 span: oxc_span::Span::new(0, 20),
2739 members: vec![method("shared")],
2740 is_side_effect_used: false,
2741 super_class: None,
2742 deprecated: false,
2743 deprecated_reason: None,
2744 },
2745 ExportInfo {
2746 name: ExportName::Named("UsedCtrl".to_string()),
2747 local_name: Some("UsedCtrl".to_string()),
2748 is_type_only: false,
2749 visibility: VisibilityTag::None,
2750 expected_unused_reason: None,
2751 span: oxc_span::Span::new(0, 20),
2752 members: vec![method("shared")],
2753 is_side_effect_used: false,
2754 super_class: None,
2755 deprecated: false,
2756 deprecated_reason: None,
2757 },
2758 ]
2759 .into(),
2760 ..Default::default()
2761 },
2762 ];
2763 let graph = ModuleGraph::build(&resolved_modules, &entry_points, &files);
2764 let root = Path::new("/project");
2765
2766 let trace = trace_class_member(&graph, root, "src/controller.ts", "shared").unwrap();
2767 assert_eq!(
2768 trace.owner_export, "UsedCtrl",
2769 "tie-break must prefer the used, non-type-only owner"
2770 );
2771 assert!(trace.owner_is_used);
2772 }
2773
2774 #[test]
2775 fn trace_nonexistent_file() {
2776 let graph = build_test_graph();
2777 let root = Path::new("/project");
2778
2779 let trace = trace_export(&graph, root, "src/nope.ts", "foo");
2780 assert!(trace.is_none());
2781 }
2782
2783 #[test]
2784 fn trace_file_edges() {
2785 let graph = build_test_graph();
2786 let root = Path::new("/project");
2787
2788 let trace = trace_file(&graph, root, "src/entry.ts").unwrap();
2789 assert!(trace.is_entry_point);
2790 assert!(trace.is_reachable);
2791 assert_eq!(trace.imports_from.len(), 1);
2792 assert_eq!(trace.imports_from[0], PathBuf::from("src/utils.ts"));
2793 assert!(trace.imported_by.is_empty());
2794 }
2795
2796 fn build_nested_duplicate_path_graph() -> ModuleGraph {
2797 let export = |name: &str| ExportInfo {
2798 name: ExportName::Named(name.to_string()),
2799 local_name: Some(name.to_string()),
2800 is_type_only: false,
2801 visibility: VisibilityTag::None,
2802 expected_unused_reason: None,
2803 span: oxc_span::Span::new(0, 20),
2804 members: vec![],
2805 is_side_effect_used: false,
2806 super_class: None,
2807 deprecated: false,
2808 deprecated_reason: None,
2809 };
2810 let nested = PathBuf::from("/project/packages/x/src/a.ts");
2811 let root_file = PathBuf::from("/project/src/a.ts");
2812 let files = vec![
2813 DiscoveredFile {
2814 id: FileId(0),
2815 path: nested.clone(),
2816 size_bytes: 20,
2817 },
2818 DiscoveredFile {
2819 id: FileId(1),
2820 path: root_file.clone(),
2821 size_bytes: 20,
2822 },
2823 ];
2824 let entry_points = vec![EntryPoint {
2825 path: root_file.clone(),
2826 source: EntryPointSource::PackageJsonMain,
2827 }];
2828 let resolved_modules = vec![
2829 ResolvedModule {
2830 file_id: FileId(0),
2831 path: nested,
2832 exports: vec![export("nestedOnly")].into(),
2833 ..Default::default()
2834 },
2835 ResolvedModule {
2836 file_id: FileId(1),
2837 path: root_file,
2838 exports: vec![export("rootOnly")].into(),
2839 ..Default::default()
2840 },
2841 ];
2842 ModuleGraph::build(&resolved_modules, &entry_points, &files)
2843 }
2844
2845 fn graph_with_module_paths(paths: &[PathBuf]) -> ModuleGraph {
2846 let files: Vec<DiscoveredFile> = paths
2847 .iter()
2848 .enumerate()
2849 .map(|(index, path)| DiscoveredFile {
2850 id: FileId(u32::try_from(index).expect("small fixture")),
2851 path: path.clone(),
2852 size_bytes: 1,
2853 })
2854 .collect();
2855 let resolved_modules: Vec<ResolvedModule> = files
2856 .iter()
2857 .map(|file| ResolvedModule {
2858 file_id: file.id,
2859 path: file.path.clone(),
2860 ..Default::default()
2861 })
2862 .collect();
2863 ModuleGraph::build(&resolved_modules, &[], &files)
2864 }
2865
2866 fn assert_lookup_matches_single_scan(graph: &ModuleGraph, root: &Path, queries: &[&str]) {
2867 let lookup = ModulePathLookup::new(graph, root);
2868 for query in queries {
2869 assert_eq!(
2870 lookup.matching(query),
2871 matching_module_indexes(graph, root, query),
2872 "query {query:?}"
2873 );
2874 }
2875 }
2876
2877 const LOOKUP_QUERIES: &[&str] = &[
2878 "src/a.ts",
2879 "./src/a.ts",
2880 "src//a.ts",
2881 r"src\a.ts",
2882 "a.ts",
2883 "x/src/a.ts",
2884 "packages/x/src/a.ts",
2885 "sub/c.ts",
2886 "c.ts",
2887 "src/",
2888 "",
2889 "missing.ts",
2890 "/elsewhere/src/a.ts",
2891 ];
2892
2893 #[test]
2896 fn module_path_lookup_matches_single_scan_lookup() {
2897 let root = Path::new("/project");
2898 let paths: Vec<PathBuf> = [
2899 "src/a.ts",
2900 "packages/x/src/a.ts",
2901 "packages/y/a.ts",
2902 "src/sub/c.ts",
2903 "lib/b.ts",
2904 ]
2905 .iter()
2906 .map(|relative| root.join(relative))
2907 .collect();
2908 let graph = graph_with_module_paths(&paths);
2909 let mut queries = LOOKUP_QUERIES.to_vec();
2910 let absolute = root.join("src/a.ts").to_string_lossy().into_owned();
2911 queries.push(&absolute);
2912 assert_lookup_matches_single_scan(&graph, root, &queries);
2913 }
2914
2915 #[test]
2918 fn module_path_lookup_matches_single_scan_lookup_on_disk() {
2919 let dir = tempfile::tempdir().expect("tempdir");
2920 let root = dir.path();
2921 for relative in ["src/a.ts", "packages/x/src/a.ts", "src/sub/c.ts"] {
2922 let path = root.join(relative);
2923 std::fs::create_dir_all(path.parent().expect("parent")).expect("mkdir");
2924 std::fs::write(&path, "").expect("write");
2925 }
2926 let canonical_root = dunce::canonicalize(root).expect("canonical root");
2927 let paths: Vec<PathBuf> = ["src/a.ts", "packages/x/src/a.ts", "src/sub/c.ts"]
2928 .iter()
2929 .map(|relative| canonical_root.join(relative))
2930 .collect();
2931 let graph = graph_with_module_paths(&paths);
2932 assert_lookup_matches_single_scan(&graph, root, LOOKUP_QUERIES);
2933 }
2934
2935 #[test]
2936 fn root_relative_path_prefers_exact_module_over_nested_suffix() {
2937 let graph = build_nested_duplicate_path_graph();
2938 let root = Path::new("/project");
2939
2940 let file = trace_file(&graph, root, "src/a.ts").expect("root file resolves");
2941 assert_eq!(file.file, PathBuf::from("src/a.ts"));
2942 assert!(file.is_entry_point);
2943
2944 let export = trace_export(&graph, root, "src/a.ts", "rootOnly");
2945 assert!(
2946 export.is_some(),
2947 "an export of the root-level file must resolve"
2948 );
2949 }
2950
2951 #[test]
2952 fn abbreviated_path_keeps_first_suffix_match() {
2953 let graph = build_nested_duplicate_path_graph();
2954 let root = Path::new("/project");
2955
2956 let file = trace_file(&graph, root, "a.ts").expect("suffix match resolves");
2957 assert_eq!(file.file, PathBuf::from("packages/x/src/a.ts"));
2958 }
2959
2960 #[test]
2961 fn trace_file_imported_by() {
2962 let graph = build_test_graph();
2963 let root = Path::new("/project");
2964
2965 let trace = trace_file(&graph, root, "src/utils.ts").unwrap();
2966 assert!(!trace.is_entry_point);
2967 assert!(trace.is_reachable);
2968 assert_eq!(trace.exports.len(), 2);
2969 assert_eq!(trace.imported_by.len(), 1);
2970 assert_eq!(trace.imported_by[0], PathBuf::from("src/entry.ts"));
2971 }
2972
2973 #[test]
2974 fn trace_unreachable_file() {
2975 let graph = build_test_graph();
2976 let root = Path::new("/project");
2977
2978 let trace = trace_file(&graph, root, "src/unused.ts").unwrap();
2979 assert!(!trace.is_reachable);
2980 assert!(!trace.is_entry_point);
2981 assert!(trace.imported_by.is_empty());
2982 }
2983
2984 #[test]
2985 fn trace_dependency_used() {
2986 let files = vec![DiscoveredFile {
2987 id: FileId(0),
2988 path: PathBuf::from("/project/src/app.ts"),
2989 size_bytes: 100,
2990 }];
2991 let entry_points = vec![EntryPoint {
2992 path: PathBuf::from("/project/src/app.ts"),
2993 source: EntryPointSource::PackageJsonMain,
2994 }];
2995 let resolved_modules = vec![ResolvedModule {
2996 file_id: FileId(0),
2997 path: PathBuf::from("/project/src/app.ts"),
2998 resolved_imports: vec![ResolvedImport {
2999 info: ImportInfo {
3000 source: "lodash".to_string(),
3001 imported_name: ImportedName::Named("get".to_string()),
3002 local_name: "get".to_string(),
3003 is_type_only: false,
3004 is_type_only_star: false,
3005 from_style: false,
3006 span: oxc_span::Span::new(0, 10),
3007 source_span: oxc_span::Span::default(),
3008 },
3009 target: ResolveResult::NpmPackage("lodash".to_string()),
3010 }],
3011 ..Default::default()
3012 }];
3013
3014 let graph = ModuleGraph::build(&resolved_modules, &entry_points, &files);
3015 let root = Path::new("/project");
3016
3017 let trace = trace_dependency(&graph, root, "lodash", &FxHashSet::default());
3018 assert!(trace.is_used);
3019 assert!(!trace.used_in_scripts);
3020 assert_eq!(trace.import_count, 1);
3021 assert_eq!(trace.imported_by[0], PathBuf::from("src/app.ts"));
3022 }
3023
3024 #[test]
3025 fn trace_dependency_unused() {
3026 let files = vec![DiscoveredFile {
3027 id: FileId(0),
3028 path: PathBuf::from("/project/src/app.ts"),
3029 size_bytes: 100,
3030 }];
3031 let entry_points = vec![EntryPoint {
3032 path: PathBuf::from("/project/src/app.ts"),
3033 source: EntryPointSource::PackageJsonMain,
3034 }];
3035 let resolved_modules = vec![ResolvedModule {
3036 file_id: FileId(0),
3037 path: PathBuf::from("/project/src/app.ts"),
3038 ..Default::default()
3039 }];
3040
3041 let graph = ModuleGraph::build(&resolved_modules, &entry_points, &files);
3042 let root = Path::new("/project");
3043
3044 let trace = trace_dependency(&graph, root, "nonexistent-pkg", &FxHashSet::default());
3045 assert!(!trace.is_used);
3046 assert!(!trace.used_in_scripts);
3047 assert_eq!(trace.import_count, 0);
3048 assert!(trace.imported_by.is_empty());
3049 }
3050
3051 #[test]
3052 fn trace_dependency_used_only_in_scripts() {
3053 let files = vec![DiscoveredFile {
3054 id: FileId(0),
3055 path: PathBuf::from("/project/src/app.ts"),
3056 size_bytes: 100,
3057 }];
3058 let entry_points = vec![EntryPoint {
3059 path: PathBuf::from("/project/src/app.ts"),
3060 source: EntryPointSource::PackageJsonMain,
3061 }];
3062 let resolved_modules = vec![ResolvedModule {
3063 file_id: FileId(0),
3064 path: PathBuf::from("/project/src/app.ts"),
3065 ..Default::default()
3066 }];
3067
3068 let graph = ModuleGraph::build(&resolved_modules, &entry_points, &files);
3069 let root = Path::new("/project");
3070 let mut script_used = FxHashSet::default();
3071 script_used.insert("microbundle".to_string());
3072
3073 let trace = trace_dependency(&graph, root, "microbundle", &script_used);
3074 assert!(
3075 trace.is_used,
3076 "is_used must be true when the package is referenced from package.json scripts"
3077 );
3078 assert!(trace.used_in_scripts);
3079 assert_eq!(trace.import_count, 0);
3080 assert!(trace.imported_by.is_empty());
3081 }
3082
3083 #[test]
3084 fn trace_clone_finds_matching_group() {
3085 use crate::duplicates::{CloneGroup, CloneInstance, DuplicationReport, DuplicationStats};
3086 let report = DuplicationReport {
3087 clone_groups: vec![CloneGroup {
3088 instances: vec![
3089 CloneInstance {
3090 file: PathBuf::from("/project/src/a.ts"),
3091 start_line: 10,
3092 end_line: 20,
3093 start_col: 0,
3094 end_col: 0,
3095 fragment: "fn foo() {}".to_string(),
3096 },
3097 CloneInstance {
3098 file: PathBuf::from("/project/src/b.ts"),
3099 start_line: 5,
3100 end_line: 15,
3101 start_col: 0,
3102 end_col: 0,
3103 fragment: "fn foo() {}".to_string(),
3104 },
3105 ],
3106 token_count: 60,
3107 line_count: 11,
3108 similarity: None,
3109 }],
3110 clone_families: vec![],
3111 mirrored_directories: vec![],
3112 stats: DuplicationStats {
3113 total_files: 2,
3114 files_with_clones: 2,
3115 total_lines: 100,
3116 duplicated_lines: 22,
3117 total_tokens: 200,
3118 duplicated_tokens: 120,
3119 clone_groups: 1,
3120 clone_families: 0,
3121 clone_instances: 2,
3122 duplication_percentage: 22.0,
3123 clone_groups_below_min_occurrences: 0,
3124 clone_groups_ignored: 0,
3125 near_candidates_skipped: 0,
3126 },
3127 };
3128 let trace = trace_clone(&report, Path::new("/project"), "src/a.ts", 15);
3129 assert!(trace.matched_instance.is_some());
3130 assert_eq!(trace.clone_groups.len(), 1);
3131 assert_eq!(trace.clone_groups[0].instances.len(), 2);
3132 assert!(trace.clone_groups[0].fingerprint.starts_with("dup:"));
3133 assert_eq!(trace.clone_groups[0].suggestion.estimated_savings, 11);
3134 }
3135
3136 #[test]
3137 fn trace_clone_by_fingerprint_resolves_and_misses() {
3138 use crate::duplicates::{
3139 CloneGroup, CloneInstance, DuplicationReport, DuplicationStats, clone_fingerprint,
3140 };
3141 let report = DuplicationReport {
3142 clone_groups: vec![CloneGroup {
3143 instances: vec![
3144 CloneInstance {
3145 file: PathBuf::from("/project/src/a.ts"),
3146 start_line: 10,
3147 end_line: 20,
3148 start_col: 0,
3149 end_col: 0,
3150 fragment: "fn buildInvoice() {}".to_string(),
3151 },
3152 CloneInstance {
3153 file: PathBuf::from("/project/src/b.ts"),
3154 start_line: 5,
3155 end_line: 15,
3156 start_col: 0,
3157 end_col: 0,
3158 fragment: "fn buildInvoice() {}".to_string(),
3159 },
3160 ],
3161 token_count: 60,
3162 line_count: 11,
3163 similarity: None,
3164 }],
3165 clone_families: vec![],
3166 mirrored_directories: vec![],
3167 stats: DuplicationStats::default(),
3168 };
3169 let fp = clone_fingerprint(&report.clone_groups[0].instances);
3170
3171 let hit = trace_clone_by_fingerprint(&report, Path::new("/project"), &fp);
3172 assert!(hit.matched_instance.is_some());
3173 assert_eq!(hit.clone_groups.len(), 1);
3174 assert_eq!(hit.clone_groups[0].fingerprint, fp);
3175 assert_eq!(hit.line, 10);
3176
3177 let miss = trace_clone_by_fingerprint(&report, Path::new("/project"), "dup:deadbeef");
3178 assert!(miss.matched_instance.is_none());
3179 assert!(miss.clone_groups.is_empty());
3180 }
3181
3182 #[test]
3183 fn trace_clone_no_match() {
3184 use crate::duplicates::{CloneGroup, CloneInstance, DuplicationReport, DuplicationStats};
3185 let report = DuplicationReport {
3186 clone_groups: vec![CloneGroup {
3187 instances: vec![CloneInstance {
3188 file: PathBuf::from("/project/src/a.ts"),
3189 start_line: 10,
3190 end_line: 20,
3191 start_col: 0,
3192 end_col: 0,
3193 fragment: "fn foo() {}".to_string(),
3194 }],
3195 token_count: 60,
3196 line_count: 11,
3197 similarity: None,
3198 }],
3199 clone_families: vec![],
3200 mirrored_directories: vec![],
3201 stats: DuplicationStats {
3202 total_files: 1,
3203 files_with_clones: 1,
3204 total_lines: 50,
3205 duplicated_lines: 11,
3206 total_tokens: 100,
3207 duplicated_tokens: 60,
3208 clone_groups: 1,
3209 clone_families: 0,
3210 clone_instances: 1,
3211 duplication_percentage: 22.0,
3212 clone_groups_below_min_occurrences: 0,
3213 clone_groups_ignored: 0,
3214 near_candidates_skipped: 0,
3215 },
3216 };
3217 let trace = trace_clone(&report, Path::new("/project"), "src/a.ts", 25);
3218 assert!(trace.matched_instance.is_none());
3219 assert!(trace.clone_groups.is_empty());
3220 }
3221
3222 #[test]
3223 fn trace_clone_line_boundary() {
3224 use crate::duplicates::{CloneGroup, CloneInstance, DuplicationReport, DuplicationStats};
3225 let report = DuplicationReport {
3226 clone_groups: vec![CloneGroup {
3227 instances: vec![
3228 CloneInstance {
3229 file: PathBuf::from("/project/src/a.ts"),
3230 start_line: 10,
3231 end_line: 20,
3232 start_col: 0,
3233 end_col: 0,
3234 fragment: "code".to_string(),
3235 },
3236 CloneInstance {
3237 file: PathBuf::from("/project/src/b.ts"),
3238 start_line: 1,
3239 end_line: 11,
3240 start_col: 0,
3241 end_col: 0,
3242 fragment: "code".to_string(),
3243 },
3244 ],
3245 token_count: 50,
3246 line_count: 11,
3247 similarity: None,
3248 }],
3249 clone_families: vec![],
3250 mirrored_directories: vec![],
3251 stats: DuplicationStats {
3252 total_files: 2,
3253 files_with_clones: 2,
3254 total_lines: 100,
3255 duplicated_lines: 22,
3256 total_tokens: 200,
3257 duplicated_tokens: 100,
3258 clone_groups: 1,
3259 clone_families: 0,
3260 clone_instances: 2,
3261 duplication_percentage: 22.0,
3262 clone_groups_below_min_occurrences: 0,
3263 clone_groups_ignored: 0,
3264 near_candidates_skipped: 0,
3265 },
3266 };
3267 let root = Path::new("/project");
3268 assert!(
3269 trace_clone(&report, root, "src/a.ts", 10)
3270 .matched_instance
3271 .is_some()
3272 );
3273 assert!(
3274 trace_clone(&report, root, "src/a.ts", 20)
3275 .matched_instance
3276 .is_some()
3277 );
3278 assert!(
3279 trace_clone(&report, root, "src/a.ts", 21)
3280 .matched_instance
3281 .is_none()
3282 );
3283 }
3284
3285 #[test]
3286 fn trace_clone_returns_relative_instance_paths() {
3287 use crate::duplicates::{CloneGroup, CloneInstance, DuplicationReport, DuplicationStats};
3288 let report = DuplicationReport {
3289 clone_groups: vec![CloneGroup {
3290 instances: vec![
3291 CloneInstance {
3292 file: PathBuf::from("/project/src/a.ts"),
3293 start_line: 1,
3294 end_line: 10,
3295 start_col: 0,
3296 end_col: 0,
3297 fragment: "code".to_string(),
3298 },
3299 CloneInstance {
3300 file: PathBuf::from("/project/src/b.ts"),
3301 start_line: 1,
3302 end_line: 10,
3303 start_col: 0,
3304 end_col: 0,
3305 fragment: "code".to_string(),
3306 },
3307 ],
3308 token_count: 50,
3309 line_count: 10,
3310 similarity: None,
3311 }],
3312 clone_families: vec![],
3313 mirrored_directories: vec![],
3314 stats: DuplicationStats {
3315 total_files: 2,
3316 files_with_clones: 2,
3317 total_lines: 50,
3318 duplicated_lines: 20,
3319 total_tokens: 100,
3320 duplicated_tokens: 100,
3321 clone_groups: 1,
3322 clone_families: 0,
3323 clone_instances: 2,
3324 duplication_percentage: 40.0,
3325 clone_groups_below_min_occurrences: 0,
3326 clone_groups_ignored: 0,
3327 near_candidates_skipped: 0,
3328 },
3329 };
3330 let trace = trace_clone(&report, Path::new("/project"), "src/a.ts", 5);
3331 let matched = trace.matched_instance.as_ref().expect("match expected");
3332 assert_eq!(matched.file, PathBuf::from("src/a.ts"));
3333 for group in &trace.clone_groups {
3334 for inst in &group.instances {
3335 let as_str = inst.file.to_string_lossy();
3336 assert!(
3337 !as_str.starts_with('/'),
3338 "instance file should be relative, got {as_str}",
3339 );
3340 assert!(
3341 !as_str.contains(":\\") && !as_str.contains(":/"),
3342 "instance file should not have a drive letter, got {as_str}",
3343 );
3344 }
3345 }
3346
3347 let json = serde_json::to_string(&trace).expect("serializes");
3348 assert!(
3349 !json.contains("\"/project/"),
3350 "serialized trace should not leak absolute paths: {json}",
3351 );
3352 }
3353
3354 fn single_module_graph(path: &str) -> ModuleGraph {
3361 let path = PathBuf::from(path);
3362 let files = vec![DiscoveredFile {
3363 id: FileId(0),
3364 path: path.clone(),
3365 size_bytes: 10,
3366 }];
3367 let resolved_modules = vec![ResolvedModule {
3368 file_id: FileId(0),
3369 path,
3370 ..Default::default()
3371 }];
3372 ModuleGraph::build(&resolved_modules, &[], &files)
3373 }
3374
3375 #[test]
3376 fn find_module_normalises_windows_module_path_against_posix_user_path() {
3377 let root = Path::new(r"D:\a\fallow\fallow\tests\fixtures\basic-project");
3378 let graph =
3379 single_module_graph(r"D:\a\fallow\fallow\tests\fixtures\basic-project\src\utils.ts");
3380 assert!(find_module(&graph, root, "src/utils.ts").is_some());
3381 assert!(find_module(&graph, root, r"src\utils.ts").is_some());
3382 }
3383
3384 #[test]
3385 fn find_module_suffix_fallback_handles_mixed_separators() {
3386 let root = Path::new("/some/other/root");
3387 let graph =
3388 single_module_graph(r"D:\a\fallow\fallow\tests\fixtures\basic-project\src\utils.ts");
3389 assert!(find_module(&graph, root, "src/utils.ts").is_some());
3390 }
3391
3392 #[test]
3401 fn export_trace_serializes_windows_path_with_forward_slashes() {
3402 let trace = ExportTrace {
3403 file: PathBuf::from(r"src\utils.ts"),
3404 export_name: "foo".to_string(),
3405 namespace: fallow_types::semantic::SemanticNamespace::Value,
3406 file_reachable: true,
3407 is_entry_point: false,
3408 is_used: true,
3409 direct_references: vec![ExportReference {
3410 from_file: PathBuf::from(r"src\entry.ts"),
3411 kind: "named import".to_string(),
3412 }],
3413 direct_references_by_namespace: Vec::new(),
3414 star_export_ambiguity: None,
3415 re_export_chains: vec![ReExportChain {
3416 barrel_file: PathBuf::from(r"src\index.ts"),
3417 exported_as: "foo".to_string(),
3418 reference_count: 1,
3419 }],
3420 reason: "ok".to_string(),
3421 semantic: None,
3422 };
3423 let json = serde_json::to_string(&trace).expect("serializes");
3424 assert!(
3425 json.contains("\"file\":\"src/utils.ts\""),
3426 "ExportTrace.file must serialize with forward slashes: {json}"
3427 );
3428 assert!(
3429 json.contains("\"from_file\":\"src/entry.ts\""),
3430 "ExportReference.from_file must serialize with forward slashes: {json}"
3431 );
3432 assert!(
3433 json.contains("\"barrel_file\":\"src/index.ts\""),
3434 "ReExportChain.barrel_file must serialize with forward slashes: {json}"
3435 );
3436 assert!(
3437 !json.contains(r"\\"),
3438 "no backslash sequence should remain anywhere in the JSON: {json}"
3439 );
3440 }
3441
3442 #[test]
3443 fn file_trace_serializes_windows_paths_with_forward_slashes() {
3444 let trace = FileTrace {
3445 file: PathBuf::from(r"src\utils.ts"),
3446 is_reachable: true,
3447 is_entry_point: false,
3448 exports: vec![],
3449 imports_from: vec![PathBuf::from(r"src\helpers.ts")],
3450 imported_by: vec![PathBuf::from(r"src\entry.ts")],
3451 re_exports: vec![TracedReExport {
3452 source_file: PathBuf::from(r"src\source.ts"),
3453 imported_name: "foo".to_string(),
3454 exported_name: "foo".to_string(),
3455 }],
3456 sources: Vec::new(),
3457 };
3458 let json = serde_json::to_string(&trace).expect("serializes");
3459 assert!(json.contains("\"file\":\"src/utils.ts\""), "got {json}");
3460 assert!(
3461 json.contains("\"imports_from\":[\"src/helpers.ts\"]"),
3462 "got {json}"
3463 );
3464 assert!(
3465 json.contains("\"imported_by\":[\"src/entry.ts\"]"),
3466 "got {json}"
3467 );
3468 assert!(
3469 json.contains("\"source_file\":\"src/source.ts\""),
3470 "got {json}"
3471 );
3472 assert!(!json.contains(r"\\"), "no backslash should remain: {json}");
3473 }
3474}