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