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, PipelineTimings, ReExportChain, TracedCloneGroup,
6    TracedExport, TracedReExport,
7};
8use rustc_hash::FxHashSet;
9
10use crate::duplicates::{
11    CloneFingerprintSet, CloneGroup, CloneInstance, DuplicationReport, dominant_identifier,
12    group_refactoring_suggestion,
13};
14use crate::graph::{ModuleGraph, ReferenceKind};
15
16/// Match a user-provided file path against a module's actual path.
17///
18/// Handles monorepo scenarios where module paths may be canonicalized
19/// (symlinks resolved) while user-provided paths are not.
20fn path_matches(module_path: &Path, root: &Path, user_path: &str) -> bool {
21    let user_path_norm = user_path.replace('\\', "/");
22    let rel = module_path.strip_prefix(root).unwrap_or(module_path);
23    let rel_str = rel.to_string_lossy().replace('\\', "/");
24    let module_str = module_path.to_string_lossy().replace('\\', "/");
25    if rel_str == user_path_norm || module_str == user_path_norm {
26        return true;
27    }
28    if dunce::canonicalize(root).is_ok_and(|canonical_root| {
29        module_path
30            .strip_prefix(&canonical_root)
31            .is_ok_and(|rel| rel.to_string_lossy().replace('\\', "/") == user_path_norm)
32    }) {
33        return true;
34    }
35    module_str.ends_with(&format!("/{user_path_norm}"))
36}
37
38/// Map a reference's `from_file` id to a root-relative [`ExportReference`].
39fn reference_to_export_reference(
40    graph: &ModuleGraph,
41    root: &Path,
42    r: &crate::graph::SymbolReference,
43) -> ExportReference {
44    let from_path = graph.modules.get(r.from_file.0 as usize).map_or_else(
45        || PathBuf::from(format!("<unknown:{}>", r.from_file.0)),
46        |m| m.path.strip_prefix(root).unwrap_or(&m.path).to_path_buf(),
47    );
48    ExportReference {
49        from_file: from_path,
50        kind: format_reference_kind(r.kind),
51    }
52}
53
54/// Collect every re-export chain across the graph that re-exports `export_name`
55/// from the module identified by `target_file_id`.
56fn collect_re_export_chains(
57    graph: &ModuleGraph,
58    root: &Path,
59    target_file_id: crate::discover::FileId,
60    export_name: &str,
61) -> Vec<ReExportChain> {
62    graph
63        .modules
64        .iter()
65        .flat_map(|m| {
66            m.re_exports
67                .iter()
68                .filter(move |re| {
69                    re.source_file == target_file_id
70                        && (re.imported_name == export_name || re.imported_name == "*")
71                })
72                .map(move |re| {
73                    let barrel_export = m.exports.iter().find(|e| {
74                        if re.exported_name == "*" {
75                            e.name.to_string() == export_name
76                        } else {
77                            e.name.to_string() == re.exported_name
78                        }
79                    });
80                    ReExportChain {
81                        barrel_file: m.path.strip_prefix(root).unwrap_or(&m.path).to_path_buf(),
82                        exported_as: re.exported_name.clone(),
83                        reference_count: barrel_export.map_or(0, |e| e.references.len()),
84                    }
85                })
86        })
87        .collect()
88}
89
90/// Build the human-readable reason string explaining an export's used/unused state.
91fn export_trace_reason(
92    module: &crate::graph::ModuleNode,
93    reference_count: usize,
94    is_used: bool,
95    re_export_chains: &[ReExportChain],
96) -> String {
97    if !module.is_reachable() {
98        "File is unreachable from any entry point".to_string()
99    } else if is_used {
100        format!(
101            "Used by {} file(s){}",
102            reference_count,
103            if re_export_chains.is_empty() {
104                String::new()
105            } else {
106                format!(", re-exported through {} barrel(s)", re_export_chains.len())
107            }
108        )
109    } else if module.is_entry_point() {
110        "No internal references, but file is an entry point (export is externally accessible)"
111            .to_string()
112    } else if !re_export_chains.is_empty() {
113        format!(
114            "Re-exported through {} barrel(s) but no consumer imports it through the barrel",
115            re_export_chains.len()
116        )
117    } else {
118        "No references found, export is unused".to_string()
119    }
120}
121
122/// Trace why an export is considered used or unused.
123#[must_use]
124pub fn trace_export(
125    graph: &ModuleGraph,
126    root: &Path,
127    file_path: &str,
128    export_name: &str,
129) -> Option<ExportTrace> {
130    let module = graph
131        .modules
132        .iter()
133        .find(|m| path_matches(&m.path, root, file_path))?;
134
135    let export = module
136        .exports
137        .iter()
138        .filter(|e| export_name_matches(e, export_name))
139        .max_by_key(|e| (!e.references.is_empty(), !e.is_type_only))?;
140
141    let direct_references: Vec<ExportReference> = export
142        .references
143        .iter()
144        .map(|r| reference_to_export_reference(graph, root, r))
145        .collect();
146
147    let re_export_chains = collect_re_export_chains(graph, root, module.file_id, export_name);
148
149    let is_used = !export.references.is_empty();
150    let reason = export_trace_reason(module, export.references.len(), is_used, &re_export_chains);
151
152    Some(ExportTrace {
153        file: module
154            .path
155            .strip_prefix(root)
156            .unwrap_or(&module.path)
157            .to_path_buf(),
158        export_name: export_name.to_string(),
159        file_reachable: module.is_reachable(),
160        is_entry_point: module.is_entry_point(),
161        is_used,
162        direct_references,
163        re_export_chains,
164        reason,
165        semantic: None,
166    })
167}
168
169/// Resolve the exact source identity required by the semantic sidecar for a
170/// graph export. This does not perform semantic analysis itself.
171#[must_use]
172pub fn semantic_symbol_for_export(
173    graph: &ModuleGraph,
174    root: &Path,
175    file_path: &str,
176    export_name: &str,
177) -> Option<fallow_types::semantic::SemanticSymbol> {
178    use fallow_types::semantic::{SemanticNamespace, SemanticSymbol};
179
180    let module = graph
181        .modules
182        .iter()
183        .find(|module| path_matches(&module.path, root, file_path))?;
184    let export = module
185        .exports
186        .iter()
187        .filter(|export| export_name_matches(export, export_name))
188        .max_by_key(|export| (!export.references.is_empty(), !export.is_type_only))?;
189    let source = std::fs::read_to_string(&module.path).ok()?;
190    let offsets = fallow_types::extract::compute_line_offsets(&source);
191    let (line, col) = fallow_types::extract::byte_offset_to_line_col(&offsets, export.span.start);
192    Some(SemanticSymbol {
193        path: module
194            .path
195            .strip_prefix(root)
196            .unwrap_or(&module.path)
197            .to_path_buf(),
198        namespace: if export.is_type_only {
199            SemanticNamespace::Type
200        } else {
201            SemanticNamespace::Value
202        },
203        declaration_kind: "export".to_string(),
204        exported_name: export_name.to_string(),
205        local_name: export_name.to_string(),
206        owner: None,
207        line,
208        col,
209    })
210}
211
212/// Resolve the source identity for a public class member semantic query.
213#[must_use]
214pub fn semantic_symbol_for_class_member(
215    graph: &ModuleGraph,
216    root: &Path,
217    file_path: &str,
218    member_name: &str,
219) -> Option<fallow_types::semantic::SemanticSymbol> {
220    use fallow_types::extract::MemberKind;
221    use fallow_types::semantic::{SemanticNamespace, SemanticSymbol};
222
223    let module = graph
224        .modules
225        .iter()
226        .find(|module| path_matches(&module.path, root, file_path))?;
227    let (owner, member) = module
228        .exports
229        .iter()
230        .filter_map(|export| {
231            export
232                .members
233                .iter()
234                .find(|member| member.name == member_name)
235                .map(|member| (export, member))
236        })
237        .max_by_key(|(export, _)| (!export.references.is_empty(), !export.is_type_only))?;
238    let declaration_kind = match member.kind {
239        MemberKind::ClassMethod => "class_method",
240        MemberKind::ClassProperty => "class_property",
241        _ => return None,
242    };
243    let source = std::fs::read_to_string(&module.path).ok()?;
244    let offsets = fallow_types::extract::compute_line_offsets(&source);
245    let (line, col) = fallow_types::extract::byte_offset_to_line_col(&offsets, member.span.start);
246    Some(SemanticSymbol {
247        path: module
248            .path
249            .strip_prefix(root)
250            .unwrap_or(&module.path)
251            .to_path_buf(),
252        namespace: SemanticNamespace::Value,
253        declaration_kind: declaration_kind.to_string(),
254        exported_name: member_name.to_string(),
255        local_name: member_name.to_string(),
256        owner: Some(owner.name.to_string()),
257        line,
258        col,
259    })
260}
261
262/// Stable reason why an exact class-method target cannot be resolved.
263#[derive(Debug, Clone, Copy, PartialEq, Eq)]
264pub enum SemanticClassMethodResolutionError {
265    /// The requested file is not part of the retained module graph.
266    FileNotFound,
267    /// The requested owner or method does not exist in the file.
268    SymbolNotFound,
269    /// More than one declaration matches the exact owner and method.
270    AmbiguousSymbol,
271    /// The matching declaration is not a supported class method.
272    UnsupportedSyntax,
273}
274
275impl std::fmt::Display for SemanticClassMethodResolutionError {
276    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
277        let reason = match self {
278            Self::FileNotFound => "file-not-found",
279            Self::SymbolNotFound => "unknown-symbol",
280            Self::AmbiguousSymbol => "ambiguous-symbol",
281            Self::UnsupportedSyntax => "unsupported-syntax",
282        };
283        formatter.write_str(reason)
284    }
285}
286
287/// Resolve one exact exported class method without a name-based fallback.
288pub fn semantic_symbol_for_exact_class_method(
289    graph: &ModuleGraph,
290    root: &Path,
291    file_path: &str,
292    owner_name: &str,
293    member_name: &str,
294) -> Result<fallow_types::semantic::SemanticSymbol, SemanticClassMethodResolutionError> {
295    use fallow_types::extract::MemberKind;
296    use fallow_types::semantic::{SemanticNamespace, SemanticSymbol};
297
298    let module = graph
299        .modules
300        .iter()
301        .find(|module| path_matches(&module.path, root, file_path))
302        .ok_or(SemanticClassMethodResolutionError::FileNotFound)?;
303    let owners = module
304        .exports
305        .iter()
306        .filter(|export| export_name_matches(export, owner_name))
307        .collect::<Vec<_>>();
308    if owners.len() != 1 {
309        return Err(if owners.is_empty() {
310            SemanticClassMethodResolutionError::SymbolNotFound
311        } else {
312            SemanticClassMethodResolutionError::AmbiguousSymbol
313        });
314    }
315    let owner = owners[0];
316    let members = owner
317        .members
318        .iter()
319        .filter(|member| member.name == member_name)
320        .collect::<Vec<_>>();
321    if members.len() != 1 {
322        return Err(if members.is_empty() {
323            SemanticClassMethodResolutionError::SymbolNotFound
324        } else {
325            SemanticClassMethodResolutionError::AmbiguousSymbol
326        });
327    }
328    let member = members[0];
329    if member.kind != MemberKind::ClassMethod {
330        return Err(SemanticClassMethodResolutionError::UnsupportedSyntax);
331    }
332    let source = std::fs::read_to_string(&module.path)
333        .map_err(|_| SemanticClassMethodResolutionError::SymbolNotFound)?;
334    let offsets = fallow_types::extract::compute_line_offsets(&source);
335    let (line, col) = fallow_types::extract::byte_offset_to_line_col(&offsets, member.span.start);
336    Ok(SemanticSymbol {
337        path: module
338            .path
339            .strip_prefix(root)
340            .unwrap_or(&module.path)
341            .to_path_buf(),
342        namespace: SemanticNamespace::Value,
343        declaration_kind: "class_method".to_string(),
344        exported_name: member_name.to_string(),
345        local_name: member_name.to_string(),
346        owner: Some(owner_name.to_string()),
347        line,
348        col,
349    })
350}
351
352/// Trace a class / enum / store MEMBER when `--trace FILE:NAME`'s `NAME` is not
353/// a top-level export but a member declared on one (issue #1744). Runs on the
354/// graph only, so it reports the OWNING export's reachability and usage (the
355/// gating precondition for member crediting) plus a pointer to the right
356/// `--unused-*-members` command, not per-member crediting provenance.
357#[must_use]
358pub fn trace_class_member(
359    graph: &ModuleGraph,
360    root: &Path,
361    file_path: &str,
362    member_name: &str,
363) -> Option<ClassMemberTrace> {
364    use fallow_types::extract::MemberKind;
365
366    let module = graph
367        .modules
368        .iter()
369        .find(|m| path_matches(&m.path, root, file_path))?;
370
371    // Find the export that declares this member. When several declare a member
372    // of the same name (rare), prefer a used, non-type-only owner so the trace
373    // reports the reachable one.
374    let (owner, member_kind) = module
375        .exports
376        .iter()
377        .filter_map(|export| {
378            export
379                .members
380                .iter()
381                .find(|member| member.name == member_name)
382                .map(|member| (export, member.kind))
383        })
384        .max_by_key(|(export, _)| (!export.references.is_empty(), !export.is_type_only))?;
385
386    let owner_name = owner.name.to_string();
387    // Reuse the export trace to compute the owner's reachability / usage /
388    // references consistently with a plain `--trace FILE:OWNER`. The `?` here is
389    // a belt-and-suspenders guard: `owner` was just located in this module's
390    // `exports`, so `trace_export` resolves it in practice; the fallthrough to
391    // `None` (and the caller's "not found" error) is unreachable barring a graph
392    // inconsistency.
393    let owner_trace = trace_export(graph, root, file_path, &owner_name)?;
394
395    let (kind_str, filter_flag) = match member_kind {
396        MemberKind::ClassMethod => ("class-method", Some("--unused-class-members")),
397        MemberKind::ClassProperty => ("class-property", Some("--unused-class-members")),
398        MemberKind::EnumMember => ("enum-member", Some("--unused-enum-members")),
399        MemberKind::StoreMember => ("store-member", Some("--unused-store-members")),
400        MemberKind::NamespaceMember => ("namespace-member", None),
401    };
402
403    let reason = class_member_trace_reason(
404        member_name,
405        &owner_name,
406        kind_str,
407        filter_flag,
408        file_path,
409        &owner_trace,
410    );
411
412    Some(ClassMemberTrace {
413        file: owner_trace.file,
414        member_name: member_name.to_string(),
415        member_kind: kind_str.to_string(),
416        owner_export: owner_name,
417        owner_is_used: owner_trace.is_used,
418        owner_file_reachable: owner_trace.file_reachable,
419        owner_is_entry_point: owner_trace.is_entry_point,
420        owner_direct_references: owner_trace.direct_references,
421        owner_re_export_chains: owner_trace.re_export_chains,
422        reason,
423        semantic: None,
424    })
425}
426
427/// Build the human-readable reason for a class-member trace, keyed on the
428/// owner's reachability / usage (the precondition that gates member crediting).
429fn class_member_trace_reason(
430    member_name: &str,
431    owner_name: &str,
432    kind_str: &str,
433    filter_flag: Option<&str>,
434    file_path: &str,
435    owner_trace: &ExportTrace,
436) -> String {
437    let head =
438        format!("'{member_name}' is a {kind_str} of '{owner_name}', not a top-level export. ");
439    let body = if !owner_trace.file_reachable {
440        format!(
441            "The file is not reachable from any entry point, so '{owner_name}' and all its \
442             members are dead (see the unused-file finding)."
443        )
444    } else if !owner_trace.is_used {
445        format!(
446            "'{owner_name}' is reachable but referenced by no file, so it is reported as an \
447             unused export and its members are not judged individually."
448        )
449    } else {
450        let refs = owner_trace.direct_references.len();
451        match filter_flag {
452            Some(flag) => format!(
453                "'{owner_name}' is used by {refs} file(s); whether '{member_name}' itself is \
454                 flagged depends on cross-file member-access resolution. Run \
455                 `fallow dead-code {flag} --file {file_path}` to see the member finding."
456            ),
457            None => format!(
458                "'{owner_name}' is used by {refs} file(s); '{member_name}' is credited through \
459                 its namespace export."
460            ),
461        }
462    };
463    format!("{head}{body}")
464}
465
466fn export_name_matches(export: &crate::graph::ExportSymbol, export_name: &str) -> bool {
467    let name_str = export.name.to_string();
468    name_str == export_name || (export_name == "default" && name_str == "default")
469}
470
471/// Map a module's exports to [`TracedExport`] entries with relativized references.
472fn traced_exports(
473    graph: &ModuleGraph,
474    root: &Path,
475    module: &crate::graph::ModuleNode,
476) -> Vec<TracedExport> {
477    module
478        .exports
479        .iter()
480        .map(|e| TracedExport {
481            name: e.name.to_string(),
482            is_type_only: e.is_type_only,
483            reference_count: e.references.len(),
484            referenced_by: e
485                .references
486                .iter()
487                .map(|r| reference_to_export_reference(graph, root, r))
488                .collect(),
489        })
490        .collect()
491}
492
493/// Collect the root-relative paths a file imports from (forward graph edges).
494fn traced_imports_from(
495    graph: &ModuleGraph,
496    root: &Path,
497    module: &crate::graph::ModuleNode,
498) -> Vec<PathBuf> {
499    graph
500        .edges_for(module.file_id)
501        .iter()
502        .filter_map(|target_id| {
503            graph
504                .modules
505                .get(target_id.0 as usize)
506                .map(|m| m.path.strip_prefix(root).unwrap_or(&m.path).to_path_buf())
507        })
508        .collect()
509}
510
511/// Collect the root-relative paths that import a file (reverse graph edges).
512fn traced_imported_by(
513    graph: &ModuleGraph,
514    root: &Path,
515    module: &crate::graph::ModuleNode,
516) -> Vec<PathBuf> {
517    graph
518        .reverse_deps
519        .get(module.file_id.0 as usize)
520        .map(|deps| {
521            deps.iter()
522                .filter_map(|fid| {
523                    graph
524                        .modules
525                        .get(fid.0 as usize)
526                        .map(|m| m.path.strip_prefix(root).unwrap_or(&m.path).to_path_buf())
527                })
528                .collect()
529        })
530        .unwrap_or_default()
531}
532
533/// Map a module's re-exports to [`TracedReExport`] entries with relativized source paths.
534fn traced_re_exports(
535    graph: &ModuleGraph,
536    root: &Path,
537    module: &crate::graph::ModuleNode,
538) -> Vec<TracedReExport> {
539    module
540        .re_exports
541        .iter()
542        .map(|re| {
543            let source_path = graph.modules.get(re.source_file.0 as usize).map_or_else(
544                || PathBuf::from(format!("<unknown:{}>", re.source_file.0)),
545                |m| m.path.strip_prefix(root).unwrap_or(&m.path).to_path_buf(),
546            );
547            TracedReExport {
548                source_file: source_path,
549                imported_name: re.imported_name.clone(),
550                exported_name: re.exported_name.clone(),
551            }
552        })
553        .collect()
554}
555
556/// Trace all edges for a file.
557#[must_use]
558pub fn trace_file(graph: &ModuleGraph, root: &Path, file_path: &str) -> Option<FileTrace> {
559    let module = graph
560        .modules
561        .iter()
562        .find(|m| path_matches(&m.path, root, file_path))?;
563
564    Some(FileTrace {
565        file: module
566            .path
567            .strip_prefix(root)
568            .unwrap_or(&module.path)
569            .to_path_buf(),
570        is_reachable: module.is_reachable(),
571        is_entry_point: module.is_entry_point(),
572        exports: traced_exports(graph, root, module),
573        imports_from: traced_imports_from(graph, root, module),
574        imported_by: traced_imported_by(graph, root, module),
575        re_exports: traced_re_exports(graph, root, module),
576    })
577}
578
579/// Trace where a dependency is used.
580///
581/// `script_used_packages` carries the package names recorded as binary invocations
582/// in package.json scripts (`build: microbundle ...`) and CI configs
583/// (`.github/workflows/*.yml`, `.gitlab-ci.yml`). The same set the unused-deps
584/// detector consults; passing it in lets the trace output match the detector's
585/// view of "used" instead of reporting `is_used=false` for tools invoked only
586/// through scripts.
587#[must_use]
588pub fn trace_dependency(
589    graph: &ModuleGraph,
590    root: &Path,
591    package_name: &str,
592    script_used_packages: &FxHashSet<String>,
593) -> DependencyTrace {
594    let imported_by: Vec<PathBuf> = graph
595        .package_usage
596        .get(package_name)
597        .map(|ids| {
598            ids.iter()
599                .filter_map(|fid| {
600                    graph
601                        .modules
602                        .get(fid.0 as usize)
603                        .map(|m| m.path.strip_prefix(root).unwrap_or(&m.path).to_path_buf())
604                })
605                .collect()
606        })
607        .unwrap_or_default();
608
609    let type_only_imported_by: Vec<PathBuf> = graph
610        .type_only_package_usage
611        .get(package_name)
612        .map(|ids| {
613            ids.iter()
614                .filter_map(|fid| {
615                    graph
616                        .modules
617                        .get(fid.0 as usize)
618                        .map(|m| m.path.strip_prefix(root).unwrap_or(&m.path).to_path_buf())
619                })
620                .collect()
621        })
622        .unwrap_or_default();
623
624    let import_count = imported_by.len();
625    let used_in_scripts = script_used_packages.contains(package_name);
626    DependencyTrace {
627        package_name: package_name.to_string(),
628        imported_by,
629        type_only_imported_by,
630        used_in_scripts,
631        is_used: import_count > 0 || used_in_scripts,
632        import_count,
633    }
634}
635
636fn format_reference_kind(kind: ReferenceKind) -> String {
637    match kind {
638        ReferenceKind::NamedImport => "named import".to_string(),
639        ReferenceKind::DefaultImport => "default import".to_string(),
640        ReferenceKind::NamespaceImport => "namespace import".to_string(),
641        ReferenceKind::ReExport => "re-export".to_string(),
642        ReferenceKind::DynamicImport => "dynamic import".to_string(),
643        ReferenceKind::SideEffectImport => "side-effect import".to_string(),
644    }
645}
646
647/// Compute the impact closure for a single file as the seed.
648///
649/// Resolves `file_path` to a graph `FileId`, walks `reverse_deps` + re-export
650/// chains to the transitive affected set, and reports the coordination gap (the
651/// seed's exported contracts consumed by modules outside the seed). Returns
652/// `None` when the file is not in the module graph.
653#[must_use]
654pub fn trace_impact_closure(
655    graph: &ModuleGraph,
656    root: &Path,
657    file_path: &str,
658) -> Option<ImpactClosureTrace> {
659    let module = graph
660        .modules
661        .iter()
662        .find(|m| path_matches(&m.path, root, file_path))?;
663
664    let closure = graph.impact_closure(&[module.file_id]);
665    let paths = graph.closure_with_paths(&closure, root);
666
667    let seed = paths
668        .in_diff
669        .first()
670        .cloned()
671        .unwrap_or_else(|| file_path.replace('\\', "/"));
672
673    let coordination_gap = paths
674        .coordination_gap
675        .into_iter()
676        .map(|gap| ImpactClosureGap {
677            consumer_file: gap.consumer_file,
678            consumed_symbols: gap.consumed_symbols,
679            note: "syntactic attention pointer, not a correctness proof".to_string(),
680        })
681        .collect();
682
683    Some(ImpactClosureTrace {
684        seed,
685        affected_not_shown: paths.affected_not_shown,
686        coordination_gap,
687    })
688}
689
690/// Build a [`TracedCloneGroup`] from a raw clone group, computing the
691/// fingerprint, group-level suggestion, and dominant-identifier name and
692/// relativizing every instance path against `root`.
693fn build_traced_group(
694    group: &CloneGroup,
695    root: &Path,
696    fingerprints: &CloneFingerprintSet,
697) -> TracedCloneGroup {
698    TracedCloneGroup {
699        fingerprint: fingerprints.fingerprint_for_group(group),
700        token_count: group.token_count,
701        line_count: group.line_count,
702        instances: group
703            .instances
704            .iter()
705            .map(|inst| relativize_instance(inst, root))
706            .collect(),
707        suggestion: group_refactoring_suggestion(group),
708        suggested_name: dominant_identifier(group),
709    }
710}
711
712#[must_use]
713pub fn trace_clone(
714    report: &DuplicationReport,
715    root: &Path,
716    file_path: &str,
717    line: usize,
718) -> CloneTrace {
719    let resolved = root.join(file_path);
720    let mut matched_instance = None;
721    let mut clone_groups = Vec::new();
722    let fingerprints = CloneFingerprintSet::from_groups(&report.clone_groups);
723
724    for group in &report.clone_groups {
725        let matching = group.instances.iter().find(|inst| {
726            let inst_matches = inst.file == resolved
727                || inst.file.strip_prefix(root).unwrap_or(&inst.file) == Path::new(file_path);
728            inst_matches && inst.start_line <= line && line <= inst.end_line
729        });
730
731        if let Some(matched) = matching {
732            if matched_instance.is_none() {
733                matched_instance = Some(relativize_instance(matched, root));
734            }
735            clone_groups.push(build_traced_group(group, root, &fingerprints));
736        }
737    }
738
739    CloneTrace {
740        file: PathBuf::from(file_path),
741        line,
742        matched_instance,
743        clone_groups,
744    }
745}
746
747/// Trace a clone group by its stable content fingerprint.
748///
749/// Fingerprints are usually `dup:<8hex>` and widen only when needed to avoid a
750/// collision inside the same report.
751///
752/// Returns a [`CloneTrace`] whose single `clone_groups` entry is the matched
753/// group and whose `file` / `line` / `matched_instance` come from that group's
754/// representative (first) instance. `matched_instance` is `None` (and
755/// `clone_groups` empty) when no group matches the fingerprint.
756#[must_use]
757pub fn trace_clone_by_fingerprint(
758    report: &DuplicationReport,
759    root: &Path,
760    fingerprint: &str,
761) -> CloneTrace {
762    let fingerprints = CloneFingerprintSet::from_groups(&report.clone_groups);
763    let matched = fingerprints.find_group(&report.clone_groups, fingerprint);
764
765    let Some(group) = matched else {
766        return CloneTrace {
767            file: PathBuf::new(),
768            line: 0,
769            matched_instance: None,
770            clone_groups: Vec::new(),
771        };
772    };
773
774    let representative = group
775        .instances
776        .first()
777        .map(|inst| relativize_instance(inst, root));
778    let (file, line) = representative.as_ref().map_or_else(
779        || (PathBuf::new(), 0),
780        |inst| (inst.file.clone(), inst.start_line),
781    );
782
783    CloneTrace {
784        file,
785        line,
786        matched_instance: representative,
787        clone_groups: vec![build_traced_group(group, root, &fingerprints)],
788    }
789}
790
791/// Return a copy of `inst` with `file` rewritten relative to `root` (forward-slash normalized
792/// for cross-platform JSON parity with `serde_path::serialize`). If `inst.file` is already
793/// outside `root`, the path is left unchanged.
794fn relativize_instance(inst: &CloneInstance, root: &Path) -> CloneInstance {
795    let rel = inst.file.strip_prefix(root).map_or_else(
796        |_| inst.file.clone(),
797        |p| PathBuf::from(p.to_string_lossy().replace('\\', "/")),
798    );
799    CloneInstance {
800        file: rel,
801        ..inst.clone()
802    }
803}
804
805#[cfg(test)]
806mod tests {
807    use super::*;
808
809    use crate::discover::{DiscoveredFile, EntryPoint, EntryPointSource, FileId};
810    use crate::extract::{ExportInfo, ExportName, ImportInfo, ImportedName, VisibilityTag};
811    use crate::resolve::{ResolveResult, ResolvedImport, ResolvedModule};
812
813    fn build_test_graph() -> ModuleGraph {
814        let files = vec![
815            DiscoveredFile {
816                id: FileId(0),
817                path: PathBuf::from("/project/src/entry.ts"),
818                size_bytes: 100,
819            },
820            DiscoveredFile {
821                id: FileId(1),
822                path: PathBuf::from("/project/src/utils.ts"),
823                size_bytes: 50,
824            },
825            DiscoveredFile {
826                id: FileId(2),
827                path: PathBuf::from("/project/src/unused.ts"),
828                size_bytes: 30,
829            },
830        ];
831
832        let entry_points = vec![EntryPoint {
833            path: PathBuf::from("/project/src/entry.ts"),
834            source: EntryPointSource::PackageJsonMain,
835        }];
836
837        let resolved_modules = vec![
838            ResolvedModule {
839                file_id: FileId(0),
840                path: PathBuf::from("/project/src/entry.ts"),
841                resolved_imports: vec![ResolvedImport {
842                    info: ImportInfo {
843                        source: "./utils".to_string(),
844                        imported_name: ImportedName::Named("foo".to_string()),
845                        local_name: "foo".to_string(),
846                        is_type_only: false,
847                        from_style: false,
848                        span: oxc_span::Span::new(0, 10),
849                        source_span: oxc_span::Span::default(),
850                    },
851                    target: ResolveResult::InternalModule(FileId(1)),
852                }],
853                ..Default::default()
854            },
855            ResolvedModule {
856                file_id: FileId(1),
857                path: PathBuf::from("/project/src/utils.ts"),
858                exports: vec![
859                    ExportInfo {
860                        name: ExportName::Named("foo".to_string()),
861                        local_name: Some("foo".to_string()),
862                        is_type_only: false,
863                        visibility: VisibilityTag::None,
864                        expected_unused_reason: None,
865                        span: oxc_span::Span::new(0, 20),
866                        members: vec![],
867                        is_side_effect_used: false,
868                        super_class: None,
869                    },
870                    ExportInfo {
871                        name: ExportName::Named("bar".to_string()),
872                        local_name: Some("bar".to_string()),
873                        is_type_only: false,
874                        visibility: VisibilityTag::None,
875                        expected_unused_reason: None,
876                        span: oxc_span::Span::new(21, 40),
877                        members: vec![],
878                        is_side_effect_used: false,
879                        super_class: None,
880                    },
881                ],
882                ..Default::default()
883            },
884            ResolvedModule {
885                file_id: FileId(2),
886                path: PathBuf::from("/project/src/unused.ts"),
887                exports: vec![ExportInfo {
888                    name: ExportName::Named("baz".to_string()),
889                    local_name: Some("baz".to_string()),
890                    is_type_only: false,
891                    visibility: VisibilityTag::None,
892                    expected_unused_reason: None,
893                    span: oxc_span::Span::new(0, 15),
894                    members: vec![],
895                    is_side_effect_used: false,
896                    super_class: None,
897                }],
898                ..Default::default()
899            },
900        ];
901
902        ModuleGraph::build(&resolved_modules, &entry_points, &files)
903    }
904
905    #[test]
906    fn trace_used_export() {
907        let graph = build_test_graph();
908        let root = Path::new("/project");
909
910        let trace = trace_export(&graph, root, "src/utils.ts", "foo").unwrap();
911        assert!(trace.is_used);
912        assert!(trace.file_reachable);
913        assert_eq!(trace.direct_references.len(), 1);
914        assert_eq!(
915            trace.direct_references[0].from_file,
916            PathBuf::from("src/entry.ts")
917        );
918        assert_eq!(trace.direct_references[0].kind, "named import");
919    }
920
921    #[test]
922    fn trace_unused_export() {
923        let graph = build_test_graph();
924        let root = Path::new("/project");
925
926        let trace = trace_export(&graph, root, "src/utils.ts", "bar").unwrap();
927        assert!(!trace.is_used);
928        assert!(trace.file_reachable);
929        assert!(trace.direct_references.is_empty());
930    }
931
932    #[test]
933    fn trace_unreachable_file_export() {
934        let graph = build_test_graph();
935        let root = Path::new("/project");
936
937        let trace = trace_export(&graph, root, "src/unused.ts", "baz").unwrap();
938        assert!(!trace.is_used);
939        assert!(!trace.file_reachable);
940        assert!(trace.reason.contains("unreachable"));
941    }
942
943    #[test]
944    fn trace_nonexistent_export() {
945        let graph = build_test_graph();
946        let root = Path::new("/project");
947
948        let trace = trace_export(&graph, root, "src/utils.ts", "nonexistent");
949        assert!(trace.is_none());
950    }
951
952    fn build_class_member_graph() -> ModuleGraph {
953        use fallow_types::extract::{MemberInfo, MemberKind};
954
955        let files = vec![
956            DiscoveredFile {
957                id: FileId(0),
958                path: PathBuf::from("/project/src/entry.ts"),
959                size_bytes: 100,
960            },
961            DiscoveredFile {
962                id: FileId(1),
963                path: PathBuf::from("/project/src/controller.ts"),
964                size_bytes: 50,
965            },
966        ];
967        let entry_points = vec![EntryPoint {
968            path: PathBuf::from("/project/src/entry.ts"),
969            source: EntryPointSource::PackageJsonMain,
970        }];
971        let method = |name: &str| MemberInfo {
972            name: name.to_string(),
973            kind: MemberKind::ClassMethod,
974            span: oxc_span::Span::new(0, 4),
975            has_decorator: false,
976            decorator_names: vec![],
977            is_instance_returning_static: false,
978            is_self_returning: false,
979        };
980        let resolved_modules = vec![
981            ResolvedModule {
982                file_id: FileId(0),
983                path: PathBuf::from("/project/src/entry.ts"),
984                resolved_imports: vec![ResolvedImport {
985                    info: ImportInfo {
986                        source: "./controller".to_string(),
987                        imported_name: ImportedName::Named("Ctrl".to_string()),
988                        local_name: "Ctrl".to_string(),
989                        is_type_only: false,
990                        from_style: false,
991                        span: oxc_span::Span::new(0, 10),
992                        source_span: oxc_span::Span::default(),
993                    },
994                    target: ResolveResult::InternalModule(FileId(1)),
995                }],
996                ..Default::default()
997            },
998            ResolvedModule {
999                file_id: FileId(1),
1000                path: PathBuf::from("/project/src/controller.ts"),
1001                exports: vec![ExportInfo {
1002                    name: ExportName::Named("Ctrl".to_string()),
1003                    local_name: Some("Ctrl".to_string()),
1004                    is_type_only: false,
1005                    visibility: VisibilityTag::None,
1006                    expected_unused_reason: None,
1007                    span: oxc_span::Span::new(0, 20),
1008                    members: vec![method("used"), method("dead")],
1009                    is_side_effect_used: false,
1010                    super_class: None,
1011                }],
1012                ..Default::default()
1013            },
1014        ];
1015        ModuleGraph::build(&resolved_modules, &entry_points, &files)
1016    }
1017
1018    #[test]
1019    fn trace_class_member_reports_owner_class() {
1020        // #1744: `--trace FILE:MEMBER` on a class member reports the owning
1021        // class instead of erroring "export not found".
1022        let graph = build_class_member_graph();
1023        let root = Path::new("/project");
1024
1025        let trace = trace_class_member(&graph, root, "src/controller.ts", "dead").unwrap();
1026        assert_eq!(trace.owner_export, "Ctrl");
1027        assert_eq!(trace.member_name, "dead");
1028        assert_eq!(trace.member_kind, "class-method");
1029        assert!(trace.owner_is_used);
1030        assert!(trace.owner_file_reachable);
1031        assert_eq!(trace.owner_direct_references.len(), 1);
1032        assert!(
1033            trace.reason.contains("--unused-class-members"),
1034            "reason should point at the member command: {}",
1035            trace.reason
1036        );
1037    }
1038
1039    #[test]
1040    fn trace_class_member_absent_name_is_none() {
1041        // A name that is neither a top-level export nor a declared member falls
1042        // through so the caller emits the "not found" error.
1043        let graph = build_class_member_graph();
1044        let root = Path::new("/project");
1045        assert!(trace_class_member(&graph, root, "src/controller.ts", "nope").is_none());
1046    }
1047
1048    #[test]
1049    fn exact_class_method_resolution_rejects_overloads_without_guessing() {
1050        use fallow_types::extract::{MemberInfo, MemberKind};
1051
1052        let temp = tempfile::tempdir().unwrap();
1053        let root = temp.path();
1054        let path = root.join("repository.ts");
1055        let source =
1056            "export class Repository {\n  save(): void;\n  save(): void {}\n  run(): void {}\n}\n";
1057        std::fs::write(&path, source).unwrap();
1058        let first = source.find("save").unwrap() as u32;
1059        let second = source.rfind("save").unwrap() as u32;
1060        let run = source.find("run").unwrap() as u32;
1061        let member = |name: &str, start| MemberInfo {
1062            name: name.to_string(),
1063            kind: MemberKind::ClassMethod,
1064            span: oxc_span::Span::new(start, start + 4),
1065            has_decorator: false,
1066            decorator_names: vec![],
1067            is_instance_returning_static: false,
1068            is_self_returning: false,
1069        };
1070        let files = vec![DiscoveredFile {
1071            id: FileId(0),
1072            path: path.clone(),
1073            size_bytes: source.len() as u64,
1074        }];
1075        let resolved_modules = vec![ResolvedModule {
1076            file_id: FileId(0),
1077            path,
1078            exports: vec![ExportInfo {
1079                name: ExportName::Named("Repository".to_string()),
1080                local_name: Some("Repository".to_string()),
1081                is_type_only: false,
1082                visibility: VisibilityTag::None,
1083                expected_unused_reason: None,
1084                span: oxc_span::Span::new(0, source.len() as u32),
1085                members: vec![
1086                    member("save", first),
1087                    member("save", second),
1088                    member("run", run),
1089                ],
1090                is_side_effect_used: false,
1091                super_class: None,
1092            }],
1093            ..Default::default()
1094        }];
1095        let graph = ModuleGraph::build(&resolved_modules, &[], &files);
1096
1097        assert_eq!(
1098            semantic_symbol_for_exact_class_method(
1099                &graph,
1100                root,
1101                "repository.ts",
1102                "Repository",
1103                "save",
1104            ),
1105            Err(SemanticClassMethodResolutionError::AmbiguousSymbol)
1106        );
1107        assert_eq!(
1108            semantic_symbol_for_exact_class_method(
1109                &graph,
1110                root,
1111                "repository.ts",
1112                "OtherRepository",
1113                "save",
1114            ),
1115            Err(SemanticClassMethodResolutionError::SymbolNotFound)
1116        );
1117        let resolved = semantic_symbol_for_exact_class_method(
1118            &graph,
1119            root,
1120            "repository.ts",
1121            "Repository",
1122            "run",
1123        )
1124        .unwrap();
1125        assert_eq!(resolved.owner.as_deref(), Some("Repository"));
1126        assert_eq!(resolved.local_name, "run");
1127    }
1128
1129    /// Build a graph where the controller declaring `Ctrl` is NOT imported by
1130    /// the entry, so its file is unreachable and every member is dead.
1131    fn build_unreachable_class_member_graph() -> ModuleGraph {
1132        use fallow_types::extract::{MemberInfo, MemberKind};
1133
1134        let files = vec![
1135            DiscoveredFile {
1136                id: FileId(0),
1137                path: PathBuf::from("/project/src/entry.ts"),
1138                size_bytes: 100,
1139            },
1140            DiscoveredFile {
1141                id: FileId(1),
1142                path: PathBuf::from("/project/src/controller.ts"),
1143                size_bytes: 50,
1144            },
1145        ];
1146        let entry_points = vec![EntryPoint {
1147            path: PathBuf::from("/project/src/entry.ts"),
1148            source: EntryPointSource::PackageJsonMain,
1149        }];
1150        let method = |name: &str| MemberInfo {
1151            name: name.to_string(),
1152            kind: MemberKind::ClassMethod,
1153            span: oxc_span::Span::new(0, 4),
1154            has_decorator: false,
1155            decorator_names: vec![],
1156            is_instance_returning_static: false,
1157            is_self_returning: false,
1158        };
1159        let resolved_modules = vec![
1160            ResolvedModule {
1161                file_id: FileId(0),
1162                path: PathBuf::from("/project/src/entry.ts"),
1163                // Entry imports nothing, so controller.ts is unreachable.
1164                ..Default::default()
1165            },
1166            ResolvedModule {
1167                file_id: FileId(1),
1168                path: PathBuf::from("/project/src/controller.ts"),
1169                exports: vec![ExportInfo {
1170                    name: ExportName::Named("Ctrl".to_string()),
1171                    local_name: Some("Ctrl".to_string()),
1172                    is_type_only: false,
1173                    visibility: VisibilityTag::None,
1174                    expected_unused_reason: None,
1175                    span: oxc_span::Span::new(0, 20),
1176                    members: vec![method("dead")],
1177                    is_side_effect_used: false,
1178                    super_class: None,
1179                }],
1180                ..Default::default()
1181            },
1182        ];
1183        ModuleGraph::build(&resolved_modules, &entry_points, &files)
1184    }
1185
1186    #[test]
1187    fn trace_class_member_unreachable_owner_reports_dead_reason() {
1188        // `!file_reachable` branch: the owning file is not reachable from any
1189        // entry point, so the reason states the class and its members are dead.
1190        let graph = build_unreachable_class_member_graph();
1191        let root = Path::new("/project");
1192
1193        let trace = trace_class_member(&graph, root, "src/controller.ts", "dead").unwrap();
1194        assert!(!trace.owner_file_reachable);
1195        assert!(
1196            trace.reason.contains("not reachable"),
1197            "unreachable owner reason should say so: {}",
1198            trace.reason
1199        );
1200        // The unreachable branch does not point at a member command (the file is
1201        // dead wholesale via the unused-file finding).
1202        assert!(!trace.reason.contains("--unused-class-members"));
1203    }
1204
1205    #[test]
1206    fn trace_class_member_prefers_used_owner_on_name_collision() {
1207        // Two exports declare a member of the same name; the tie-break in
1208        // `max_by_key` must prefer the used, non-type-only owner so the trace
1209        // reports the reachable class rather than a type-only shadow.
1210        use fallow_types::extract::{MemberInfo, MemberKind};
1211
1212        let files = vec![
1213            DiscoveredFile {
1214                id: FileId(0),
1215                path: PathBuf::from("/project/src/entry.ts"),
1216                size_bytes: 100,
1217            },
1218            DiscoveredFile {
1219                id: FileId(1),
1220                path: PathBuf::from("/project/src/controller.ts"),
1221                size_bytes: 50,
1222            },
1223        ];
1224        let entry_points = vec![EntryPoint {
1225            path: PathBuf::from("/project/src/entry.ts"),
1226            source: EntryPointSource::PackageJsonMain,
1227        }];
1228        let method = |name: &str| MemberInfo {
1229            name: name.to_string(),
1230            kind: MemberKind::ClassMethod,
1231            span: oxc_span::Span::new(0, 4),
1232            has_decorator: false,
1233            decorator_names: vec![],
1234            is_instance_returning_static: false,
1235            is_self_returning: false,
1236        };
1237        let resolved_modules = vec![
1238            ResolvedModule {
1239                file_id: FileId(0),
1240                path: PathBuf::from("/project/src/entry.ts"),
1241                resolved_imports: vec![ResolvedImport {
1242                    info: ImportInfo {
1243                        source: "./controller".to_string(),
1244                        imported_name: ImportedName::Named("UsedCtrl".to_string()),
1245                        local_name: "UsedCtrl".to_string(),
1246                        is_type_only: false,
1247                        from_style: false,
1248                        span: oxc_span::Span::new(0, 10),
1249                        source_span: oxc_span::Span::default(),
1250                    },
1251                    target: ResolveResult::InternalModule(FileId(1)),
1252                }],
1253                ..Default::default()
1254            },
1255            ResolvedModule {
1256                file_id: FileId(1),
1257                path: PathBuf::from("/project/src/controller.ts"),
1258                exports: vec![
1259                    // Type-only, unreferenced owner declared FIRST: must lose the
1260                    // tie-break to the used, non-type-only owner below.
1261                    ExportInfo {
1262                        name: ExportName::Named("TypeCtrl".to_string()),
1263                        local_name: Some("TypeCtrl".to_string()),
1264                        is_type_only: true,
1265                        visibility: VisibilityTag::None,
1266                        expected_unused_reason: None,
1267                        span: oxc_span::Span::new(0, 20),
1268                        members: vec![method("shared")],
1269                        is_side_effect_used: false,
1270                        super_class: None,
1271                    },
1272                    ExportInfo {
1273                        name: ExportName::Named("UsedCtrl".to_string()),
1274                        local_name: Some("UsedCtrl".to_string()),
1275                        is_type_only: false,
1276                        visibility: VisibilityTag::None,
1277                        expected_unused_reason: None,
1278                        span: oxc_span::Span::new(0, 20),
1279                        members: vec![method("shared")],
1280                        is_side_effect_used: false,
1281                        super_class: None,
1282                    },
1283                ],
1284                ..Default::default()
1285            },
1286        ];
1287        let graph = ModuleGraph::build(&resolved_modules, &entry_points, &files);
1288        let root = Path::new("/project");
1289
1290        let trace = trace_class_member(&graph, root, "src/controller.ts", "shared").unwrap();
1291        assert_eq!(
1292            trace.owner_export, "UsedCtrl",
1293            "tie-break must prefer the used, non-type-only owner"
1294        );
1295        assert!(trace.owner_is_used);
1296    }
1297
1298    #[test]
1299    fn trace_nonexistent_file() {
1300        let graph = build_test_graph();
1301        let root = Path::new("/project");
1302
1303        let trace = trace_export(&graph, root, "src/nope.ts", "foo");
1304        assert!(trace.is_none());
1305    }
1306
1307    #[test]
1308    fn trace_file_edges() {
1309        let graph = build_test_graph();
1310        let root = Path::new("/project");
1311
1312        let trace = trace_file(&graph, root, "src/entry.ts").unwrap();
1313        assert!(trace.is_entry_point);
1314        assert!(trace.is_reachable);
1315        assert_eq!(trace.imports_from.len(), 1);
1316        assert_eq!(trace.imports_from[0], PathBuf::from("src/utils.ts"));
1317        assert!(trace.imported_by.is_empty());
1318    }
1319
1320    #[test]
1321    fn trace_file_imported_by() {
1322        let graph = build_test_graph();
1323        let root = Path::new("/project");
1324
1325        let trace = trace_file(&graph, root, "src/utils.ts").unwrap();
1326        assert!(!trace.is_entry_point);
1327        assert!(trace.is_reachable);
1328        assert_eq!(trace.exports.len(), 2);
1329        assert_eq!(trace.imported_by.len(), 1);
1330        assert_eq!(trace.imported_by[0], PathBuf::from("src/entry.ts"));
1331    }
1332
1333    #[test]
1334    fn trace_unreachable_file() {
1335        let graph = build_test_graph();
1336        let root = Path::new("/project");
1337
1338        let trace = trace_file(&graph, root, "src/unused.ts").unwrap();
1339        assert!(!trace.is_reachable);
1340        assert!(!trace.is_entry_point);
1341        assert!(trace.imported_by.is_empty());
1342    }
1343
1344    #[test]
1345    fn trace_dependency_used() {
1346        let files = vec![DiscoveredFile {
1347            id: FileId(0),
1348            path: PathBuf::from("/project/src/app.ts"),
1349            size_bytes: 100,
1350        }];
1351        let entry_points = vec![EntryPoint {
1352            path: PathBuf::from("/project/src/app.ts"),
1353            source: EntryPointSource::PackageJsonMain,
1354        }];
1355        let resolved_modules = vec![ResolvedModule {
1356            file_id: FileId(0),
1357            path: PathBuf::from("/project/src/app.ts"),
1358            resolved_imports: vec![ResolvedImport {
1359                info: ImportInfo {
1360                    source: "lodash".to_string(),
1361                    imported_name: ImportedName::Named("get".to_string()),
1362                    local_name: "get".to_string(),
1363                    is_type_only: false,
1364                    from_style: false,
1365                    span: oxc_span::Span::new(0, 10),
1366                    source_span: oxc_span::Span::default(),
1367                },
1368                target: ResolveResult::NpmPackage("lodash".to_string()),
1369            }],
1370            ..Default::default()
1371        }];
1372
1373        let graph = ModuleGraph::build(&resolved_modules, &entry_points, &files);
1374        let root = Path::new("/project");
1375
1376        let trace = trace_dependency(&graph, root, "lodash", &FxHashSet::default());
1377        assert!(trace.is_used);
1378        assert!(!trace.used_in_scripts);
1379        assert_eq!(trace.import_count, 1);
1380        assert_eq!(trace.imported_by[0], PathBuf::from("src/app.ts"));
1381    }
1382
1383    #[test]
1384    fn trace_dependency_unused() {
1385        let files = vec![DiscoveredFile {
1386            id: FileId(0),
1387            path: PathBuf::from("/project/src/app.ts"),
1388            size_bytes: 100,
1389        }];
1390        let entry_points = vec![EntryPoint {
1391            path: PathBuf::from("/project/src/app.ts"),
1392            source: EntryPointSource::PackageJsonMain,
1393        }];
1394        let resolved_modules = vec![ResolvedModule {
1395            file_id: FileId(0),
1396            path: PathBuf::from("/project/src/app.ts"),
1397            ..Default::default()
1398        }];
1399
1400        let graph = ModuleGraph::build(&resolved_modules, &entry_points, &files);
1401        let root = Path::new("/project");
1402
1403        let trace = trace_dependency(&graph, root, "nonexistent-pkg", &FxHashSet::default());
1404        assert!(!trace.is_used);
1405        assert!(!trace.used_in_scripts);
1406        assert_eq!(trace.import_count, 0);
1407        assert!(trace.imported_by.is_empty());
1408    }
1409
1410    #[test]
1411    fn trace_dependency_used_only_in_scripts() {
1412        let files = vec![DiscoveredFile {
1413            id: FileId(0),
1414            path: PathBuf::from("/project/src/app.ts"),
1415            size_bytes: 100,
1416        }];
1417        let entry_points = vec![EntryPoint {
1418            path: PathBuf::from("/project/src/app.ts"),
1419            source: EntryPointSource::PackageJsonMain,
1420        }];
1421        let resolved_modules = vec![ResolvedModule {
1422            file_id: FileId(0),
1423            path: PathBuf::from("/project/src/app.ts"),
1424            ..Default::default()
1425        }];
1426
1427        let graph = ModuleGraph::build(&resolved_modules, &entry_points, &files);
1428        let root = Path::new("/project");
1429        let mut script_used = FxHashSet::default();
1430        script_used.insert("microbundle".to_string());
1431
1432        let trace = trace_dependency(&graph, root, "microbundle", &script_used);
1433        assert!(
1434            trace.is_used,
1435            "is_used must be true when the package is referenced from package.json scripts"
1436        );
1437        assert!(trace.used_in_scripts);
1438        assert_eq!(trace.import_count, 0);
1439        assert!(trace.imported_by.is_empty());
1440    }
1441
1442    #[test]
1443    fn trace_clone_finds_matching_group() {
1444        use crate::duplicates::{CloneGroup, CloneInstance, DuplicationReport, DuplicationStats};
1445        let report = DuplicationReport {
1446            clone_groups: vec![CloneGroup {
1447                instances: vec![
1448                    CloneInstance {
1449                        file: PathBuf::from("/project/src/a.ts"),
1450                        start_line: 10,
1451                        end_line: 20,
1452                        start_col: 0,
1453                        end_col: 0,
1454                        fragment: "fn foo() {}".to_string(),
1455                    },
1456                    CloneInstance {
1457                        file: PathBuf::from("/project/src/b.ts"),
1458                        start_line: 5,
1459                        end_line: 15,
1460                        start_col: 0,
1461                        end_col: 0,
1462                        fragment: "fn foo() {}".to_string(),
1463                    },
1464                ],
1465                token_count: 60,
1466                line_count: 11,
1467            }],
1468            clone_families: vec![],
1469            mirrored_directories: vec![],
1470            stats: DuplicationStats {
1471                total_files: 2,
1472                files_with_clones: 2,
1473                total_lines: 100,
1474                duplicated_lines: 22,
1475                total_tokens: 200,
1476                duplicated_tokens: 120,
1477                clone_groups: 1,
1478                clone_instances: 2,
1479                duplication_percentage: 22.0,
1480                clone_groups_below_min_occurrences: 0,
1481            },
1482        };
1483        let trace = trace_clone(&report, Path::new("/project"), "src/a.ts", 15);
1484        assert!(trace.matched_instance.is_some());
1485        assert_eq!(trace.clone_groups.len(), 1);
1486        assert_eq!(trace.clone_groups[0].instances.len(), 2);
1487        assert!(trace.clone_groups[0].fingerprint.starts_with("dup:"));
1488        assert_eq!(trace.clone_groups[0].suggestion.estimated_savings, 11);
1489    }
1490
1491    #[test]
1492    fn trace_clone_by_fingerprint_resolves_and_misses() {
1493        use crate::duplicates::{
1494            CloneGroup, CloneInstance, DuplicationReport, DuplicationStats, clone_fingerprint,
1495        };
1496        let report = DuplicationReport {
1497            clone_groups: vec![CloneGroup {
1498                instances: vec![
1499                    CloneInstance {
1500                        file: PathBuf::from("/project/src/a.ts"),
1501                        start_line: 10,
1502                        end_line: 20,
1503                        start_col: 0,
1504                        end_col: 0,
1505                        fragment: "fn buildInvoice() {}".to_string(),
1506                    },
1507                    CloneInstance {
1508                        file: PathBuf::from("/project/src/b.ts"),
1509                        start_line: 5,
1510                        end_line: 15,
1511                        start_col: 0,
1512                        end_col: 0,
1513                        fragment: "fn buildInvoice() {}".to_string(),
1514                    },
1515                ],
1516                token_count: 60,
1517                line_count: 11,
1518            }],
1519            clone_families: vec![],
1520            mirrored_directories: vec![],
1521            stats: DuplicationStats::default(),
1522        };
1523        let fp = clone_fingerprint(&report.clone_groups[0].instances);
1524
1525        let hit = trace_clone_by_fingerprint(&report, Path::new("/project"), &fp);
1526        assert!(hit.matched_instance.is_some());
1527        assert_eq!(hit.clone_groups.len(), 1);
1528        assert_eq!(hit.clone_groups[0].fingerprint, fp);
1529        assert_eq!(hit.line, 10);
1530
1531        let miss = trace_clone_by_fingerprint(&report, Path::new("/project"), "dup:deadbeef");
1532        assert!(miss.matched_instance.is_none());
1533        assert!(miss.clone_groups.is_empty());
1534    }
1535
1536    #[test]
1537    fn trace_clone_no_match() {
1538        use crate::duplicates::{CloneGroup, CloneInstance, DuplicationReport, DuplicationStats};
1539        let report = DuplicationReport {
1540            clone_groups: vec![CloneGroup {
1541                instances: vec![CloneInstance {
1542                    file: PathBuf::from("/project/src/a.ts"),
1543                    start_line: 10,
1544                    end_line: 20,
1545                    start_col: 0,
1546                    end_col: 0,
1547                    fragment: "fn foo() {}".to_string(),
1548                }],
1549                token_count: 60,
1550                line_count: 11,
1551            }],
1552            clone_families: vec![],
1553            mirrored_directories: vec![],
1554            stats: DuplicationStats {
1555                total_files: 1,
1556                files_with_clones: 1,
1557                total_lines: 50,
1558                duplicated_lines: 11,
1559                total_tokens: 100,
1560                duplicated_tokens: 60,
1561                clone_groups: 1,
1562                clone_instances: 1,
1563                duplication_percentage: 22.0,
1564                clone_groups_below_min_occurrences: 0,
1565            },
1566        };
1567        let trace = trace_clone(&report, Path::new("/project"), "src/a.ts", 25);
1568        assert!(trace.matched_instance.is_none());
1569        assert!(trace.clone_groups.is_empty());
1570    }
1571
1572    #[test]
1573    fn trace_clone_line_boundary() {
1574        use crate::duplicates::{CloneGroup, CloneInstance, DuplicationReport, DuplicationStats};
1575        let report = DuplicationReport {
1576            clone_groups: vec![CloneGroup {
1577                instances: vec![
1578                    CloneInstance {
1579                        file: PathBuf::from("/project/src/a.ts"),
1580                        start_line: 10,
1581                        end_line: 20,
1582                        start_col: 0,
1583                        end_col: 0,
1584                        fragment: "code".to_string(),
1585                    },
1586                    CloneInstance {
1587                        file: PathBuf::from("/project/src/b.ts"),
1588                        start_line: 1,
1589                        end_line: 11,
1590                        start_col: 0,
1591                        end_col: 0,
1592                        fragment: "code".to_string(),
1593                    },
1594                ],
1595                token_count: 50,
1596                line_count: 11,
1597            }],
1598            clone_families: vec![],
1599            mirrored_directories: vec![],
1600            stats: DuplicationStats {
1601                total_files: 2,
1602                files_with_clones: 2,
1603                total_lines: 100,
1604                duplicated_lines: 22,
1605                total_tokens: 200,
1606                duplicated_tokens: 100,
1607                clone_groups: 1,
1608                clone_instances: 2,
1609                duplication_percentage: 22.0,
1610                clone_groups_below_min_occurrences: 0,
1611            },
1612        };
1613        let root = Path::new("/project");
1614        assert!(
1615            trace_clone(&report, root, "src/a.ts", 10)
1616                .matched_instance
1617                .is_some()
1618        );
1619        assert!(
1620            trace_clone(&report, root, "src/a.ts", 20)
1621                .matched_instance
1622                .is_some()
1623        );
1624        assert!(
1625            trace_clone(&report, root, "src/a.ts", 21)
1626                .matched_instance
1627                .is_none()
1628        );
1629    }
1630
1631    #[test]
1632    fn trace_clone_returns_relative_instance_paths() {
1633        use crate::duplicates::{CloneGroup, CloneInstance, DuplicationReport, DuplicationStats};
1634        let report = DuplicationReport {
1635            clone_groups: vec![CloneGroup {
1636                instances: vec![
1637                    CloneInstance {
1638                        file: PathBuf::from("/project/src/a.ts"),
1639                        start_line: 1,
1640                        end_line: 10,
1641                        start_col: 0,
1642                        end_col: 0,
1643                        fragment: "code".to_string(),
1644                    },
1645                    CloneInstance {
1646                        file: PathBuf::from("/project/src/b.ts"),
1647                        start_line: 1,
1648                        end_line: 10,
1649                        start_col: 0,
1650                        end_col: 0,
1651                        fragment: "code".to_string(),
1652                    },
1653                ],
1654                token_count: 50,
1655                line_count: 10,
1656            }],
1657            clone_families: vec![],
1658            mirrored_directories: vec![],
1659            stats: DuplicationStats {
1660                total_files: 2,
1661                files_with_clones: 2,
1662                total_lines: 50,
1663                duplicated_lines: 20,
1664                total_tokens: 100,
1665                duplicated_tokens: 100,
1666                clone_groups: 1,
1667                clone_instances: 2,
1668                duplication_percentage: 40.0,
1669                clone_groups_below_min_occurrences: 0,
1670            },
1671        };
1672        let trace = trace_clone(&report, Path::new("/project"), "src/a.ts", 5);
1673        let matched = trace.matched_instance.as_ref().expect("match expected");
1674        assert_eq!(matched.file, PathBuf::from("src/a.ts"));
1675        for group in &trace.clone_groups {
1676            for inst in &group.instances {
1677                let as_str = inst.file.to_string_lossy();
1678                assert!(
1679                    !as_str.starts_with('/'),
1680                    "instance file should be relative, got {as_str}",
1681                );
1682                assert!(
1683                    !as_str.contains(":\\") && !as_str.contains(":/"),
1684                    "instance file should not have a drive letter, got {as_str}",
1685                );
1686            }
1687        }
1688
1689        let json = serde_json::to_string(&trace).expect("serializes");
1690        assert!(
1691            !json.contains("\"/project/"),
1692            "serialized trace should not leak absolute paths: {json}",
1693        );
1694    }
1695
1696    /// Regression for the MCP e2e `trace_export` / `trace_file` Windows
1697    /// failures: the MCP layer passes forward-slashed user input
1698    /// (`src/utils.ts`) but `module_path` on Windows uses backslash
1699    /// separators (`D:\a\fallow\...\src\utils.ts`). The byte-level
1700    /// equality check missed every match. The helper now normalises
1701    /// both sides to forward slashes before comparing.
1702    #[test]
1703    fn path_matches_normalises_windows_module_path_against_posix_user_path() {
1704        let root = Path::new(r"D:\a\fallow\fallow\tests\fixtures\basic-project");
1705        let module_path =
1706            PathBuf::from(r"D:\a\fallow\fallow\tests\fixtures\basic-project\src\utils.ts");
1707        assert!(path_matches(&module_path, root, "src/utils.ts"));
1708        assert!(path_matches(&module_path, root, r"src\utils.ts"));
1709    }
1710
1711    #[test]
1712    fn path_matches_ends_with_fallback_handles_mixed_separators() {
1713        let root = Path::new("/some/other/root");
1714        let module_path =
1715            PathBuf::from(r"D:\a\fallow\fallow\tests\fixtures\basic-project\src\utils.ts");
1716        assert!(path_matches(&module_path, root, "src/utils.ts"));
1717    }
1718
1719    /// Regression for the MCP e2e trace_export / trace_file failures: even
1720    /// after `path_matches` correctly identified the file on Windows, the
1721    /// trace output struct's `file: PathBuf` field serialized the stored
1722    /// backslash-shaped path verbatim. JSON consumers (MCP agents, CI
1723    /// pipelines, the cross-platform trace_file assertion in
1724    /// `e2e_trace_file_returns_json`) expect forward-slash. Pin the
1725    /// contract via raw-string Windows-shaped `PathBuf::from` so the test
1726    /// runs cross-platform.
1727    #[test]
1728    fn export_trace_serializes_windows_path_with_forward_slashes() {
1729        let trace = ExportTrace {
1730            file: PathBuf::from(r"src\utils.ts"),
1731            export_name: "foo".to_string(),
1732            file_reachable: true,
1733            is_entry_point: false,
1734            is_used: true,
1735            direct_references: vec![ExportReference {
1736                from_file: PathBuf::from(r"src\entry.ts"),
1737                kind: "named import".to_string(),
1738            }],
1739            re_export_chains: vec![ReExportChain {
1740                barrel_file: PathBuf::from(r"src\index.ts"),
1741                exported_as: "foo".to_string(),
1742                reference_count: 1,
1743            }],
1744            reason: "ok".to_string(),
1745            semantic: None,
1746        };
1747        let json = serde_json::to_string(&trace).expect("serializes");
1748        assert!(
1749            json.contains("\"file\":\"src/utils.ts\""),
1750            "ExportTrace.file must serialize with forward slashes: {json}"
1751        );
1752        assert!(
1753            json.contains("\"from_file\":\"src/entry.ts\""),
1754            "ExportReference.from_file must serialize with forward slashes: {json}"
1755        );
1756        assert!(
1757            json.contains("\"barrel_file\":\"src/index.ts\""),
1758            "ReExportChain.barrel_file must serialize with forward slashes: {json}"
1759        );
1760        assert!(
1761            !json.contains(r"\\"),
1762            "no backslash sequence should remain anywhere in the JSON: {json}"
1763        );
1764    }
1765
1766    #[test]
1767    fn file_trace_serializes_windows_paths_with_forward_slashes() {
1768        let trace = FileTrace {
1769            file: PathBuf::from(r"src\utils.ts"),
1770            is_reachable: true,
1771            is_entry_point: false,
1772            exports: vec![],
1773            imports_from: vec![PathBuf::from(r"src\helpers.ts")],
1774            imported_by: vec![PathBuf::from(r"src\entry.ts")],
1775            re_exports: vec![TracedReExport {
1776                source_file: PathBuf::from(r"src\source.ts"),
1777                imported_name: "foo".to_string(),
1778                exported_name: "foo".to_string(),
1779            }],
1780        };
1781        let json = serde_json::to_string(&trace).expect("serializes");
1782        assert!(json.contains("\"file\":\"src/utils.ts\""), "got {json}");
1783        assert!(
1784            json.contains("\"imports_from\":[\"src/helpers.ts\"]"),
1785            "got {json}"
1786        );
1787        assert!(
1788            json.contains("\"imported_by\":[\"src/entry.ts\"]"),
1789            "got {json}"
1790        );
1791        assert!(
1792            json.contains("\"source_file\":\"src/source.ts\""),
1793            "got {json}"
1794        );
1795        assert!(!json.contains(r"\\"), "no backslash should remain: {json}");
1796    }
1797}