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