Skip to main content

fallow_engine/
trace_impl.rs

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