Skip to main content

fallow_core/
trace.rs

1use std::path::{Path, PathBuf};
2
3pub use fallow_types::trace::{
4    CloneTrace, DependencyTrace, ExportReference, ExportTrace, FileTrace, ImpactClosureGap,
5    ImpactClosureTrace, PipelineTimings, ReExportChain, TracedCloneGroup, TracedExport,
6    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
168fn export_name_matches(export: &crate::graph::ExportSymbol, export_name: &str) -> bool {
169    let name_str = export.name.to_string();
170    name_str == export_name || (export_name == "default" && name_str == "default")
171}
172
173/// Map a module's exports to [`TracedExport`] entries with relativized references.
174fn traced_exports(
175    graph: &ModuleGraph,
176    root: &Path,
177    module: &crate::graph::ModuleNode,
178) -> Vec<TracedExport> {
179    module
180        .exports
181        .iter()
182        .map(|e| TracedExport {
183            name: e.name.to_string(),
184            is_type_only: e.is_type_only,
185            reference_count: e.references.len(),
186            referenced_by: e
187                .references
188                .iter()
189                .map(|r| reference_to_export_reference(graph, root, r))
190                .collect(),
191        })
192        .collect()
193}
194
195/// Collect the root-relative paths a file imports from (forward graph edges).
196fn traced_imports_from(
197    graph: &ModuleGraph,
198    root: &Path,
199    module: &crate::graph::ModuleNode,
200) -> Vec<PathBuf> {
201    graph
202        .edges_for(module.file_id)
203        .iter()
204        .filter_map(|target_id| {
205            graph
206                .modules
207                .get(target_id.0 as usize)
208                .map(|m| m.path.strip_prefix(root).unwrap_or(&m.path).to_path_buf())
209        })
210        .collect()
211}
212
213/// Collect the root-relative paths that import a file (reverse graph edges).
214fn traced_imported_by(
215    graph: &ModuleGraph,
216    root: &Path,
217    module: &crate::graph::ModuleNode,
218) -> Vec<PathBuf> {
219    graph
220        .reverse_deps
221        .get(module.file_id.0 as usize)
222        .map(|deps| {
223            deps.iter()
224                .filter_map(|fid| {
225                    graph
226                        .modules
227                        .get(fid.0 as usize)
228                        .map(|m| m.path.strip_prefix(root).unwrap_or(&m.path).to_path_buf())
229                })
230                .collect()
231        })
232        .unwrap_or_default()
233}
234
235/// Map a module's re-exports to [`TracedReExport`] entries with relativized source paths.
236fn traced_re_exports(
237    graph: &ModuleGraph,
238    root: &Path,
239    module: &crate::graph::ModuleNode,
240) -> Vec<TracedReExport> {
241    module
242        .re_exports
243        .iter()
244        .map(|re| {
245            let source_path = graph.modules.get(re.source_file.0 as usize).map_or_else(
246                || PathBuf::from(format!("<unknown:{}>", re.source_file.0)),
247                |m| m.path.strip_prefix(root).unwrap_or(&m.path).to_path_buf(),
248            );
249            TracedReExport {
250                source_file: source_path,
251                imported_name: re.imported_name.clone(),
252                exported_name: re.exported_name.clone(),
253            }
254        })
255        .collect()
256}
257
258/// Trace all edges for a file.
259#[must_use]
260pub fn trace_file(graph: &ModuleGraph, root: &Path, file_path: &str) -> Option<FileTrace> {
261    let module = graph
262        .modules
263        .iter()
264        .find(|m| path_matches(&m.path, root, file_path))?;
265
266    Some(FileTrace {
267        file: module
268            .path
269            .strip_prefix(root)
270            .unwrap_or(&module.path)
271            .to_path_buf(),
272        is_reachable: module.is_reachable(),
273        is_entry_point: module.is_entry_point(),
274        exports: traced_exports(graph, root, module),
275        imports_from: traced_imports_from(graph, root, module),
276        imported_by: traced_imported_by(graph, root, module),
277        re_exports: traced_re_exports(graph, root, module),
278    })
279}
280
281/// Trace where a dependency is used.
282///
283/// `script_used_packages` carries the package names recorded as binary invocations
284/// in package.json scripts (`build: microbundle ...`) and CI configs
285/// (`.github/workflows/*.yml`, `.gitlab-ci.yml`). The same set the unused-deps
286/// detector consults; passing it in lets the trace output match the detector's
287/// view of "used" instead of reporting `is_used=false` for tools invoked only
288/// through scripts.
289#[expect(
290    clippy::implicit_hasher,
291    reason = "fallow standardizes on FxHashSet across the workspace"
292)]
293#[must_use]
294pub fn trace_dependency(
295    graph: &ModuleGraph,
296    root: &Path,
297    package_name: &str,
298    script_used_packages: &FxHashSet<String>,
299) -> DependencyTrace {
300    let imported_by: Vec<PathBuf> = graph
301        .package_usage
302        .get(package_name)
303        .map(|ids| {
304            ids.iter()
305                .filter_map(|fid| {
306                    graph
307                        .modules
308                        .get(fid.0 as usize)
309                        .map(|m| m.path.strip_prefix(root).unwrap_or(&m.path).to_path_buf())
310                })
311                .collect()
312        })
313        .unwrap_or_default();
314
315    let type_only_imported_by: Vec<PathBuf> = graph
316        .type_only_package_usage
317        .get(package_name)
318        .map(|ids| {
319            ids.iter()
320                .filter_map(|fid| {
321                    graph
322                        .modules
323                        .get(fid.0 as usize)
324                        .map(|m| m.path.strip_prefix(root).unwrap_or(&m.path).to_path_buf())
325                })
326                .collect()
327        })
328        .unwrap_or_default();
329
330    let import_count = imported_by.len();
331    let used_in_scripts = script_used_packages.contains(package_name);
332    DependencyTrace {
333        package_name: package_name.to_string(),
334        imported_by,
335        type_only_imported_by,
336        used_in_scripts,
337        is_used: import_count > 0 || used_in_scripts,
338        import_count,
339    }
340}
341
342fn format_reference_kind(kind: ReferenceKind) -> String {
343    match kind {
344        ReferenceKind::NamedImport => "named import".to_string(),
345        ReferenceKind::DefaultImport => "default import".to_string(),
346        ReferenceKind::NamespaceImport => "namespace import".to_string(),
347        ReferenceKind::ReExport => "re-export".to_string(),
348        ReferenceKind::DynamicImport => "dynamic import".to_string(),
349        ReferenceKind::SideEffectImport => "side-effect import".to_string(),
350    }
351}
352
353/// Compute the impact closure for a single file as the seed.
354///
355/// Resolves `file_path` to a graph `FileId`, walks `reverse_deps` + re-export
356/// chains to the transitive affected set, and reports the coordination gap (the
357/// seed's exported contracts consumed by modules outside the seed). Returns
358/// `None` when the file is not in the module graph.
359#[must_use]
360pub fn trace_impact_closure(
361    graph: &ModuleGraph,
362    root: &Path,
363    file_path: &str,
364) -> Option<ImpactClosureTrace> {
365    let module = graph
366        .modules
367        .iter()
368        .find(|m| path_matches(&m.path, root, file_path))?;
369
370    let closure = graph.impact_closure(&[module.file_id]);
371    let paths = graph.closure_with_paths(&closure, root);
372
373    let seed = paths
374        .in_diff
375        .first()
376        .cloned()
377        .unwrap_or_else(|| file_path.replace('\\', "/"));
378
379    let coordination_gap = paths
380        .coordination_gap
381        .into_iter()
382        .map(|gap| ImpactClosureGap {
383            consumer_file: gap.consumer_file,
384            consumed_symbols: gap.consumed_symbols,
385            note: "syntactic attention pointer, not a correctness proof".to_string(),
386        })
387        .collect();
388
389    Some(ImpactClosureTrace {
390        seed,
391        affected_not_shown: paths.affected_not_shown,
392        coordination_gap,
393    })
394}
395
396/// Build a [`TracedCloneGroup`] from a raw clone group, computing the
397/// fingerprint, group-level suggestion, and dominant-identifier name and
398/// relativizing every instance path against `root`.
399fn build_traced_group(
400    group: &CloneGroup,
401    root: &Path,
402    fingerprints: &CloneFingerprintSet,
403) -> TracedCloneGroup {
404    TracedCloneGroup {
405        fingerprint: fingerprints.fingerprint_for_group(group),
406        token_count: group.token_count,
407        line_count: group.line_count,
408        instances: group
409            .instances
410            .iter()
411            .map(|inst| relativize_instance(inst, root))
412            .collect(),
413        suggestion: group_refactoring_suggestion(group),
414        suggested_name: dominant_identifier(group),
415    }
416}
417
418#[must_use]
419pub fn trace_clone(
420    report: &DuplicationReport,
421    root: &Path,
422    file_path: &str,
423    line: usize,
424) -> CloneTrace {
425    let resolved = root.join(file_path);
426    let mut matched_instance = None;
427    let mut clone_groups = Vec::new();
428    let fingerprints = CloneFingerprintSet::from_groups(&report.clone_groups);
429
430    for group in &report.clone_groups {
431        let matching = group.instances.iter().find(|inst| {
432            let inst_matches = inst.file == resolved
433                || inst.file.strip_prefix(root).unwrap_or(&inst.file) == Path::new(file_path);
434            inst_matches && inst.start_line <= line && line <= inst.end_line
435        });
436
437        if let Some(matched) = matching {
438            if matched_instance.is_none() {
439                matched_instance = Some(relativize_instance(matched, root));
440            }
441            clone_groups.push(build_traced_group(group, root, &fingerprints));
442        }
443    }
444
445    CloneTrace {
446        file: PathBuf::from(file_path),
447        line,
448        matched_instance,
449        clone_groups,
450    }
451}
452
453/// Trace a clone group by its stable content fingerprint.
454///
455/// Fingerprints are usually `dup:<8hex>` and widen only when needed to avoid a
456/// collision inside the same report.
457///
458/// Returns a [`CloneTrace`] whose single `clone_groups` entry is the matched
459/// group and whose `file` / `line` / `matched_instance` come from that group's
460/// representative (first) instance. `matched_instance` is `None` (and
461/// `clone_groups` empty) when no group matches the fingerprint.
462#[must_use]
463pub fn trace_clone_by_fingerprint(
464    report: &DuplicationReport,
465    root: &Path,
466    fingerprint: &str,
467) -> CloneTrace {
468    let fingerprints = CloneFingerprintSet::from_groups(&report.clone_groups);
469    let matched = fingerprints.find_group(&report.clone_groups, fingerprint);
470
471    let Some(group) = matched else {
472        return CloneTrace {
473            file: PathBuf::new(),
474            line: 0,
475            matched_instance: None,
476            clone_groups: Vec::new(),
477        };
478    };
479
480    let representative = group
481        .instances
482        .first()
483        .map(|inst| relativize_instance(inst, root));
484    let (file, line) = representative.as_ref().map_or_else(
485        || (PathBuf::new(), 0),
486        |inst| (inst.file.clone(), inst.start_line),
487    );
488
489    CloneTrace {
490        file,
491        line,
492        matched_instance: representative,
493        clone_groups: vec![build_traced_group(group, root, &fingerprints)],
494    }
495}
496
497/// Return a copy of `inst` with `file` rewritten relative to `root` (forward-slash normalized
498/// for cross-platform JSON parity with `serde_path::serialize`). If `inst.file` is already
499/// outside `root`, the path is left unchanged.
500fn relativize_instance(inst: &CloneInstance, root: &Path) -> CloneInstance {
501    let rel = inst.file.strip_prefix(root).map_or_else(
502        |_| inst.file.clone(),
503        |p| PathBuf::from(p.to_string_lossy().replace('\\', "/")),
504    );
505    CloneInstance {
506        file: rel,
507        ..inst.clone()
508    }
509}
510
511#[cfg(test)]
512mod tests {
513    use super::*;
514
515    use crate::discover::{DiscoveredFile, EntryPoint, EntryPointSource, FileId};
516    use crate::extract::{ExportInfo, ExportName, ImportInfo, ImportedName, VisibilityTag};
517    use crate::resolve::{ResolveResult, ResolvedImport, ResolvedModule};
518
519    fn build_test_graph() -> ModuleGraph {
520        let files = vec![
521            DiscoveredFile {
522                id: FileId(0),
523                path: PathBuf::from("/project/src/entry.ts"),
524                size_bytes: 100,
525            },
526            DiscoveredFile {
527                id: FileId(1),
528                path: PathBuf::from("/project/src/utils.ts"),
529                size_bytes: 50,
530            },
531            DiscoveredFile {
532                id: FileId(2),
533                path: PathBuf::from("/project/src/unused.ts"),
534                size_bytes: 30,
535            },
536        ];
537
538        let entry_points = vec![EntryPoint {
539            path: PathBuf::from("/project/src/entry.ts"),
540            source: EntryPointSource::PackageJsonMain,
541        }];
542
543        let resolved_modules = vec![
544            ResolvedModule {
545                file_id: FileId(0),
546                path: PathBuf::from("/project/src/entry.ts"),
547                resolved_imports: vec![ResolvedImport {
548                    info: ImportInfo {
549                        source: "./utils".to_string(),
550                        imported_name: ImportedName::Named("foo".to_string()),
551                        local_name: "foo".to_string(),
552                        is_type_only: false,
553                        from_style: false,
554                        span: oxc_span::Span::new(0, 10),
555                        source_span: oxc_span::Span::default(),
556                    },
557                    target: ResolveResult::InternalModule(FileId(1)),
558                }],
559                ..Default::default()
560            },
561            ResolvedModule {
562                file_id: FileId(1),
563                path: PathBuf::from("/project/src/utils.ts"),
564                exports: vec![
565                    ExportInfo {
566                        name: ExportName::Named("foo".to_string()),
567                        local_name: Some("foo".to_string()),
568                        is_type_only: false,
569                        visibility: VisibilityTag::None,
570                        expected_unused_reason: None,
571                        span: oxc_span::Span::new(0, 20),
572                        members: vec![],
573                        is_side_effect_used: false,
574                        super_class: None,
575                    },
576                    ExportInfo {
577                        name: ExportName::Named("bar".to_string()),
578                        local_name: Some("bar".to_string()),
579                        is_type_only: false,
580                        visibility: VisibilityTag::None,
581                        expected_unused_reason: None,
582                        span: oxc_span::Span::new(21, 40),
583                        members: vec![],
584                        is_side_effect_used: false,
585                        super_class: None,
586                    },
587                ],
588                ..Default::default()
589            },
590            ResolvedModule {
591                file_id: FileId(2),
592                path: PathBuf::from("/project/src/unused.ts"),
593                exports: vec![ExportInfo {
594                    name: ExportName::Named("baz".to_string()),
595                    local_name: Some("baz".to_string()),
596                    is_type_only: false,
597                    visibility: VisibilityTag::None,
598                    expected_unused_reason: None,
599                    span: oxc_span::Span::new(0, 15),
600                    members: vec![],
601                    is_side_effect_used: false,
602                    super_class: None,
603                }],
604                ..Default::default()
605            },
606        ];
607
608        ModuleGraph::build(&resolved_modules, &entry_points, &files)
609    }
610
611    #[test]
612    fn trace_used_export() {
613        let graph = build_test_graph();
614        let root = Path::new("/project");
615
616        let trace = trace_export(&graph, root, "src/utils.ts", "foo").unwrap();
617        assert!(trace.is_used);
618        assert!(trace.file_reachable);
619        assert_eq!(trace.direct_references.len(), 1);
620        assert_eq!(
621            trace.direct_references[0].from_file,
622            PathBuf::from("src/entry.ts")
623        );
624        assert_eq!(trace.direct_references[0].kind, "named import");
625    }
626
627    #[test]
628    fn trace_unused_export() {
629        let graph = build_test_graph();
630        let root = Path::new("/project");
631
632        let trace = trace_export(&graph, root, "src/utils.ts", "bar").unwrap();
633        assert!(!trace.is_used);
634        assert!(trace.file_reachable);
635        assert!(trace.direct_references.is_empty());
636    }
637
638    #[test]
639    fn trace_unreachable_file_export() {
640        let graph = build_test_graph();
641        let root = Path::new("/project");
642
643        let trace = trace_export(&graph, root, "src/unused.ts", "baz").unwrap();
644        assert!(!trace.is_used);
645        assert!(!trace.file_reachable);
646        assert!(trace.reason.contains("unreachable"));
647    }
648
649    #[test]
650    fn trace_nonexistent_export() {
651        let graph = build_test_graph();
652        let root = Path::new("/project");
653
654        let trace = trace_export(&graph, root, "src/utils.ts", "nonexistent");
655        assert!(trace.is_none());
656    }
657
658    #[test]
659    fn trace_nonexistent_file() {
660        let graph = build_test_graph();
661        let root = Path::new("/project");
662
663        let trace = trace_export(&graph, root, "src/nope.ts", "foo");
664        assert!(trace.is_none());
665    }
666
667    #[test]
668    fn trace_file_edges() {
669        let graph = build_test_graph();
670        let root = Path::new("/project");
671
672        let trace = trace_file(&graph, root, "src/entry.ts").unwrap();
673        assert!(trace.is_entry_point);
674        assert!(trace.is_reachable);
675        assert_eq!(trace.imports_from.len(), 1);
676        assert_eq!(trace.imports_from[0], PathBuf::from("src/utils.ts"));
677        assert!(trace.imported_by.is_empty());
678    }
679
680    #[test]
681    fn trace_file_imported_by() {
682        let graph = build_test_graph();
683        let root = Path::new("/project");
684
685        let trace = trace_file(&graph, root, "src/utils.ts").unwrap();
686        assert!(!trace.is_entry_point);
687        assert!(trace.is_reachable);
688        assert_eq!(trace.exports.len(), 2);
689        assert_eq!(trace.imported_by.len(), 1);
690        assert_eq!(trace.imported_by[0], PathBuf::from("src/entry.ts"));
691    }
692
693    #[test]
694    fn trace_unreachable_file() {
695        let graph = build_test_graph();
696        let root = Path::new("/project");
697
698        let trace = trace_file(&graph, root, "src/unused.ts").unwrap();
699        assert!(!trace.is_reachable);
700        assert!(!trace.is_entry_point);
701        assert!(trace.imported_by.is_empty());
702    }
703
704    #[test]
705    fn trace_dependency_used() {
706        let files = vec![DiscoveredFile {
707            id: FileId(0),
708            path: PathBuf::from("/project/src/app.ts"),
709            size_bytes: 100,
710        }];
711        let entry_points = vec![EntryPoint {
712            path: PathBuf::from("/project/src/app.ts"),
713            source: EntryPointSource::PackageJsonMain,
714        }];
715        let resolved_modules = vec![ResolvedModule {
716            file_id: FileId(0),
717            path: PathBuf::from("/project/src/app.ts"),
718            resolved_imports: vec![ResolvedImport {
719                info: ImportInfo {
720                    source: "lodash".to_string(),
721                    imported_name: ImportedName::Named("get".to_string()),
722                    local_name: "get".to_string(),
723                    is_type_only: false,
724                    from_style: false,
725                    span: oxc_span::Span::new(0, 10),
726                    source_span: oxc_span::Span::default(),
727                },
728                target: ResolveResult::NpmPackage("lodash".to_string()),
729            }],
730            ..Default::default()
731        }];
732
733        let graph = ModuleGraph::build(&resolved_modules, &entry_points, &files);
734        let root = Path::new("/project");
735
736        let trace = trace_dependency(&graph, root, "lodash", &FxHashSet::default());
737        assert!(trace.is_used);
738        assert!(!trace.used_in_scripts);
739        assert_eq!(trace.import_count, 1);
740        assert_eq!(trace.imported_by[0], PathBuf::from("src/app.ts"));
741    }
742
743    #[test]
744    fn trace_dependency_unused() {
745        let files = vec![DiscoveredFile {
746            id: FileId(0),
747            path: PathBuf::from("/project/src/app.ts"),
748            size_bytes: 100,
749        }];
750        let entry_points = vec![EntryPoint {
751            path: PathBuf::from("/project/src/app.ts"),
752            source: EntryPointSource::PackageJsonMain,
753        }];
754        let resolved_modules = vec![ResolvedModule {
755            file_id: FileId(0),
756            path: PathBuf::from("/project/src/app.ts"),
757            ..Default::default()
758        }];
759
760        let graph = ModuleGraph::build(&resolved_modules, &entry_points, &files);
761        let root = Path::new("/project");
762
763        let trace = trace_dependency(&graph, root, "nonexistent-pkg", &FxHashSet::default());
764        assert!(!trace.is_used);
765        assert!(!trace.used_in_scripts);
766        assert_eq!(trace.import_count, 0);
767        assert!(trace.imported_by.is_empty());
768    }
769
770    #[test]
771    fn trace_dependency_used_only_in_scripts() {
772        let files = vec![DiscoveredFile {
773            id: FileId(0),
774            path: PathBuf::from("/project/src/app.ts"),
775            size_bytes: 100,
776        }];
777        let entry_points = vec![EntryPoint {
778            path: PathBuf::from("/project/src/app.ts"),
779            source: EntryPointSource::PackageJsonMain,
780        }];
781        let resolved_modules = vec![ResolvedModule {
782            file_id: FileId(0),
783            path: PathBuf::from("/project/src/app.ts"),
784            ..Default::default()
785        }];
786
787        let graph = ModuleGraph::build(&resolved_modules, &entry_points, &files);
788        let root = Path::new("/project");
789        let mut script_used = FxHashSet::default();
790        script_used.insert("microbundle".to_string());
791
792        let trace = trace_dependency(&graph, root, "microbundle", &script_used);
793        assert!(
794            trace.is_used,
795            "is_used must be true when the package is referenced from package.json scripts"
796        );
797        assert!(trace.used_in_scripts);
798        assert_eq!(trace.import_count, 0);
799        assert!(trace.imported_by.is_empty());
800    }
801
802    #[test]
803    fn trace_clone_finds_matching_group() {
804        use crate::duplicates::{CloneGroup, CloneInstance, DuplicationReport, DuplicationStats};
805        let report = DuplicationReport {
806            clone_groups: vec![CloneGroup {
807                instances: vec![
808                    CloneInstance {
809                        file: PathBuf::from("/project/src/a.ts"),
810                        start_line: 10,
811                        end_line: 20,
812                        start_col: 0,
813                        end_col: 0,
814                        fragment: "fn foo() {}".to_string(),
815                    },
816                    CloneInstance {
817                        file: PathBuf::from("/project/src/b.ts"),
818                        start_line: 5,
819                        end_line: 15,
820                        start_col: 0,
821                        end_col: 0,
822                        fragment: "fn foo() {}".to_string(),
823                    },
824                ],
825                token_count: 60,
826                line_count: 11,
827            }],
828            clone_families: vec![],
829            mirrored_directories: vec![],
830            stats: DuplicationStats {
831                total_files: 2,
832                files_with_clones: 2,
833                total_lines: 100,
834                duplicated_lines: 22,
835                total_tokens: 200,
836                duplicated_tokens: 120,
837                clone_groups: 1,
838                clone_instances: 2,
839                duplication_percentage: 22.0,
840                clone_groups_below_min_occurrences: 0,
841            },
842        };
843        let trace = trace_clone(&report, Path::new("/project"), "src/a.ts", 15);
844        assert!(trace.matched_instance.is_some());
845        assert_eq!(trace.clone_groups.len(), 1);
846        assert_eq!(trace.clone_groups[0].instances.len(), 2);
847        assert!(trace.clone_groups[0].fingerprint.starts_with("dup:"));
848        assert_eq!(trace.clone_groups[0].suggestion.estimated_savings, 11);
849    }
850
851    #[test]
852    fn trace_clone_by_fingerprint_resolves_and_misses() {
853        use crate::duplicates::{
854            CloneGroup, CloneInstance, DuplicationReport, DuplicationStats, clone_fingerprint,
855        };
856        let report = DuplicationReport {
857            clone_groups: vec![CloneGroup {
858                instances: vec![
859                    CloneInstance {
860                        file: PathBuf::from("/project/src/a.ts"),
861                        start_line: 10,
862                        end_line: 20,
863                        start_col: 0,
864                        end_col: 0,
865                        fragment: "fn buildInvoice() {}".to_string(),
866                    },
867                    CloneInstance {
868                        file: PathBuf::from("/project/src/b.ts"),
869                        start_line: 5,
870                        end_line: 15,
871                        start_col: 0,
872                        end_col: 0,
873                        fragment: "fn buildInvoice() {}".to_string(),
874                    },
875                ],
876                token_count: 60,
877                line_count: 11,
878            }],
879            clone_families: vec![],
880            mirrored_directories: vec![],
881            stats: DuplicationStats::default(),
882        };
883        let fp = clone_fingerprint(&report.clone_groups[0].instances);
884
885        let hit = trace_clone_by_fingerprint(&report, Path::new("/project"), &fp);
886        assert!(hit.matched_instance.is_some());
887        assert_eq!(hit.clone_groups.len(), 1);
888        assert_eq!(hit.clone_groups[0].fingerprint, fp);
889        assert_eq!(hit.line, 10);
890
891        let miss = trace_clone_by_fingerprint(&report, Path::new("/project"), "dup:deadbeef");
892        assert!(miss.matched_instance.is_none());
893        assert!(miss.clone_groups.is_empty());
894    }
895
896    #[test]
897    fn trace_clone_no_match() {
898        use crate::duplicates::{CloneGroup, CloneInstance, DuplicationReport, DuplicationStats};
899        let report = DuplicationReport {
900            clone_groups: vec![CloneGroup {
901                instances: vec![CloneInstance {
902                    file: PathBuf::from("/project/src/a.ts"),
903                    start_line: 10,
904                    end_line: 20,
905                    start_col: 0,
906                    end_col: 0,
907                    fragment: "fn foo() {}".to_string(),
908                }],
909                token_count: 60,
910                line_count: 11,
911            }],
912            clone_families: vec![],
913            mirrored_directories: vec![],
914            stats: DuplicationStats {
915                total_files: 1,
916                files_with_clones: 1,
917                total_lines: 50,
918                duplicated_lines: 11,
919                total_tokens: 100,
920                duplicated_tokens: 60,
921                clone_groups: 1,
922                clone_instances: 1,
923                duplication_percentage: 22.0,
924                clone_groups_below_min_occurrences: 0,
925            },
926        };
927        let trace = trace_clone(&report, Path::new("/project"), "src/a.ts", 25);
928        assert!(trace.matched_instance.is_none());
929        assert!(trace.clone_groups.is_empty());
930    }
931
932    #[test]
933    fn trace_clone_line_boundary() {
934        use crate::duplicates::{CloneGroup, CloneInstance, DuplicationReport, DuplicationStats};
935        let report = DuplicationReport {
936            clone_groups: vec![CloneGroup {
937                instances: vec![
938                    CloneInstance {
939                        file: PathBuf::from("/project/src/a.ts"),
940                        start_line: 10,
941                        end_line: 20,
942                        start_col: 0,
943                        end_col: 0,
944                        fragment: "code".to_string(),
945                    },
946                    CloneInstance {
947                        file: PathBuf::from("/project/src/b.ts"),
948                        start_line: 1,
949                        end_line: 11,
950                        start_col: 0,
951                        end_col: 0,
952                        fragment: "code".to_string(),
953                    },
954                ],
955                token_count: 50,
956                line_count: 11,
957            }],
958            clone_families: vec![],
959            mirrored_directories: vec![],
960            stats: DuplicationStats {
961                total_files: 2,
962                files_with_clones: 2,
963                total_lines: 100,
964                duplicated_lines: 22,
965                total_tokens: 200,
966                duplicated_tokens: 100,
967                clone_groups: 1,
968                clone_instances: 2,
969                duplication_percentage: 22.0,
970                clone_groups_below_min_occurrences: 0,
971            },
972        };
973        let root = Path::new("/project");
974        assert!(
975            trace_clone(&report, root, "src/a.ts", 10)
976                .matched_instance
977                .is_some()
978        );
979        assert!(
980            trace_clone(&report, root, "src/a.ts", 20)
981                .matched_instance
982                .is_some()
983        );
984        assert!(
985            trace_clone(&report, root, "src/a.ts", 21)
986                .matched_instance
987                .is_none()
988        );
989    }
990
991    #[test]
992    fn trace_clone_returns_relative_instance_paths() {
993        use crate::duplicates::{CloneGroup, CloneInstance, DuplicationReport, DuplicationStats};
994        let report = DuplicationReport {
995            clone_groups: vec![CloneGroup {
996                instances: vec![
997                    CloneInstance {
998                        file: PathBuf::from("/project/src/a.ts"),
999                        start_line: 1,
1000                        end_line: 10,
1001                        start_col: 0,
1002                        end_col: 0,
1003                        fragment: "code".to_string(),
1004                    },
1005                    CloneInstance {
1006                        file: PathBuf::from("/project/src/b.ts"),
1007                        start_line: 1,
1008                        end_line: 10,
1009                        start_col: 0,
1010                        end_col: 0,
1011                        fragment: "code".to_string(),
1012                    },
1013                ],
1014                token_count: 50,
1015                line_count: 10,
1016            }],
1017            clone_families: vec![],
1018            mirrored_directories: vec![],
1019            stats: DuplicationStats {
1020                total_files: 2,
1021                files_with_clones: 2,
1022                total_lines: 50,
1023                duplicated_lines: 20,
1024                total_tokens: 100,
1025                duplicated_tokens: 100,
1026                clone_groups: 1,
1027                clone_instances: 2,
1028                duplication_percentage: 40.0,
1029                clone_groups_below_min_occurrences: 0,
1030            },
1031        };
1032        let trace = trace_clone(&report, Path::new("/project"), "src/a.ts", 5);
1033        let matched = trace.matched_instance.as_ref().expect("match expected");
1034        assert_eq!(matched.file, PathBuf::from("src/a.ts"));
1035        for group in &trace.clone_groups {
1036            for inst in &group.instances {
1037                let as_str = inst.file.to_string_lossy();
1038                assert!(
1039                    !as_str.starts_with('/'),
1040                    "instance file should be relative, got {as_str}",
1041                );
1042                assert!(
1043                    !as_str.contains(":\\") && !as_str.contains(":/"),
1044                    "instance file should not have a drive letter, got {as_str}",
1045                );
1046            }
1047        }
1048
1049        let json = serde_json::to_string(&trace).expect("serializes");
1050        assert!(
1051            !json.contains("\"/project/"),
1052            "serialized trace should not leak absolute paths: {json}",
1053        );
1054    }
1055
1056    /// Regression for the MCP e2e `trace_export` / `trace_file` Windows
1057    /// failures: the MCP layer passes forward-slashed user input
1058    /// (`src/utils.ts`) but `module_path` on Windows uses backslash
1059    /// separators (`D:\a\fallow\...\src\utils.ts`). The byte-level
1060    /// equality check missed every match. The helper now normalises
1061    /// both sides to forward slashes before comparing.
1062    #[test]
1063    fn path_matches_normalises_windows_module_path_against_posix_user_path() {
1064        let root = Path::new(r"D:\a\fallow\fallow\tests\fixtures\basic-project");
1065        let module_path =
1066            PathBuf::from(r"D:\a\fallow\fallow\tests\fixtures\basic-project\src\utils.ts");
1067        assert!(path_matches(&module_path, root, "src/utils.ts"));
1068        assert!(path_matches(&module_path, root, r"src\utils.ts"));
1069    }
1070
1071    #[test]
1072    fn path_matches_ends_with_fallback_handles_mixed_separators() {
1073        let root = Path::new("/some/other/root");
1074        let module_path =
1075            PathBuf::from(r"D:\a\fallow\fallow\tests\fixtures\basic-project\src\utils.ts");
1076        assert!(path_matches(&module_path, root, "src/utils.ts"));
1077    }
1078
1079    /// Regression for the MCP e2e trace_export / trace_file failures: even
1080    /// after `path_matches` correctly identified the file on Windows, the
1081    /// trace output struct's `file: PathBuf` field serialized the stored
1082    /// backslash-shaped path verbatim. JSON consumers (MCP agents, CI
1083    /// pipelines, the cross-platform trace_file assertion in
1084    /// `e2e_trace_file_returns_json`) expect forward-slash. Pin the
1085    /// contract via raw-string Windows-shaped `PathBuf::from` so the test
1086    /// runs cross-platform.
1087    #[test]
1088    fn export_trace_serializes_windows_path_with_forward_slashes() {
1089        let trace = ExportTrace {
1090            file: PathBuf::from(r"src\utils.ts"),
1091            export_name: "foo".to_string(),
1092            file_reachable: true,
1093            is_entry_point: false,
1094            is_used: true,
1095            direct_references: vec![ExportReference {
1096                from_file: PathBuf::from(r"src\entry.ts"),
1097                kind: "named import".to_string(),
1098            }],
1099            re_export_chains: vec![ReExportChain {
1100                barrel_file: PathBuf::from(r"src\index.ts"),
1101                exported_as: "foo".to_string(),
1102                reference_count: 1,
1103            }],
1104            reason: "ok".to_string(),
1105        };
1106        let json = serde_json::to_string(&trace).expect("serializes");
1107        assert!(
1108            json.contains("\"file\":\"src/utils.ts\""),
1109            "ExportTrace.file must serialize with forward slashes: {json}"
1110        );
1111        assert!(
1112            json.contains("\"from_file\":\"src/entry.ts\""),
1113            "ExportReference.from_file must serialize with forward slashes: {json}"
1114        );
1115        assert!(
1116            json.contains("\"barrel_file\":\"src/index.ts\""),
1117            "ReExportChain.barrel_file must serialize with forward slashes: {json}"
1118        );
1119        assert!(
1120            !json.contains(r"\\"),
1121            "no backslash sequence should remain anywhere in the JSON: {json}"
1122        );
1123    }
1124
1125    #[test]
1126    fn file_trace_serializes_windows_paths_with_forward_slashes() {
1127        let trace = FileTrace {
1128            file: PathBuf::from(r"src\utils.ts"),
1129            is_reachable: true,
1130            is_entry_point: false,
1131            exports: vec![],
1132            imports_from: vec![PathBuf::from(r"src\helpers.ts")],
1133            imported_by: vec![PathBuf::from(r"src\entry.ts")],
1134            re_exports: vec![TracedReExport {
1135                source_file: PathBuf::from(r"src\source.ts"),
1136                imported_name: "foo".to_string(),
1137                exported_name: "foo".to_string(),
1138            }],
1139        };
1140        let json = serde_json::to_string(&trace).expect("serializes");
1141        assert!(json.contains("\"file\":\"src/utils.ts\""), "got {json}");
1142        assert!(
1143            json.contains("\"imports_from\":[\"src/helpers.ts\"]"),
1144            "got {json}"
1145        );
1146        assert!(
1147            json.contains("\"imported_by\":[\"src/entry.ts\"]"),
1148            "got {json}"
1149        );
1150        assert!(
1151            json.contains("\"source_file\":\"src/source.ts\""),
1152            "got {json}"
1153        );
1154        assert!(!json.contains(r"\\"), "no backslash should remain: {json}");
1155    }
1156}