Skip to main content

fallow_engine/
trace_impl.rs

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::{EffectiveExportResolution, ExportNamespace, ModuleGraph, ReferenceKind};
15
16/// Match a user-provided file path against a module's actual path.
17///
18/// Handles monorepo scenarios where module paths may be canonicalized
19/// (symlinks resolved) while user-provided paths are not.
20fn 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
38/// Map a reference's `from_file` id to a root-relative [`ExportReference`].
39fn 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
54/// Project graph-owned effective routes into the public trace contract.
55fn collect_re_export_chains(
56    graph: &ModuleGraph,
57    root: &Path,
58    target_file_id: crate::discover::FileId,
59    export_name: &str,
60    namespace: ExportNamespace,
61) -> Vec<ReExportChain> {
62    graph
63        .effective_re_export_routes(target_file_id, export_name, namespace)
64        .into_iter()
65        .filter_map(|route| {
66            let module = graph.modules.get(route.barrel_file().0 as usize)?;
67            Some(ReExportChain {
68                barrel_file: module
69                    .path
70                    .strip_prefix(root)
71                    .unwrap_or(&module.path)
72                    .to_path_buf(),
73                exported_as: route.exported_name().to_string(),
74                reference_count: graph
75                    .effective_export_surface_references(
76                        route.barrel_file(),
77                        route.exported_name(),
78                        namespace,
79                    )
80                    .len(),
81            })
82        })
83        .collect()
84}
85
86/// Build the human-readable reason string explaining an export's used/unused state.
87fn export_trace_reason(
88    module: &crate::graph::ModuleNode,
89    reference_count: usize,
90    is_used: bool,
91    re_export_chains: &[ReExportChain],
92) -> String {
93    if !module.is_reachable() {
94        "File is unreachable from any entry point".to_string()
95    } else if is_used {
96        format!(
97            "Used by {} file(s){}",
98            reference_count,
99            if re_export_chains.is_empty() {
100                String::new()
101            } else {
102                format!(", re-exported through {} barrel(s)", re_export_chains.len())
103            }
104        )
105    } else if module.is_entry_point() {
106        "No internal references, but file is an entry point (export is externally accessible)"
107            .to_string()
108    } else if !re_export_chains.is_empty() {
109        format!(
110            "Re-exported through {} barrel(s) but no consumer imports it through the barrel",
111            re_export_chains.len()
112        )
113    } else {
114        "No references found, export is unused".to_string()
115    }
116}
117
118/// Trace why an export is considered used or unused.
119#[must_use]
120pub fn trace_export(
121    graph: &ModuleGraph,
122    root: &Path,
123    file_path: &str,
124    export_name: &str,
125) -> Option<ExportTrace> {
126    let module = graph
127        .modules
128        .iter()
129        .find(|m| path_matches(&m.path, root, file_path))?;
130
131    let surface = select_export(graph, module, export_name)?;
132    let namespace = surface.namespace();
133
134    let mut referenced_files = FxHashSet::default();
135    let direct_references: Vec<ExportReference> = graph
136        .effective_export_surface_references(module.file_id, export_name, namespace)
137        .into_iter()
138        .filter(|reference| referenced_files.insert(reference.from_file))
139        .map(|r| reference_to_export_reference(graph, root, r))
140        .collect();
141
142    let re_export_chains =
143        collect_re_export_chains(graph, root, module.file_id, export_name, namespace);
144
145    let reference_count = direct_references.len();
146    let is_used = reference_count > 0;
147    let reason = export_trace_reason(module, reference_count, is_used, &re_export_chains);
148
149    Some(ExportTrace {
150        file: module
151            .path
152            .strip_prefix(root)
153            .unwrap_or(&module.path)
154            .to_path_buf(),
155        export_name: export_name.to_string(),
156        namespace: match namespace {
157            ExportNamespace::Type => fallow_types::semantic::SemanticNamespace::Type,
158            ExportNamespace::Value => fallow_types::semantic::SemanticNamespace::Value,
159        },
160        file_reachable: module.is_reachable(),
161        is_entry_point: module.is_entry_point(),
162        is_used,
163        direct_references,
164        re_export_chains,
165        reason,
166        semantic: None,
167    })
168}
169
170/// Resolve the exact source identity required by the semantic sidecar for a
171/// graph export. This does not perform semantic analysis itself.
172#[must_use]
173pub fn semantic_symbol_for_export(
174    graph: &ModuleGraph,
175    root: &Path,
176    file_path: &str,
177    export_name: &str,
178) -> Option<fallow_types::semantic::SemanticSymbol> {
179    use fallow_types::semantic::{SemanticNamespace, SemanticSymbol};
180
181    let module = graph
182        .modules
183        .iter()
184        .find(|module| path_matches(&module.path, root, file_path))?;
185    let surface = select_export(graph, module, export_name)?;
186    let namespace = surface.namespace();
187    let (identity_module, span, identity_exported_name, local_name) = if let Some(re_export) =
188        graph.effective_export_surface_re_export(module.file_id, export_name, namespace)
189    {
190        let local_name = if re_export.imported_name == "*" {
191            export_name
192        } else {
193            re_export.imported_name.as_str()
194        };
195        (module, re_export.span, export_name, local_name)
196    } else {
197        let origin = surface.origin()?;
198        let origin_module = graph.modules.get(origin.file_id().0 as usize)?;
199        let origin_export = origin.export();
200        let origin_name = match &origin_export.name {
201            fallow_types::extract::ExportName::Named(name) => name.as_str(),
202            fallow_types::extract::ExportName::Default => "default",
203        };
204        (origin_module, origin_export.span, origin_name, origin_name)
205    };
206    let source = std::fs::read_to_string(&identity_module.path).ok()?;
207    let offsets = fallow_types::extract::compute_line_offsets(&source);
208    let (line, col) = fallow_types::extract::byte_offset_to_line_col(&offsets, span.start);
209    Some(SemanticSymbol {
210        path: identity_module
211            .path
212            .strip_prefix(root)
213            .unwrap_or(&identity_module.path)
214            .to_path_buf(),
215        namespace: match namespace {
216            ExportNamespace::Type => SemanticNamespace::Type,
217            ExportNamespace::Value => SemanticNamespace::Value,
218        },
219        declaration_kind: "export".to_string(),
220        exported_name: identity_exported_name.to_string(),
221        local_name: local_name.to_string(),
222        owner: None,
223        line,
224        col,
225    })
226}
227
228/// Resolve the source identity for a public class member semantic query.
229#[must_use]
230pub fn semantic_symbol_for_class_member(
231    graph: &ModuleGraph,
232    root: &Path,
233    file_path: &str,
234    member_name: &str,
235) -> Option<fallow_types::semantic::SemanticSymbol> {
236    use fallow_types::extract::MemberKind;
237    use fallow_types::semantic::{SemanticNamespace, SemanticSymbol};
238
239    let module = graph
240        .modules
241        .iter()
242        .find(|module| path_matches(&module.path, root, file_path))?;
243    let (owner, member) = module
244        .exports
245        .iter()
246        .filter_map(|export| {
247            export
248                .members
249                .iter()
250                .find(|member| member.name == member_name)
251                .map(|member| (export, member))
252        })
253        .max_by_key(|(export, _)| (!export.references.is_empty(), !export.is_type_only))?;
254    let declaration_kind = match member.kind {
255        MemberKind::ClassMethod => "class_method",
256        MemberKind::ClassProperty => "class_property",
257        _ => return None,
258    };
259    let source = std::fs::read_to_string(&module.path).ok()?;
260    let offsets = fallow_types::extract::compute_line_offsets(&source);
261    let (line, col) = fallow_types::extract::byte_offset_to_line_col(&offsets, member.span.start);
262    Some(SemanticSymbol {
263        path: module
264            .path
265            .strip_prefix(root)
266            .unwrap_or(&module.path)
267            .to_path_buf(),
268        namespace: SemanticNamespace::Value,
269        declaration_kind: declaration_kind.to_string(),
270        exported_name: member_name.to_string(),
271        local_name: member_name.to_string(),
272        owner: Some(owner.name.to_string()),
273        line,
274        col,
275    })
276}
277
278/// Stable reason why an exact class-method target cannot be resolved.
279#[derive(Debug, Clone, Copy, PartialEq, Eq)]
280pub enum SemanticClassMethodResolutionError {
281    /// The requested file is not part of the retained module graph.
282    FileNotFound,
283    /// The requested owner or method does not exist in the file.
284    SymbolNotFound,
285    /// More than one declaration matches the exact owner and method.
286    AmbiguousSymbol,
287    /// The matching declaration is not a supported class method.
288    UnsupportedSyntax,
289}
290
291impl std::fmt::Display for SemanticClassMethodResolutionError {
292    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
293        let reason = match self {
294            Self::FileNotFound => "file-not-found",
295            Self::SymbolNotFound => "unknown-symbol",
296            Self::AmbiguousSymbol => "ambiguous-symbol",
297            Self::UnsupportedSyntax => "unsupported-syntax",
298        };
299        formatter.write_str(reason)
300    }
301}
302
303/// Resolve one exact exported class method without a name-based fallback.
304pub fn semantic_symbol_for_exact_class_method(
305    graph: &ModuleGraph,
306    root: &Path,
307    file_path: &str,
308    owner_name: &str,
309    member_name: &str,
310) -> Result<fallow_types::semantic::SemanticSymbol, SemanticClassMethodResolutionError> {
311    use fallow_types::extract::MemberKind;
312    use fallow_types::semantic::{SemanticNamespace, SemanticSymbol};
313
314    let module = graph
315        .modules
316        .iter()
317        .find(|module| path_matches(&module.path, root, file_path))
318        .ok_or(SemanticClassMethodResolutionError::FileNotFound)?;
319    let owners = module
320        .exports
321        .iter()
322        .filter(|export| export.name.matches_str(owner_name))
323        .collect::<Vec<_>>();
324    if owners.len() != 1 {
325        return Err(if owners.is_empty() {
326            SemanticClassMethodResolutionError::SymbolNotFound
327        } else {
328            SemanticClassMethodResolutionError::AmbiguousSymbol
329        });
330    }
331    let owner = owners[0];
332    let members = owner
333        .members
334        .iter()
335        .filter(|member| member.name == member_name)
336        .collect::<Vec<_>>();
337    if members.len() != 1 {
338        return Err(if members.is_empty() {
339            SemanticClassMethodResolutionError::SymbolNotFound
340        } else {
341            SemanticClassMethodResolutionError::AmbiguousSymbol
342        });
343    }
344    let member = members[0];
345    if member.kind != MemberKind::ClassMethod {
346        return Err(SemanticClassMethodResolutionError::UnsupportedSyntax);
347    }
348    let source = std::fs::read_to_string(&module.path)
349        .map_err(|_| SemanticClassMethodResolutionError::SymbolNotFound)?;
350    let offsets = fallow_types::extract::compute_line_offsets(&source);
351    let (line, col) = fallow_types::extract::byte_offset_to_line_col(&offsets, member.span.start);
352    Ok(SemanticSymbol {
353        path: module
354            .path
355            .strip_prefix(root)
356            .unwrap_or(&module.path)
357            .to_path_buf(),
358        namespace: SemanticNamespace::Value,
359        declaration_kind: "class_method".to_string(),
360        exported_name: member_name.to_string(),
361        local_name: member_name.to_string(),
362        owner: Some(owner_name.to_string()),
363        line,
364        col,
365    })
366}
367
368/// Trace a class / enum / store MEMBER when `--trace FILE:NAME`'s `NAME` is not
369/// a top-level export but a member declared on one (issue #1744). Runs on the
370/// graph only, so it reports the OWNING export's reachability and usage (the
371/// gating precondition for member crediting) plus a pointer to the right
372/// `--unused-*-members` command, not per-member crediting provenance.
373#[must_use]
374pub fn trace_class_member(
375    graph: &ModuleGraph,
376    root: &Path,
377    file_path: &str,
378    member_name: &str,
379) -> Option<ClassMemberTrace> {
380    use fallow_types::extract::MemberKind;
381
382    let module = graph
383        .modules
384        .iter()
385        .find(|m| path_matches(&m.path, root, file_path))?;
386
387    // Find the export that declares this member. When several declare a member
388    // of the same name (rare), prefer a used, non-type-only owner so the trace
389    // reports the reachable one.
390    let (owner, member_kind) = module
391        .exports
392        .iter()
393        .filter_map(|export| {
394            export
395                .members
396                .iter()
397                .find(|member| member.name == member_name)
398                .map(|member| (export, member.kind))
399        })
400        .max_by_key(|(export, _)| (!export.references.is_empty(), !export.is_type_only))?;
401
402    let owner_name = owner.name.to_string();
403    // Reuse the export trace to compute the owner's reachability / usage /
404    // references consistently with a plain `--trace FILE:OWNER`. The `?` here is
405    // a belt-and-suspenders guard: `owner` was just located in this module's
406    // `exports`, so `trace_export` resolves it in practice; the fallthrough to
407    // `None` (and the caller's "not found" error) is unreachable barring a graph
408    // inconsistency.
409    let owner_trace = trace_export(graph, root, file_path, &owner_name)?;
410
411    let (kind_str, filter_flag) = match member_kind {
412        MemberKind::ClassMethod => ("class-method", Some("--unused-class-members")),
413        MemberKind::ClassProperty => ("class-property", Some("--unused-class-members")),
414        MemberKind::EnumMember => ("enum-member", Some("--unused-enum-members")),
415        MemberKind::StoreMember => ("store-member", Some("--unused-store-members")),
416        MemberKind::NamespaceMember => ("namespace-member", None),
417    };
418
419    let reason = class_member_trace_reason(
420        member_name,
421        &owner_name,
422        kind_str,
423        filter_flag,
424        file_path,
425        &owner_trace,
426    );
427
428    Some(ClassMemberTrace {
429        file: owner_trace.file,
430        member_name: member_name.to_string(),
431        member_kind: kind_str.to_string(),
432        owner_export: owner_name,
433        owner_is_used: owner_trace.is_used,
434        owner_file_reachable: owner_trace.file_reachable,
435        owner_is_entry_point: owner_trace.is_entry_point,
436        owner_direct_references: owner_trace.direct_references,
437        owner_re_export_chains: owner_trace.re_export_chains,
438        reason,
439        semantic: None,
440    })
441}
442
443/// Build the human-readable reason for a class-member trace, keyed on the
444/// owner's reachability / usage (the precondition that gates member crediting).
445fn class_member_trace_reason(
446    member_name: &str,
447    owner_name: &str,
448    kind_str: &str,
449    filter_flag: Option<&str>,
450    file_path: &str,
451    owner_trace: &ExportTrace,
452) -> String {
453    let head =
454        format!("'{member_name}' is a {kind_str} of '{owner_name}', not a top-level export. ");
455    let body = if !owner_trace.file_reachable {
456        format!(
457            "The file is not reachable from any entry point, so '{owner_name}' and all its \
458             members are dead (see the unused-file finding)."
459        )
460    } else if !owner_trace.is_used {
461        format!(
462            "'{owner_name}' is reachable but referenced by no file, so it is reported as an \
463             unused export and its members are not judged individually."
464        )
465    } else {
466        let refs = owner_trace.direct_references.len();
467        match filter_flag {
468            Some(flag) => format!(
469                "'{owner_name}' is used by {refs} file(s); whether '{member_name}' itself is \
470                 flagged depends on cross-file member-access resolution. Run \
471                 `fallow dead-code {flag} --file {file_path}` to see the member finding."
472            ),
473            None => format!(
474                "'{owner_name}' is used by {refs} file(s); '{member_name}' is credited through \
475                 its namespace export."
476            ),
477        }
478    };
479    format!("{head}{body}")
480}
481
482fn select_export<'graph>(
483    graph: &'graph ModuleGraph,
484    module: &'graph crate::graph::ModuleNode,
485    export_name: &str,
486) -> Option<crate::graph::EffectiveExportSurface<'graph>> {
487    [ExportNamespace::Value, ExportNamespace::Type]
488        .into_iter()
489        .find_map(|namespace| {
490            graph.effective_export_surface(module.file_id, export_name, namespace)
491        })
492}
493
494/// Map a module's exports to [`TracedExport`] entries with relativized references.
495fn traced_exports(
496    graph: &ModuleGraph,
497    root: &Path,
498    module: &crate::graph::ModuleNode,
499) -> Vec<TracedExport> {
500    module
501        .exports
502        .iter()
503        .map(|e| {
504            let referenced_by: Vec<_> = e
505                .physical_references()
506                .map(|r| reference_to_export_reference(graph, root, r))
507                .collect();
508            TracedExport {
509                name: e.name.to_string(),
510                is_type_only: e.is_type_only,
511                reference_count: referenced_by.len(),
512                referenced_by,
513            }
514        })
515        .collect()
516}
517
518/// Collect the root-relative paths a file imports from (forward graph edges).
519fn traced_imports_from(
520    graph: &ModuleGraph,
521    root: &Path,
522    module: &crate::graph::ModuleNode,
523) -> Vec<PathBuf> {
524    graph
525        .edges_for(module.file_id)
526        .iter()
527        .filter_map(|target_id| {
528            graph
529                .modules
530                .get(target_id.0 as usize)
531                .map(|m| m.path.strip_prefix(root).unwrap_or(&m.path).to_path_buf())
532        })
533        .collect()
534}
535
536/// Collect the root-relative paths that import a file (reverse graph edges).
537fn traced_imported_by(
538    graph: &ModuleGraph,
539    root: &Path,
540    module: &crate::graph::ModuleNode,
541) -> Vec<PathBuf> {
542    graph
543        .reverse_deps
544        .get(module.file_id.0 as usize)
545        .map(|deps| {
546            deps.iter()
547                .filter_map(|fid| {
548                    graph
549                        .modules
550                        .get(fid.0 as usize)
551                        .map(|m| m.path.strip_prefix(root).unwrap_or(&m.path).to_path_buf())
552                })
553                .collect()
554        })
555        .unwrap_or_default()
556}
557
558/// Map a module's re-exports to [`TracedReExport`] entries with relativized source paths.
559fn traced_re_exports(
560    graph: &ModuleGraph,
561    root: &Path,
562    module: &crate::graph::ModuleNode,
563) -> Vec<TracedReExport> {
564    module
565        .re_exports
566        .iter()
567        .map(|re| {
568            let source_path = graph.modules.get(re.source_file.0 as usize).map_or_else(
569                || PathBuf::from(format!("<unknown:{}>", re.source_file.0)),
570                |m| m.path.strip_prefix(root).unwrap_or(&m.path).to_path_buf(),
571            );
572            TracedReExport {
573                source_file: source_path,
574                imported_name: re.imported_name.clone(),
575                exported_name: re.exported_name.clone(),
576            }
577        })
578        .collect()
579}
580
581/// Trace all edges for a file.
582#[must_use]
583pub fn trace_file(graph: &ModuleGraph, root: &Path, file_path: &str) -> Option<FileTrace> {
584    let module = graph
585        .modules
586        .iter()
587        .find(|m| path_matches(&m.path, root, file_path))?;
588
589    Some(FileTrace {
590        file: module
591            .path
592            .strip_prefix(root)
593            .unwrap_or(&module.path)
594            .to_path_buf(),
595        is_reachable: module.is_reachable(),
596        is_entry_point: module.is_entry_point(),
597        exports: traced_exports(graph, root, module),
598        imports_from: traced_imports_from(graph, root, module),
599        imported_by: traced_imported_by(graph, root, module),
600        re_exports: traced_re_exports(graph, root, module),
601    })
602}
603
604/// Trace where a dependency is used.
605///
606/// `script_used_packages` carries the package names recorded as binary invocations
607/// in package.json scripts (`build: microbundle ...`) and CI configs
608/// (`.github/workflows/*.yml`, `.gitlab-ci.yml`). The same set the unused-deps
609/// detector consults; passing it in lets the trace output match the detector's
610/// view of "used" instead of reporting `is_used=false` for tools invoked only
611/// through scripts.
612#[must_use]
613pub fn trace_dependency(
614    graph: &ModuleGraph,
615    root: &Path,
616    package_name: &str,
617    script_used_packages: &FxHashSet<String>,
618) -> DependencyTrace {
619    let imported_by: Vec<PathBuf> = graph
620        .package_usage
621        .get(package_name)
622        .map(|ids| {
623            ids.iter()
624                .filter_map(|fid| {
625                    graph
626                        .modules
627                        .get(fid.0 as usize)
628                        .map(|m| m.path.strip_prefix(root).unwrap_or(&m.path).to_path_buf())
629                })
630                .collect()
631        })
632        .unwrap_or_default();
633
634    let type_only_imported_by: Vec<PathBuf> = graph
635        .type_only_package_usage
636        .get(package_name)
637        .map(|ids| {
638            ids.iter()
639                .filter_map(|fid| {
640                    graph
641                        .modules
642                        .get(fid.0 as usize)
643                        .map(|m| m.path.strip_prefix(root).unwrap_or(&m.path).to_path_buf())
644                })
645                .collect()
646        })
647        .unwrap_or_default();
648
649    let import_count = imported_by.len();
650    let used_in_scripts = script_used_packages.contains(package_name);
651    DependencyTrace {
652        package_name: package_name.to_string(),
653        imported_by,
654        type_only_imported_by,
655        used_in_scripts,
656        is_used: import_count > 0 || used_in_scripts,
657        import_count,
658    }
659}
660
661fn format_reference_kind(kind: ReferenceKind) -> String {
662    match kind {
663        ReferenceKind::NamedImport => "named import".to_string(),
664        ReferenceKind::DefaultImport => "default import".to_string(),
665        ReferenceKind::NamespaceImport => "namespace import".to_string(),
666        ReferenceKind::ReExport => "re-export".to_string(),
667        ReferenceKind::DynamicImport => "dynamic import".to_string(),
668        ReferenceKind::SideEffectImport => "side-effect import".to_string(),
669    }
670}
671
672/// Compute the impact closure for a single file as the seed.
673///
674/// Resolves `file_path` to a graph `FileId`, walks `reverse_deps` + re-export
675/// chains to the transitive affected set, and reports the coordination gap (the
676/// seed's exported contracts consumed by modules outside the seed). Returns
677/// `None` when the file is not in the module graph.
678#[must_use]
679pub fn trace_impact_closure(
680    graph: &ModuleGraph,
681    root: &Path,
682    file_path: &str,
683) -> Option<ImpactClosureTrace> {
684    let module = graph
685        .modules
686        .iter()
687        .find(|m| path_matches(&m.path, root, file_path))?;
688
689    let closure = graph.impact_closure(&[module.file_id]);
690    let paths = graph.closure_with_paths(&closure, root);
691
692    let seed = paths
693        .in_diff
694        .first()
695        .cloned()
696        .unwrap_or_else(|| file_path.replace('\\', "/"));
697
698    let coordination_gap = paths
699        .coordination_gap
700        .into_iter()
701        .map(|gap| ImpactClosureGap {
702            consumer_file: gap.consumer_file,
703            consumed_symbols: gap.consumed_symbols,
704            note: "syntactic attention pointer, not a correctness proof".to_string(),
705        })
706        .collect();
707
708    Some(ImpactClosureTrace {
709        seed,
710        affected_not_shown: paths.affected_not_shown,
711        coordination_gap,
712    })
713}
714
715/// Build a [`TracedCloneGroup`] from a raw clone group, computing the
716/// fingerprint, group-level suggestion, and dominant-identifier name and
717/// relativizing every instance path against `root`.
718fn build_traced_group(
719    group: &CloneGroup,
720    root: &Path,
721    fingerprints: &CloneFingerprintSet,
722) -> TracedCloneGroup {
723    TracedCloneGroup {
724        fingerprint: fingerprints.fingerprint_for_group(group),
725        token_count: group.token_count,
726        line_count: group.line_count,
727        spread: group.spread(),
728        similarity: group.similarity,
729        instances: group
730            .instances
731            .iter()
732            .map(|inst| relativize_instance(inst, root))
733            .collect(),
734        suggestion: group_refactoring_suggestion(group),
735        suggested_name: dominant_identifier(group),
736    }
737}
738
739#[must_use]
740pub fn trace_clone(
741    report: &DuplicationReport,
742    root: &Path,
743    file_path: &str,
744    line: usize,
745) -> CloneTrace {
746    let resolved = root.join(file_path);
747    let mut matched_instance = None;
748    let mut clone_groups = Vec::new();
749    let fingerprints = CloneFingerprintSet::from_groups(&report.clone_groups);
750
751    for group in &report.clone_groups {
752        let matching = group.instances.iter().find(|inst| {
753            let inst_matches = inst.file == resolved
754                || inst.file.strip_prefix(root).unwrap_or(&inst.file) == Path::new(file_path);
755            inst_matches && inst.start_line <= line && line <= inst.end_line
756        });
757
758        if let Some(matched) = matching {
759            if matched_instance.is_none() {
760                matched_instance = Some(relativize_instance(matched, root));
761            }
762            clone_groups.push(build_traced_group(group, root, &fingerprints));
763        }
764    }
765
766    CloneTrace {
767        file: PathBuf::from(file_path),
768        line,
769        matched_instance,
770        clone_groups,
771    }
772}
773
774/// Trace a clone group by its stable content fingerprint.
775///
776/// Fingerprints are usually `dup:<8hex>` and widen only when needed to avoid a
777/// collision inside the same report.
778///
779/// Returns a [`CloneTrace`] whose single `clone_groups` entry is the matched
780/// group and whose `file` / `line` / `matched_instance` come from that group's
781/// representative (first) instance. `matched_instance` is `None` (and
782/// `clone_groups` empty) when no group matches the fingerprint.
783#[must_use]
784pub fn trace_clone_by_fingerprint(
785    report: &DuplicationReport,
786    root: &Path,
787    fingerprint: &str,
788) -> CloneTrace {
789    let fingerprints = CloneFingerprintSet::from_groups(&report.clone_groups);
790    let matched = fingerprints.find_group(&report.clone_groups, fingerprint);
791
792    let Some(group) = matched else {
793        return CloneTrace {
794            file: PathBuf::new(),
795            line: 0,
796            matched_instance: None,
797            clone_groups: Vec::new(),
798        };
799    };
800
801    let representative = group
802        .instances
803        .first()
804        .map(|inst| relativize_instance(inst, root));
805    let (file, line) = representative.as_ref().map_or_else(
806        || (PathBuf::new(), 0),
807        |inst| (inst.file.clone(), inst.start_line),
808    );
809
810    CloneTrace {
811        file,
812        line,
813        matched_instance: representative,
814        clone_groups: vec![build_traced_group(group, root, &fingerprints)],
815    }
816}
817
818/// Return a copy of `inst` with `file` rewritten relative to `root` (forward-slash normalized
819/// for cross-platform JSON parity with `serde_path::serialize`). If `inst.file` is already
820/// outside `root`, the path is left unchanged.
821fn relativize_instance(inst: &CloneInstance, root: &Path) -> CloneInstance {
822    let rel = inst.file.strip_prefix(root).map_or_else(
823        |_| inst.file.clone(),
824        |p| PathBuf::from(p.to_string_lossy().replace('\\', "/")),
825    );
826    CloneInstance {
827        file: rel,
828        ..inst.clone()
829    }
830}
831
832#[cfg(test)]
833mod tests {
834    use super::*;
835
836    use crate::discover::{DiscoveredFile, EntryPoint, EntryPointSource, FileId};
837    use crate::extract::{ExportInfo, ExportName, ImportInfo, ImportedName, VisibilityTag};
838    use crate::resolve::{ResolveResult, ResolvedImport, ResolvedModule, ResolvedReExport};
839    use fallow_types::extract::ReExportInfo;
840
841    fn resolved_re_export(
842        source: FileId,
843        imported_name: &str,
844        exported_name: &str,
845    ) -> ResolvedReExport {
846        ResolvedReExport {
847            info: ReExportInfo {
848                source: "./source".to_string(),
849                imported_name: imported_name.to_string(),
850                exported_name: exported_name.to_string(),
851                is_type_only: false,
852                span: oxc_span::Span::default(),
853                statement_span: oxc_span::Span::default(),
854                source_span: oxc_span::Span::default(),
855            },
856            target: ResolveResult::InternalModule(source),
857        }
858    }
859
860    fn build_test_graph() -> ModuleGraph {
861        let files = vec![
862            DiscoveredFile {
863                id: FileId(0),
864                path: PathBuf::from("/project/src/entry.ts"),
865                size_bytes: 100,
866            },
867            DiscoveredFile {
868                id: FileId(1),
869                path: PathBuf::from("/project/src/utils.ts"),
870                size_bytes: 50,
871            },
872            DiscoveredFile {
873                id: FileId(2),
874                path: PathBuf::from("/project/src/unused.ts"),
875                size_bytes: 30,
876            },
877        ];
878
879        let entry_points = vec![EntryPoint {
880            path: PathBuf::from("/project/src/entry.ts"),
881            source: EntryPointSource::PackageJsonMain,
882        }];
883
884        let resolved_modules = vec![
885            ResolvedModule {
886                file_id: FileId(0),
887                path: PathBuf::from("/project/src/entry.ts"),
888                resolved_imports: vec![ResolvedImport {
889                    info: ImportInfo {
890                        source: "./utils".to_string(),
891                        imported_name: ImportedName::Named("foo".to_string()),
892                        local_name: "foo".to_string(),
893                        is_type_only: false,
894                        from_style: false,
895                        span: oxc_span::Span::new(0, 10),
896                        source_span: oxc_span::Span::default(),
897                    },
898                    target: ResolveResult::InternalModule(FileId(1)),
899                }],
900                ..Default::default()
901            },
902            ResolvedModule {
903                file_id: FileId(1),
904                path: PathBuf::from("/project/src/utils.ts"),
905                exports: vec![
906                    ExportInfo {
907                        name: ExportName::Named("foo".to_string()),
908                        local_name: Some("foo".to_string()),
909                        is_type_only: false,
910                        visibility: VisibilityTag::None,
911                        expected_unused_reason: None,
912                        span: oxc_span::Span::new(0, 20),
913                        members: vec![],
914                        is_side_effect_used: false,
915                        super_class: None,
916                    },
917                    ExportInfo {
918                        name: ExportName::Named("bar".to_string()),
919                        local_name: Some("bar".to_string()),
920                        is_type_only: false,
921                        visibility: VisibilityTag::None,
922                        expected_unused_reason: None,
923                        span: oxc_span::Span::new(21, 40),
924                        members: vec![],
925                        is_side_effect_used: false,
926                        super_class: None,
927                    },
928                ]
929                .into(),
930                ..Default::default()
931            },
932            ResolvedModule {
933                file_id: FileId(2),
934                path: PathBuf::from("/project/src/unused.ts"),
935                exports: vec![ExportInfo {
936                    name: ExportName::Named("baz".to_string()),
937                    local_name: Some("baz".to_string()),
938                    is_type_only: false,
939                    visibility: VisibilityTag::None,
940                    expected_unused_reason: None,
941                    span: oxc_span::Span::new(0, 15),
942                    members: vec![],
943                    is_side_effect_used: false,
944                    super_class: None,
945                }]
946                .into(),
947                ..Default::default()
948            },
949        ];
950
951        ModuleGraph::build(&resolved_modules, &entry_points, &files)
952    }
953
954    #[test]
955    fn trace_used_export() {
956        let graph = build_test_graph();
957        let root = Path::new("/project");
958
959        let trace = trace_export(&graph, root, "src/utils.ts", "foo").unwrap();
960        assert!(trace.is_used);
961        assert!(trace.file_reachable);
962        assert_eq!(trace.direct_references.len(), 1);
963        assert_eq!(
964            trace.direct_references[0].from_file,
965            PathBuf::from("src/entry.ts")
966        );
967        assert_eq!(trace.direct_references[0].kind, "named import");
968    }
969
970    #[test]
971    fn trace_unused_export() {
972        let graph = build_test_graph();
973        let root = Path::new("/project");
974
975        let trace = trace_export(&graph, root, "src/utils.ts", "bar").unwrap();
976        assert!(!trace.is_used);
977        assert!(trace.file_reachable);
978        assert!(trace.direct_references.is_empty());
979    }
980
981    #[test]
982    fn trace_unreachable_file_export() {
983        let graph = build_test_graph();
984        let root = Path::new("/project");
985
986        let trace = trace_export(&graph, root, "src/unused.ts", "baz").unwrap();
987        assert!(!trace.is_used);
988        assert!(!trace.file_reachable);
989        assert!(trace.reason.contains("unreachable"));
990    }
991
992    #[test]
993    fn trace_nonexistent_export() {
994        let graph = build_test_graph();
995        let root = Path::new("/project");
996
997        let trace = trace_export(&graph, root, "src/utils.ts", "nonexistent");
998        assert!(trace.is_none());
999    }
1000
1001    #[test]
1002    fn trace_reports_only_the_effective_re_export_origin() {
1003        let files: Vec<_> = ["entry", "barrel", "star-source", "explicit-source"]
1004            .into_iter()
1005            .enumerate()
1006            .map(|(index, name)| DiscoveredFile {
1007                id: FileId(index as u32),
1008                path: PathBuf::from(format!("/project/src/{name}.ts")),
1009                size_bytes: 10,
1010            })
1011            .collect();
1012        let entry_points = vec![EntryPoint {
1013            path: files[0].path.clone(),
1014            source: EntryPointSource::PackageJsonMain,
1015        }];
1016        let re_export = |source: FileId, imported: &str, exported: &str| ResolvedReExport {
1017            info: ReExportInfo {
1018                source: format!("./{}", source.0),
1019                imported_name: imported.to_string(),
1020                exported_name: exported.to_string(),
1021                is_type_only: false,
1022                span: oxc_span::Span::default(),
1023                statement_span: oxc_span::Span::default(),
1024                source_span: oxc_span::Span::default(),
1025            },
1026            target: ResolveResult::InternalModule(source),
1027        };
1028        let export = || ExportInfo {
1029            name: ExportName::Named("foo".to_string()),
1030            local_name: Some("foo".to_string()),
1031            is_type_only: false,
1032            visibility: VisibilityTag::None,
1033            expected_unused_reason: None,
1034            span: oxc_span::Span::new(0, 3),
1035            members: Vec::new(),
1036            is_side_effect_used: false,
1037            super_class: None,
1038        };
1039        let resolved = vec![
1040            ResolvedModule {
1041                file_id: FileId(0),
1042                path: files[0].path.clone(),
1043                resolved_imports: vec![ResolvedImport {
1044                    info: ImportInfo {
1045                        source: "./barrel".to_string(),
1046                        imported_name: ImportedName::Named("foo".to_string()),
1047                        local_name: "foo".to_string(),
1048                        is_type_only: false,
1049                        from_style: false,
1050                        span: oxc_span::Span::default(),
1051                        source_span: oxc_span::Span::default(),
1052                    },
1053                    target: ResolveResult::InternalModule(FileId(1)),
1054                }],
1055                ..Default::default()
1056            },
1057            ResolvedModule {
1058                file_id: FileId(1),
1059                path: files[1].path.clone(),
1060                re_exports: vec![
1061                    re_export(FileId(2), "*", "*"),
1062                    re_export(FileId(3), "foo", "foo"),
1063                ],
1064                ..Default::default()
1065            },
1066            ResolvedModule {
1067                file_id: FileId(2),
1068                path: files[2].path.clone(),
1069                exports: vec![export()].into(),
1070                ..Default::default()
1071            },
1072            ResolvedModule {
1073                file_id: FileId(3),
1074                path: files[3].path.clone(),
1075                exports: vec![export()].into(),
1076                ..Default::default()
1077            },
1078        ];
1079        let graph = ModuleGraph::build(&resolved, &entry_points, &files);
1080
1081        let shadowed = trace_export(&graph, Path::new("/project"), "src/star-source.ts", "foo")
1082            .expect("shadowed source export exists");
1083        let effective = trace_export(
1084            &graph,
1085            Path::new("/project"),
1086            "src/explicit-source.ts",
1087            "foo",
1088        )
1089        .expect("effective source export exists");
1090
1091        assert!(shadowed.re_export_chains.is_empty());
1092        assert_eq!(effective.re_export_chains.len(), 1);
1093        assert_eq!(effective.re_export_chains[0].exported_as, "foo");
1094    }
1095
1096    fn star_surface_trace_graph(root: &Path) -> ModuleGraph {
1097        let src = root.join("src");
1098        std::fs::create_dir_all(&src).expect("create source directory");
1099        let paths: Vec<_> = ["source", "barrel-a", "barrel-b", "outer", "entry"]
1100            .into_iter()
1101            .map(|name| src.join(format!("{name}.ts")))
1102            .collect();
1103        std::fs::write(&paths[0], "\n\nexport const foo = 1;\n").expect("write source");
1104        for path in &paths[1..] {
1105            std::fs::write(path, "export {};\n").expect("write module");
1106        }
1107        let files: Vec<_> = paths
1108            .iter()
1109            .enumerate()
1110            .map(|(index, path)| DiscoveredFile {
1111                id: FileId(index as u32),
1112                path: path.clone(),
1113                size_bytes: 20,
1114            })
1115            .collect();
1116        let resolved = vec![
1117            ResolvedModule {
1118                file_id: FileId(0),
1119                path: paths[0].clone(),
1120                exports: vec![ExportInfo {
1121                    name: ExportName::Named("foo".to_string()),
1122                    local_name: Some("foo".to_string()),
1123                    is_type_only: false,
1124                    visibility: VisibilityTag::None,
1125                    expected_unused_reason: None,
1126                    span: oxc_span::Span::new(2, 5),
1127                    members: Vec::new(),
1128                    is_side_effect_used: false,
1129                    super_class: None,
1130                }]
1131                .into(),
1132                ..Default::default()
1133            },
1134            ResolvedModule {
1135                file_id: FileId(1),
1136                path: paths[1].clone(),
1137                re_exports: vec![resolved_re_export(FileId(0), "*", "*")],
1138                ..Default::default()
1139            },
1140            ResolvedModule {
1141                file_id: FileId(2),
1142                path: paths[2].clone(),
1143                re_exports: vec![resolved_re_export(FileId(0), "*", "*")],
1144                ..Default::default()
1145            },
1146            ResolvedModule {
1147                file_id: FileId(3),
1148                path: paths[3].clone(),
1149                re_exports: vec![
1150                    resolved_re_export(FileId(1), "foo", "left"),
1151                    resolved_re_export(FileId(1), "foo", "right"),
1152                ],
1153                ..Default::default()
1154            },
1155            ResolvedModule {
1156                file_id: FileId(4),
1157                path: paths[4].clone(),
1158                resolved_imports: vec![
1159                    ResolvedImport {
1160                        info: ImportInfo {
1161                            source: "./outer".to_string(),
1162                            imported_name: ImportedName::Named("left".to_string()),
1163                            local_name: "left".to_string(),
1164                            is_type_only: false,
1165                            from_style: false,
1166                            span: oxc_span::Span::new(10, 20),
1167                            source_span: oxc_span::Span::default(),
1168                        },
1169                        target: ResolveResult::InternalModule(FileId(3)),
1170                    },
1171                    ResolvedImport {
1172                        info: ImportInfo {
1173                            source: "./barrel-b".to_string(),
1174                            imported_name: ImportedName::Named("foo".to_string()),
1175                            local_name: "otherFoo".to_string(),
1176                            is_type_only: false,
1177                            from_style: false,
1178                            span: oxc_span::Span::new(30, 40),
1179                            source_span: oxc_span::Span::default(),
1180                        },
1181                        target: ResolveResult::InternalModule(FileId(2)),
1182                    },
1183                ],
1184                ..Default::default()
1185            },
1186        ];
1187        let entry_points = vec![EntryPoint {
1188            path: paths[4].clone(),
1189            source: EntryPointSource::PackageJsonMain,
1190        }];
1191        ModuleGraph::build(&resolved, &entry_points, &files)
1192    }
1193
1194    #[test]
1195    fn star_surface_trace_keeps_aliases_separate_and_uses_origin_identity() {
1196        let root = tempfile::tempdir().expect("temporary project");
1197        let graph = star_surface_trace_graph(root.path());
1198
1199        let used = trace_export(&graph, root.path(), "src/barrel-a.ts", "foo")
1200            .expect("aliased barrel exposes foo");
1201        let sibling = trace_export(&graph, root.path(), "src/barrel-b.ts", "foo")
1202            .expect("sibling barrel exposes foo");
1203        assert!(used.is_used);
1204        assert_eq!(used.direct_references.len(), 1);
1205        assert!(sibling.is_used);
1206        assert_eq!(sibling.direct_references.len(), 1);
1207
1208        let source_trace = trace_export(&graph, root.path(), "src/source.ts", "foo")
1209            .expect("source declaration is traceable");
1210        let chain_count = |file: &str, name: &str| {
1211            source_trace
1212                .re_export_chains
1213                .iter()
1214                .find(|chain| chain.barrel_file == Path::new(file) && chain.exported_as == name)
1215                .map(|chain| chain.reference_count)
1216        };
1217        assert_eq!(chain_count("src/barrel-a.ts", "foo"), Some(1));
1218        assert_eq!(chain_count("src/barrel-b.ts", "foo"), Some(1));
1219        assert_eq!(chain_count("src/outer.ts", "left"), Some(1));
1220        assert_eq!(chain_count("src/outer.ts", "right"), Some(0));
1221
1222        let semantic = semantic_symbol_for_export(&graph, root.path(), "src/barrel-a.ts", "foo")
1223            .expect("star surface resolves to its declaration identity");
1224        assert_eq!(semantic.path, Path::new("src/source.ts"));
1225        assert_eq!(semantic.exported_name, "foo");
1226        assert_eq!(semantic.local_name, "foo");
1227        assert_eq!((semantic.line, semantic.col), (3, 0));
1228
1229        let alias = semantic_symbol_for_export(&graph, root.path(), "src/outer.ts", "left")
1230            .expect("named re-export keeps its export-specifier identity");
1231        assert_eq!(alias.path, Path::new("src/outer.ts"));
1232        assert_eq!(alias.exported_name, "left");
1233        assert_eq!(alias.local_name, "foo");
1234    }
1235
1236    #[test]
1237    fn trace_follows_renamed_and_convergent_re_export_routes() {
1238        let names = [
1239            "source",
1240            "renamed",
1241            "final",
1242            "left",
1243            "right",
1244            "diamond-entry",
1245        ];
1246        let files: Vec<_> = names
1247            .into_iter()
1248            .enumerate()
1249            .map(|(index, name)| DiscoveredFile {
1250                id: FileId(index as u32),
1251                path: PathBuf::from(format!("/project/src/{name}.ts")),
1252                size_bytes: 10,
1253            })
1254            .collect();
1255        let re_export = |source: FileId, imported: &str, exported: &str| ResolvedReExport {
1256            info: ReExportInfo {
1257                source: format!("./{}", source.0),
1258                imported_name: imported.to_string(),
1259                exported_name: exported.to_string(),
1260                is_type_only: false,
1261                span: oxc_span::Span::default(),
1262                statement_span: oxc_span::Span::default(),
1263                source_span: oxc_span::Span::default(),
1264            },
1265            target: ResolveResult::InternalModule(source),
1266        };
1267        let mut resolved: Vec<_> = files
1268            .iter()
1269            .map(|file| ResolvedModule {
1270                file_id: file.id,
1271                path: file.path.clone(),
1272                ..Default::default()
1273            })
1274            .collect();
1275        resolved[0].exports = vec![ExportInfo {
1276            name: ExportName::Named("foo".to_string()),
1277            local_name: Some("foo".to_string()),
1278            is_type_only: false,
1279            visibility: VisibilityTag::None,
1280            expected_unused_reason: None,
1281            span: oxc_span::Span::new(0, 3),
1282            members: Vec::new(),
1283            is_side_effect_used: false,
1284            super_class: None,
1285        }]
1286        .into();
1287        resolved[1].re_exports = vec![re_export(FileId(0), "foo", "bar")];
1288        resolved[2].re_exports = vec![re_export(FileId(1), "bar", "baz")];
1289        resolved[3].re_exports = vec![re_export(FileId(0), "*", "*")];
1290        resolved[4].re_exports = vec![re_export(FileId(0), "*", "*")];
1291        resolved[5].re_exports = vec![
1292            re_export(FileId(3), "*", "*"),
1293            re_export(FileId(4), "*", "*"),
1294        ];
1295        let entry_points = vec![EntryPoint {
1296            path: files[5].path.clone(),
1297            source: EntryPointSource::PackageJsonMain,
1298        }];
1299        let graph = ModuleGraph::build(&resolved, &entry_points, &files);
1300
1301        let trace = trace_export(&graph, Path::new("/project"), "src/source.ts", "foo")
1302            .expect("source export exists");
1303        let routes: FxHashSet<_> = trace
1304            .re_export_chains
1305            .iter()
1306            .map(|route| (route.barrel_file.as_path(), route.exported_as.as_str()))
1307            .collect();
1308
1309        assert_eq!(routes.len(), 5);
1310        assert!(routes.contains(&(Path::new("src/renamed.ts"), "bar")));
1311        assert!(routes.contains(&(Path::new("src/final.ts"), "baz")));
1312        assert!(routes.contains(&(Path::new("src/left.ts"), "foo")));
1313        assert!(routes.contains(&(Path::new("src/right.ts"), "foo")));
1314        assert!(routes.contains(&(Path::new("src/diamond-entry.ts"), "foo")));
1315    }
1316
1317    #[test]
1318    fn trace_prefers_the_value_namespace_independent_of_usage() {
1319        let files = vec![
1320            DiscoveredFile {
1321                id: FileId(0),
1322                path: PathBuf::from("/project/src/entry.ts"),
1323                size_bytes: 10,
1324            },
1325            DiscoveredFile {
1326                id: FileId(1),
1327                path: PathBuf::from("/project/src/source.ts"),
1328                size_bytes: 10,
1329            },
1330        ];
1331        let export = |is_type_only| ExportInfo {
1332            name: ExportName::Named("Foo".to_string()),
1333            local_name: Some("Foo".to_string()),
1334            is_type_only,
1335            visibility: VisibilityTag::None,
1336            expected_unused_reason: None,
1337            span: oxc_span::Span::new(0, 3),
1338            members: Vec::new(),
1339            is_side_effect_used: false,
1340            super_class: None,
1341        };
1342        let resolved = vec![
1343            ResolvedModule {
1344                file_id: FileId(0),
1345                path: files[0].path.clone(),
1346                resolved_imports: vec![ResolvedImport {
1347                    info: ImportInfo {
1348                        source: "./source".to_string(),
1349                        imported_name: ImportedName::Named("Foo".to_string()),
1350                        local_name: "Foo".to_string(),
1351                        is_type_only: true,
1352                        from_style: false,
1353                        span: oxc_span::Span::default(),
1354                        source_span: oxc_span::Span::default(),
1355                    },
1356                    target: ResolveResult::InternalModule(FileId(1)),
1357                }],
1358                ..Default::default()
1359            },
1360            ResolvedModule {
1361                file_id: FileId(1),
1362                path: files[1].path.clone(),
1363                exports: vec![export(false), export(true)].into(),
1364                ..Default::default()
1365            },
1366        ];
1367        let entry_points = vec![EntryPoint {
1368            path: files[0].path.clone(),
1369            source: EntryPointSource::PackageJsonMain,
1370        }];
1371        let graph = ModuleGraph::build(&resolved, &entry_points, &files);
1372
1373        let trace = trace_export(&graph, Path::new("/project"), "src/source.ts", "Foo")
1374            .expect("value export exists");
1375
1376        assert_eq!(
1377            trace.namespace,
1378            fallow_types::semantic::SemanticNamespace::Value
1379        );
1380        assert!(!trace.is_used, "type usage must not select the type export");
1381    }
1382
1383    #[test]
1384    fn trace_preserves_dual_namespace_named_re_exports() {
1385        let files: Vec<_> = ["entry", "barrel", "types", "values"]
1386            .into_iter()
1387            .enumerate()
1388            .map(|(index, name)| DiscoveredFile {
1389                id: FileId(index as u32),
1390                path: PathBuf::from(format!("/project/src/{name}.ts")),
1391                size_bytes: 10,
1392            })
1393            .collect();
1394        let export = |is_type_only| ExportInfo {
1395            name: ExportName::Named("Foo".to_string()),
1396            local_name: Some("Foo".to_string()),
1397            is_type_only,
1398            visibility: VisibilityTag::None,
1399            expected_unused_reason: None,
1400            span: oxc_span::Span::new(0, 3),
1401            members: Vec::new(),
1402            is_side_effect_used: false,
1403            super_class: None,
1404        };
1405        let re_export = |source: FileId, is_type_only| ResolvedReExport {
1406            info: ReExportInfo {
1407                source: format!("./{}", source.0),
1408                imported_name: "Foo".to_string(),
1409                exported_name: "Foo".to_string(),
1410                is_type_only,
1411                span: oxc_span::Span::default(),
1412                statement_span: oxc_span::Span::default(),
1413                source_span: oxc_span::Span::default(),
1414            },
1415            target: ResolveResult::InternalModule(source),
1416        };
1417        let mut resolved = vec![
1418            ResolvedModule {
1419                file_id: FileId(0),
1420                path: files[0].path.clone(),
1421                resolved_imports: vec![ResolvedImport {
1422                    info: ImportInfo {
1423                        source: "./barrel".to_string(),
1424                        imported_name: ImportedName::Named("Foo".to_string()),
1425                        local_name: "Foo".to_string(),
1426                        is_type_only: false,
1427                        from_style: false,
1428                        span: oxc_span::Span::new(0, 10),
1429                        source_span: oxc_span::Span::default(),
1430                    },
1431                    target: ResolveResult::InternalModule(FileId(1)),
1432                }],
1433                ..Default::default()
1434            },
1435            ResolvedModule {
1436                file_id: FileId(1),
1437                path: files[1].path.clone(),
1438                re_exports: vec![re_export(FileId(2), true), re_export(FileId(3), false)],
1439                ..Default::default()
1440            },
1441            ResolvedModule {
1442                file_id: FileId(2),
1443                path: files[2].path.clone(),
1444                exports: vec![export(true)].into(),
1445                ..Default::default()
1446            },
1447            ResolvedModule {
1448                file_id: FileId(3),
1449                path: files[3].path.clone(),
1450                exports: vec![export(false)].into(),
1451                ..Default::default()
1452            },
1453        ];
1454        let entry_points = vec![EntryPoint {
1455            path: files[0].path.clone(),
1456            source: EntryPointSource::PackageJsonMain,
1457        }];
1458        let graph = ModuleGraph::build(&resolved, &entry_points, &files);
1459        resolved[1].re_exports.reverse();
1460        let reversed_graph = ModuleGraph::build(&resolved, &entry_points, &files);
1461
1462        let trace = trace_export(&graph, Path::new("/project"), "src/barrel.ts", "Foo")
1463            .expect("barrel exposes Foo in both namespaces");
1464
1465        assert_eq!(
1466            trace.namespace,
1467            fallow_types::semantic::SemanticNamespace::Value
1468        );
1469        assert!(
1470            trace.is_used,
1471            "the value import must credit the value surface"
1472        );
1473        assert_eq!(trace.direct_references.len(), 1);
1474        let reversed_trace = trace_export(
1475            &reversed_graph,
1476            Path::new("/project"),
1477            "src/barrel.ts",
1478            "Foo",
1479        )
1480        .expect("reversed declarations expose the same surface");
1481        assert_eq!(
1482            serde_json::to_value(trace).expect("serialize trace"),
1483            serde_json::to_value(reversed_trace).expect("serialize reversed trace")
1484        );
1485    }
1486
1487    fn build_class_member_graph() -> ModuleGraph {
1488        use fallow_types::extract::{MemberInfo, MemberKind};
1489
1490        let files = vec![
1491            DiscoveredFile {
1492                id: FileId(0),
1493                path: PathBuf::from("/project/src/entry.ts"),
1494                size_bytes: 100,
1495            },
1496            DiscoveredFile {
1497                id: FileId(1),
1498                path: PathBuf::from("/project/src/controller.ts"),
1499                size_bytes: 50,
1500            },
1501        ];
1502        let entry_points = vec![EntryPoint {
1503            path: PathBuf::from("/project/src/entry.ts"),
1504            source: EntryPointSource::PackageJsonMain,
1505        }];
1506        let method = |name: &str| MemberInfo {
1507            name: name.to_string(),
1508            kind: MemberKind::ClassMethod,
1509            span: oxc_span::Span::new(0, 4),
1510            has_decorator: false,
1511            decorator_names: vec![],
1512            is_instance_returning_static: false,
1513            is_self_returning: false,
1514        };
1515        let resolved_modules = vec![
1516            ResolvedModule {
1517                file_id: FileId(0),
1518                path: PathBuf::from("/project/src/entry.ts"),
1519                resolved_imports: vec![ResolvedImport {
1520                    info: ImportInfo {
1521                        source: "./controller".to_string(),
1522                        imported_name: ImportedName::Named("Ctrl".to_string()),
1523                        local_name: "Ctrl".to_string(),
1524                        is_type_only: false,
1525                        from_style: false,
1526                        span: oxc_span::Span::new(0, 10),
1527                        source_span: oxc_span::Span::default(),
1528                    },
1529                    target: ResolveResult::InternalModule(FileId(1)),
1530                }],
1531                ..Default::default()
1532            },
1533            ResolvedModule {
1534                file_id: FileId(1),
1535                path: PathBuf::from("/project/src/controller.ts"),
1536                exports: vec![ExportInfo {
1537                    name: ExportName::Named("Ctrl".to_string()),
1538                    local_name: Some("Ctrl".to_string()),
1539                    is_type_only: false,
1540                    visibility: VisibilityTag::None,
1541                    expected_unused_reason: None,
1542                    span: oxc_span::Span::new(0, 20),
1543                    members: vec![method("used"), method("dead")],
1544                    is_side_effect_used: false,
1545                    super_class: None,
1546                }]
1547                .into(),
1548                ..Default::default()
1549            },
1550        ];
1551        ModuleGraph::build(&resolved_modules, &entry_points, &files)
1552    }
1553
1554    #[test]
1555    fn trace_class_member_reports_owner_class() {
1556        // #1744: `--trace FILE:MEMBER` on a class member reports the owning
1557        // class instead of erroring "export not found".
1558        let graph = build_class_member_graph();
1559        let root = Path::new("/project");
1560
1561        let trace = trace_class_member(&graph, root, "src/controller.ts", "dead").unwrap();
1562        assert_eq!(trace.owner_export, "Ctrl");
1563        assert_eq!(trace.member_name, "dead");
1564        assert_eq!(trace.member_kind, "class-method");
1565        assert!(trace.owner_is_used);
1566        assert!(trace.owner_file_reachable);
1567        assert_eq!(trace.owner_direct_references.len(), 1);
1568        assert!(
1569            trace.reason.contains("--unused-class-members"),
1570            "reason should point at the member command: {}",
1571            trace.reason
1572        );
1573    }
1574
1575    #[test]
1576    fn trace_class_member_absent_name_is_none() {
1577        // A name that is neither a top-level export nor a declared member falls
1578        // through so the caller emits the "not found" error.
1579        let graph = build_class_member_graph();
1580        let root = Path::new("/project");
1581        assert!(trace_class_member(&graph, root, "src/controller.ts", "nope").is_none());
1582    }
1583
1584    #[test]
1585    fn exact_class_method_resolution_rejects_overloads_without_guessing() {
1586        use fallow_types::extract::{MemberInfo, MemberKind};
1587
1588        let temp = tempfile::tempdir().unwrap();
1589        let root = temp.path();
1590        let path = root.join("repository.ts");
1591        let source =
1592            "export class Repository {\n  save(): void;\n  save(): void {}\n  run(): void {}\n}\n";
1593        std::fs::write(&path, source).unwrap();
1594        let first = source.find("save").unwrap() as u32;
1595        let second = source.rfind("save").unwrap() as u32;
1596        let run = source.find("run").unwrap() as u32;
1597        let member = |name: &str, start| MemberInfo {
1598            name: name.to_string(),
1599            kind: MemberKind::ClassMethod,
1600            span: oxc_span::Span::new(start, start + 4),
1601            has_decorator: false,
1602            decorator_names: vec![],
1603            is_instance_returning_static: false,
1604            is_self_returning: false,
1605        };
1606        let files = vec![DiscoveredFile {
1607            id: FileId(0),
1608            path: path.clone(),
1609            size_bytes: source.len() as u64,
1610        }];
1611        let resolved_modules = vec![ResolvedModule {
1612            file_id: FileId(0),
1613            path,
1614            exports: vec![ExportInfo {
1615                name: ExportName::Named("Repository".to_string()),
1616                local_name: Some("Repository".to_string()),
1617                is_type_only: false,
1618                visibility: VisibilityTag::None,
1619                expected_unused_reason: None,
1620                span: oxc_span::Span::new(0, source.len() as u32),
1621                members: vec![
1622                    member("save", first),
1623                    member("save", second),
1624                    member("run", run),
1625                ],
1626                is_side_effect_used: false,
1627                super_class: None,
1628            }]
1629            .into(),
1630            ..Default::default()
1631        }];
1632        let graph = ModuleGraph::build(&resolved_modules, &[], &files);
1633
1634        assert_eq!(
1635            semantic_symbol_for_exact_class_method(
1636                &graph,
1637                root,
1638                "repository.ts",
1639                "Repository",
1640                "save",
1641            ),
1642            Err(SemanticClassMethodResolutionError::AmbiguousSymbol)
1643        );
1644        assert_eq!(
1645            semantic_symbol_for_exact_class_method(
1646                &graph,
1647                root,
1648                "repository.ts",
1649                "OtherRepository",
1650                "save",
1651            ),
1652            Err(SemanticClassMethodResolutionError::SymbolNotFound)
1653        );
1654        let resolved = semantic_symbol_for_exact_class_method(
1655            &graph,
1656            root,
1657            "repository.ts",
1658            "Repository",
1659            "run",
1660        )
1661        .unwrap();
1662        assert_eq!(resolved.owner.as_deref(), Some("Repository"));
1663        assert_eq!(resolved.local_name, "run");
1664    }
1665
1666    /// Build a graph where the controller declaring `Ctrl` is NOT imported by
1667    /// the entry, so its file is unreachable and every member is dead.
1668    fn build_unreachable_class_member_graph() -> ModuleGraph {
1669        use fallow_types::extract::{MemberInfo, MemberKind};
1670
1671        let files = vec![
1672            DiscoveredFile {
1673                id: FileId(0),
1674                path: PathBuf::from("/project/src/entry.ts"),
1675                size_bytes: 100,
1676            },
1677            DiscoveredFile {
1678                id: FileId(1),
1679                path: PathBuf::from("/project/src/controller.ts"),
1680                size_bytes: 50,
1681            },
1682        ];
1683        let entry_points = vec![EntryPoint {
1684            path: PathBuf::from("/project/src/entry.ts"),
1685            source: EntryPointSource::PackageJsonMain,
1686        }];
1687        let method = |name: &str| MemberInfo {
1688            name: name.to_string(),
1689            kind: MemberKind::ClassMethod,
1690            span: oxc_span::Span::new(0, 4),
1691            has_decorator: false,
1692            decorator_names: vec![],
1693            is_instance_returning_static: false,
1694            is_self_returning: false,
1695        };
1696        let resolved_modules = vec![
1697            ResolvedModule {
1698                file_id: FileId(0),
1699                path: PathBuf::from("/project/src/entry.ts"),
1700                // Entry imports nothing, so controller.ts is unreachable.
1701                ..Default::default()
1702            },
1703            ResolvedModule {
1704                file_id: FileId(1),
1705                path: PathBuf::from("/project/src/controller.ts"),
1706                exports: vec![ExportInfo {
1707                    name: ExportName::Named("Ctrl".to_string()),
1708                    local_name: Some("Ctrl".to_string()),
1709                    is_type_only: false,
1710                    visibility: VisibilityTag::None,
1711                    expected_unused_reason: None,
1712                    span: oxc_span::Span::new(0, 20),
1713                    members: vec![method("dead")],
1714                    is_side_effect_used: false,
1715                    super_class: None,
1716                }]
1717                .into(),
1718                ..Default::default()
1719            },
1720        ];
1721        ModuleGraph::build(&resolved_modules, &entry_points, &files)
1722    }
1723
1724    #[test]
1725    fn trace_class_member_unreachable_owner_reports_dead_reason() {
1726        // `!file_reachable` branch: the owning file is not reachable from any
1727        // entry point, so the reason states the class and its members are dead.
1728        let graph = build_unreachable_class_member_graph();
1729        let root = Path::new("/project");
1730
1731        let trace = trace_class_member(&graph, root, "src/controller.ts", "dead").unwrap();
1732        assert!(!trace.owner_file_reachable);
1733        assert!(
1734            trace.reason.contains("not reachable"),
1735            "unreachable owner reason should say so: {}",
1736            trace.reason
1737        );
1738        // The unreachable branch does not point at a member command (the file is
1739        // dead wholesale via the unused-file finding).
1740        assert!(!trace.reason.contains("--unused-class-members"));
1741    }
1742
1743    #[test]
1744    fn trace_class_member_prefers_used_owner_on_name_collision() {
1745        // Two exports declare a member of the same name; the tie-break in
1746        // `max_by_key` must prefer the used, non-type-only owner so the trace
1747        // reports the reachable class rather than a type-only shadow.
1748        use fallow_types::extract::{MemberInfo, MemberKind};
1749
1750        let files = vec![
1751            DiscoveredFile {
1752                id: FileId(0),
1753                path: PathBuf::from("/project/src/entry.ts"),
1754                size_bytes: 100,
1755            },
1756            DiscoveredFile {
1757                id: FileId(1),
1758                path: PathBuf::from("/project/src/controller.ts"),
1759                size_bytes: 50,
1760            },
1761        ];
1762        let entry_points = vec![EntryPoint {
1763            path: PathBuf::from("/project/src/entry.ts"),
1764            source: EntryPointSource::PackageJsonMain,
1765        }];
1766        let method = |name: &str| MemberInfo {
1767            name: name.to_string(),
1768            kind: MemberKind::ClassMethod,
1769            span: oxc_span::Span::new(0, 4),
1770            has_decorator: false,
1771            decorator_names: vec![],
1772            is_instance_returning_static: false,
1773            is_self_returning: false,
1774        };
1775        let resolved_modules = vec![
1776            ResolvedModule {
1777                file_id: FileId(0),
1778                path: PathBuf::from("/project/src/entry.ts"),
1779                resolved_imports: vec![ResolvedImport {
1780                    info: ImportInfo {
1781                        source: "./controller".to_string(),
1782                        imported_name: ImportedName::Named("UsedCtrl".to_string()),
1783                        local_name: "UsedCtrl".to_string(),
1784                        is_type_only: false,
1785                        from_style: false,
1786                        span: oxc_span::Span::new(0, 10),
1787                        source_span: oxc_span::Span::default(),
1788                    },
1789                    target: ResolveResult::InternalModule(FileId(1)),
1790                }],
1791                ..Default::default()
1792            },
1793            ResolvedModule {
1794                file_id: FileId(1),
1795                path: PathBuf::from("/project/src/controller.ts"),
1796                exports: vec![
1797                    // Type-only, unreferenced owner declared FIRST: must lose the
1798                    // tie-break to the used, non-type-only owner below.
1799                    ExportInfo {
1800                        name: ExportName::Named("TypeCtrl".to_string()),
1801                        local_name: Some("TypeCtrl".to_string()),
1802                        is_type_only: true,
1803                        visibility: VisibilityTag::None,
1804                        expected_unused_reason: None,
1805                        span: oxc_span::Span::new(0, 20),
1806                        members: vec![method("shared")],
1807                        is_side_effect_used: false,
1808                        super_class: None,
1809                    },
1810                    ExportInfo {
1811                        name: ExportName::Named("UsedCtrl".to_string()),
1812                        local_name: Some("UsedCtrl".to_string()),
1813                        is_type_only: false,
1814                        visibility: VisibilityTag::None,
1815                        expected_unused_reason: None,
1816                        span: oxc_span::Span::new(0, 20),
1817                        members: vec![method("shared")],
1818                        is_side_effect_used: false,
1819                        super_class: None,
1820                    },
1821                ]
1822                .into(),
1823                ..Default::default()
1824            },
1825        ];
1826        let graph = ModuleGraph::build(&resolved_modules, &entry_points, &files);
1827        let root = Path::new("/project");
1828
1829        let trace = trace_class_member(&graph, root, "src/controller.ts", "shared").unwrap();
1830        assert_eq!(
1831            trace.owner_export, "UsedCtrl",
1832            "tie-break must prefer the used, non-type-only owner"
1833        );
1834        assert!(trace.owner_is_used);
1835    }
1836
1837    #[test]
1838    fn trace_nonexistent_file() {
1839        let graph = build_test_graph();
1840        let root = Path::new("/project");
1841
1842        let trace = trace_export(&graph, root, "src/nope.ts", "foo");
1843        assert!(trace.is_none());
1844    }
1845
1846    #[test]
1847    fn trace_file_edges() {
1848        let graph = build_test_graph();
1849        let root = Path::new("/project");
1850
1851        let trace = trace_file(&graph, root, "src/entry.ts").unwrap();
1852        assert!(trace.is_entry_point);
1853        assert!(trace.is_reachable);
1854        assert_eq!(trace.imports_from.len(), 1);
1855        assert_eq!(trace.imports_from[0], PathBuf::from("src/utils.ts"));
1856        assert!(trace.imported_by.is_empty());
1857    }
1858
1859    #[test]
1860    fn trace_file_imported_by() {
1861        let graph = build_test_graph();
1862        let root = Path::new("/project");
1863
1864        let trace = trace_file(&graph, root, "src/utils.ts").unwrap();
1865        assert!(!trace.is_entry_point);
1866        assert!(trace.is_reachable);
1867        assert_eq!(trace.exports.len(), 2);
1868        assert_eq!(trace.imported_by.len(), 1);
1869        assert_eq!(trace.imported_by[0], PathBuf::from("src/entry.ts"));
1870    }
1871
1872    #[test]
1873    fn trace_unreachable_file() {
1874        let graph = build_test_graph();
1875        let root = Path::new("/project");
1876
1877        let trace = trace_file(&graph, root, "src/unused.ts").unwrap();
1878        assert!(!trace.is_reachable);
1879        assert!(!trace.is_entry_point);
1880        assert!(trace.imported_by.is_empty());
1881    }
1882
1883    #[test]
1884    fn trace_dependency_used() {
1885        let files = vec![DiscoveredFile {
1886            id: FileId(0),
1887            path: PathBuf::from("/project/src/app.ts"),
1888            size_bytes: 100,
1889        }];
1890        let entry_points = vec![EntryPoint {
1891            path: PathBuf::from("/project/src/app.ts"),
1892            source: EntryPointSource::PackageJsonMain,
1893        }];
1894        let resolved_modules = vec![ResolvedModule {
1895            file_id: FileId(0),
1896            path: PathBuf::from("/project/src/app.ts"),
1897            resolved_imports: vec![ResolvedImport {
1898                info: ImportInfo {
1899                    source: "lodash".to_string(),
1900                    imported_name: ImportedName::Named("get".to_string()),
1901                    local_name: "get".to_string(),
1902                    is_type_only: false,
1903                    from_style: false,
1904                    span: oxc_span::Span::new(0, 10),
1905                    source_span: oxc_span::Span::default(),
1906                },
1907                target: ResolveResult::NpmPackage("lodash".to_string()),
1908            }],
1909            ..Default::default()
1910        }];
1911
1912        let graph = ModuleGraph::build(&resolved_modules, &entry_points, &files);
1913        let root = Path::new("/project");
1914
1915        let trace = trace_dependency(&graph, root, "lodash", &FxHashSet::default());
1916        assert!(trace.is_used);
1917        assert!(!trace.used_in_scripts);
1918        assert_eq!(trace.import_count, 1);
1919        assert_eq!(trace.imported_by[0], PathBuf::from("src/app.ts"));
1920    }
1921
1922    #[test]
1923    fn trace_dependency_unused() {
1924        let files = vec![DiscoveredFile {
1925            id: FileId(0),
1926            path: PathBuf::from("/project/src/app.ts"),
1927            size_bytes: 100,
1928        }];
1929        let entry_points = vec![EntryPoint {
1930            path: PathBuf::from("/project/src/app.ts"),
1931            source: EntryPointSource::PackageJsonMain,
1932        }];
1933        let resolved_modules = vec![ResolvedModule {
1934            file_id: FileId(0),
1935            path: PathBuf::from("/project/src/app.ts"),
1936            ..Default::default()
1937        }];
1938
1939        let graph = ModuleGraph::build(&resolved_modules, &entry_points, &files);
1940        let root = Path::new("/project");
1941
1942        let trace = trace_dependency(&graph, root, "nonexistent-pkg", &FxHashSet::default());
1943        assert!(!trace.is_used);
1944        assert!(!trace.used_in_scripts);
1945        assert_eq!(trace.import_count, 0);
1946        assert!(trace.imported_by.is_empty());
1947    }
1948
1949    #[test]
1950    fn trace_dependency_used_only_in_scripts() {
1951        let files = vec![DiscoveredFile {
1952            id: FileId(0),
1953            path: PathBuf::from("/project/src/app.ts"),
1954            size_bytes: 100,
1955        }];
1956        let entry_points = vec![EntryPoint {
1957            path: PathBuf::from("/project/src/app.ts"),
1958            source: EntryPointSource::PackageJsonMain,
1959        }];
1960        let resolved_modules = vec![ResolvedModule {
1961            file_id: FileId(0),
1962            path: PathBuf::from("/project/src/app.ts"),
1963            ..Default::default()
1964        }];
1965
1966        let graph = ModuleGraph::build(&resolved_modules, &entry_points, &files);
1967        let root = Path::new("/project");
1968        let mut script_used = FxHashSet::default();
1969        script_used.insert("microbundle".to_string());
1970
1971        let trace = trace_dependency(&graph, root, "microbundle", &script_used);
1972        assert!(
1973            trace.is_used,
1974            "is_used must be true when the package is referenced from package.json scripts"
1975        );
1976        assert!(trace.used_in_scripts);
1977        assert_eq!(trace.import_count, 0);
1978        assert!(trace.imported_by.is_empty());
1979    }
1980
1981    #[test]
1982    fn trace_clone_finds_matching_group() {
1983        use crate::duplicates::{CloneGroup, CloneInstance, DuplicationReport, DuplicationStats};
1984        let report = DuplicationReport {
1985            clone_groups: vec![CloneGroup {
1986                instances: vec![
1987                    CloneInstance {
1988                        file: PathBuf::from("/project/src/a.ts"),
1989                        start_line: 10,
1990                        end_line: 20,
1991                        start_col: 0,
1992                        end_col: 0,
1993                        fragment: "fn foo() {}".to_string(),
1994                    },
1995                    CloneInstance {
1996                        file: PathBuf::from("/project/src/b.ts"),
1997                        start_line: 5,
1998                        end_line: 15,
1999                        start_col: 0,
2000                        end_col: 0,
2001                        fragment: "fn foo() {}".to_string(),
2002                    },
2003                ],
2004                token_count: 60,
2005                line_count: 11,
2006                similarity: None,
2007            }],
2008            clone_families: vec![],
2009            mirrored_directories: vec![],
2010            stats: DuplicationStats {
2011                total_files: 2,
2012                files_with_clones: 2,
2013                total_lines: 100,
2014                duplicated_lines: 22,
2015                total_tokens: 200,
2016                duplicated_tokens: 120,
2017                clone_groups: 1,
2018                clone_instances: 2,
2019                duplication_percentage: 22.0,
2020                clone_groups_below_min_occurrences: 0,
2021                clone_groups_ignored: 0,
2022                near_candidates_skipped: 0,
2023            },
2024        };
2025        let trace = trace_clone(&report, Path::new("/project"), "src/a.ts", 15);
2026        assert!(trace.matched_instance.is_some());
2027        assert_eq!(trace.clone_groups.len(), 1);
2028        assert_eq!(trace.clone_groups[0].instances.len(), 2);
2029        assert!(trace.clone_groups[0].fingerprint.starts_with("dup:"));
2030        assert_eq!(trace.clone_groups[0].suggestion.estimated_savings, 11);
2031    }
2032
2033    #[test]
2034    fn trace_clone_by_fingerprint_resolves_and_misses() {
2035        use crate::duplicates::{
2036            CloneGroup, CloneInstance, DuplicationReport, DuplicationStats, clone_fingerprint,
2037        };
2038        let report = DuplicationReport {
2039            clone_groups: vec![CloneGroup {
2040                instances: vec![
2041                    CloneInstance {
2042                        file: PathBuf::from("/project/src/a.ts"),
2043                        start_line: 10,
2044                        end_line: 20,
2045                        start_col: 0,
2046                        end_col: 0,
2047                        fragment: "fn buildInvoice() {}".to_string(),
2048                    },
2049                    CloneInstance {
2050                        file: PathBuf::from("/project/src/b.ts"),
2051                        start_line: 5,
2052                        end_line: 15,
2053                        start_col: 0,
2054                        end_col: 0,
2055                        fragment: "fn buildInvoice() {}".to_string(),
2056                    },
2057                ],
2058                token_count: 60,
2059                line_count: 11,
2060                similarity: None,
2061            }],
2062            clone_families: vec![],
2063            mirrored_directories: vec![],
2064            stats: DuplicationStats::default(),
2065        };
2066        let fp = clone_fingerprint(&report.clone_groups[0].instances);
2067
2068        let hit = trace_clone_by_fingerprint(&report, Path::new("/project"), &fp);
2069        assert!(hit.matched_instance.is_some());
2070        assert_eq!(hit.clone_groups.len(), 1);
2071        assert_eq!(hit.clone_groups[0].fingerprint, fp);
2072        assert_eq!(hit.line, 10);
2073
2074        let miss = trace_clone_by_fingerprint(&report, Path::new("/project"), "dup:deadbeef");
2075        assert!(miss.matched_instance.is_none());
2076        assert!(miss.clone_groups.is_empty());
2077    }
2078
2079    #[test]
2080    fn trace_clone_no_match() {
2081        use crate::duplicates::{CloneGroup, CloneInstance, DuplicationReport, DuplicationStats};
2082        let report = DuplicationReport {
2083            clone_groups: vec![CloneGroup {
2084                instances: vec![CloneInstance {
2085                    file: PathBuf::from("/project/src/a.ts"),
2086                    start_line: 10,
2087                    end_line: 20,
2088                    start_col: 0,
2089                    end_col: 0,
2090                    fragment: "fn foo() {}".to_string(),
2091                }],
2092                token_count: 60,
2093                line_count: 11,
2094                similarity: None,
2095            }],
2096            clone_families: vec![],
2097            mirrored_directories: vec![],
2098            stats: DuplicationStats {
2099                total_files: 1,
2100                files_with_clones: 1,
2101                total_lines: 50,
2102                duplicated_lines: 11,
2103                total_tokens: 100,
2104                duplicated_tokens: 60,
2105                clone_groups: 1,
2106                clone_instances: 1,
2107                duplication_percentage: 22.0,
2108                clone_groups_below_min_occurrences: 0,
2109                clone_groups_ignored: 0,
2110                near_candidates_skipped: 0,
2111            },
2112        };
2113        let trace = trace_clone(&report, Path::new("/project"), "src/a.ts", 25);
2114        assert!(trace.matched_instance.is_none());
2115        assert!(trace.clone_groups.is_empty());
2116    }
2117
2118    #[test]
2119    fn trace_clone_line_boundary() {
2120        use crate::duplicates::{CloneGroup, CloneInstance, DuplicationReport, DuplicationStats};
2121        let report = DuplicationReport {
2122            clone_groups: vec![CloneGroup {
2123                instances: vec![
2124                    CloneInstance {
2125                        file: PathBuf::from("/project/src/a.ts"),
2126                        start_line: 10,
2127                        end_line: 20,
2128                        start_col: 0,
2129                        end_col: 0,
2130                        fragment: "code".to_string(),
2131                    },
2132                    CloneInstance {
2133                        file: PathBuf::from("/project/src/b.ts"),
2134                        start_line: 1,
2135                        end_line: 11,
2136                        start_col: 0,
2137                        end_col: 0,
2138                        fragment: "code".to_string(),
2139                    },
2140                ],
2141                token_count: 50,
2142                line_count: 11,
2143                similarity: None,
2144            }],
2145            clone_families: vec![],
2146            mirrored_directories: vec![],
2147            stats: DuplicationStats {
2148                total_files: 2,
2149                files_with_clones: 2,
2150                total_lines: 100,
2151                duplicated_lines: 22,
2152                total_tokens: 200,
2153                duplicated_tokens: 100,
2154                clone_groups: 1,
2155                clone_instances: 2,
2156                duplication_percentage: 22.0,
2157                clone_groups_below_min_occurrences: 0,
2158                clone_groups_ignored: 0,
2159                near_candidates_skipped: 0,
2160            },
2161        };
2162        let root = Path::new("/project");
2163        assert!(
2164            trace_clone(&report, root, "src/a.ts", 10)
2165                .matched_instance
2166                .is_some()
2167        );
2168        assert!(
2169            trace_clone(&report, root, "src/a.ts", 20)
2170                .matched_instance
2171                .is_some()
2172        );
2173        assert!(
2174            trace_clone(&report, root, "src/a.ts", 21)
2175                .matched_instance
2176                .is_none()
2177        );
2178    }
2179
2180    #[test]
2181    fn trace_clone_returns_relative_instance_paths() {
2182        use crate::duplicates::{CloneGroup, CloneInstance, DuplicationReport, DuplicationStats};
2183        let report = DuplicationReport {
2184            clone_groups: vec![CloneGroup {
2185                instances: vec![
2186                    CloneInstance {
2187                        file: PathBuf::from("/project/src/a.ts"),
2188                        start_line: 1,
2189                        end_line: 10,
2190                        start_col: 0,
2191                        end_col: 0,
2192                        fragment: "code".to_string(),
2193                    },
2194                    CloneInstance {
2195                        file: PathBuf::from("/project/src/b.ts"),
2196                        start_line: 1,
2197                        end_line: 10,
2198                        start_col: 0,
2199                        end_col: 0,
2200                        fragment: "code".to_string(),
2201                    },
2202                ],
2203                token_count: 50,
2204                line_count: 10,
2205                similarity: None,
2206            }],
2207            clone_families: vec![],
2208            mirrored_directories: vec![],
2209            stats: DuplicationStats {
2210                total_files: 2,
2211                files_with_clones: 2,
2212                total_lines: 50,
2213                duplicated_lines: 20,
2214                total_tokens: 100,
2215                duplicated_tokens: 100,
2216                clone_groups: 1,
2217                clone_instances: 2,
2218                duplication_percentage: 40.0,
2219                clone_groups_below_min_occurrences: 0,
2220                clone_groups_ignored: 0,
2221                near_candidates_skipped: 0,
2222            },
2223        };
2224        let trace = trace_clone(&report, Path::new("/project"), "src/a.ts", 5);
2225        let matched = trace.matched_instance.as_ref().expect("match expected");
2226        assert_eq!(matched.file, PathBuf::from("src/a.ts"));
2227        for group in &trace.clone_groups {
2228            for inst in &group.instances {
2229                let as_str = inst.file.to_string_lossy();
2230                assert!(
2231                    !as_str.starts_with('/'),
2232                    "instance file should be relative, got {as_str}",
2233                );
2234                assert!(
2235                    !as_str.contains(":\\") && !as_str.contains(":/"),
2236                    "instance file should not have a drive letter, got {as_str}",
2237                );
2238            }
2239        }
2240
2241        let json = serde_json::to_string(&trace).expect("serializes");
2242        assert!(
2243            !json.contains("\"/project/"),
2244            "serialized trace should not leak absolute paths: {json}",
2245        );
2246    }
2247
2248    /// Regression for the MCP e2e `trace_export` / `trace_file` Windows
2249    /// failures: the MCP layer passes forward-slashed user input
2250    /// (`src/utils.ts`) but `module_path` on Windows uses backslash
2251    /// separators (`D:\a\fallow\...\src\utils.ts`). The byte-level
2252    /// equality check missed every match. The helper now normalises
2253    /// both sides to forward slashes before comparing.
2254    #[test]
2255    fn path_matches_normalises_windows_module_path_against_posix_user_path() {
2256        let root = Path::new(r"D:\a\fallow\fallow\tests\fixtures\basic-project");
2257        let module_path =
2258            PathBuf::from(r"D:\a\fallow\fallow\tests\fixtures\basic-project\src\utils.ts");
2259        assert!(path_matches(&module_path, root, "src/utils.ts"));
2260        assert!(path_matches(&module_path, root, r"src\utils.ts"));
2261    }
2262
2263    #[test]
2264    fn path_matches_ends_with_fallback_handles_mixed_separators() {
2265        let root = Path::new("/some/other/root");
2266        let module_path =
2267            PathBuf::from(r"D:\a\fallow\fallow\tests\fixtures\basic-project\src\utils.ts");
2268        assert!(path_matches(&module_path, root, "src/utils.ts"));
2269    }
2270
2271    /// Regression for the MCP e2e trace_export / trace_file failures: even
2272    /// after `path_matches` correctly identified the file on Windows, the
2273    /// trace output struct's `file: PathBuf` field serialized the stored
2274    /// backslash-shaped path verbatim. JSON consumers (MCP agents, CI
2275    /// pipelines, the cross-platform trace_file assertion in
2276    /// `e2e_trace_file_returns_json`) expect forward-slash. Pin the
2277    /// contract via raw-string Windows-shaped `PathBuf::from` so the test
2278    /// runs cross-platform.
2279    #[test]
2280    fn export_trace_serializes_windows_path_with_forward_slashes() {
2281        let trace = ExportTrace {
2282            file: PathBuf::from(r"src\utils.ts"),
2283            export_name: "foo".to_string(),
2284            namespace: fallow_types::semantic::SemanticNamespace::Value,
2285            file_reachable: true,
2286            is_entry_point: false,
2287            is_used: true,
2288            direct_references: vec![ExportReference {
2289                from_file: PathBuf::from(r"src\entry.ts"),
2290                kind: "named import".to_string(),
2291            }],
2292            re_export_chains: vec![ReExportChain {
2293                barrel_file: PathBuf::from(r"src\index.ts"),
2294                exported_as: "foo".to_string(),
2295                reference_count: 1,
2296            }],
2297            reason: "ok".to_string(),
2298            semantic: None,
2299        };
2300        let json = serde_json::to_string(&trace).expect("serializes");
2301        assert!(
2302            json.contains("\"file\":\"src/utils.ts\""),
2303            "ExportTrace.file must serialize with forward slashes: {json}"
2304        );
2305        assert!(
2306            json.contains("\"from_file\":\"src/entry.ts\""),
2307            "ExportReference.from_file must serialize with forward slashes: {json}"
2308        );
2309        assert!(
2310            json.contains("\"barrel_file\":\"src/index.ts\""),
2311            "ReExportChain.barrel_file must serialize with forward slashes: {json}"
2312        );
2313        assert!(
2314            !json.contains(r"\\"),
2315            "no backslash sequence should remain anywhere in the JSON: {json}"
2316        );
2317    }
2318
2319    #[test]
2320    fn file_trace_serializes_windows_paths_with_forward_slashes() {
2321        let trace = FileTrace {
2322            file: PathBuf::from(r"src\utils.ts"),
2323            is_reachable: true,
2324            is_entry_point: false,
2325            exports: vec![],
2326            imports_from: vec![PathBuf::from(r"src\helpers.ts")],
2327            imported_by: vec![PathBuf::from(r"src\entry.ts")],
2328            re_exports: vec![TracedReExport {
2329                source_file: PathBuf::from(r"src\source.ts"),
2330                imported_name: "foo".to_string(),
2331                exported_name: "foo".to_string(),
2332            }],
2333        };
2334        let json = serde_json::to_string(&trace).expect("serializes");
2335        assert!(json.contains("\"file\":\"src/utils.ts\""), "got {json}");
2336        assert!(
2337            json.contains("\"imports_from\":[\"src/helpers.ts\"]"),
2338            "got {json}"
2339        );
2340        assert!(
2341            json.contains("\"imported_by\":[\"src/entry.ts\"]"),
2342            "got {json}"
2343        );
2344        assert!(
2345            json.contains("\"source_file\":\"src/source.ts\""),
2346            "got {json}"
2347        );
2348        assert!(!json.contains(r"\\"), "no backslash should remain: {json}");
2349    }
2350}