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