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