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        spread: group.spread(),
703        similarity: group.similarity,
704        instances: group
705            .instances
706            .iter()
707            .map(|inst| relativize_instance(inst, root))
708            .collect(),
709        suggestion: group_refactoring_suggestion(group),
710        suggested_name: dominant_identifier(group),
711    }
712}
713
714#[must_use]
715pub fn trace_clone(
716    report: &DuplicationReport,
717    root: &Path,
718    file_path: &str,
719    line: usize,
720) -> CloneTrace {
721    let resolved = root.join(file_path);
722    let mut matched_instance = None;
723    let mut clone_groups = Vec::new();
724    let fingerprints = CloneFingerprintSet::from_groups(&report.clone_groups);
725
726    for group in &report.clone_groups {
727        let matching = group.instances.iter().find(|inst| {
728            let inst_matches = inst.file == resolved
729                || inst.file.strip_prefix(root).unwrap_or(&inst.file) == Path::new(file_path);
730            inst_matches && inst.start_line <= line && line <= inst.end_line
731        });
732
733        if let Some(matched) = matching {
734            if matched_instance.is_none() {
735                matched_instance = Some(relativize_instance(matched, root));
736            }
737            clone_groups.push(build_traced_group(group, root, &fingerprints));
738        }
739    }
740
741    CloneTrace {
742        file: PathBuf::from(file_path),
743        line,
744        matched_instance,
745        clone_groups,
746    }
747}
748
749/// Trace a clone group by its stable content fingerprint.
750///
751/// Fingerprints are usually `dup:<8hex>` and widen only when needed to avoid a
752/// collision inside the same report.
753///
754/// Returns a [`CloneTrace`] whose single `clone_groups` entry is the matched
755/// group and whose `file` / `line` / `matched_instance` come from that group's
756/// representative (first) instance. `matched_instance` is `None` (and
757/// `clone_groups` empty) when no group matches the fingerprint.
758#[must_use]
759pub fn trace_clone_by_fingerprint(
760    report: &DuplicationReport,
761    root: &Path,
762    fingerprint: &str,
763) -> CloneTrace {
764    let fingerprints = CloneFingerprintSet::from_groups(&report.clone_groups);
765    let matched = fingerprints.find_group(&report.clone_groups, fingerprint);
766
767    let Some(group) = matched else {
768        return CloneTrace {
769            file: PathBuf::new(),
770            line: 0,
771            matched_instance: None,
772            clone_groups: Vec::new(),
773        };
774    };
775
776    let representative = group
777        .instances
778        .first()
779        .map(|inst| relativize_instance(inst, root));
780    let (file, line) = representative.as_ref().map_or_else(
781        || (PathBuf::new(), 0),
782        |inst| (inst.file.clone(), inst.start_line),
783    );
784
785    CloneTrace {
786        file,
787        line,
788        matched_instance: representative,
789        clone_groups: vec![build_traced_group(group, root, &fingerprints)],
790    }
791}
792
793/// Return a copy of `inst` with `file` rewritten relative to `root` (forward-slash normalized
794/// for cross-platform JSON parity with `serde_path::serialize`). If `inst.file` is already
795/// outside `root`, the path is left unchanged.
796fn relativize_instance(inst: &CloneInstance, root: &Path) -> CloneInstance {
797    let rel = inst.file.strip_prefix(root).map_or_else(
798        |_| inst.file.clone(),
799        |p| PathBuf::from(p.to_string_lossy().replace('\\', "/")),
800    );
801    CloneInstance {
802        file: rel,
803        ..inst.clone()
804    }
805}
806
807#[cfg(test)]
808mod tests {
809    use super::*;
810
811    use crate::discover::{DiscoveredFile, EntryPoint, EntryPointSource, FileId};
812    use crate::extract::{ExportInfo, ExportName, ImportInfo, ImportedName, VisibilityTag};
813    use crate::resolve::{ResolveResult, ResolvedImport, ResolvedModule};
814
815    fn build_test_graph() -> ModuleGraph {
816        let files = vec![
817            DiscoveredFile {
818                id: FileId(0),
819                path: PathBuf::from("/project/src/entry.ts"),
820                size_bytes: 100,
821            },
822            DiscoveredFile {
823                id: FileId(1),
824                path: PathBuf::from("/project/src/utils.ts"),
825                size_bytes: 50,
826            },
827            DiscoveredFile {
828                id: FileId(2),
829                path: PathBuf::from("/project/src/unused.ts"),
830                size_bytes: 30,
831            },
832        ];
833
834        let entry_points = vec![EntryPoint {
835            path: PathBuf::from("/project/src/entry.ts"),
836            source: EntryPointSource::PackageJsonMain,
837        }];
838
839        let resolved_modules = vec![
840            ResolvedModule {
841                file_id: FileId(0),
842                path: PathBuf::from("/project/src/entry.ts"),
843                resolved_imports: vec![ResolvedImport {
844                    info: ImportInfo {
845                        source: "./utils".to_string(),
846                        imported_name: ImportedName::Named("foo".to_string()),
847                        local_name: "foo".to_string(),
848                        is_type_only: false,
849                        from_style: false,
850                        span: oxc_span::Span::new(0, 10),
851                        source_span: oxc_span::Span::default(),
852                    },
853                    target: ResolveResult::InternalModule(FileId(1)),
854                }],
855                ..Default::default()
856            },
857            ResolvedModule {
858                file_id: FileId(1),
859                path: PathBuf::from("/project/src/utils.ts"),
860                exports: vec![
861                    ExportInfo {
862                        name: ExportName::Named("foo".to_string()),
863                        local_name: Some("foo".to_string()),
864                        is_type_only: false,
865                        visibility: VisibilityTag::None,
866                        expected_unused_reason: None,
867                        span: oxc_span::Span::new(0, 20),
868                        members: vec![],
869                        is_side_effect_used: false,
870                        super_class: None,
871                    },
872                    ExportInfo {
873                        name: ExportName::Named("bar".to_string()),
874                        local_name: Some("bar".to_string()),
875                        is_type_only: false,
876                        visibility: VisibilityTag::None,
877                        expected_unused_reason: None,
878                        span: oxc_span::Span::new(21, 40),
879                        members: vec![],
880                        is_side_effect_used: false,
881                        super_class: None,
882                    },
883                ]
884                .into(),
885                ..Default::default()
886            },
887            ResolvedModule {
888                file_id: FileId(2),
889                path: PathBuf::from("/project/src/unused.ts"),
890                exports: vec![ExportInfo {
891                    name: ExportName::Named("baz".to_string()),
892                    local_name: Some("baz".to_string()),
893                    is_type_only: false,
894                    visibility: VisibilityTag::None,
895                    expected_unused_reason: None,
896                    span: oxc_span::Span::new(0, 15),
897                    members: vec![],
898                    is_side_effect_used: false,
899                    super_class: None,
900                }]
901                .into(),
902                ..Default::default()
903            },
904        ];
905
906        ModuleGraph::build(&resolved_modules, &entry_points, &files)
907    }
908
909    #[test]
910    fn trace_used_export() {
911        let graph = build_test_graph();
912        let root = Path::new("/project");
913
914        let trace = trace_export(&graph, root, "src/utils.ts", "foo").unwrap();
915        assert!(trace.is_used);
916        assert!(trace.file_reachable);
917        assert_eq!(trace.direct_references.len(), 1);
918        assert_eq!(
919            trace.direct_references[0].from_file,
920            PathBuf::from("src/entry.ts")
921        );
922        assert_eq!(trace.direct_references[0].kind, "named import");
923    }
924
925    #[test]
926    fn trace_unused_export() {
927        let graph = build_test_graph();
928        let root = Path::new("/project");
929
930        let trace = trace_export(&graph, root, "src/utils.ts", "bar").unwrap();
931        assert!(!trace.is_used);
932        assert!(trace.file_reachable);
933        assert!(trace.direct_references.is_empty());
934    }
935
936    #[test]
937    fn trace_unreachable_file_export() {
938        let graph = build_test_graph();
939        let root = Path::new("/project");
940
941        let trace = trace_export(&graph, root, "src/unused.ts", "baz").unwrap();
942        assert!(!trace.is_used);
943        assert!(!trace.file_reachable);
944        assert!(trace.reason.contains("unreachable"));
945    }
946
947    #[test]
948    fn trace_nonexistent_export() {
949        let graph = build_test_graph();
950        let root = Path::new("/project");
951
952        let trace = trace_export(&graph, root, "src/utils.ts", "nonexistent");
953        assert!(trace.is_none());
954    }
955
956    fn build_class_member_graph() -> ModuleGraph {
957        use fallow_types::extract::{MemberInfo, MemberKind};
958
959        let files = vec![
960            DiscoveredFile {
961                id: FileId(0),
962                path: PathBuf::from("/project/src/entry.ts"),
963                size_bytes: 100,
964            },
965            DiscoveredFile {
966                id: FileId(1),
967                path: PathBuf::from("/project/src/controller.ts"),
968                size_bytes: 50,
969            },
970        ];
971        let entry_points = vec![EntryPoint {
972            path: PathBuf::from("/project/src/entry.ts"),
973            source: EntryPointSource::PackageJsonMain,
974        }];
975        let method = |name: &str| MemberInfo {
976            name: name.to_string(),
977            kind: MemberKind::ClassMethod,
978            span: oxc_span::Span::new(0, 4),
979            has_decorator: false,
980            decorator_names: vec![],
981            is_instance_returning_static: false,
982            is_self_returning: false,
983        };
984        let resolved_modules = vec![
985            ResolvedModule {
986                file_id: FileId(0),
987                path: PathBuf::from("/project/src/entry.ts"),
988                resolved_imports: vec![ResolvedImport {
989                    info: ImportInfo {
990                        source: "./controller".to_string(),
991                        imported_name: ImportedName::Named("Ctrl".to_string()),
992                        local_name: "Ctrl".to_string(),
993                        is_type_only: false,
994                        from_style: false,
995                        span: oxc_span::Span::new(0, 10),
996                        source_span: oxc_span::Span::default(),
997                    },
998                    target: ResolveResult::InternalModule(FileId(1)),
999                }],
1000                ..Default::default()
1001            },
1002            ResolvedModule {
1003                file_id: FileId(1),
1004                path: PathBuf::from("/project/src/controller.ts"),
1005                exports: vec![ExportInfo {
1006                    name: ExportName::Named("Ctrl".to_string()),
1007                    local_name: Some("Ctrl".to_string()),
1008                    is_type_only: false,
1009                    visibility: VisibilityTag::None,
1010                    expected_unused_reason: None,
1011                    span: oxc_span::Span::new(0, 20),
1012                    members: vec![method("used"), method("dead")],
1013                    is_side_effect_used: false,
1014                    super_class: None,
1015                }]
1016                .into(),
1017                ..Default::default()
1018            },
1019        ];
1020        ModuleGraph::build(&resolved_modules, &entry_points, &files)
1021    }
1022
1023    #[test]
1024    fn trace_class_member_reports_owner_class() {
1025        // #1744: `--trace FILE:MEMBER` on a class member reports the owning
1026        // class instead of erroring "export not found".
1027        let graph = build_class_member_graph();
1028        let root = Path::new("/project");
1029
1030        let trace = trace_class_member(&graph, root, "src/controller.ts", "dead").unwrap();
1031        assert_eq!(trace.owner_export, "Ctrl");
1032        assert_eq!(trace.member_name, "dead");
1033        assert_eq!(trace.member_kind, "class-method");
1034        assert!(trace.owner_is_used);
1035        assert!(trace.owner_file_reachable);
1036        assert_eq!(trace.owner_direct_references.len(), 1);
1037        assert!(
1038            trace.reason.contains("--unused-class-members"),
1039            "reason should point at the member command: {}",
1040            trace.reason
1041        );
1042    }
1043
1044    #[test]
1045    fn trace_class_member_absent_name_is_none() {
1046        // A name that is neither a top-level export nor a declared member falls
1047        // through so the caller emits the "not found" error.
1048        let graph = build_class_member_graph();
1049        let root = Path::new("/project");
1050        assert!(trace_class_member(&graph, root, "src/controller.ts", "nope").is_none());
1051    }
1052
1053    #[test]
1054    fn exact_class_method_resolution_rejects_overloads_without_guessing() {
1055        use fallow_types::extract::{MemberInfo, MemberKind};
1056
1057        let temp = tempfile::tempdir().unwrap();
1058        let root = temp.path();
1059        let path = root.join("repository.ts");
1060        let source =
1061            "export class Repository {\n  save(): void;\n  save(): void {}\n  run(): void {}\n}\n";
1062        std::fs::write(&path, source).unwrap();
1063        let first = source.find("save").unwrap() as u32;
1064        let second = source.rfind("save").unwrap() as u32;
1065        let run = source.find("run").unwrap() as u32;
1066        let member = |name: &str, start| MemberInfo {
1067            name: name.to_string(),
1068            kind: MemberKind::ClassMethod,
1069            span: oxc_span::Span::new(start, start + 4),
1070            has_decorator: false,
1071            decorator_names: vec![],
1072            is_instance_returning_static: false,
1073            is_self_returning: false,
1074        };
1075        let files = vec![DiscoveredFile {
1076            id: FileId(0),
1077            path: path.clone(),
1078            size_bytes: source.len() as u64,
1079        }];
1080        let resolved_modules = vec![ResolvedModule {
1081            file_id: FileId(0),
1082            path,
1083            exports: vec![ExportInfo {
1084                name: ExportName::Named("Repository".to_string()),
1085                local_name: Some("Repository".to_string()),
1086                is_type_only: false,
1087                visibility: VisibilityTag::None,
1088                expected_unused_reason: None,
1089                span: oxc_span::Span::new(0, source.len() as u32),
1090                members: vec![
1091                    member("save", first),
1092                    member("save", second),
1093                    member("run", run),
1094                ],
1095                is_side_effect_used: false,
1096                super_class: None,
1097            }]
1098            .into(),
1099            ..Default::default()
1100        }];
1101        let graph = ModuleGraph::build(&resolved_modules, &[], &files);
1102
1103        assert_eq!(
1104            semantic_symbol_for_exact_class_method(
1105                &graph,
1106                root,
1107                "repository.ts",
1108                "Repository",
1109                "save",
1110            ),
1111            Err(SemanticClassMethodResolutionError::AmbiguousSymbol)
1112        );
1113        assert_eq!(
1114            semantic_symbol_for_exact_class_method(
1115                &graph,
1116                root,
1117                "repository.ts",
1118                "OtherRepository",
1119                "save",
1120            ),
1121            Err(SemanticClassMethodResolutionError::SymbolNotFound)
1122        );
1123        let resolved = semantic_symbol_for_exact_class_method(
1124            &graph,
1125            root,
1126            "repository.ts",
1127            "Repository",
1128            "run",
1129        )
1130        .unwrap();
1131        assert_eq!(resolved.owner.as_deref(), Some("Repository"));
1132        assert_eq!(resolved.local_name, "run");
1133    }
1134
1135    /// Build a graph where the controller declaring `Ctrl` is NOT imported by
1136    /// the entry, so its file is unreachable and every member is dead.
1137    fn build_unreachable_class_member_graph() -> ModuleGraph {
1138        use fallow_types::extract::{MemberInfo, MemberKind};
1139
1140        let files = vec![
1141            DiscoveredFile {
1142                id: FileId(0),
1143                path: PathBuf::from("/project/src/entry.ts"),
1144                size_bytes: 100,
1145            },
1146            DiscoveredFile {
1147                id: FileId(1),
1148                path: PathBuf::from("/project/src/controller.ts"),
1149                size_bytes: 50,
1150            },
1151        ];
1152        let entry_points = vec![EntryPoint {
1153            path: PathBuf::from("/project/src/entry.ts"),
1154            source: EntryPointSource::PackageJsonMain,
1155        }];
1156        let method = |name: &str| MemberInfo {
1157            name: name.to_string(),
1158            kind: MemberKind::ClassMethod,
1159            span: oxc_span::Span::new(0, 4),
1160            has_decorator: false,
1161            decorator_names: vec![],
1162            is_instance_returning_static: false,
1163            is_self_returning: false,
1164        };
1165        let resolved_modules = vec![
1166            ResolvedModule {
1167                file_id: FileId(0),
1168                path: PathBuf::from("/project/src/entry.ts"),
1169                // Entry imports nothing, so controller.ts is unreachable.
1170                ..Default::default()
1171            },
1172            ResolvedModule {
1173                file_id: FileId(1),
1174                path: PathBuf::from("/project/src/controller.ts"),
1175                exports: vec![ExportInfo {
1176                    name: ExportName::Named("Ctrl".to_string()),
1177                    local_name: Some("Ctrl".to_string()),
1178                    is_type_only: false,
1179                    visibility: VisibilityTag::None,
1180                    expected_unused_reason: None,
1181                    span: oxc_span::Span::new(0, 20),
1182                    members: vec![method("dead")],
1183                    is_side_effect_used: false,
1184                    super_class: None,
1185                }]
1186                .into(),
1187                ..Default::default()
1188            },
1189        ];
1190        ModuleGraph::build(&resolved_modules, &entry_points, &files)
1191    }
1192
1193    #[test]
1194    fn trace_class_member_unreachable_owner_reports_dead_reason() {
1195        // `!file_reachable` branch: the owning file is not reachable from any
1196        // entry point, so the reason states the class and its members are dead.
1197        let graph = build_unreachable_class_member_graph();
1198        let root = Path::new("/project");
1199
1200        let trace = trace_class_member(&graph, root, "src/controller.ts", "dead").unwrap();
1201        assert!(!trace.owner_file_reachable);
1202        assert!(
1203            trace.reason.contains("not reachable"),
1204            "unreachable owner reason should say so: {}",
1205            trace.reason
1206        );
1207        // The unreachable branch does not point at a member command (the file is
1208        // dead wholesale via the unused-file finding).
1209        assert!(!trace.reason.contains("--unused-class-members"));
1210    }
1211
1212    #[test]
1213    fn trace_class_member_prefers_used_owner_on_name_collision() {
1214        // Two exports declare a member of the same name; the tie-break in
1215        // `max_by_key` must prefer the used, non-type-only owner so the trace
1216        // reports the reachable class rather than a type-only shadow.
1217        use fallow_types::extract::{MemberInfo, MemberKind};
1218
1219        let files = vec![
1220            DiscoveredFile {
1221                id: FileId(0),
1222                path: PathBuf::from("/project/src/entry.ts"),
1223                size_bytes: 100,
1224            },
1225            DiscoveredFile {
1226                id: FileId(1),
1227                path: PathBuf::from("/project/src/controller.ts"),
1228                size_bytes: 50,
1229            },
1230        ];
1231        let entry_points = vec![EntryPoint {
1232            path: PathBuf::from("/project/src/entry.ts"),
1233            source: EntryPointSource::PackageJsonMain,
1234        }];
1235        let method = |name: &str| MemberInfo {
1236            name: name.to_string(),
1237            kind: MemberKind::ClassMethod,
1238            span: oxc_span::Span::new(0, 4),
1239            has_decorator: false,
1240            decorator_names: vec![],
1241            is_instance_returning_static: false,
1242            is_self_returning: false,
1243        };
1244        let resolved_modules = vec![
1245            ResolvedModule {
1246                file_id: FileId(0),
1247                path: PathBuf::from("/project/src/entry.ts"),
1248                resolved_imports: vec![ResolvedImport {
1249                    info: ImportInfo {
1250                        source: "./controller".to_string(),
1251                        imported_name: ImportedName::Named("UsedCtrl".to_string()),
1252                        local_name: "UsedCtrl".to_string(),
1253                        is_type_only: false,
1254                        from_style: false,
1255                        span: oxc_span::Span::new(0, 10),
1256                        source_span: oxc_span::Span::default(),
1257                    },
1258                    target: ResolveResult::InternalModule(FileId(1)),
1259                }],
1260                ..Default::default()
1261            },
1262            ResolvedModule {
1263                file_id: FileId(1),
1264                path: PathBuf::from("/project/src/controller.ts"),
1265                exports: vec![
1266                    // Type-only, unreferenced owner declared FIRST: must lose the
1267                    // tie-break to the used, non-type-only owner below.
1268                    ExportInfo {
1269                        name: ExportName::Named("TypeCtrl".to_string()),
1270                        local_name: Some("TypeCtrl".to_string()),
1271                        is_type_only: true,
1272                        visibility: VisibilityTag::None,
1273                        expected_unused_reason: None,
1274                        span: oxc_span::Span::new(0, 20),
1275                        members: vec![method("shared")],
1276                        is_side_effect_used: false,
1277                        super_class: None,
1278                    },
1279                    ExportInfo {
1280                        name: ExportName::Named("UsedCtrl".to_string()),
1281                        local_name: Some("UsedCtrl".to_string()),
1282                        is_type_only: false,
1283                        visibility: VisibilityTag::None,
1284                        expected_unused_reason: None,
1285                        span: oxc_span::Span::new(0, 20),
1286                        members: vec![method("shared")],
1287                        is_side_effect_used: false,
1288                        super_class: None,
1289                    },
1290                ]
1291                .into(),
1292                ..Default::default()
1293            },
1294        ];
1295        let graph = ModuleGraph::build(&resolved_modules, &entry_points, &files);
1296        let root = Path::new("/project");
1297
1298        let trace = trace_class_member(&graph, root, "src/controller.ts", "shared").unwrap();
1299        assert_eq!(
1300            trace.owner_export, "UsedCtrl",
1301            "tie-break must prefer the used, non-type-only owner"
1302        );
1303        assert!(trace.owner_is_used);
1304    }
1305
1306    #[test]
1307    fn trace_nonexistent_file() {
1308        let graph = build_test_graph();
1309        let root = Path::new("/project");
1310
1311        let trace = trace_export(&graph, root, "src/nope.ts", "foo");
1312        assert!(trace.is_none());
1313    }
1314
1315    #[test]
1316    fn trace_file_edges() {
1317        let graph = build_test_graph();
1318        let root = Path::new("/project");
1319
1320        let trace = trace_file(&graph, root, "src/entry.ts").unwrap();
1321        assert!(trace.is_entry_point);
1322        assert!(trace.is_reachable);
1323        assert_eq!(trace.imports_from.len(), 1);
1324        assert_eq!(trace.imports_from[0], PathBuf::from("src/utils.ts"));
1325        assert!(trace.imported_by.is_empty());
1326    }
1327
1328    #[test]
1329    fn trace_file_imported_by() {
1330        let graph = build_test_graph();
1331        let root = Path::new("/project");
1332
1333        let trace = trace_file(&graph, root, "src/utils.ts").unwrap();
1334        assert!(!trace.is_entry_point);
1335        assert!(trace.is_reachable);
1336        assert_eq!(trace.exports.len(), 2);
1337        assert_eq!(trace.imported_by.len(), 1);
1338        assert_eq!(trace.imported_by[0], PathBuf::from("src/entry.ts"));
1339    }
1340
1341    #[test]
1342    fn trace_unreachable_file() {
1343        let graph = build_test_graph();
1344        let root = Path::new("/project");
1345
1346        let trace = trace_file(&graph, root, "src/unused.ts").unwrap();
1347        assert!(!trace.is_reachable);
1348        assert!(!trace.is_entry_point);
1349        assert!(trace.imported_by.is_empty());
1350    }
1351
1352    #[test]
1353    fn trace_dependency_used() {
1354        let files = vec![DiscoveredFile {
1355            id: FileId(0),
1356            path: PathBuf::from("/project/src/app.ts"),
1357            size_bytes: 100,
1358        }];
1359        let entry_points = vec![EntryPoint {
1360            path: PathBuf::from("/project/src/app.ts"),
1361            source: EntryPointSource::PackageJsonMain,
1362        }];
1363        let resolved_modules = vec![ResolvedModule {
1364            file_id: FileId(0),
1365            path: PathBuf::from("/project/src/app.ts"),
1366            resolved_imports: vec![ResolvedImport {
1367                info: ImportInfo {
1368                    source: "lodash".to_string(),
1369                    imported_name: ImportedName::Named("get".to_string()),
1370                    local_name: "get".to_string(),
1371                    is_type_only: false,
1372                    from_style: false,
1373                    span: oxc_span::Span::new(0, 10),
1374                    source_span: oxc_span::Span::default(),
1375                },
1376                target: ResolveResult::NpmPackage("lodash".to_string()),
1377            }],
1378            ..Default::default()
1379        }];
1380
1381        let graph = ModuleGraph::build(&resolved_modules, &entry_points, &files);
1382        let root = Path::new("/project");
1383
1384        let trace = trace_dependency(&graph, root, "lodash", &FxHashSet::default());
1385        assert!(trace.is_used);
1386        assert!(!trace.used_in_scripts);
1387        assert_eq!(trace.import_count, 1);
1388        assert_eq!(trace.imported_by[0], PathBuf::from("src/app.ts"));
1389    }
1390
1391    #[test]
1392    fn trace_dependency_unused() {
1393        let files = vec![DiscoveredFile {
1394            id: FileId(0),
1395            path: PathBuf::from("/project/src/app.ts"),
1396            size_bytes: 100,
1397        }];
1398        let entry_points = vec![EntryPoint {
1399            path: PathBuf::from("/project/src/app.ts"),
1400            source: EntryPointSource::PackageJsonMain,
1401        }];
1402        let resolved_modules = vec![ResolvedModule {
1403            file_id: FileId(0),
1404            path: PathBuf::from("/project/src/app.ts"),
1405            ..Default::default()
1406        }];
1407
1408        let graph = ModuleGraph::build(&resolved_modules, &entry_points, &files);
1409        let root = Path::new("/project");
1410
1411        let trace = trace_dependency(&graph, root, "nonexistent-pkg", &FxHashSet::default());
1412        assert!(!trace.is_used);
1413        assert!(!trace.used_in_scripts);
1414        assert_eq!(trace.import_count, 0);
1415        assert!(trace.imported_by.is_empty());
1416    }
1417
1418    #[test]
1419    fn trace_dependency_used_only_in_scripts() {
1420        let files = vec![DiscoveredFile {
1421            id: FileId(0),
1422            path: PathBuf::from("/project/src/app.ts"),
1423            size_bytes: 100,
1424        }];
1425        let entry_points = vec![EntryPoint {
1426            path: PathBuf::from("/project/src/app.ts"),
1427            source: EntryPointSource::PackageJsonMain,
1428        }];
1429        let resolved_modules = vec![ResolvedModule {
1430            file_id: FileId(0),
1431            path: PathBuf::from("/project/src/app.ts"),
1432            ..Default::default()
1433        }];
1434
1435        let graph = ModuleGraph::build(&resolved_modules, &entry_points, &files);
1436        let root = Path::new("/project");
1437        let mut script_used = FxHashSet::default();
1438        script_used.insert("microbundle".to_string());
1439
1440        let trace = trace_dependency(&graph, root, "microbundle", &script_used);
1441        assert!(
1442            trace.is_used,
1443            "is_used must be true when the package is referenced from package.json scripts"
1444        );
1445        assert!(trace.used_in_scripts);
1446        assert_eq!(trace.import_count, 0);
1447        assert!(trace.imported_by.is_empty());
1448    }
1449
1450    #[test]
1451    fn trace_clone_finds_matching_group() {
1452        use crate::duplicates::{CloneGroup, CloneInstance, DuplicationReport, DuplicationStats};
1453        let report = DuplicationReport {
1454            clone_groups: vec![CloneGroup {
1455                instances: vec![
1456                    CloneInstance {
1457                        file: PathBuf::from("/project/src/a.ts"),
1458                        start_line: 10,
1459                        end_line: 20,
1460                        start_col: 0,
1461                        end_col: 0,
1462                        fragment: "fn foo() {}".to_string(),
1463                    },
1464                    CloneInstance {
1465                        file: PathBuf::from("/project/src/b.ts"),
1466                        start_line: 5,
1467                        end_line: 15,
1468                        start_col: 0,
1469                        end_col: 0,
1470                        fragment: "fn foo() {}".to_string(),
1471                    },
1472                ],
1473                token_count: 60,
1474                line_count: 11,
1475                similarity: None,
1476            }],
1477            clone_families: vec![],
1478            mirrored_directories: vec![],
1479            stats: DuplicationStats {
1480                total_files: 2,
1481                files_with_clones: 2,
1482                total_lines: 100,
1483                duplicated_lines: 22,
1484                total_tokens: 200,
1485                duplicated_tokens: 120,
1486                clone_groups: 1,
1487                clone_instances: 2,
1488                duplication_percentage: 22.0,
1489                clone_groups_below_min_occurrences: 0,
1490                clone_groups_ignored: 0,
1491                near_candidates_skipped: 0,
1492            },
1493        };
1494        let trace = trace_clone(&report, Path::new("/project"), "src/a.ts", 15);
1495        assert!(trace.matched_instance.is_some());
1496        assert_eq!(trace.clone_groups.len(), 1);
1497        assert_eq!(trace.clone_groups[0].instances.len(), 2);
1498        assert!(trace.clone_groups[0].fingerprint.starts_with("dup:"));
1499        assert_eq!(trace.clone_groups[0].suggestion.estimated_savings, 11);
1500    }
1501
1502    #[test]
1503    fn trace_clone_by_fingerprint_resolves_and_misses() {
1504        use crate::duplicates::{
1505            CloneGroup, CloneInstance, DuplicationReport, DuplicationStats, clone_fingerprint,
1506        };
1507        let report = DuplicationReport {
1508            clone_groups: vec![CloneGroup {
1509                instances: vec![
1510                    CloneInstance {
1511                        file: PathBuf::from("/project/src/a.ts"),
1512                        start_line: 10,
1513                        end_line: 20,
1514                        start_col: 0,
1515                        end_col: 0,
1516                        fragment: "fn buildInvoice() {}".to_string(),
1517                    },
1518                    CloneInstance {
1519                        file: PathBuf::from("/project/src/b.ts"),
1520                        start_line: 5,
1521                        end_line: 15,
1522                        start_col: 0,
1523                        end_col: 0,
1524                        fragment: "fn buildInvoice() {}".to_string(),
1525                    },
1526                ],
1527                token_count: 60,
1528                line_count: 11,
1529                similarity: None,
1530            }],
1531            clone_families: vec![],
1532            mirrored_directories: vec![],
1533            stats: DuplicationStats::default(),
1534        };
1535        let fp = clone_fingerprint(&report.clone_groups[0].instances);
1536
1537        let hit = trace_clone_by_fingerprint(&report, Path::new("/project"), &fp);
1538        assert!(hit.matched_instance.is_some());
1539        assert_eq!(hit.clone_groups.len(), 1);
1540        assert_eq!(hit.clone_groups[0].fingerprint, fp);
1541        assert_eq!(hit.line, 10);
1542
1543        let miss = trace_clone_by_fingerprint(&report, Path::new("/project"), "dup:deadbeef");
1544        assert!(miss.matched_instance.is_none());
1545        assert!(miss.clone_groups.is_empty());
1546    }
1547
1548    #[test]
1549    fn trace_clone_no_match() {
1550        use crate::duplicates::{CloneGroup, CloneInstance, DuplicationReport, DuplicationStats};
1551        let report = DuplicationReport {
1552            clone_groups: vec![CloneGroup {
1553                instances: vec![CloneInstance {
1554                    file: PathBuf::from("/project/src/a.ts"),
1555                    start_line: 10,
1556                    end_line: 20,
1557                    start_col: 0,
1558                    end_col: 0,
1559                    fragment: "fn foo() {}".to_string(),
1560                }],
1561                token_count: 60,
1562                line_count: 11,
1563                similarity: None,
1564            }],
1565            clone_families: vec![],
1566            mirrored_directories: vec![],
1567            stats: DuplicationStats {
1568                total_files: 1,
1569                files_with_clones: 1,
1570                total_lines: 50,
1571                duplicated_lines: 11,
1572                total_tokens: 100,
1573                duplicated_tokens: 60,
1574                clone_groups: 1,
1575                clone_instances: 1,
1576                duplication_percentage: 22.0,
1577                clone_groups_below_min_occurrences: 0,
1578                clone_groups_ignored: 0,
1579                near_candidates_skipped: 0,
1580            },
1581        };
1582        let trace = trace_clone(&report, Path::new("/project"), "src/a.ts", 25);
1583        assert!(trace.matched_instance.is_none());
1584        assert!(trace.clone_groups.is_empty());
1585    }
1586
1587    #[test]
1588    fn trace_clone_line_boundary() {
1589        use crate::duplicates::{CloneGroup, CloneInstance, DuplicationReport, DuplicationStats};
1590        let report = DuplicationReport {
1591            clone_groups: vec![CloneGroup {
1592                instances: vec![
1593                    CloneInstance {
1594                        file: PathBuf::from("/project/src/a.ts"),
1595                        start_line: 10,
1596                        end_line: 20,
1597                        start_col: 0,
1598                        end_col: 0,
1599                        fragment: "code".to_string(),
1600                    },
1601                    CloneInstance {
1602                        file: PathBuf::from("/project/src/b.ts"),
1603                        start_line: 1,
1604                        end_line: 11,
1605                        start_col: 0,
1606                        end_col: 0,
1607                        fragment: "code".to_string(),
1608                    },
1609                ],
1610                token_count: 50,
1611                line_count: 11,
1612                similarity: None,
1613            }],
1614            clone_families: vec![],
1615            mirrored_directories: vec![],
1616            stats: DuplicationStats {
1617                total_files: 2,
1618                files_with_clones: 2,
1619                total_lines: 100,
1620                duplicated_lines: 22,
1621                total_tokens: 200,
1622                duplicated_tokens: 100,
1623                clone_groups: 1,
1624                clone_instances: 2,
1625                duplication_percentage: 22.0,
1626                clone_groups_below_min_occurrences: 0,
1627                clone_groups_ignored: 0,
1628                near_candidates_skipped: 0,
1629            },
1630        };
1631        let root = Path::new("/project");
1632        assert!(
1633            trace_clone(&report, root, "src/a.ts", 10)
1634                .matched_instance
1635                .is_some()
1636        );
1637        assert!(
1638            trace_clone(&report, root, "src/a.ts", 20)
1639                .matched_instance
1640                .is_some()
1641        );
1642        assert!(
1643            trace_clone(&report, root, "src/a.ts", 21)
1644                .matched_instance
1645                .is_none()
1646        );
1647    }
1648
1649    #[test]
1650    fn trace_clone_returns_relative_instance_paths() {
1651        use crate::duplicates::{CloneGroup, CloneInstance, DuplicationReport, DuplicationStats};
1652        let report = DuplicationReport {
1653            clone_groups: vec![CloneGroup {
1654                instances: vec![
1655                    CloneInstance {
1656                        file: PathBuf::from("/project/src/a.ts"),
1657                        start_line: 1,
1658                        end_line: 10,
1659                        start_col: 0,
1660                        end_col: 0,
1661                        fragment: "code".to_string(),
1662                    },
1663                    CloneInstance {
1664                        file: PathBuf::from("/project/src/b.ts"),
1665                        start_line: 1,
1666                        end_line: 10,
1667                        start_col: 0,
1668                        end_col: 0,
1669                        fragment: "code".to_string(),
1670                    },
1671                ],
1672                token_count: 50,
1673                line_count: 10,
1674                similarity: None,
1675            }],
1676            clone_families: vec![],
1677            mirrored_directories: vec![],
1678            stats: DuplicationStats {
1679                total_files: 2,
1680                files_with_clones: 2,
1681                total_lines: 50,
1682                duplicated_lines: 20,
1683                total_tokens: 100,
1684                duplicated_tokens: 100,
1685                clone_groups: 1,
1686                clone_instances: 2,
1687                duplication_percentage: 40.0,
1688                clone_groups_below_min_occurrences: 0,
1689                clone_groups_ignored: 0,
1690                near_candidates_skipped: 0,
1691            },
1692        };
1693        let trace = trace_clone(&report, Path::new("/project"), "src/a.ts", 5);
1694        let matched = trace.matched_instance.as_ref().expect("match expected");
1695        assert_eq!(matched.file, PathBuf::from("src/a.ts"));
1696        for group in &trace.clone_groups {
1697            for inst in &group.instances {
1698                let as_str = inst.file.to_string_lossy();
1699                assert!(
1700                    !as_str.starts_with('/'),
1701                    "instance file should be relative, got {as_str}",
1702                );
1703                assert!(
1704                    !as_str.contains(":\\") && !as_str.contains(":/"),
1705                    "instance file should not have a drive letter, got {as_str}",
1706                );
1707            }
1708        }
1709
1710        let json = serde_json::to_string(&trace).expect("serializes");
1711        assert!(
1712            !json.contains("\"/project/"),
1713            "serialized trace should not leak absolute paths: {json}",
1714        );
1715    }
1716
1717    /// Regression for the MCP e2e `trace_export` / `trace_file` Windows
1718    /// failures: the MCP layer passes forward-slashed user input
1719    /// (`src/utils.ts`) but `module_path` on Windows uses backslash
1720    /// separators (`D:\a\fallow\...\src\utils.ts`). The byte-level
1721    /// equality check missed every match. The helper now normalises
1722    /// both sides to forward slashes before comparing.
1723    #[test]
1724    fn path_matches_normalises_windows_module_path_against_posix_user_path() {
1725        let root = Path::new(r"D:\a\fallow\fallow\tests\fixtures\basic-project");
1726        let module_path =
1727            PathBuf::from(r"D:\a\fallow\fallow\tests\fixtures\basic-project\src\utils.ts");
1728        assert!(path_matches(&module_path, root, "src/utils.ts"));
1729        assert!(path_matches(&module_path, root, r"src\utils.ts"));
1730    }
1731
1732    #[test]
1733    fn path_matches_ends_with_fallback_handles_mixed_separators() {
1734        let root = Path::new("/some/other/root");
1735        let module_path =
1736            PathBuf::from(r"D:\a\fallow\fallow\tests\fixtures\basic-project\src\utils.ts");
1737        assert!(path_matches(&module_path, root, "src/utils.ts"));
1738    }
1739
1740    /// Regression for the MCP e2e trace_export / trace_file failures: even
1741    /// after `path_matches` correctly identified the file on Windows, the
1742    /// trace output struct's `file: PathBuf` field serialized the stored
1743    /// backslash-shaped path verbatim. JSON consumers (MCP agents, CI
1744    /// pipelines, the cross-platform trace_file assertion in
1745    /// `e2e_trace_file_returns_json`) expect forward-slash. Pin the
1746    /// contract via raw-string Windows-shaped `PathBuf::from` so the test
1747    /// runs cross-platform.
1748    #[test]
1749    fn export_trace_serializes_windows_path_with_forward_slashes() {
1750        let trace = ExportTrace {
1751            file: PathBuf::from(r"src\utils.ts"),
1752            export_name: "foo".to_string(),
1753            file_reachable: true,
1754            is_entry_point: false,
1755            is_used: true,
1756            direct_references: vec![ExportReference {
1757                from_file: PathBuf::from(r"src\entry.ts"),
1758                kind: "named import".to_string(),
1759            }],
1760            re_export_chains: vec![ReExportChain {
1761                barrel_file: PathBuf::from(r"src\index.ts"),
1762                exported_as: "foo".to_string(),
1763                reference_count: 1,
1764            }],
1765            reason: "ok".to_string(),
1766            semantic: None,
1767        };
1768        let json = serde_json::to_string(&trace).expect("serializes");
1769        assert!(
1770            json.contains("\"file\":\"src/utils.ts\""),
1771            "ExportTrace.file must serialize with forward slashes: {json}"
1772        );
1773        assert!(
1774            json.contains("\"from_file\":\"src/entry.ts\""),
1775            "ExportReference.from_file must serialize with forward slashes: {json}"
1776        );
1777        assert!(
1778            json.contains("\"barrel_file\":\"src/index.ts\""),
1779            "ReExportChain.barrel_file must serialize with forward slashes: {json}"
1780        );
1781        assert!(
1782            !json.contains(r"\\"),
1783            "no backslash sequence should remain anywhere in the JSON: {json}"
1784        );
1785    }
1786
1787    #[test]
1788    fn file_trace_serializes_windows_paths_with_forward_slashes() {
1789        let trace = FileTrace {
1790            file: PathBuf::from(r"src\utils.ts"),
1791            is_reachable: true,
1792            is_entry_point: false,
1793            exports: vec![],
1794            imports_from: vec![PathBuf::from(r"src\helpers.ts")],
1795            imported_by: vec![PathBuf::from(r"src\entry.ts")],
1796            re_exports: vec![TracedReExport {
1797                source_file: PathBuf::from(r"src\source.ts"),
1798                imported_name: "foo".to_string(),
1799                exported_name: "foo".to_string(),
1800            }],
1801        };
1802        let json = serde_json::to_string(&trace).expect("serializes");
1803        assert!(json.contains("\"file\":\"src/utils.ts\""), "got {json}");
1804        assert!(
1805            json.contains("\"imports_from\":[\"src/helpers.ts\"]"),
1806            "got {json}"
1807        );
1808        assert!(
1809            json.contains("\"imported_by\":[\"src/entry.ts\"]"),
1810            "got {json}"
1811        );
1812        assert!(
1813            json.contains("\"source_file\":\"src/source.ts\""),
1814            "got {json}"
1815        );
1816        assert!(!json.contains(r"\\"), "no backslash should remain: {json}");
1817    }
1818}