Skip to main content

fallow_engine/
trace_impl.rs

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