Skip to main content

fallow_core/
trace.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    })
166}
167
168/// Trace a class / enum / store MEMBER when `--trace FILE:NAME`'s `NAME` is not
169/// a top-level export but a member declared on one (issue #1744). Runs on the
170/// graph only, so it reports the OWNING export's reachability and usage (the
171/// gating precondition for member crediting) plus a pointer to the right
172/// `--unused-*-members` command, not per-member crediting provenance.
173#[must_use]
174pub fn trace_class_member(
175    graph: &ModuleGraph,
176    root: &Path,
177    file_path: &str,
178    member_name: &str,
179) -> Option<ClassMemberTrace> {
180    use fallow_types::extract::MemberKind;
181
182    let module = graph
183        .modules
184        .iter()
185        .find(|m| path_matches(&m.path, root, file_path))?;
186
187    // Find the export that declares this member. When several declare a member
188    // of the same name (rare), prefer a used, non-type-only owner so the trace
189    // reports the reachable one.
190    let (owner, member_kind) = module
191        .exports
192        .iter()
193        .filter_map(|export| {
194            export
195                .members
196                .iter()
197                .find(|member| member.name == member_name)
198                .map(|member| (export, member.kind))
199        })
200        .max_by_key(|(export, _)| (!export.references.is_empty(), !export.is_type_only))?;
201
202    let owner_name = owner.name.to_string();
203    // Reuse the export trace to compute the owner's reachability / usage /
204    // references consistently with a plain `--trace FILE:OWNER`. The `?` here is
205    // a belt-and-suspenders guard: `owner` was just located in this module's
206    // `exports`, so `trace_export` resolves it in practice; the fallthrough to
207    // `None` (and the caller's "not found" error) is unreachable barring a graph
208    // inconsistency.
209    let owner_trace = trace_export(graph, root, file_path, &owner_name)?;
210
211    let (kind_str, filter_flag) = match member_kind {
212        MemberKind::ClassMethod => ("class-method", Some("--unused-class-members")),
213        MemberKind::ClassProperty => ("class-property", Some("--unused-class-members")),
214        MemberKind::EnumMember => ("enum-member", Some("--unused-enum-members")),
215        MemberKind::StoreMember => ("store-member", Some("--unused-store-members")),
216        MemberKind::NamespaceMember => ("namespace-member", None),
217    };
218
219    let reason = class_member_trace_reason(
220        member_name,
221        &owner_name,
222        kind_str,
223        filter_flag,
224        file_path,
225        &owner_trace,
226    );
227
228    Some(ClassMemberTrace {
229        file: owner_trace.file,
230        member_name: member_name.to_string(),
231        member_kind: kind_str.to_string(),
232        owner_export: owner_name,
233        owner_is_used: owner_trace.is_used,
234        owner_file_reachable: owner_trace.file_reachable,
235        owner_is_entry_point: owner_trace.is_entry_point,
236        owner_direct_references: owner_trace.direct_references,
237        owner_re_export_chains: owner_trace.re_export_chains,
238        reason,
239    })
240}
241
242/// Build the human-readable reason for a class-member trace, keyed on the
243/// owner's reachability / usage (the precondition that gates member crediting).
244fn class_member_trace_reason(
245    member_name: &str,
246    owner_name: &str,
247    kind_str: &str,
248    filter_flag: Option<&str>,
249    file_path: &str,
250    owner_trace: &ExportTrace,
251) -> String {
252    let head =
253        format!("'{member_name}' is a {kind_str} of '{owner_name}', not a top-level export. ");
254    let body = if !owner_trace.file_reachable {
255        format!(
256            "The file is not reachable from any entry point, so '{owner_name}' and all its \
257             members are dead (see the unused-file finding)."
258        )
259    } else if !owner_trace.is_used {
260        format!(
261            "'{owner_name}' is reachable but referenced by no file, so it is reported as an \
262             unused export and its members are not judged individually."
263        )
264    } else {
265        let refs = owner_trace.direct_references.len();
266        match filter_flag {
267            Some(flag) => format!(
268                "'{owner_name}' is used by {refs} file(s); whether '{member_name}' itself is \
269                 flagged depends on cross-file member-access resolution. Run \
270                 `fallow dead-code {flag} --file {file_path}` to see the member finding."
271            ),
272            None => format!(
273                "'{owner_name}' is used by {refs} file(s); '{member_name}' is credited through \
274                 its namespace export."
275            ),
276        }
277    };
278    format!("{head}{body}")
279}
280
281fn export_name_matches(export: &crate::graph::ExportSymbol, export_name: &str) -> bool {
282    let name_str = export.name.to_string();
283    name_str == export_name || (export_name == "default" && name_str == "default")
284}
285
286/// Map a module's exports to [`TracedExport`] entries with relativized references.
287fn traced_exports(
288    graph: &ModuleGraph,
289    root: &Path,
290    module: &crate::graph::ModuleNode,
291) -> Vec<TracedExport> {
292    module
293        .exports
294        .iter()
295        .map(|e| TracedExport {
296            name: e.name.to_string(),
297            is_type_only: e.is_type_only,
298            reference_count: e.references.len(),
299            referenced_by: e
300                .references
301                .iter()
302                .map(|r| reference_to_export_reference(graph, root, r))
303                .collect(),
304        })
305        .collect()
306}
307
308/// Collect the root-relative paths a file imports from (forward graph edges).
309fn traced_imports_from(
310    graph: &ModuleGraph,
311    root: &Path,
312    module: &crate::graph::ModuleNode,
313) -> Vec<PathBuf> {
314    graph
315        .edges_for(module.file_id)
316        .iter()
317        .filter_map(|target_id| {
318            graph
319                .modules
320                .get(target_id.0 as usize)
321                .map(|m| m.path.strip_prefix(root).unwrap_or(&m.path).to_path_buf())
322        })
323        .collect()
324}
325
326/// Collect the root-relative paths that import a file (reverse graph edges).
327fn traced_imported_by(
328    graph: &ModuleGraph,
329    root: &Path,
330    module: &crate::graph::ModuleNode,
331) -> Vec<PathBuf> {
332    graph
333        .reverse_deps
334        .get(module.file_id.0 as usize)
335        .map(|deps| {
336            deps.iter()
337                .filter_map(|fid| {
338                    graph
339                        .modules
340                        .get(fid.0 as usize)
341                        .map(|m| m.path.strip_prefix(root).unwrap_or(&m.path).to_path_buf())
342                })
343                .collect()
344        })
345        .unwrap_or_default()
346}
347
348/// Map a module's re-exports to [`TracedReExport`] entries with relativized source paths.
349fn traced_re_exports(
350    graph: &ModuleGraph,
351    root: &Path,
352    module: &crate::graph::ModuleNode,
353) -> Vec<TracedReExport> {
354    module
355        .re_exports
356        .iter()
357        .map(|re| {
358            let source_path = graph.modules.get(re.source_file.0 as usize).map_or_else(
359                || PathBuf::from(format!("<unknown:{}>", re.source_file.0)),
360                |m| m.path.strip_prefix(root).unwrap_or(&m.path).to_path_buf(),
361            );
362            TracedReExport {
363                source_file: source_path,
364                imported_name: re.imported_name.clone(),
365                exported_name: re.exported_name.clone(),
366            }
367        })
368        .collect()
369}
370
371/// Trace all edges for a file.
372#[must_use]
373pub fn trace_file(graph: &ModuleGraph, root: &Path, file_path: &str) -> Option<FileTrace> {
374    let module = graph
375        .modules
376        .iter()
377        .find(|m| path_matches(&m.path, root, file_path))?;
378
379    Some(FileTrace {
380        file: module
381            .path
382            .strip_prefix(root)
383            .unwrap_or(&module.path)
384            .to_path_buf(),
385        is_reachable: module.is_reachable(),
386        is_entry_point: module.is_entry_point(),
387        exports: traced_exports(graph, root, module),
388        imports_from: traced_imports_from(graph, root, module),
389        imported_by: traced_imported_by(graph, root, module),
390        re_exports: traced_re_exports(graph, root, module),
391    })
392}
393
394/// Trace where a dependency is used.
395///
396/// `script_used_packages` carries the package names recorded as binary invocations
397/// in package.json scripts (`build: microbundle ...`) and CI configs
398/// (`.github/workflows/*.yml`, `.gitlab-ci.yml`). The same set the unused-deps
399/// detector consults; passing it in lets the trace output match the detector's
400/// view of "used" instead of reporting `is_used=false` for tools invoked only
401/// through scripts.
402#[expect(
403    clippy::implicit_hasher,
404    reason = "fallow standardizes on FxHashSet across the workspace"
405)]
406#[must_use]
407pub fn trace_dependency(
408    graph: &ModuleGraph,
409    root: &Path,
410    package_name: &str,
411    script_used_packages: &FxHashSet<String>,
412) -> DependencyTrace {
413    let imported_by: Vec<PathBuf> = graph
414        .package_usage
415        .get(package_name)
416        .map(|ids| {
417            ids.iter()
418                .filter_map(|fid| {
419                    graph
420                        .modules
421                        .get(fid.0 as usize)
422                        .map(|m| m.path.strip_prefix(root).unwrap_or(&m.path).to_path_buf())
423                })
424                .collect()
425        })
426        .unwrap_or_default();
427
428    let type_only_imported_by: Vec<PathBuf> = graph
429        .type_only_package_usage
430        .get(package_name)
431        .map(|ids| {
432            ids.iter()
433                .filter_map(|fid| {
434                    graph
435                        .modules
436                        .get(fid.0 as usize)
437                        .map(|m| m.path.strip_prefix(root).unwrap_or(&m.path).to_path_buf())
438                })
439                .collect()
440        })
441        .unwrap_or_default();
442
443    let import_count = imported_by.len();
444    let used_in_scripts = script_used_packages.contains(package_name);
445    DependencyTrace {
446        package_name: package_name.to_string(),
447        imported_by,
448        type_only_imported_by,
449        used_in_scripts,
450        is_used: import_count > 0 || used_in_scripts,
451        import_count,
452    }
453}
454
455fn format_reference_kind(kind: ReferenceKind) -> String {
456    match kind {
457        ReferenceKind::NamedImport => "named import".to_string(),
458        ReferenceKind::DefaultImport => "default import".to_string(),
459        ReferenceKind::NamespaceImport => "namespace import".to_string(),
460        ReferenceKind::ReExport => "re-export".to_string(),
461        ReferenceKind::DynamicImport => "dynamic import".to_string(),
462        ReferenceKind::SideEffectImport => "side-effect import".to_string(),
463    }
464}
465
466/// Compute the impact closure for a single file as the seed.
467///
468/// Resolves `file_path` to a graph `FileId`, walks `reverse_deps` + re-export
469/// chains to the transitive affected set, and reports the coordination gap (the
470/// seed's exported contracts consumed by modules outside the seed). Returns
471/// `None` when the file is not in the module graph.
472#[must_use]
473pub fn trace_impact_closure(
474    graph: &ModuleGraph,
475    root: &Path,
476    file_path: &str,
477) -> Option<ImpactClosureTrace> {
478    let module = graph
479        .modules
480        .iter()
481        .find(|m| path_matches(&m.path, root, file_path))?;
482
483    let closure = graph.impact_closure(&[module.file_id]);
484    let paths = graph.closure_with_paths(&closure, root);
485
486    let seed = paths
487        .in_diff
488        .first()
489        .cloned()
490        .unwrap_or_else(|| file_path.replace('\\', "/"));
491
492    let coordination_gap = paths
493        .coordination_gap
494        .into_iter()
495        .map(|gap| ImpactClosureGap {
496            consumer_file: gap.consumer_file,
497            consumed_symbols: gap.consumed_symbols,
498            note: "syntactic attention pointer, not a correctness proof".to_string(),
499        })
500        .collect();
501
502    Some(ImpactClosureTrace {
503        seed,
504        affected_not_shown: paths.affected_not_shown,
505        coordination_gap,
506    })
507}
508
509/// Build a [`TracedCloneGroup`] from a raw clone group, computing the
510/// fingerprint, group-level suggestion, and dominant-identifier name and
511/// relativizing every instance path against `root`.
512fn build_traced_group(
513    group: &CloneGroup,
514    root: &Path,
515    fingerprints: &CloneFingerprintSet,
516) -> TracedCloneGroup {
517    TracedCloneGroup {
518        fingerprint: fingerprints.fingerprint_for_group(group),
519        token_count: group.token_count,
520        line_count: group.line_count,
521        instances: group
522            .instances
523            .iter()
524            .map(|inst| relativize_instance(inst, root))
525            .collect(),
526        suggestion: group_refactoring_suggestion(group),
527        suggested_name: dominant_identifier(group),
528    }
529}
530
531#[must_use]
532pub fn trace_clone(
533    report: &DuplicationReport,
534    root: &Path,
535    file_path: &str,
536    line: usize,
537) -> CloneTrace {
538    let resolved = root.join(file_path);
539    let mut matched_instance = None;
540    let mut clone_groups = Vec::new();
541    let fingerprints = CloneFingerprintSet::from_groups(&report.clone_groups);
542
543    for group in &report.clone_groups {
544        let matching = group.instances.iter().find(|inst| {
545            let inst_matches = inst.file == resolved
546                || inst.file.strip_prefix(root).unwrap_or(&inst.file) == Path::new(file_path);
547            inst_matches && inst.start_line <= line && line <= inst.end_line
548        });
549
550        if let Some(matched) = matching {
551            if matched_instance.is_none() {
552                matched_instance = Some(relativize_instance(matched, root));
553            }
554            clone_groups.push(build_traced_group(group, root, &fingerprints));
555        }
556    }
557
558    CloneTrace {
559        file: PathBuf::from(file_path),
560        line,
561        matched_instance,
562        clone_groups,
563    }
564}
565
566/// Trace a clone group by its stable content fingerprint.
567///
568/// Fingerprints are usually `dup:<8hex>` and widen only when needed to avoid a
569/// collision inside the same report.
570///
571/// Returns a [`CloneTrace`] whose single `clone_groups` entry is the matched
572/// group and whose `file` / `line` / `matched_instance` come from that group's
573/// representative (first) instance. `matched_instance` is `None` (and
574/// `clone_groups` empty) when no group matches the fingerprint.
575#[must_use]
576pub fn trace_clone_by_fingerprint(
577    report: &DuplicationReport,
578    root: &Path,
579    fingerprint: &str,
580) -> CloneTrace {
581    let fingerprints = CloneFingerprintSet::from_groups(&report.clone_groups);
582    let matched = fingerprints.find_group(&report.clone_groups, fingerprint);
583
584    let Some(group) = matched else {
585        return CloneTrace {
586            file: PathBuf::new(),
587            line: 0,
588            matched_instance: None,
589            clone_groups: Vec::new(),
590        };
591    };
592
593    let representative = group
594        .instances
595        .first()
596        .map(|inst| relativize_instance(inst, root));
597    let (file, line) = representative.as_ref().map_or_else(
598        || (PathBuf::new(), 0),
599        |inst| (inst.file.clone(), inst.start_line),
600    );
601
602    CloneTrace {
603        file,
604        line,
605        matched_instance: representative,
606        clone_groups: vec![build_traced_group(group, root, &fingerprints)],
607    }
608}
609
610/// Return a copy of `inst` with `file` rewritten relative to `root` (forward-slash normalized
611/// for cross-platform JSON parity with `serde_path::serialize`). If `inst.file` is already
612/// outside `root`, the path is left unchanged.
613fn relativize_instance(inst: &CloneInstance, root: &Path) -> CloneInstance {
614    let rel = inst.file.strip_prefix(root).map_or_else(
615        |_| inst.file.clone(),
616        |p| PathBuf::from(p.to_string_lossy().replace('\\', "/")),
617    );
618    CloneInstance {
619        file: rel,
620        ..inst.clone()
621    }
622}
623
624#[cfg(test)]
625mod tests {
626    use super::*;
627
628    use crate::discover::{DiscoveredFile, EntryPoint, EntryPointSource, FileId};
629    use crate::extract::{ExportInfo, ExportName, ImportInfo, ImportedName, VisibilityTag};
630    use crate::resolve::{ResolveResult, ResolvedImport, ResolvedModule};
631
632    fn build_test_graph() -> ModuleGraph {
633        let files = vec![
634            DiscoveredFile {
635                id: FileId(0),
636                path: PathBuf::from("/project/src/entry.ts"),
637                size_bytes: 100,
638            },
639            DiscoveredFile {
640                id: FileId(1),
641                path: PathBuf::from("/project/src/utils.ts"),
642                size_bytes: 50,
643            },
644            DiscoveredFile {
645                id: FileId(2),
646                path: PathBuf::from("/project/src/unused.ts"),
647                size_bytes: 30,
648            },
649        ];
650
651        let entry_points = vec![EntryPoint {
652            path: PathBuf::from("/project/src/entry.ts"),
653            source: EntryPointSource::PackageJsonMain,
654        }];
655
656        let resolved_modules = vec![
657            ResolvedModule {
658                file_id: FileId(0),
659                path: PathBuf::from("/project/src/entry.ts"),
660                resolved_imports: vec![ResolvedImport {
661                    info: ImportInfo {
662                        source: "./utils".to_string(),
663                        imported_name: ImportedName::Named("foo".to_string()),
664                        local_name: "foo".to_string(),
665                        is_type_only: false,
666                        from_style: false,
667                        span: oxc_span::Span::new(0, 10),
668                        source_span: oxc_span::Span::default(),
669                    },
670                    target: ResolveResult::InternalModule(FileId(1)),
671                }],
672                ..Default::default()
673            },
674            ResolvedModule {
675                file_id: FileId(1),
676                path: PathBuf::from("/project/src/utils.ts"),
677                exports: vec![
678                    ExportInfo {
679                        name: ExportName::Named("foo".to_string()),
680                        local_name: Some("foo".to_string()),
681                        is_type_only: false,
682                        visibility: VisibilityTag::None,
683                        expected_unused_reason: None,
684                        span: oxc_span::Span::new(0, 20),
685                        members: vec![],
686                        is_side_effect_used: false,
687                        super_class: None,
688                    },
689                    ExportInfo {
690                        name: ExportName::Named("bar".to_string()),
691                        local_name: Some("bar".to_string()),
692                        is_type_only: false,
693                        visibility: VisibilityTag::None,
694                        expected_unused_reason: None,
695                        span: oxc_span::Span::new(21, 40),
696                        members: vec![],
697                        is_side_effect_used: false,
698                        super_class: None,
699                    },
700                ],
701                ..Default::default()
702            },
703            ResolvedModule {
704                file_id: FileId(2),
705                path: PathBuf::from("/project/src/unused.ts"),
706                exports: vec![ExportInfo {
707                    name: ExportName::Named("baz".to_string()),
708                    local_name: Some("baz".to_string()),
709                    is_type_only: false,
710                    visibility: VisibilityTag::None,
711                    expected_unused_reason: None,
712                    span: oxc_span::Span::new(0, 15),
713                    members: vec![],
714                    is_side_effect_used: false,
715                    super_class: None,
716                }],
717                ..Default::default()
718            },
719        ];
720
721        ModuleGraph::build(&resolved_modules, &entry_points, &files)
722    }
723
724    #[test]
725    fn trace_used_export() {
726        let graph = build_test_graph();
727        let root = Path::new("/project");
728
729        let trace = trace_export(&graph, root, "src/utils.ts", "foo").unwrap();
730        assert!(trace.is_used);
731        assert!(trace.file_reachable);
732        assert_eq!(trace.direct_references.len(), 1);
733        assert_eq!(
734            trace.direct_references[0].from_file,
735            PathBuf::from("src/entry.ts")
736        );
737        assert_eq!(trace.direct_references[0].kind, "named import");
738    }
739
740    #[test]
741    fn trace_unused_export() {
742        let graph = build_test_graph();
743        let root = Path::new("/project");
744
745        let trace = trace_export(&graph, root, "src/utils.ts", "bar").unwrap();
746        assert!(!trace.is_used);
747        assert!(trace.file_reachable);
748        assert!(trace.direct_references.is_empty());
749    }
750
751    #[test]
752    fn trace_unreachable_file_export() {
753        let graph = build_test_graph();
754        let root = Path::new("/project");
755
756        let trace = trace_export(&graph, root, "src/unused.ts", "baz").unwrap();
757        assert!(!trace.is_used);
758        assert!(!trace.file_reachable);
759        assert!(trace.reason.contains("unreachable"));
760    }
761
762    #[test]
763    fn trace_nonexistent_export() {
764        let graph = build_test_graph();
765        let root = Path::new("/project");
766
767        let trace = trace_export(&graph, root, "src/utils.ts", "nonexistent");
768        assert!(trace.is_none());
769    }
770
771    fn build_class_member_graph() -> ModuleGraph {
772        use fallow_types::extract::{MemberInfo, MemberKind};
773
774        let files = vec![
775            DiscoveredFile {
776                id: FileId(0),
777                path: PathBuf::from("/project/src/entry.ts"),
778                size_bytes: 100,
779            },
780            DiscoveredFile {
781                id: FileId(1),
782                path: PathBuf::from("/project/src/controller.ts"),
783                size_bytes: 50,
784            },
785        ];
786        let entry_points = vec![EntryPoint {
787            path: PathBuf::from("/project/src/entry.ts"),
788            source: EntryPointSource::PackageJsonMain,
789        }];
790        let method = |name: &str| MemberInfo {
791            name: name.to_string(),
792            kind: MemberKind::ClassMethod,
793            span: oxc_span::Span::new(0, 4),
794            has_decorator: false,
795            decorator_names: vec![],
796            is_instance_returning_static: false,
797            is_self_returning: false,
798        };
799        let resolved_modules = vec![
800            ResolvedModule {
801                file_id: FileId(0),
802                path: PathBuf::from("/project/src/entry.ts"),
803                resolved_imports: vec![ResolvedImport {
804                    info: ImportInfo {
805                        source: "./controller".to_string(),
806                        imported_name: ImportedName::Named("Ctrl".to_string()),
807                        local_name: "Ctrl".to_string(),
808                        is_type_only: false,
809                        from_style: false,
810                        span: oxc_span::Span::new(0, 10),
811                        source_span: oxc_span::Span::default(),
812                    },
813                    target: ResolveResult::InternalModule(FileId(1)),
814                }],
815                ..Default::default()
816            },
817            ResolvedModule {
818                file_id: FileId(1),
819                path: PathBuf::from("/project/src/controller.ts"),
820                exports: vec![ExportInfo {
821                    name: ExportName::Named("Ctrl".to_string()),
822                    local_name: Some("Ctrl".to_string()),
823                    is_type_only: false,
824                    visibility: VisibilityTag::None,
825                    expected_unused_reason: None,
826                    span: oxc_span::Span::new(0, 20),
827                    members: vec![method("used"), method("dead")],
828                    is_side_effect_used: false,
829                    super_class: None,
830                }],
831                ..Default::default()
832            },
833        ];
834        ModuleGraph::build(&resolved_modules, &entry_points, &files)
835    }
836
837    #[test]
838    fn trace_class_member_reports_owner_class() {
839        // #1744: `--trace FILE:MEMBER` on a class member reports the owning
840        // class instead of erroring "export not found".
841        let graph = build_class_member_graph();
842        let root = Path::new("/project");
843
844        let trace = trace_class_member(&graph, root, "src/controller.ts", "dead").unwrap();
845        assert_eq!(trace.owner_export, "Ctrl");
846        assert_eq!(trace.member_name, "dead");
847        assert_eq!(trace.member_kind, "class-method");
848        assert!(trace.owner_is_used);
849        assert!(trace.owner_file_reachable);
850        assert_eq!(trace.owner_direct_references.len(), 1);
851        assert!(
852            trace.reason.contains("--unused-class-members"),
853            "reason should point at the member command: {}",
854            trace.reason
855        );
856    }
857
858    #[test]
859    fn trace_class_member_absent_name_is_none() {
860        // A name that is neither a top-level export nor a declared member falls
861        // through so the caller emits the "not found" error.
862        let graph = build_class_member_graph();
863        let root = Path::new("/project");
864        assert!(trace_class_member(&graph, root, "src/controller.ts", "nope").is_none());
865    }
866
867    /// Build a graph where the controller declaring `Ctrl` is NOT imported by
868    /// the entry, so its file is unreachable and every member is dead.
869    fn build_unreachable_class_member_graph() -> ModuleGraph {
870        use fallow_types::extract::{MemberInfo, MemberKind};
871
872        let files = vec![
873            DiscoveredFile {
874                id: FileId(0),
875                path: PathBuf::from("/project/src/entry.ts"),
876                size_bytes: 100,
877            },
878            DiscoveredFile {
879                id: FileId(1),
880                path: PathBuf::from("/project/src/controller.ts"),
881                size_bytes: 50,
882            },
883        ];
884        let entry_points = vec![EntryPoint {
885            path: PathBuf::from("/project/src/entry.ts"),
886            source: EntryPointSource::PackageJsonMain,
887        }];
888        let method = |name: &str| MemberInfo {
889            name: name.to_string(),
890            kind: MemberKind::ClassMethod,
891            span: oxc_span::Span::new(0, 4),
892            has_decorator: false,
893            decorator_names: vec![],
894            is_instance_returning_static: false,
895            is_self_returning: false,
896        };
897        let resolved_modules = vec![
898            ResolvedModule {
899                file_id: FileId(0),
900                path: PathBuf::from("/project/src/entry.ts"),
901                // Entry imports nothing, so controller.ts is unreachable.
902                ..Default::default()
903            },
904            ResolvedModule {
905                file_id: FileId(1),
906                path: PathBuf::from("/project/src/controller.ts"),
907                exports: vec![ExportInfo {
908                    name: ExportName::Named("Ctrl".to_string()),
909                    local_name: Some("Ctrl".to_string()),
910                    is_type_only: false,
911                    visibility: VisibilityTag::None,
912                    expected_unused_reason: None,
913                    span: oxc_span::Span::new(0, 20),
914                    members: vec![method("dead")],
915                    is_side_effect_used: false,
916                    super_class: None,
917                }],
918                ..Default::default()
919            },
920        ];
921        ModuleGraph::build(&resolved_modules, &entry_points, &files)
922    }
923
924    #[test]
925    fn trace_class_member_unreachable_owner_reports_dead_reason() {
926        // `!file_reachable` branch: the owning file is not reachable from any
927        // entry point, so the reason states the class and its members are dead.
928        let graph = build_unreachable_class_member_graph();
929        let root = Path::new("/project");
930
931        let trace = trace_class_member(&graph, root, "src/controller.ts", "dead").unwrap();
932        assert!(!trace.owner_file_reachable);
933        assert!(
934            trace.reason.contains("not reachable"),
935            "unreachable owner reason should say so: {}",
936            trace.reason
937        );
938        // The unreachable branch does not point at a member command (the file is
939        // dead wholesale via the unused-file finding).
940        assert!(!trace.reason.contains("--unused-class-members"));
941    }
942
943    #[test]
944    fn trace_class_member_prefers_used_owner_on_name_collision() {
945        // Two exports declare a member of the same name; the tie-break in
946        // `max_by_key` must prefer the used, non-type-only owner so the trace
947        // reports the reachable class rather than a type-only shadow.
948        use fallow_types::extract::{MemberInfo, MemberKind};
949
950        let files = vec![
951            DiscoveredFile {
952                id: FileId(0),
953                path: PathBuf::from("/project/src/entry.ts"),
954                size_bytes: 100,
955            },
956            DiscoveredFile {
957                id: FileId(1),
958                path: PathBuf::from("/project/src/controller.ts"),
959                size_bytes: 50,
960            },
961        ];
962        let entry_points = vec![EntryPoint {
963            path: PathBuf::from("/project/src/entry.ts"),
964            source: EntryPointSource::PackageJsonMain,
965        }];
966        let method = |name: &str| MemberInfo {
967            name: name.to_string(),
968            kind: MemberKind::ClassMethod,
969            span: oxc_span::Span::new(0, 4),
970            has_decorator: false,
971            decorator_names: vec![],
972            is_instance_returning_static: false,
973            is_self_returning: false,
974        };
975        let resolved_modules = vec![
976            ResolvedModule {
977                file_id: FileId(0),
978                path: PathBuf::from("/project/src/entry.ts"),
979                resolved_imports: vec![ResolvedImport {
980                    info: ImportInfo {
981                        source: "./controller".to_string(),
982                        imported_name: ImportedName::Named("UsedCtrl".to_string()),
983                        local_name: "UsedCtrl".to_string(),
984                        is_type_only: false,
985                        from_style: false,
986                        span: oxc_span::Span::new(0, 10),
987                        source_span: oxc_span::Span::default(),
988                    },
989                    target: ResolveResult::InternalModule(FileId(1)),
990                }],
991                ..Default::default()
992            },
993            ResolvedModule {
994                file_id: FileId(1),
995                path: PathBuf::from("/project/src/controller.ts"),
996                exports: vec![
997                    // Type-only, unreferenced owner declared FIRST: must lose the
998                    // tie-break to the used, non-type-only owner below.
999                    ExportInfo {
1000                        name: ExportName::Named("TypeCtrl".to_string()),
1001                        local_name: Some("TypeCtrl".to_string()),
1002                        is_type_only: true,
1003                        visibility: VisibilityTag::None,
1004                        expected_unused_reason: None,
1005                        span: oxc_span::Span::new(0, 20),
1006                        members: vec![method("shared")],
1007                        is_side_effect_used: false,
1008                        super_class: None,
1009                    },
1010                    ExportInfo {
1011                        name: ExportName::Named("UsedCtrl".to_string()),
1012                        local_name: Some("UsedCtrl".to_string()),
1013                        is_type_only: false,
1014                        visibility: VisibilityTag::None,
1015                        expected_unused_reason: None,
1016                        span: oxc_span::Span::new(0, 20),
1017                        members: vec![method("shared")],
1018                        is_side_effect_used: false,
1019                        super_class: None,
1020                    },
1021                ],
1022                ..Default::default()
1023            },
1024        ];
1025        let graph = ModuleGraph::build(&resolved_modules, &entry_points, &files);
1026        let root = Path::new("/project");
1027
1028        let trace = trace_class_member(&graph, root, "src/controller.ts", "shared").unwrap();
1029        assert_eq!(
1030            trace.owner_export, "UsedCtrl",
1031            "tie-break must prefer the used, non-type-only owner"
1032        );
1033        assert!(trace.owner_is_used);
1034    }
1035
1036    #[test]
1037    fn trace_nonexistent_file() {
1038        let graph = build_test_graph();
1039        let root = Path::new("/project");
1040
1041        let trace = trace_export(&graph, root, "src/nope.ts", "foo");
1042        assert!(trace.is_none());
1043    }
1044
1045    #[test]
1046    fn trace_file_edges() {
1047        let graph = build_test_graph();
1048        let root = Path::new("/project");
1049
1050        let trace = trace_file(&graph, root, "src/entry.ts").unwrap();
1051        assert!(trace.is_entry_point);
1052        assert!(trace.is_reachable);
1053        assert_eq!(trace.imports_from.len(), 1);
1054        assert_eq!(trace.imports_from[0], PathBuf::from("src/utils.ts"));
1055        assert!(trace.imported_by.is_empty());
1056    }
1057
1058    #[test]
1059    fn trace_file_imported_by() {
1060        let graph = build_test_graph();
1061        let root = Path::new("/project");
1062
1063        let trace = trace_file(&graph, root, "src/utils.ts").unwrap();
1064        assert!(!trace.is_entry_point);
1065        assert!(trace.is_reachable);
1066        assert_eq!(trace.exports.len(), 2);
1067        assert_eq!(trace.imported_by.len(), 1);
1068        assert_eq!(trace.imported_by[0], PathBuf::from("src/entry.ts"));
1069    }
1070
1071    #[test]
1072    fn trace_unreachable_file() {
1073        let graph = build_test_graph();
1074        let root = Path::new("/project");
1075
1076        let trace = trace_file(&graph, root, "src/unused.ts").unwrap();
1077        assert!(!trace.is_reachable);
1078        assert!(!trace.is_entry_point);
1079        assert!(trace.imported_by.is_empty());
1080    }
1081
1082    #[test]
1083    fn trace_dependency_used() {
1084        let files = vec![DiscoveredFile {
1085            id: FileId(0),
1086            path: PathBuf::from("/project/src/app.ts"),
1087            size_bytes: 100,
1088        }];
1089        let entry_points = vec![EntryPoint {
1090            path: PathBuf::from("/project/src/app.ts"),
1091            source: EntryPointSource::PackageJsonMain,
1092        }];
1093        let resolved_modules = vec![ResolvedModule {
1094            file_id: FileId(0),
1095            path: PathBuf::from("/project/src/app.ts"),
1096            resolved_imports: vec![ResolvedImport {
1097                info: ImportInfo {
1098                    source: "lodash".to_string(),
1099                    imported_name: ImportedName::Named("get".to_string()),
1100                    local_name: "get".to_string(),
1101                    is_type_only: false,
1102                    from_style: false,
1103                    span: oxc_span::Span::new(0, 10),
1104                    source_span: oxc_span::Span::default(),
1105                },
1106                target: ResolveResult::NpmPackage("lodash".to_string()),
1107            }],
1108            ..Default::default()
1109        }];
1110
1111        let graph = ModuleGraph::build(&resolved_modules, &entry_points, &files);
1112        let root = Path::new("/project");
1113
1114        let trace = trace_dependency(&graph, root, "lodash", &FxHashSet::default());
1115        assert!(trace.is_used);
1116        assert!(!trace.used_in_scripts);
1117        assert_eq!(trace.import_count, 1);
1118        assert_eq!(trace.imported_by[0], PathBuf::from("src/app.ts"));
1119    }
1120
1121    #[test]
1122    fn trace_dependency_unused() {
1123        let files = vec![DiscoveredFile {
1124            id: FileId(0),
1125            path: PathBuf::from("/project/src/app.ts"),
1126            size_bytes: 100,
1127        }];
1128        let entry_points = vec![EntryPoint {
1129            path: PathBuf::from("/project/src/app.ts"),
1130            source: EntryPointSource::PackageJsonMain,
1131        }];
1132        let resolved_modules = vec![ResolvedModule {
1133            file_id: FileId(0),
1134            path: PathBuf::from("/project/src/app.ts"),
1135            ..Default::default()
1136        }];
1137
1138        let graph = ModuleGraph::build(&resolved_modules, &entry_points, &files);
1139        let root = Path::new("/project");
1140
1141        let trace = trace_dependency(&graph, root, "nonexistent-pkg", &FxHashSet::default());
1142        assert!(!trace.is_used);
1143        assert!(!trace.used_in_scripts);
1144        assert_eq!(trace.import_count, 0);
1145        assert!(trace.imported_by.is_empty());
1146    }
1147
1148    #[test]
1149    fn trace_dependency_used_only_in_scripts() {
1150        let files = vec![DiscoveredFile {
1151            id: FileId(0),
1152            path: PathBuf::from("/project/src/app.ts"),
1153            size_bytes: 100,
1154        }];
1155        let entry_points = vec![EntryPoint {
1156            path: PathBuf::from("/project/src/app.ts"),
1157            source: EntryPointSource::PackageJsonMain,
1158        }];
1159        let resolved_modules = vec![ResolvedModule {
1160            file_id: FileId(0),
1161            path: PathBuf::from("/project/src/app.ts"),
1162            ..Default::default()
1163        }];
1164
1165        let graph = ModuleGraph::build(&resolved_modules, &entry_points, &files);
1166        let root = Path::new("/project");
1167        let mut script_used = FxHashSet::default();
1168        script_used.insert("microbundle".to_string());
1169
1170        let trace = trace_dependency(&graph, root, "microbundle", &script_used);
1171        assert!(
1172            trace.is_used,
1173            "is_used must be true when the package is referenced from package.json scripts"
1174        );
1175        assert!(trace.used_in_scripts);
1176        assert_eq!(trace.import_count, 0);
1177        assert!(trace.imported_by.is_empty());
1178    }
1179
1180    #[test]
1181    fn trace_clone_finds_matching_group() {
1182        use crate::duplicates::{CloneGroup, CloneInstance, DuplicationReport, DuplicationStats};
1183        let report = DuplicationReport {
1184            clone_groups: vec![CloneGroup {
1185                instances: vec![
1186                    CloneInstance {
1187                        file: PathBuf::from("/project/src/a.ts"),
1188                        start_line: 10,
1189                        end_line: 20,
1190                        start_col: 0,
1191                        end_col: 0,
1192                        fragment: "fn foo() {}".to_string(),
1193                    },
1194                    CloneInstance {
1195                        file: PathBuf::from("/project/src/b.ts"),
1196                        start_line: 5,
1197                        end_line: 15,
1198                        start_col: 0,
1199                        end_col: 0,
1200                        fragment: "fn foo() {}".to_string(),
1201                    },
1202                ],
1203                token_count: 60,
1204                line_count: 11,
1205            }],
1206            clone_families: vec![],
1207            mirrored_directories: vec![],
1208            stats: DuplicationStats {
1209                total_files: 2,
1210                files_with_clones: 2,
1211                total_lines: 100,
1212                duplicated_lines: 22,
1213                total_tokens: 200,
1214                duplicated_tokens: 120,
1215                clone_groups: 1,
1216                clone_instances: 2,
1217                duplication_percentage: 22.0,
1218                clone_groups_below_min_occurrences: 0,
1219            },
1220        };
1221        let trace = trace_clone(&report, Path::new("/project"), "src/a.ts", 15);
1222        assert!(trace.matched_instance.is_some());
1223        assert_eq!(trace.clone_groups.len(), 1);
1224        assert_eq!(trace.clone_groups[0].instances.len(), 2);
1225        assert!(trace.clone_groups[0].fingerprint.starts_with("dup:"));
1226        assert_eq!(trace.clone_groups[0].suggestion.estimated_savings, 11);
1227    }
1228
1229    #[test]
1230    fn trace_clone_by_fingerprint_resolves_and_misses() {
1231        use crate::duplicates::{
1232            CloneGroup, CloneInstance, DuplicationReport, DuplicationStats, clone_fingerprint,
1233        };
1234        let report = DuplicationReport {
1235            clone_groups: vec![CloneGroup {
1236                instances: vec![
1237                    CloneInstance {
1238                        file: PathBuf::from("/project/src/a.ts"),
1239                        start_line: 10,
1240                        end_line: 20,
1241                        start_col: 0,
1242                        end_col: 0,
1243                        fragment: "fn buildInvoice() {}".to_string(),
1244                    },
1245                    CloneInstance {
1246                        file: PathBuf::from("/project/src/b.ts"),
1247                        start_line: 5,
1248                        end_line: 15,
1249                        start_col: 0,
1250                        end_col: 0,
1251                        fragment: "fn buildInvoice() {}".to_string(),
1252                    },
1253                ],
1254                token_count: 60,
1255                line_count: 11,
1256            }],
1257            clone_families: vec![],
1258            mirrored_directories: vec![],
1259            stats: DuplicationStats::default(),
1260        };
1261        let fp = clone_fingerprint(&report.clone_groups[0].instances);
1262
1263        let hit = trace_clone_by_fingerprint(&report, Path::new("/project"), &fp);
1264        assert!(hit.matched_instance.is_some());
1265        assert_eq!(hit.clone_groups.len(), 1);
1266        assert_eq!(hit.clone_groups[0].fingerprint, fp);
1267        assert_eq!(hit.line, 10);
1268
1269        let miss = trace_clone_by_fingerprint(&report, Path::new("/project"), "dup:deadbeef");
1270        assert!(miss.matched_instance.is_none());
1271        assert!(miss.clone_groups.is_empty());
1272    }
1273
1274    #[test]
1275    fn trace_clone_no_match() {
1276        use crate::duplicates::{CloneGroup, CloneInstance, DuplicationReport, DuplicationStats};
1277        let report = DuplicationReport {
1278            clone_groups: vec![CloneGroup {
1279                instances: vec![CloneInstance {
1280                    file: PathBuf::from("/project/src/a.ts"),
1281                    start_line: 10,
1282                    end_line: 20,
1283                    start_col: 0,
1284                    end_col: 0,
1285                    fragment: "fn foo() {}".to_string(),
1286                }],
1287                token_count: 60,
1288                line_count: 11,
1289            }],
1290            clone_families: vec![],
1291            mirrored_directories: vec![],
1292            stats: DuplicationStats {
1293                total_files: 1,
1294                files_with_clones: 1,
1295                total_lines: 50,
1296                duplicated_lines: 11,
1297                total_tokens: 100,
1298                duplicated_tokens: 60,
1299                clone_groups: 1,
1300                clone_instances: 1,
1301                duplication_percentage: 22.0,
1302                clone_groups_below_min_occurrences: 0,
1303            },
1304        };
1305        let trace = trace_clone(&report, Path::new("/project"), "src/a.ts", 25);
1306        assert!(trace.matched_instance.is_none());
1307        assert!(trace.clone_groups.is_empty());
1308    }
1309
1310    #[test]
1311    fn trace_clone_line_boundary() {
1312        use crate::duplicates::{CloneGroup, CloneInstance, DuplicationReport, DuplicationStats};
1313        let report = DuplicationReport {
1314            clone_groups: vec![CloneGroup {
1315                instances: vec![
1316                    CloneInstance {
1317                        file: PathBuf::from("/project/src/a.ts"),
1318                        start_line: 10,
1319                        end_line: 20,
1320                        start_col: 0,
1321                        end_col: 0,
1322                        fragment: "code".to_string(),
1323                    },
1324                    CloneInstance {
1325                        file: PathBuf::from("/project/src/b.ts"),
1326                        start_line: 1,
1327                        end_line: 11,
1328                        start_col: 0,
1329                        end_col: 0,
1330                        fragment: "code".to_string(),
1331                    },
1332                ],
1333                token_count: 50,
1334                line_count: 11,
1335            }],
1336            clone_families: vec![],
1337            mirrored_directories: vec![],
1338            stats: DuplicationStats {
1339                total_files: 2,
1340                files_with_clones: 2,
1341                total_lines: 100,
1342                duplicated_lines: 22,
1343                total_tokens: 200,
1344                duplicated_tokens: 100,
1345                clone_groups: 1,
1346                clone_instances: 2,
1347                duplication_percentage: 22.0,
1348                clone_groups_below_min_occurrences: 0,
1349            },
1350        };
1351        let root = Path::new("/project");
1352        assert!(
1353            trace_clone(&report, root, "src/a.ts", 10)
1354                .matched_instance
1355                .is_some()
1356        );
1357        assert!(
1358            trace_clone(&report, root, "src/a.ts", 20)
1359                .matched_instance
1360                .is_some()
1361        );
1362        assert!(
1363            trace_clone(&report, root, "src/a.ts", 21)
1364                .matched_instance
1365                .is_none()
1366        );
1367    }
1368
1369    #[test]
1370    fn trace_clone_returns_relative_instance_paths() {
1371        use crate::duplicates::{CloneGroup, CloneInstance, DuplicationReport, DuplicationStats};
1372        let report = DuplicationReport {
1373            clone_groups: vec![CloneGroup {
1374                instances: vec![
1375                    CloneInstance {
1376                        file: PathBuf::from("/project/src/a.ts"),
1377                        start_line: 1,
1378                        end_line: 10,
1379                        start_col: 0,
1380                        end_col: 0,
1381                        fragment: "code".to_string(),
1382                    },
1383                    CloneInstance {
1384                        file: PathBuf::from("/project/src/b.ts"),
1385                        start_line: 1,
1386                        end_line: 10,
1387                        start_col: 0,
1388                        end_col: 0,
1389                        fragment: "code".to_string(),
1390                    },
1391                ],
1392                token_count: 50,
1393                line_count: 10,
1394            }],
1395            clone_families: vec![],
1396            mirrored_directories: vec![],
1397            stats: DuplicationStats {
1398                total_files: 2,
1399                files_with_clones: 2,
1400                total_lines: 50,
1401                duplicated_lines: 20,
1402                total_tokens: 100,
1403                duplicated_tokens: 100,
1404                clone_groups: 1,
1405                clone_instances: 2,
1406                duplication_percentage: 40.0,
1407                clone_groups_below_min_occurrences: 0,
1408            },
1409        };
1410        let trace = trace_clone(&report, Path::new("/project"), "src/a.ts", 5);
1411        let matched = trace.matched_instance.as_ref().expect("match expected");
1412        assert_eq!(matched.file, PathBuf::from("src/a.ts"));
1413        for group in &trace.clone_groups {
1414            for inst in &group.instances {
1415                let as_str = inst.file.to_string_lossy();
1416                assert!(
1417                    !as_str.starts_with('/'),
1418                    "instance file should be relative, got {as_str}",
1419                );
1420                assert!(
1421                    !as_str.contains(":\\") && !as_str.contains(":/"),
1422                    "instance file should not have a drive letter, got {as_str}",
1423                );
1424            }
1425        }
1426
1427        let json = serde_json::to_string(&trace).expect("serializes");
1428        assert!(
1429            !json.contains("\"/project/"),
1430            "serialized trace should not leak absolute paths: {json}",
1431        );
1432    }
1433
1434    /// Regression for the MCP e2e `trace_export` / `trace_file` Windows
1435    /// failures: the MCP layer passes forward-slashed user input
1436    /// (`src/utils.ts`) but `module_path` on Windows uses backslash
1437    /// separators (`D:\a\fallow\...\src\utils.ts`). The byte-level
1438    /// equality check missed every match. The helper now normalises
1439    /// both sides to forward slashes before comparing.
1440    #[test]
1441    fn path_matches_normalises_windows_module_path_against_posix_user_path() {
1442        let root = Path::new(r"D:\a\fallow\fallow\tests\fixtures\basic-project");
1443        let module_path =
1444            PathBuf::from(r"D:\a\fallow\fallow\tests\fixtures\basic-project\src\utils.ts");
1445        assert!(path_matches(&module_path, root, "src/utils.ts"));
1446        assert!(path_matches(&module_path, root, r"src\utils.ts"));
1447    }
1448
1449    #[test]
1450    fn path_matches_ends_with_fallback_handles_mixed_separators() {
1451        let root = Path::new("/some/other/root");
1452        let module_path =
1453            PathBuf::from(r"D:\a\fallow\fallow\tests\fixtures\basic-project\src\utils.ts");
1454        assert!(path_matches(&module_path, root, "src/utils.ts"));
1455    }
1456
1457    /// Regression for the MCP e2e trace_export / trace_file failures: even
1458    /// after `path_matches` correctly identified the file on Windows, the
1459    /// trace output struct's `file: PathBuf` field serialized the stored
1460    /// backslash-shaped path verbatim. JSON consumers (MCP agents, CI
1461    /// pipelines, the cross-platform trace_file assertion in
1462    /// `e2e_trace_file_returns_json`) expect forward-slash. Pin the
1463    /// contract via raw-string Windows-shaped `PathBuf::from` so the test
1464    /// runs cross-platform.
1465    #[test]
1466    fn export_trace_serializes_windows_path_with_forward_slashes() {
1467        let trace = ExportTrace {
1468            file: PathBuf::from(r"src\utils.ts"),
1469            export_name: "foo".to_string(),
1470            file_reachable: true,
1471            is_entry_point: false,
1472            is_used: true,
1473            direct_references: vec![ExportReference {
1474                from_file: PathBuf::from(r"src\entry.ts"),
1475                kind: "named import".to_string(),
1476            }],
1477            re_export_chains: vec![ReExportChain {
1478                barrel_file: PathBuf::from(r"src\index.ts"),
1479                exported_as: "foo".to_string(),
1480                reference_count: 1,
1481            }],
1482            reason: "ok".to_string(),
1483        };
1484        let json = serde_json::to_string(&trace).expect("serializes");
1485        assert!(
1486            json.contains("\"file\":\"src/utils.ts\""),
1487            "ExportTrace.file must serialize with forward slashes: {json}"
1488        );
1489        assert!(
1490            json.contains("\"from_file\":\"src/entry.ts\""),
1491            "ExportReference.from_file must serialize with forward slashes: {json}"
1492        );
1493        assert!(
1494            json.contains("\"barrel_file\":\"src/index.ts\""),
1495            "ReExportChain.barrel_file must serialize with forward slashes: {json}"
1496        );
1497        assert!(
1498            !json.contains(r"\\"),
1499            "no backslash sequence should remain anywhere in the JSON: {json}"
1500        );
1501    }
1502
1503    #[test]
1504    fn file_trace_serializes_windows_paths_with_forward_slashes() {
1505        let trace = FileTrace {
1506            file: PathBuf::from(r"src\utils.ts"),
1507            is_reachable: true,
1508            is_entry_point: false,
1509            exports: vec![],
1510            imports_from: vec![PathBuf::from(r"src\helpers.ts")],
1511            imported_by: vec![PathBuf::from(r"src\entry.ts")],
1512            re_exports: vec![TracedReExport {
1513                source_file: PathBuf::from(r"src\source.ts"),
1514                imported_name: "foo".to_string(),
1515                exported_name: "foo".to_string(),
1516            }],
1517        };
1518        let json = serde_json::to_string(&trace).expect("serializes");
1519        assert!(json.contains("\"file\":\"src/utils.ts\""), "got {json}");
1520        assert!(
1521            json.contains("\"imports_from\":[\"src/helpers.ts\"]"),
1522            "got {json}"
1523        );
1524        assert!(
1525            json.contains("\"imported_by\":[\"src/entry.ts\"]"),
1526            "got {json}"
1527        );
1528        assert!(
1529            json.contains("\"source_file\":\"src/source.ts\""),
1530            "got {json}"
1531        );
1532        assert!(!json.contains(r"\\"), "no backslash should remain: {json}");
1533    }
1534}