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