Skip to main content

fallow_engine/
trace_impl.rs

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