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 instances: group
703 .instances
704 .iter()
705 .map(|inst| relativize_instance(inst, root))
706 .collect(),
707 suggestion: group_refactoring_suggestion(group),
708 suggested_name: dominant_identifier(group),
709 }
710}
711
712#[must_use]
713pub fn trace_clone(
714 report: &DuplicationReport,
715 root: &Path,
716 file_path: &str,
717 line: usize,
718) -> CloneTrace {
719 let resolved = root.join(file_path);
720 let mut matched_instance = None;
721 let mut clone_groups = Vec::new();
722 let fingerprints = CloneFingerprintSet::from_groups(&report.clone_groups);
723
724 for group in &report.clone_groups {
725 let matching = group.instances.iter().find(|inst| {
726 let inst_matches = inst.file == resolved
727 || inst.file.strip_prefix(root).unwrap_or(&inst.file) == Path::new(file_path);
728 inst_matches && inst.start_line <= line && line <= inst.end_line
729 });
730
731 if let Some(matched) = matching {
732 if matched_instance.is_none() {
733 matched_instance = Some(relativize_instance(matched, root));
734 }
735 clone_groups.push(build_traced_group(group, root, &fingerprints));
736 }
737 }
738
739 CloneTrace {
740 file: PathBuf::from(file_path),
741 line,
742 matched_instance,
743 clone_groups,
744 }
745}
746
747#[must_use]
757pub fn trace_clone_by_fingerprint(
758 report: &DuplicationReport,
759 root: &Path,
760 fingerprint: &str,
761) -> CloneTrace {
762 let fingerprints = CloneFingerprintSet::from_groups(&report.clone_groups);
763 let matched = fingerprints.find_group(&report.clone_groups, fingerprint);
764
765 let Some(group) = matched else {
766 return CloneTrace {
767 file: PathBuf::new(),
768 line: 0,
769 matched_instance: None,
770 clone_groups: Vec::new(),
771 };
772 };
773
774 let representative = group
775 .instances
776 .first()
777 .map(|inst| relativize_instance(inst, root));
778 let (file, line) = representative.as_ref().map_or_else(
779 || (PathBuf::new(), 0),
780 |inst| (inst.file.clone(), inst.start_line),
781 );
782
783 CloneTrace {
784 file,
785 line,
786 matched_instance: representative,
787 clone_groups: vec![build_traced_group(group, root, &fingerprints)],
788 }
789}
790
791fn relativize_instance(inst: &CloneInstance, root: &Path) -> CloneInstance {
795 let rel = inst.file.strip_prefix(root).map_or_else(
796 |_| inst.file.clone(),
797 |p| PathBuf::from(p.to_string_lossy().replace('\\', "/")),
798 );
799 CloneInstance {
800 file: rel,
801 ..inst.clone()
802 }
803}
804
805#[cfg(test)]
806mod tests {
807 use super::*;
808
809 use crate::discover::{DiscoveredFile, EntryPoint, EntryPointSource, FileId};
810 use crate::extract::{ExportInfo, ExportName, ImportInfo, ImportedName, VisibilityTag};
811 use crate::resolve::{ResolveResult, ResolvedImport, ResolvedModule};
812
813 fn build_test_graph() -> ModuleGraph {
814 let files = vec![
815 DiscoveredFile {
816 id: FileId(0),
817 path: PathBuf::from("/project/src/entry.ts"),
818 size_bytes: 100,
819 },
820 DiscoveredFile {
821 id: FileId(1),
822 path: PathBuf::from("/project/src/utils.ts"),
823 size_bytes: 50,
824 },
825 DiscoveredFile {
826 id: FileId(2),
827 path: PathBuf::from("/project/src/unused.ts"),
828 size_bytes: 30,
829 },
830 ];
831
832 let entry_points = vec![EntryPoint {
833 path: PathBuf::from("/project/src/entry.ts"),
834 source: EntryPointSource::PackageJsonMain,
835 }];
836
837 let resolved_modules = vec![
838 ResolvedModule {
839 file_id: FileId(0),
840 path: PathBuf::from("/project/src/entry.ts"),
841 resolved_imports: vec![ResolvedImport {
842 info: ImportInfo {
843 source: "./utils".to_string(),
844 imported_name: ImportedName::Named("foo".to_string()),
845 local_name: "foo".to_string(),
846 is_type_only: false,
847 from_style: false,
848 span: oxc_span::Span::new(0, 10),
849 source_span: oxc_span::Span::default(),
850 },
851 target: ResolveResult::InternalModule(FileId(1)),
852 }],
853 ..Default::default()
854 },
855 ResolvedModule {
856 file_id: FileId(1),
857 path: PathBuf::from("/project/src/utils.ts"),
858 exports: vec![
859 ExportInfo {
860 name: ExportName::Named("foo".to_string()),
861 local_name: Some("foo".to_string()),
862 is_type_only: false,
863 visibility: VisibilityTag::None,
864 expected_unused_reason: None,
865 span: oxc_span::Span::new(0, 20),
866 members: vec![],
867 is_side_effect_used: false,
868 super_class: None,
869 },
870 ExportInfo {
871 name: ExportName::Named("bar".to_string()),
872 local_name: Some("bar".to_string()),
873 is_type_only: false,
874 visibility: VisibilityTag::None,
875 expected_unused_reason: None,
876 span: oxc_span::Span::new(21, 40),
877 members: vec![],
878 is_side_effect_used: false,
879 super_class: None,
880 },
881 ],
882 ..Default::default()
883 },
884 ResolvedModule {
885 file_id: FileId(2),
886 path: PathBuf::from("/project/src/unused.ts"),
887 exports: vec![ExportInfo {
888 name: ExportName::Named("baz".to_string()),
889 local_name: Some("baz".to_string()),
890 is_type_only: false,
891 visibility: VisibilityTag::None,
892 expected_unused_reason: None,
893 span: oxc_span::Span::new(0, 15),
894 members: vec![],
895 is_side_effect_used: false,
896 super_class: None,
897 }],
898 ..Default::default()
899 },
900 ];
901
902 ModuleGraph::build(&resolved_modules, &entry_points, &files)
903 }
904
905 #[test]
906 fn trace_used_export() {
907 let graph = build_test_graph();
908 let root = Path::new("/project");
909
910 let trace = trace_export(&graph, root, "src/utils.ts", "foo").unwrap();
911 assert!(trace.is_used);
912 assert!(trace.file_reachable);
913 assert_eq!(trace.direct_references.len(), 1);
914 assert_eq!(
915 trace.direct_references[0].from_file,
916 PathBuf::from("src/entry.ts")
917 );
918 assert_eq!(trace.direct_references[0].kind, "named import");
919 }
920
921 #[test]
922 fn trace_unused_export() {
923 let graph = build_test_graph();
924 let root = Path::new("/project");
925
926 let trace = trace_export(&graph, root, "src/utils.ts", "bar").unwrap();
927 assert!(!trace.is_used);
928 assert!(trace.file_reachable);
929 assert!(trace.direct_references.is_empty());
930 }
931
932 #[test]
933 fn trace_unreachable_file_export() {
934 let graph = build_test_graph();
935 let root = Path::new("/project");
936
937 let trace = trace_export(&graph, root, "src/unused.ts", "baz").unwrap();
938 assert!(!trace.is_used);
939 assert!(!trace.file_reachable);
940 assert!(trace.reason.contains("unreachable"));
941 }
942
943 #[test]
944 fn trace_nonexistent_export() {
945 let graph = build_test_graph();
946 let root = Path::new("/project");
947
948 let trace = trace_export(&graph, root, "src/utils.ts", "nonexistent");
949 assert!(trace.is_none());
950 }
951
952 fn build_class_member_graph() -> ModuleGraph {
953 use fallow_types::extract::{MemberInfo, MemberKind};
954
955 let files = vec![
956 DiscoveredFile {
957 id: FileId(0),
958 path: PathBuf::from("/project/src/entry.ts"),
959 size_bytes: 100,
960 },
961 DiscoveredFile {
962 id: FileId(1),
963 path: PathBuf::from("/project/src/controller.ts"),
964 size_bytes: 50,
965 },
966 ];
967 let entry_points = vec![EntryPoint {
968 path: PathBuf::from("/project/src/entry.ts"),
969 source: EntryPointSource::PackageJsonMain,
970 }];
971 let method = |name: &str| MemberInfo {
972 name: name.to_string(),
973 kind: MemberKind::ClassMethod,
974 span: oxc_span::Span::new(0, 4),
975 has_decorator: false,
976 decorator_names: vec![],
977 is_instance_returning_static: false,
978 is_self_returning: false,
979 };
980 let resolved_modules = vec![
981 ResolvedModule {
982 file_id: FileId(0),
983 path: PathBuf::from("/project/src/entry.ts"),
984 resolved_imports: vec![ResolvedImport {
985 info: ImportInfo {
986 source: "./controller".to_string(),
987 imported_name: ImportedName::Named("Ctrl".to_string()),
988 local_name: "Ctrl".to_string(),
989 is_type_only: false,
990 from_style: false,
991 span: oxc_span::Span::new(0, 10),
992 source_span: oxc_span::Span::default(),
993 },
994 target: ResolveResult::InternalModule(FileId(1)),
995 }],
996 ..Default::default()
997 },
998 ResolvedModule {
999 file_id: FileId(1),
1000 path: PathBuf::from("/project/src/controller.ts"),
1001 exports: vec![ExportInfo {
1002 name: ExportName::Named("Ctrl".to_string()),
1003 local_name: Some("Ctrl".to_string()),
1004 is_type_only: false,
1005 visibility: VisibilityTag::None,
1006 expected_unused_reason: None,
1007 span: oxc_span::Span::new(0, 20),
1008 members: vec![method("used"), method("dead")],
1009 is_side_effect_used: false,
1010 super_class: None,
1011 }],
1012 ..Default::default()
1013 },
1014 ];
1015 ModuleGraph::build(&resolved_modules, &entry_points, &files)
1016 }
1017
1018 #[test]
1019 fn trace_class_member_reports_owner_class() {
1020 let graph = build_class_member_graph();
1023 let root = Path::new("/project");
1024
1025 let trace = trace_class_member(&graph, root, "src/controller.ts", "dead").unwrap();
1026 assert_eq!(trace.owner_export, "Ctrl");
1027 assert_eq!(trace.member_name, "dead");
1028 assert_eq!(trace.member_kind, "class-method");
1029 assert!(trace.owner_is_used);
1030 assert!(trace.owner_file_reachable);
1031 assert_eq!(trace.owner_direct_references.len(), 1);
1032 assert!(
1033 trace.reason.contains("--unused-class-members"),
1034 "reason should point at the member command: {}",
1035 trace.reason
1036 );
1037 }
1038
1039 #[test]
1040 fn trace_class_member_absent_name_is_none() {
1041 let graph = build_class_member_graph();
1044 let root = Path::new("/project");
1045 assert!(trace_class_member(&graph, root, "src/controller.ts", "nope").is_none());
1046 }
1047
1048 #[test]
1049 fn exact_class_method_resolution_rejects_overloads_without_guessing() {
1050 use fallow_types::extract::{MemberInfo, MemberKind};
1051
1052 let temp = tempfile::tempdir().unwrap();
1053 let root = temp.path();
1054 let path = root.join("repository.ts");
1055 let source =
1056 "export class Repository {\n save(): void;\n save(): void {}\n run(): void {}\n}\n";
1057 std::fs::write(&path, source).unwrap();
1058 let first = source.find("save").unwrap() as u32;
1059 let second = source.rfind("save").unwrap() as u32;
1060 let run = source.find("run").unwrap() as u32;
1061 let member = |name: &str, start| MemberInfo {
1062 name: name.to_string(),
1063 kind: MemberKind::ClassMethod,
1064 span: oxc_span::Span::new(start, start + 4),
1065 has_decorator: false,
1066 decorator_names: vec![],
1067 is_instance_returning_static: false,
1068 is_self_returning: false,
1069 };
1070 let files = vec![DiscoveredFile {
1071 id: FileId(0),
1072 path: path.clone(),
1073 size_bytes: source.len() as u64,
1074 }];
1075 let resolved_modules = vec![ResolvedModule {
1076 file_id: FileId(0),
1077 path,
1078 exports: vec![ExportInfo {
1079 name: ExportName::Named("Repository".to_string()),
1080 local_name: Some("Repository".to_string()),
1081 is_type_only: false,
1082 visibility: VisibilityTag::None,
1083 expected_unused_reason: None,
1084 span: oxc_span::Span::new(0, source.len() as u32),
1085 members: vec![
1086 member("save", first),
1087 member("save", second),
1088 member("run", run),
1089 ],
1090 is_side_effect_used: false,
1091 super_class: None,
1092 }],
1093 ..Default::default()
1094 }];
1095 let graph = ModuleGraph::build(&resolved_modules, &[], &files);
1096
1097 assert_eq!(
1098 semantic_symbol_for_exact_class_method(
1099 &graph,
1100 root,
1101 "repository.ts",
1102 "Repository",
1103 "save",
1104 ),
1105 Err(SemanticClassMethodResolutionError::AmbiguousSymbol)
1106 );
1107 assert_eq!(
1108 semantic_symbol_for_exact_class_method(
1109 &graph,
1110 root,
1111 "repository.ts",
1112 "OtherRepository",
1113 "save",
1114 ),
1115 Err(SemanticClassMethodResolutionError::SymbolNotFound)
1116 );
1117 let resolved = semantic_symbol_for_exact_class_method(
1118 &graph,
1119 root,
1120 "repository.ts",
1121 "Repository",
1122 "run",
1123 )
1124 .unwrap();
1125 assert_eq!(resolved.owner.as_deref(), Some("Repository"));
1126 assert_eq!(resolved.local_name, "run");
1127 }
1128
1129 fn build_unreachable_class_member_graph() -> ModuleGraph {
1132 use fallow_types::extract::{MemberInfo, MemberKind};
1133
1134 let files = vec![
1135 DiscoveredFile {
1136 id: FileId(0),
1137 path: PathBuf::from("/project/src/entry.ts"),
1138 size_bytes: 100,
1139 },
1140 DiscoveredFile {
1141 id: FileId(1),
1142 path: PathBuf::from("/project/src/controller.ts"),
1143 size_bytes: 50,
1144 },
1145 ];
1146 let entry_points = vec![EntryPoint {
1147 path: PathBuf::from("/project/src/entry.ts"),
1148 source: EntryPointSource::PackageJsonMain,
1149 }];
1150 let method = |name: &str| MemberInfo {
1151 name: name.to_string(),
1152 kind: MemberKind::ClassMethod,
1153 span: oxc_span::Span::new(0, 4),
1154 has_decorator: false,
1155 decorator_names: vec![],
1156 is_instance_returning_static: false,
1157 is_self_returning: false,
1158 };
1159 let resolved_modules = vec![
1160 ResolvedModule {
1161 file_id: FileId(0),
1162 path: PathBuf::from("/project/src/entry.ts"),
1163 ..Default::default()
1165 },
1166 ResolvedModule {
1167 file_id: FileId(1),
1168 path: PathBuf::from("/project/src/controller.ts"),
1169 exports: vec![ExportInfo {
1170 name: ExportName::Named("Ctrl".to_string()),
1171 local_name: Some("Ctrl".to_string()),
1172 is_type_only: false,
1173 visibility: VisibilityTag::None,
1174 expected_unused_reason: None,
1175 span: oxc_span::Span::new(0, 20),
1176 members: vec![method("dead")],
1177 is_side_effect_used: false,
1178 super_class: None,
1179 }],
1180 ..Default::default()
1181 },
1182 ];
1183 ModuleGraph::build(&resolved_modules, &entry_points, &files)
1184 }
1185
1186 #[test]
1187 fn trace_class_member_unreachable_owner_reports_dead_reason() {
1188 let graph = build_unreachable_class_member_graph();
1191 let root = Path::new("/project");
1192
1193 let trace = trace_class_member(&graph, root, "src/controller.ts", "dead").unwrap();
1194 assert!(!trace.owner_file_reachable);
1195 assert!(
1196 trace.reason.contains("not reachable"),
1197 "unreachable owner reason should say so: {}",
1198 trace.reason
1199 );
1200 assert!(!trace.reason.contains("--unused-class-members"));
1203 }
1204
1205 #[test]
1206 fn trace_class_member_prefers_used_owner_on_name_collision() {
1207 use fallow_types::extract::{MemberInfo, MemberKind};
1211
1212 let files = vec![
1213 DiscoveredFile {
1214 id: FileId(0),
1215 path: PathBuf::from("/project/src/entry.ts"),
1216 size_bytes: 100,
1217 },
1218 DiscoveredFile {
1219 id: FileId(1),
1220 path: PathBuf::from("/project/src/controller.ts"),
1221 size_bytes: 50,
1222 },
1223 ];
1224 let entry_points = vec![EntryPoint {
1225 path: PathBuf::from("/project/src/entry.ts"),
1226 source: EntryPointSource::PackageJsonMain,
1227 }];
1228 let method = |name: &str| MemberInfo {
1229 name: name.to_string(),
1230 kind: MemberKind::ClassMethod,
1231 span: oxc_span::Span::new(0, 4),
1232 has_decorator: false,
1233 decorator_names: vec![],
1234 is_instance_returning_static: false,
1235 is_self_returning: false,
1236 };
1237 let resolved_modules = vec![
1238 ResolvedModule {
1239 file_id: FileId(0),
1240 path: PathBuf::from("/project/src/entry.ts"),
1241 resolved_imports: vec![ResolvedImport {
1242 info: ImportInfo {
1243 source: "./controller".to_string(),
1244 imported_name: ImportedName::Named("UsedCtrl".to_string()),
1245 local_name: "UsedCtrl".to_string(),
1246 is_type_only: false,
1247 from_style: false,
1248 span: oxc_span::Span::new(0, 10),
1249 source_span: oxc_span::Span::default(),
1250 },
1251 target: ResolveResult::InternalModule(FileId(1)),
1252 }],
1253 ..Default::default()
1254 },
1255 ResolvedModule {
1256 file_id: FileId(1),
1257 path: PathBuf::from("/project/src/controller.ts"),
1258 exports: vec![
1259 ExportInfo {
1262 name: ExportName::Named("TypeCtrl".to_string()),
1263 local_name: Some("TypeCtrl".to_string()),
1264 is_type_only: true,
1265 visibility: VisibilityTag::None,
1266 expected_unused_reason: None,
1267 span: oxc_span::Span::new(0, 20),
1268 members: vec![method("shared")],
1269 is_side_effect_used: false,
1270 super_class: None,
1271 },
1272 ExportInfo {
1273 name: ExportName::Named("UsedCtrl".to_string()),
1274 local_name: Some("UsedCtrl".to_string()),
1275 is_type_only: false,
1276 visibility: VisibilityTag::None,
1277 expected_unused_reason: None,
1278 span: oxc_span::Span::new(0, 20),
1279 members: vec![method("shared")],
1280 is_side_effect_used: false,
1281 super_class: None,
1282 },
1283 ],
1284 ..Default::default()
1285 },
1286 ];
1287 let graph = ModuleGraph::build(&resolved_modules, &entry_points, &files);
1288 let root = Path::new("/project");
1289
1290 let trace = trace_class_member(&graph, root, "src/controller.ts", "shared").unwrap();
1291 assert_eq!(
1292 trace.owner_export, "UsedCtrl",
1293 "tie-break must prefer the used, non-type-only owner"
1294 );
1295 assert!(trace.owner_is_used);
1296 }
1297
1298 #[test]
1299 fn trace_nonexistent_file() {
1300 let graph = build_test_graph();
1301 let root = Path::new("/project");
1302
1303 let trace = trace_export(&graph, root, "src/nope.ts", "foo");
1304 assert!(trace.is_none());
1305 }
1306
1307 #[test]
1308 fn trace_file_edges() {
1309 let graph = build_test_graph();
1310 let root = Path::new("/project");
1311
1312 let trace = trace_file(&graph, root, "src/entry.ts").unwrap();
1313 assert!(trace.is_entry_point);
1314 assert!(trace.is_reachable);
1315 assert_eq!(trace.imports_from.len(), 1);
1316 assert_eq!(trace.imports_from[0], PathBuf::from("src/utils.ts"));
1317 assert!(trace.imported_by.is_empty());
1318 }
1319
1320 #[test]
1321 fn trace_file_imported_by() {
1322 let graph = build_test_graph();
1323 let root = Path::new("/project");
1324
1325 let trace = trace_file(&graph, root, "src/utils.ts").unwrap();
1326 assert!(!trace.is_entry_point);
1327 assert!(trace.is_reachable);
1328 assert_eq!(trace.exports.len(), 2);
1329 assert_eq!(trace.imported_by.len(), 1);
1330 assert_eq!(trace.imported_by[0], PathBuf::from("src/entry.ts"));
1331 }
1332
1333 #[test]
1334 fn trace_unreachable_file() {
1335 let graph = build_test_graph();
1336 let root = Path::new("/project");
1337
1338 let trace = trace_file(&graph, root, "src/unused.ts").unwrap();
1339 assert!(!trace.is_reachable);
1340 assert!(!trace.is_entry_point);
1341 assert!(trace.imported_by.is_empty());
1342 }
1343
1344 #[test]
1345 fn trace_dependency_used() {
1346 let files = vec![DiscoveredFile {
1347 id: FileId(0),
1348 path: PathBuf::from("/project/src/app.ts"),
1349 size_bytes: 100,
1350 }];
1351 let entry_points = vec![EntryPoint {
1352 path: PathBuf::from("/project/src/app.ts"),
1353 source: EntryPointSource::PackageJsonMain,
1354 }];
1355 let resolved_modules = vec![ResolvedModule {
1356 file_id: FileId(0),
1357 path: PathBuf::from("/project/src/app.ts"),
1358 resolved_imports: vec![ResolvedImport {
1359 info: ImportInfo {
1360 source: "lodash".to_string(),
1361 imported_name: ImportedName::Named("get".to_string()),
1362 local_name: "get".to_string(),
1363 is_type_only: false,
1364 from_style: false,
1365 span: oxc_span::Span::new(0, 10),
1366 source_span: oxc_span::Span::default(),
1367 },
1368 target: ResolveResult::NpmPackage("lodash".to_string()),
1369 }],
1370 ..Default::default()
1371 }];
1372
1373 let graph = ModuleGraph::build(&resolved_modules, &entry_points, &files);
1374 let root = Path::new("/project");
1375
1376 let trace = trace_dependency(&graph, root, "lodash", &FxHashSet::default());
1377 assert!(trace.is_used);
1378 assert!(!trace.used_in_scripts);
1379 assert_eq!(trace.import_count, 1);
1380 assert_eq!(trace.imported_by[0], PathBuf::from("src/app.ts"));
1381 }
1382
1383 #[test]
1384 fn trace_dependency_unused() {
1385 let files = vec![DiscoveredFile {
1386 id: FileId(0),
1387 path: PathBuf::from("/project/src/app.ts"),
1388 size_bytes: 100,
1389 }];
1390 let entry_points = vec![EntryPoint {
1391 path: PathBuf::from("/project/src/app.ts"),
1392 source: EntryPointSource::PackageJsonMain,
1393 }];
1394 let resolved_modules = vec![ResolvedModule {
1395 file_id: FileId(0),
1396 path: PathBuf::from("/project/src/app.ts"),
1397 ..Default::default()
1398 }];
1399
1400 let graph = ModuleGraph::build(&resolved_modules, &entry_points, &files);
1401 let root = Path::new("/project");
1402
1403 let trace = trace_dependency(&graph, root, "nonexistent-pkg", &FxHashSet::default());
1404 assert!(!trace.is_used);
1405 assert!(!trace.used_in_scripts);
1406 assert_eq!(trace.import_count, 0);
1407 assert!(trace.imported_by.is_empty());
1408 }
1409
1410 #[test]
1411 fn trace_dependency_used_only_in_scripts() {
1412 let files = vec![DiscoveredFile {
1413 id: FileId(0),
1414 path: PathBuf::from("/project/src/app.ts"),
1415 size_bytes: 100,
1416 }];
1417 let entry_points = vec![EntryPoint {
1418 path: PathBuf::from("/project/src/app.ts"),
1419 source: EntryPointSource::PackageJsonMain,
1420 }];
1421 let resolved_modules = vec![ResolvedModule {
1422 file_id: FileId(0),
1423 path: PathBuf::from("/project/src/app.ts"),
1424 ..Default::default()
1425 }];
1426
1427 let graph = ModuleGraph::build(&resolved_modules, &entry_points, &files);
1428 let root = Path::new("/project");
1429 let mut script_used = FxHashSet::default();
1430 script_used.insert("microbundle".to_string());
1431
1432 let trace = trace_dependency(&graph, root, "microbundle", &script_used);
1433 assert!(
1434 trace.is_used,
1435 "is_used must be true when the package is referenced from package.json scripts"
1436 );
1437 assert!(trace.used_in_scripts);
1438 assert_eq!(trace.import_count, 0);
1439 assert!(trace.imported_by.is_empty());
1440 }
1441
1442 #[test]
1443 fn trace_clone_finds_matching_group() {
1444 use crate::duplicates::{CloneGroup, CloneInstance, DuplicationReport, DuplicationStats};
1445 let report = DuplicationReport {
1446 clone_groups: vec![CloneGroup {
1447 instances: vec![
1448 CloneInstance {
1449 file: PathBuf::from("/project/src/a.ts"),
1450 start_line: 10,
1451 end_line: 20,
1452 start_col: 0,
1453 end_col: 0,
1454 fragment: "fn foo() {}".to_string(),
1455 },
1456 CloneInstance {
1457 file: PathBuf::from("/project/src/b.ts"),
1458 start_line: 5,
1459 end_line: 15,
1460 start_col: 0,
1461 end_col: 0,
1462 fragment: "fn foo() {}".to_string(),
1463 },
1464 ],
1465 token_count: 60,
1466 line_count: 11,
1467 }],
1468 clone_families: vec![],
1469 mirrored_directories: vec![],
1470 stats: DuplicationStats {
1471 total_files: 2,
1472 files_with_clones: 2,
1473 total_lines: 100,
1474 duplicated_lines: 22,
1475 total_tokens: 200,
1476 duplicated_tokens: 120,
1477 clone_groups: 1,
1478 clone_instances: 2,
1479 duplication_percentage: 22.0,
1480 clone_groups_below_min_occurrences: 0,
1481 },
1482 };
1483 let trace = trace_clone(&report, Path::new("/project"), "src/a.ts", 15);
1484 assert!(trace.matched_instance.is_some());
1485 assert_eq!(trace.clone_groups.len(), 1);
1486 assert_eq!(trace.clone_groups[0].instances.len(), 2);
1487 assert!(trace.clone_groups[0].fingerprint.starts_with("dup:"));
1488 assert_eq!(trace.clone_groups[0].suggestion.estimated_savings, 11);
1489 }
1490
1491 #[test]
1492 fn trace_clone_by_fingerprint_resolves_and_misses() {
1493 use crate::duplicates::{
1494 CloneGroup, CloneInstance, DuplicationReport, DuplicationStats, clone_fingerprint,
1495 };
1496 let report = DuplicationReport {
1497 clone_groups: vec![CloneGroup {
1498 instances: vec![
1499 CloneInstance {
1500 file: PathBuf::from("/project/src/a.ts"),
1501 start_line: 10,
1502 end_line: 20,
1503 start_col: 0,
1504 end_col: 0,
1505 fragment: "fn buildInvoice() {}".to_string(),
1506 },
1507 CloneInstance {
1508 file: PathBuf::from("/project/src/b.ts"),
1509 start_line: 5,
1510 end_line: 15,
1511 start_col: 0,
1512 end_col: 0,
1513 fragment: "fn buildInvoice() {}".to_string(),
1514 },
1515 ],
1516 token_count: 60,
1517 line_count: 11,
1518 }],
1519 clone_families: vec![],
1520 mirrored_directories: vec![],
1521 stats: DuplicationStats::default(),
1522 };
1523 let fp = clone_fingerprint(&report.clone_groups[0].instances);
1524
1525 let hit = trace_clone_by_fingerprint(&report, Path::new("/project"), &fp);
1526 assert!(hit.matched_instance.is_some());
1527 assert_eq!(hit.clone_groups.len(), 1);
1528 assert_eq!(hit.clone_groups[0].fingerprint, fp);
1529 assert_eq!(hit.line, 10);
1530
1531 let miss = trace_clone_by_fingerprint(&report, Path::new("/project"), "dup:deadbeef");
1532 assert!(miss.matched_instance.is_none());
1533 assert!(miss.clone_groups.is_empty());
1534 }
1535
1536 #[test]
1537 fn trace_clone_no_match() {
1538 use crate::duplicates::{CloneGroup, CloneInstance, DuplicationReport, DuplicationStats};
1539 let report = DuplicationReport {
1540 clone_groups: vec![CloneGroup {
1541 instances: vec![CloneInstance {
1542 file: PathBuf::from("/project/src/a.ts"),
1543 start_line: 10,
1544 end_line: 20,
1545 start_col: 0,
1546 end_col: 0,
1547 fragment: "fn foo() {}".to_string(),
1548 }],
1549 token_count: 60,
1550 line_count: 11,
1551 }],
1552 clone_families: vec![],
1553 mirrored_directories: vec![],
1554 stats: DuplicationStats {
1555 total_files: 1,
1556 files_with_clones: 1,
1557 total_lines: 50,
1558 duplicated_lines: 11,
1559 total_tokens: 100,
1560 duplicated_tokens: 60,
1561 clone_groups: 1,
1562 clone_instances: 1,
1563 duplication_percentage: 22.0,
1564 clone_groups_below_min_occurrences: 0,
1565 },
1566 };
1567 let trace = trace_clone(&report, Path::new("/project"), "src/a.ts", 25);
1568 assert!(trace.matched_instance.is_none());
1569 assert!(trace.clone_groups.is_empty());
1570 }
1571
1572 #[test]
1573 fn trace_clone_line_boundary() {
1574 use crate::duplicates::{CloneGroup, CloneInstance, DuplicationReport, DuplicationStats};
1575 let report = DuplicationReport {
1576 clone_groups: vec![CloneGroup {
1577 instances: vec![
1578 CloneInstance {
1579 file: PathBuf::from("/project/src/a.ts"),
1580 start_line: 10,
1581 end_line: 20,
1582 start_col: 0,
1583 end_col: 0,
1584 fragment: "code".to_string(),
1585 },
1586 CloneInstance {
1587 file: PathBuf::from("/project/src/b.ts"),
1588 start_line: 1,
1589 end_line: 11,
1590 start_col: 0,
1591 end_col: 0,
1592 fragment: "code".to_string(),
1593 },
1594 ],
1595 token_count: 50,
1596 line_count: 11,
1597 }],
1598 clone_families: vec![],
1599 mirrored_directories: vec![],
1600 stats: DuplicationStats {
1601 total_files: 2,
1602 files_with_clones: 2,
1603 total_lines: 100,
1604 duplicated_lines: 22,
1605 total_tokens: 200,
1606 duplicated_tokens: 100,
1607 clone_groups: 1,
1608 clone_instances: 2,
1609 duplication_percentage: 22.0,
1610 clone_groups_below_min_occurrences: 0,
1611 },
1612 };
1613 let root = Path::new("/project");
1614 assert!(
1615 trace_clone(&report, root, "src/a.ts", 10)
1616 .matched_instance
1617 .is_some()
1618 );
1619 assert!(
1620 trace_clone(&report, root, "src/a.ts", 20)
1621 .matched_instance
1622 .is_some()
1623 );
1624 assert!(
1625 trace_clone(&report, root, "src/a.ts", 21)
1626 .matched_instance
1627 .is_none()
1628 );
1629 }
1630
1631 #[test]
1632 fn trace_clone_returns_relative_instance_paths() {
1633 use crate::duplicates::{CloneGroup, CloneInstance, DuplicationReport, DuplicationStats};
1634 let report = DuplicationReport {
1635 clone_groups: vec![CloneGroup {
1636 instances: vec![
1637 CloneInstance {
1638 file: PathBuf::from("/project/src/a.ts"),
1639 start_line: 1,
1640 end_line: 10,
1641 start_col: 0,
1642 end_col: 0,
1643 fragment: "code".to_string(),
1644 },
1645 CloneInstance {
1646 file: PathBuf::from("/project/src/b.ts"),
1647 start_line: 1,
1648 end_line: 10,
1649 start_col: 0,
1650 end_col: 0,
1651 fragment: "code".to_string(),
1652 },
1653 ],
1654 token_count: 50,
1655 line_count: 10,
1656 }],
1657 clone_families: vec![],
1658 mirrored_directories: vec![],
1659 stats: DuplicationStats {
1660 total_files: 2,
1661 files_with_clones: 2,
1662 total_lines: 50,
1663 duplicated_lines: 20,
1664 total_tokens: 100,
1665 duplicated_tokens: 100,
1666 clone_groups: 1,
1667 clone_instances: 2,
1668 duplication_percentage: 40.0,
1669 clone_groups_below_min_occurrences: 0,
1670 },
1671 };
1672 let trace = trace_clone(&report, Path::new("/project"), "src/a.ts", 5);
1673 let matched = trace.matched_instance.as_ref().expect("match expected");
1674 assert_eq!(matched.file, PathBuf::from("src/a.ts"));
1675 for group in &trace.clone_groups {
1676 for inst in &group.instances {
1677 let as_str = inst.file.to_string_lossy();
1678 assert!(
1679 !as_str.starts_with('/'),
1680 "instance file should be relative, got {as_str}",
1681 );
1682 assert!(
1683 !as_str.contains(":\\") && !as_str.contains(":/"),
1684 "instance file should not have a drive letter, got {as_str}",
1685 );
1686 }
1687 }
1688
1689 let json = serde_json::to_string(&trace).expect("serializes");
1690 assert!(
1691 !json.contains("\"/project/"),
1692 "serialized trace should not leak absolute paths: {json}",
1693 );
1694 }
1695
1696 #[test]
1703 fn path_matches_normalises_windows_module_path_against_posix_user_path() {
1704 let root = Path::new(r"D:\a\fallow\fallow\tests\fixtures\basic-project");
1705 let module_path =
1706 PathBuf::from(r"D:\a\fallow\fallow\tests\fixtures\basic-project\src\utils.ts");
1707 assert!(path_matches(&module_path, root, "src/utils.ts"));
1708 assert!(path_matches(&module_path, root, r"src\utils.ts"));
1709 }
1710
1711 #[test]
1712 fn path_matches_ends_with_fallback_handles_mixed_separators() {
1713 let root = Path::new("/some/other/root");
1714 let module_path =
1715 PathBuf::from(r"D:\a\fallow\fallow\tests\fixtures\basic-project\src\utils.ts");
1716 assert!(path_matches(&module_path, root, "src/utils.ts"));
1717 }
1718
1719 #[test]
1728 fn export_trace_serializes_windows_path_with_forward_slashes() {
1729 let trace = ExportTrace {
1730 file: PathBuf::from(r"src\utils.ts"),
1731 export_name: "foo".to_string(),
1732 file_reachable: true,
1733 is_entry_point: false,
1734 is_used: true,
1735 direct_references: vec![ExportReference {
1736 from_file: PathBuf::from(r"src\entry.ts"),
1737 kind: "named import".to_string(),
1738 }],
1739 re_export_chains: vec![ReExportChain {
1740 barrel_file: PathBuf::from(r"src\index.ts"),
1741 exported_as: "foo".to_string(),
1742 reference_count: 1,
1743 }],
1744 reason: "ok".to_string(),
1745 semantic: None,
1746 };
1747 let json = serde_json::to_string(&trace).expect("serializes");
1748 assert!(
1749 json.contains("\"file\":\"src/utils.ts\""),
1750 "ExportTrace.file must serialize with forward slashes: {json}"
1751 );
1752 assert!(
1753 json.contains("\"from_file\":\"src/entry.ts\""),
1754 "ExportReference.from_file must serialize with forward slashes: {json}"
1755 );
1756 assert!(
1757 json.contains("\"barrel_file\":\"src/index.ts\""),
1758 "ReExportChain.barrel_file must serialize with forward slashes: {json}"
1759 );
1760 assert!(
1761 !json.contains(r"\\"),
1762 "no backslash sequence should remain anywhere in the JSON: {json}"
1763 );
1764 }
1765
1766 #[test]
1767 fn file_trace_serializes_windows_paths_with_forward_slashes() {
1768 let trace = FileTrace {
1769 file: PathBuf::from(r"src\utils.ts"),
1770 is_reachable: true,
1771 is_entry_point: false,
1772 exports: vec![],
1773 imports_from: vec![PathBuf::from(r"src\helpers.ts")],
1774 imported_by: vec![PathBuf::from(r"src\entry.ts")],
1775 re_exports: vec![TracedReExport {
1776 source_file: PathBuf::from(r"src\source.ts"),
1777 imported_name: "foo".to_string(),
1778 exported_name: "foo".to_string(),
1779 }],
1780 };
1781 let json = serde_json::to_string(&trace).expect("serializes");
1782 assert!(json.contains("\"file\":\"src/utils.ts\""), "got {json}");
1783 assert!(
1784 json.contains("\"imports_from\":[\"src/helpers.ts\"]"),
1785 "got {json}"
1786 );
1787 assert!(
1788 json.contains("\"imported_by\":[\"src/entry.ts\"]"),
1789 "got {json}"
1790 );
1791 assert!(
1792 json.contains("\"source_file\":\"src/source.ts\""),
1793 "got {json}"
1794 );
1795 assert!(!json.contains(r"\\"), "no backslash should remain: {json}");
1796 }
1797}