1use std::path::{Path, PathBuf};
2
3pub use fallow_types::trace::{
4 ClassMemberTrace, CloneTrace, DependencyTrace, ExportReference, ExportTrace, FileTrace,
5 ImpactClosureGap, ImpactClosureTrace, NamespacedExportReferences, PipelineTimings,
6 ReExportChain, TracedCloneGroup, TracedExport, TracedReExport,
7};
8use fallow_types::trace_chain::StarExportAmbiguity;
9use rustc_hash::FxHashSet;
10
11use crate::duplicates::{
12 CloneFingerprintSet, CloneGroup, CloneInstance, DuplicationReport, dominant_identifier,
13 group_refactoring_suggestion,
14};
15use crate::graph::{EffectiveExportResolution, ExportNamespace, ModuleGraph, ReferenceKind};
16
17fn path_matches(module_path: &Path, root: &Path, user_path: &str) -> bool {
22 let user_path_norm = user_path.replace('\\', "/");
23 let rel = module_path.strip_prefix(root).unwrap_or(module_path);
24 let rel_str = rel.to_string_lossy().replace('\\', "/");
25 let module_str = module_path.to_string_lossy().replace('\\', "/");
26 if rel_str == user_path_norm || module_str == user_path_norm {
27 return true;
28 }
29 if dunce::canonicalize(root).is_ok_and(|canonical_root| {
30 module_path
31 .strip_prefix(&canonical_root)
32 .is_ok_and(|rel| rel.to_string_lossy().replace('\\', "/") == user_path_norm)
33 }) {
34 return true;
35 }
36 module_str.ends_with(&format!("/{user_path_norm}"))
37}
38
39pub fn reconcile_semantic_trace_reachability(
43 graph: &ModuleGraph,
44 root: &Path,
45 target_reachable: bool,
46 trace: &mut fallow_types::semantic::SemanticSymbolTrace,
47) {
48 if trace.assertion != "references-found" || trace.references.is_empty() {
49 return;
50 }
51 let has_reachable_reference = target_reachable
52 && trace.references.iter().any(|reference| {
53 let reference_path = reference.path.to_string_lossy();
54 graph.modules.iter().any(|module| {
55 path_matches(&module.path, root, &reference_path) && module.is_reachable()
56 })
57 });
58 if has_reachable_reference {
59 return;
60 }
61
62 trace.assertion = "references-only-in-unreachable-files".to_string();
63 trace.status = fallow_types::semantic::SemanticCompleteness::Partial;
64 trace.identity.completeness = fallow_types::semantic::SemanticCompleteness::Partial;
65 let action =
66 "Review the unreachable consumer files before removing this declaration.".to_string();
67 if !trace.actions.contains(&action) {
68 trace.actions.push(action);
69 }
70}
71
72fn reference_to_export_reference(
74 graph: &ModuleGraph,
75 root: &Path,
76 r: &crate::graph::SymbolReference,
77) -> ExportReference {
78 let from_path = graph.modules.get(r.from_file.0 as usize).map_or_else(
79 || PathBuf::from(format!("<unknown:{}>", r.from_file.0)),
80 |m| m.path.strip_prefix(root).unwrap_or(&m.path).to_path_buf(),
81 );
82 ExportReference {
83 from_file: from_path,
84 kind: format_reference_kind(r.kind),
85 }
86}
87
88fn collect_re_export_chains(
90 graph: &ModuleGraph,
91 root: &Path,
92 target_file_id: crate::discover::FileId,
93 export_name: &str,
94 namespace: ExportNamespace,
95) -> Vec<ReExportChain> {
96 graph
97 .effective_re_export_routes(target_file_id, export_name, namespace)
98 .into_iter()
99 .filter_map(|route| {
100 let module = graph.modules.get(route.barrel_file().0 as usize)?;
101 Some(ReExportChain {
102 barrel_file: module
103 .path
104 .strip_prefix(root)
105 .unwrap_or(&module.path)
106 .to_path_buf(),
107 exported_as: route.exported_name().to_string(),
108 reference_count: graph
109 .effective_export_surface_references(
110 route.barrel_file(),
111 route.exported_name(),
112 namespace,
113 )
114 .into_iter()
115 .filter(|reference| {
116 graph
117 .modules
118 .get(reference.from_file.0 as usize)
119 .is_some_and(crate::graph::ModuleNode::is_reachable)
120 })
121 .count(),
122 })
123 })
124 .collect()
125}
126
127fn export_trace_reason(
129 module: &crate::graph::ModuleNode,
130 reference_count: usize,
131 is_used: bool,
132 re_export_chains: &[ReExportChain],
133) -> String {
134 if !module.is_reachable() {
135 "File is unreachable from any entry point".to_string()
136 } else if is_used {
137 format!(
138 "Used by {} file(s){}",
139 reference_count,
140 if re_export_chains.is_empty() {
141 String::new()
142 } else {
143 format!(", re-exported through {} barrel(s)", re_export_chains.len())
144 }
145 )
146 } else if module.is_entry_point() {
147 "No internal references, but file is an entry point (export is externally accessible)"
148 .to_string()
149 } else if !re_export_chains.is_empty() {
150 format!(
151 "Re-exported through {} barrel(s) but no consumer imports it through the barrel",
152 re_export_chains.len()
153 )
154 } else {
155 "No references found, export is unused".to_string()
156 }
157}
158
159#[must_use]
161pub fn trace_export(
162 graph: &ModuleGraph,
163 root: &Path,
164 file_path: &str,
165 export_name: &str,
166) -> Option<ExportTrace> {
167 let module = graph
168 .modules
169 .iter()
170 .find(|m| path_matches(&m.path, root, file_path))?;
171
172 let star_export_ambiguity =
173 trace_star_export_ambiguity(graph, root, module.file_id, export_name);
174 let Some(surface) = select_export(graph, module, export_name) else {
175 let ambiguity = star_export_ambiguity?;
176 let namespace = ambiguity.namespaces.first().copied().unwrap_or_default();
177 return Some(ExportTrace {
178 file: module
179 .path
180 .strip_prefix(root)
181 .unwrap_or(&module.path)
182 .to_path_buf(),
183 export_name: export_name.to_string(),
184 namespace,
185 file_reachable: module.is_reachable(),
186 is_entry_point: module.is_entry_point(),
187 is_used: false,
188 direct_references: Vec::new(),
189 direct_references_by_namespace: Vec::new(),
190 star_export_ambiguity: Some(ambiguity),
191 re_export_chains: Vec::new(),
192 reason: "Star re-export collision makes this name ambiguous".to_string(),
193 semantic: None,
194 });
195 };
196 let (namespace, direct_references, direct_references_by_namespace) =
197 crediting_export_references(graph, root, module.file_id, export_name, surface);
198
199 let re_export_chains =
200 collect_re_export_chains(graph, root, module.file_id, export_name, namespace);
201
202 let reference_count = direct_references.len();
203 let is_used = module.is_reachable() && reference_count > 0;
204 let reason = if star_export_ambiguity.is_some() {
205 "Star re-export collision prevents consumers from resolving this declaration".to_string()
206 } else {
207 export_trace_reason(module, reference_count, is_used, &re_export_chains)
208 };
209
210 Some(ExportTrace {
211 file: module
212 .path
213 .strip_prefix(root)
214 .unwrap_or(&module.path)
215 .to_path_buf(),
216 export_name: export_name.to_string(),
217 namespace: match namespace {
218 ExportNamespace::Type => fallow_types::semantic::SemanticNamespace::Type,
219 ExportNamespace::Value => fallow_types::semantic::SemanticNamespace::Value,
220 },
221 file_reachable: module.is_reachable(),
222 is_entry_point: module.is_entry_point(),
223 is_used,
224 direct_references,
225 direct_references_by_namespace,
226 star_export_ambiguity,
227 re_export_chains,
228 reason,
229 semantic: None,
230 })
231}
232
233fn direct_export_references(
235 graph: &ModuleGraph,
236 root: &Path,
237 file_id: crate::discover::FileId,
238 export_name: &str,
239 namespace: ExportNamespace,
240) -> Vec<ExportReference> {
241 let mut referenced_files = FxHashSet::default();
242 graph
243 .effective_export_surface_references(file_id, export_name, namespace)
244 .into_iter()
245 .filter(|reference| {
246 graph
247 .modules
248 .get(reference.from_file.0 as usize)
249 .is_some_and(crate::graph::ModuleNode::is_reachable)
250 })
251 .filter(|reference| referenced_files.insert(reference.from_file))
252 .map(|r| reference_to_export_reference(graph, root, r))
253 .collect()
254}
255
256fn crediting_export_references(
268 graph: &ModuleGraph,
269 root: &Path,
270 file_id: crate::discover::FileId,
271 export_name: &str,
272 surface: crate::graph::EffectiveExportSurface<'_>,
273) -> (
274 ExportNamespace,
275 Vec<ExportReference>,
276 Vec<NamespacedExportReferences>,
277) {
278 let namespace = surface.namespace();
279 let references = direct_export_references(graph, root, file_id, export_name, namespace);
280 let other = match namespace {
281 ExportNamespace::Type => ExportNamespace::Value,
282 ExportNamespace::Value => ExportNamespace::Type,
283 };
284 let same_binding = graph
285 .effective_export_surface(file_id, export_name, other)
286 .is_some_and(|candidate| {
287 graph.effective_bindings_share_declaration_group(candidate.binding(), surface.binding())
288 });
289 if !same_binding {
290 return (namespace, references, Vec::new());
291 }
292 let other_references = direct_export_references(graph, root, file_id, export_name, other);
293 let by_namespace = if references.is_empty() || other_references.is_empty() {
294 Vec::new()
295 } else {
296 vec![
297 namespaced_references(namespace, references.clone()),
298 namespaced_references(other, other_references.clone()),
299 ]
300 };
301 if references.is_empty() && !other_references.is_empty() {
302 (other, other_references, by_namespace)
303 } else {
304 (namespace, references, by_namespace)
305 }
306}
307
308fn namespaced_references(
309 namespace: ExportNamespace,
310 references: Vec<ExportReference>,
311) -> NamespacedExportReferences {
312 NamespacedExportReferences {
313 namespace: match namespace {
314 ExportNamespace::Type => fallow_types::semantic::SemanticNamespace::Type,
315 ExportNamespace::Value => fallow_types::semantic::SemanticNamespace::Value,
316 },
317 reference_count: references.len(),
318 references,
319 }
320}
321
322fn trace_star_export_ambiguity(
323 graph: &ModuleGraph,
324 root: &Path,
325 file_id: crate::discover::FileId,
326 export_name: &str,
327) -> Option<StarExportAmbiguity> {
328 let collisions: Vec<_> = graph
329 .ambiguous_star_exports()
330 .into_iter()
331 .filter(|collision| {
332 collision.name.as_ref() == export_name
333 && (collision.barrel == file_id || collision.contributors.contains(&file_id))
334 })
335 .collect();
336 if collisions.is_empty() {
337 return None;
338 }
339 let mut sources: Vec<_> = collisions
340 .iter()
341 .flat_map(|collision| collision.contributors.iter())
342 .filter_map(|contributor| graph.modules.get(contributor.0 as usize))
343 .map(|module| {
344 module
345 .path
346 .strip_prefix(root)
347 .unwrap_or(&module.path)
348 .to_path_buf()
349 })
350 .collect();
351 sources.sort();
352 sources.dedup();
353 let mut namespaces: Vec<_> = collisions
354 .iter()
355 .map(|collision| match collision.namespace {
356 ExportNamespace::Type => fallow_types::semantic::SemanticNamespace::Type,
357 ExportNamespace::Value => fallow_types::semantic::SemanticNamespace::Value,
358 })
359 .collect();
360 namespaces.sort_unstable_by_key(|namespace| match namespace {
361 fallow_types::semantic::SemanticNamespace::Type => 0,
362 fallow_types::semantic::SemanticNamespace::Value => 1,
363 });
364 namespaces.dedup();
365 Some(StarExportAmbiguity {
366 sources,
367 namespaces,
368 })
369}
370
371#[must_use]
374pub fn semantic_symbol_for_export(
375 graph: &ModuleGraph,
376 root: &Path,
377 file_path: &str,
378 export_name: &str,
379) -> Option<fallow_types::semantic::SemanticSymbol> {
380 use fallow_types::semantic::{SemanticNamespace, SemanticSymbol};
381
382 let module = graph
383 .modules
384 .iter()
385 .find(|module| path_matches(&module.path, root, file_path))?;
386 let surface = select_export(graph, module, export_name)?;
387 let namespace = surface.namespace();
388 let (identity_module, span, identity_exported_name, local_name) = if let Some(re_export) =
389 graph.effective_export_surface_re_export(module.file_id, export_name, namespace)
390 {
391 let local_name = if re_export.imported_name == "*" {
392 export_name
393 } else {
394 re_export.imported_name.as_str()
395 };
396 (module, re_export.span, export_name, local_name)
397 } else {
398 let origin = surface.origin()?;
399 let origin_module = graph.modules.get(origin.file_id().0 as usize)?;
400 let origin_export = origin.export();
401 let origin_name = match &origin_export.name {
402 fallow_types::extract::ExportName::Named(name) => name.as_str(),
403 fallow_types::extract::ExportName::Default => "default",
404 };
405 (origin_module, origin_export.span, origin_name, origin_name)
406 };
407 let source = std::fs::read_to_string(&identity_module.path).ok()?;
408 let offsets = fallow_types::extract::compute_line_offsets(&source);
409 let (line, col) = fallow_types::extract::byte_offset_to_line_col(&offsets, span.start);
410 Some(SemanticSymbol {
411 path: identity_module
412 .path
413 .strip_prefix(root)
414 .unwrap_or(&identity_module.path)
415 .to_path_buf(),
416 namespace: match namespace {
417 ExportNamespace::Type => SemanticNamespace::Type,
418 ExportNamespace::Value => SemanticNamespace::Value,
419 },
420 declaration_kind: "export".to_string(),
421 exported_name: identity_exported_name.to_string(),
422 local_name: local_name.to_string(),
423 owner: None,
424 line,
425 col,
426 })
427}
428
429#[must_use]
431pub fn semantic_symbol_for_class_member(
432 graph: &ModuleGraph,
433 root: &Path,
434 file_path: &str,
435 member_name: &str,
436) -> Option<fallow_types::semantic::SemanticSymbol> {
437 use fallow_types::extract::MemberKind;
438 use fallow_types::semantic::{SemanticNamespace, SemanticSymbol};
439
440 let module = graph
441 .modules
442 .iter()
443 .find(|module| path_matches(&module.path, root, file_path))?;
444 let (owner, member) = module
445 .exports
446 .iter()
447 .filter_map(|export| {
448 export
449 .members
450 .iter()
451 .find(|member| member.name == member_name)
452 .map(|member| (export, member))
453 })
454 .max_by_key(|(export, _)| (!export.references.is_empty(), !export.is_type_only))?;
455 let declaration_kind = match member.kind {
456 MemberKind::ClassMethod => "class_method",
457 MemberKind::ClassProperty => "class_property",
458 _ => return None,
459 };
460 let source = std::fs::read_to_string(&module.path).ok()?;
461 let offsets = fallow_types::extract::compute_line_offsets(&source);
462 let (line, col) = fallow_types::extract::byte_offset_to_line_col(&offsets, member.span.start);
463 Some(SemanticSymbol {
464 path: module
465 .path
466 .strip_prefix(root)
467 .unwrap_or(&module.path)
468 .to_path_buf(),
469 namespace: SemanticNamespace::Value,
470 declaration_kind: declaration_kind.to_string(),
471 exported_name: member_name.to_string(),
472 local_name: member_name.to_string(),
473 owner: Some(owner.name.to_string()),
474 line,
475 col,
476 })
477}
478
479#[derive(Debug, Clone, Copy, PartialEq, Eq)]
481pub enum SemanticClassMethodResolutionError {
482 FileNotFound,
484 SymbolNotFound,
486 AmbiguousSymbol,
488 UnsupportedSyntax,
490}
491
492impl std::fmt::Display for SemanticClassMethodResolutionError {
493 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
494 let reason = match self {
495 Self::FileNotFound => "file-not-found",
496 Self::SymbolNotFound => "unknown-symbol",
497 Self::AmbiguousSymbol => "ambiguous-symbol",
498 Self::UnsupportedSyntax => "unsupported-syntax",
499 };
500 formatter.write_str(reason)
501 }
502}
503
504pub fn semantic_symbol_for_exact_class_method(
506 graph: &ModuleGraph,
507 root: &Path,
508 file_path: &str,
509 owner_name: &str,
510 member_name: &str,
511) -> Result<fallow_types::semantic::SemanticSymbol, SemanticClassMethodResolutionError> {
512 use fallow_types::extract::MemberKind;
513 use fallow_types::semantic::{SemanticNamespace, SemanticSymbol};
514
515 let module = graph
516 .modules
517 .iter()
518 .find(|module| path_matches(&module.path, root, file_path))
519 .ok_or(SemanticClassMethodResolutionError::FileNotFound)?;
520 let mut owners = module
521 .exports
522 .iter()
523 .filter(|export| export.name.matches_str(owner_name));
524 let owner = owners
525 .next()
526 .ok_or(SemanticClassMethodResolutionError::SymbolNotFound)?;
527 if owners.next().is_some() {
528 return Err(SemanticClassMethodResolutionError::AmbiguousSymbol);
529 }
530 let mut members = owner
531 .members
532 .iter()
533 .filter(|member| member.name == member_name);
534 let member = members
535 .next()
536 .ok_or(SemanticClassMethodResolutionError::SymbolNotFound)?;
537 if members.next().is_some() {
538 return Err(SemanticClassMethodResolutionError::AmbiguousSymbol);
539 }
540 if member.kind != MemberKind::ClassMethod {
541 return Err(SemanticClassMethodResolutionError::UnsupportedSyntax);
542 }
543 let source = std::fs::read_to_string(&module.path)
544 .map_err(|_| SemanticClassMethodResolutionError::SymbolNotFound)?;
545 let offsets = fallow_types::extract::compute_line_offsets(&source);
546 let (line, col) = fallow_types::extract::byte_offset_to_line_col(&offsets, member.span.start);
547 Ok(SemanticSymbol {
548 path: module
549 .path
550 .strip_prefix(root)
551 .unwrap_or(&module.path)
552 .to_path_buf(),
553 namespace: SemanticNamespace::Value,
554 declaration_kind: "class_method".to_string(),
555 exported_name: member_name.to_string(),
556 local_name: member_name.to_string(),
557 owner: Some(owner_name.to_string()),
558 line,
559 col,
560 })
561}
562
563#[must_use]
569pub fn trace_class_member(
570 graph: &ModuleGraph,
571 root: &Path,
572 file_path: &str,
573 member_name: &str,
574) -> Option<ClassMemberTrace> {
575 use fallow_types::extract::MemberKind;
576
577 let module = graph
578 .modules
579 .iter()
580 .find(|m| path_matches(&m.path, root, file_path))?;
581
582 let (owner, member_kind) = 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.kind))
594 })
595 .max_by_key(|(export, _)| (!export.references.is_empty(), !export.is_type_only))?;
596
597 let owner_name = owner.name.to_string();
598 let owner_trace = trace_export(graph, root, file_path, &owner_name)?;
605
606 let (kind_str, filter_flag) = match member_kind {
607 MemberKind::ClassMethod => ("class-method", Some("--unused-class-members")),
608 MemberKind::ClassProperty => ("class-property", Some("--unused-class-members")),
609 MemberKind::EnumMember => ("enum-member", Some("--unused-enum-members")),
610 MemberKind::StoreMember => ("store-member", Some("--unused-store-members")),
611 MemberKind::NamespaceMember => ("namespace-member", None),
612 };
613
614 let reason = class_member_trace_reason(
615 member_name,
616 &owner_name,
617 kind_str,
618 filter_flag,
619 file_path,
620 &owner_trace,
621 );
622
623 Some(ClassMemberTrace {
624 file: owner_trace.file,
625 member_name: member_name.to_string(),
626 member_kind: kind_str.to_string(),
627 owner_export: owner_name,
628 owner_namespace: owner_trace.namespace,
629 owner_is_used: owner_trace.is_used,
630 owner_file_reachable: owner_trace.file_reachable,
631 owner_is_entry_point: owner_trace.is_entry_point,
632 owner_direct_references: owner_trace.direct_references,
633 owner_re_export_chains: owner_trace.re_export_chains,
634 reason,
635 semantic: None,
636 })
637}
638
639fn class_member_trace_reason(
642 member_name: &str,
643 owner_name: &str,
644 kind_str: &str,
645 filter_flag: Option<&str>,
646 file_path: &str,
647 owner_trace: &ExportTrace,
648) -> String {
649 let head =
650 format!("'{member_name}' is a {kind_str} of '{owner_name}', not a top-level export. ");
651 let body = if !owner_trace.file_reachable {
652 format!(
653 "The file is not reachable from any entry point, so '{owner_name}' and all its \
654 members are dead (see the unused-file finding)."
655 )
656 } else if !owner_trace.is_used {
657 format!(
658 "'{owner_name}' is reachable but referenced by no file, so it is reported as an \
659 unused export and its members are not judged individually."
660 )
661 } else {
662 let refs = owner_trace.direct_references.len();
663 match filter_flag {
664 Some(flag) => format!(
665 "'{owner_name}' is used by {refs} file(s); whether '{member_name}' itself is \
666 flagged depends on cross-file member-access resolution. Run \
667 `fallow dead-code {flag} --file {file_path}` to see the member finding."
668 ),
669 None => format!(
670 "'{owner_name}' is used by {refs} file(s); '{member_name}' is credited through \
671 its namespace export."
672 ),
673 }
674 };
675 format!("{head}{body}")
676}
677
678fn select_export<'graph>(
679 graph: &'graph ModuleGraph,
680 module: &'graph crate::graph::ModuleNode,
681 export_name: &str,
682) -> Option<crate::graph::EffectiveExportSurface<'graph>> {
683 [ExportNamespace::Value, ExportNamespace::Type]
684 .into_iter()
685 .find_map(|namespace| {
686 graph.effective_export_surface(module.file_id, export_name, namespace)
687 })
688}
689
690fn traced_exports(
692 graph: &ModuleGraph,
693 root: &Path,
694 module: &crate::graph::ModuleNode,
695) -> Vec<TracedExport> {
696 module
697 .exports
698 .iter()
699 .map(|e| {
700 let referenced_by: Vec<_> = e
701 .physical_references()
702 .map(|r| reference_to_export_reference(graph, root, r))
703 .collect();
704 TracedExport {
705 name: e.name.to_string(),
706 is_type_only: e.is_type_only,
707 reference_count: referenced_by.len(),
708 referenced_by,
709 }
710 })
711 .collect()
712}
713
714fn traced_imports_from(
716 graph: &ModuleGraph,
717 root: &Path,
718 module: &crate::graph::ModuleNode,
719) -> Vec<PathBuf> {
720 graph
721 .edges_for(module.file_id)
722 .iter()
723 .filter_map(|target_id| {
724 graph
725 .modules
726 .get(target_id.0 as usize)
727 .map(|m| m.path.strip_prefix(root).unwrap_or(&m.path).to_path_buf())
728 })
729 .collect()
730}
731
732fn traced_imported_by(
734 graph: &ModuleGraph,
735 root: &Path,
736 module: &crate::graph::ModuleNode,
737) -> Vec<PathBuf> {
738 graph
739 .reverse_deps
740 .get(module.file_id.0 as usize)
741 .map(|deps| {
742 deps.iter()
743 .filter_map(|fid| {
744 graph
745 .modules
746 .get(fid.0 as usize)
747 .map(|m| m.path.strip_prefix(root).unwrap_or(&m.path).to_path_buf())
748 })
749 .collect()
750 })
751 .unwrap_or_default()
752}
753
754fn traced_re_exports(
756 graph: &ModuleGraph,
757 root: &Path,
758 module: &crate::graph::ModuleNode,
759) -> Vec<TracedReExport> {
760 module
761 .re_exports
762 .iter()
763 .map(|re| {
764 let source_path = graph.modules.get(re.source_file.0 as usize).map_or_else(
765 || PathBuf::from(format!("<unknown:{}>", re.source_file.0)),
766 |m| m.path.strip_prefix(root).unwrap_or(&m.path).to_path_buf(),
767 );
768 TracedReExport {
769 source_file: source_path,
770 imported_name: re.imported_name.clone(),
771 exported_name: re.exported_name.clone(),
772 }
773 })
774 .collect()
775}
776
777#[must_use]
779pub fn trace_file(graph: &ModuleGraph, root: &Path, file_path: &str) -> Option<FileTrace> {
780 let module = graph
781 .modules
782 .iter()
783 .find(|m| path_matches(&m.path, root, file_path))?;
784
785 Some(FileTrace {
786 file: module
787 .path
788 .strip_prefix(root)
789 .unwrap_or(&module.path)
790 .to_path_buf(),
791 is_reachable: module.is_reachable(),
792 is_entry_point: module.is_entry_point(),
793 exports: traced_exports(graph, root, module),
794 imports_from: traced_imports_from(graph, root, module),
795 imported_by: traced_imported_by(graph, root, module),
796 re_exports: traced_re_exports(graph, root, module),
797 })
798}
799
800#[must_use]
809pub fn trace_dependency(
810 graph: &ModuleGraph,
811 root: &Path,
812 package_name: &str,
813 script_used_packages: &FxHashSet<String>,
814) -> DependencyTrace {
815 let imported_by: Vec<PathBuf> = graph
816 .package_usage
817 .get(package_name)
818 .map(|ids| {
819 ids.iter()
820 .filter_map(|fid| {
821 graph
822 .modules
823 .get(fid.0 as usize)
824 .map(|m| m.path.strip_prefix(root).unwrap_or(&m.path).to_path_buf())
825 })
826 .collect()
827 })
828 .unwrap_or_default();
829
830 let type_only_imported_by: Vec<PathBuf> = graph
831 .type_only_package_usage
832 .get(package_name)
833 .map(|ids| {
834 ids.iter()
835 .filter_map(|fid| {
836 graph
837 .modules
838 .get(fid.0 as usize)
839 .map(|m| m.path.strip_prefix(root).unwrap_or(&m.path).to_path_buf())
840 })
841 .collect()
842 })
843 .unwrap_or_default();
844
845 let import_count = imported_by.len();
846 let used_in_scripts = script_used_packages.contains(package_name);
847 DependencyTrace {
848 package_name: package_name.to_string(),
849 imported_by,
850 type_only_imported_by,
851 used_in_scripts,
852 is_used: import_count > 0 || used_in_scripts,
853 import_count,
854 }
855}
856
857fn format_reference_kind(kind: ReferenceKind) -> String {
858 match kind {
859 ReferenceKind::NamedImport => "named import".to_string(),
860 ReferenceKind::DefaultImport => "default import".to_string(),
861 ReferenceKind::NamespaceImport => "namespace import".to_string(),
862 ReferenceKind::ReExport => "re-export".to_string(),
863 ReferenceKind::DynamicImport => "dynamic import".to_string(),
864 ReferenceKind::SideEffectImport => "side-effect import".to_string(),
865 }
866}
867
868#[must_use]
875pub fn trace_impact_closure(
876 graph: &ModuleGraph,
877 root: &Path,
878 file_path: &str,
879) -> Option<ImpactClosureTrace> {
880 let module = graph
881 .modules
882 .iter()
883 .find(|m| path_matches(&m.path, root, file_path))?;
884
885 let closure = graph.impact_closure(&[module.file_id]);
886 let paths = graph.closure_with_paths(&closure, root);
887
888 let seed = paths
889 .in_diff
890 .first()
891 .cloned()
892 .unwrap_or_else(|| file_path.replace('\\', "/"));
893
894 let coordination_gap = paths
895 .coordination_gap
896 .into_iter()
897 .map(|gap| ImpactClosureGap {
898 consumer_file: gap.consumer_file,
899 consumed_symbols: gap.consumed_symbols,
900 note: "syntactic attention pointer, not a correctness proof".to_string(),
901 })
902 .collect();
903
904 Some(ImpactClosureTrace {
905 seed,
906 affected_not_shown: paths.affected_not_shown,
907 coordination_gap,
908 })
909}
910
911fn build_traced_group(
915 group: &CloneGroup,
916 root: &Path,
917 fingerprints: &CloneFingerprintSet,
918) -> TracedCloneGroup {
919 TracedCloneGroup {
920 fingerprint: fingerprints.fingerprint_for_group(group),
921 token_count: group.token_count,
922 line_count: group.line_count,
923 spread: group.spread(),
924 similarity: group.similarity,
925 instances: group
926 .instances
927 .iter()
928 .map(|inst| relativize_instance(inst, root))
929 .collect(),
930 suggestion: group_refactoring_suggestion(group),
931 suggested_name: dominant_identifier(group),
932 }
933}
934
935#[must_use]
936pub fn trace_clone(
937 report: &DuplicationReport,
938 root: &Path,
939 file_path: &str,
940 line: usize,
941) -> CloneTrace {
942 let resolved = root.join(file_path);
943 let mut matched_instance = None;
944 let mut clone_groups = Vec::new();
945 let fingerprints = CloneFingerprintSet::from_groups(&report.clone_groups);
946
947 for group in &report.clone_groups {
948 let matching = group.instances.iter().find(|inst| {
949 let inst_matches = inst.file == resolved
950 || inst.file.strip_prefix(root).unwrap_or(&inst.file) == Path::new(file_path);
951 inst_matches && inst.start_line <= line && line <= inst.end_line
952 });
953
954 if let Some(matched) = matching {
955 if matched_instance.is_none() {
956 matched_instance = Some(relativize_instance(matched, root));
957 }
958 clone_groups.push(build_traced_group(group, root, &fingerprints));
959 }
960 }
961
962 CloneTrace {
963 file: PathBuf::from(file_path),
964 line,
965 matched_instance,
966 clone_groups,
967 }
968}
969
970#[must_use]
980pub fn trace_clone_by_fingerprint(
981 report: &DuplicationReport,
982 root: &Path,
983 fingerprint: &str,
984) -> CloneTrace {
985 let fingerprints = CloneFingerprintSet::from_groups(&report.clone_groups);
986 let matched = fingerprints.find_group(&report.clone_groups, fingerprint);
987
988 let Some(group) = matched else {
989 return CloneTrace {
990 file: PathBuf::new(),
991 line: 0,
992 matched_instance: None,
993 clone_groups: Vec::new(),
994 };
995 };
996
997 let representative = group
998 .instances
999 .first()
1000 .map(|inst| relativize_instance(inst, root));
1001 let (file, line) = representative.as_ref().map_or_else(
1002 || (PathBuf::new(), 0),
1003 |inst| (inst.file.clone(), inst.start_line),
1004 );
1005
1006 CloneTrace {
1007 file,
1008 line,
1009 matched_instance: representative,
1010 clone_groups: vec![build_traced_group(group, root, &fingerprints)],
1011 }
1012}
1013
1014fn relativize_instance(inst: &CloneInstance, root: &Path) -> CloneInstance {
1018 let rel = inst.file.strip_prefix(root).map_or_else(
1019 |_| inst.file.clone(),
1020 |p| PathBuf::from(p.to_string_lossy().replace('\\', "/")),
1021 );
1022 CloneInstance {
1023 file: rel,
1024 ..inst.clone()
1025 }
1026}
1027
1028#[cfg(test)]
1029mod tests {
1030 use super::*;
1031
1032 use crate::discover::{DiscoveredFile, EntryPoint, EntryPointSource, FileId};
1033 use crate::extract::{ExportInfo, ExportName, ImportInfo, ImportedName, VisibilityTag};
1034 use crate::resolve::{ResolveResult, ResolvedImport, ResolvedModule, ResolvedReExport};
1035 use fallow_types::extract::ReExportInfo;
1036
1037 fn resolved_re_export(
1038 source: FileId,
1039 imported_name: &str,
1040 exported_name: &str,
1041 ) -> ResolvedReExport {
1042 ResolvedReExport {
1043 info: ReExportInfo {
1044 source: "./source".to_string(),
1045 imported_name: imported_name.to_string(),
1046 exported_name: exported_name.to_string(),
1047 is_type_only: false,
1048 span: oxc_span::Span::default(),
1049 statement_span: oxc_span::Span::default(),
1050 source_span: oxc_span::Span::default(),
1051 },
1052 target: ResolveResult::InternalModule(source),
1053 }
1054 }
1055
1056 fn build_test_graph() -> ModuleGraph {
1057 let files = vec![
1058 DiscoveredFile {
1059 id: FileId(0),
1060 path: PathBuf::from("/project/src/entry.ts"),
1061 size_bytes: 100,
1062 },
1063 DiscoveredFile {
1064 id: FileId(1),
1065 path: PathBuf::from("/project/src/utils.ts"),
1066 size_bytes: 50,
1067 },
1068 DiscoveredFile {
1069 id: FileId(2),
1070 path: PathBuf::from("/project/src/unused.ts"),
1071 size_bytes: 30,
1072 },
1073 ];
1074
1075 let entry_points = vec![EntryPoint {
1076 path: PathBuf::from("/project/src/entry.ts"),
1077 source: EntryPointSource::PackageJsonMain,
1078 }];
1079
1080 let resolved_modules = vec![
1081 ResolvedModule {
1082 file_id: FileId(0),
1083 path: PathBuf::from("/project/src/entry.ts"),
1084 resolved_imports: vec![ResolvedImport {
1085 info: ImportInfo {
1086 source: "./utils".to_string(),
1087 imported_name: ImportedName::Named("foo".to_string()),
1088 local_name: "foo".to_string(),
1089 is_type_only: false,
1090 is_type_only_star: false,
1091 from_style: false,
1092 span: oxc_span::Span::new(0, 10),
1093 source_span: oxc_span::Span::default(),
1094 },
1095 target: ResolveResult::InternalModule(FileId(1)),
1096 }],
1097 ..Default::default()
1098 },
1099 ResolvedModule {
1100 file_id: FileId(1),
1101 path: PathBuf::from("/project/src/utils.ts"),
1102 exports: vec![
1103 ExportInfo {
1104 name: ExportName::Named("foo".to_string()),
1105 local_name: Some("foo".to_string()),
1106 is_type_only: false,
1107 visibility: VisibilityTag::None,
1108 expected_unused_reason: None,
1109 span: oxc_span::Span::new(0, 20),
1110 members: vec![],
1111 is_side_effect_used: false,
1112 super_class: None,
1113 },
1114 ExportInfo {
1115 name: ExportName::Named("bar".to_string()),
1116 local_name: Some("bar".to_string()),
1117 is_type_only: false,
1118 visibility: VisibilityTag::None,
1119 expected_unused_reason: None,
1120 span: oxc_span::Span::new(21, 40),
1121 members: vec![],
1122 is_side_effect_used: false,
1123 super_class: None,
1124 },
1125 ]
1126 .into(),
1127 ..Default::default()
1128 },
1129 ResolvedModule {
1130 file_id: FileId(2),
1131 path: PathBuf::from("/project/src/unused.ts"),
1132 exports: vec![ExportInfo {
1133 name: ExportName::Named("baz".to_string()),
1134 local_name: Some("baz".to_string()),
1135 is_type_only: false,
1136 visibility: VisibilityTag::None,
1137 expected_unused_reason: None,
1138 span: oxc_span::Span::new(0, 15),
1139 members: vec![],
1140 is_side_effect_used: false,
1141 super_class: None,
1142 }]
1143 .into(),
1144 ..Default::default()
1145 },
1146 ];
1147
1148 ModuleGraph::build(&resolved_modules, &entry_points, &files)
1149 }
1150
1151 #[test]
1152 fn trace_used_export() {
1153 let graph = build_test_graph();
1154 let root = Path::new("/project");
1155
1156 let trace = trace_export(&graph, root, "src/utils.ts", "foo").unwrap();
1157 assert!(trace.is_used);
1158 assert!(trace.file_reachable);
1159 assert_eq!(trace.direct_references.len(), 1);
1160 assert_eq!(
1161 trace.direct_references[0].from_file,
1162 PathBuf::from("src/entry.ts")
1163 );
1164 assert_eq!(trace.direct_references[0].kind, "named import");
1165 }
1166
1167 #[test]
1168 fn trace_unused_export() {
1169 let graph = build_test_graph();
1170 let root = Path::new("/project");
1171
1172 let trace = trace_export(&graph, root, "src/utils.ts", "bar").unwrap();
1173 assert!(!trace.is_used);
1174 assert!(trace.file_reachable);
1175 assert!(trace.direct_references.is_empty());
1176 assert_eq!(
1177 trace.namespace,
1178 fallow_types::semantic::SemanticNamespace::Value,
1179 "an unreferenced value export stays in the value namespace"
1180 );
1181 }
1182
1183 #[test]
1184 fn trace_unreachable_file_export() {
1185 let graph = build_test_graph();
1186 let root = Path::new("/project");
1187
1188 let trace = trace_export(&graph, root, "src/unused.ts", "baz").unwrap();
1189 assert!(!trace.is_used);
1190 assert!(!trace.file_reachable);
1191 assert!(trace.reason.contains("unreachable"));
1192 }
1193
1194 #[test]
1195 fn trace_nonexistent_export() {
1196 let graph = build_test_graph();
1197 let root = Path::new("/project");
1198
1199 let trace = trace_export(&graph, root, "src/utils.ts", "nonexistent");
1200 assert!(trace.is_none());
1201 }
1202
1203 #[test]
1204 fn trace_reports_only_the_effective_re_export_origin() {
1205 let files: Vec<_> = ["entry", "barrel", "star-source", "explicit-source"]
1206 .into_iter()
1207 .enumerate()
1208 .map(|(index, name)| DiscoveredFile {
1209 id: FileId(index as u32),
1210 path: PathBuf::from(format!("/project/src/{name}.ts")),
1211 size_bytes: 10,
1212 })
1213 .collect();
1214 let entry_points = vec![EntryPoint {
1215 path: files[0].path.clone(),
1216 source: EntryPointSource::PackageJsonMain,
1217 }];
1218 let re_export = |source: FileId, imported: &str, exported: &str| ResolvedReExport {
1219 info: ReExportInfo {
1220 source: format!("./{}", source.0),
1221 imported_name: imported.to_string(),
1222 exported_name: exported.to_string(),
1223 is_type_only: false,
1224 span: oxc_span::Span::default(),
1225 statement_span: oxc_span::Span::default(),
1226 source_span: oxc_span::Span::default(),
1227 },
1228 target: ResolveResult::InternalModule(source),
1229 };
1230 let export = || ExportInfo {
1231 name: ExportName::Named("foo".to_string()),
1232 local_name: Some("foo".to_string()),
1233 is_type_only: false,
1234 visibility: VisibilityTag::None,
1235 expected_unused_reason: None,
1236 span: oxc_span::Span::new(0, 3),
1237 members: Vec::new(),
1238 is_side_effect_used: false,
1239 super_class: None,
1240 };
1241 let resolved = vec![
1242 ResolvedModule {
1243 file_id: FileId(0),
1244 path: files[0].path.clone(),
1245 resolved_imports: vec![ResolvedImport {
1246 info: ImportInfo {
1247 source: "./barrel".to_string(),
1248 imported_name: ImportedName::Named("foo".to_string()),
1249 local_name: "foo".to_string(),
1250 is_type_only: false,
1251 is_type_only_star: false,
1252 from_style: false,
1253 span: oxc_span::Span::default(),
1254 source_span: oxc_span::Span::default(),
1255 },
1256 target: ResolveResult::InternalModule(FileId(1)),
1257 }],
1258 ..Default::default()
1259 },
1260 ResolvedModule {
1261 file_id: FileId(1),
1262 path: files[1].path.clone(),
1263 re_exports: vec![
1264 re_export(FileId(2), "*", "*"),
1265 re_export(FileId(3), "foo", "foo"),
1266 ],
1267 ..Default::default()
1268 },
1269 ResolvedModule {
1270 file_id: FileId(2),
1271 path: files[2].path.clone(),
1272 exports: vec![export()].into(),
1273 ..Default::default()
1274 },
1275 ResolvedModule {
1276 file_id: FileId(3),
1277 path: files[3].path.clone(),
1278 exports: vec![export()].into(),
1279 ..Default::default()
1280 },
1281 ];
1282 let graph = ModuleGraph::build(&resolved, &entry_points, &files);
1283
1284 let shadowed = trace_export(&graph, Path::new("/project"), "src/star-source.ts", "foo")
1285 .expect("shadowed source export exists");
1286 let effective = trace_export(
1287 &graph,
1288 Path::new("/project"),
1289 "src/explicit-source.ts",
1290 "foo",
1291 )
1292 .expect("effective source export exists");
1293
1294 assert!(shadowed.re_export_chains.is_empty());
1295 assert_eq!(effective.re_export_chains.len(), 1);
1296 assert_eq!(effective.re_export_chains[0].exported_as, "foo");
1297 }
1298
1299 fn star_surface_trace_graph(root: &Path) -> ModuleGraph {
1300 let src = root.join("src");
1301 std::fs::create_dir_all(&src).expect("create source directory");
1302 let paths: Vec<_> = ["source", "barrel-a", "barrel-b", "outer", "entry"]
1303 .into_iter()
1304 .map(|name| src.join(format!("{name}.ts")))
1305 .collect();
1306 std::fs::write(&paths[0], "\n\nexport const foo = 1;\n").expect("write source");
1307 for path in &paths[1..] {
1308 std::fs::write(path, "export {};\n").expect("write module");
1309 }
1310 let files: Vec<_> = paths
1311 .iter()
1312 .enumerate()
1313 .map(|(index, path)| DiscoveredFile {
1314 id: FileId(index as u32),
1315 path: path.clone(),
1316 size_bytes: 20,
1317 })
1318 .collect();
1319 let resolved = vec![
1320 ResolvedModule {
1321 file_id: FileId(0),
1322 path: paths[0].clone(),
1323 exports: vec![ExportInfo {
1324 name: ExportName::Named("foo".to_string()),
1325 local_name: Some("foo".to_string()),
1326 is_type_only: false,
1327 visibility: VisibilityTag::None,
1328 expected_unused_reason: None,
1329 span: oxc_span::Span::new(2, 5),
1330 members: Vec::new(),
1331 is_side_effect_used: false,
1332 super_class: None,
1333 }]
1334 .into(),
1335 ..Default::default()
1336 },
1337 ResolvedModule {
1338 file_id: FileId(1),
1339 path: paths[1].clone(),
1340 re_exports: vec![resolved_re_export(FileId(0), "*", "*")],
1341 ..Default::default()
1342 },
1343 ResolvedModule {
1344 file_id: FileId(2),
1345 path: paths[2].clone(),
1346 re_exports: vec![resolved_re_export(FileId(0), "*", "*")],
1347 ..Default::default()
1348 },
1349 ResolvedModule {
1350 file_id: FileId(3),
1351 path: paths[3].clone(),
1352 re_exports: vec![
1353 resolved_re_export(FileId(1), "foo", "left"),
1354 resolved_re_export(FileId(1), "foo", "right"),
1355 ],
1356 ..Default::default()
1357 },
1358 ResolvedModule {
1359 file_id: FileId(4),
1360 path: paths[4].clone(),
1361 resolved_imports: vec![
1362 ResolvedImport {
1363 info: ImportInfo {
1364 source: "./outer".to_string(),
1365 imported_name: ImportedName::Named("left".to_string()),
1366 local_name: "left".to_string(),
1367 is_type_only: false,
1368 is_type_only_star: false,
1369 from_style: false,
1370 span: oxc_span::Span::new(10, 20),
1371 source_span: oxc_span::Span::default(),
1372 },
1373 target: ResolveResult::InternalModule(FileId(3)),
1374 },
1375 ResolvedImport {
1376 info: ImportInfo {
1377 source: "./barrel-b".to_string(),
1378 imported_name: ImportedName::Named("foo".to_string()),
1379 local_name: "otherFoo".to_string(),
1380 is_type_only: false,
1381 is_type_only_star: false,
1382 from_style: false,
1383 span: oxc_span::Span::new(30, 40),
1384 source_span: oxc_span::Span::default(),
1385 },
1386 target: ResolveResult::InternalModule(FileId(2)),
1387 },
1388 ],
1389 ..Default::default()
1390 },
1391 ];
1392 let entry_points = vec![EntryPoint {
1393 path: paths[4].clone(),
1394 source: EntryPointSource::PackageJsonMain,
1395 }];
1396 ModuleGraph::build(&resolved, &entry_points, &files)
1397 }
1398
1399 #[test]
1400 fn star_surface_trace_keeps_aliases_separate_and_uses_origin_identity() {
1401 let root = tempfile::tempdir().expect("temporary project");
1402 let graph = star_surface_trace_graph(root.path());
1403
1404 let used = trace_export(&graph, root.path(), "src/barrel-a.ts", "foo")
1405 .expect("aliased barrel exposes foo");
1406 let sibling = trace_export(&graph, root.path(), "src/barrel-b.ts", "foo")
1407 .expect("sibling barrel exposes foo");
1408 assert!(used.is_used);
1409 assert_eq!(used.direct_references.len(), 1);
1410 assert!(sibling.is_used);
1411 assert_eq!(sibling.direct_references.len(), 1);
1412
1413 let source_trace = trace_export(&graph, root.path(), "src/source.ts", "foo")
1414 .expect("source declaration is traceable");
1415 let chain_count = |file: &str, name: &str| {
1416 source_trace
1417 .re_export_chains
1418 .iter()
1419 .find(|chain| chain.barrel_file == Path::new(file) && chain.exported_as == name)
1420 .map(|chain| chain.reference_count)
1421 };
1422 assert_eq!(chain_count("src/barrel-a.ts", "foo"), Some(1));
1423 assert_eq!(chain_count("src/barrel-b.ts", "foo"), Some(1));
1424 assert_eq!(chain_count("src/outer.ts", "left"), Some(1));
1425 assert_eq!(chain_count("src/outer.ts", "right"), Some(0));
1426
1427 let semantic = semantic_symbol_for_export(&graph, root.path(), "src/barrel-a.ts", "foo")
1428 .expect("star surface resolves to its declaration identity");
1429 assert_eq!(semantic.path, Path::new("src/source.ts"));
1430 assert_eq!(semantic.exported_name, "foo");
1431 assert_eq!(semantic.local_name, "foo");
1432 assert_eq!((semantic.line, semantic.col), (3, 0));
1433
1434 let alias = semantic_symbol_for_export(&graph, root.path(), "src/outer.ts", "left")
1435 .expect("named re-export keeps its export-specifier identity");
1436 assert_eq!(alias.path, Path::new("src/outer.ts"));
1437 assert_eq!(alias.exported_name, "left");
1438 assert_eq!(alias.local_name, "foo");
1439 }
1440
1441 #[test]
1442 fn trace_follows_renamed_and_convergent_re_export_routes() {
1443 let names = [
1444 "source",
1445 "renamed",
1446 "final",
1447 "left",
1448 "right",
1449 "diamond-entry",
1450 ];
1451 let files: Vec<_> = names
1452 .into_iter()
1453 .enumerate()
1454 .map(|(index, name)| DiscoveredFile {
1455 id: FileId(index as u32),
1456 path: PathBuf::from(format!("/project/src/{name}.ts")),
1457 size_bytes: 10,
1458 })
1459 .collect();
1460 let re_export = |source: FileId, imported: &str, exported: &str| ResolvedReExport {
1461 info: ReExportInfo {
1462 source: format!("./{}", source.0),
1463 imported_name: imported.to_string(),
1464 exported_name: exported.to_string(),
1465 is_type_only: false,
1466 span: oxc_span::Span::default(),
1467 statement_span: oxc_span::Span::default(),
1468 source_span: oxc_span::Span::default(),
1469 },
1470 target: ResolveResult::InternalModule(source),
1471 };
1472 let mut resolved: Vec<_> = files
1473 .iter()
1474 .map(|file| ResolvedModule {
1475 file_id: file.id,
1476 path: file.path.clone(),
1477 ..Default::default()
1478 })
1479 .collect();
1480 resolved[0].exports = vec![ExportInfo {
1481 name: ExportName::Named("foo".to_string()),
1482 local_name: Some("foo".to_string()),
1483 is_type_only: false,
1484 visibility: VisibilityTag::None,
1485 expected_unused_reason: None,
1486 span: oxc_span::Span::new(0, 3),
1487 members: Vec::new(),
1488 is_side_effect_used: false,
1489 super_class: None,
1490 }]
1491 .into();
1492 resolved[1].re_exports = vec![re_export(FileId(0), "foo", "bar")];
1493 resolved[2].re_exports = vec![re_export(FileId(1), "bar", "baz")];
1494 resolved[3].re_exports = vec![re_export(FileId(0), "*", "*")];
1495 resolved[4].re_exports = vec![re_export(FileId(0), "*", "*")];
1496 resolved[5].re_exports = vec![
1497 re_export(FileId(3), "*", "*"),
1498 re_export(FileId(4), "*", "*"),
1499 ];
1500 let entry_points = vec![EntryPoint {
1501 path: files[5].path.clone(),
1502 source: EntryPointSource::PackageJsonMain,
1503 }];
1504 let graph = ModuleGraph::build(&resolved, &entry_points, &files);
1505
1506 let trace = trace_export(&graph, Path::new("/project"), "src/source.ts", "foo")
1507 .expect("source export exists");
1508 let routes: FxHashSet<_> = trace
1509 .re_export_chains
1510 .iter()
1511 .map(|route| (route.barrel_file.as_path(), route.exported_as.as_str()))
1512 .collect();
1513
1514 assert_eq!(routes.len(), 5);
1515 assert!(routes.contains(&(Path::new("src/renamed.ts"), "bar")));
1516 assert!(routes.contains(&(Path::new("src/final.ts"), "baz")));
1517 assert!(routes.contains(&(Path::new("src/left.ts"), "foo")));
1518 assert!(routes.contains(&(Path::new("src/right.ts"), "foo")));
1519 assert!(routes.contains(&(Path::new("src/diamond-entry.ts"), "foo")));
1520 }
1521
1522 #[test]
1523 fn trace_prefers_the_value_namespace_independent_of_usage() {
1524 let files = vec![
1525 DiscoveredFile {
1526 id: FileId(0),
1527 path: PathBuf::from("/project/src/entry.ts"),
1528 size_bytes: 10,
1529 },
1530 DiscoveredFile {
1531 id: FileId(1),
1532 path: PathBuf::from("/project/src/source.ts"),
1533 size_bytes: 10,
1534 },
1535 ];
1536 let export = |is_type_only| ExportInfo {
1537 name: ExportName::Named("Foo".to_string()),
1538 local_name: Some("Foo".to_string()),
1539 is_type_only,
1540 visibility: VisibilityTag::None,
1541 expected_unused_reason: None,
1542 span: oxc_span::Span::new(0, 3),
1543 members: Vec::new(),
1544 is_side_effect_used: false,
1545 super_class: None,
1546 };
1547 let resolved = vec![
1548 ResolvedModule {
1549 file_id: FileId(0),
1550 path: files[0].path.clone(),
1551 resolved_imports: vec![ResolvedImport {
1552 info: ImportInfo {
1553 source: "./source".to_string(),
1554 imported_name: ImportedName::Named("Foo".to_string()),
1555 local_name: "Foo".to_string(),
1556 is_type_only: true,
1557 is_type_only_star: false,
1558 from_style: false,
1559 span: oxc_span::Span::default(),
1560 source_span: oxc_span::Span::default(),
1561 },
1562 target: ResolveResult::InternalModule(FileId(1)),
1563 }],
1564 ..Default::default()
1565 },
1566 ResolvedModule {
1567 file_id: FileId(1),
1568 path: files[1].path.clone(),
1569 exports: vec![export(false), export(true)].into(),
1570 ..Default::default()
1571 },
1572 ];
1573 let entry_points = vec![EntryPoint {
1574 path: files[0].path.clone(),
1575 source: EntryPointSource::PackageJsonMain,
1576 }];
1577 let graph = ModuleGraph::build(&resolved, &entry_points, &files);
1578
1579 let trace = trace_export(&graph, Path::new("/project"), "src/source.ts", "Foo")
1580 .expect("value export exists");
1581
1582 assert_eq!(
1590 trace.namespace,
1591 fallow_types::semantic::SemanticNamespace::Value
1592 );
1593 assert!(!trace.is_used, "type usage must not select the type export");
1594 }
1595
1596 fn source_consumer_graph(
1599 source_exports: Vec<ExportInfo>,
1600 import_is_type_only: bool,
1601 classified_usage: bool,
1602 semantic_facts: Vec<fallow_types::extract::SemanticFact>,
1603 ) -> ModuleGraph {
1604 let files = vec![
1605 DiscoveredFile {
1606 id: FileId(0),
1607 path: PathBuf::from("/project/src/entry.ts"),
1608 size_bytes: 10,
1609 },
1610 DiscoveredFile {
1611 id: FileId(1),
1612 path: PathBuf::from("/project/src/source.ts"),
1613 size_bytes: 10,
1614 },
1615 ];
1616 let classified = if classified_usage {
1617 vec!["Foo".to_string()]
1618 } else {
1619 Vec::new()
1620 };
1621 let resolved = vec![
1622 ResolvedModule {
1623 file_id: FileId(0),
1624 path: files[0].path.clone(),
1625 resolved_imports: vec![ResolvedImport {
1626 info: ImportInfo {
1627 source: "./source".to_string(),
1628 imported_name: ImportedName::Named("Foo".to_string()),
1629 local_name: "Foo".to_string(),
1630 is_type_only: import_is_type_only,
1631 is_type_only_star: false,
1632 from_style: false,
1633 span: oxc_span::Span::new(0, 10),
1634 source_span: oxc_span::Span::default(),
1635 },
1636 target: ResolveResult::InternalModule(FileId(1)),
1637 }],
1638 type_referenced_import_bindings: classified.clone(),
1639 value_referenced_import_bindings: classified,
1640 ..Default::default()
1641 },
1642 ResolvedModule {
1643 file_id: FileId(1),
1644 path: files[1].path.clone(),
1645 exports: source_exports.into(),
1646 semantic_facts: semantic_facts.into(),
1647 ..Default::default()
1648 },
1649 ];
1650 let entry_points = vec![EntryPoint {
1651 path: files[0].path.clone(),
1652 source: EntryPointSource::PackageJsonMain,
1653 }];
1654 ModuleGraph::build(&resolved, &entry_points, &files)
1655 }
1656
1657 fn dual_lane_consumer_graph(source_exports: Vec<ExportInfo>) -> ModuleGraph {
1661 let files = vec![
1662 DiscoveredFile {
1663 id: FileId(0),
1664 path: PathBuf::from("/project/src/entry.ts"),
1665 size_bytes: 10,
1666 },
1667 DiscoveredFile {
1668 id: FileId(1),
1669 path: PathBuf::from("/project/src/source.ts"),
1670 size_bytes: 10,
1671 },
1672 DiscoveredFile {
1673 id: FileId(2),
1674 path: PathBuf::from("/project/src/typed.ts"),
1675 size_bytes: 10,
1676 },
1677 ];
1678 let consumer = |file_id: FileId, path: PathBuf, is_type_only: bool| ResolvedModule {
1679 file_id,
1680 path,
1681 resolved_imports: vec![ResolvedImport {
1682 info: ImportInfo {
1683 source: "./source".to_string(),
1684 imported_name: ImportedName::Named("Foo".to_string()),
1685 local_name: "Foo".to_string(),
1686 is_type_only,
1687 is_type_only_star: false,
1688 from_style: false,
1689 span: oxc_span::Span::new(0, 10),
1690 source_span: oxc_span::Span::default(),
1691 },
1692 target: ResolveResult::InternalModule(FileId(1)),
1693 }],
1694 ..Default::default()
1695 };
1696 let resolved = vec![
1697 consumer(FileId(0), files[0].path.clone(), false),
1698 ResolvedModule {
1699 file_id: FileId(1),
1700 path: files[1].path.clone(),
1701 exports: source_exports.into(),
1702 ..Default::default()
1703 },
1704 consumer(FileId(2), files[2].path.clone(), true),
1705 ];
1706 let entry_points = vec![
1707 EntryPoint {
1708 path: files[0].path.clone(),
1709 source: EntryPointSource::PackageJsonMain,
1710 },
1711 EntryPoint {
1712 path: files[2].path.clone(),
1713 source: EntryPointSource::PackageJsonMain,
1714 },
1715 ];
1716 ModuleGraph::build(&resolved, &entry_points, &files)
1717 }
1718
1719 fn named_foo_export(is_type_only: bool) -> ExportInfo {
1720 ExportInfo {
1721 name: ExportName::Named("Foo".to_string()),
1722 local_name: Some("Foo".to_string()),
1723 is_type_only,
1724 visibility: VisibilityTag::None,
1725 expected_unused_reason: None,
1726 span: oxc_span::Span::new(0, 3),
1727 members: Vec::new(),
1728 is_side_effect_used: false,
1729 super_class: None,
1730 }
1731 }
1732
1733 #[test]
1734 fn trace_credits_a_value_only_export_through_the_type_lane() {
1735 let graph = source_consumer_graph(vec![named_foo_export(false)], true, false, Vec::new());
1739
1740 let trace = trace_export(&graph, Path::new("/project"), "src/source.ts", "Foo")
1741 .expect("value export exists");
1742
1743 assert_eq!(
1744 trace.namespace,
1745 fallow_types::semantic::SemanticNamespace::Type,
1746 "the type lane carries the only credit"
1747 );
1748 assert!(
1749 trace.is_used,
1750 "a type-only import credits a value-only export"
1751 );
1752 assert_eq!(trace.direct_references.len(), 1);
1753 assert_eq!(
1754 trace.direct_references[0].from_file,
1755 PathBuf::from("src/entry.ts")
1756 );
1757 assert_eq!(trace.direct_references[0].kind, "named import");
1758 assert_eq!(trace.reason, "Used by 1 file(s)");
1759 }
1760
1761 #[test]
1762 fn trace_omits_redundant_single_namespace_evidence() {
1763 let graph = source_consumer_graph(vec![named_foo_export(false)], false, false, Vec::new());
1764
1765 let trace = trace_export(&graph, Path::new("/project"), "src/source.ts", "Foo")
1766 .expect("value export exists");
1767
1768 assert_eq!(
1769 trace.namespace,
1770 fallow_types::semantic::SemanticNamespace::Value
1771 );
1772 assert_eq!(trace.direct_references.len(), 1);
1773 assert!(trace.direct_references_by_namespace.is_empty());
1774 let json = serde_json::to_value(&trace).expect("serialize trace");
1775 assert!(json.get("direct_references_by_namespace").is_none());
1776 }
1777
1778 #[test]
1779 fn trace_keeps_the_value_lane_when_one_binding_carries_both_lanes() {
1780 let graph = dual_lane_consumer_graph(vec![named_foo_export(false)]);
1786
1787 let trace = trace_export(&graph, Path::new("/project"), "src/source.ts", "Foo")
1788 .expect("value export exists");
1789
1790 assert_eq!(
1791 trace.namespace,
1792 fallow_types::semantic::SemanticNamespace::Value
1793 );
1794 assert!(trace.is_used);
1795 assert_eq!(trace.direct_references.len(), 1);
1796 assert_eq!(
1797 trace.direct_references[0].from_file,
1798 PathBuf::from("src/entry.ts"),
1799 "the value consumer stays the listed reference"
1800 );
1801 assert_eq!(trace.direct_references_by_namespace.len(), 2);
1802 assert!(trace.direct_references_by_namespace.iter().any(|lane| {
1803 lane.namespace == fallow_types::semantic::SemanticNamespace::Type
1804 && lane.reference_count == 1
1805 && lane.references[0].from_file == Path::new("src/typed.ts")
1806 }));
1807 }
1808
1809 #[test]
1810 fn trace_credits_a_declaration_merge_that_stays_one_binding() {
1811 let graph = source_consumer_graph(
1815 vec![named_foo_export(false), named_foo_export(false)],
1816 true,
1817 false,
1818 Vec::new(),
1819 );
1820
1821 let trace = trace_export(&graph, Path::new("/project"), "src/source.ts", "Foo")
1822 .expect("value export exists");
1823
1824 assert_eq!(
1825 trace.namespace,
1826 fallow_types::semantic::SemanticNamespace::Type,
1827 "the merged binding is reachable from the type lane"
1828 );
1829 assert!(trace.is_used);
1830 assert_eq!(trace.direct_references.len(), 1);
1831 assert_eq!(
1832 trace.direct_references[0].from_file,
1833 PathBuf::from("src/entry.ts")
1834 );
1835 }
1836
1837 #[test]
1838 fn trace_credits_a_class_interface_declaration_merge() {
1839 let mut interface = named_foo_export(true);
1840 interface.span = oxc_span::Span::new(0, 3);
1841 let mut class = named_foo_export(false);
1842 class.span = oxc_span::Span::new(4, 7);
1843 let graph = source_consumer_graph(
1844 vec![interface, class],
1845 true,
1846 true,
1847 vec![fallow_types::extract::SemanticFact::DeclarationMerge(
1848 fallow_types::extract::DeclarationMergeFact {
1849 export_spans: vec![(0, 3), (4, 7)],
1850 },
1851 )],
1852 );
1853
1854 let trace = trace_export(&graph, Path::new("/project"), "src/source.ts", "Foo")
1855 .expect("merged class export exists");
1856
1857 assert_eq!(
1858 trace.namespace,
1859 fallow_types::semantic::SemanticNamespace::Type
1860 );
1861 assert!(trace.is_used);
1862 assert_eq!(trace.direct_references.len(), 1);
1863 }
1864
1865 #[test]
1866 fn trace_keeps_the_value_namespace_when_lanes_hold_distinct_bindings() {
1867 let graph = source_consumer_graph(
1874 vec![named_foo_export(false), named_foo_export(true)],
1875 false,
1876 true,
1877 Vec::new(),
1878 );
1879
1880 let trace = trace_export(&graph, Path::new("/project"), "src/source.ts", "Foo")
1881 .expect("value export exists");
1882
1883 assert_eq!(
1884 trace.namespace,
1885 fallow_types::semantic::SemanticNamespace::Value
1886 );
1887 assert!(trace.is_used);
1888 assert_eq!(trace.direct_references.len(), 1);
1889 assert_eq!(
1890 trace.direct_references[0].from_file,
1891 PathBuf::from("src/entry.ts")
1892 );
1893 }
1894
1895 #[test]
1896 fn class_member_trace_inherits_the_type_lane_credit_of_its_owner() {
1897 use fallow_types::extract::{MemberInfo, MemberKind};
1898
1899 let mut owner = named_foo_export(false);
1903 owner.members = vec![MemberInfo {
1904 name: "run".to_string(),
1905 kind: MemberKind::ClassMethod,
1906 span: oxc_span::Span::new(0, 3),
1907 has_decorator: false,
1908 decorator_names: vec![],
1909 is_instance_returning_static: false,
1910 is_self_returning: false,
1911 }];
1912 let graph = source_consumer_graph(vec![owner], true, false, Vec::new());
1913
1914 let trace = trace_class_member(&graph, Path::new("/project"), "src/source.ts", "run")
1915 .expect("member of the traced export");
1916
1917 assert!(trace.owner_is_used, "the type lane credits the owner");
1918 assert_eq!(
1919 trace.owner_namespace,
1920 fallow_types::semantic::SemanticNamespace::Type,
1921 "the member payload names the lane that credits its owner"
1922 );
1923 assert_eq!(trace.owner_direct_references.len(), 1);
1924 assert_eq!(
1925 trace.owner_direct_references[0].from_file,
1926 PathBuf::from("src/entry.ts")
1927 );
1928 assert!(
1929 trace.reason.contains("'Foo' is used by 1 file(s)"),
1930 "the reason must follow the owner's credit: {}",
1931 trace.reason
1932 );
1933 }
1934
1935 #[test]
1936 fn trace_preserves_dual_namespace_named_re_exports() {
1937 let files: Vec<_> = ["entry", "barrel", "types", "values"]
1938 .into_iter()
1939 .enumerate()
1940 .map(|(index, name)| DiscoveredFile {
1941 id: FileId(index as u32),
1942 path: PathBuf::from(format!("/project/src/{name}.ts")),
1943 size_bytes: 10,
1944 })
1945 .collect();
1946 let export = |is_type_only| ExportInfo {
1947 name: ExportName::Named("Foo".to_string()),
1948 local_name: Some("Foo".to_string()),
1949 is_type_only,
1950 visibility: VisibilityTag::None,
1951 expected_unused_reason: None,
1952 span: oxc_span::Span::new(0, 3),
1953 members: Vec::new(),
1954 is_side_effect_used: false,
1955 super_class: None,
1956 };
1957 let re_export = |source: FileId, is_type_only| ResolvedReExport {
1958 info: ReExportInfo {
1959 source: format!("./{}", source.0),
1960 imported_name: "Foo".to_string(),
1961 exported_name: "Foo".to_string(),
1962 is_type_only,
1963 span: oxc_span::Span::default(),
1964 statement_span: oxc_span::Span::default(),
1965 source_span: oxc_span::Span::default(),
1966 },
1967 target: ResolveResult::InternalModule(source),
1968 };
1969 let mut resolved = vec![
1970 ResolvedModule {
1971 file_id: FileId(0),
1972 path: files[0].path.clone(),
1973 resolved_imports: vec![ResolvedImport {
1974 info: ImportInfo {
1975 source: "./barrel".to_string(),
1976 imported_name: ImportedName::Named("Foo".to_string()),
1977 local_name: "Foo".to_string(),
1978 is_type_only: false,
1979 is_type_only_star: false,
1980 from_style: false,
1981 span: oxc_span::Span::new(0, 10),
1982 source_span: oxc_span::Span::default(),
1983 },
1984 target: ResolveResult::InternalModule(FileId(1)),
1985 }],
1986 ..Default::default()
1987 },
1988 ResolvedModule {
1989 file_id: FileId(1),
1990 path: files[1].path.clone(),
1991 re_exports: vec![re_export(FileId(2), true), re_export(FileId(3), false)],
1992 ..Default::default()
1993 },
1994 ResolvedModule {
1995 file_id: FileId(2),
1996 path: files[2].path.clone(),
1997 exports: vec![export(true)].into(),
1998 ..Default::default()
1999 },
2000 ResolvedModule {
2001 file_id: FileId(3),
2002 path: files[3].path.clone(),
2003 exports: vec![export(false)].into(),
2004 ..Default::default()
2005 },
2006 ];
2007 let entry_points = vec![EntryPoint {
2008 path: files[0].path.clone(),
2009 source: EntryPointSource::PackageJsonMain,
2010 }];
2011 let graph = ModuleGraph::build(&resolved, &entry_points, &files);
2012 resolved[1].re_exports.reverse();
2013 let reversed_graph = ModuleGraph::build(&resolved, &entry_points, &files);
2014
2015 let trace = trace_export(&graph, Path::new("/project"), "src/barrel.ts", "Foo")
2016 .expect("barrel exposes Foo in both namespaces");
2017
2018 assert_eq!(
2019 trace.namespace,
2020 fallow_types::semantic::SemanticNamespace::Value
2021 );
2022 assert!(
2023 trace.is_used,
2024 "the value import must credit the value surface"
2025 );
2026 assert_eq!(trace.direct_references.len(), 1);
2027 let reversed_trace = trace_export(
2028 &reversed_graph,
2029 Path::new("/project"),
2030 "src/barrel.ts",
2031 "Foo",
2032 )
2033 .expect("reversed declarations expose the same surface");
2034 assert_eq!(
2035 serde_json::to_value(trace).expect("serialize trace"),
2036 serde_json::to_value(reversed_trace).expect("serialize reversed trace")
2037 );
2038 }
2039
2040 fn build_class_member_graph() -> ModuleGraph {
2041 use fallow_types::extract::{MemberInfo, MemberKind};
2042
2043 let files = vec![
2044 DiscoveredFile {
2045 id: FileId(0),
2046 path: PathBuf::from("/project/src/entry.ts"),
2047 size_bytes: 100,
2048 },
2049 DiscoveredFile {
2050 id: FileId(1),
2051 path: PathBuf::from("/project/src/controller.ts"),
2052 size_bytes: 50,
2053 },
2054 ];
2055 let entry_points = vec![EntryPoint {
2056 path: PathBuf::from("/project/src/entry.ts"),
2057 source: EntryPointSource::PackageJsonMain,
2058 }];
2059 let method = |name: &str| MemberInfo {
2060 name: name.to_string(),
2061 kind: MemberKind::ClassMethod,
2062 span: oxc_span::Span::new(0, 4),
2063 has_decorator: false,
2064 decorator_names: vec![],
2065 is_instance_returning_static: false,
2066 is_self_returning: false,
2067 };
2068 let resolved_modules = vec![
2069 ResolvedModule {
2070 file_id: FileId(0),
2071 path: PathBuf::from("/project/src/entry.ts"),
2072 resolved_imports: vec![ResolvedImport {
2073 info: ImportInfo {
2074 source: "./controller".to_string(),
2075 imported_name: ImportedName::Named("Ctrl".to_string()),
2076 local_name: "Ctrl".to_string(),
2077 is_type_only: false,
2078 is_type_only_star: false,
2079 from_style: false,
2080 span: oxc_span::Span::new(0, 10),
2081 source_span: oxc_span::Span::default(),
2082 },
2083 target: ResolveResult::InternalModule(FileId(1)),
2084 }],
2085 ..Default::default()
2086 },
2087 ResolvedModule {
2088 file_id: FileId(1),
2089 path: PathBuf::from("/project/src/controller.ts"),
2090 exports: vec![ExportInfo {
2091 name: ExportName::Named("Ctrl".to_string()),
2092 local_name: Some("Ctrl".to_string()),
2093 is_type_only: false,
2094 visibility: VisibilityTag::None,
2095 expected_unused_reason: None,
2096 span: oxc_span::Span::new(0, 20),
2097 members: vec![method("used"), method("dead")],
2098 is_side_effect_used: false,
2099 super_class: None,
2100 }]
2101 .into(),
2102 ..Default::default()
2103 },
2104 ];
2105 ModuleGraph::build(&resolved_modules, &entry_points, &files)
2106 }
2107
2108 #[test]
2109 fn trace_class_member_reports_owner_class() {
2110 let graph = build_class_member_graph();
2113 let root = Path::new("/project");
2114
2115 let trace = trace_class_member(&graph, root, "src/controller.ts", "dead").unwrap();
2116 assert_eq!(trace.owner_export, "Ctrl");
2117 assert_eq!(trace.member_name, "dead");
2118 assert_eq!(trace.member_kind, "class-method");
2119 assert!(trace.owner_is_used);
2120 assert!(trace.owner_file_reachable);
2121 assert_eq!(trace.owner_direct_references.len(), 1);
2122 assert!(
2123 trace.reason.contains("--unused-class-members"),
2124 "reason should point at the member command: {}",
2125 trace.reason
2126 );
2127 }
2128
2129 #[test]
2130 fn trace_class_member_absent_name_is_none() {
2131 let graph = build_class_member_graph();
2134 let root = Path::new("/project");
2135 assert!(trace_class_member(&graph, root, "src/controller.ts", "nope").is_none());
2136 }
2137
2138 fn exact_class_method_fixture() -> (tempfile::TempDir, ModuleGraph) {
2139 use fallow_types::extract::{MemberInfo, MemberKind};
2140
2141 let temp = tempfile::tempdir().unwrap();
2142 let root = temp.path();
2143 let path = root.join("repository.ts");
2144 let source =
2145 "export class Repository {\n save(): void;\n save(): void {}\n run(): void {}\n}\n";
2146 std::fs::write(&path, source).unwrap();
2147 let first = source.find("save").unwrap() as u32;
2148 let second = source.rfind("save").unwrap() as u32;
2149 let run = source.find("run").unwrap() as u32;
2150 let member = |name: &str, start| MemberInfo {
2151 name: name.to_string(),
2152 kind: MemberKind::ClassMethod,
2153 span: oxc_span::Span::new(start, start + 4),
2154 has_decorator: false,
2155 decorator_names: vec![],
2156 is_instance_returning_static: false,
2157 is_self_returning: false,
2158 };
2159 let files = vec![DiscoveredFile {
2160 id: FileId(0),
2161 path: path.clone(),
2162 size_bytes: source.len() as u64,
2163 }];
2164 let resolved_modules = vec![ResolvedModule {
2165 file_id: FileId(0),
2166 path,
2167 exports: vec![ExportInfo {
2168 name: ExportName::Named("Repository".to_string()),
2169 local_name: Some("Repository".to_string()),
2170 is_type_only: false,
2171 visibility: VisibilityTag::None,
2172 expected_unused_reason: None,
2173 span: oxc_span::Span::new(0, source.len() as u32),
2174 members: vec![
2175 member("save", first),
2176 member("save", second),
2177 member("run", run),
2178 ],
2179 is_side_effect_used: false,
2180 super_class: None,
2181 }]
2182 .into(),
2183 ..Default::default()
2184 }];
2185 let graph = ModuleGraph::build(&resolved_modules, &[], &files);
2186 (temp, graph)
2187 }
2188
2189 #[test]
2190 fn exact_class_method_resolution_rejects_overloads_without_guessing() {
2191 let (temp, graph) = exact_class_method_fixture();
2192 let root = temp.path();
2193 assert_eq!(
2194 semantic_symbol_for_exact_class_method(
2195 &graph,
2196 root,
2197 "repository.ts",
2198 "Repository",
2199 "save",
2200 ),
2201 Err(SemanticClassMethodResolutionError::AmbiguousSymbol)
2202 );
2203 assert_eq!(
2204 semantic_symbol_for_exact_class_method(
2205 &graph,
2206 root,
2207 "repository.ts",
2208 "OtherRepository",
2209 "save",
2210 ),
2211 Err(SemanticClassMethodResolutionError::SymbolNotFound)
2212 );
2213 let resolved = semantic_symbol_for_exact_class_method(
2214 &graph,
2215 root,
2216 "repository.ts",
2217 "Repository",
2218 "run",
2219 )
2220 .unwrap();
2221 assert_eq!(resolved.owner.as_deref(), Some("Repository"));
2222 assert_eq!(resolved.local_name, "run");
2223 }
2224
2225 #[test]
2226 fn exact_class_method_resolution_preserves_error_precedence() {
2227 use fallow_types::extract::MemberKind;
2228
2229 let (temp, mut graph) = exact_class_method_fixture();
2230 let root = temp.path();
2231 for (file, owner, member, expected) in [
2232 (
2233 "missing.ts",
2234 "Repository",
2235 "run",
2236 SemanticClassMethodResolutionError::FileNotFound,
2237 ),
2238 (
2239 "repository.ts",
2240 "Repository",
2241 "missing",
2242 SemanticClassMethodResolutionError::SymbolNotFound,
2243 ),
2244 ] {
2245 assert_eq!(
2246 semantic_symbol_for_exact_class_method(&graph, root, file, owner, member),
2247 Err(expected),
2248 );
2249 }
2250 graph.modules[0].exports[0].members[2].kind = MemberKind::ClassProperty;
2251 assert_eq!(
2252 semantic_symbol_for_exact_class_method(
2253 &graph,
2254 root,
2255 "repository.ts",
2256 "Repository",
2257 "run"
2258 ),
2259 Err(SemanticClassMethodResolutionError::UnsupportedSyntax),
2260 );
2261 graph.modules[0].exports[0].members[2].kind = MemberKind::ClassMethod;
2262 std::fs::remove_file(&graph.modules[0].path).expect("remove fixture source");
2263 assert_eq!(
2264 semantic_symbol_for_exact_class_method(
2265 &graph,
2266 root,
2267 "repository.ts",
2268 "Repository",
2269 "run"
2270 ),
2271 Err(SemanticClassMethodResolutionError::SymbolNotFound),
2272 );
2273 let (_other_temp, mut other_graph) = exact_class_method_fixture();
2274 let duplicate = other_graph.modules[0]
2275 .exports
2276 .pop()
2277 .expect("fixture declares an owner");
2278 graph.modules[0].exports.push(duplicate);
2279 assert_eq!(
2280 semantic_symbol_for_exact_class_method(
2281 &graph,
2282 root,
2283 "repository.ts",
2284 "Repository",
2285 "run"
2286 ),
2287 Err(SemanticClassMethodResolutionError::AmbiguousSymbol),
2288 "owner ambiguity must be reported before reading source",
2289 );
2290 }
2291
2292 fn build_unreachable_class_member_graph() -> ModuleGraph {
2295 use fallow_types::extract::{MemberInfo, MemberKind};
2296
2297 let files = vec![
2298 DiscoveredFile {
2299 id: FileId(0),
2300 path: PathBuf::from("/project/src/entry.ts"),
2301 size_bytes: 100,
2302 },
2303 DiscoveredFile {
2304 id: FileId(1),
2305 path: PathBuf::from("/project/src/controller.ts"),
2306 size_bytes: 50,
2307 },
2308 ];
2309 let entry_points = vec![EntryPoint {
2310 path: PathBuf::from("/project/src/entry.ts"),
2311 source: EntryPointSource::PackageJsonMain,
2312 }];
2313 let method = |name: &str| MemberInfo {
2314 name: name.to_string(),
2315 kind: MemberKind::ClassMethod,
2316 span: oxc_span::Span::new(0, 4),
2317 has_decorator: false,
2318 decorator_names: vec![],
2319 is_instance_returning_static: false,
2320 is_self_returning: false,
2321 };
2322 let resolved_modules = vec![
2323 ResolvedModule {
2324 file_id: FileId(0),
2325 path: PathBuf::from("/project/src/entry.ts"),
2326 ..Default::default()
2328 },
2329 ResolvedModule {
2330 file_id: FileId(1),
2331 path: PathBuf::from("/project/src/controller.ts"),
2332 exports: vec![ExportInfo {
2333 name: ExportName::Named("Ctrl".to_string()),
2334 local_name: Some("Ctrl".to_string()),
2335 is_type_only: false,
2336 visibility: VisibilityTag::None,
2337 expected_unused_reason: None,
2338 span: oxc_span::Span::new(0, 20),
2339 members: vec![method("dead")],
2340 is_side_effect_used: false,
2341 super_class: None,
2342 }]
2343 .into(),
2344 ..Default::default()
2345 },
2346 ];
2347 ModuleGraph::build(&resolved_modules, &entry_points, &files)
2348 }
2349
2350 #[test]
2351 fn trace_class_member_unreachable_owner_reports_dead_reason() {
2352 let graph = build_unreachable_class_member_graph();
2355 let root = Path::new("/project");
2356
2357 let trace = trace_class_member(&graph, root, "src/controller.ts", "dead").unwrap();
2358 assert!(!trace.owner_file_reachable);
2359 assert!(
2360 trace.reason.contains("not reachable"),
2361 "unreachable owner reason should say so: {}",
2362 trace.reason
2363 );
2364 assert!(!trace.reason.contains("--unused-class-members"));
2367 }
2368
2369 #[test]
2370 fn trace_class_member_prefers_used_owner_on_name_collision() {
2371 use fallow_types::extract::{MemberInfo, MemberKind};
2375
2376 let files = vec![
2377 DiscoveredFile {
2378 id: FileId(0),
2379 path: PathBuf::from("/project/src/entry.ts"),
2380 size_bytes: 100,
2381 },
2382 DiscoveredFile {
2383 id: FileId(1),
2384 path: PathBuf::from("/project/src/controller.ts"),
2385 size_bytes: 50,
2386 },
2387 ];
2388 let entry_points = vec![EntryPoint {
2389 path: PathBuf::from("/project/src/entry.ts"),
2390 source: EntryPointSource::PackageJsonMain,
2391 }];
2392 let method = |name: &str| MemberInfo {
2393 name: name.to_string(),
2394 kind: MemberKind::ClassMethod,
2395 span: oxc_span::Span::new(0, 4),
2396 has_decorator: false,
2397 decorator_names: vec![],
2398 is_instance_returning_static: false,
2399 is_self_returning: false,
2400 };
2401 let resolved_modules = vec![
2402 ResolvedModule {
2403 file_id: FileId(0),
2404 path: PathBuf::from("/project/src/entry.ts"),
2405 resolved_imports: vec![ResolvedImport {
2406 info: ImportInfo {
2407 source: "./controller".to_string(),
2408 imported_name: ImportedName::Named("UsedCtrl".to_string()),
2409 local_name: "UsedCtrl".to_string(),
2410 is_type_only: false,
2411 is_type_only_star: false,
2412 from_style: false,
2413 span: oxc_span::Span::new(0, 10),
2414 source_span: oxc_span::Span::default(),
2415 },
2416 target: ResolveResult::InternalModule(FileId(1)),
2417 }],
2418 ..Default::default()
2419 },
2420 ResolvedModule {
2421 file_id: FileId(1),
2422 path: PathBuf::from("/project/src/controller.ts"),
2423 exports: vec![
2424 ExportInfo {
2427 name: ExportName::Named("TypeCtrl".to_string()),
2428 local_name: Some("TypeCtrl".to_string()),
2429 is_type_only: true,
2430 visibility: VisibilityTag::None,
2431 expected_unused_reason: None,
2432 span: oxc_span::Span::new(0, 20),
2433 members: vec![method("shared")],
2434 is_side_effect_used: false,
2435 super_class: None,
2436 },
2437 ExportInfo {
2438 name: ExportName::Named("UsedCtrl".to_string()),
2439 local_name: Some("UsedCtrl".to_string()),
2440 is_type_only: false,
2441 visibility: VisibilityTag::None,
2442 expected_unused_reason: None,
2443 span: oxc_span::Span::new(0, 20),
2444 members: vec![method("shared")],
2445 is_side_effect_used: false,
2446 super_class: None,
2447 },
2448 ]
2449 .into(),
2450 ..Default::default()
2451 },
2452 ];
2453 let graph = ModuleGraph::build(&resolved_modules, &entry_points, &files);
2454 let root = Path::new("/project");
2455
2456 let trace = trace_class_member(&graph, root, "src/controller.ts", "shared").unwrap();
2457 assert_eq!(
2458 trace.owner_export, "UsedCtrl",
2459 "tie-break must prefer the used, non-type-only owner"
2460 );
2461 assert!(trace.owner_is_used);
2462 }
2463
2464 #[test]
2465 fn trace_nonexistent_file() {
2466 let graph = build_test_graph();
2467 let root = Path::new("/project");
2468
2469 let trace = trace_export(&graph, root, "src/nope.ts", "foo");
2470 assert!(trace.is_none());
2471 }
2472
2473 #[test]
2474 fn trace_file_edges() {
2475 let graph = build_test_graph();
2476 let root = Path::new("/project");
2477
2478 let trace = trace_file(&graph, root, "src/entry.ts").unwrap();
2479 assert!(trace.is_entry_point);
2480 assert!(trace.is_reachable);
2481 assert_eq!(trace.imports_from.len(), 1);
2482 assert_eq!(trace.imports_from[0], PathBuf::from("src/utils.ts"));
2483 assert!(trace.imported_by.is_empty());
2484 }
2485
2486 #[test]
2487 fn trace_file_imported_by() {
2488 let graph = build_test_graph();
2489 let root = Path::new("/project");
2490
2491 let trace = trace_file(&graph, root, "src/utils.ts").unwrap();
2492 assert!(!trace.is_entry_point);
2493 assert!(trace.is_reachable);
2494 assert_eq!(trace.exports.len(), 2);
2495 assert_eq!(trace.imported_by.len(), 1);
2496 assert_eq!(trace.imported_by[0], PathBuf::from("src/entry.ts"));
2497 }
2498
2499 #[test]
2500 fn trace_unreachable_file() {
2501 let graph = build_test_graph();
2502 let root = Path::new("/project");
2503
2504 let trace = trace_file(&graph, root, "src/unused.ts").unwrap();
2505 assert!(!trace.is_reachable);
2506 assert!(!trace.is_entry_point);
2507 assert!(trace.imported_by.is_empty());
2508 }
2509
2510 #[test]
2511 fn trace_dependency_used() {
2512 let files = vec![DiscoveredFile {
2513 id: FileId(0),
2514 path: PathBuf::from("/project/src/app.ts"),
2515 size_bytes: 100,
2516 }];
2517 let entry_points = vec![EntryPoint {
2518 path: PathBuf::from("/project/src/app.ts"),
2519 source: EntryPointSource::PackageJsonMain,
2520 }];
2521 let resolved_modules = vec![ResolvedModule {
2522 file_id: FileId(0),
2523 path: PathBuf::from("/project/src/app.ts"),
2524 resolved_imports: vec![ResolvedImport {
2525 info: ImportInfo {
2526 source: "lodash".to_string(),
2527 imported_name: ImportedName::Named("get".to_string()),
2528 local_name: "get".to_string(),
2529 is_type_only: false,
2530 is_type_only_star: false,
2531 from_style: false,
2532 span: oxc_span::Span::new(0, 10),
2533 source_span: oxc_span::Span::default(),
2534 },
2535 target: ResolveResult::NpmPackage("lodash".to_string()),
2536 }],
2537 ..Default::default()
2538 }];
2539
2540 let graph = ModuleGraph::build(&resolved_modules, &entry_points, &files);
2541 let root = Path::new("/project");
2542
2543 let trace = trace_dependency(&graph, root, "lodash", &FxHashSet::default());
2544 assert!(trace.is_used);
2545 assert!(!trace.used_in_scripts);
2546 assert_eq!(trace.import_count, 1);
2547 assert_eq!(trace.imported_by[0], PathBuf::from("src/app.ts"));
2548 }
2549
2550 #[test]
2551 fn trace_dependency_unused() {
2552 let files = vec![DiscoveredFile {
2553 id: FileId(0),
2554 path: PathBuf::from("/project/src/app.ts"),
2555 size_bytes: 100,
2556 }];
2557 let entry_points = vec![EntryPoint {
2558 path: PathBuf::from("/project/src/app.ts"),
2559 source: EntryPointSource::PackageJsonMain,
2560 }];
2561 let resolved_modules = vec![ResolvedModule {
2562 file_id: FileId(0),
2563 path: PathBuf::from("/project/src/app.ts"),
2564 ..Default::default()
2565 }];
2566
2567 let graph = ModuleGraph::build(&resolved_modules, &entry_points, &files);
2568 let root = Path::new("/project");
2569
2570 let trace = trace_dependency(&graph, root, "nonexistent-pkg", &FxHashSet::default());
2571 assert!(!trace.is_used);
2572 assert!(!trace.used_in_scripts);
2573 assert_eq!(trace.import_count, 0);
2574 assert!(trace.imported_by.is_empty());
2575 }
2576
2577 #[test]
2578 fn trace_dependency_used_only_in_scripts() {
2579 let files = vec![DiscoveredFile {
2580 id: FileId(0),
2581 path: PathBuf::from("/project/src/app.ts"),
2582 size_bytes: 100,
2583 }];
2584 let entry_points = vec![EntryPoint {
2585 path: PathBuf::from("/project/src/app.ts"),
2586 source: EntryPointSource::PackageJsonMain,
2587 }];
2588 let resolved_modules = vec![ResolvedModule {
2589 file_id: FileId(0),
2590 path: PathBuf::from("/project/src/app.ts"),
2591 ..Default::default()
2592 }];
2593
2594 let graph = ModuleGraph::build(&resolved_modules, &entry_points, &files);
2595 let root = Path::new("/project");
2596 let mut script_used = FxHashSet::default();
2597 script_used.insert("microbundle".to_string());
2598
2599 let trace = trace_dependency(&graph, root, "microbundle", &script_used);
2600 assert!(
2601 trace.is_used,
2602 "is_used must be true when the package is referenced from package.json scripts"
2603 );
2604 assert!(trace.used_in_scripts);
2605 assert_eq!(trace.import_count, 0);
2606 assert!(trace.imported_by.is_empty());
2607 }
2608
2609 #[test]
2610 fn trace_clone_finds_matching_group() {
2611 use crate::duplicates::{CloneGroup, CloneInstance, DuplicationReport, DuplicationStats};
2612 let report = DuplicationReport {
2613 clone_groups: vec![CloneGroup {
2614 instances: vec![
2615 CloneInstance {
2616 file: PathBuf::from("/project/src/a.ts"),
2617 start_line: 10,
2618 end_line: 20,
2619 start_col: 0,
2620 end_col: 0,
2621 fragment: "fn foo() {}".to_string(),
2622 },
2623 CloneInstance {
2624 file: PathBuf::from("/project/src/b.ts"),
2625 start_line: 5,
2626 end_line: 15,
2627 start_col: 0,
2628 end_col: 0,
2629 fragment: "fn foo() {}".to_string(),
2630 },
2631 ],
2632 token_count: 60,
2633 line_count: 11,
2634 similarity: None,
2635 }],
2636 clone_families: vec![],
2637 mirrored_directories: vec![],
2638 stats: DuplicationStats {
2639 total_files: 2,
2640 files_with_clones: 2,
2641 total_lines: 100,
2642 duplicated_lines: 22,
2643 total_tokens: 200,
2644 duplicated_tokens: 120,
2645 clone_groups: 1,
2646 clone_instances: 2,
2647 duplication_percentage: 22.0,
2648 clone_groups_below_min_occurrences: 0,
2649 clone_groups_ignored: 0,
2650 near_candidates_skipped: 0,
2651 },
2652 };
2653 let trace = trace_clone(&report, Path::new("/project"), "src/a.ts", 15);
2654 assert!(trace.matched_instance.is_some());
2655 assert_eq!(trace.clone_groups.len(), 1);
2656 assert_eq!(trace.clone_groups[0].instances.len(), 2);
2657 assert!(trace.clone_groups[0].fingerprint.starts_with("dup:"));
2658 assert_eq!(trace.clone_groups[0].suggestion.estimated_savings, 11);
2659 }
2660
2661 #[test]
2662 fn trace_clone_by_fingerprint_resolves_and_misses() {
2663 use crate::duplicates::{
2664 CloneGroup, CloneInstance, DuplicationReport, DuplicationStats, clone_fingerprint,
2665 };
2666 let report = DuplicationReport {
2667 clone_groups: vec![CloneGroup {
2668 instances: vec![
2669 CloneInstance {
2670 file: PathBuf::from("/project/src/a.ts"),
2671 start_line: 10,
2672 end_line: 20,
2673 start_col: 0,
2674 end_col: 0,
2675 fragment: "fn buildInvoice() {}".to_string(),
2676 },
2677 CloneInstance {
2678 file: PathBuf::from("/project/src/b.ts"),
2679 start_line: 5,
2680 end_line: 15,
2681 start_col: 0,
2682 end_col: 0,
2683 fragment: "fn buildInvoice() {}".to_string(),
2684 },
2685 ],
2686 token_count: 60,
2687 line_count: 11,
2688 similarity: None,
2689 }],
2690 clone_families: vec![],
2691 mirrored_directories: vec![],
2692 stats: DuplicationStats::default(),
2693 };
2694 let fp = clone_fingerprint(&report.clone_groups[0].instances);
2695
2696 let hit = trace_clone_by_fingerprint(&report, Path::new("/project"), &fp);
2697 assert!(hit.matched_instance.is_some());
2698 assert_eq!(hit.clone_groups.len(), 1);
2699 assert_eq!(hit.clone_groups[0].fingerprint, fp);
2700 assert_eq!(hit.line, 10);
2701
2702 let miss = trace_clone_by_fingerprint(&report, Path::new("/project"), "dup:deadbeef");
2703 assert!(miss.matched_instance.is_none());
2704 assert!(miss.clone_groups.is_empty());
2705 }
2706
2707 #[test]
2708 fn trace_clone_no_match() {
2709 use crate::duplicates::{CloneGroup, CloneInstance, DuplicationReport, DuplicationStats};
2710 let report = DuplicationReport {
2711 clone_groups: vec![CloneGroup {
2712 instances: vec![CloneInstance {
2713 file: PathBuf::from("/project/src/a.ts"),
2714 start_line: 10,
2715 end_line: 20,
2716 start_col: 0,
2717 end_col: 0,
2718 fragment: "fn foo() {}".to_string(),
2719 }],
2720 token_count: 60,
2721 line_count: 11,
2722 similarity: None,
2723 }],
2724 clone_families: vec![],
2725 mirrored_directories: vec![],
2726 stats: DuplicationStats {
2727 total_files: 1,
2728 files_with_clones: 1,
2729 total_lines: 50,
2730 duplicated_lines: 11,
2731 total_tokens: 100,
2732 duplicated_tokens: 60,
2733 clone_groups: 1,
2734 clone_instances: 1,
2735 duplication_percentage: 22.0,
2736 clone_groups_below_min_occurrences: 0,
2737 clone_groups_ignored: 0,
2738 near_candidates_skipped: 0,
2739 },
2740 };
2741 let trace = trace_clone(&report, Path::new("/project"), "src/a.ts", 25);
2742 assert!(trace.matched_instance.is_none());
2743 assert!(trace.clone_groups.is_empty());
2744 }
2745
2746 #[test]
2747 fn trace_clone_line_boundary() {
2748 use crate::duplicates::{CloneGroup, CloneInstance, DuplicationReport, DuplicationStats};
2749 let report = DuplicationReport {
2750 clone_groups: vec![CloneGroup {
2751 instances: vec![
2752 CloneInstance {
2753 file: PathBuf::from("/project/src/a.ts"),
2754 start_line: 10,
2755 end_line: 20,
2756 start_col: 0,
2757 end_col: 0,
2758 fragment: "code".to_string(),
2759 },
2760 CloneInstance {
2761 file: PathBuf::from("/project/src/b.ts"),
2762 start_line: 1,
2763 end_line: 11,
2764 start_col: 0,
2765 end_col: 0,
2766 fragment: "code".to_string(),
2767 },
2768 ],
2769 token_count: 50,
2770 line_count: 11,
2771 similarity: None,
2772 }],
2773 clone_families: vec![],
2774 mirrored_directories: vec![],
2775 stats: DuplicationStats {
2776 total_files: 2,
2777 files_with_clones: 2,
2778 total_lines: 100,
2779 duplicated_lines: 22,
2780 total_tokens: 200,
2781 duplicated_tokens: 100,
2782 clone_groups: 1,
2783 clone_instances: 2,
2784 duplication_percentage: 22.0,
2785 clone_groups_below_min_occurrences: 0,
2786 clone_groups_ignored: 0,
2787 near_candidates_skipped: 0,
2788 },
2789 };
2790 let root = Path::new("/project");
2791 assert!(
2792 trace_clone(&report, root, "src/a.ts", 10)
2793 .matched_instance
2794 .is_some()
2795 );
2796 assert!(
2797 trace_clone(&report, root, "src/a.ts", 20)
2798 .matched_instance
2799 .is_some()
2800 );
2801 assert!(
2802 trace_clone(&report, root, "src/a.ts", 21)
2803 .matched_instance
2804 .is_none()
2805 );
2806 }
2807
2808 #[test]
2809 fn trace_clone_returns_relative_instance_paths() {
2810 use crate::duplicates::{CloneGroup, CloneInstance, DuplicationReport, DuplicationStats};
2811 let report = DuplicationReport {
2812 clone_groups: vec![CloneGroup {
2813 instances: vec![
2814 CloneInstance {
2815 file: PathBuf::from("/project/src/a.ts"),
2816 start_line: 1,
2817 end_line: 10,
2818 start_col: 0,
2819 end_col: 0,
2820 fragment: "code".to_string(),
2821 },
2822 CloneInstance {
2823 file: PathBuf::from("/project/src/b.ts"),
2824 start_line: 1,
2825 end_line: 10,
2826 start_col: 0,
2827 end_col: 0,
2828 fragment: "code".to_string(),
2829 },
2830 ],
2831 token_count: 50,
2832 line_count: 10,
2833 similarity: None,
2834 }],
2835 clone_families: vec![],
2836 mirrored_directories: vec![],
2837 stats: DuplicationStats {
2838 total_files: 2,
2839 files_with_clones: 2,
2840 total_lines: 50,
2841 duplicated_lines: 20,
2842 total_tokens: 100,
2843 duplicated_tokens: 100,
2844 clone_groups: 1,
2845 clone_instances: 2,
2846 duplication_percentage: 40.0,
2847 clone_groups_below_min_occurrences: 0,
2848 clone_groups_ignored: 0,
2849 near_candidates_skipped: 0,
2850 },
2851 };
2852 let trace = trace_clone(&report, Path::new("/project"), "src/a.ts", 5);
2853 let matched = trace.matched_instance.as_ref().expect("match expected");
2854 assert_eq!(matched.file, PathBuf::from("src/a.ts"));
2855 for group in &trace.clone_groups {
2856 for inst in &group.instances {
2857 let as_str = inst.file.to_string_lossy();
2858 assert!(
2859 !as_str.starts_with('/'),
2860 "instance file should be relative, got {as_str}",
2861 );
2862 assert!(
2863 !as_str.contains(":\\") && !as_str.contains(":/"),
2864 "instance file should not have a drive letter, got {as_str}",
2865 );
2866 }
2867 }
2868
2869 let json = serde_json::to_string(&trace).expect("serializes");
2870 assert!(
2871 !json.contains("\"/project/"),
2872 "serialized trace should not leak absolute paths: {json}",
2873 );
2874 }
2875
2876 #[test]
2883 fn path_matches_normalises_windows_module_path_against_posix_user_path() {
2884 let root = Path::new(r"D:\a\fallow\fallow\tests\fixtures\basic-project");
2885 let module_path =
2886 PathBuf::from(r"D:\a\fallow\fallow\tests\fixtures\basic-project\src\utils.ts");
2887 assert!(path_matches(&module_path, root, "src/utils.ts"));
2888 assert!(path_matches(&module_path, root, r"src\utils.ts"));
2889 }
2890
2891 #[test]
2892 fn path_matches_ends_with_fallback_handles_mixed_separators() {
2893 let root = Path::new("/some/other/root");
2894 let module_path =
2895 PathBuf::from(r"D:\a\fallow\fallow\tests\fixtures\basic-project\src\utils.ts");
2896 assert!(path_matches(&module_path, root, "src/utils.ts"));
2897 }
2898
2899 #[test]
2908 fn export_trace_serializes_windows_path_with_forward_slashes() {
2909 let trace = ExportTrace {
2910 file: PathBuf::from(r"src\utils.ts"),
2911 export_name: "foo".to_string(),
2912 namespace: fallow_types::semantic::SemanticNamespace::Value,
2913 file_reachable: true,
2914 is_entry_point: false,
2915 is_used: true,
2916 direct_references: vec![ExportReference {
2917 from_file: PathBuf::from(r"src\entry.ts"),
2918 kind: "named import".to_string(),
2919 }],
2920 direct_references_by_namespace: Vec::new(),
2921 star_export_ambiguity: None,
2922 re_export_chains: vec![ReExportChain {
2923 barrel_file: PathBuf::from(r"src\index.ts"),
2924 exported_as: "foo".to_string(),
2925 reference_count: 1,
2926 }],
2927 reason: "ok".to_string(),
2928 semantic: None,
2929 };
2930 let json = serde_json::to_string(&trace).expect("serializes");
2931 assert!(
2932 json.contains("\"file\":\"src/utils.ts\""),
2933 "ExportTrace.file must serialize with forward slashes: {json}"
2934 );
2935 assert!(
2936 json.contains("\"from_file\":\"src/entry.ts\""),
2937 "ExportReference.from_file must serialize with forward slashes: {json}"
2938 );
2939 assert!(
2940 json.contains("\"barrel_file\":\"src/index.ts\""),
2941 "ReExportChain.barrel_file must serialize with forward slashes: {json}"
2942 );
2943 assert!(
2944 !json.contains(r"\\"),
2945 "no backslash sequence should remain anywhere in the JSON: {json}"
2946 );
2947 }
2948
2949 #[test]
2950 fn file_trace_serializes_windows_paths_with_forward_slashes() {
2951 let trace = FileTrace {
2952 file: PathBuf::from(r"src\utils.ts"),
2953 is_reachable: true,
2954 is_entry_point: false,
2955 exports: vec![],
2956 imports_from: vec![PathBuf::from(r"src\helpers.ts")],
2957 imported_by: vec![PathBuf::from(r"src\entry.ts")],
2958 re_exports: vec![TracedReExport {
2959 source_file: PathBuf::from(r"src\source.ts"),
2960 imported_name: "foo".to_string(),
2961 exported_name: "foo".to_string(),
2962 }],
2963 };
2964 let json = serde_json::to_string(&trace).expect("serializes");
2965 assert!(json.contains("\"file\":\"src/utils.ts\""), "got {json}");
2966 assert!(
2967 json.contains("\"imports_from\":[\"src/helpers.ts\"]"),
2968 "got {json}"
2969 );
2970 assert!(
2971 json.contains("\"imported_by\":[\"src/entry.ts\"]"),
2972 "got {json}"
2973 );
2974 assert!(
2975 json.contains("\"source_file\":\"src/source.ts\""),
2976 "got {json}"
2977 );
2978 assert!(!json.contains(r"\\"), "no backslash should remain: {json}");
2979 }
2980}