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