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    eager_only: bool,
1076) -> Result<ImportPathTrace, ImportPathEndpoint> {
1077    let from_index = import_path_endpoint_index(
1078        graph,
1079        root,
1080        from_path,
1081        ImportPathEndpoint::From,
1082        ImportPathEndpoint::AmbiguousFrom,
1083    )?;
1084    let to_index = import_path_endpoint_index(
1085        graph,
1086        root,
1087        to_path,
1088        ImportPathEndpoint::To,
1089        ImportPathEndpoint::AmbiguousTo,
1090    )?;
1091    let from = &graph.modules[from_index];
1092    let to = &graph.modules[to_index];
1093
1094    let from_rel = relativize(&from.path, root);
1095    let to_rel = relativize(&to.path, root);
1096
1097    let route = if eager_only {
1098        graph.shortest_eager_import_path(from.file_id, to.file_id)
1099    } else {
1100        graph.shortest_import_path(from.file_id, to.file_id)
1101    };
1102    let Some(hops) = route else {
1103        let kind = if eager_only {
1104            "eager import path"
1105        } else {
1106            "import path"
1107        };
1108        return Ok(ImportPathTrace {
1109            schema_version: ImportPathTraceSchemaVersion::V1,
1110            reason: format!("no {kind} from {from_rel} to {to_rel}"),
1111            from: from_rel,
1112            to: to_rel,
1113            reachable: false,
1114            hops: 0,
1115            path: Vec::new(),
1116        });
1117    };
1118
1119    if hops.is_empty() {
1120        return Ok(ImportPathTrace {
1121            schema_version: ImportPathTraceSchemaVersion::V1,
1122            reason: format!("{from_rel} is the same module as {to_rel}"),
1123            from: from_rel,
1124            to: to_rel,
1125            reachable: true,
1126            hops: 0,
1127            path: Vec::new(),
1128        });
1129    }
1130
1131    let path = resolve_import_path_hops(graph, root, &hops);
1132    let hop_count = path.len();
1133    let plural = if hop_count == 1 { "hop" } else { "hops" };
1134    // A route whose every hop is type-only is erased at build time. Saying so is
1135    // the difference between "these modules ship coupled" and "the coupling only
1136    // exists for the type checker".
1137    let reason = if path.iter().all(|hop| hop.type_only) {
1138        format!(
1139            "{from_rel} reaches {to_rel} in {hop_count} type-only {plural}, erased at build time"
1140        )
1141    } else {
1142        format!("{from_rel} reaches {to_rel} in {hop_count} {plural}")
1143    };
1144
1145    Ok(ImportPathTrace {
1146        schema_version: ImportPathTraceSchemaVersion::V1,
1147        from: from_rel,
1148        to: to_rel,
1149        reachable: true,
1150        hops: hop_count,
1151        path,
1152        reason,
1153    })
1154}
1155
1156/// Resolve graph-level hops to the wire shape, turning each import span into a
1157/// 1-based line. Each source file is read at most once per trace; a file that
1158/// cannot be read yields `import_line: None` rather than a guessed line.
1159fn resolve_import_path_hops(
1160    graph: &ModuleGraph,
1161    root: &Path,
1162    hops: &[GraphImportPathHop],
1163) -> Vec<ImportPathHop> {
1164    let mut line_offsets: FxHashMap<FileId, Option<Vec<u32>>> = FxHashMap::default();
1165    hops.iter()
1166        .filter_map(|hop| {
1167            let from = graph.modules.get(hop.from.0 as usize)?;
1168            let to = graph.modules.get(hop.to.0 as usize)?;
1169            let import_line = hop.import_span_start.and_then(|span_start| {
1170                line_offsets
1171                    .entry(hop.from)
1172                    .or_insert_with(|| {
1173                        std::fs::read_to_string(&from.path)
1174                            .ok()
1175                            .map(|source| fallow_types::extract::compute_line_offsets(&source))
1176                    })
1177                    .as_ref()
1178                    .map(|offsets| {
1179                        fallow_types::extract::byte_offset_to_line_col(offsets, span_start).0
1180                    })
1181            });
1182            Some(ImportPathHop {
1183                from: relativize(&from.path, root),
1184                to: relativize(&to.path, root),
1185                type_only: hop.all_type_only,
1186                dynamic: hop.dynamic,
1187                import_line,
1188            })
1189        })
1190        .collect()
1191}
1192
1193/// Relativize a module path against the project root, forward-slashed.
1194pub fn relativize(path: &Path, root: &Path) -> String {
1195    path.strip_prefix(root)
1196        .unwrap_or(path)
1197        .to_string_lossy()
1198        .replace('\\', "/")
1199}
1200
1201/// Build a [`TracedCloneGroup`] from a raw clone group, computing the
1202/// fingerprint, group-level suggestion, and dominant-identifier name and
1203/// relativizing every instance path against `root`.
1204fn build_traced_group(
1205    group: &CloneGroup,
1206    root: &Path,
1207    fingerprints: &CloneFingerprintSet,
1208) -> TracedCloneGroup {
1209    TracedCloneGroup {
1210        fingerprint: fingerprints.fingerprint_for_group(group),
1211        token_count: group.token_count,
1212        line_count: group.line_count,
1213        spread: group.spread(),
1214        similarity: group.similarity,
1215        instances: group
1216            .instances
1217            .iter()
1218            .map(|inst| relativize_instance(inst, root))
1219            .collect(),
1220        suggestion: group_refactoring_suggestion(group),
1221        suggested_name: dominant_identifier(group),
1222    }
1223}
1224
1225#[must_use]
1226pub fn trace_clone(
1227    report: &DuplicationReport,
1228    root: &Path,
1229    file_path: &str,
1230    line: usize,
1231) -> CloneTrace {
1232    let resolved = root.join(file_path);
1233    let mut matched_instance = None;
1234    let mut clone_groups = Vec::new();
1235    let fingerprints = CloneFingerprintSet::from_groups(&report.clone_groups);
1236
1237    for group in &report.clone_groups {
1238        let matching = group.instances.iter().find(|inst| {
1239            let inst_matches = inst.file == resolved
1240                || inst.file.strip_prefix(root).unwrap_or(&inst.file) == Path::new(file_path);
1241            inst_matches && inst.start_line <= line && line <= inst.end_line
1242        });
1243
1244        if let Some(matched) = matching {
1245            if matched_instance.is_none() {
1246                matched_instance = Some(relativize_instance(matched, root));
1247            }
1248            clone_groups.push(build_traced_group(group, root, &fingerprints));
1249        }
1250    }
1251
1252    CloneTrace {
1253        file: PathBuf::from(file_path),
1254        line,
1255        matched_instance,
1256        clone_groups,
1257    }
1258}
1259
1260/// Trace a clone group by its stable content fingerprint.
1261///
1262/// Fingerprints are usually `dup:<8hex>` and widen only when needed to avoid a
1263/// collision inside the same report.
1264///
1265/// Returns a [`CloneTrace`] whose single `clone_groups` entry is the matched
1266/// group and whose `file` / `line` / `matched_instance` come from that group's
1267/// representative (first) instance. `matched_instance` is `None` (and
1268/// `clone_groups` empty) when no group matches the fingerprint.
1269#[must_use]
1270pub fn trace_clone_by_fingerprint(
1271    report: &DuplicationReport,
1272    root: &Path,
1273    fingerprint: &str,
1274) -> CloneTrace {
1275    let fingerprints = CloneFingerprintSet::from_groups(&report.clone_groups);
1276    let matched = fingerprints.find_group(&report.clone_groups, fingerprint);
1277
1278    let Some(group) = matched else {
1279        return CloneTrace {
1280            file: PathBuf::new(),
1281            line: 0,
1282            matched_instance: None,
1283            clone_groups: Vec::new(),
1284        };
1285    };
1286
1287    let representative = group
1288        .instances
1289        .first()
1290        .map(|inst| relativize_instance(inst, root));
1291    let (file, line) = representative.as_ref().map_or_else(
1292        || (PathBuf::new(), 0),
1293        |inst| (inst.file.clone(), inst.start_line),
1294    );
1295
1296    CloneTrace {
1297        file,
1298        line,
1299        matched_instance: representative,
1300        clone_groups: vec![build_traced_group(group, root, &fingerprints)],
1301    }
1302}
1303
1304/// Return a copy of `inst` with `file` rewritten relative to `root` (forward-slash normalized
1305/// for cross-platform JSON parity with `serde_path::serialize`). If `inst.file` is already
1306/// outside `root`, the path is left unchanged.
1307fn relativize_instance(inst: &CloneInstance, root: &Path) -> CloneInstance {
1308    let rel = inst.file.strip_prefix(root).map_or_else(
1309        |_| inst.file.clone(),
1310        |p| PathBuf::from(p.to_string_lossy().replace('\\', "/")),
1311    );
1312    CloneInstance {
1313        file: rel,
1314        ..inst.clone()
1315    }
1316}
1317
1318#[cfg(test)]
1319mod tests {
1320    use super::*;
1321
1322    use crate::discover::{DiscoveredFile, EntryPoint, EntryPointSource, FileId};
1323    use fallow_graph::resolve::{ResolveResult, ResolvedImport, ResolvedModule, ResolvedReExport};
1324    use fallow_types::extract::ReExportInfo;
1325    use fallow_types::extract::{ExportInfo, ExportName, ImportInfo, ImportedName, VisibilityTag};
1326
1327    fn resolved_re_export(
1328        source: FileId,
1329        imported_name: &str,
1330        exported_name: &str,
1331    ) -> ResolvedReExport {
1332        ResolvedReExport {
1333            info: ReExportInfo {
1334                source: "./source".to_string(),
1335                imported_name: imported_name.to_string(),
1336                exported_name: exported_name.to_string(),
1337                is_type_only: false,
1338                span: oxc_span::Span::default(),
1339                statement_span: oxc_span::Span::default(),
1340                source_span: oxc_span::Span::default(),
1341            },
1342            target: ResolveResult::InternalModule(source),
1343        }
1344    }
1345
1346    fn build_test_graph() -> ModuleGraph {
1347        let files = vec![
1348            DiscoveredFile {
1349                id: FileId(0),
1350                path: PathBuf::from("/project/src/entry.ts"),
1351                size_bytes: 100,
1352            },
1353            DiscoveredFile {
1354                id: FileId(1),
1355                path: PathBuf::from("/project/src/utils.ts"),
1356                size_bytes: 50,
1357            },
1358            DiscoveredFile {
1359                id: FileId(2),
1360                path: PathBuf::from("/project/src/unused.ts"),
1361                size_bytes: 30,
1362            },
1363        ];
1364
1365        let entry_points = vec![EntryPoint {
1366            path: PathBuf::from("/project/src/entry.ts"),
1367            source: EntryPointSource::PackageJsonMain,
1368        }];
1369
1370        let resolved_modules = vec![
1371            ResolvedModule {
1372                file_id: FileId(0),
1373                path: PathBuf::from("/project/src/entry.ts"),
1374                resolved_imports: vec![ResolvedImport {
1375                    info: ImportInfo {
1376                        source: "./utils".to_string(),
1377                        imported_name: ImportedName::Named("foo".to_string()),
1378                        local_name: "foo".to_string(),
1379                        is_type_only: false,
1380                        is_type_only_star: false,
1381                        from_style: false,
1382                        span: oxc_span::Span::new(0, 10),
1383                        source_span: oxc_span::Span::default(),
1384                    },
1385                    target: ResolveResult::InternalModule(FileId(1)),
1386                }],
1387                ..Default::default()
1388            },
1389            ResolvedModule {
1390                file_id: FileId(1),
1391                path: PathBuf::from("/project/src/utils.ts"),
1392                exports: vec![
1393                    ExportInfo {
1394                        name: ExportName::Named("foo".to_string()),
1395                        local_name: Some("foo".to_string()),
1396                        is_type_only: false,
1397                        visibility: VisibilityTag::None,
1398                        expected_unused_reason: None,
1399                        span: oxc_span::Span::new(0, 20),
1400                        members: vec![],
1401                        is_side_effect_used: false,
1402                        super_class: None,
1403                        deprecated: false,
1404                        deprecated_reason: None,
1405                    },
1406                    ExportInfo {
1407                        name: ExportName::Named("bar".to_string()),
1408                        local_name: Some("bar".to_string()),
1409                        is_type_only: false,
1410                        visibility: VisibilityTag::None,
1411                        expected_unused_reason: None,
1412                        span: oxc_span::Span::new(21, 40),
1413                        members: vec![],
1414                        is_side_effect_used: false,
1415                        super_class: None,
1416                        deprecated: false,
1417                        deprecated_reason: None,
1418                    },
1419                ]
1420                .into(),
1421                ..Default::default()
1422            },
1423            ResolvedModule {
1424                file_id: FileId(2),
1425                path: PathBuf::from("/project/src/unused.ts"),
1426                exports: vec![ExportInfo {
1427                    name: ExportName::Named("baz".to_string()),
1428                    local_name: Some("baz".to_string()),
1429                    is_type_only: false,
1430                    visibility: VisibilityTag::None,
1431                    expected_unused_reason: None,
1432                    span: oxc_span::Span::new(0, 15),
1433                    members: vec![],
1434                    is_side_effect_used: false,
1435                    super_class: None,
1436                    deprecated: false,
1437                    deprecated_reason: None,
1438                }]
1439                .into(),
1440                ..Default::default()
1441            },
1442        ];
1443
1444        ModuleGraph::build(&resolved_modules, &entry_points, &files)
1445    }
1446
1447    #[test]
1448    fn trace_used_export() {
1449        let graph = build_test_graph();
1450        let root = Path::new("/project");
1451
1452        let trace = trace_export(&graph, root, "src/utils.ts", "foo").unwrap();
1453        assert!(trace.is_used);
1454        assert!(trace.file_reachable);
1455        assert_eq!(trace.direct_references.len(), 1);
1456        assert_eq!(
1457            trace.direct_references[0].from_file,
1458            PathBuf::from("src/entry.ts")
1459        );
1460        assert_eq!(trace.direct_references[0].kind, "named import");
1461    }
1462
1463    #[test]
1464    fn trace_unused_export() {
1465        let graph = build_test_graph();
1466        let root = Path::new("/project");
1467
1468        let trace = trace_export(&graph, root, "src/utils.ts", "bar").unwrap();
1469        assert!(!trace.is_used);
1470        assert!(trace.file_reachable);
1471        assert!(trace.direct_references.is_empty());
1472        assert_eq!(
1473            trace.namespace,
1474            fallow_types::semantic::SemanticNamespace::Value,
1475            "an unreferenced value export stays in the value namespace"
1476        );
1477    }
1478
1479    #[test]
1480    fn trace_unreachable_file_export() {
1481        let graph = build_test_graph();
1482        let root = Path::new("/project");
1483
1484        let trace = trace_export(&graph, root, "src/unused.ts", "baz").unwrap();
1485        assert!(!trace.is_used);
1486        assert!(!trace.file_reachable);
1487        assert!(trace.reason.contains("unreachable"));
1488    }
1489
1490    #[test]
1491    fn trace_nonexistent_export() {
1492        let graph = build_test_graph();
1493        let root = Path::new("/project");
1494
1495        let trace = trace_export(&graph, root, "src/utils.ts", "nonexistent");
1496        assert!(trace.is_none());
1497    }
1498
1499    #[test]
1500    fn trace_reports_only_the_effective_re_export_origin() {
1501        let files: Vec<_> = ["entry", "barrel", "star-source", "explicit-source"]
1502            .into_iter()
1503            .enumerate()
1504            .map(|(index, name)| DiscoveredFile {
1505                id: FileId(index as u32),
1506                path: PathBuf::from(format!("/project/src/{name}.ts")),
1507                size_bytes: 10,
1508            })
1509            .collect();
1510        let entry_points = vec![EntryPoint {
1511            path: files[0].path.clone(),
1512            source: EntryPointSource::PackageJsonMain,
1513        }];
1514        let re_export = |source: FileId, imported: &str, exported: &str| ResolvedReExport {
1515            info: ReExportInfo {
1516                source: format!("./{}", source.0),
1517                imported_name: imported.to_string(),
1518                exported_name: exported.to_string(),
1519                is_type_only: false,
1520                span: oxc_span::Span::default(),
1521                statement_span: oxc_span::Span::default(),
1522                source_span: oxc_span::Span::default(),
1523            },
1524            target: ResolveResult::InternalModule(source),
1525        };
1526        let export = || ExportInfo {
1527            name: ExportName::Named("foo".to_string()),
1528            local_name: Some("foo".to_string()),
1529            is_type_only: false,
1530            visibility: VisibilityTag::None,
1531            expected_unused_reason: None,
1532            span: oxc_span::Span::new(0, 3),
1533            members: Vec::new(),
1534            is_side_effect_used: false,
1535            super_class: None,
1536            deprecated: false,
1537            deprecated_reason: None,
1538        };
1539        let resolved = vec![
1540            ResolvedModule {
1541                file_id: FileId(0),
1542                path: files[0].path.clone(),
1543                resolved_imports: vec![ResolvedImport {
1544                    info: ImportInfo {
1545                        source: "./barrel".to_string(),
1546                        imported_name: ImportedName::Named("foo".to_string()),
1547                        local_name: "foo".to_string(),
1548                        is_type_only: false,
1549                        is_type_only_star: false,
1550                        from_style: false,
1551                        span: oxc_span::Span::default(),
1552                        source_span: oxc_span::Span::default(),
1553                    },
1554                    target: ResolveResult::InternalModule(FileId(1)),
1555                }],
1556                ..Default::default()
1557            },
1558            ResolvedModule {
1559                file_id: FileId(1),
1560                path: files[1].path.clone(),
1561                re_exports: vec![
1562                    re_export(FileId(2), "*", "*"),
1563                    re_export(FileId(3), "foo", "foo"),
1564                ],
1565                ..Default::default()
1566            },
1567            ResolvedModule {
1568                file_id: FileId(2),
1569                path: files[2].path.clone(),
1570                exports: vec![export()].into(),
1571                ..Default::default()
1572            },
1573            ResolvedModule {
1574                file_id: FileId(3),
1575                path: files[3].path.clone(),
1576                exports: vec![export()].into(),
1577                ..Default::default()
1578            },
1579        ];
1580        let graph = ModuleGraph::build(&resolved, &entry_points, &files);
1581
1582        let shadowed = trace_export(&graph, Path::new("/project"), "src/star-source.ts", "foo")
1583            .expect("shadowed source export exists");
1584        let effective = trace_export(
1585            &graph,
1586            Path::new("/project"),
1587            "src/explicit-source.ts",
1588            "foo",
1589        )
1590        .expect("effective source export exists");
1591
1592        assert!(shadowed.re_export_chains.is_empty());
1593        assert_eq!(effective.re_export_chains.len(), 1);
1594        assert_eq!(effective.re_export_chains[0].exported_as, "foo");
1595    }
1596
1597    fn star_surface_trace_graph(root: &Path) -> ModuleGraph {
1598        let src = root.join("src");
1599        std::fs::create_dir_all(&src).expect("create source directory");
1600        let paths: Vec<_> = ["source", "barrel-a", "barrel-b", "outer", "entry"]
1601            .into_iter()
1602            .map(|name| src.join(format!("{name}.ts")))
1603            .collect();
1604        std::fs::write(&paths[0], "\n\nexport const foo = 1;\n").expect("write source");
1605        for path in &paths[1..] {
1606            std::fs::write(path, "export {};\n").expect("write module");
1607        }
1608        let files: Vec<_> = paths
1609            .iter()
1610            .enumerate()
1611            .map(|(index, path)| DiscoveredFile {
1612                id: FileId(index as u32),
1613                path: path.clone(),
1614                size_bytes: 20,
1615            })
1616            .collect();
1617        let resolved = vec![
1618            ResolvedModule {
1619                file_id: FileId(0),
1620                path: paths[0].clone(),
1621                exports: vec![ExportInfo {
1622                    name: ExportName::Named("foo".to_string()),
1623                    local_name: Some("foo".to_string()),
1624                    is_type_only: false,
1625                    visibility: VisibilityTag::None,
1626                    expected_unused_reason: None,
1627                    span: oxc_span::Span::new(2, 5),
1628                    members: Vec::new(),
1629                    is_side_effect_used: false,
1630                    super_class: None,
1631                    deprecated: false,
1632                    deprecated_reason: None,
1633                }]
1634                .into(),
1635                ..Default::default()
1636            },
1637            ResolvedModule {
1638                file_id: FileId(1),
1639                path: paths[1].clone(),
1640                re_exports: vec![resolved_re_export(FileId(0), "*", "*")],
1641                ..Default::default()
1642            },
1643            ResolvedModule {
1644                file_id: FileId(2),
1645                path: paths[2].clone(),
1646                re_exports: vec![resolved_re_export(FileId(0), "*", "*")],
1647                ..Default::default()
1648            },
1649            ResolvedModule {
1650                file_id: FileId(3),
1651                path: paths[3].clone(),
1652                re_exports: vec![
1653                    resolved_re_export(FileId(1), "foo", "left"),
1654                    resolved_re_export(FileId(1), "foo", "right"),
1655                ],
1656                ..Default::default()
1657            },
1658            ResolvedModule {
1659                file_id: FileId(4),
1660                path: paths[4].clone(),
1661                resolved_imports: vec![
1662                    ResolvedImport {
1663                        info: ImportInfo {
1664                            source: "./outer".to_string(),
1665                            imported_name: ImportedName::Named("left".to_string()),
1666                            local_name: "left".to_string(),
1667                            is_type_only: false,
1668                            is_type_only_star: false,
1669                            from_style: false,
1670                            span: oxc_span::Span::new(10, 20),
1671                            source_span: oxc_span::Span::default(),
1672                        },
1673                        target: ResolveResult::InternalModule(FileId(3)),
1674                    },
1675                    ResolvedImport {
1676                        info: ImportInfo {
1677                            source: "./barrel-b".to_string(),
1678                            imported_name: ImportedName::Named("foo".to_string()),
1679                            local_name: "otherFoo".to_string(),
1680                            is_type_only: false,
1681                            is_type_only_star: false,
1682                            from_style: false,
1683                            span: oxc_span::Span::new(30, 40),
1684                            source_span: oxc_span::Span::default(),
1685                        },
1686                        target: ResolveResult::InternalModule(FileId(2)),
1687                    },
1688                ],
1689                ..Default::default()
1690            },
1691        ];
1692        let entry_points = vec![EntryPoint {
1693            path: paths[4].clone(),
1694            source: EntryPointSource::PackageJsonMain,
1695        }];
1696        ModuleGraph::build(&resolved, &entry_points, &files)
1697    }
1698
1699    #[test]
1700    fn star_surface_trace_keeps_aliases_separate_and_uses_origin_identity() {
1701        let root = tempfile::tempdir().expect("temporary project");
1702        let graph = star_surface_trace_graph(root.path());
1703
1704        let used = trace_export(&graph, root.path(), "src/barrel-a.ts", "foo")
1705            .expect("aliased barrel exposes foo");
1706        let sibling = trace_export(&graph, root.path(), "src/barrel-b.ts", "foo")
1707            .expect("sibling barrel exposes foo");
1708        assert!(used.is_used);
1709        assert_eq!(used.direct_references.len(), 1);
1710        assert!(sibling.is_used);
1711        assert_eq!(sibling.direct_references.len(), 1);
1712
1713        let source_trace = trace_export(&graph, root.path(), "src/source.ts", "foo")
1714            .expect("source declaration is traceable");
1715        let chain_count = |file: &str, name: &str| {
1716            source_trace
1717                .re_export_chains
1718                .iter()
1719                .find(|chain| chain.barrel_file == Path::new(file) && chain.exported_as == name)
1720                .map(|chain| chain.reference_count)
1721        };
1722        assert_eq!(chain_count("src/barrel-a.ts", "foo"), Some(1));
1723        assert_eq!(chain_count("src/barrel-b.ts", "foo"), Some(1));
1724        assert_eq!(chain_count("src/outer.ts", "left"), Some(1));
1725        assert_eq!(chain_count("src/outer.ts", "right"), Some(0));
1726
1727        let semantic = semantic_symbol_for_export(&graph, root.path(), "src/barrel-a.ts", "foo")
1728            .expect("star surface resolves to its declaration identity");
1729        assert_eq!(semantic.path, Path::new("src/source.ts"));
1730        assert_eq!(semantic.exported_name, "foo");
1731        assert_eq!(semantic.local_name, "foo");
1732        assert_eq!((semantic.line, semantic.col), (3, 0));
1733
1734        let alias = semantic_symbol_for_export(&graph, root.path(), "src/outer.ts", "left")
1735            .expect("named re-export keeps its export-specifier identity");
1736        assert_eq!(alias.path, Path::new("src/outer.ts"));
1737        assert_eq!(alias.exported_name, "left");
1738        assert_eq!(alias.local_name, "foo");
1739    }
1740
1741    #[test]
1742    fn trace_follows_renamed_and_convergent_re_export_routes() {
1743        let names = [
1744            "source",
1745            "renamed",
1746            "final",
1747            "left",
1748            "right",
1749            "diamond-entry",
1750        ];
1751        let files: Vec<_> = names
1752            .into_iter()
1753            .enumerate()
1754            .map(|(index, name)| DiscoveredFile {
1755                id: FileId(index as u32),
1756                path: PathBuf::from(format!("/project/src/{name}.ts")),
1757                size_bytes: 10,
1758            })
1759            .collect();
1760        let re_export = |source: FileId, imported: &str, exported: &str| ResolvedReExport {
1761            info: ReExportInfo {
1762                source: format!("./{}", source.0),
1763                imported_name: imported.to_string(),
1764                exported_name: exported.to_string(),
1765                is_type_only: false,
1766                span: oxc_span::Span::default(),
1767                statement_span: oxc_span::Span::default(),
1768                source_span: oxc_span::Span::default(),
1769            },
1770            target: ResolveResult::InternalModule(source),
1771        };
1772        let mut resolved: Vec<_> = files
1773            .iter()
1774            .map(|file| ResolvedModule {
1775                file_id: file.id,
1776                path: file.path.clone(),
1777                ..Default::default()
1778            })
1779            .collect();
1780        resolved[0].exports = vec![ExportInfo {
1781            name: ExportName::Named("foo".to_string()),
1782            local_name: Some("foo".to_string()),
1783            is_type_only: false,
1784            visibility: VisibilityTag::None,
1785            expected_unused_reason: None,
1786            span: oxc_span::Span::new(0, 3),
1787            members: Vec::new(),
1788            is_side_effect_used: false,
1789            super_class: None,
1790            deprecated: false,
1791            deprecated_reason: None,
1792        }]
1793        .into();
1794        resolved[1].re_exports = vec![re_export(FileId(0), "foo", "bar")];
1795        resolved[2].re_exports = vec![re_export(FileId(1), "bar", "baz")];
1796        resolved[3].re_exports = vec![re_export(FileId(0), "*", "*")];
1797        resolved[4].re_exports = vec![re_export(FileId(0), "*", "*")];
1798        resolved[5].re_exports = vec![
1799            re_export(FileId(3), "*", "*"),
1800            re_export(FileId(4), "*", "*"),
1801        ];
1802        let entry_points = vec![EntryPoint {
1803            path: files[5].path.clone(),
1804            source: EntryPointSource::PackageJsonMain,
1805        }];
1806        let graph = ModuleGraph::build(&resolved, &entry_points, &files);
1807
1808        let trace = trace_export(&graph, Path::new("/project"), "src/source.ts", "foo")
1809            .expect("source export exists");
1810        let routes: FxHashSet<_> = trace
1811            .re_export_chains
1812            .iter()
1813            .map(|route| (route.barrel_file.as_path(), route.exported_as.as_str()))
1814            .collect();
1815
1816        assert_eq!(routes.len(), 5);
1817        assert!(routes.contains(&(Path::new("src/renamed.ts"), "bar")));
1818        assert!(routes.contains(&(Path::new("src/final.ts"), "baz")));
1819        assert!(routes.contains(&(Path::new("src/left.ts"), "foo")));
1820        assert!(routes.contains(&(Path::new("src/right.ts"), "foo")));
1821        assert!(routes.contains(&(Path::new("src/diamond-entry.ts"), "foo")));
1822    }
1823
1824    #[test]
1825    fn trace_prefers_the_value_namespace_independent_of_usage() {
1826        let files = vec![
1827            DiscoveredFile {
1828                id: FileId(0),
1829                path: PathBuf::from("/project/src/entry.ts"),
1830                size_bytes: 10,
1831            },
1832            DiscoveredFile {
1833                id: FileId(1),
1834                path: PathBuf::from("/project/src/source.ts"),
1835                size_bytes: 10,
1836            },
1837        ];
1838        let export = |is_type_only| ExportInfo {
1839            name: ExportName::Named("Foo".to_string()),
1840            local_name: Some("Foo".to_string()),
1841            is_type_only,
1842            visibility: VisibilityTag::None,
1843            expected_unused_reason: None,
1844            span: oxc_span::Span::new(0, 3),
1845            members: Vec::new(),
1846            is_side_effect_used: false,
1847            super_class: None,
1848            deprecated: false,
1849            deprecated_reason: None,
1850        };
1851        let resolved = vec![
1852            ResolvedModule {
1853                file_id: FileId(0),
1854                path: files[0].path.clone(),
1855                resolved_imports: vec![ResolvedImport {
1856                    info: ImportInfo {
1857                        source: "./source".to_string(),
1858                        imported_name: ImportedName::Named("Foo".to_string()),
1859                        local_name: "Foo".to_string(),
1860                        is_type_only: true,
1861                        is_type_only_star: false,
1862                        from_style: false,
1863                        span: oxc_span::Span::default(),
1864                        source_span: oxc_span::Span::default(),
1865                    },
1866                    target: ResolveResult::InternalModule(FileId(1)),
1867                }],
1868                ..Default::default()
1869            },
1870            ResolvedModule {
1871                file_id: FileId(1),
1872                path: files[1].path.clone(),
1873                exports: vec![export(false), export(true)].into(),
1874                ..Default::default()
1875            },
1876        ];
1877        let entry_points = vec![EntryPoint {
1878            path: files[0].path.clone(),
1879            source: EntryPointSource::PackageJsonMain,
1880        }];
1881        let graph = ModuleGraph::build(&resolved, &entry_points, &files);
1882
1883        let trace = trace_export(&graph, Path::new("/project"), "src/source.ts", "Foo")
1884            .expect("value export exists");
1885
1886        // The type import credits the distinct `export type Foo` declaration,
1887        // so dead-code still reports the value `Foo`; the trace must agree and
1888        // must not borrow the type lane here (issue #2371). A declaration
1889        // merge that splits across lanes, `interface Foo` next to `class Foo`,
1890        // reaches the graph as this same pair of surfaces, so it is the shape
1891        // the documented gap names: dead-code credits the class through the
1892        // merge while the trace still reports it unused.
1893        assert_eq!(
1894            trace.namespace,
1895            fallow_types::semantic::SemanticNamespace::Value
1896        );
1897        assert!(!trace.is_used, "type usage must not select the type export");
1898    }
1899
1900    /// Consumer `entry.ts` importing `Foo` from `source.ts`, whose exports are
1901    /// supplied by the caller.
1902    fn source_consumer_graph(
1903        source_exports: Vec<ExportInfo>,
1904        import_is_type_only: bool,
1905        classified_usage: bool,
1906        semantic_facts: Vec<fallow_types::extract::SemanticFact>,
1907    ) -> ModuleGraph {
1908        let files = vec![
1909            DiscoveredFile {
1910                id: FileId(0),
1911                path: PathBuf::from("/project/src/entry.ts"),
1912                size_bytes: 10,
1913            },
1914            DiscoveredFile {
1915                id: FileId(1),
1916                path: PathBuf::from("/project/src/source.ts"),
1917                size_bytes: 10,
1918            },
1919        ];
1920        let classified = if classified_usage {
1921            vec!["Foo".to_string()]
1922        } else {
1923            Vec::new()
1924        };
1925        let resolved = vec![
1926            ResolvedModule {
1927                file_id: FileId(0),
1928                path: files[0].path.clone(),
1929                resolved_imports: vec![ResolvedImport {
1930                    info: ImportInfo {
1931                        source: "./source".to_string(),
1932                        imported_name: ImportedName::Named("Foo".to_string()),
1933                        local_name: "Foo".to_string(),
1934                        is_type_only: import_is_type_only,
1935                        is_type_only_star: false,
1936                        from_style: false,
1937                        span: oxc_span::Span::new(0, 10),
1938                        source_span: oxc_span::Span::default(),
1939                    },
1940                    target: ResolveResult::InternalModule(FileId(1)),
1941                }],
1942                type_referenced_import_bindings: classified.clone(),
1943                value_referenced_import_bindings: classified,
1944                ..Default::default()
1945            },
1946            ResolvedModule {
1947                file_id: FileId(1),
1948                path: files[1].path.clone(),
1949                exports: source_exports.into(),
1950                semantic_facts: semantic_facts.into(),
1951                ..Default::default()
1952            },
1953        ];
1954        let entry_points = vec![EntryPoint {
1955            path: files[0].path.clone(),
1956            source: EntryPointSource::PackageJsonMain,
1957        }];
1958        ModuleGraph::build(&resolved, &entry_points, &files)
1959    }
1960
1961    /// Two consumers of `source.ts`: `entry.ts` imports `Foo` in value
1962    /// position and `typed.ts` imports the same name with `import type`, so
1963    /// one effective binding carries a reference in both lanes.
1964    fn dual_lane_consumer_graph(source_exports: Vec<ExportInfo>) -> ModuleGraph {
1965        let files = vec![
1966            DiscoveredFile {
1967                id: FileId(0),
1968                path: PathBuf::from("/project/src/entry.ts"),
1969                size_bytes: 10,
1970            },
1971            DiscoveredFile {
1972                id: FileId(1),
1973                path: PathBuf::from("/project/src/source.ts"),
1974                size_bytes: 10,
1975            },
1976            DiscoveredFile {
1977                id: FileId(2),
1978                path: PathBuf::from("/project/src/typed.ts"),
1979                size_bytes: 10,
1980            },
1981        ];
1982        let consumer = |file_id: FileId, path: PathBuf, is_type_only: bool| ResolvedModule {
1983            file_id,
1984            path,
1985            resolved_imports: vec![ResolvedImport {
1986                info: ImportInfo {
1987                    source: "./source".to_string(),
1988                    imported_name: ImportedName::Named("Foo".to_string()),
1989                    local_name: "Foo".to_string(),
1990                    is_type_only,
1991                    is_type_only_star: false,
1992                    from_style: false,
1993                    span: oxc_span::Span::new(0, 10),
1994                    source_span: oxc_span::Span::default(),
1995                },
1996                target: ResolveResult::InternalModule(FileId(1)),
1997            }],
1998            ..Default::default()
1999        };
2000        let resolved = vec![
2001            consumer(FileId(0), files[0].path.clone(), false),
2002            ResolvedModule {
2003                file_id: FileId(1),
2004                path: files[1].path.clone(),
2005                exports: source_exports.into(),
2006                ..Default::default()
2007            },
2008            consumer(FileId(2), files[2].path.clone(), true),
2009        ];
2010        let entry_points = vec![
2011            EntryPoint {
2012                path: files[0].path.clone(),
2013                source: EntryPointSource::PackageJsonMain,
2014            },
2015            EntryPoint {
2016                path: files[2].path.clone(),
2017                source: EntryPointSource::PackageJsonMain,
2018            },
2019        ];
2020        ModuleGraph::build(&resolved, &entry_points, &files)
2021    }
2022
2023    fn named_foo_export(is_type_only: bool) -> ExportInfo {
2024        ExportInfo {
2025            name: ExportName::Named("Foo".to_string()),
2026            local_name: Some("Foo".to_string()),
2027            is_type_only,
2028            visibility: VisibilityTag::None,
2029            expected_unused_reason: None,
2030            span: oxc_span::Span::new(0, 3),
2031            members: Vec::new(),
2032            is_side_effect_used: false,
2033            super_class: None,
2034            deprecated: false,
2035            deprecated_reason: None,
2036        }
2037    }
2038
2039    #[test]
2040    fn trace_credits_a_value_only_export_through_the_type_lane() {
2041        // Issue #2371: `import type { Foo }` of `export const Foo` lands on the
2042        // value declaration through the type-lane fallback, and dead-code
2043        // counts that as a use. The trace reports the crediting lane.
2044        let graph = source_consumer_graph(vec![named_foo_export(false)], true, false, Vec::new());
2045
2046        let trace = trace_export(&graph, Path::new("/project"), "src/source.ts", "Foo")
2047            .expect("value export exists");
2048
2049        assert_eq!(
2050            trace.namespace,
2051            fallow_types::semantic::SemanticNamespace::Type,
2052            "the type lane carries the only credit"
2053        );
2054        assert!(
2055            trace.is_used,
2056            "a type-only import credits a value-only export"
2057        );
2058        assert_eq!(trace.direct_references.len(), 1);
2059        assert_eq!(
2060            trace.direct_references[0].from_file,
2061            PathBuf::from("src/entry.ts")
2062        );
2063        assert_eq!(trace.direct_references[0].kind, "named import");
2064        assert_eq!(trace.reason, "Used by 1 file(s)");
2065    }
2066
2067    #[test]
2068    fn trace_omits_redundant_single_namespace_evidence() {
2069        let graph = source_consumer_graph(vec![named_foo_export(false)], false, false, Vec::new());
2070
2071        let trace = trace_export(&graph, Path::new("/project"), "src/source.ts", "Foo")
2072            .expect("value export exists");
2073
2074        assert_eq!(
2075            trace.namespace,
2076            fallow_types::semantic::SemanticNamespace::Value
2077        );
2078        assert_eq!(trace.direct_references.len(), 1);
2079        assert!(trace.direct_references_by_namespace.is_empty());
2080        let json = serde_json::to_value(&trace).expect("serialize trace");
2081        assert!(json.get("direct_references_by_namespace").is_none());
2082    }
2083
2084    #[test]
2085    fn trace_keeps_the_value_lane_when_one_binding_carries_both_lanes() {
2086        // The preferred lane wins whenever it carries a reference, including
2087        // when the other lane resolves to the SAME binding and carries one
2088        // too: `export class Foo` consumed by a value importer and by an
2089        // `import type` importer must keep reporting the value consumer.
2090        // Without that rule the type lane would take over the payload.
2091        let graph = dual_lane_consumer_graph(vec![named_foo_export(false)]);
2092
2093        let trace = trace_export(&graph, Path::new("/project"), "src/source.ts", "Foo")
2094            .expect("value export exists");
2095
2096        assert_eq!(
2097            trace.namespace,
2098            fallow_types::semantic::SemanticNamespace::Value
2099        );
2100        assert!(trace.is_used);
2101        assert_eq!(trace.direct_references.len(), 1);
2102        assert_eq!(
2103            trace.direct_references[0].from_file,
2104            PathBuf::from("src/entry.ts"),
2105            "the value consumer stays the listed reference"
2106        );
2107        assert_eq!(trace.direct_references_by_namespace.len(), 2);
2108        assert!(trace.direct_references_by_namespace.iter().any(|lane| {
2109            lane.namespace == fallow_types::semantic::SemanticNamespace::Type
2110                && lane.reference_count == 1
2111                && lane.references[0].from_file == Path::new("src/typed.ts")
2112        }));
2113    }
2114
2115    #[test]
2116    fn trace_credits_a_declaration_merge_that_stays_one_binding() {
2117        // A merge whose parts share the value lane, `class Foo` next to
2118        // `namespace Foo`, is one effective binding in both lanes, so a bound
2119        // `import type` credits it and the trace reports the crediting lane.
2120        let graph = source_consumer_graph(
2121            vec![named_foo_export(false), named_foo_export(false)],
2122            true,
2123            false,
2124            Vec::new(),
2125        );
2126
2127        let trace = trace_export(&graph, Path::new("/project"), "src/source.ts", "Foo")
2128            .expect("value export exists");
2129
2130        assert_eq!(
2131            trace.namespace,
2132            fallow_types::semantic::SemanticNamespace::Type,
2133            "the merged binding is reachable from the type lane"
2134        );
2135        assert!(trace.is_used);
2136        assert_eq!(trace.direct_references.len(), 1);
2137        assert_eq!(
2138            trace.direct_references[0].from_file,
2139            PathBuf::from("src/entry.ts")
2140        );
2141    }
2142
2143    #[test]
2144    fn trace_credits_a_class_interface_declaration_merge() {
2145        let mut interface = named_foo_export(true);
2146        interface.span = oxc_span::Span::new(0, 3);
2147        let mut class = named_foo_export(false);
2148        class.span = oxc_span::Span::new(4, 7);
2149        let graph = source_consumer_graph(
2150            vec![interface, class],
2151            true,
2152            true,
2153            vec![fallow_types::extract::SemanticFact::DeclarationMerge(
2154                fallow_types::extract::DeclarationMergeFact {
2155                    export_spans: vec![(0, 3), (4, 7)],
2156                },
2157            )],
2158        );
2159
2160        let trace = trace_export(&graph, Path::new("/project"), "src/source.ts", "Foo")
2161            .expect("merged class export exists");
2162
2163        assert_eq!(
2164            trace.namespace,
2165            fallow_types::semantic::SemanticNamespace::Type
2166        );
2167        assert!(trace.is_used);
2168        assert_eq!(trace.direct_references.len(), 1);
2169    }
2170
2171    #[test]
2172    fn trace_keeps_the_value_namespace_when_lanes_hold_distinct_bindings() {
2173        // Deliberate negative control: two same-name declarations in opposite
2174        // lanes are two bindings, so the value lane is kept even though the
2175        // type lane also carries a reference. The value lane holds the
2176        // reference here, so this pins the preferred-lane rule;
2177        // `trace_prefers_the_value_namespace_independent_of_usage` is the test
2178        // that reaches and pins the binding-equality guard.
2179        let graph = source_consumer_graph(
2180            vec![named_foo_export(false), named_foo_export(true)],
2181            false,
2182            true,
2183            Vec::new(),
2184        );
2185
2186        let trace = trace_export(&graph, Path::new("/project"), "src/source.ts", "Foo")
2187            .expect("value export exists");
2188
2189        assert_eq!(
2190            trace.namespace,
2191            fallow_types::semantic::SemanticNamespace::Value
2192        );
2193        assert!(trace.is_used);
2194        assert_eq!(trace.direct_references.len(), 1);
2195        assert_eq!(
2196            trace.direct_references[0].from_file,
2197            PathBuf::from("src/entry.ts")
2198        );
2199    }
2200
2201    #[test]
2202    fn class_member_trace_inherits_the_type_lane_credit_of_its_owner() {
2203        use fallow_types::extract::{MemberInfo, MemberKind};
2204
2205        // Issue #2371: the member trace is built from the owner's export
2206        // trace, so an owner credited only through the type lane reports a
2207        // used owner instead of the "referenced by no file" reason.
2208        let mut owner = named_foo_export(false);
2209        owner.members = vec![MemberInfo {
2210            name: "run".to_string(),
2211            kind: MemberKind::ClassMethod,
2212            span: oxc_span::Span::new(0, 3),
2213            has_decorator: false,
2214            decorator_names: vec![],
2215            is_instance_returning_static: false,
2216            is_self_returning: false,
2217        }];
2218        let graph = source_consumer_graph(vec![owner], true, false, Vec::new());
2219
2220        let trace = trace_class_member(&graph, Path::new("/project"), "src/source.ts", "run")
2221            .expect("member of the traced export");
2222
2223        assert!(trace.owner_is_used, "the type lane credits the owner");
2224        assert_eq!(
2225            trace.owner_namespace,
2226            fallow_types::semantic::SemanticNamespace::Type,
2227            "the member payload names the lane that credits its owner"
2228        );
2229        assert_eq!(trace.owner_direct_references.len(), 1);
2230        assert_eq!(
2231            trace.owner_direct_references[0].from_file,
2232            PathBuf::from("src/entry.ts")
2233        );
2234        assert!(
2235            trace.reason.contains("'Foo' is used by 1 file(s)"),
2236            "the reason must follow the owner's credit: {}",
2237            trace.reason
2238        );
2239    }
2240
2241    #[test]
2242    #[expect(
2243        clippy::too_many_lines,
2244        reason = "the fixture names every ExportInfo field so a new field is a compile error here"
2245    )]
2246    fn trace_preserves_dual_namespace_named_re_exports() {
2247        let files: Vec<_> = ["entry", "barrel", "types", "values"]
2248            .into_iter()
2249            .enumerate()
2250            .map(|(index, name)| DiscoveredFile {
2251                id: FileId(index as u32),
2252                path: PathBuf::from(format!("/project/src/{name}.ts")),
2253                size_bytes: 10,
2254            })
2255            .collect();
2256        let export = |is_type_only| ExportInfo {
2257            name: ExportName::Named("Foo".to_string()),
2258            local_name: Some("Foo".to_string()),
2259            is_type_only,
2260            visibility: VisibilityTag::None,
2261            expected_unused_reason: None,
2262            span: oxc_span::Span::new(0, 3),
2263            members: Vec::new(),
2264            is_side_effect_used: false,
2265            super_class: None,
2266            deprecated: false,
2267            deprecated_reason: None,
2268        };
2269        let re_export = |source: FileId, is_type_only| ResolvedReExport {
2270            info: ReExportInfo {
2271                source: format!("./{}", source.0),
2272                imported_name: "Foo".to_string(),
2273                exported_name: "Foo".to_string(),
2274                is_type_only,
2275                span: oxc_span::Span::default(),
2276                statement_span: oxc_span::Span::default(),
2277                source_span: oxc_span::Span::default(),
2278            },
2279            target: ResolveResult::InternalModule(source),
2280        };
2281        let mut resolved = vec![
2282            ResolvedModule {
2283                file_id: FileId(0),
2284                path: files[0].path.clone(),
2285                resolved_imports: vec![ResolvedImport {
2286                    info: ImportInfo {
2287                        source: "./barrel".to_string(),
2288                        imported_name: ImportedName::Named("Foo".to_string()),
2289                        local_name: "Foo".to_string(),
2290                        is_type_only: false,
2291                        is_type_only_star: false,
2292                        from_style: false,
2293                        span: oxc_span::Span::new(0, 10),
2294                        source_span: oxc_span::Span::default(),
2295                    },
2296                    target: ResolveResult::InternalModule(FileId(1)),
2297                }],
2298                ..Default::default()
2299            },
2300            ResolvedModule {
2301                file_id: FileId(1),
2302                path: files[1].path.clone(),
2303                re_exports: vec![re_export(FileId(2), true), re_export(FileId(3), false)],
2304                ..Default::default()
2305            },
2306            ResolvedModule {
2307                file_id: FileId(2),
2308                path: files[2].path.clone(),
2309                exports: vec![export(true)].into(),
2310                ..Default::default()
2311            },
2312            ResolvedModule {
2313                file_id: FileId(3),
2314                path: files[3].path.clone(),
2315                exports: vec![export(false)].into(),
2316                ..Default::default()
2317            },
2318        ];
2319        let entry_points = vec![EntryPoint {
2320            path: files[0].path.clone(),
2321            source: EntryPointSource::PackageJsonMain,
2322        }];
2323        let graph = ModuleGraph::build(&resolved, &entry_points, &files);
2324        resolved[1].re_exports.reverse();
2325        let reversed_graph = ModuleGraph::build(&resolved, &entry_points, &files);
2326
2327        let trace = trace_export(&graph, Path::new("/project"), "src/barrel.ts", "Foo")
2328            .expect("barrel exposes Foo in both namespaces");
2329
2330        assert_eq!(
2331            trace.namespace,
2332            fallow_types::semantic::SemanticNamespace::Value
2333        );
2334        assert!(
2335            trace.is_used,
2336            "the value import must credit the value surface"
2337        );
2338        assert_eq!(trace.direct_references.len(), 1);
2339        let reversed_trace = trace_export(
2340            &reversed_graph,
2341            Path::new("/project"),
2342            "src/barrel.ts",
2343            "Foo",
2344        )
2345        .expect("reversed declarations expose the same surface");
2346        assert_eq!(
2347            serde_json::to_value(trace).expect("serialize trace"),
2348            serde_json::to_value(reversed_trace).expect("serialize reversed trace")
2349        );
2350    }
2351
2352    fn build_class_member_graph() -> ModuleGraph {
2353        use fallow_types::extract::{MemberInfo, MemberKind};
2354
2355        let files = vec![
2356            DiscoveredFile {
2357                id: FileId(0),
2358                path: PathBuf::from("/project/src/entry.ts"),
2359                size_bytes: 100,
2360            },
2361            DiscoveredFile {
2362                id: FileId(1),
2363                path: PathBuf::from("/project/src/controller.ts"),
2364                size_bytes: 50,
2365            },
2366        ];
2367        let entry_points = vec![EntryPoint {
2368            path: PathBuf::from("/project/src/entry.ts"),
2369            source: EntryPointSource::PackageJsonMain,
2370        }];
2371        let method = |name: &str| MemberInfo {
2372            name: name.to_string(),
2373            kind: MemberKind::ClassMethod,
2374            span: oxc_span::Span::new(0, 4),
2375            has_decorator: false,
2376            decorator_names: vec![],
2377            is_instance_returning_static: false,
2378            is_self_returning: false,
2379        };
2380        let resolved_modules = vec![
2381            ResolvedModule {
2382                file_id: FileId(0),
2383                path: PathBuf::from("/project/src/entry.ts"),
2384                resolved_imports: vec![ResolvedImport {
2385                    info: ImportInfo {
2386                        source: "./controller".to_string(),
2387                        imported_name: ImportedName::Named("Ctrl".to_string()),
2388                        local_name: "Ctrl".to_string(),
2389                        is_type_only: false,
2390                        is_type_only_star: false,
2391                        from_style: false,
2392                        span: oxc_span::Span::new(0, 10),
2393                        source_span: oxc_span::Span::default(),
2394                    },
2395                    target: ResolveResult::InternalModule(FileId(1)),
2396                }],
2397                ..Default::default()
2398            },
2399            ResolvedModule {
2400                file_id: FileId(1),
2401                path: PathBuf::from("/project/src/controller.ts"),
2402                exports: vec![ExportInfo {
2403                    name: ExportName::Named("Ctrl".to_string()),
2404                    local_name: Some("Ctrl".to_string()),
2405                    is_type_only: false,
2406                    visibility: VisibilityTag::None,
2407                    expected_unused_reason: None,
2408                    span: oxc_span::Span::new(0, 20),
2409                    members: vec![method("used"), method("dead")],
2410                    is_side_effect_used: false,
2411                    super_class: None,
2412                    deprecated: false,
2413                    deprecated_reason: None,
2414                }]
2415                .into(),
2416                ..Default::default()
2417            },
2418        ];
2419        ModuleGraph::build(&resolved_modules, &entry_points, &files)
2420    }
2421
2422    #[test]
2423    fn trace_class_member_reports_owner_class() {
2424        // #1744: `--trace FILE:MEMBER` on a class member reports the owning
2425        // class instead of erroring "export not found".
2426        let graph = build_class_member_graph();
2427        let root = Path::new("/project");
2428
2429        let trace = trace_class_member(&graph, root, "src/controller.ts", "dead").unwrap();
2430        assert_eq!(trace.owner_export, "Ctrl");
2431        assert_eq!(trace.member_name, "dead");
2432        assert_eq!(trace.member_kind, "class-method");
2433        assert!(trace.owner_is_used);
2434        assert!(trace.owner_file_reachable);
2435        assert_eq!(trace.owner_direct_references.len(), 1);
2436        assert!(
2437            trace.reason.contains("--unused-class-members"),
2438            "reason should point at the member command: {}",
2439            trace.reason
2440        );
2441    }
2442
2443    #[test]
2444    fn trace_class_member_absent_name_is_none() {
2445        // A name that is neither a top-level export nor a declared member falls
2446        // through so the caller emits the "not found" error.
2447        let graph = build_class_member_graph();
2448        let root = Path::new("/project");
2449        assert!(trace_class_member(&graph, root, "src/controller.ts", "nope").is_none());
2450    }
2451
2452    fn exact_class_method_fixture() -> (tempfile::TempDir, ModuleGraph) {
2453        use fallow_types::extract::{MemberInfo, MemberKind};
2454
2455        let temp = tempfile::tempdir().unwrap();
2456        let root = temp.path();
2457        let path = root.join("repository.ts");
2458        let source =
2459            "export class Repository {\n  save(): void;\n  save(): void {}\n  run(): void {}\n}\n";
2460        std::fs::write(&path, source).unwrap();
2461        let first = source.find("save").unwrap() as u32;
2462        let second = source.rfind("save").unwrap() as u32;
2463        let run = source.find("run").unwrap() as u32;
2464        let member = |name: &str, start| MemberInfo {
2465            name: name.to_string(),
2466            kind: MemberKind::ClassMethod,
2467            span: oxc_span::Span::new(start, start + 4),
2468            has_decorator: false,
2469            decorator_names: vec![],
2470            is_instance_returning_static: false,
2471            is_self_returning: false,
2472        };
2473        let files = vec![DiscoveredFile {
2474            id: FileId(0),
2475            path: path.clone(),
2476            size_bytes: source.len() as u64,
2477        }];
2478        let resolved_modules = vec![ResolvedModule {
2479            file_id: FileId(0),
2480            path,
2481            exports: vec![ExportInfo {
2482                name: ExportName::Named("Repository".to_string()),
2483                local_name: Some("Repository".to_string()),
2484                is_type_only: false,
2485                visibility: VisibilityTag::None,
2486                expected_unused_reason: None,
2487                span: oxc_span::Span::new(0, source.len() as u32),
2488                members: vec![
2489                    member("save", first),
2490                    member("save", second),
2491                    member("run", run),
2492                ],
2493                is_side_effect_used: false,
2494                super_class: None,
2495                deprecated: false,
2496                deprecated_reason: None,
2497            }]
2498            .into(),
2499            ..Default::default()
2500        }];
2501        let graph = ModuleGraph::build(&resolved_modules, &[], &files);
2502        (temp, graph)
2503    }
2504
2505    #[test]
2506    fn exact_class_method_resolution_rejects_overloads_without_guessing() {
2507        let (temp, graph) = exact_class_method_fixture();
2508        let root = temp.path();
2509        assert_eq!(
2510            semantic_symbol_for_exact_class_method(
2511                &graph,
2512                root,
2513                "repository.ts",
2514                "Repository",
2515                "save",
2516            ),
2517            Err(SemanticClassMethodResolutionError::AmbiguousSymbol)
2518        );
2519        assert_eq!(
2520            semantic_symbol_for_exact_class_method(
2521                &graph,
2522                root,
2523                "repository.ts",
2524                "OtherRepository",
2525                "save",
2526            ),
2527            Err(SemanticClassMethodResolutionError::SymbolNotFound)
2528        );
2529        let resolved = semantic_symbol_for_exact_class_method(
2530            &graph,
2531            root,
2532            "repository.ts",
2533            "Repository",
2534            "run",
2535        )
2536        .unwrap();
2537        assert_eq!(resolved.owner.as_deref(), Some("Repository"));
2538        assert_eq!(resolved.local_name, "run");
2539    }
2540
2541    #[test]
2542    fn exact_class_method_resolution_preserves_error_precedence() {
2543        use fallow_types::extract::MemberKind;
2544
2545        let (temp, mut graph) = exact_class_method_fixture();
2546        let root = temp.path();
2547        for (file, owner, member, expected) in [
2548            (
2549                "missing.ts",
2550                "Repository",
2551                "run",
2552                SemanticClassMethodResolutionError::FileNotFound,
2553            ),
2554            (
2555                "repository.ts",
2556                "Repository",
2557                "missing",
2558                SemanticClassMethodResolutionError::SymbolNotFound,
2559            ),
2560        ] {
2561            assert_eq!(
2562                semantic_symbol_for_exact_class_method(&graph, root, file, owner, member),
2563                Err(expected),
2564            );
2565        }
2566        graph.modules[0].exports[0].members[2].kind = MemberKind::ClassProperty;
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::UnsupportedSyntax),
2576        );
2577        graph.modules[0].exports[0].members[2].kind = MemberKind::ClassMethod;
2578        std::fs::remove_file(&graph.modules[0].path).expect("remove fixture source");
2579        assert_eq!(
2580            semantic_symbol_for_exact_class_method(
2581                &graph,
2582                root,
2583                "repository.ts",
2584                "Repository",
2585                "run"
2586            ),
2587            Err(SemanticClassMethodResolutionError::SymbolNotFound),
2588        );
2589        let (_other_temp, mut other_graph) = exact_class_method_fixture();
2590        let duplicate = other_graph.modules[0]
2591            .exports
2592            .pop()
2593            .expect("fixture declares an owner");
2594        graph.modules[0].exports.push(duplicate);
2595        assert_eq!(
2596            semantic_symbol_for_exact_class_method(
2597                &graph,
2598                root,
2599                "repository.ts",
2600                "Repository",
2601                "run"
2602            ),
2603            Err(SemanticClassMethodResolutionError::AmbiguousSymbol),
2604            "owner ambiguity must be reported before reading source",
2605        );
2606    }
2607
2608    /// Build a graph where the controller declaring `Ctrl` is NOT imported by
2609    /// the entry, so its file is unreachable and every member is dead.
2610    fn build_unreachable_class_member_graph() -> ModuleGraph {
2611        use fallow_types::extract::{MemberInfo, MemberKind};
2612
2613        let files = vec![
2614            DiscoveredFile {
2615                id: FileId(0),
2616                path: PathBuf::from("/project/src/entry.ts"),
2617                size_bytes: 100,
2618            },
2619            DiscoveredFile {
2620                id: FileId(1),
2621                path: PathBuf::from("/project/src/controller.ts"),
2622                size_bytes: 50,
2623            },
2624        ];
2625        let entry_points = vec![EntryPoint {
2626            path: PathBuf::from("/project/src/entry.ts"),
2627            source: EntryPointSource::PackageJsonMain,
2628        }];
2629        let method = |name: &str| MemberInfo {
2630            name: name.to_string(),
2631            kind: MemberKind::ClassMethod,
2632            span: oxc_span::Span::new(0, 4),
2633            has_decorator: false,
2634            decorator_names: vec![],
2635            is_instance_returning_static: false,
2636            is_self_returning: false,
2637        };
2638        let resolved_modules = vec![
2639            ResolvedModule {
2640                file_id: FileId(0),
2641                path: PathBuf::from("/project/src/entry.ts"),
2642                // Entry imports nothing, so controller.ts is unreachable.
2643                ..Default::default()
2644            },
2645            ResolvedModule {
2646                file_id: FileId(1),
2647                path: PathBuf::from("/project/src/controller.ts"),
2648                exports: vec![ExportInfo {
2649                    name: ExportName::Named("Ctrl".to_string()),
2650                    local_name: Some("Ctrl".to_string()),
2651                    is_type_only: false,
2652                    visibility: VisibilityTag::None,
2653                    expected_unused_reason: None,
2654                    span: oxc_span::Span::new(0, 20),
2655                    members: vec![method("dead")],
2656                    is_side_effect_used: false,
2657                    super_class: None,
2658                    deprecated: false,
2659                    deprecated_reason: None,
2660                }]
2661                .into(),
2662                ..Default::default()
2663            },
2664        ];
2665        ModuleGraph::build(&resolved_modules, &entry_points, &files)
2666    }
2667
2668    #[test]
2669    fn trace_class_member_unreachable_owner_reports_dead_reason() {
2670        // `!file_reachable` branch: the owning file is not reachable from any
2671        // entry point, so the reason states the class and its members are dead.
2672        let graph = build_unreachable_class_member_graph();
2673        let root = Path::new("/project");
2674
2675        let trace = trace_class_member(&graph, root, "src/controller.ts", "dead").unwrap();
2676        assert!(!trace.owner_file_reachable);
2677        assert!(
2678            trace.reason.contains("not reachable"),
2679            "unreachable owner reason should say so: {}",
2680            trace.reason
2681        );
2682        // The unreachable branch does not point at a member command (the file is
2683        // dead wholesale via the unused-file finding).
2684        assert!(!trace.reason.contains("--unused-class-members"));
2685    }
2686
2687    #[test]
2688    fn trace_class_member_prefers_used_owner_on_name_collision() {
2689        // Two exports declare a member of the same name; the tie-break in
2690        // `max_by_key` must prefer the used, non-type-only owner so the trace
2691        // reports the reachable class rather than a type-only shadow.
2692        use fallow_types::extract::{MemberInfo, MemberKind};
2693
2694        let files = vec![
2695            DiscoveredFile {
2696                id: FileId(0),
2697                path: PathBuf::from("/project/src/entry.ts"),
2698                size_bytes: 100,
2699            },
2700            DiscoveredFile {
2701                id: FileId(1),
2702                path: PathBuf::from("/project/src/controller.ts"),
2703                size_bytes: 50,
2704            },
2705        ];
2706        let entry_points = vec![EntryPoint {
2707            path: PathBuf::from("/project/src/entry.ts"),
2708            source: EntryPointSource::PackageJsonMain,
2709        }];
2710        let method = |name: &str| MemberInfo {
2711            name: name.to_string(),
2712            kind: MemberKind::ClassMethod,
2713            span: oxc_span::Span::new(0, 4),
2714            has_decorator: false,
2715            decorator_names: vec![],
2716            is_instance_returning_static: false,
2717            is_self_returning: false,
2718        };
2719        let resolved_modules = vec![
2720            ResolvedModule {
2721                file_id: FileId(0),
2722                path: PathBuf::from("/project/src/entry.ts"),
2723                resolved_imports: vec![ResolvedImport {
2724                    info: ImportInfo {
2725                        source: "./controller".to_string(),
2726                        imported_name: ImportedName::Named("UsedCtrl".to_string()),
2727                        local_name: "UsedCtrl".to_string(),
2728                        is_type_only: false,
2729                        is_type_only_star: false,
2730                        from_style: false,
2731                        span: oxc_span::Span::new(0, 10),
2732                        source_span: oxc_span::Span::default(),
2733                    },
2734                    target: ResolveResult::InternalModule(FileId(1)),
2735                }],
2736                ..Default::default()
2737            },
2738            ResolvedModule {
2739                file_id: FileId(1),
2740                path: PathBuf::from("/project/src/controller.ts"),
2741                exports: vec![
2742                    // Type-only, unreferenced owner declared FIRST: must lose the
2743                    // tie-break to the used, non-type-only owner below.
2744                    ExportInfo {
2745                        name: ExportName::Named("TypeCtrl".to_string()),
2746                        local_name: Some("TypeCtrl".to_string()),
2747                        is_type_only: true,
2748                        visibility: VisibilityTag::None,
2749                        expected_unused_reason: None,
2750                        span: oxc_span::Span::new(0, 20),
2751                        members: vec![method("shared")],
2752                        is_side_effect_used: false,
2753                        super_class: None,
2754                        deprecated: false,
2755                        deprecated_reason: None,
2756                    },
2757                    ExportInfo {
2758                        name: ExportName::Named("UsedCtrl".to_string()),
2759                        local_name: Some("UsedCtrl".to_string()),
2760                        is_type_only: false,
2761                        visibility: VisibilityTag::None,
2762                        expected_unused_reason: None,
2763                        span: oxc_span::Span::new(0, 20),
2764                        members: vec![method("shared")],
2765                        is_side_effect_used: false,
2766                        super_class: None,
2767                        deprecated: false,
2768                        deprecated_reason: None,
2769                    },
2770                ]
2771                .into(),
2772                ..Default::default()
2773            },
2774        ];
2775        let graph = ModuleGraph::build(&resolved_modules, &entry_points, &files);
2776        let root = Path::new("/project");
2777
2778        let trace = trace_class_member(&graph, root, "src/controller.ts", "shared").unwrap();
2779        assert_eq!(
2780            trace.owner_export, "UsedCtrl",
2781            "tie-break must prefer the used, non-type-only owner"
2782        );
2783        assert!(trace.owner_is_used);
2784    }
2785
2786    #[test]
2787    fn trace_nonexistent_file() {
2788        let graph = build_test_graph();
2789        let root = Path::new("/project");
2790
2791        let trace = trace_export(&graph, root, "src/nope.ts", "foo");
2792        assert!(trace.is_none());
2793    }
2794
2795    #[test]
2796    fn trace_file_edges() {
2797        let graph = build_test_graph();
2798        let root = Path::new("/project");
2799
2800        let trace = trace_file(&graph, root, "src/entry.ts").unwrap();
2801        assert!(trace.is_entry_point);
2802        assert!(trace.is_reachable);
2803        assert_eq!(trace.imports_from.len(), 1);
2804        assert_eq!(trace.imports_from[0], PathBuf::from("src/utils.ts"));
2805        assert!(trace.imported_by.is_empty());
2806    }
2807
2808    fn build_nested_duplicate_path_graph() -> ModuleGraph {
2809        let export = |name: &str| ExportInfo {
2810            name: ExportName::Named(name.to_string()),
2811            local_name: Some(name.to_string()),
2812            is_type_only: false,
2813            visibility: VisibilityTag::None,
2814            expected_unused_reason: None,
2815            span: oxc_span::Span::new(0, 20),
2816            members: vec![],
2817            is_side_effect_used: false,
2818            super_class: None,
2819            deprecated: false,
2820            deprecated_reason: None,
2821        };
2822        let nested = PathBuf::from("/project/packages/x/src/a.ts");
2823        let root_file = PathBuf::from("/project/src/a.ts");
2824        let files = vec![
2825            DiscoveredFile {
2826                id: FileId(0),
2827                path: nested.clone(),
2828                size_bytes: 20,
2829            },
2830            DiscoveredFile {
2831                id: FileId(1),
2832                path: root_file.clone(),
2833                size_bytes: 20,
2834            },
2835        ];
2836        let entry_points = vec![EntryPoint {
2837            path: root_file.clone(),
2838            source: EntryPointSource::PackageJsonMain,
2839        }];
2840        let resolved_modules = vec![
2841            ResolvedModule {
2842                file_id: FileId(0),
2843                path: nested,
2844                exports: vec![export("nestedOnly")].into(),
2845                ..Default::default()
2846            },
2847            ResolvedModule {
2848                file_id: FileId(1),
2849                path: root_file,
2850                exports: vec![export("rootOnly")].into(),
2851                ..Default::default()
2852            },
2853        ];
2854        ModuleGraph::build(&resolved_modules, &entry_points, &files)
2855    }
2856
2857    fn graph_with_module_paths(paths: &[PathBuf]) -> ModuleGraph {
2858        let files: Vec<DiscoveredFile> = paths
2859            .iter()
2860            .enumerate()
2861            .map(|(index, path)| DiscoveredFile {
2862                id: FileId(u32::try_from(index).expect("small fixture")),
2863                path: path.clone(),
2864                size_bytes: 1,
2865            })
2866            .collect();
2867        let resolved_modules: Vec<ResolvedModule> = files
2868            .iter()
2869            .map(|file| ResolvedModule {
2870                file_id: file.id,
2871                path: file.path.clone(),
2872                ..Default::default()
2873            })
2874            .collect();
2875        ModuleGraph::build(&resolved_modules, &[], &files)
2876    }
2877
2878    fn assert_lookup_matches_single_scan(graph: &ModuleGraph, root: &Path, queries: &[&str]) {
2879        let lookup = ModulePathLookup::new(graph, root);
2880        for query in queries {
2881            assert_eq!(
2882                lookup.matching(query),
2883                matching_module_indexes(graph, root, query),
2884                "query {query:?}"
2885            );
2886        }
2887    }
2888
2889    const LOOKUP_QUERIES: &[&str] = &[
2890        "src/a.ts",
2891        "./src/a.ts",
2892        "src//a.ts",
2893        r"src\a.ts",
2894        "a.ts",
2895        "x/src/a.ts",
2896        "packages/x/src/a.ts",
2897        "sub/c.ts",
2898        "c.ts",
2899        "src/",
2900        "",
2901        "missing.ts",
2902        "/elsewhere/src/a.ts",
2903    ];
2904
2905    /// The indexed lookup must return the same modules, in the same order, as
2906    /// the single-scan lookup for exact, suffix, ambiguous and missing paths.
2907    #[test]
2908    fn module_path_lookup_matches_single_scan_lookup() {
2909        let root = Path::new("/project");
2910        let paths: Vec<PathBuf> = [
2911            "src/a.ts",
2912            "packages/x/src/a.ts",
2913            "packages/y/a.ts",
2914            "src/sub/c.ts",
2915            "lib/b.ts",
2916        ]
2917        .iter()
2918        .map(|relative| root.join(relative))
2919        .collect();
2920        let graph = graph_with_module_paths(&paths);
2921        let mut queries = LOOKUP_QUERIES.to_vec();
2922        let absolute = root.join("src/a.ts").to_string_lossy().into_owned();
2923        queries.push(&absolute);
2924        assert_lookup_matches_single_scan(&graph, root, &queries);
2925    }
2926
2927    /// With real files, the canonical-root and canonical-target forms take
2928    /// part too. On macOS the temp dir sits behind a symlink.
2929    #[test]
2930    fn module_path_lookup_matches_single_scan_lookup_on_disk() {
2931        let dir = tempfile::tempdir().expect("tempdir");
2932        let root = dir.path();
2933        for relative in ["src/a.ts", "packages/x/src/a.ts", "src/sub/c.ts"] {
2934            let path = root.join(relative);
2935            std::fs::create_dir_all(path.parent().expect("parent")).expect("mkdir");
2936            std::fs::write(&path, "").expect("write");
2937        }
2938        let canonical_root = dunce::canonicalize(root).expect("canonical root");
2939        let paths: Vec<PathBuf> = ["src/a.ts", "packages/x/src/a.ts", "src/sub/c.ts"]
2940            .iter()
2941            .map(|relative| canonical_root.join(relative))
2942            .collect();
2943        let graph = graph_with_module_paths(&paths);
2944        assert_lookup_matches_single_scan(&graph, root, LOOKUP_QUERIES);
2945    }
2946
2947    #[test]
2948    fn root_relative_path_prefers_exact_module_over_nested_suffix() {
2949        let graph = build_nested_duplicate_path_graph();
2950        let root = Path::new("/project");
2951
2952        let file = trace_file(&graph, root, "src/a.ts").expect("root file resolves");
2953        assert_eq!(file.file, PathBuf::from("src/a.ts"));
2954        assert!(file.is_entry_point);
2955
2956        let export = trace_export(&graph, root, "src/a.ts", "rootOnly");
2957        assert!(
2958            export.is_some(),
2959            "an export of the root-level file must resolve"
2960        );
2961    }
2962
2963    #[test]
2964    fn abbreviated_path_keeps_first_suffix_match() {
2965        let graph = build_nested_duplicate_path_graph();
2966        let root = Path::new("/project");
2967
2968        let file = trace_file(&graph, root, "a.ts").expect("suffix match resolves");
2969        assert_eq!(file.file, PathBuf::from("packages/x/src/a.ts"));
2970    }
2971
2972    #[test]
2973    fn trace_file_imported_by() {
2974        let graph = build_test_graph();
2975        let root = Path::new("/project");
2976
2977        let trace = trace_file(&graph, root, "src/utils.ts").unwrap();
2978        assert!(!trace.is_entry_point);
2979        assert!(trace.is_reachable);
2980        assert_eq!(trace.exports.len(), 2);
2981        assert_eq!(trace.imported_by.len(), 1);
2982        assert_eq!(trace.imported_by[0], PathBuf::from("src/entry.ts"));
2983    }
2984
2985    #[test]
2986    fn trace_unreachable_file() {
2987        let graph = build_test_graph();
2988        let root = Path::new("/project");
2989
2990        let trace = trace_file(&graph, root, "src/unused.ts").unwrap();
2991        assert!(!trace.is_reachable);
2992        assert!(!trace.is_entry_point);
2993        assert!(trace.imported_by.is_empty());
2994    }
2995
2996    #[test]
2997    fn trace_dependency_used() {
2998        let files = vec![DiscoveredFile {
2999            id: FileId(0),
3000            path: PathBuf::from("/project/src/app.ts"),
3001            size_bytes: 100,
3002        }];
3003        let entry_points = vec![EntryPoint {
3004            path: PathBuf::from("/project/src/app.ts"),
3005            source: EntryPointSource::PackageJsonMain,
3006        }];
3007        let resolved_modules = vec![ResolvedModule {
3008            file_id: FileId(0),
3009            path: PathBuf::from("/project/src/app.ts"),
3010            resolved_imports: vec![ResolvedImport {
3011                info: ImportInfo {
3012                    source: "lodash".to_string(),
3013                    imported_name: ImportedName::Named("get".to_string()),
3014                    local_name: "get".to_string(),
3015                    is_type_only: false,
3016                    is_type_only_star: false,
3017                    from_style: false,
3018                    span: oxc_span::Span::new(0, 10),
3019                    source_span: oxc_span::Span::default(),
3020                },
3021                target: ResolveResult::NpmPackage("lodash".to_string()),
3022            }],
3023            ..Default::default()
3024        }];
3025
3026        let graph = ModuleGraph::build(&resolved_modules, &entry_points, &files);
3027        let root = Path::new("/project");
3028
3029        let trace = trace_dependency(&graph, root, "lodash", &FxHashSet::default());
3030        assert!(trace.is_used);
3031        assert!(!trace.used_in_scripts);
3032        assert_eq!(trace.import_count, 1);
3033        assert_eq!(trace.imported_by[0], PathBuf::from("src/app.ts"));
3034    }
3035
3036    #[test]
3037    fn trace_dependency_unused() {
3038        let files = vec![DiscoveredFile {
3039            id: FileId(0),
3040            path: PathBuf::from("/project/src/app.ts"),
3041            size_bytes: 100,
3042        }];
3043        let entry_points = vec![EntryPoint {
3044            path: PathBuf::from("/project/src/app.ts"),
3045            source: EntryPointSource::PackageJsonMain,
3046        }];
3047        let resolved_modules = vec![ResolvedModule {
3048            file_id: FileId(0),
3049            path: PathBuf::from("/project/src/app.ts"),
3050            ..Default::default()
3051        }];
3052
3053        let graph = ModuleGraph::build(&resolved_modules, &entry_points, &files);
3054        let root = Path::new("/project");
3055
3056        let trace = trace_dependency(&graph, root, "nonexistent-pkg", &FxHashSet::default());
3057        assert!(!trace.is_used);
3058        assert!(!trace.used_in_scripts);
3059        assert_eq!(trace.import_count, 0);
3060        assert!(trace.imported_by.is_empty());
3061    }
3062
3063    #[test]
3064    fn trace_dependency_used_only_in_scripts() {
3065        let files = vec![DiscoveredFile {
3066            id: FileId(0),
3067            path: PathBuf::from("/project/src/app.ts"),
3068            size_bytes: 100,
3069        }];
3070        let entry_points = vec![EntryPoint {
3071            path: PathBuf::from("/project/src/app.ts"),
3072            source: EntryPointSource::PackageJsonMain,
3073        }];
3074        let resolved_modules = vec![ResolvedModule {
3075            file_id: FileId(0),
3076            path: PathBuf::from("/project/src/app.ts"),
3077            ..Default::default()
3078        }];
3079
3080        let graph = ModuleGraph::build(&resolved_modules, &entry_points, &files);
3081        let root = Path::new("/project");
3082        let mut script_used = FxHashSet::default();
3083        script_used.insert("microbundle".to_string());
3084
3085        let trace = trace_dependency(&graph, root, "microbundle", &script_used);
3086        assert!(
3087            trace.is_used,
3088            "is_used must be true when the package is referenced from package.json scripts"
3089        );
3090        assert!(trace.used_in_scripts);
3091        assert_eq!(trace.import_count, 0);
3092        assert!(trace.imported_by.is_empty());
3093    }
3094
3095    #[test]
3096    fn trace_clone_finds_matching_group() {
3097        use crate::duplicates::{CloneGroup, CloneInstance, DuplicationReport, DuplicationStats};
3098        let report = DuplicationReport {
3099            clone_groups: vec![CloneGroup {
3100                instances: vec![
3101                    CloneInstance {
3102                        file: PathBuf::from("/project/src/a.ts"),
3103                        start_line: 10,
3104                        end_line: 20,
3105                        start_col: 0,
3106                        end_col: 0,
3107                        fragment: "fn foo() {}".to_string(),
3108                    },
3109                    CloneInstance {
3110                        file: PathBuf::from("/project/src/b.ts"),
3111                        start_line: 5,
3112                        end_line: 15,
3113                        start_col: 0,
3114                        end_col: 0,
3115                        fragment: "fn foo() {}".to_string(),
3116                    },
3117                ],
3118                token_count: 60,
3119                line_count: 11,
3120                similarity: None,
3121            }],
3122            clone_families: vec![],
3123            mirrored_directories: vec![],
3124            stats: DuplicationStats {
3125                total_files: 2,
3126                files_with_clones: 2,
3127                total_lines: 100,
3128                duplicated_lines: 22,
3129                total_tokens: 200,
3130                duplicated_tokens: 120,
3131                clone_groups: 1,
3132                clone_families: 0,
3133                clone_instances: 2,
3134                duplication_percentage: 22.0,
3135                clone_groups_below_min_occurrences: 0,
3136                clone_groups_ignored: 0,
3137                near_candidates_skipped: 0,
3138            },
3139        };
3140        let trace = trace_clone(&report, Path::new("/project"), "src/a.ts", 15);
3141        assert!(trace.matched_instance.is_some());
3142        assert_eq!(trace.clone_groups.len(), 1);
3143        assert_eq!(trace.clone_groups[0].instances.len(), 2);
3144        assert!(trace.clone_groups[0].fingerprint.starts_with("dup:"));
3145        assert_eq!(trace.clone_groups[0].suggestion.estimated_savings, 11);
3146    }
3147
3148    #[test]
3149    fn trace_clone_by_fingerprint_resolves_and_misses() {
3150        use crate::duplicates::{
3151            CloneGroup, CloneInstance, DuplicationReport, DuplicationStats, clone_fingerprint,
3152        };
3153        let report = DuplicationReport {
3154            clone_groups: vec![CloneGroup {
3155                instances: vec![
3156                    CloneInstance {
3157                        file: PathBuf::from("/project/src/a.ts"),
3158                        start_line: 10,
3159                        end_line: 20,
3160                        start_col: 0,
3161                        end_col: 0,
3162                        fragment: "fn buildInvoice() {}".to_string(),
3163                    },
3164                    CloneInstance {
3165                        file: PathBuf::from("/project/src/b.ts"),
3166                        start_line: 5,
3167                        end_line: 15,
3168                        start_col: 0,
3169                        end_col: 0,
3170                        fragment: "fn buildInvoice() {}".to_string(),
3171                    },
3172                ],
3173                token_count: 60,
3174                line_count: 11,
3175                similarity: None,
3176            }],
3177            clone_families: vec![],
3178            mirrored_directories: vec![],
3179            stats: DuplicationStats::default(),
3180        };
3181        let fp = clone_fingerprint(&report.clone_groups[0].instances);
3182
3183        let hit = trace_clone_by_fingerprint(&report, Path::new("/project"), &fp);
3184        assert!(hit.matched_instance.is_some());
3185        assert_eq!(hit.clone_groups.len(), 1);
3186        assert_eq!(hit.clone_groups[0].fingerprint, fp);
3187        assert_eq!(hit.line, 10);
3188
3189        let miss = trace_clone_by_fingerprint(&report, Path::new("/project"), "dup:deadbeef");
3190        assert!(miss.matched_instance.is_none());
3191        assert!(miss.clone_groups.is_empty());
3192    }
3193
3194    #[test]
3195    fn trace_clone_no_match() {
3196        use crate::duplicates::{CloneGroup, CloneInstance, DuplicationReport, DuplicationStats};
3197        let report = DuplicationReport {
3198            clone_groups: vec![CloneGroup {
3199                instances: vec![CloneInstance {
3200                    file: PathBuf::from("/project/src/a.ts"),
3201                    start_line: 10,
3202                    end_line: 20,
3203                    start_col: 0,
3204                    end_col: 0,
3205                    fragment: "fn foo() {}".to_string(),
3206                }],
3207                token_count: 60,
3208                line_count: 11,
3209                similarity: None,
3210            }],
3211            clone_families: vec![],
3212            mirrored_directories: vec![],
3213            stats: DuplicationStats {
3214                total_files: 1,
3215                files_with_clones: 1,
3216                total_lines: 50,
3217                duplicated_lines: 11,
3218                total_tokens: 100,
3219                duplicated_tokens: 60,
3220                clone_groups: 1,
3221                clone_families: 0,
3222                clone_instances: 1,
3223                duplication_percentage: 22.0,
3224                clone_groups_below_min_occurrences: 0,
3225                clone_groups_ignored: 0,
3226                near_candidates_skipped: 0,
3227            },
3228        };
3229        let trace = trace_clone(&report, Path::new("/project"), "src/a.ts", 25);
3230        assert!(trace.matched_instance.is_none());
3231        assert!(trace.clone_groups.is_empty());
3232    }
3233
3234    #[test]
3235    fn trace_clone_line_boundary() {
3236        use crate::duplicates::{CloneGroup, CloneInstance, DuplicationReport, DuplicationStats};
3237        let report = DuplicationReport {
3238            clone_groups: vec![CloneGroup {
3239                instances: vec![
3240                    CloneInstance {
3241                        file: PathBuf::from("/project/src/a.ts"),
3242                        start_line: 10,
3243                        end_line: 20,
3244                        start_col: 0,
3245                        end_col: 0,
3246                        fragment: "code".to_string(),
3247                    },
3248                    CloneInstance {
3249                        file: PathBuf::from("/project/src/b.ts"),
3250                        start_line: 1,
3251                        end_line: 11,
3252                        start_col: 0,
3253                        end_col: 0,
3254                        fragment: "code".to_string(),
3255                    },
3256                ],
3257                token_count: 50,
3258                line_count: 11,
3259                similarity: None,
3260            }],
3261            clone_families: vec![],
3262            mirrored_directories: vec![],
3263            stats: DuplicationStats {
3264                total_files: 2,
3265                files_with_clones: 2,
3266                total_lines: 100,
3267                duplicated_lines: 22,
3268                total_tokens: 200,
3269                duplicated_tokens: 100,
3270                clone_groups: 1,
3271                clone_families: 0,
3272                clone_instances: 2,
3273                duplication_percentage: 22.0,
3274                clone_groups_below_min_occurrences: 0,
3275                clone_groups_ignored: 0,
3276                near_candidates_skipped: 0,
3277            },
3278        };
3279        let root = Path::new("/project");
3280        assert!(
3281            trace_clone(&report, root, "src/a.ts", 10)
3282                .matched_instance
3283                .is_some()
3284        );
3285        assert!(
3286            trace_clone(&report, root, "src/a.ts", 20)
3287                .matched_instance
3288                .is_some()
3289        );
3290        assert!(
3291            trace_clone(&report, root, "src/a.ts", 21)
3292                .matched_instance
3293                .is_none()
3294        );
3295    }
3296
3297    #[test]
3298    fn trace_clone_returns_relative_instance_paths() {
3299        use crate::duplicates::{CloneGroup, CloneInstance, DuplicationReport, DuplicationStats};
3300        let report = DuplicationReport {
3301            clone_groups: vec![CloneGroup {
3302                instances: vec![
3303                    CloneInstance {
3304                        file: PathBuf::from("/project/src/a.ts"),
3305                        start_line: 1,
3306                        end_line: 10,
3307                        start_col: 0,
3308                        end_col: 0,
3309                        fragment: "code".to_string(),
3310                    },
3311                    CloneInstance {
3312                        file: PathBuf::from("/project/src/b.ts"),
3313                        start_line: 1,
3314                        end_line: 10,
3315                        start_col: 0,
3316                        end_col: 0,
3317                        fragment: "code".to_string(),
3318                    },
3319                ],
3320                token_count: 50,
3321                line_count: 10,
3322                similarity: None,
3323            }],
3324            clone_families: vec![],
3325            mirrored_directories: vec![],
3326            stats: DuplicationStats {
3327                total_files: 2,
3328                files_with_clones: 2,
3329                total_lines: 50,
3330                duplicated_lines: 20,
3331                total_tokens: 100,
3332                duplicated_tokens: 100,
3333                clone_groups: 1,
3334                clone_families: 0,
3335                clone_instances: 2,
3336                duplication_percentage: 40.0,
3337                clone_groups_below_min_occurrences: 0,
3338                clone_groups_ignored: 0,
3339                near_candidates_skipped: 0,
3340            },
3341        };
3342        let trace = trace_clone(&report, Path::new("/project"), "src/a.ts", 5);
3343        let matched = trace.matched_instance.as_ref().expect("match expected");
3344        assert_eq!(matched.file, PathBuf::from("src/a.ts"));
3345        for group in &trace.clone_groups {
3346            for inst in &group.instances {
3347                let as_str = inst.file.to_string_lossy();
3348                assert!(
3349                    !as_str.starts_with('/'),
3350                    "instance file should be relative, got {as_str}",
3351                );
3352                assert!(
3353                    !as_str.contains(":\\") && !as_str.contains(":/"),
3354                    "instance file should not have a drive letter, got {as_str}",
3355                );
3356            }
3357        }
3358
3359        let json = serde_json::to_string(&trace).expect("serializes");
3360        assert!(
3361            !json.contains("\"/project/"),
3362            "serialized trace should not leak absolute paths: {json}",
3363        );
3364    }
3365
3366    /// Regression for the MCP e2e `trace_export` / `trace_file` Windows
3367    /// failures: the MCP layer passes forward-slashed user input
3368    /// (`src/utils.ts`) but `module_path` on Windows uses backslash
3369    /// separators (`D:\a\fallow\...\src\utils.ts`). The byte-level
3370    /// equality check missed every match. The lookup now normalises
3371    /// both sides to forward slashes before comparing.
3372    fn single_module_graph(path: &str) -> ModuleGraph {
3373        let path = PathBuf::from(path);
3374        let files = vec![DiscoveredFile {
3375            id: FileId(0),
3376            path: path.clone(),
3377            size_bytes: 10,
3378        }];
3379        let resolved_modules = vec![ResolvedModule {
3380            file_id: FileId(0),
3381            path,
3382            ..Default::default()
3383        }];
3384        ModuleGraph::build(&resolved_modules, &[], &files)
3385    }
3386
3387    #[test]
3388    fn find_module_normalises_windows_module_path_against_posix_user_path() {
3389        let root = Path::new(r"D:\a\fallow\fallow\tests\fixtures\basic-project");
3390        let graph =
3391            single_module_graph(r"D:\a\fallow\fallow\tests\fixtures\basic-project\src\utils.ts");
3392        assert!(find_module(&graph, root, "src/utils.ts").is_some());
3393        assert!(find_module(&graph, root, r"src\utils.ts").is_some());
3394    }
3395
3396    #[test]
3397    fn find_module_suffix_fallback_handles_mixed_separators() {
3398        let root = Path::new("/some/other/root");
3399        let graph =
3400            single_module_graph(r"D:\a\fallow\fallow\tests\fixtures\basic-project\src\utils.ts");
3401        assert!(find_module(&graph, root, "src/utils.ts").is_some());
3402    }
3403
3404    /// Regression for the MCP e2e trace_export / trace_file failures: even
3405    /// after `find_module` correctly identified the file on Windows, the
3406    /// trace output struct's `file: PathBuf` field serialized the stored
3407    /// backslash-shaped path verbatim. JSON consumers (MCP agents, CI
3408    /// pipelines, the cross-platform trace_file assertion in
3409    /// `e2e_trace_file_returns_json`) expect forward-slash. Pin the
3410    /// contract via raw-string Windows-shaped `PathBuf::from` so the test
3411    /// runs cross-platform.
3412    #[test]
3413    fn export_trace_serializes_windows_path_with_forward_slashes() {
3414        let trace = ExportTrace {
3415            file: PathBuf::from(r"src\utils.ts"),
3416            export_name: "foo".to_string(),
3417            namespace: fallow_types::semantic::SemanticNamespace::Value,
3418            file_reachable: true,
3419            is_entry_point: false,
3420            is_used: true,
3421            direct_references: vec![ExportReference {
3422                from_file: PathBuf::from(r"src\entry.ts"),
3423                kind: "named import".to_string(),
3424            }],
3425            direct_references_by_namespace: Vec::new(),
3426            star_export_ambiguity: None,
3427            re_export_chains: vec![ReExportChain {
3428                barrel_file: PathBuf::from(r"src\index.ts"),
3429                exported_as: "foo".to_string(),
3430                reference_count: 1,
3431            }],
3432            reason: "ok".to_string(),
3433            semantic: None,
3434        };
3435        let json = serde_json::to_string(&trace).expect("serializes");
3436        assert!(
3437            json.contains("\"file\":\"src/utils.ts\""),
3438            "ExportTrace.file must serialize with forward slashes: {json}"
3439        );
3440        assert!(
3441            json.contains("\"from_file\":\"src/entry.ts\""),
3442            "ExportReference.from_file must serialize with forward slashes: {json}"
3443        );
3444        assert!(
3445            json.contains("\"barrel_file\":\"src/index.ts\""),
3446            "ReExportChain.barrel_file must serialize with forward slashes: {json}"
3447        );
3448        assert!(
3449            !json.contains(r"\\"),
3450            "no backslash sequence should remain anywhere in the JSON: {json}"
3451        );
3452    }
3453
3454    #[test]
3455    fn file_trace_serializes_windows_paths_with_forward_slashes() {
3456        let trace = FileTrace {
3457            file: PathBuf::from(r"src\utils.ts"),
3458            is_reachable: true,
3459            is_entry_point: false,
3460            exports: vec![],
3461            imports_from: vec![PathBuf::from(r"src\helpers.ts")],
3462            imported_by: vec![PathBuf::from(r"src\entry.ts")],
3463            re_exports: vec![TracedReExport {
3464                source_file: PathBuf::from(r"src\source.ts"),
3465                imported_name: "foo".to_string(),
3466                exported_name: "foo".to_string(),
3467            }],
3468            sources: Vec::new(),
3469        };
3470        let json = serde_json::to_string(&trace).expect("serializes");
3471        assert!(json.contains("\"file\":\"src/utils.ts\""), "got {json}");
3472        assert!(
3473            json.contains("\"imports_from\":[\"src/helpers.ts\"]"),
3474            "got {json}"
3475        );
3476        assert!(
3477            json.contains("\"imported_by\":[\"src/entry.ts\"]"),
3478            "got {json}"
3479        );
3480        assert!(
3481            json.contains("\"source_file\":\"src/source.ts\""),
3482            "got {json}"
3483        );
3484        assert!(!json.contains(r"\\"), "no backslash should remain: {json}");
3485    }
3486}