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