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 owners = module
521 .exports
522 .iter()
523 .filter(|export| export.name.matches_str(owner_name))
524 .collect::<Vec<_>>();
525 if owners.len() != 1 {
526 return Err(if owners.is_empty() {
527 SemanticClassMethodResolutionError::SymbolNotFound
528 } else {
529 SemanticClassMethodResolutionError::AmbiguousSymbol
530 });
531 }
532 let owner = owners[0];
533 let members = owner
534 .members
535 .iter()
536 .filter(|member| member.name == member_name)
537 .collect::<Vec<_>>();
538 if members.len() != 1 {
539 return Err(if members.is_empty() {
540 SemanticClassMethodResolutionError::SymbolNotFound
541 } else {
542 SemanticClassMethodResolutionError::AmbiguousSymbol
543 });
544 }
545 let member = members[0];
546 if member.kind != MemberKind::ClassMethod {
547 return Err(SemanticClassMethodResolutionError::UnsupportedSyntax);
548 }
549 let source = std::fs::read_to_string(&module.path)
550 .map_err(|_| SemanticClassMethodResolutionError::SymbolNotFound)?;
551 let offsets = fallow_types::extract::compute_line_offsets(&source);
552 let (line, col) = fallow_types::extract::byte_offset_to_line_col(&offsets, member.span.start);
553 Ok(SemanticSymbol {
554 path: module
555 .path
556 .strip_prefix(root)
557 .unwrap_or(&module.path)
558 .to_path_buf(),
559 namespace: SemanticNamespace::Value,
560 declaration_kind: "class_method".to_string(),
561 exported_name: member_name.to_string(),
562 local_name: member_name.to_string(),
563 owner: Some(owner_name.to_string()),
564 line,
565 col,
566 })
567}
568
569#[must_use]
575pub fn trace_class_member(
576 graph: &ModuleGraph,
577 root: &Path,
578 file_path: &str,
579 member_name: &str,
580) -> Option<ClassMemberTrace> {
581 use fallow_types::extract::MemberKind;
582
583 let module = graph
584 .modules
585 .iter()
586 .find(|m| path_matches(&m.path, root, file_path))?;
587
588 let (owner, member_kind) = module
592 .exports
593 .iter()
594 .filter_map(|export| {
595 export
596 .members
597 .iter()
598 .find(|member| member.name == member_name)
599 .map(|member| (export, member.kind))
600 })
601 .max_by_key(|(export, _)| (!export.references.is_empty(), !export.is_type_only))?;
602
603 let owner_name = owner.name.to_string();
604 let owner_trace = trace_export(graph, root, file_path, &owner_name)?;
611
612 let (kind_str, filter_flag) = match member_kind {
613 MemberKind::ClassMethod => ("class-method", Some("--unused-class-members")),
614 MemberKind::ClassProperty => ("class-property", Some("--unused-class-members")),
615 MemberKind::EnumMember => ("enum-member", Some("--unused-enum-members")),
616 MemberKind::StoreMember => ("store-member", Some("--unused-store-members")),
617 MemberKind::NamespaceMember => ("namespace-member", None),
618 };
619
620 let reason = class_member_trace_reason(
621 member_name,
622 &owner_name,
623 kind_str,
624 filter_flag,
625 file_path,
626 &owner_trace,
627 );
628
629 Some(ClassMemberTrace {
630 file: owner_trace.file,
631 member_name: member_name.to_string(),
632 member_kind: kind_str.to_string(),
633 owner_export: owner_name,
634 owner_namespace: owner_trace.namespace,
635 owner_is_used: owner_trace.is_used,
636 owner_file_reachable: owner_trace.file_reachable,
637 owner_is_entry_point: owner_trace.is_entry_point,
638 owner_direct_references: owner_trace.direct_references,
639 owner_re_export_chains: owner_trace.re_export_chains,
640 reason,
641 semantic: None,
642 })
643}
644
645fn class_member_trace_reason(
648 member_name: &str,
649 owner_name: &str,
650 kind_str: &str,
651 filter_flag: Option<&str>,
652 file_path: &str,
653 owner_trace: &ExportTrace,
654) -> String {
655 let head =
656 format!("'{member_name}' is a {kind_str} of '{owner_name}', not a top-level export. ");
657 let body = if !owner_trace.file_reachable {
658 format!(
659 "The file is not reachable from any entry point, so '{owner_name}' and all its \
660 members are dead (see the unused-file finding)."
661 )
662 } else if !owner_trace.is_used {
663 format!(
664 "'{owner_name}' is reachable but referenced by no file, so it is reported as an \
665 unused export and its members are not judged individually."
666 )
667 } else {
668 let refs = owner_trace.direct_references.len();
669 match filter_flag {
670 Some(flag) => format!(
671 "'{owner_name}' is used by {refs} file(s); whether '{member_name}' itself is \
672 flagged depends on cross-file member-access resolution. Run \
673 `fallow dead-code {flag} --file {file_path}` to see the member finding."
674 ),
675 None => format!(
676 "'{owner_name}' is used by {refs} file(s); '{member_name}' is credited through \
677 its namespace export."
678 ),
679 }
680 };
681 format!("{head}{body}")
682}
683
684fn select_export<'graph>(
685 graph: &'graph ModuleGraph,
686 module: &'graph crate::graph::ModuleNode,
687 export_name: &str,
688) -> Option<crate::graph::EffectiveExportSurface<'graph>> {
689 [ExportNamespace::Value, ExportNamespace::Type]
690 .into_iter()
691 .find_map(|namespace| {
692 graph.effective_export_surface(module.file_id, export_name, namespace)
693 })
694}
695
696fn traced_exports(
698 graph: &ModuleGraph,
699 root: &Path,
700 module: &crate::graph::ModuleNode,
701) -> Vec<TracedExport> {
702 module
703 .exports
704 .iter()
705 .map(|e| {
706 let referenced_by: Vec<_> = e
707 .physical_references()
708 .map(|r| reference_to_export_reference(graph, root, r))
709 .collect();
710 TracedExport {
711 name: e.name.to_string(),
712 is_type_only: e.is_type_only,
713 reference_count: referenced_by.len(),
714 referenced_by,
715 }
716 })
717 .collect()
718}
719
720fn traced_imports_from(
722 graph: &ModuleGraph,
723 root: &Path,
724 module: &crate::graph::ModuleNode,
725) -> Vec<PathBuf> {
726 graph
727 .edges_for(module.file_id)
728 .iter()
729 .filter_map(|target_id| {
730 graph
731 .modules
732 .get(target_id.0 as usize)
733 .map(|m| m.path.strip_prefix(root).unwrap_or(&m.path).to_path_buf())
734 })
735 .collect()
736}
737
738fn traced_imported_by(
740 graph: &ModuleGraph,
741 root: &Path,
742 module: &crate::graph::ModuleNode,
743) -> Vec<PathBuf> {
744 graph
745 .reverse_deps
746 .get(module.file_id.0 as usize)
747 .map(|deps| {
748 deps.iter()
749 .filter_map(|fid| {
750 graph
751 .modules
752 .get(fid.0 as usize)
753 .map(|m| m.path.strip_prefix(root).unwrap_or(&m.path).to_path_buf())
754 })
755 .collect()
756 })
757 .unwrap_or_default()
758}
759
760fn traced_re_exports(
762 graph: &ModuleGraph,
763 root: &Path,
764 module: &crate::graph::ModuleNode,
765) -> Vec<TracedReExport> {
766 module
767 .re_exports
768 .iter()
769 .map(|re| {
770 let source_path = graph.modules.get(re.source_file.0 as usize).map_or_else(
771 || PathBuf::from(format!("<unknown:{}>", re.source_file.0)),
772 |m| m.path.strip_prefix(root).unwrap_or(&m.path).to_path_buf(),
773 );
774 TracedReExport {
775 source_file: source_path,
776 imported_name: re.imported_name.clone(),
777 exported_name: re.exported_name.clone(),
778 }
779 })
780 .collect()
781}
782
783#[must_use]
785pub fn trace_file(graph: &ModuleGraph, root: &Path, file_path: &str) -> Option<FileTrace> {
786 let module = graph
787 .modules
788 .iter()
789 .find(|m| path_matches(&m.path, root, file_path))?;
790
791 Some(FileTrace {
792 file: module
793 .path
794 .strip_prefix(root)
795 .unwrap_or(&module.path)
796 .to_path_buf(),
797 is_reachable: module.is_reachable(),
798 is_entry_point: module.is_entry_point(),
799 exports: traced_exports(graph, root, module),
800 imports_from: traced_imports_from(graph, root, module),
801 imported_by: traced_imported_by(graph, root, module),
802 re_exports: traced_re_exports(graph, root, module),
803 })
804}
805
806#[must_use]
815pub fn trace_dependency(
816 graph: &ModuleGraph,
817 root: &Path,
818 package_name: &str,
819 script_used_packages: &FxHashSet<String>,
820) -> DependencyTrace {
821 let imported_by: Vec<PathBuf> = graph
822 .package_usage
823 .get(package_name)
824 .map(|ids| {
825 ids.iter()
826 .filter_map(|fid| {
827 graph
828 .modules
829 .get(fid.0 as usize)
830 .map(|m| m.path.strip_prefix(root).unwrap_or(&m.path).to_path_buf())
831 })
832 .collect()
833 })
834 .unwrap_or_default();
835
836 let type_only_imported_by: Vec<PathBuf> = graph
837 .type_only_package_usage
838 .get(package_name)
839 .map(|ids| {
840 ids.iter()
841 .filter_map(|fid| {
842 graph
843 .modules
844 .get(fid.0 as usize)
845 .map(|m| m.path.strip_prefix(root).unwrap_or(&m.path).to_path_buf())
846 })
847 .collect()
848 })
849 .unwrap_or_default();
850
851 let import_count = imported_by.len();
852 let used_in_scripts = script_used_packages.contains(package_name);
853 DependencyTrace {
854 package_name: package_name.to_string(),
855 imported_by,
856 type_only_imported_by,
857 used_in_scripts,
858 is_used: import_count > 0 || used_in_scripts,
859 import_count,
860 }
861}
862
863fn format_reference_kind(kind: ReferenceKind) -> String {
864 match kind {
865 ReferenceKind::NamedImport => "named import".to_string(),
866 ReferenceKind::DefaultImport => "default import".to_string(),
867 ReferenceKind::NamespaceImport => "namespace import".to_string(),
868 ReferenceKind::ReExport => "re-export".to_string(),
869 ReferenceKind::DynamicImport => "dynamic import".to_string(),
870 ReferenceKind::SideEffectImport => "side-effect import".to_string(),
871 }
872}
873
874#[must_use]
881pub fn trace_impact_closure(
882 graph: &ModuleGraph,
883 root: &Path,
884 file_path: &str,
885) -> Option<ImpactClosureTrace> {
886 let module = graph
887 .modules
888 .iter()
889 .find(|m| path_matches(&m.path, root, file_path))?;
890
891 let closure = graph.impact_closure(&[module.file_id]);
892 let paths = graph.closure_with_paths(&closure, root);
893
894 let seed = paths
895 .in_diff
896 .first()
897 .cloned()
898 .unwrap_or_else(|| file_path.replace('\\', "/"));
899
900 let coordination_gap = paths
901 .coordination_gap
902 .into_iter()
903 .map(|gap| ImpactClosureGap {
904 consumer_file: gap.consumer_file,
905 consumed_symbols: gap.consumed_symbols,
906 note: "syntactic attention pointer, not a correctness proof".to_string(),
907 })
908 .collect();
909
910 Some(ImpactClosureTrace {
911 seed,
912 affected_not_shown: paths.affected_not_shown,
913 coordination_gap,
914 })
915}
916
917fn build_traced_group(
921 group: &CloneGroup,
922 root: &Path,
923 fingerprints: &CloneFingerprintSet,
924) -> TracedCloneGroup {
925 TracedCloneGroup {
926 fingerprint: fingerprints.fingerprint_for_group(group),
927 token_count: group.token_count,
928 line_count: group.line_count,
929 spread: group.spread(),
930 similarity: group.similarity,
931 instances: group
932 .instances
933 .iter()
934 .map(|inst| relativize_instance(inst, root))
935 .collect(),
936 suggestion: group_refactoring_suggestion(group),
937 suggested_name: dominant_identifier(group),
938 }
939}
940
941#[must_use]
942pub fn trace_clone(
943 report: &DuplicationReport,
944 root: &Path,
945 file_path: &str,
946 line: usize,
947) -> CloneTrace {
948 let resolved = root.join(file_path);
949 let mut matched_instance = None;
950 let mut clone_groups = Vec::new();
951 let fingerprints = CloneFingerprintSet::from_groups(&report.clone_groups);
952
953 for group in &report.clone_groups {
954 let matching = group.instances.iter().find(|inst| {
955 let inst_matches = inst.file == resolved
956 || inst.file.strip_prefix(root).unwrap_or(&inst.file) == Path::new(file_path);
957 inst_matches && inst.start_line <= line && line <= inst.end_line
958 });
959
960 if let Some(matched) = matching {
961 if matched_instance.is_none() {
962 matched_instance = Some(relativize_instance(matched, root));
963 }
964 clone_groups.push(build_traced_group(group, root, &fingerprints));
965 }
966 }
967
968 CloneTrace {
969 file: PathBuf::from(file_path),
970 line,
971 matched_instance,
972 clone_groups,
973 }
974}
975
976#[must_use]
986pub fn trace_clone_by_fingerprint(
987 report: &DuplicationReport,
988 root: &Path,
989 fingerprint: &str,
990) -> CloneTrace {
991 let fingerprints = CloneFingerprintSet::from_groups(&report.clone_groups);
992 let matched = fingerprints.find_group(&report.clone_groups, fingerprint);
993
994 let Some(group) = matched else {
995 return CloneTrace {
996 file: PathBuf::new(),
997 line: 0,
998 matched_instance: None,
999 clone_groups: Vec::new(),
1000 };
1001 };
1002
1003 let representative = group
1004 .instances
1005 .first()
1006 .map(|inst| relativize_instance(inst, root));
1007 let (file, line) = representative.as_ref().map_or_else(
1008 || (PathBuf::new(), 0),
1009 |inst| (inst.file.clone(), inst.start_line),
1010 );
1011
1012 CloneTrace {
1013 file,
1014 line,
1015 matched_instance: representative,
1016 clone_groups: vec![build_traced_group(group, root, &fingerprints)],
1017 }
1018}
1019
1020fn relativize_instance(inst: &CloneInstance, root: &Path) -> CloneInstance {
1024 let rel = inst.file.strip_prefix(root).map_or_else(
1025 |_| inst.file.clone(),
1026 |p| PathBuf::from(p.to_string_lossy().replace('\\', "/")),
1027 );
1028 CloneInstance {
1029 file: rel,
1030 ..inst.clone()
1031 }
1032}
1033
1034#[cfg(test)]
1035mod tests {
1036 use super::*;
1037
1038 use crate::discover::{DiscoveredFile, EntryPoint, EntryPointSource, FileId};
1039 use crate::extract::{ExportInfo, ExportName, ImportInfo, ImportedName, VisibilityTag};
1040 use crate::resolve::{ResolveResult, ResolvedImport, ResolvedModule, ResolvedReExport};
1041 use fallow_types::extract::ReExportInfo;
1042
1043 fn resolved_re_export(
1044 source: FileId,
1045 imported_name: &str,
1046 exported_name: &str,
1047 ) -> ResolvedReExport {
1048 ResolvedReExport {
1049 info: ReExportInfo {
1050 source: "./source".to_string(),
1051 imported_name: imported_name.to_string(),
1052 exported_name: exported_name.to_string(),
1053 is_type_only: false,
1054 span: oxc_span::Span::default(),
1055 statement_span: oxc_span::Span::default(),
1056 source_span: oxc_span::Span::default(),
1057 },
1058 target: ResolveResult::InternalModule(source),
1059 }
1060 }
1061
1062 fn build_test_graph() -> ModuleGraph {
1063 let files = vec![
1064 DiscoveredFile {
1065 id: FileId(0),
1066 path: PathBuf::from("/project/src/entry.ts"),
1067 size_bytes: 100,
1068 },
1069 DiscoveredFile {
1070 id: FileId(1),
1071 path: PathBuf::from("/project/src/utils.ts"),
1072 size_bytes: 50,
1073 },
1074 DiscoveredFile {
1075 id: FileId(2),
1076 path: PathBuf::from("/project/src/unused.ts"),
1077 size_bytes: 30,
1078 },
1079 ];
1080
1081 let entry_points = vec![EntryPoint {
1082 path: PathBuf::from("/project/src/entry.ts"),
1083 source: EntryPointSource::PackageJsonMain,
1084 }];
1085
1086 let resolved_modules = vec![
1087 ResolvedModule {
1088 file_id: FileId(0),
1089 path: PathBuf::from("/project/src/entry.ts"),
1090 resolved_imports: vec![ResolvedImport {
1091 info: ImportInfo {
1092 source: "./utils".to_string(),
1093 imported_name: ImportedName::Named("foo".to_string()),
1094 local_name: "foo".to_string(),
1095 is_type_only: false,
1096 is_type_only_star: false,
1097 from_style: false,
1098 span: oxc_span::Span::new(0, 10),
1099 source_span: oxc_span::Span::default(),
1100 },
1101 target: ResolveResult::InternalModule(FileId(1)),
1102 }],
1103 ..Default::default()
1104 },
1105 ResolvedModule {
1106 file_id: FileId(1),
1107 path: PathBuf::from("/project/src/utils.ts"),
1108 exports: vec![
1109 ExportInfo {
1110 name: ExportName::Named("foo".to_string()),
1111 local_name: Some("foo".to_string()),
1112 is_type_only: false,
1113 visibility: VisibilityTag::None,
1114 expected_unused_reason: None,
1115 span: oxc_span::Span::new(0, 20),
1116 members: vec![],
1117 is_side_effect_used: false,
1118 super_class: None,
1119 },
1120 ExportInfo {
1121 name: ExportName::Named("bar".to_string()),
1122 local_name: Some("bar".to_string()),
1123 is_type_only: false,
1124 visibility: VisibilityTag::None,
1125 expected_unused_reason: None,
1126 span: oxc_span::Span::new(21, 40),
1127 members: vec![],
1128 is_side_effect_used: false,
1129 super_class: None,
1130 },
1131 ]
1132 .into(),
1133 ..Default::default()
1134 },
1135 ResolvedModule {
1136 file_id: FileId(2),
1137 path: PathBuf::from("/project/src/unused.ts"),
1138 exports: vec![ExportInfo {
1139 name: ExportName::Named("baz".to_string()),
1140 local_name: Some("baz".to_string()),
1141 is_type_only: false,
1142 visibility: VisibilityTag::None,
1143 expected_unused_reason: None,
1144 span: oxc_span::Span::new(0, 15),
1145 members: vec![],
1146 is_side_effect_used: false,
1147 super_class: None,
1148 }]
1149 .into(),
1150 ..Default::default()
1151 },
1152 ];
1153
1154 ModuleGraph::build(&resolved_modules, &entry_points, &files)
1155 }
1156
1157 #[test]
1158 fn trace_used_export() {
1159 let graph = build_test_graph();
1160 let root = Path::new("/project");
1161
1162 let trace = trace_export(&graph, root, "src/utils.ts", "foo").unwrap();
1163 assert!(trace.is_used);
1164 assert!(trace.file_reachable);
1165 assert_eq!(trace.direct_references.len(), 1);
1166 assert_eq!(
1167 trace.direct_references[0].from_file,
1168 PathBuf::from("src/entry.ts")
1169 );
1170 assert_eq!(trace.direct_references[0].kind, "named import");
1171 }
1172
1173 #[test]
1174 fn trace_unused_export() {
1175 let graph = build_test_graph();
1176 let root = Path::new("/project");
1177
1178 let trace = trace_export(&graph, root, "src/utils.ts", "bar").unwrap();
1179 assert!(!trace.is_used);
1180 assert!(trace.file_reachable);
1181 assert!(trace.direct_references.is_empty());
1182 assert_eq!(
1183 trace.namespace,
1184 fallow_types::semantic::SemanticNamespace::Value,
1185 "an unreferenced value export stays in the value namespace"
1186 );
1187 }
1188
1189 #[test]
1190 fn trace_unreachable_file_export() {
1191 let graph = build_test_graph();
1192 let root = Path::new("/project");
1193
1194 let trace = trace_export(&graph, root, "src/unused.ts", "baz").unwrap();
1195 assert!(!trace.is_used);
1196 assert!(!trace.file_reachable);
1197 assert!(trace.reason.contains("unreachable"));
1198 }
1199
1200 #[test]
1201 fn trace_nonexistent_export() {
1202 let graph = build_test_graph();
1203 let root = Path::new("/project");
1204
1205 let trace = trace_export(&graph, root, "src/utils.ts", "nonexistent");
1206 assert!(trace.is_none());
1207 }
1208
1209 #[test]
1210 fn trace_reports_only_the_effective_re_export_origin() {
1211 let files: Vec<_> = ["entry", "barrel", "star-source", "explicit-source"]
1212 .into_iter()
1213 .enumerate()
1214 .map(|(index, name)| DiscoveredFile {
1215 id: FileId(index as u32),
1216 path: PathBuf::from(format!("/project/src/{name}.ts")),
1217 size_bytes: 10,
1218 })
1219 .collect();
1220 let entry_points = vec![EntryPoint {
1221 path: files[0].path.clone(),
1222 source: EntryPointSource::PackageJsonMain,
1223 }];
1224 let re_export = |source: FileId, imported: &str, exported: &str| ResolvedReExport {
1225 info: ReExportInfo {
1226 source: format!("./{}", source.0),
1227 imported_name: imported.to_string(),
1228 exported_name: exported.to_string(),
1229 is_type_only: false,
1230 span: oxc_span::Span::default(),
1231 statement_span: oxc_span::Span::default(),
1232 source_span: oxc_span::Span::default(),
1233 },
1234 target: ResolveResult::InternalModule(source),
1235 };
1236 let export = || ExportInfo {
1237 name: ExportName::Named("foo".to_string()),
1238 local_name: Some("foo".to_string()),
1239 is_type_only: false,
1240 visibility: VisibilityTag::None,
1241 expected_unused_reason: None,
1242 span: oxc_span::Span::new(0, 3),
1243 members: Vec::new(),
1244 is_side_effect_used: false,
1245 super_class: None,
1246 };
1247 let resolved = vec![
1248 ResolvedModule {
1249 file_id: FileId(0),
1250 path: files[0].path.clone(),
1251 resolved_imports: vec![ResolvedImport {
1252 info: ImportInfo {
1253 source: "./barrel".to_string(),
1254 imported_name: ImportedName::Named("foo".to_string()),
1255 local_name: "foo".to_string(),
1256 is_type_only: false,
1257 is_type_only_star: false,
1258 from_style: false,
1259 span: oxc_span::Span::default(),
1260 source_span: oxc_span::Span::default(),
1261 },
1262 target: ResolveResult::InternalModule(FileId(1)),
1263 }],
1264 ..Default::default()
1265 },
1266 ResolvedModule {
1267 file_id: FileId(1),
1268 path: files[1].path.clone(),
1269 re_exports: vec![
1270 re_export(FileId(2), "*", "*"),
1271 re_export(FileId(3), "foo", "foo"),
1272 ],
1273 ..Default::default()
1274 },
1275 ResolvedModule {
1276 file_id: FileId(2),
1277 path: files[2].path.clone(),
1278 exports: vec![export()].into(),
1279 ..Default::default()
1280 },
1281 ResolvedModule {
1282 file_id: FileId(3),
1283 path: files[3].path.clone(),
1284 exports: vec![export()].into(),
1285 ..Default::default()
1286 },
1287 ];
1288 let graph = ModuleGraph::build(&resolved, &entry_points, &files);
1289
1290 let shadowed = trace_export(&graph, Path::new("/project"), "src/star-source.ts", "foo")
1291 .expect("shadowed source export exists");
1292 let effective = trace_export(
1293 &graph,
1294 Path::new("/project"),
1295 "src/explicit-source.ts",
1296 "foo",
1297 )
1298 .expect("effective source export exists");
1299
1300 assert!(shadowed.re_export_chains.is_empty());
1301 assert_eq!(effective.re_export_chains.len(), 1);
1302 assert_eq!(effective.re_export_chains[0].exported_as, "foo");
1303 }
1304
1305 fn star_surface_trace_graph(root: &Path) -> ModuleGraph {
1306 let src = root.join("src");
1307 std::fs::create_dir_all(&src).expect("create source directory");
1308 let paths: Vec<_> = ["source", "barrel-a", "barrel-b", "outer", "entry"]
1309 .into_iter()
1310 .map(|name| src.join(format!("{name}.ts")))
1311 .collect();
1312 std::fs::write(&paths[0], "\n\nexport const foo = 1;\n").expect("write source");
1313 for path in &paths[1..] {
1314 std::fs::write(path, "export {};\n").expect("write module");
1315 }
1316 let files: Vec<_> = paths
1317 .iter()
1318 .enumerate()
1319 .map(|(index, path)| DiscoveredFile {
1320 id: FileId(index as u32),
1321 path: path.clone(),
1322 size_bytes: 20,
1323 })
1324 .collect();
1325 let resolved = vec![
1326 ResolvedModule {
1327 file_id: FileId(0),
1328 path: paths[0].clone(),
1329 exports: vec![ExportInfo {
1330 name: ExportName::Named("foo".to_string()),
1331 local_name: Some("foo".to_string()),
1332 is_type_only: false,
1333 visibility: VisibilityTag::None,
1334 expected_unused_reason: None,
1335 span: oxc_span::Span::new(2, 5),
1336 members: Vec::new(),
1337 is_side_effect_used: false,
1338 super_class: None,
1339 }]
1340 .into(),
1341 ..Default::default()
1342 },
1343 ResolvedModule {
1344 file_id: FileId(1),
1345 path: paths[1].clone(),
1346 re_exports: vec![resolved_re_export(FileId(0), "*", "*")],
1347 ..Default::default()
1348 },
1349 ResolvedModule {
1350 file_id: FileId(2),
1351 path: paths[2].clone(),
1352 re_exports: vec![resolved_re_export(FileId(0), "*", "*")],
1353 ..Default::default()
1354 },
1355 ResolvedModule {
1356 file_id: FileId(3),
1357 path: paths[3].clone(),
1358 re_exports: vec![
1359 resolved_re_export(FileId(1), "foo", "left"),
1360 resolved_re_export(FileId(1), "foo", "right"),
1361 ],
1362 ..Default::default()
1363 },
1364 ResolvedModule {
1365 file_id: FileId(4),
1366 path: paths[4].clone(),
1367 resolved_imports: vec![
1368 ResolvedImport {
1369 info: ImportInfo {
1370 source: "./outer".to_string(),
1371 imported_name: ImportedName::Named("left".to_string()),
1372 local_name: "left".to_string(),
1373 is_type_only: false,
1374 is_type_only_star: false,
1375 from_style: false,
1376 span: oxc_span::Span::new(10, 20),
1377 source_span: oxc_span::Span::default(),
1378 },
1379 target: ResolveResult::InternalModule(FileId(3)),
1380 },
1381 ResolvedImport {
1382 info: ImportInfo {
1383 source: "./barrel-b".to_string(),
1384 imported_name: ImportedName::Named("foo".to_string()),
1385 local_name: "otherFoo".to_string(),
1386 is_type_only: false,
1387 is_type_only_star: false,
1388 from_style: false,
1389 span: oxc_span::Span::new(30, 40),
1390 source_span: oxc_span::Span::default(),
1391 },
1392 target: ResolveResult::InternalModule(FileId(2)),
1393 },
1394 ],
1395 ..Default::default()
1396 },
1397 ];
1398 let entry_points = vec![EntryPoint {
1399 path: paths[4].clone(),
1400 source: EntryPointSource::PackageJsonMain,
1401 }];
1402 ModuleGraph::build(&resolved, &entry_points, &files)
1403 }
1404
1405 #[test]
1406 fn star_surface_trace_keeps_aliases_separate_and_uses_origin_identity() {
1407 let root = tempfile::tempdir().expect("temporary project");
1408 let graph = star_surface_trace_graph(root.path());
1409
1410 let used = trace_export(&graph, root.path(), "src/barrel-a.ts", "foo")
1411 .expect("aliased barrel exposes foo");
1412 let sibling = trace_export(&graph, root.path(), "src/barrel-b.ts", "foo")
1413 .expect("sibling barrel exposes foo");
1414 assert!(used.is_used);
1415 assert_eq!(used.direct_references.len(), 1);
1416 assert!(sibling.is_used);
1417 assert_eq!(sibling.direct_references.len(), 1);
1418
1419 let source_trace = trace_export(&graph, root.path(), "src/source.ts", "foo")
1420 .expect("source declaration is traceable");
1421 let chain_count = |file: &str, name: &str| {
1422 source_trace
1423 .re_export_chains
1424 .iter()
1425 .find(|chain| chain.barrel_file == Path::new(file) && chain.exported_as == name)
1426 .map(|chain| chain.reference_count)
1427 };
1428 assert_eq!(chain_count("src/barrel-a.ts", "foo"), Some(1));
1429 assert_eq!(chain_count("src/barrel-b.ts", "foo"), Some(1));
1430 assert_eq!(chain_count("src/outer.ts", "left"), Some(1));
1431 assert_eq!(chain_count("src/outer.ts", "right"), Some(0));
1432
1433 let semantic = semantic_symbol_for_export(&graph, root.path(), "src/barrel-a.ts", "foo")
1434 .expect("star surface resolves to its declaration identity");
1435 assert_eq!(semantic.path, Path::new("src/source.ts"));
1436 assert_eq!(semantic.exported_name, "foo");
1437 assert_eq!(semantic.local_name, "foo");
1438 assert_eq!((semantic.line, semantic.col), (3, 0));
1439
1440 let alias = semantic_symbol_for_export(&graph, root.path(), "src/outer.ts", "left")
1441 .expect("named re-export keeps its export-specifier identity");
1442 assert_eq!(alias.path, Path::new("src/outer.ts"));
1443 assert_eq!(alias.exported_name, "left");
1444 assert_eq!(alias.local_name, "foo");
1445 }
1446
1447 #[test]
1448 fn trace_follows_renamed_and_convergent_re_export_routes() {
1449 let names = [
1450 "source",
1451 "renamed",
1452 "final",
1453 "left",
1454 "right",
1455 "diamond-entry",
1456 ];
1457 let files: Vec<_> = names
1458 .into_iter()
1459 .enumerate()
1460 .map(|(index, name)| DiscoveredFile {
1461 id: FileId(index as u32),
1462 path: PathBuf::from(format!("/project/src/{name}.ts")),
1463 size_bytes: 10,
1464 })
1465 .collect();
1466 let re_export = |source: FileId, imported: &str, exported: &str| ResolvedReExport {
1467 info: ReExportInfo {
1468 source: format!("./{}", source.0),
1469 imported_name: imported.to_string(),
1470 exported_name: exported.to_string(),
1471 is_type_only: false,
1472 span: oxc_span::Span::default(),
1473 statement_span: oxc_span::Span::default(),
1474 source_span: oxc_span::Span::default(),
1475 },
1476 target: ResolveResult::InternalModule(source),
1477 };
1478 let mut resolved: Vec<_> = files
1479 .iter()
1480 .map(|file| ResolvedModule {
1481 file_id: file.id,
1482 path: file.path.clone(),
1483 ..Default::default()
1484 })
1485 .collect();
1486 resolved[0].exports = vec![ExportInfo {
1487 name: ExportName::Named("foo".to_string()),
1488 local_name: Some("foo".to_string()),
1489 is_type_only: false,
1490 visibility: VisibilityTag::None,
1491 expected_unused_reason: None,
1492 span: oxc_span::Span::new(0, 3),
1493 members: Vec::new(),
1494 is_side_effect_used: false,
1495 super_class: None,
1496 }]
1497 .into();
1498 resolved[1].re_exports = vec![re_export(FileId(0), "foo", "bar")];
1499 resolved[2].re_exports = vec![re_export(FileId(1), "bar", "baz")];
1500 resolved[3].re_exports = vec![re_export(FileId(0), "*", "*")];
1501 resolved[4].re_exports = vec![re_export(FileId(0), "*", "*")];
1502 resolved[5].re_exports = vec![
1503 re_export(FileId(3), "*", "*"),
1504 re_export(FileId(4), "*", "*"),
1505 ];
1506 let entry_points = vec![EntryPoint {
1507 path: files[5].path.clone(),
1508 source: EntryPointSource::PackageJsonMain,
1509 }];
1510 let graph = ModuleGraph::build(&resolved, &entry_points, &files);
1511
1512 let trace = trace_export(&graph, Path::new("/project"), "src/source.ts", "foo")
1513 .expect("source export exists");
1514 let routes: FxHashSet<_> = trace
1515 .re_export_chains
1516 .iter()
1517 .map(|route| (route.barrel_file.as_path(), route.exported_as.as_str()))
1518 .collect();
1519
1520 assert_eq!(routes.len(), 5);
1521 assert!(routes.contains(&(Path::new("src/renamed.ts"), "bar")));
1522 assert!(routes.contains(&(Path::new("src/final.ts"), "baz")));
1523 assert!(routes.contains(&(Path::new("src/left.ts"), "foo")));
1524 assert!(routes.contains(&(Path::new("src/right.ts"), "foo")));
1525 assert!(routes.contains(&(Path::new("src/diamond-entry.ts"), "foo")));
1526 }
1527
1528 #[test]
1529 fn trace_prefers_the_value_namespace_independent_of_usage() {
1530 let files = vec![
1531 DiscoveredFile {
1532 id: FileId(0),
1533 path: PathBuf::from("/project/src/entry.ts"),
1534 size_bytes: 10,
1535 },
1536 DiscoveredFile {
1537 id: FileId(1),
1538 path: PathBuf::from("/project/src/source.ts"),
1539 size_bytes: 10,
1540 },
1541 ];
1542 let export = |is_type_only| ExportInfo {
1543 name: ExportName::Named("Foo".to_string()),
1544 local_name: Some("Foo".to_string()),
1545 is_type_only,
1546 visibility: VisibilityTag::None,
1547 expected_unused_reason: None,
1548 span: oxc_span::Span::new(0, 3),
1549 members: Vec::new(),
1550 is_side_effect_used: false,
1551 super_class: None,
1552 };
1553 let resolved = vec![
1554 ResolvedModule {
1555 file_id: FileId(0),
1556 path: files[0].path.clone(),
1557 resolved_imports: vec![ResolvedImport {
1558 info: ImportInfo {
1559 source: "./source".to_string(),
1560 imported_name: ImportedName::Named("Foo".to_string()),
1561 local_name: "Foo".to_string(),
1562 is_type_only: true,
1563 is_type_only_star: false,
1564 from_style: false,
1565 span: oxc_span::Span::default(),
1566 source_span: oxc_span::Span::default(),
1567 },
1568 target: ResolveResult::InternalModule(FileId(1)),
1569 }],
1570 ..Default::default()
1571 },
1572 ResolvedModule {
1573 file_id: FileId(1),
1574 path: files[1].path.clone(),
1575 exports: vec![export(false), export(true)].into(),
1576 ..Default::default()
1577 },
1578 ];
1579 let entry_points = vec![EntryPoint {
1580 path: files[0].path.clone(),
1581 source: EntryPointSource::PackageJsonMain,
1582 }];
1583 let graph = ModuleGraph::build(&resolved, &entry_points, &files);
1584
1585 let trace = trace_export(&graph, Path::new("/project"), "src/source.ts", "Foo")
1586 .expect("value export exists");
1587
1588 assert_eq!(
1596 trace.namespace,
1597 fallow_types::semantic::SemanticNamespace::Value
1598 );
1599 assert!(!trace.is_used, "type usage must not select the type export");
1600 }
1601
1602 fn source_consumer_graph(
1605 source_exports: Vec<ExportInfo>,
1606 import_is_type_only: bool,
1607 classified_usage: bool,
1608 semantic_facts: Vec<fallow_types::extract::SemanticFact>,
1609 ) -> ModuleGraph {
1610 let files = vec![
1611 DiscoveredFile {
1612 id: FileId(0),
1613 path: PathBuf::from("/project/src/entry.ts"),
1614 size_bytes: 10,
1615 },
1616 DiscoveredFile {
1617 id: FileId(1),
1618 path: PathBuf::from("/project/src/source.ts"),
1619 size_bytes: 10,
1620 },
1621 ];
1622 let classified = if classified_usage {
1623 vec!["Foo".to_string()]
1624 } else {
1625 Vec::new()
1626 };
1627 let resolved = vec![
1628 ResolvedModule {
1629 file_id: FileId(0),
1630 path: files[0].path.clone(),
1631 resolved_imports: vec![ResolvedImport {
1632 info: ImportInfo {
1633 source: "./source".to_string(),
1634 imported_name: ImportedName::Named("Foo".to_string()),
1635 local_name: "Foo".to_string(),
1636 is_type_only: import_is_type_only,
1637 is_type_only_star: false,
1638 from_style: false,
1639 span: oxc_span::Span::new(0, 10),
1640 source_span: oxc_span::Span::default(),
1641 },
1642 target: ResolveResult::InternalModule(FileId(1)),
1643 }],
1644 type_referenced_import_bindings: classified.clone(),
1645 value_referenced_import_bindings: classified,
1646 ..Default::default()
1647 },
1648 ResolvedModule {
1649 file_id: FileId(1),
1650 path: files[1].path.clone(),
1651 exports: source_exports.into(),
1652 semantic_facts: semantic_facts.into(),
1653 ..Default::default()
1654 },
1655 ];
1656 let entry_points = vec![EntryPoint {
1657 path: files[0].path.clone(),
1658 source: EntryPointSource::PackageJsonMain,
1659 }];
1660 ModuleGraph::build(&resolved, &entry_points, &files)
1661 }
1662
1663 fn dual_lane_consumer_graph(source_exports: Vec<ExportInfo>) -> ModuleGraph {
1667 let files = vec![
1668 DiscoveredFile {
1669 id: FileId(0),
1670 path: PathBuf::from("/project/src/entry.ts"),
1671 size_bytes: 10,
1672 },
1673 DiscoveredFile {
1674 id: FileId(1),
1675 path: PathBuf::from("/project/src/source.ts"),
1676 size_bytes: 10,
1677 },
1678 DiscoveredFile {
1679 id: FileId(2),
1680 path: PathBuf::from("/project/src/typed.ts"),
1681 size_bytes: 10,
1682 },
1683 ];
1684 let consumer = |file_id: FileId, path: PathBuf, is_type_only: bool| ResolvedModule {
1685 file_id,
1686 path,
1687 resolved_imports: vec![ResolvedImport {
1688 info: ImportInfo {
1689 source: "./source".to_string(),
1690 imported_name: ImportedName::Named("Foo".to_string()),
1691 local_name: "Foo".to_string(),
1692 is_type_only,
1693 is_type_only_star: false,
1694 from_style: false,
1695 span: oxc_span::Span::new(0, 10),
1696 source_span: oxc_span::Span::default(),
1697 },
1698 target: ResolveResult::InternalModule(FileId(1)),
1699 }],
1700 ..Default::default()
1701 };
1702 let resolved = vec![
1703 consumer(FileId(0), files[0].path.clone(), false),
1704 ResolvedModule {
1705 file_id: FileId(1),
1706 path: files[1].path.clone(),
1707 exports: source_exports.into(),
1708 ..Default::default()
1709 },
1710 consumer(FileId(2), files[2].path.clone(), true),
1711 ];
1712 let entry_points = vec![
1713 EntryPoint {
1714 path: files[0].path.clone(),
1715 source: EntryPointSource::PackageJsonMain,
1716 },
1717 EntryPoint {
1718 path: files[2].path.clone(),
1719 source: EntryPointSource::PackageJsonMain,
1720 },
1721 ];
1722 ModuleGraph::build(&resolved, &entry_points, &files)
1723 }
1724
1725 fn named_foo_export(is_type_only: bool) -> ExportInfo {
1726 ExportInfo {
1727 name: ExportName::Named("Foo".to_string()),
1728 local_name: Some("Foo".to_string()),
1729 is_type_only,
1730 visibility: VisibilityTag::None,
1731 expected_unused_reason: None,
1732 span: oxc_span::Span::new(0, 3),
1733 members: Vec::new(),
1734 is_side_effect_used: false,
1735 super_class: None,
1736 }
1737 }
1738
1739 #[test]
1740 fn trace_credits_a_value_only_export_through_the_type_lane() {
1741 let graph = source_consumer_graph(vec![named_foo_export(false)], true, false, Vec::new());
1745
1746 let trace = trace_export(&graph, Path::new("/project"), "src/source.ts", "Foo")
1747 .expect("value export exists");
1748
1749 assert_eq!(
1750 trace.namespace,
1751 fallow_types::semantic::SemanticNamespace::Type,
1752 "the type lane carries the only credit"
1753 );
1754 assert!(
1755 trace.is_used,
1756 "a type-only import credits a value-only export"
1757 );
1758 assert_eq!(trace.direct_references.len(), 1);
1759 assert_eq!(
1760 trace.direct_references[0].from_file,
1761 PathBuf::from("src/entry.ts")
1762 );
1763 assert_eq!(trace.direct_references[0].kind, "named import");
1764 assert_eq!(trace.reason, "Used by 1 file(s)");
1765 }
1766
1767 #[test]
1768 fn trace_omits_redundant_single_namespace_evidence() {
1769 let graph = source_consumer_graph(vec![named_foo_export(false)], false, false, Vec::new());
1770
1771 let trace = trace_export(&graph, Path::new("/project"), "src/source.ts", "Foo")
1772 .expect("value export exists");
1773
1774 assert_eq!(
1775 trace.namespace,
1776 fallow_types::semantic::SemanticNamespace::Value
1777 );
1778 assert_eq!(trace.direct_references.len(), 1);
1779 assert!(trace.direct_references_by_namespace.is_empty());
1780 let json = serde_json::to_value(&trace).expect("serialize trace");
1781 assert!(json.get("direct_references_by_namespace").is_none());
1782 }
1783
1784 #[test]
1785 fn trace_keeps_the_value_lane_when_one_binding_carries_both_lanes() {
1786 let graph = dual_lane_consumer_graph(vec![named_foo_export(false)]);
1792
1793 let trace = trace_export(&graph, Path::new("/project"), "src/source.ts", "Foo")
1794 .expect("value export exists");
1795
1796 assert_eq!(
1797 trace.namespace,
1798 fallow_types::semantic::SemanticNamespace::Value
1799 );
1800 assert!(trace.is_used);
1801 assert_eq!(trace.direct_references.len(), 1);
1802 assert_eq!(
1803 trace.direct_references[0].from_file,
1804 PathBuf::from("src/entry.ts"),
1805 "the value consumer stays the listed reference"
1806 );
1807 assert_eq!(trace.direct_references_by_namespace.len(), 2);
1808 assert!(trace.direct_references_by_namespace.iter().any(|lane| {
1809 lane.namespace == fallow_types::semantic::SemanticNamespace::Type
1810 && lane.reference_count == 1
1811 && lane.references[0].from_file == Path::new("src/typed.ts")
1812 }));
1813 }
1814
1815 #[test]
1816 fn trace_credits_a_declaration_merge_that_stays_one_binding() {
1817 let graph = source_consumer_graph(
1821 vec![named_foo_export(false), named_foo_export(false)],
1822 true,
1823 false,
1824 Vec::new(),
1825 );
1826
1827 let trace = trace_export(&graph, Path::new("/project"), "src/source.ts", "Foo")
1828 .expect("value export exists");
1829
1830 assert_eq!(
1831 trace.namespace,
1832 fallow_types::semantic::SemanticNamespace::Type,
1833 "the merged binding is reachable from the type lane"
1834 );
1835 assert!(trace.is_used);
1836 assert_eq!(trace.direct_references.len(), 1);
1837 assert_eq!(
1838 trace.direct_references[0].from_file,
1839 PathBuf::from("src/entry.ts")
1840 );
1841 }
1842
1843 #[test]
1844 fn trace_credits_a_class_interface_declaration_merge() {
1845 let mut interface = named_foo_export(true);
1846 interface.span = oxc_span::Span::new(0, 3);
1847 let mut class = named_foo_export(false);
1848 class.span = oxc_span::Span::new(4, 7);
1849 let graph = source_consumer_graph(
1850 vec![interface, class],
1851 true,
1852 true,
1853 vec![fallow_types::extract::SemanticFact::DeclarationMerge(
1854 fallow_types::extract::DeclarationMergeFact {
1855 export_spans: vec![(0, 3), (4, 7)],
1856 },
1857 )],
1858 );
1859
1860 let trace = trace_export(&graph, Path::new("/project"), "src/source.ts", "Foo")
1861 .expect("merged class export exists");
1862
1863 assert_eq!(
1864 trace.namespace,
1865 fallow_types::semantic::SemanticNamespace::Type
1866 );
1867 assert!(trace.is_used);
1868 assert_eq!(trace.direct_references.len(), 1);
1869 }
1870
1871 #[test]
1872 fn trace_keeps_the_value_namespace_when_lanes_hold_distinct_bindings() {
1873 let graph = source_consumer_graph(
1880 vec![named_foo_export(false), named_foo_export(true)],
1881 false,
1882 true,
1883 Vec::new(),
1884 );
1885
1886 let trace = trace_export(&graph, Path::new("/project"), "src/source.ts", "Foo")
1887 .expect("value export exists");
1888
1889 assert_eq!(
1890 trace.namespace,
1891 fallow_types::semantic::SemanticNamespace::Value
1892 );
1893 assert!(trace.is_used);
1894 assert_eq!(trace.direct_references.len(), 1);
1895 assert_eq!(
1896 trace.direct_references[0].from_file,
1897 PathBuf::from("src/entry.ts")
1898 );
1899 }
1900
1901 #[test]
1902 fn class_member_trace_inherits_the_type_lane_credit_of_its_owner() {
1903 use fallow_types::extract::{MemberInfo, MemberKind};
1904
1905 let mut owner = named_foo_export(false);
1909 owner.members = vec![MemberInfo {
1910 name: "run".to_string(),
1911 kind: MemberKind::ClassMethod,
1912 span: oxc_span::Span::new(0, 3),
1913 has_decorator: false,
1914 decorator_names: vec![],
1915 is_instance_returning_static: false,
1916 is_self_returning: false,
1917 }];
1918 let graph = source_consumer_graph(vec![owner], true, false, Vec::new());
1919
1920 let trace = trace_class_member(&graph, Path::new("/project"), "src/source.ts", "run")
1921 .expect("member of the traced export");
1922
1923 assert!(trace.owner_is_used, "the type lane credits the owner");
1924 assert_eq!(
1925 trace.owner_namespace,
1926 fallow_types::semantic::SemanticNamespace::Type,
1927 "the member payload names the lane that credits its owner"
1928 );
1929 assert_eq!(trace.owner_direct_references.len(), 1);
1930 assert_eq!(
1931 trace.owner_direct_references[0].from_file,
1932 PathBuf::from("src/entry.ts")
1933 );
1934 assert!(
1935 trace.reason.contains("'Foo' is used by 1 file(s)"),
1936 "the reason must follow the owner's credit: {}",
1937 trace.reason
1938 );
1939 }
1940
1941 #[test]
1942 fn trace_preserves_dual_namespace_named_re_exports() {
1943 let files: Vec<_> = ["entry", "barrel", "types", "values"]
1944 .into_iter()
1945 .enumerate()
1946 .map(|(index, name)| DiscoveredFile {
1947 id: FileId(index as u32),
1948 path: PathBuf::from(format!("/project/src/{name}.ts")),
1949 size_bytes: 10,
1950 })
1951 .collect();
1952 let export = |is_type_only| ExportInfo {
1953 name: ExportName::Named("Foo".to_string()),
1954 local_name: Some("Foo".to_string()),
1955 is_type_only,
1956 visibility: VisibilityTag::None,
1957 expected_unused_reason: None,
1958 span: oxc_span::Span::new(0, 3),
1959 members: Vec::new(),
1960 is_side_effect_used: false,
1961 super_class: None,
1962 };
1963 let re_export = |source: FileId, is_type_only| ResolvedReExport {
1964 info: ReExportInfo {
1965 source: format!("./{}", source.0),
1966 imported_name: "Foo".to_string(),
1967 exported_name: "Foo".to_string(),
1968 is_type_only,
1969 span: oxc_span::Span::default(),
1970 statement_span: oxc_span::Span::default(),
1971 source_span: oxc_span::Span::default(),
1972 },
1973 target: ResolveResult::InternalModule(source),
1974 };
1975 let mut resolved = vec![
1976 ResolvedModule {
1977 file_id: FileId(0),
1978 path: files[0].path.clone(),
1979 resolved_imports: vec![ResolvedImport {
1980 info: ImportInfo {
1981 source: "./barrel".to_string(),
1982 imported_name: ImportedName::Named("Foo".to_string()),
1983 local_name: "Foo".to_string(),
1984 is_type_only: false,
1985 is_type_only_star: false,
1986 from_style: false,
1987 span: oxc_span::Span::new(0, 10),
1988 source_span: oxc_span::Span::default(),
1989 },
1990 target: ResolveResult::InternalModule(FileId(1)),
1991 }],
1992 ..Default::default()
1993 },
1994 ResolvedModule {
1995 file_id: FileId(1),
1996 path: files[1].path.clone(),
1997 re_exports: vec![re_export(FileId(2), true), re_export(FileId(3), false)],
1998 ..Default::default()
1999 },
2000 ResolvedModule {
2001 file_id: FileId(2),
2002 path: files[2].path.clone(),
2003 exports: vec![export(true)].into(),
2004 ..Default::default()
2005 },
2006 ResolvedModule {
2007 file_id: FileId(3),
2008 path: files[3].path.clone(),
2009 exports: vec![export(false)].into(),
2010 ..Default::default()
2011 },
2012 ];
2013 let entry_points = vec![EntryPoint {
2014 path: files[0].path.clone(),
2015 source: EntryPointSource::PackageJsonMain,
2016 }];
2017 let graph = ModuleGraph::build(&resolved, &entry_points, &files);
2018 resolved[1].re_exports.reverse();
2019 let reversed_graph = ModuleGraph::build(&resolved, &entry_points, &files);
2020
2021 let trace = trace_export(&graph, Path::new("/project"), "src/barrel.ts", "Foo")
2022 .expect("barrel exposes Foo in both namespaces");
2023
2024 assert_eq!(
2025 trace.namespace,
2026 fallow_types::semantic::SemanticNamespace::Value
2027 );
2028 assert!(
2029 trace.is_used,
2030 "the value import must credit the value surface"
2031 );
2032 assert_eq!(trace.direct_references.len(), 1);
2033 let reversed_trace = trace_export(
2034 &reversed_graph,
2035 Path::new("/project"),
2036 "src/barrel.ts",
2037 "Foo",
2038 )
2039 .expect("reversed declarations expose the same surface");
2040 assert_eq!(
2041 serde_json::to_value(trace).expect("serialize trace"),
2042 serde_json::to_value(reversed_trace).expect("serialize reversed trace")
2043 );
2044 }
2045
2046 fn build_class_member_graph() -> ModuleGraph {
2047 use fallow_types::extract::{MemberInfo, MemberKind};
2048
2049 let files = vec![
2050 DiscoveredFile {
2051 id: FileId(0),
2052 path: PathBuf::from("/project/src/entry.ts"),
2053 size_bytes: 100,
2054 },
2055 DiscoveredFile {
2056 id: FileId(1),
2057 path: PathBuf::from("/project/src/controller.ts"),
2058 size_bytes: 50,
2059 },
2060 ];
2061 let entry_points = vec![EntryPoint {
2062 path: PathBuf::from("/project/src/entry.ts"),
2063 source: EntryPointSource::PackageJsonMain,
2064 }];
2065 let method = |name: &str| MemberInfo {
2066 name: name.to_string(),
2067 kind: MemberKind::ClassMethod,
2068 span: oxc_span::Span::new(0, 4),
2069 has_decorator: false,
2070 decorator_names: vec![],
2071 is_instance_returning_static: false,
2072 is_self_returning: false,
2073 };
2074 let resolved_modules = vec![
2075 ResolvedModule {
2076 file_id: FileId(0),
2077 path: PathBuf::from("/project/src/entry.ts"),
2078 resolved_imports: vec![ResolvedImport {
2079 info: ImportInfo {
2080 source: "./controller".to_string(),
2081 imported_name: ImportedName::Named("Ctrl".to_string()),
2082 local_name: "Ctrl".to_string(),
2083 is_type_only: false,
2084 is_type_only_star: false,
2085 from_style: false,
2086 span: oxc_span::Span::new(0, 10),
2087 source_span: oxc_span::Span::default(),
2088 },
2089 target: ResolveResult::InternalModule(FileId(1)),
2090 }],
2091 ..Default::default()
2092 },
2093 ResolvedModule {
2094 file_id: FileId(1),
2095 path: PathBuf::from("/project/src/controller.ts"),
2096 exports: vec![ExportInfo {
2097 name: ExportName::Named("Ctrl".to_string()),
2098 local_name: Some("Ctrl".to_string()),
2099 is_type_only: false,
2100 visibility: VisibilityTag::None,
2101 expected_unused_reason: None,
2102 span: oxc_span::Span::new(0, 20),
2103 members: vec![method("used"), method("dead")],
2104 is_side_effect_used: false,
2105 super_class: None,
2106 }]
2107 .into(),
2108 ..Default::default()
2109 },
2110 ];
2111 ModuleGraph::build(&resolved_modules, &entry_points, &files)
2112 }
2113
2114 #[test]
2115 fn trace_class_member_reports_owner_class() {
2116 let graph = build_class_member_graph();
2119 let root = Path::new("/project");
2120
2121 let trace = trace_class_member(&graph, root, "src/controller.ts", "dead").unwrap();
2122 assert_eq!(trace.owner_export, "Ctrl");
2123 assert_eq!(trace.member_name, "dead");
2124 assert_eq!(trace.member_kind, "class-method");
2125 assert!(trace.owner_is_used);
2126 assert!(trace.owner_file_reachable);
2127 assert_eq!(trace.owner_direct_references.len(), 1);
2128 assert!(
2129 trace.reason.contains("--unused-class-members"),
2130 "reason should point at the member command: {}",
2131 trace.reason
2132 );
2133 }
2134
2135 #[test]
2136 fn trace_class_member_absent_name_is_none() {
2137 let graph = build_class_member_graph();
2140 let root = Path::new("/project");
2141 assert!(trace_class_member(&graph, root, "src/controller.ts", "nope").is_none());
2142 }
2143
2144 #[test]
2145 fn exact_class_method_resolution_rejects_overloads_without_guessing() {
2146 use fallow_types::extract::{MemberInfo, MemberKind};
2147
2148 let temp = tempfile::tempdir().unwrap();
2149 let root = temp.path();
2150 let path = root.join("repository.ts");
2151 let source =
2152 "export class Repository {\n save(): void;\n save(): void {}\n run(): void {}\n}\n";
2153 std::fs::write(&path, source).unwrap();
2154 let first = source.find("save").unwrap() as u32;
2155 let second = source.rfind("save").unwrap() as u32;
2156 let run = source.find("run").unwrap() as u32;
2157 let member = |name: &str, start| MemberInfo {
2158 name: name.to_string(),
2159 kind: MemberKind::ClassMethod,
2160 span: oxc_span::Span::new(start, start + 4),
2161 has_decorator: false,
2162 decorator_names: vec![],
2163 is_instance_returning_static: false,
2164 is_self_returning: false,
2165 };
2166 let files = vec![DiscoveredFile {
2167 id: FileId(0),
2168 path: path.clone(),
2169 size_bytes: source.len() as u64,
2170 }];
2171 let resolved_modules = vec![ResolvedModule {
2172 file_id: FileId(0),
2173 path,
2174 exports: vec![ExportInfo {
2175 name: ExportName::Named("Repository".to_string()),
2176 local_name: Some("Repository".to_string()),
2177 is_type_only: false,
2178 visibility: VisibilityTag::None,
2179 expected_unused_reason: None,
2180 span: oxc_span::Span::new(0, source.len() as u32),
2181 members: vec![
2182 member("save", first),
2183 member("save", second),
2184 member("run", run),
2185 ],
2186 is_side_effect_used: false,
2187 super_class: None,
2188 }]
2189 .into(),
2190 ..Default::default()
2191 }];
2192 let graph = ModuleGraph::build(&resolved_modules, &[], &files);
2193
2194 assert_eq!(
2195 semantic_symbol_for_exact_class_method(
2196 &graph,
2197 root,
2198 "repository.ts",
2199 "Repository",
2200 "save",
2201 ),
2202 Err(SemanticClassMethodResolutionError::AmbiguousSymbol)
2203 );
2204 assert_eq!(
2205 semantic_symbol_for_exact_class_method(
2206 &graph,
2207 root,
2208 "repository.ts",
2209 "OtherRepository",
2210 "save",
2211 ),
2212 Err(SemanticClassMethodResolutionError::SymbolNotFound)
2213 );
2214 let resolved = semantic_symbol_for_exact_class_method(
2215 &graph,
2216 root,
2217 "repository.ts",
2218 "Repository",
2219 "run",
2220 )
2221 .unwrap();
2222 assert_eq!(resolved.owner.as_deref(), Some("Repository"));
2223 assert_eq!(resolved.local_name, "run");
2224 }
2225
2226 fn build_unreachable_class_member_graph() -> ModuleGraph {
2229 use fallow_types::extract::{MemberInfo, MemberKind};
2230
2231 let files = vec![
2232 DiscoveredFile {
2233 id: FileId(0),
2234 path: PathBuf::from("/project/src/entry.ts"),
2235 size_bytes: 100,
2236 },
2237 DiscoveredFile {
2238 id: FileId(1),
2239 path: PathBuf::from("/project/src/controller.ts"),
2240 size_bytes: 50,
2241 },
2242 ];
2243 let entry_points = vec![EntryPoint {
2244 path: PathBuf::from("/project/src/entry.ts"),
2245 source: EntryPointSource::PackageJsonMain,
2246 }];
2247 let method = |name: &str| MemberInfo {
2248 name: name.to_string(),
2249 kind: MemberKind::ClassMethod,
2250 span: oxc_span::Span::new(0, 4),
2251 has_decorator: false,
2252 decorator_names: vec![],
2253 is_instance_returning_static: false,
2254 is_self_returning: false,
2255 };
2256 let resolved_modules = vec![
2257 ResolvedModule {
2258 file_id: FileId(0),
2259 path: PathBuf::from("/project/src/entry.ts"),
2260 ..Default::default()
2262 },
2263 ResolvedModule {
2264 file_id: FileId(1),
2265 path: PathBuf::from("/project/src/controller.ts"),
2266 exports: vec![ExportInfo {
2267 name: ExportName::Named("Ctrl".to_string()),
2268 local_name: Some("Ctrl".to_string()),
2269 is_type_only: false,
2270 visibility: VisibilityTag::None,
2271 expected_unused_reason: None,
2272 span: oxc_span::Span::new(0, 20),
2273 members: vec![method("dead")],
2274 is_side_effect_used: false,
2275 super_class: None,
2276 }]
2277 .into(),
2278 ..Default::default()
2279 },
2280 ];
2281 ModuleGraph::build(&resolved_modules, &entry_points, &files)
2282 }
2283
2284 #[test]
2285 fn trace_class_member_unreachable_owner_reports_dead_reason() {
2286 let graph = build_unreachable_class_member_graph();
2289 let root = Path::new("/project");
2290
2291 let trace = trace_class_member(&graph, root, "src/controller.ts", "dead").unwrap();
2292 assert!(!trace.owner_file_reachable);
2293 assert!(
2294 trace.reason.contains("not reachable"),
2295 "unreachable owner reason should say so: {}",
2296 trace.reason
2297 );
2298 assert!(!trace.reason.contains("--unused-class-members"));
2301 }
2302
2303 #[test]
2304 fn trace_class_member_prefers_used_owner_on_name_collision() {
2305 use fallow_types::extract::{MemberInfo, MemberKind};
2309
2310 let files = vec![
2311 DiscoveredFile {
2312 id: FileId(0),
2313 path: PathBuf::from("/project/src/entry.ts"),
2314 size_bytes: 100,
2315 },
2316 DiscoveredFile {
2317 id: FileId(1),
2318 path: PathBuf::from("/project/src/controller.ts"),
2319 size_bytes: 50,
2320 },
2321 ];
2322 let entry_points = vec![EntryPoint {
2323 path: PathBuf::from("/project/src/entry.ts"),
2324 source: EntryPointSource::PackageJsonMain,
2325 }];
2326 let method = |name: &str| MemberInfo {
2327 name: name.to_string(),
2328 kind: MemberKind::ClassMethod,
2329 span: oxc_span::Span::new(0, 4),
2330 has_decorator: false,
2331 decorator_names: vec![],
2332 is_instance_returning_static: false,
2333 is_self_returning: false,
2334 };
2335 let resolved_modules = vec![
2336 ResolvedModule {
2337 file_id: FileId(0),
2338 path: PathBuf::from("/project/src/entry.ts"),
2339 resolved_imports: vec![ResolvedImport {
2340 info: ImportInfo {
2341 source: "./controller".to_string(),
2342 imported_name: ImportedName::Named("UsedCtrl".to_string()),
2343 local_name: "UsedCtrl".to_string(),
2344 is_type_only: false,
2345 is_type_only_star: false,
2346 from_style: false,
2347 span: oxc_span::Span::new(0, 10),
2348 source_span: oxc_span::Span::default(),
2349 },
2350 target: ResolveResult::InternalModule(FileId(1)),
2351 }],
2352 ..Default::default()
2353 },
2354 ResolvedModule {
2355 file_id: FileId(1),
2356 path: PathBuf::from("/project/src/controller.ts"),
2357 exports: vec![
2358 ExportInfo {
2361 name: ExportName::Named("TypeCtrl".to_string()),
2362 local_name: Some("TypeCtrl".to_string()),
2363 is_type_only: true,
2364 visibility: VisibilityTag::None,
2365 expected_unused_reason: None,
2366 span: oxc_span::Span::new(0, 20),
2367 members: vec![method("shared")],
2368 is_side_effect_used: false,
2369 super_class: None,
2370 },
2371 ExportInfo {
2372 name: ExportName::Named("UsedCtrl".to_string()),
2373 local_name: Some("UsedCtrl".to_string()),
2374 is_type_only: false,
2375 visibility: VisibilityTag::None,
2376 expected_unused_reason: None,
2377 span: oxc_span::Span::new(0, 20),
2378 members: vec![method("shared")],
2379 is_side_effect_used: false,
2380 super_class: None,
2381 },
2382 ]
2383 .into(),
2384 ..Default::default()
2385 },
2386 ];
2387 let graph = ModuleGraph::build(&resolved_modules, &entry_points, &files);
2388 let root = Path::new("/project");
2389
2390 let trace = trace_class_member(&graph, root, "src/controller.ts", "shared").unwrap();
2391 assert_eq!(
2392 trace.owner_export, "UsedCtrl",
2393 "tie-break must prefer the used, non-type-only owner"
2394 );
2395 assert!(trace.owner_is_used);
2396 }
2397
2398 #[test]
2399 fn trace_nonexistent_file() {
2400 let graph = build_test_graph();
2401 let root = Path::new("/project");
2402
2403 let trace = trace_export(&graph, root, "src/nope.ts", "foo");
2404 assert!(trace.is_none());
2405 }
2406
2407 #[test]
2408 fn trace_file_edges() {
2409 let graph = build_test_graph();
2410 let root = Path::new("/project");
2411
2412 let trace = trace_file(&graph, root, "src/entry.ts").unwrap();
2413 assert!(trace.is_entry_point);
2414 assert!(trace.is_reachable);
2415 assert_eq!(trace.imports_from.len(), 1);
2416 assert_eq!(trace.imports_from[0], PathBuf::from("src/utils.ts"));
2417 assert!(trace.imported_by.is_empty());
2418 }
2419
2420 #[test]
2421 fn trace_file_imported_by() {
2422 let graph = build_test_graph();
2423 let root = Path::new("/project");
2424
2425 let trace = trace_file(&graph, root, "src/utils.ts").unwrap();
2426 assert!(!trace.is_entry_point);
2427 assert!(trace.is_reachable);
2428 assert_eq!(trace.exports.len(), 2);
2429 assert_eq!(trace.imported_by.len(), 1);
2430 assert_eq!(trace.imported_by[0], PathBuf::from("src/entry.ts"));
2431 }
2432
2433 #[test]
2434 fn trace_unreachable_file() {
2435 let graph = build_test_graph();
2436 let root = Path::new("/project");
2437
2438 let trace = trace_file(&graph, root, "src/unused.ts").unwrap();
2439 assert!(!trace.is_reachable);
2440 assert!(!trace.is_entry_point);
2441 assert!(trace.imported_by.is_empty());
2442 }
2443
2444 #[test]
2445 fn trace_dependency_used() {
2446 let files = vec![DiscoveredFile {
2447 id: FileId(0),
2448 path: PathBuf::from("/project/src/app.ts"),
2449 size_bytes: 100,
2450 }];
2451 let entry_points = vec![EntryPoint {
2452 path: PathBuf::from("/project/src/app.ts"),
2453 source: EntryPointSource::PackageJsonMain,
2454 }];
2455 let resolved_modules = vec![ResolvedModule {
2456 file_id: FileId(0),
2457 path: PathBuf::from("/project/src/app.ts"),
2458 resolved_imports: vec![ResolvedImport {
2459 info: ImportInfo {
2460 source: "lodash".to_string(),
2461 imported_name: ImportedName::Named("get".to_string()),
2462 local_name: "get".to_string(),
2463 is_type_only: false,
2464 is_type_only_star: false,
2465 from_style: false,
2466 span: oxc_span::Span::new(0, 10),
2467 source_span: oxc_span::Span::default(),
2468 },
2469 target: ResolveResult::NpmPackage("lodash".to_string()),
2470 }],
2471 ..Default::default()
2472 }];
2473
2474 let graph = ModuleGraph::build(&resolved_modules, &entry_points, &files);
2475 let root = Path::new("/project");
2476
2477 let trace = trace_dependency(&graph, root, "lodash", &FxHashSet::default());
2478 assert!(trace.is_used);
2479 assert!(!trace.used_in_scripts);
2480 assert_eq!(trace.import_count, 1);
2481 assert_eq!(trace.imported_by[0], PathBuf::from("src/app.ts"));
2482 }
2483
2484 #[test]
2485 fn trace_dependency_unused() {
2486 let files = vec![DiscoveredFile {
2487 id: FileId(0),
2488 path: PathBuf::from("/project/src/app.ts"),
2489 size_bytes: 100,
2490 }];
2491 let entry_points = vec![EntryPoint {
2492 path: PathBuf::from("/project/src/app.ts"),
2493 source: EntryPointSource::PackageJsonMain,
2494 }];
2495 let resolved_modules = vec![ResolvedModule {
2496 file_id: FileId(0),
2497 path: PathBuf::from("/project/src/app.ts"),
2498 ..Default::default()
2499 }];
2500
2501 let graph = ModuleGraph::build(&resolved_modules, &entry_points, &files);
2502 let root = Path::new("/project");
2503
2504 let trace = trace_dependency(&graph, root, "nonexistent-pkg", &FxHashSet::default());
2505 assert!(!trace.is_used);
2506 assert!(!trace.used_in_scripts);
2507 assert_eq!(trace.import_count, 0);
2508 assert!(trace.imported_by.is_empty());
2509 }
2510
2511 #[test]
2512 fn trace_dependency_used_only_in_scripts() {
2513 let files = vec![DiscoveredFile {
2514 id: FileId(0),
2515 path: PathBuf::from("/project/src/app.ts"),
2516 size_bytes: 100,
2517 }];
2518 let entry_points = vec![EntryPoint {
2519 path: PathBuf::from("/project/src/app.ts"),
2520 source: EntryPointSource::PackageJsonMain,
2521 }];
2522 let resolved_modules = vec![ResolvedModule {
2523 file_id: FileId(0),
2524 path: PathBuf::from("/project/src/app.ts"),
2525 ..Default::default()
2526 }];
2527
2528 let graph = ModuleGraph::build(&resolved_modules, &entry_points, &files);
2529 let root = Path::new("/project");
2530 let mut script_used = FxHashSet::default();
2531 script_used.insert("microbundle".to_string());
2532
2533 let trace = trace_dependency(&graph, root, "microbundle", &script_used);
2534 assert!(
2535 trace.is_used,
2536 "is_used must be true when the package is referenced from package.json scripts"
2537 );
2538 assert!(trace.used_in_scripts);
2539 assert_eq!(trace.import_count, 0);
2540 assert!(trace.imported_by.is_empty());
2541 }
2542
2543 #[test]
2544 fn trace_clone_finds_matching_group() {
2545 use crate::duplicates::{CloneGroup, CloneInstance, DuplicationReport, DuplicationStats};
2546 let report = DuplicationReport {
2547 clone_groups: vec![CloneGroup {
2548 instances: vec![
2549 CloneInstance {
2550 file: PathBuf::from("/project/src/a.ts"),
2551 start_line: 10,
2552 end_line: 20,
2553 start_col: 0,
2554 end_col: 0,
2555 fragment: "fn foo() {}".to_string(),
2556 },
2557 CloneInstance {
2558 file: PathBuf::from("/project/src/b.ts"),
2559 start_line: 5,
2560 end_line: 15,
2561 start_col: 0,
2562 end_col: 0,
2563 fragment: "fn foo() {}".to_string(),
2564 },
2565 ],
2566 token_count: 60,
2567 line_count: 11,
2568 similarity: None,
2569 }],
2570 clone_families: vec![],
2571 mirrored_directories: vec![],
2572 stats: DuplicationStats {
2573 total_files: 2,
2574 files_with_clones: 2,
2575 total_lines: 100,
2576 duplicated_lines: 22,
2577 total_tokens: 200,
2578 duplicated_tokens: 120,
2579 clone_groups: 1,
2580 clone_instances: 2,
2581 duplication_percentage: 22.0,
2582 clone_groups_below_min_occurrences: 0,
2583 clone_groups_ignored: 0,
2584 near_candidates_skipped: 0,
2585 },
2586 };
2587 let trace = trace_clone(&report, Path::new("/project"), "src/a.ts", 15);
2588 assert!(trace.matched_instance.is_some());
2589 assert_eq!(trace.clone_groups.len(), 1);
2590 assert_eq!(trace.clone_groups[0].instances.len(), 2);
2591 assert!(trace.clone_groups[0].fingerprint.starts_with("dup:"));
2592 assert_eq!(trace.clone_groups[0].suggestion.estimated_savings, 11);
2593 }
2594
2595 #[test]
2596 fn trace_clone_by_fingerprint_resolves_and_misses() {
2597 use crate::duplicates::{
2598 CloneGroup, CloneInstance, DuplicationReport, DuplicationStats, clone_fingerprint,
2599 };
2600 let report = DuplicationReport {
2601 clone_groups: vec![CloneGroup {
2602 instances: vec![
2603 CloneInstance {
2604 file: PathBuf::from("/project/src/a.ts"),
2605 start_line: 10,
2606 end_line: 20,
2607 start_col: 0,
2608 end_col: 0,
2609 fragment: "fn buildInvoice() {}".to_string(),
2610 },
2611 CloneInstance {
2612 file: PathBuf::from("/project/src/b.ts"),
2613 start_line: 5,
2614 end_line: 15,
2615 start_col: 0,
2616 end_col: 0,
2617 fragment: "fn buildInvoice() {}".to_string(),
2618 },
2619 ],
2620 token_count: 60,
2621 line_count: 11,
2622 similarity: None,
2623 }],
2624 clone_families: vec![],
2625 mirrored_directories: vec![],
2626 stats: DuplicationStats::default(),
2627 };
2628 let fp = clone_fingerprint(&report.clone_groups[0].instances);
2629
2630 let hit = trace_clone_by_fingerprint(&report, Path::new("/project"), &fp);
2631 assert!(hit.matched_instance.is_some());
2632 assert_eq!(hit.clone_groups.len(), 1);
2633 assert_eq!(hit.clone_groups[0].fingerprint, fp);
2634 assert_eq!(hit.line, 10);
2635
2636 let miss = trace_clone_by_fingerprint(&report, Path::new("/project"), "dup:deadbeef");
2637 assert!(miss.matched_instance.is_none());
2638 assert!(miss.clone_groups.is_empty());
2639 }
2640
2641 #[test]
2642 fn trace_clone_no_match() {
2643 use crate::duplicates::{CloneGroup, CloneInstance, DuplicationReport, DuplicationStats};
2644 let report = DuplicationReport {
2645 clone_groups: vec![CloneGroup {
2646 instances: vec![CloneInstance {
2647 file: PathBuf::from("/project/src/a.ts"),
2648 start_line: 10,
2649 end_line: 20,
2650 start_col: 0,
2651 end_col: 0,
2652 fragment: "fn foo() {}".to_string(),
2653 }],
2654 token_count: 60,
2655 line_count: 11,
2656 similarity: None,
2657 }],
2658 clone_families: vec![],
2659 mirrored_directories: vec![],
2660 stats: DuplicationStats {
2661 total_files: 1,
2662 files_with_clones: 1,
2663 total_lines: 50,
2664 duplicated_lines: 11,
2665 total_tokens: 100,
2666 duplicated_tokens: 60,
2667 clone_groups: 1,
2668 clone_instances: 1,
2669 duplication_percentage: 22.0,
2670 clone_groups_below_min_occurrences: 0,
2671 clone_groups_ignored: 0,
2672 near_candidates_skipped: 0,
2673 },
2674 };
2675 let trace = trace_clone(&report, Path::new("/project"), "src/a.ts", 25);
2676 assert!(trace.matched_instance.is_none());
2677 assert!(trace.clone_groups.is_empty());
2678 }
2679
2680 #[test]
2681 fn trace_clone_line_boundary() {
2682 use crate::duplicates::{CloneGroup, CloneInstance, DuplicationReport, DuplicationStats};
2683 let report = DuplicationReport {
2684 clone_groups: vec![CloneGroup {
2685 instances: vec![
2686 CloneInstance {
2687 file: PathBuf::from("/project/src/a.ts"),
2688 start_line: 10,
2689 end_line: 20,
2690 start_col: 0,
2691 end_col: 0,
2692 fragment: "code".to_string(),
2693 },
2694 CloneInstance {
2695 file: PathBuf::from("/project/src/b.ts"),
2696 start_line: 1,
2697 end_line: 11,
2698 start_col: 0,
2699 end_col: 0,
2700 fragment: "code".to_string(),
2701 },
2702 ],
2703 token_count: 50,
2704 line_count: 11,
2705 similarity: None,
2706 }],
2707 clone_families: vec![],
2708 mirrored_directories: vec![],
2709 stats: DuplicationStats {
2710 total_files: 2,
2711 files_with_clones: 2,
2712 total_lines: 100,
2713 duplicated_lines: 22,
2714 total_tokens: 200,
2715 duplicated_tokens: 100,
2716 clone_groups: 1,
2717 clone_instances: 2,
2718 duplication_percentage: 22.0,
2719 clone_groups_below_min_occurrences: 0,
2720 clone_groups_ignored: 0,
2721 near_candidates_skipped: 0,
2722 },
2723 };
2724 let root = Path::new("/project");
2725 assert!(
2726 trace_clone(&report, root, "src/a.ts", 10)
2727 .matched_instance
2728 .is_some()
2729 );
2730 assert!(
2731 trace_clone(&report, root, "src/a.ts", 20)
2732 .matched_instance
2733 .is_some()
2734 );
2735 assert!(
2736 trace_clone(&report, root, "src/a.ts", 21)
2737 .matched_instance
2738 .is_none()
2739 );
2740 }
2741
2742 #[test]
2743 fn trace_clone_returns_relative_instance_paths() {
2744 use crate::duplicates::{CloneGroup, CloneInstance, DuplicationReport, DuplicationStats};
2745 let report = DuplicationReport {
2746 clone_groups: vec![CloneGroup {
2747 instances: vec![
2748 CloneInstance {
2749 file: PathBuf::from("/project/src/a.ts"),
2750 start_line: 1,
2751 end_line: 10,
2752 start_col: 0,
2753 end_col: 0,
2754 fragment: "code".to_string(),
2755 },
2756 CloneInstance {
2757 file: PathBuf::from("/project/src/b.ts"),
2758 start_line: 1,
2759 end_line: 10,
2760 start_col: 0,
2761 end_col: 0,
2762 fragment: "code".to_string(),
2763 },
2764 ],
2765 token_count: 50,
2766 line_count: 10,
2767 similarity: None,
2768 }],
2769 clone_families: vec![],
2770 mirrored_directories: vec![],
2771 stats: DuplicationStats {
2772 total_files: 2,
2773 files_with_clones: 2,
2774 total_lines: 50,
2775 duplicated_lines: 20,
2776 total_tokens: 100,
2777 duplicated_tokens: 100,
2778 clone_groups: 1,
2779 clone_instances: 2,
2780 duplication_percentage: 40.0,
2781 clone_groups_below_min_occurrences: 0,
2782 clone_groups_ignored: 0,
2783 near_candidates_skipped: 0,
2784 },
2785 };
2786 let trace = trace_clone(&report, Path::new("/project"), "src/a.ts", 5);
2787 let matched = trace.matched_instance.as_ref().expect("match expected");
2788 assert_eq!(matched.file, PathBuf::from("src/a.ts"));
2789 for group in &trace.clone_groups {
2790 for inst in &group.instances {
2791 let as_str = inst.file.to_string_lossy();
2792 assert!(
2793 !as_str.starts_with('/'),
2794 "instance file should be relative, got {as_str}",
2795 );
2796 assert!(
2797 !as_str.contains(":\\") && !as_str.contains(":/"),
2798 "instance file should not have a drive letter, got {as_str}",
2799 );
2800 }
2801 }
2802
2803 let json = serde_json::to_string(&trace).expect("serializes");
2804 assert!(
2805 !json.contains("\"/project/"),
2806 "serialized trace should not leak absolute paths: {json}",
2807 );
2808 }
2809
2810 #[test]
2817 fn path_matches_normalises_windows_module_path_against_posix_user_path() {
2818 let root = Path::new(r"D:\a\fallow\fallow\tests\fixtures\basic-project");
2819 let module_path =
2820 PathBuf::from(r"D:\a\fallow\fallow\tests\fixtures\basic-project\src\utils.ts");
2821 assert!(path_matches(&module_path, root, "src/utils.ts"));
2822 assert!(path_matches(&module_path, root, r"src\utils.ts"));
2823 }
2824
2825 #[test]
2826 fn path_matches_ends_with_fallback_handles_mixed_separators() {
2827 let root = Path::new("/some/other/root");
2828 let module_path =
2829 PathBuf::from(r"D:\a\fallow\fallow\tests\fixtures\basic-project\src\utils.ts");
2830 assert!(path_matches(&module_path, root, "src/utils.ts"));
2831 }
2832
2833 #[test]
2842 fn export_trace_serializes_windows_path_with_forward_slashes() {
2843 let trace = ExportTrace {
2844 file: PathBuf::from(r"src\utils.ts"),
2845 export_name: "foo".to_string(),
2846 namespace: fallow_types::semantic::SemanticNamespace::Value,
2847 file_reachable: true,
2848 is_entry_point: false,
2849 is_used: true,
2850 direct_references: vec![ExportReference {
2851 from_file: PathBuf::from(r"src\entry.ts"),
2852 kind: "named import".to_string(),
2853 }],
2854 direct_references_by_namespace: Vec::new(),
2855 star_export_ambiguity: None,
2856 re_export_chains: vec![ReExportChain {
2857 barrel_file: PathBuf::from(r"src\index.ts"),
2858 exported_as: "foo".to_string(),
2859 reference_count: 1,
2860 }],
2861 reason: "ok".to_string(),
2862 semantic: None,
2863 };
2864 let json = serde_json::to_string(&trace).expect("serializes");
2865 assert!(
2866 json.contains("\"file\":\"src/utils.ts\""),
2867 "ExportTrace.file must serialize with forward slashes: {json}"
2868 );
2869 assert!(
2870 json.contains("\"from_file\":\"src/entry.ts\""),
2871 "ExportReference.from_file must serialize with forward slashes: {json}"
2872 );
2873 assert!(
2874 json.contains("\"barrel_file\":\"src/index.ts\""),
2875 "ReExportChain.barrel_file must serialize with forward slashes: {json}"
2876 );
2877 assert!(
2878 !json.contains(r"\\"),
2879 "no backslash sequence should remain anywhere in the JSON: {json}"
2880 );
2881 }
2882
2883 #[test]
2884 fn file_trace_serializes_windows_paths_with_forward_slashes() {
2885 let trace = FileTrace {
2886 file: PathBuf::from(r"src\utils.ts"),
2887 is_reachable: true,
2888 is_entry_point: false,
2889 exports: vec![],
2890 imports_from: vec![PathBuf::from(r"src\helpers.ts")],
2891 imported_by: vec![PathBuf::from(r"src\entry.ts")],
2892 re_exports: vec![TracedReExport {
2893 source_file: PathBuf::from(r"src\source.ts"),
2894 imported_name: "foo".to_string(),
2895 exported_name: "foo".to_string(),
2896 }],
2897 };
2898 let json = serde_json::to_string(&trace).expect("serializes");
2899 assert!(json.contains("\"file\":\"src/utils.ts\""), "got {json}");
2900 assert!(
2901 json.contains("\"imports_from\":[\"src/helpers.ts\"]"),
2902 "got {json}"
2903 );
2904 assert!(
2905 json.contains("\"imported_by\":[\"src/entry.ts\"]"),
2906 "got {json}"
2907 );
2908 assert!(
2909 json.contains("\"source_file\":\"src/source.ts\""),
2910 "got {json}"
2911 );
2912 assert!(!json.contains(r"\\"), "no backslash should remain: {json}");
2913 }
2914}