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