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