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