1use std::path::{Path, PathBuf};
2
3pub use fallow_types::trace::{
4 ClassMemberTrace, CloneTrace, DependencyTrace, ExportReference, ExportTrace, FileTrace,
5 ImpactClosureGap, ImpactClosureTrace, PipelineTimings, ReExportChain, TracedCloneGroup,
6 TracedExport, TracedReExport,
7};
8use rustc_hash::FxHashSet;
9
10use crate::duplicates::{
11 CloneFingerprintSet, CloneGroup, CloneInstance, DuplicationReport, dominant_identifier,
12 group_refactoring_suggestion,
13};
14use crate::graph::{ModuleGraph, ReferenceKind};
15
16fn path_matches(module_path: &Path, root: &Path, user_path: &str) -> bool {
21 let user_path_norm = user_path.replace('\\', "/");
22 let rel = module_path.strip_prefix(root).unwrap_or(module_path);
23 let rel_str = rel.to_string_lossy().replace('\\', "/");
24 let module_str = module_path.to_string_lossy().replace('\\', "/");
25 if rel_str == user_path_norm || module_str == user_path_norm {
26 return true;
27 }
28 if dunce::canonicalize(root).is_ok_and(|canonical_root| {
29 module_path
30 .strip_prefix(&canonical_root)
31 .is_ok_and(|rel| rel.to_string_lossy().replace('\\', "/") == user_path_norm)
32 }) {
33 return true;
34 }
35 module_str.ends_with(&format!("/{user_path_norm}"))
36}
37
38fn reference_to_export_reference(
40 graph: &ModuleGraph,
41 root: &Path,
42 r: &crate::graph::SymbolReference,
43) -> ExportReference {
44 let from_path = graph.modules.get(r.from_file.0 as usize).map_or_else(
45 || PathBuf::from(format!("<unknown:{}>", r.from_file.0)),
46 |m| m.path.strip_prefix(root).unwrap_or(&m.path).to_path_buf(),
47 );
48 ExportReference {
49 from_file: from_path,
50 kind: format_reference_kind(r.kind),
51 }
52}
53
54fn collect_re_export_chains(
57 graph: &ModuleGraph,
58 root: &Path,
59 target_file_id: crate::discover::FileId,
60 export_name: &str,
61) -> Vec<ReExportChain> {
62 graph
63 .modules
64 .iter()
65 .flat_map(|m| {
66 m.re_exports
67 .iter()
68 .filter(move |re| {
69 re.source_file == target_file_id
70 && (re.imported_name == export_name || re.imported_name == "*")
71 })
72 .map(move |re| {
73 let barrel_export = m.exports.iter().find(|e| {
74 if re.exported_name == "*" {
75 e.name.to_string() == export_name
76 } else {
77 e.name.to_string() == re.exported_name
78 }
79 });
80 ReExportChain {
81 barrel_file: m.path.strip_prefix(root).unwrap_or(&m.path).to_path_buf(),
82 exported_as: re.exported_name.clone(),
83 reference_count: barrel_export.map_or(0, |e| e.references.len()),
84 }
85 })
86 })
87 .collect()
88}
89
90fn export_trace_reason(
92 module: &crate::graph::ModuleNode,
93 reference_count: usize,
94 is_used: bool,
95 re_export_chains: &[ReExportChain],
96) -> String {
97 if !module.is_reachable() {
98 "File is unreachable from any entry point".to_string()
99 } else if is_used {
100 format!(
101 "Used by {} file(s){}",
102 reference_count,
103 if re_export_chains.is_empty() {
104 String::new()
105 } else {
106 format!(", re-exported through {} barrel(s)", re_export_chains.len())
107 }
108 )
109 } else if module.is_entry_point() {
110 "No internal references, but file is an entry point (export is externally accessible)"
111 .to_string()
112 } else if !re_export_chains.is_empty() {
113 format!(
114 "Re-exported through {} barrel(s) but no consumer imports it through the barrel",
115 re_export_chains.len()
116 )
117 } else {
118 "No references found, export is unused".to_string()
119 }
120}
121
122#[must_use]
124pub fn trace_export(
125 graph: &ModuleGraph,
126 root: &Path,
127 file_path: &str,
128 export_name: &str,
129) -> Option<ExportTrace> {
130 let module = graph
131 .modules
132 .iter()
133 .find(|m| path_matches(&m.path, root, file_path))?;
134
135 let export = module
136 .exports
137 .iter()
138 .filter(|e| export_name_matches(e, export_name))
139 .max_by_key(|e| (!e.references.is_empty(), !e.is_type_only))?;
140
141 let direct_references: Vec<ExportReference> = export
142 .references
143 .iter()
144 .map(|r| reference_to_export_reference(graph, root, r))
145 .collect();
146
147 let re_export_chains = collect_re_export_chains(graph, root, module.file_id, export_name);
148
149 let is_used = !export.references.is_empty();
150 let reason = export_trace_reason(module, export.references.len(), is_used, &re_export_chains);
151
152 Some(ExportTrace {
153 file: module
154 .path
155 .strip_prefix(root)
156 .unwrap_or(&module.path)
157 .to_path_buf(),
158 export_name: export_name.to_string(),
159 file_reachable: module.is_reachable(),
160 is_entry_point: module.is_entry_point(),
161 is_used,
162 direct_references,
163 re_export_chains,
164 reason,
165 semantic: None,
166 })
167}
168
169#[must_use]
172pub fn semantic_symbol_for_export(
173 graph: &ModuleGraph,
174 root: &Path,
175 file_path: &str,
176 export_name: &str,
177) -> Option<fallow_types::semantic::SemanticSymbol> {
178 use fallow_types::semantic::{SemanticNamespace, SemanticSymbol};
179
180 let module = graph
181 .modules
182 .iter()
183 .find(|module| path_matches(&module.path, root, file_path))?;
184 let export = module
185 .exports
186 .iter()
187 .filter(|export| export_name_matches(export, export_name))
188 .max_by_key(|export| (!export.references.is_empty(), !export.is_type_only))?;
189 let source = std::fs::read_to_string(&module.path).ok()?;
190 let offsets = fallow_types::extract::compute_line_offsets(&source);
191 let (line, col) = fallow_types::extract::byte_offset_to_line_col(&offsets, export.span.start);
192 Some(SemanticSymbol {
193 path: module
194 .path
195 .strip_prefix(root)
196 .unwrap_or(&module.path)
197 .to_path_buf(),
198 namespace: if export.is_type_only {
199 SemanticNamespace::Type
200 } else {
201 SemanticNamespace::Value
202 },
203 declaration_kind: "export".to_string(),
204 exported_name: export_name.to_string(),
205 local_name: export_name.to_string(),
206 owner: None,
207 line,
208 col,
209 })
210}
211
212#[must_use]
214pub fn semantic_symbol_for_class_member(
215 graph: &ModuleGraph,
216 root: &Path,
217 file_path: &str,
218 member_name: &str,
219) -> Option<fallow_types::semantic::SemanticSymbol> {
220 use fallow_types::extract::MemberKind;
221 use fallow_types::semantic::{SemanticNamespace, SemanticSymbol};
222
223 let module = graph
224 .modules
225 .iter()
226 .find(|module| path_matches(&module.path, root, file_path))?;
227 let (owner, member) = module
228 .exports
229 .iter()
230 .filter_map(|export| {
231 export
232 .members
233 .iter()
234 .find(|member| member.name == member_name)
235 .map(|member| (export, member))
236 })
237 .max_by_key(|(export, _)| (!export.references.is_empty(), !export.is_type_only))?;
238 let declaration_kind = match member.kind {
239 MemberKind::ClassMethod => "class_method",
240 MemberKind::ClassProperty => "class_property",
241 _ => return None,
242 };
243 let source = std::fs::read_to_string(&module.path).ok()?;
244 let offsets = fallow_types::extract::compute_line_offsets(&source);
245 let (line, col) = fallow_types::extract::byte_offset_to_line_col(&offsets, member.span.start);
246 Some(SemanticSymbol {
247 path: module
248 .path
249 .strip_prefix(root)
250 .unwrap_or(&module.path)
251 .to_path_buf(),
252 namespace: SemanticNamespace::Value,
253 declaration_kind: declaration_kind.to_string(),
254 exported_name: member_name.to_string(),
255 local_name: member_name.to_string(),
256 owner: Some(owner.name.to_string()),
257 line,
258 col,
259 })
260}
261
262#[derive(Debug, Clone, Copy, PartialEq, Eq)]
264pub enum SemanticClassMethodResolutionError {
265 FileNotFound,
267 SymbolNotFound,
269 AmbiguousSymbol,
271 UnsupportedSyntax,
273}
274
275impl std::fmt::Display for SemanticClassMethodResolutionError {
276 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
277 let reason = match self {
278 Self::FileNotFound => "file-not-found",
279 Self::SymbolNotFound => "unknown-symbol",
280 Self::AmbiguousSymbol => "ambiguous-symbol",
281 Self::UnsupportedSyntax => "unsupported-syntax",
282 };
283 formatter.write_str(reason)
284 }
285}
286
287pub fn semantic_symbol_for_exact_class_method(
289 graph: &ModuleGraph,
290 root: &Path,
291 file_path: &str,
292 owner_name: &str,
293 member_name: &str,
294) -> Result<fallow_types::semantic::SemanticSymbol, SemanticClassMethodResolutionError> {
295 use fallow_types::extract::MemberKind;
296 use fallow_types::semantic::{SemanticNamespace, SemanticSymbol};
297
298 let module = graph
299 .modules
300 .iter()
301 .find(|module| path_matches(&module.path, root, file_path))
302 .ok_or(SemanticClassMethodResolutionError::FileNotFound)?;
303 let owners = module
304 .exports
305 .iter()
306 .filter(|export| export_name_matches(export, owner_name))
307 .collect::<Vec<_>>();
308 if owners.len() != 1 {
309 return Err(if owners.is_empty() {
310 SemanticClassMethodResolutionError::SymbolNotFound
311 } else {
312 SemanticClassMethodResolutionError::AmbiguousSymbol
313 });
314 }
315 let owner = owners[0];
316 let members = owner
317 .members
318 .iter()
319 .filter(|member| member.name == member_name)
320 .collect::<Vec<_>>();
321 if members.len() != 1 {
322 return Err(if members.is_empty() {
323 SemanticClassMethodResolutionError::SymbolNotFound
324 } else {
325 SemanticClassMethodResolutionError::AmbiguousSymbol
326 });
327 }
328 let member = members[0];
329 if member.kind != MemberKind::ClassMethod {
330 return Err(SemanticClassMethodResolutionError::UnsupportedSyntax);
331 }
332 let source = std::fs::read_to_string(&module.path)
333 .map_err(|_| SemanticClassMethodResolutionError::SymbolNotFound)?;
334 let offsets = fallow_types::extract::compute_line_offsets(&source);
335 let (line, col) = fallow_types::extract::byte_offset_to_line_col(&offsets, member.span.start);
336 Ok(SemanticSymbol {
337 path: module
338 .path
339 .strip_prefix(root)
340 .unwrap_or(&module.path)
341 .to_path_buf(),
342 namespace: SemanticNamespace::Value,
343 declaration_kind: "class_method".to_string(),
344 exported_name: member_name.to_string(),
345 local_name: member_name.to_string(),
346 owner: Some(owner_name.to_string()),
347 line,
348 col,
349 })
350}
351
352#[must_use]
358pub fn trace_class_member(
359 graph: &ModuleGraph,
360 root: &Path,
361 file_path: &str,
362 member_name: &str,
363) -> Option<ClassMemberTrace> {
364 use fallow_types::extract::MemberKind;
365
366 let module = graph
367 .modules
368 .iter()
369 .find(|m| path_matches(&m.path, root, file_path))?;
370
371 let (owner, member_kind) = module
375 .exports
376 .iter()
377 .filter_map(|export| {
378 export
379 .members
380 .iter()
381 .find(|member| member.name == member_name)
382 .map(|member| (export, member.kind))
383 })
384 .max_by_key(|(export, _)| (!export.references.is_empty(), !export.is_type_only))?;
385
386 let owner_name = owner.name.to_string();
387 let owner_trace = trace_export(graph, root, file_path, &owner_name)?;
394
395 let (kind_str, filter_flag) = match member_kind {
396 MemberKind::ClassMethod => ("class-method", Some("--unused-class-members")),
397 MemberKind::ClassProperty => ("class-property", Some("--unused-class-members")),
398 MemberKind::EnumMember => ("enum-member", Some("--unused-enum-members")),
399 MemberKind::StoreMember => ("store-member", Some("--unused-store-members")),
400 MemberKind::NamespaceMember => ("namespace-member", None),
401 };
402
403 let reason = class_member_trace_reason(
404 member_name,
405 &owner_name,
406 kind_str,
407 filter_flag,
408 file_path,
409 &owner_trace,
410 );
411
412 Some(ClassMemberTrace {
413 file: owner_trace.file,
414 member_name: member_name.to_string(),
415 member_kind: kind_str.to_string(),
416 owner_export: owner_name,
417 owner_is_used: owner_trace.is_used,
418 owner_file_reachable: owner_trace.file_reachable,
419 owner_is_entry_point: owner_trace.is_entry_point,
420 owner_direct_references: owner_trace.direct_references,
421 owner_re_export_chains: owner_trace.re_export_chains,
422 reason,
423 semantic: None,
424 })
425}
426
427fn class_member_trace_reason(
430 member_name: &str,
431 owner_name: &str,
432 kind_str: &str,
433 filter_flag: Option<&str>,
434 file_path: &str,
435 owner_trace: &ExportTrace,
436) -> String {
437 let head =
438 format!("'{member_name}' is a {kind_str} of '{owner_name}', not a top-level export. ");
439 let body = if !owner_trace.file_reachable {
440 format!(
441 "The file is not reachable from any entry point, so '{owner_name}' and all its \
442 members are dead (see the unused-file finding)."
443 )
444 } else if !owner_trace.is_used {
445 format!(
446 "'{owner_name}' is reachable but referenced by no file, so it is reported as an \
447 unused export and its members are not judged individually."
448 )
449 } else {
450 let refs = owner_trace.direct_references.len();
451 match filter_flag {
452 Some(flag) => format!(
453 "'{owner_name}' is used by {refs} file(s); whether '{member_name}' itself is \
454 flagged depends on cross-file member-access resolution. Run \
455 `fallow dead-code {flag} --file {file_path}` to see the member finding."
456 ),
457 None => format!(
458 "'{owner_name}' is used by {refs} file(s); '{member_name}' is credited through \
459 its namespace export."
460 ),
461 }
462 };
463 format!("{head}{body}")
464}
465
466fn export_name_matches(export: &crate::graph::ExportSymbol, export_name: &str) -> bool {
467 let name_str = export.name.to_string();
468 name_str == export_name || (export_name == "default" && name_str == "default")
469}
470
471fn traced_exports(
473 graph: &ModuleGraph,
474 root: &Path,
475 module: &crate::graph::ModuleNode,
476) -> Vec<TracedExport> {
477 module
478 .exports
479 .iter()
480 .map(|e| TracedExport {
481 name: e.name.to_string(),
482 is_type_only: e.is_type_only,
483 reference_count: e.references.len(),
484 referenced_by: e
485 .references
486 .iter()
487 .map(|r| reference_to_export_reference(graph, root, r))
488 .collect(),
489 })
490 .collect()
491}
492
493fn traced_imports_from(
495 graph: &ModuleGraph,
496 root: &Path,
497 module: &crate::graph::ModuleNode,
498) -> Vec<PathBuf> {
499 graph
500 .edges_for(module.file_id)
501 .iter()
502 .filter_map(|target_id| {
503 graph
504 .modules
505 .get(target_id.0 as usize)
506 .map(|m| m.path.strip_prefix(root).unwrap_or(&m.path).to_path_buf())
507 })
508 .collect()
509}
510
511fn traced_imported_by(
513 graph: &ModuleGraph,
514 root: &Path,
515 module: &crate::graph::ModuleNode,
516) -> Vec<PathBuf> {
517 graph
518 .reverse_deps
519 .get(module.file_id.0 as usize)
520 .map(|deps| {
521 deps.iter()
522 .filter_map(|fid| {
523 graph
524 .modules
525 .get(fid.0 as usize)
526 .map(|m| m.path.strip_prefix(root).unwrap_or(&m.path).to_path_buf())
527 })
528 .collect()
529 })
530 .unwrap_or_default()
531}
532
533fn traced_re_exports(
535 graph: &ModuleGraph,
536 root: &Path,
537 module: &crate::graph::ModuleNode,
538) -> Vec<TracedReExport> {
539 module
540 .re_exports
541 .iter()
542 .map(|re| {
543 let source_path = graph.modules.get(re.source_file.0 as usize).map_or_else(
544 || PathBuf::from(format!("<unknown:{}>", re.source_file.0)),
545 |m| m.path.strip_prefix(root).unwrap_or(&m.path).to_path_buf(),
546 );
547 TracedReExport {
548 source_file: source_path,
549 imported_name: re.imported_name.clone(),
550 exported_name: re.exported_name.clone(),
551 }
552 })
553 .collect()
554}
555
556#[must_use]
558pub fn trace_file(graph: &ModuleGraph, root: &Path, file_path: &str) -> Option<FileTrace> {
559 let module = graph
560 .modules
561 .iter()
562 .find(|m| path_matches(&m.path, root, file_path))?;
563
564 Some(FileTrace {
565 file: module
566 .path
567 .strip_prefix(root)
568 .unwrap_or(&module.path)
569 .to_path_buf(),
570 is_reachable: module.is_reachable(),
571 is_entry_point: module.is_entry_point(),
572 exports: traced_exports(graph, root, module),
573 imports_from: traced_imports_from(graph, root, module),
574 imported_by: traced_imported_by(graph, root, module),
575 re_exports: traced_re_exports(graph, root, module),
576 })
577}
578
579#[must_use]
588pub fn trace_dependency(
589 graph: &ModuleGraph,
590 root: &Path,
591 package_name: &str,
592 script_used_packages: &FxHashSet<String>,
593) -> DependencyTrace {
594 let imported_by: Vec<PathBuf> = graph
595 .package_usage
596 .get(package_name)
597 .map(|ids| {
598 ids.iter()
599 .filter_map(|fid| {
600 graph
601 .modules
602 .get(fid.0 as usize)
603 .map(|m| m.path.strip_prefix(root).unwrap_or(&m.path).to_path_buf())
604 })
605 .collect()
606 })
607 .unwrap_or_default();
608
609 let type_only_imported_by: Vec<PathBuf> = graph
610 .type_only_package_usage
611 .get(package_name)
612 .map(|ids| {
613 ids.iter()
614 .filter_map(|fid| {
615 graph
616 .modules
617 .get(fid.0 as usize)
618 .map(|m| m.path.strip_prefix(root).unwrap_or(&m.path).to_path_buf())
619 })
620 .collect()
621 })
622 .unwrap_or_default();
623
624 let import_count = imported_by.len();
625 let used_in_scripts = script_used_packages.contains(package_name);
626 DependencyTrace {
627 package_name: package_name.to_string(),
628 imported_by,
629 type_only_imported_by,
630 used_in_scripts,
631 is_used: import_count > 0 || used_in_scripts,
632 import_count,
633 }
634}
635
636fn format_reference_kind(kind: ReferenceKind) -> String {
637 match kind {
638 ReferenceKind::NamedImport => "named import".to_string(),
639 ReferenceKind::DefaultImport => "default import".to_string(),
640 ReferenceKind::NamespaceImport => "namespace import".to_string(),
641 ReferenceKind::ReExport => "re-export".to_string(),
642 ReferenceKind::DynamicImport => "dynamic import".to_string(),
643 ReferenceKind::SideEffectImport => "side-effect import".to_string(),
644 }
645}
646
647#[must_use]
654pub fn trace_impact_closure(
655 graph: &ModuleGraph,
656 root: &Path,
657 file_path: &str,
658) -> Option<ImpactClosureTrace> {
659 let module = graph
660 .modules
661 .iter()
662 .find(|m| path_matches(&m.path, root, file_path))?;
663
664 let closure = graph.impact_closure(&[module.file_id]);
665 let paths = graph.closure_with_paths(&closure, root);
666
667 let seed = paths
668 .in_diff
669 .first()
670 .cloned()
671 .unwrap_or_else(|| file_path.replace('\\', "/"));
672
673 let coordination_gap = paths
674 .coordination_gap
675 .into_iter()
676 .map(|gap| ImpactClosureGap {
677 consumer_file: gap.consumer_file,
678 consumed_symbols: gap.consumed_symbols,
679 note: "syntactic attention pointer, not a correctness proof".to_string(),
680 })
681 .collect();
682
683 Some(ImpactClosureTrace {
684 seed,
685 affected_not_shown: paths.affected_not_shown,
686 coordination_gap,
687 })
688}
689
690fn build_traced_group(
694 group: &CloneGroup,
695 root: &Path,
696 fingerprints: &CloneFingerprintSet,
697) -> TracedCloneGroup {
698 TracedCloneGroup {
699 fingerprint: fingerprints.fingerprint_for_group(group),
700 token_count: group.token_count,
701 line_count: group.line_count,
702 spread: group.spread(),
703 similarity: group.similarity,
704 instances: group
705 .instances
706 .iter()
707 .map(|inst| relativize_instance(inst, root))
708 .collect(),
709 suggestion: group_refactoring_suggestion(group),
710 suggested_name: dominant_identifier(group),
711 }
712}
713
714#[must_use]
715pub fn trace_clone(
716 report: &DuplicationReport,
717 root: &Path,
718 file_path: &str,
719 line: usize,
720) -> CloneTrace {
721 let resolved = root.join(file_path);
722 let mut matched_instance = None;
723 let mut clone_groups = Vec::new();
724 let fingerprints = CloneFingerprintSet::from_groups(&report.clone_groups);
725
726 for group in &report.clone_groups {
727 let matching = group.instances.iter().find(|inst| {
728 let inst_matches = inst.file == resolved
729 || inst.file.strip_prefix(root).unwrap_or(&inst.file) == Path::new(file_path);
730 inst_matches && inst.start_line <= line && line <= inst.end_line
731 });
732
733 if let Some(matched) = matching {
734 if matched_instance.is_none() {
735 matched_instance = Some(relativize_instance(matched, root));
736 }
737 clone_groups.push(build_traced_group(group, root, &fingerprints));
738 }
739 }
740
741 CloneTrace {
742 file: PathBuf::from(file_path),
743 line,
744 matched_instance,
745 clone_groups,
746 }
747}
748
749#[must_use]
759pub fn trace_clone_by_fingerprint(
760 report: &DuplicationReport,
761 root: &Path,
762 fingerprint: &str,
763) -> CloneTrace {
764 let fingerprints = CloneFingerprintSet::from_groups(&report.clone_groups);
765 let matched = fingerprints.find_group(&report.clone_groups, fingerprint);
766
767 let Some(group) = matched else {
768 return CloneTrace {
769 file: PathBuf::new(),
770 line: 0,
771 matched_instance: None,
772 clone_groups: Vec::new(),
773 };
774 };
775
776 let representative = group
777 .instances
778 .first()
779 .map(|inst| relativize_instance(inst, root));
780 let (file, line) = representative.as_ref().map_or_else(
781 || (PathBuf::new(), 0),
782 |inst| (inst.file.clone(), inst.start_line),
783 );
784
785 CloneTrace {
786 file,
787 line,
788 matched_instance: representative,
789 clone_groups: vec![build_traced_group(group, root, &fingerprints)],
790 }
791}
792
793fn relativize_instance(inst: &CloneInstance, root: &Path) -> CloneInstance {
797 let rel = inst.file.strip_prefix(root).map_or_else(
798 |_| inst.file.clone(),
799 |p| PathBuf::from(p.to_string_lossy().replace('\\', "/")),
800 );
801 CloneInstance {
802 file: rel,
803 ..inst.clone()
804 }
805}
806
807#[cfg(test)]
808mod tests {
809 use super::*;
810
811 use crate::discover::{DiscoveredFile, EntryPoint, EntryPointSource, FileId};
812 use crate::extract::{ExportInfo, ExportName, ImportInfo, ImportedName, VisibilityTag};
813 use crate::resolve::{ResolveResult, ResolvedImport, ResolvedModule};
814
815 fn build_test_graph() -> ModuleGraph {
816 let files = vec![
817 DiscoveredFile {
818 id: FileId(0),
819 path: PathBuf::from("/project/src/entry.ts"),
820 size_bytes: 100,
821 },
822 DiscoveredFile {
823 id: FileId(1),
824 path: PathBuf::from("/project/src/utils.ts"),
825 size_bytes: 50,
826 },
827 DiscoveredFile {
828 id: FileId(2),
829 path: PathBuf::from("/project/src/unused.ts"),
830 size_bytes: 30,
831 },
832 ];
833
834 let entry_points = vec![EntryPoint {
835 path: PathBuf::from("/project/src/entry.ts"),
836 source: EntryPointSource::PackageJsonMain,
837 }];
838
839 let resolved_modules = vec![
840 ResolvedModule {
841 file_id: FileId(0),
842 path: PathBuf::from("/project/src/entry.ts"),
843 resolved_imports: vec![ResolvedImport {
844 info: ImportInfo {
845 source: "./utils".to_string(),
846 imported_name: ImportedName::Named("foo".to_string()),
847 local_name: "foo".to_string(),
848 is_type_only: false,
849 from_style: false,
850 span: oxc_span::Span::new(0, 10),
851 source_span: oxc_span::Span::default(),
852 },
853 target: ResolveResult::InternalModule(FileId(1)),
854 }],
855 ..Default::default()
856 },
857 ResolvedModule {
858 file_id: FileId(1),
859 path: PathBuf::from("/project/src/utils.ts"),
860 exports: vec![
861 ExportInfo {
862 name: ExportName::Named("foo".to_string()),
863 local_name: Some("foo".to_string()),
864 is_type_only: false,
865 visibility: VisibilityTag::None,
866 expected_unused_reason: None,
867 span: oxc_span::Span::new(0, 20),
868 members: vec![],
869 is_side_effect_used: false,
870 super_class: None,
871 },
872 ExportInfo {
873 name: ExportName::Named("bar".to_string()),
874 local_name: Some("bar".to_string()),
875 is_type_only: false,
876 visibility: VisibilityTag::None,
877 expected_unused_reason: None,
878 span: oxc_span::Span::new(21, 40),
879 members: vec![],
880 is_side_effect_used: false,
881 super_class: None,
882 },
883 ]
884 .into(),
885 ..Default::default()
886 },
887 ResolvedModule {
888 file_id: FileId(2),
889 path: PathBuf::from("/project/src/unused.ts"),
890 exports: vec![ExportInfo {
891 name: ExportName::Named("baz".to_string()),
892 local_name: Some("baz".to_string()),
893 is_type_only: false,
894 visibility: VisibilityTag::None,
895 expected_unused_reason: None,
896 span: oxc_span::Span::new(0, 15),
897 members: vec![],
898 is_side_effect_used: false,
899 super_class: None,
900 }]
901 .into(),
902 ..Default::default()
903 },
904 ];
905
906 ModuleGraph::build(&resolved_modules, &entry_points, &files)
907 }
908
909 #[test]
910 fn trace_used_export() {
911 let graph = build_test_graph();
912 let root = Path::new("/project");
913
914 let trace = trace_export(&graph, root, "src/utils.ts", "foo").unwrap();
915 assert!(trace.is_used);
916 assert!(trace.file_reachable);
917 assert_eq!(trace.direct_references.len(), 1);
918 assert_eq!(
919 trace.direct_references[0].from_file,
920 PathBuf::from("src/entry.ts")
921 );
922 assert_eq!(trace.direct_references[0].kind, "named import");
923 }
924
925 #[test]
926 fn trace_unused_export() {
927 let graph = build_test_graph();
928 let root = Path::new("/project");
929
930 let trace = trace_export(&graph, root, "src/utils.ts", "bar").unwrap();
931 assert!(!trace.is_used);
932 assert!(trace.file_reachable);
933 assert!(trace.direct_references.is_empty());
934 }
935
936 #[test]
937 fn trace_unreachable_file_export() {
938 let graph = build_test_graph();
939 let root = Path::new("/project");
940
941 let trace = trace_export(&graph, root, "src/unused.ts", "baz").unwrap();
942 assert!(!trace.is_used);
943 assert!(!trace.file_reachable);
944 assert!(trace.reason.contains("unreachable"));
945 }
946
947 #[test]
948 fn trace_nonexistent_export() {
949 let graph = build_test_graph();
950 let root = Path::new("/project");
951
952 let trace = trace_export(&graph, root, "src/utils.ts", "nonexistent");
953 assert!(trace.is_none());
954 }
955
956 fn build_class_member_graph() -> ModuleGraph {
957 use fallow_types::extract::{MemberInfo, MemberKind};
958
959 let files = vec![
960 DiscoveredFile {
961 id: FileId(0),
962 path: PathBuf::from("/project/src/entry.ts"),
963 size_bytes: 100,
964 },
965 DiscoveredFile {
966 id: FileId(1),
967 path: PathBuf::from("/project/src/controller.ts"),
968 size_bytes: 50,
969 },
970 ];
971 let entry_points = vec![EntryPoint {
972 path: PathBuf::from("/project/src/entry.ts"),
973 source: EntryPointSource::PackageJsonMain,
974 }];
975 let method = |name: &str| MemberInfo {
976 name: name.to_string(),
977 kind: MemberKind::ClassMethod,
978 span: oxc_span::Span::new(0, 4),
979 has_decorator: false,
980 decorator_names: vec![],
981 is_instance_returning_static: false,
982 is_self_returning: false,
983 };
984 let resolved_modules = vec![
985 ResolvedModule {
986 file_id: FileId(0),
987 path: PathBuf::from("/project/src/entry.ts"),
988 resolved_imports: vec![ResolvedImport {
989 info: ImportInfo {
990 source: "./controller".to_string(),
991 imported_name: ImportedName::Named("Ctrl".to_string()),
992 local_name: "Ctrl".to_string(),
993 is_type_only: false,
994 from_style: false,
995 span: oxc_span::Span::new(0, 10),
996 source_span: oxc_span::Span::default(),
997 },
998 target: ResolveResult::InternalModule(FileId(1)),
999 }],
1000 ..Default::default()
1001 },
1002 ResolvedModule {
1003 file_id: FileId(1),
1004 path: PathBuf::from("/project/src/controller.ts"),
1005 exports: vec![ExportInfo {
1006 name: ExportName::Named("Ctrl".to_string()),
1007 local_name: Some("Ctrl".to_string()),
1008 is_type_only: false,
1009 visibility: VisibilityTag::None,
1010 expected_unused_reason: None,
1011 span: oxc_span::Span::new(0, 20),
1012 members: vec![method("used"), method("dead")],
1013 is_side_effect_used: false,
1014 super_class: None,
1015 }]
1016 .into(),
1017 ..Default::default()
1018 },
1019 ];
1020 ModuleGraph::build(&resolved_modules, &entry_points, &files)
1021 }
1022
1023 #[test]
1024 fn trace_class_member_reports_owner_class() {
1025 let graph = build_class_member_graph();
1028 let root = Path::new("/project");
1029
1030 let trace = trace_class_member(&graph, root, "src/controller.ts", "dead").unwrap();
1031 assert_eq!(trace.owner_export, "Ctrl");
1032 assert_eq!(trace.member_name, "dead");
1033 assert_eq!(trace.member_kind, "class-method");
1034 assert!(trace.owner_is_used);
1035 assert!(trace.owner_file_reachable);
1036 assert_eq!(trace.owner_direct_references.len(), 1);
1037 assert!(
1038 trace.reason.contains("--unused-class-members"),
1039 "reason should point at the member command: {}",
1040 trace.reason
1041 );
1042 }
1043
1044 #[test]
1045 fn trace_class_member_absent_name_is_none() {
1046 let graph = build_class_member_graph();
1049 let root = Path::new("/project");
1050 assert!(trace_class_member(&graph, root, "src/controller.ts", "nope").is_none());
1051 }
1052
1053 #[test]
1054 fn exact_class_method_resolution_rejects_overloads_without_guessing() {
1055 use fallow_types::extract::{MemberInfo, MemberKind};
1056
1057 let temp = tempfile::tempdir().unwrap();
1058 let root = temp.path();
1059 let path = root.join("repository.ts");
1060 let source =
1061 "export class Repository {\n save(): void;\n save(): void {}\n run(): void {}\n}\n";
1062 std::fs::write(&path, source).unwrap();
1063 let first = source.find("save").unwrap() as u32;
1064 let second = source.rfind("save").unwrap() as u32;
1065 let run = source.find("run").unwrap() as u32;
1066 let member = |name: &str, start| MemberInfo {
1067 name: name.to_string(),
1068 kind: MemberKind::ClassMethod,
1069 span: oxc_span::Span::new(start, start + 4),
1070 has_decorator: false,
1071 decorator_names: vec![],
1072 is_instance_returning_static: false,
1073 is_self_returning: false,
1074 };
1075 let files = vec![DiscoveredFile {
1076 id: FileId(0),
1077 path: path.clone(),
1078 size_bytes: source.len() as u64,
1079 }];
1080 let resolved_modules = vec![ResolvedModule {
1081 file_id: FileId(0),
1082 path,
1083 exports: vec![ExportInfo {
1084 name: ExportName::Named("Repository".to_string()),
1085 local_name: Some("Repository".to_string()),
1086 is_type_only: false,
1087 visibility: VisibilityTag::None,
1088 expected_unused_reason: None,
1089 span: oxc_span::Span::new(0, source.len() as u32),
1090 members: vec![
1091 member("save", first),
1092 member("save", second),
1093 member("run", run),
1094 ],
1095 is_side_effect_used: false,
1096 super_class: None,
1097 }]
1098 .into(),
1099 ..Default::default()
1100 }];
1101 let graph = ModuleGraph::build(&resolved_modules, &[], &files);
1102
1103 assert_eq!(
1104 semantic_symbol_for_exact_class_method(
1105 &graph,
1106 root,
1107 "repository.ts",
1108 "Repository",
1109 "save",
1110 ),
1111 Err(SemanticClassMethodResolutionError::AmbiguousSymbol)
1112 );
1113 assert_eq!(
1114 semantic_symbol_for_exact_class_method(
1115 &graph,
1116 root,
1117 "repository.ts",
1118 "OtherRepository",
1119 "save",
1120 ),
1121 Err(SemanticClassMethodResolutionError::SymbolNotFound)
1122 );
1123 let resolved = semantic_symbol_for_exact_class_method(
1124 &graph,
1125 root,
1126 "repository.ts",
1127 "Repository",
1128 "run",
1129 )
1130 .unwrap();
1131 assert_eq!(resolved.owner.as_deref(), Some("Repository"));
1132 assert_eq!(resolved.local_name, "run");
1133 }
1134
1135 fn build_unreachable_class_member_graph() -> ModuleGraph {
1138 use fallow_types::extract::{MemberInfo, MemberKind};
1139
1140 let files = vec![
1141 DiscoveredFile {
1142 id: FileId(0),
1143 path: PathBuf::from("/project/src/entry.ts"),
1144 size_bytes: 100,
1145 },
1146 DiscoveredFile {
1147 id: FileId(1),
1148 path: PathBuf::from("/project/src/controller.ts"),
1149 size_bytes: 50,
1150 },
1151 ];
1152 let entry_points = vec![EntryPoint {
1153 path: PathBuf::from("/project/src/entry.ts"),
1154 source: EntryPointSource::PackageJsonMain,
1155 }];
1156 let method = |name: &str| MemberInfo {
1157 name: name.to_string(),
1158 kind: MemberKind::ClassMethod,
1159 span: oxc_span::Span::new(0, 4),
1160 has_decorator: false,
1161 decorator_names: vec![],
1162 is_instance_returning_static: false,
1163 is_self_returning: false,
1164 };
1165 let resolved_modules = vec![
1166 ResolvedModule {
1167 file_id: FileId(0),
1168 path: PathBuf::from("/project/src/entry.ts"),
1169 ..Default::default()
1171 },
1172 ResolvedModule {
1173 file_id: FileId(1),
1174 path: PathBuf::from("/project/src/controller.ts"),
1175 exports: vec![ExportInfo {
1176 name: ExportName::Named("Ctrl".to_string()),
1177 local_name: Some("Ctrl".to_string()),
1178 is_type_only: false,
1179 visibility: VisibilityTag::None,
1180 expected_unused_reason: None,
1181 span: oxc_span::Span::new(0, 20),
1182 members: vec![method("dead")],
1183 is_side_effect_used: false,
1184 super_class: None,
1185 }]
1186 .into(),
1187 ..Default::default()
1188 },
1189 ];
1190 ModuleGraph::build(&resolved_modules, &entry_points, &files)
1191 }
1192
1193 #[test]
1194 fn trace_class_member_unreachable_owner_reports_dead_reason() {
1195 let graph = build_unreachable_class_member_graph();
1198 let root = Path::new("/project");
1199
1200 let trace = trace_class_member(&graph, root, "src/controller.ts", "dead").unwrap();
1201 assert!(!trace.owner_file_reachable);
1202 assert!(
1203 trace.reason.contains("not reachable"),
1204 "unreachable owner reason should say so: {}",
1205 trace.reason
1206 );
1207 assert!(!trace.reason.contains("--unused-class-members"));
1210 }
1211
1212 #[test]
1213 fn trace_class_member_prefers_used_owner_on_name_collision() {
1214 use fallow_types::extract::{MemberInfo, MemberKind};
1218
1219 let files = vec![
1220 DiscoveredFile {
1221 id: FileId(0),
1222 path: PathBuf::from("/project/src/entry.ts"),
1223 size_bytes: 100,
1224 },
1225 DiscoveredFile {
1226 id: FileId(1),
1227 path: PathBuf::from("/project/src/controller.ts"),
1228 size_bytes: 50,
1229 },
1230 ];
1231 let entry_points = vec![EntryPoint {
1232 path: PathBuf::from("/project/src/entry.ts"),
1233 source: EntryPointSource::PackageJsonMain,
1234 }];
1235 let method = |name: &str| MemberInfo {
1236 name: name.to_string(),
1237 kind: MemberKind::ClassMethod,
1238 span: oxc_span::Span::new(0, 4),
1239 has_decorator: false,
1240 decorator_names: vec![],
1241 is_instance_returning_static: false,
1242 is_self_returning: false,
1243 };
1244 let resolved_modules = vec![
1245 ResolvedModule {
1246 file_id: FileId(0),
1247 path: PathBuf::from("/project/src/entry.ts"),
1248 resolved_imports: vec![ResolvedImport {
1249 info: ImportInfo {
1250 source: "./controller".to_string(),
1251 imported_name: ImportedName::Named("UsedCtrl".to_string()),
1252 local_name: "UsedCtrl".to_string(),
1253 is_type_only: false,
1254 from_style: false,
1255 span: oxc_span::Span::new(0, 10),
1256 source_span: oxc_span::Span::default(),
1257 },
1258 target: ResolveResult::InternalModule(FileId(1)),
1259 }],
1260 ..Default::default()
1261 },
1262 ResolvedModule {
1263 file_id: FileId(1),
1264 path: PathBuf::from("/project/src/controller.ts"),
1265 exports: vec![
1266 ExportInfo {
1269 name: ExportName::Named("TypeCtrl".to_string()),
1270 local_name: Some("TypeCtrl".to_string()),
1271 is_type_only: true,
1272 visibility: VisibilityTag::None,
1273 expected_unused_reason: None,
1274 span: oxc_span::Span::new(0, 20),
1275 members: vec![method("shared")],
1276 is_side_effect_used: false,
1277 super_class: None,
1278 },
1279 ExportInfo {
1280 name: ExportName::Named("UsedCtrl".to_string()),
1281 local_name: Some("UsedCtrl".to_string()),
1282 is_type_only: false,
1283 visibility: VisibilityTag::None,
1284 expected_unused_reason: None,
1285 span: oxc_span::Span::new(0, 20),
1286 members: vec![method("shared")],
1287 is_side_effect_used: false,
1288 super_class: None,
1289 },
1290 ]
1291 .into(),
1292 ..Default::default()
1293 },
1294 ];
1295 let graph = ModuleGraph::build(&resolved_modules, &entry_points, &files);
1296 let root = Path::new("/project");
1297
1298 let trace = trace_class_member(&graph, root, "src/controller.ts", "shared").unwrap();
1299 assert_eq!(
1300 trace.owner_export, "UsedCtrl",
1301 "tie-break must prefer the used, non-type-only owner"
1302 );
1303 assert!(trace.owner_is_used);
1304 }
1305
1306 #[test]
1307 fn trace_nonexistent_file() {
1308 let graph = build_test_graph();
1309 let root = Path::new("/project");
1310
1311 let trace = trace_export(&graph, root, "src/nope.ts", "foo");
1312 assert!(trace.is_none());
1313 }
1314
1315 #[test]
1316 fn trace_file_edges() {
1317 let graph = build_test_graph();
1318 let root = Path::new("/project");
1319
1320 let trace = trace_file(&graph, root, "src/entry.ts").unwrap();
1321 assert!(trace.is_entry_point);
1322 assert!(trace.is_reachable);
1323 assert_eq!(trace.imports_from.len(), 1);
1324 assert_eq!(trace.imports_from[0], PathBuf::from("src/utils.ts"));
1325 assert!(trace.imported_by.is_empty());
1326 }
1327
1328 #[test]
1329 fn trace_file_imported_by() {
1330 let graph = build_test_graph();
1331 let root = Path::new("/project");
1332
1333 let trace = trace_file(&graph, root, "src/utils.ts").unwrap();
1334 assert!(!trace.is_entry_point);
1335 assert!(trace.is_reachable);
1336 assert_eq!(trace.exports.len(), 2);
1337 assert_eq!(trace.imported_by.len(), 1);
1338 assert_eq!(trace.imported_by[0], PathBuf::from("src/entry.ts"));
1339 }
1340
1341 #[test]
1342 fn trace_unreachable_file() {
1343 let graph = build_test_graph();
1344 let root = Path::new("/project");
1345
1346 let trace = trace_file(&graph, root, "src/unused.ts").unwrap();
1347 assert!(!trace.is_reachable);
1348 assert!(!trace.is_entry_point);
1349 assert!(trace.imported_by.is_empty());
1350 }
1351
1352 #[test]
1353 fn trace_dependency_used() {
1354 let files = vec![DiscoveredFile {
1355 id: FileId(0),
1356 path: PathBuf::from("/project/src/app.ts"),
1357 size_bytes: 100,
1358 }];
1359 let entry_points = vec![EntryPoint {
1360 path: PathBuf::from("/project/src/app.ts"),
1361 source: EntryPointSource::PackageJsonMain,
1362 }];
1363 let resolved_modules = vec![ResolvedModule {
1364 file_id: FileId(0),
1365 path: PathBuf::from("/project/src/app.ts"),
1366 resolved_imports: vec![ResolvedImport {
1367 info: ImportInfo {
1368 source: "lodash".to_string(),
1369 imported_name: ImportedName::Named("get".to_string()),
1370 local_name: "get".to_string(),
1371 is_type_only: false,
1372 from_style: false,
1373 span: oxc_span::Span::new(0, 10),
1374 source_span: oxc_span::Span::default(),
1375 },
1376 target: ResolveResult::NpmPackage("lodash".to_string()),
1377 }],
1378 ..Default::default()
1379 }];
1380
1381 let graph = ModuleGraph::build(&resolved_modules, &entry_points, &files);
1382 let root = Path::new("/project");
1383
1384 let trace = trace_dependency(&graph, root, "lodash", &FxHashSet::default());
1385 assert!(trace.is_used);
1386 assert!(!trace.used_in_scripts);
1387 assert_eq!(trace.import_count, 1);
1388 assert_eq!(trace.imported_by[0], PathBuf::from("src/app.ts"));
1389 }
1390
1391 #[test]
1392 fn trace_dependency_unused() {
1393 let files = vec![DiscoveredFile {
1394 id: FileId(0),
1395 path: PathBuf::from("/project/src/app.ts"),
1396 size_bytes: 100,
1397 }];
1398 let entry_points = vec![EntryPoint {
1399 path: PathBuf::from("/project/src/app.ts"),
1400 source: EntryPointSource::PackageJsonMain,
1401 }];
1402 let resolved_modules = vec![ResolvedModule {
1403 file_id: FileId(0),
1404 path: PathBuf::from("/project/src/app.ts"),
1405 ..Default::default()
1406 }];
1407
1408 let graph = ModuleGraph::build(&resolved_modules, &entry_points, &files);
1409 let root = Path::new("/project");
1410
1411 let trace = trace_dependency(&graph, root, "nonexistent-pkg", &FxHashSet::default());
1412 assert!(!trace.is_used);
1413 assert!(!trace.used_in_scripts);
1414 assert_eq!(trace.import_count, 0);
1415 assert!(trace.imported_by.is_empty());
1416 }
1417
1418 #[test]
1419 fn trace_dependency_used_only_in_scripts() {
1420 let files = vec![DiscoveredFile {
1421 id: FileId(0),
1422 path: PathBuf::from("/project/src/app.ts"),
1423 size_bytes: 100,
1424 }];
1425 let entry_points = vec![EntryPoint {
1426 path: PathBuf::from("/project/src/app.ts"),
1427 source: EntryPointSource::PackageJsonMain,
1428 }];
1429 let resolved_modules = vec![ResolvedModule {
1430 file_id: FileId(0),
1431 path: PathBuf::from("/project/src/app.ts"),
1432 ..Default::default()
1433 }];
1434
1435 let graph = ModuleGraph::build(&resolved_modules, &entry_points, &files);
1436 let root = Path::new("/project");
1437 let mut script_used = FxHashSet::default();
1438 script_used.insert("microbundle".to_string());
1439
1440 let trace = trace_dependency(&graph, root, "microbundle", &script_used);
1441 assert!(
1442 trace.is_used,
1443 "is_used must be true when the package is referenced from package.json scripts"
1444 );
1445 assert!(trace.used_in_scripts);
1446 assert_eq!(trace.import_count, 0);
1447 assert!(trace.imported_by.is_empty());
1448 }
1449
1450 #[test]
1451 fn trace_clone_finds_matching_group() {
1452 use crate::duplicates::{CloneGroup, CloneInstance, DuplicationReport, DuplicationStats};
1453 let report = DuplicationReport {
1454 clone_groups: vec![CloneGroup {
1455 instances: vec![
1456 CloneInstance {
1457 file: PathBuf::from("/project/src/a.ts"),
1458 start_line: 10,
1459 end_line: 20,
1460 start_col: 0,
1461 end_col: 0,
1462 fragment: "fn foo() {}".to_string(),
1463 },
1464 CloneInstance {
1465 file: PathBuf::from("/project/src/b.ts"),
1466 start_line: 5,
1467 end_line: 15,
1468 start_col: 0,
1469 end_col: 0,
1470 fragment: "fn foo() {}".to_string(),
1471 },
1472 ],
1473 token_count: 60,
1474 line_count: 11,
1475 similarity: None,
1476 }],
1477 clone_families: vec![],
1478 mirrored_directories: vec![],
1479 stats: DuplicationStats {
1480 total_files: 2,
1481 files_with_clones: 2,
1482 total_lines: 100,
1483 duplicated_lines: 22,
1484 total_tokens: 200,
1485 duplicated_tokens: 120,
1486 clone_groups: 1,
1487 clone_instances: 2,
1488 duplication_percentage: 22.0,
1489 clone_groups_below_min_occurrences: 0,
1490 clone_groups_ignored: 0,
1491 near_candidates_skipped: 0,
1492 },
1493 };
1494 let trace = trace_clone(&report, Path::new("/project"), "src/a.ts", 15);
1495 assert!(trace.matched_instance.is_some());
1496 assert_eq!(trace.clone_groups.len(), 1);
1497 assert_eq!(trace.clone_groups[0].instances.len(), 2);
1498 assert!(trace.clone_groups[0].fingerprint.starts_with("dup:"));
1499 assert_eq!(trace.clone_groups[0].suggestion.estimated_savings, 11);
1500 }
1501
1502 #[test]
1503 fn trace_clone_by_fingerprint_resolves_and_misses() {
1504 use crate::duplicates::{
1505 CloneGroup, CloneInstance, DuplicationReport, DuplicationStats, clone_fingerprint,
1506 };
1507 let report = DuplicationReport {
1508 clone_groups: vec![CloneGroup {
1509 instances: vec![
1510 CloneInstance {
1511 file: PathBuf::from("/project/src/a.ts"),
1512 start_line: 10,
1513 end_line: 20,
1514 start_col: 0,
1515 end_col: 0,
1516 fragment: "fn buildInvoice() {}".to_string(),
1517 },
1518 CloneInstance {
1519 file: PathBuf::from("/project/src/b.ts"),
1520 start_line: 5,
1521 end_line: 15,
1522 start_col: 0,
1523 end_col: 0,
1524 fragment: "fn buildInvoice() {}".to_string(),
1525 },
1526 ],
1527 token_count: 60,
1528 line_count: 11,
1529 similarity: None,
1530 }],
1531 clone_families: vec![],
1532 mirrored_directories: vec![],
1533 stats: DuplicationStats::default(),
1534 };
1535 let fp = clone_fingerprint(&report.clone_groups[0].instances);
1536
1537 let hit = trace_clone_by_fingerprint(&report, Path::new("/project"), &fp);
1538 assert!(hit.matched_instance.is_some());
1539 assert_eq!(hit.clone_groups.len(), 1);
1540 assert_eq!(hit.clone_groups[0].fingerprint, fp);
1541 assert_eq!(hit.line, 10);
1542
1543 let miss = trace_clone_by_fingerprint(&report, Path::new("/project"), "dup:deadbeef");
1544 assert!(miss.matched_instance.is_none());
1545 assert!(miss.clone_groups.is_empty());
1546 }
1547
1548 #[test]
1549 fn trace_clone_no_match() {
1550 use crate::duplicates::{CloneGroup, CloneInstance, DuplicationReport, DuplicationStats};
1551 let report = DuplicationReport {
1552 clone_groups: vec![CloneGroup {
1553 instances: vec![CloneInstance {
1554 file: PathBuf::from("/project/src/a.ts"),
1555 start_line: 10,
1556 end_line: 20,
1557 start_col: 0,
1558 end_col: 0,
1559 fragment: "fn foo() {}".to_string(),
1560 }],
1561 token_count: 60,
1562 line_count: 11,
1563 similarity: None,
1564 }],
1565 clone_families: vec![],
1566 mirrored_directories: vec![],
1567 stats: DuplicationStats {
1568 total_files: 1,
1569 files_with_clones: 1,
1570 total_lines: 50,
1571 duplicated_lines: 11,
1572 total_tokens: 100,
1573 duplicated_tokens: 60,
1574 clone_groups: 1,
1575 clone_instances: 1,
1576 duplication_percentage: 22.0,
1577 clone_groups_below_min_occurrences: 0,
1578 clone_groups_ignored: 0,
1579 near_candidates_skipped: 0,
1580 },
1581 };
1582 let trace = trace_clone(&report, Path::new("/project"), "src/a.ts", 25);
1583 assert!(trace.matched_instance.is_none());
1584 assert!(trace.clone_groups.is_empty());
1585 }
1586
1587 #[test]
1588 fn trace_clone_line_boundary() {
1589 use crate::duplicates::{CloneGroup, CloneInstance, DuplicationReport, DuplicationStats};
1590 let report = DuplicationReport {
1591 clone_groups: vec![CloneGroup {
1592 instances: vec![
1593 CloneInstance {
1594 file: PathBuf::from("/project/src/a.ts"),
1595 start_line: 10,
1596 end_line: 20,
1597 start_col: 0,
1598 end_col: 0,
1599 fragment: "code".to_string(),
1600 },
1601 CloneInstance {
1602 file: PathBuf::from("/project/src/b.ts"),
1603 start_line: 1,
1604 end_line: 11,
1605 start_col: 0,
1606 end_col: 0,
1607 fragment: "code".to_string(),
1608 },
1609 ],
1610 token_count: 50,
1611 line_count: 11,
1612 similarity: None,
1613 }],
1614 clone_families: vec![],
1615 mirrored_directories: vec![],
1616 stats: DuplicationStats {
1617 total_files: 2,
1618 files_with_clones: 2,
1619 total_lines: 100,
1620 duplicated_lines: 22,
1621 total_tokens: 200,
1622 duplicated_tokens: 100,
1623 clone_groups: 1,
1624 clone_instances: 2,
1625 duplication_percentage: 22.0,
1626 clone_groups_below_min_occurrences: 0,
1627 clone_groups_ignored: 0,
1628 near_candidates_skipped: 0,
1629 },
1630 };
1631 let root = Path::new("/project");
1632 assert!(
1633 trace_clone(&report, root, "src/a.ts", 10)
1634 .matched_instance
1635 .is_some()
1636 );
1637 assert!(
1638 trace_clone(&report, root, "src/a.ts", 20)
1639 .matched_instance
1640 .is_some()
1641 );
1642 assert!(
1643 trace_clone(&report, root, "src/a.ts", 21)
1644 .matched_instance
1645 .is_none()
1646 );
1647 }
1648
1649 #[test]
1650 fn trace_clone_returns_relative_instance_paths() {
1651 use crate::duplicates::{CloneGroup, CloneInstance, DuplicationReport, DuplicationStats};
1652 let report = DuplicationReport {
1653 clone_groups: vec![CloneGroup {
1654 instances: vec![
1655 CloneInstance {
1656 file: PathBuf::from("/project/src/a.ts"),
1657 start_line: 1,
1658 end_line: 10,
1659 start_col: 0,
1660 end_col: 0,
1661 fragment: "code".to_string(),
1662 },
1663 CloneInstance {
1664 file: PathBuf::from("/project/src/b.ts"),
1665 start_line: 1,
1666 end_line: 10,
1667 start_col: 0,
1668 end_col: 0,
1669 fragment: "code".to_string(),
1670 },
1671 ],
1672 token_count: 50,
1673 line_count: 10,
1674 similarity: None,
1675 }],
1676 clone_families: vec![],
1677 mirrored_directories: vec![],
1678 stats: DuplicationStats {
1679 total_files: 2,
1680 files_with_clones: 2,
1681 total_lines: 50,
1682 duplicated_lines: 20,
1683 total_tokens: 100,
1684 duplicated_tokens: 100,
1685 clone_groups: 1,
1686 clone_instances: 2,
1687 duplication_percentage: 40.0,
1688 clone_groups_below_min_occurrences: 0,
1689 clone_groups_ignored: 0,
1690 near_candidates_skipped: 0,
1691 },
1692 };
1693 let trace = trace_clone(&report, Path::new("/project"), "src/a.ts", 5);
1694 let matched = trace.matched_instance.as_ref().expect("match expected");
1695 assert_eq!(matched.file, PathBuf::from("src/a.ts"));
1696 for group in &trace.clone_groups {
1697 for inst in &group.instances {
1698 let as_str = inst.file.to_string_lossy();
1699 assert!(
1700 !as_str.starts_with('/'),
1701 "instance file should be relative, got {as_str}",
1702 );
1703 assert!(
1704 !as_str.contains(":\\") && !as_str.contains(":/"),
1705 "instance file should not have a drive letter, got {as_str}",
1706 );
1707 }
1708 }
1709
1710 let json = serde_json::to_string(&trace).expect("serializes");
1711 assert!(
1712 !json.contains("\"/project/"),
1713 "serialized trace should not leak absolute paths: {json}",
1714 );
1715 }
1716
1717 #[test]
1724 fn path_matches_normalises_windows_module_path_against_posix_user_path() {
1725 let root = Path::new(r"D:\a\fallow\fallow\tests\fixtures\basic-project");
1726 let module_path =
1727 PathBuf::from(r"D:\a\fallow\fallow\tests\fixtures\basic-project\src\utils.ts");
1728 assert!(path_matches(&module_path, root, "src/utils.ts"));
1729 assert!(path_matches(&module_path, root, r"src\utils.ts"));
1730 }
1731
1732 #[test]
1733 fn path_matches_ends_with_fallback_handles_mixed_separators() {
1734 let root = Path::new("/some/other/root");
1735 let module_path =
1736 PathBuf::from(r"D:\a\fallow\fallow\tests\fixtures\basic-project\src\utils.ts");
1737 assert!(path_matches(&module_path, root, "src/utils.ts"));
1738 }
1739
1740 #[test]
1749 fn export_trace_serializes_windows_path_with_forward_slashes() {
1750 let trace = ExportTrace {
1751 file: PathBuf::from(r"src\utils.ts"),
1752 export_name: "foo".to_string(),
1753 file_reachable: true,
1754 is_entry_point: false,
1755 is_used: true,
1756 direct_references: vec![ExportReference {
1757 from_file: PathBuf::from(r"src\entry.ts"),
1758 kind: "named import".to_string(),
1759 }],
1760 re_export_chains: vec![ReExportChain {
1761 barrel_file: PathBuf::from(r"src\index.ts"),
1762 exported_as: "foo".to_string(),
1763 reference_count: 1,
1764 }],
1765 reason: "ok".to_string(),
1766 semantic: None,
1767 };
1768 let json = serde_json::to_string(&trace).expect("serializes");
1769 assert!(
1770 json.contains("\"file\":\"src/utils.ts\""),
1771 "ExportTrace.file must serialize with forward slashes: {json}"
1772 );
1773 assert!(
1774 json.contains("\"from_file\":\"src/entry.ts\""),
1775 "ExportReference.from_file must serialize with forward slashes: {json}"
1776 );
1777 assert!(
1778 json.contains("\"barrel_file\":\"src/index.ts\""),
1779 "ReExportChain.barrel_file must serialize with forward slashes: {json}"
1780 );
1781 assert!(
1782 !json.contains(r"\\"),
1783 "no backslash sequence should remain anywhere in the JSON: {json}"
1784 );
1785 }
1786
1787 #[test]
1788 fn file_trace_serializes_windows_paths_with_forward_slashes() {
1789 let trace = FileTrace {
1790 file: PathBuf::from(r"src\utils.ts"),
1791 is_reachable: true,
1792 is_entry_point: false,
1793 exports: vec![],
1794 imports_from: vec![PathBuf::from(r"src\helpers.ts")],
1795 imported_by: vec![PathBuf::from(r"src\entry.ts")],
1796 re_exports: vec![TracedReExport {
1797 source_file: PathBuf::from(r"src\source.ts"),
1798 imported_name: "foo".to_string(),
1799 exported_name: "foo".to_string(),
1800 }],
1801 };
1802 let json = serde_json::to_string(&trace).expect("serializes");
1803 assert!(json.contains("\"file\":\"src/utils.ts\""), "got {json}");
1804 assert!(
1805 json.contains("\"imports_from\":[\"src/helpers.ts\"]"),
1806 "got {json}"
1807 );
1808 assert!(
1809 json.contains("\"imported_by\":[\"src/entry.ts\"]"),
1810 "got {json}"
1811 );
1812 assert!(
1813 json.contains("\"source_file\":\"src/source.ts\""),
1814 "got {json}"
1815 );
1816 assert!(!json.contains(r"\\"), "no backslash should remain: {json}");
1817 }
1818}