Skip to main content

fallow_core/
trace.rs

1use std::path::{Path, PathBuf};
2
3use fallow_types::serde_path;
4use rustc_hash::FxHashSet;
5use serde::Serialize;
6
7use crate::duplicates::{CloneInstance, DuplicationReport};
8use crate::graph::{ModuleGraph, ReferenceKind};
9
10/// Match a user-provided file path against a module's actual path.
11///
12/// Handles monorepo scenarios where module paths may be canonicalized
13/// (symlinks resolved) while user-provided paths are not.
14fn path_matches(module_path: &Path, root: &Path, user_path: &str) -> bool {
15    // Normalise to forward slashes on both sides so user-supplied
16    // forward-slashed input (`src/utils.ts`, the shape MCP and most
17    // cross-platform tooling produces) matches Windows-shaped module
18    // paths (`D:\a\...\src\utils.ts`). Without this, the four byte-level
19    // string comparisons below all silently miss on Windows even though
20    // the file is in the graph. POSIX is a no-op.
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/// Result of tracing an export: why is it considered used or unused?
39#[derive(Debug, Serialize)]
40pub struct ExportTrace {
41    /// The file containing the export.
42    #[serde(serialize_with = "serde_path::serialize")]
43    pub file: PathBuf,
44    /// The export name being traced.
45    pub export_name: String,
46    /// Whether the file is reachable from an entry point.
47    pub file_reachable: bool,
48    /// Whether the file is an entry point.
49    pub is_entry_point: bool,
50    /// Whether the export is considered used.
51    pub is_used: bool,
52    /// Files that reference this export directly.
53    pub direct_references: Vec<ExportReference>,
54    /// Re-export chains that pass through this export.
55    pub re_export_chains: Vec<ReExportChain>,
56    /// Reason summary.
57    pub reason: String,
58}
59
60/// A direct reference to an export.
61#[derive(Debug, Serialize)]
62pub struct ExportReference {
63    #[serde(serialize_with = "serde_path::serialize")]
64    pub from_file: PathBuf,
65    pub kind: String,
66}
67
68/// A re-export chain showing how an export is propagated.
69#[derive(Debug, Serialize)]
70pub struct ReExportChain {
71    /// The barrel file that re-exports this symbol.
72    #[serde(serialize_with = "serde_path::serialize")]
73    pub barrel_file: PathBuf,
74    /// The name it's re-exported as.
75    pub exported_as: String,
76    /// Number of references on the barrel's re-exported symbol.
77    pub reference_count: usize,
78}
79
80/// Result of tracing all edges for a file.
81#[derive(Debug, Serialize)]
82pub struct FileTrace {
83    /// The traced file.
84    #[serde(serialize_with = "serde_path::serialize")]
85    pub file: PathBuf,
86    /// Whether this file is reachable from entry points.
87    pub is_reachable: bool,
88    /// Whether this file is an entry point.
89    pub is_entry_point: bool,
90    /// Exports declared by this file.
91    pub exports: Vec<TracedExport>,
92    /// Files that this file imports from.
93    #[serde(serialize_with = "serde_path::serialize_vec")]
94    pub imports_from: Vec<PathBuf>,
95    /// Files that import from this file.
96    #[serde(serialize_with = "serde_path::serialize_vec")]
97    pub imported_by: Vec<PathBuf>,
98    /// Re-exports declared by this file.
99    pub re_exports: Vec<TracedReExport>,
100}
101
102/// An export with its usage info.
103#[derive(Debug, Serialize)]
104pub struct TracedExport {
105    pub name: String,
106    pub is_type_only: bool,
107    pub reference_count: usize,
108    pub referenced_by: Vec<ExportReference>,
109}
110
111/// A re-export with source info.
112#[derive(Debug, Serialize)]
113pub struct TracedReExport {
114    #[serde(serialize_with = "serde_path::serialize")]
115    pub source_file: PathBuf,
116    pub imported_name: String,
117    pub exported_name: String,
118}
119
120/// Result of tracing a dependency: where is it used?
121#[derive(Debug, Serialize)]
122pub struct DependencyTrace {
123    /// The dependency name being traced.
124    pub package_name: String,
125    /// Files that import this dependency.
126    #[serde(serialize_with = "serde_path::serialize_vec")]
127    pub imported_by: Vec<PathBuf>,
128    /// Files that import this dependency with type-only imports.
129    #[serde(serialize_with = "serde_path::serialize_vec")]
130    pub type_only_imported_by: Vec<PathBuf>,
131    /// Whether the dependency is invoked from package.json scripts or CI configs
132    /// (e.g., `microbundle build`, `vitest run` in `scripts`, or binary names in
133    /// `.github/workflows/*.yml` / `.gitlab-ci.yml`). Mirrors how the unused-deps
134    /// detector classifies tooling usage so trace output stays consistent with it.
135    pub used_in_scripts: bool,
136    /// Whether the dependency is used at all (imports OR script/CI invocations).
137    pub is_used: bool,
138    /// Total import count.
139    pub import_count: usize,
140}
141
142/// Pipeline performance timings.
143#[derive(Debug, Clone, Serialize)]
144pub struct PipelineTimings {
145    pub discover_files_ms: f64,
146    pub file_count: usize,
147    pub workspaces_ms: f64,
148    pub workspace_count: usize,
149    pub plugins_ms: f64,
150    pub script_analysis_ms: f64,
151    pub parse_extract_ms: f64,
152    /// Summed wall-clock time of the actual AST parses across all rayon
153    /// workers (the parse stage's CPU cost). `parse_extract_ms` is the
154    /// stage's wall-clock time; this is the work done in parallel within it.
155    /// Observational and non-deterministic (varies run to run); do not assert
156    /// against it.
157    pub parse_cpu_ms: f64,
158    pub module_count: usize,
159    /// Number of files whose parse results were loaded from cache (skipped parsing).
160    pub cache_hits: usize,
161    /// Number of files that required a full parse (new or changed content).
162    pub cache_misses: usize,
163    pub cache_update_ms: f64,
164    pub entry_points_ms: f64,
165    pub entry_point_count: usize,
166    pub resolve_imports_ms: f64,
167    pub build_graph_ms: f64,
168    pub analyze_ms: f64,
169    #[serde(skip_serializing_if = "Option::is_none")]
170    pub duplication_ms: Option<f64>,
171    pub total_ms: f64,
172}
173
174/// Trace why an export is considered used or unused.
175#[must_use]
176pub fn trace_export(
177    graph: &ModuleGraph,
178    root: &Path,
179    file_path: &str,
180    export_name: &str,
181) -> Option<ExportTrace> {
182    // Find the file in the graph
183    let module = graph
184        .modules
185        .iter()
186        .find(|m| path_matches(&m.path, root, file_path))?;
187
188    // Find the export
189    let export = module.exports.iter().find(|e| {
190        let name_str = e.name.to_string();
191        name_str == export_name || (export_name == "default" && name_str == "default")
192    })?;
193
194    let direct_references: Vec<ExportReference> = export
195        .references
196        .iter()
197        .map(|r| {
198            let from_path = graph.modules.get(r.from_file.0 as usize).map_or_else(
199                || PathBuf::from(format!("<unknown:{}>", r.from_file.0)),
200                |m| m.path.strip_prefix(root).unwrap_or(&m.path).to_path_buf(),
201            );
202            ExportReference {
203                from_file: from_path,
204                kind: format_reference_kind(r.kind),
205            }
206        })
207        .collect();
208
209    // Find re-export chains involving this export
210    let re_export_chains: Vec<ReExportChain> = graph
211        .modules
212        .iter()
213        .flat_map(|m| {
214            m.re_exports
215                .iter()
216                .filter(|re| {
217                    re.source_file == module.file_id
218                        && (re.imported_name == export_name || re.imported_name == "*")
219                })
220                .map(|re| {
221                    let barrel_export = m.exports.iter().find(|e| {
222                        if re.exported_name == "*" {
223                            e.name.to_string() == export_name
224                        } else {
225                            e.name.to_string() == re.exported_name
226                        }
227                    });
228                    ReExportChain {
229                        barrel_file: m.path.strip_prefix(root).unwrap_or(&m.path).to_path_buf(),
230                        exported_as: re.exported_name.clone(),
231                        reference_count: barrel_export.map_or(0, |e| e.references.len()),
232                    }
233                })
234        })
235        .collect();
236
237    let is_used = !export.references.is_empty();
238    let reason = if !module.is_reachable() {
239        "File is unreachable from any entry point".to_string()
240    } else if is_used {
241        format!(
242            "Used by {} file(s){}",
243            export.references.len(),
244            if re_export_chains.is_empty() {
245                String::new()
246            } else {
247                format!(", re-exported through {} barrel(s)", re_export_chains.len())
248            }
249        )
250    } else if module.is_entry_point() {
251        "No internal references, but file is an entry point (export is externally accessible)"
252            .to_string()
253    } else if !re_export_chains.is_empty() {
254        format!(
255            "Re-exported through {} barrel(s) but no consumer imports it through the barrel",
256            re_export_chains.len()
257        )
258    } else {
259        "No references found, export is unused".to_string()
260    };
261
262    Some(ExportTrace {
263        file: module
264            .path
265            .strip_prefix(root)
266            .unwrap_or(&module.path)
267            .to_path_buf(),
268        export_name: export_name.to_string(),
269        file_reachable: module.is_reachable(),
270        is_entry_point: module.is_entry_point(),
271        is_used,
272        direct_references,
273        re_export_chains,
274        reason,
275    })
276}
277
278/// Trace all edges for a file.
279#[must_use]
280pub fn trace_file(graph: &ModuleGraph, root: &Path, file_path: &str) -> Option<FileTrace> {
281    let module = graph
282        .modules
283        .iter()
284        .find(|m| path_matches(&m.path, root, file_path))?;
285
286    let exports: Vec<TracedExport> = module
287        .exports
288        .iter()
289        .map(|e| TracedExport {
290            name: e.name.to_string(),
291            is_type_only: e.is_type_only,
292            reference_count: e.references.len(),
293            referenced_by: e
294                .references
295                .iter()
296                .map(|r| {
297                    let from_path = graph.modules.get(r.from_file.0 as usize).map_or_else(
298                        || PathBuf::from(format!("<unknown:{}>", r.from_file.0)),
299                        |m| m.path.strip_prefix(root).unwrap_or(&m.path).to_path_buf(),
300                    );
301                    ExportReference {
302                        from_file: from_path,
303                        kind: format_reference_kind(r.kind),
304                    }
305                })
306                .collect(),
307        })
308        .collect();
309
310    // Edges FROM this file (what it imports)
311    let imports_from: Vec<PathBuf> = graph
312        .edges_for(module.file_id)
313        .iter()
314        .filter_map(|target_id| {
315            graph
316                .modules
317                .get(target_id.0 as usize)
318                .map(|m| m.path.strip_prefix(root).unwrap_or(&m.path).to_path_buf())
319        })
320        .collect();
321
322    // Reverse deps: who imports this file
323    let imported_by: Vec<PathBuf> = graph
324        .reverse_deps
325        .get(module.file_id.0 as usize)
326        .map(|deps| {
327            deps.iter()
328                .filter_map(|fid| {
329                    graph
330                        .modules
331                        .get(fid.0 as usize)
332                        .map(|m| m.path.strip_prefix(root).unwrap_or(&m.path).to_path_buf())
333                })
334                .collect()
335        })
336        .unwrap_or_default();
337
338    let re_exports: Vec<TracedReExport> = module
339        .re_exports
340        .iter()
341        .map(|re| {
342            let source_path = graph.modules.get(re.source_file.0 as usize).map_or_else(
343                || PathBuf::from(format!("<unknown:{}>", re.source_file.0)),
344                |m| m.path.strip_prefix(root).unwrap_or(&m.path).to_path_buf(),
345            );
346            TracedReExport {
347                source_file: source_path,
348                imported_name: re.imported_name.clone(),
349                exported_name: re.exported_name.clone(),
350            }
351        })
352        .collect();
353
354    Some(FileTrace {
355        file: module
356            .path
357            .strip_prefix(root)
358            .unwrap_or(&module.path)
359            .to_path_buf(),
360        is_reachable: module.is_reachable(),
361        is_entry_point: module.is_entry_point(),
362        exports,
363        imports_from,
364        imported_by,
365        re_exports,
366    })
367}
368
369/// Trace where a dependency is used.
370///
371/// `script_used_packages` carries the package names recorded as binary invocations
372/// in package.json scripts (`build: microbundle ...`) and CI configs
373/// (`.github/workflows/*.yml`, `.gitlab-ci.yml`). The same set the unused-deps
374/// detector consults; passing it in lets the trace output match the detector's
375/// view of "used" instead of reporting `is_used=false` for tools invoked only
376/// through scripts.
377#[expect(
378    clippy::implicit_hasher,
379    reason = "fallow standardizes on FxHashSet across the workspace"
380)]
381#[must_use]
382pub fn trace_dependency(
383    graph: &ModuleGraph,
384    root: &Path,
385    package_name: &str,
386    script_used_packages: &FxHashSet<String>,
387) -> DependencyTrace {
388    let imported_by: Vec<PathBuf> = graph
389        .package_usage
390        .get(package_name)
391        .map(|ids| {
392            ids.iter()
393                .filter_map(|fid| {
394                    graph
395                        .modules
396                        .get(fid.0 as usize)
397                        .map(|m| m.path.strip_prefix(root).unwrap_or(&m.path).to_path_buf())
398                })
399                .collect()
400        })
401        .unwrap_or_default();
402
403    let type_only_imported_by: Vec<PathBuf> = graph
404        .type_only_package_usage
405        .get(package_name)
406        .map(|ids| {
407            ids.iter()
408                .filter_map(|fid| {
409                    graph
410                        .modules
411                        .get(fid.0 as usize)
412                        .map(|m| m.path.strip_prefix(root).unwrap_or(&m.path).to_path_buf())
413                })
414                .collect()
415        })
416        .unwrap_or_default();
417
418    let import_count = imported_by.len();
419    let used_in_scripts = script_used_packages.contains(package_name);
420    DependencyTrace {
421        package_name: package_name.to_string(),
422        imported_by,
423        type_only_imported_by,
424        used_in_scripts,
425        is_used: import_count > 0 || used_in_scripts,
426        import_count,
427    }
428}
429
430fn format_reference_kind(kind: ReferenceKind) -> String {
431    match kind {
432        ReferenceKind::NamedImport => "named import".to_string(),
433        ReferenceKind::DefaultImport => "default import".to_string(),
434        ReferenceKind::NamespaceImport => "namespace import".to_string(),
435        ReferenceKind::ReExport => "re-export".to_string(),
436        ReferenceKind::DynamicImport => "dynamic import".to_string(),
437        ReferenceKind::SideEffectImport => "side-effect import".to_string(),
438    }
439}
440
441/// Result of tracing a clone: all groups containing the code at a given location.
442#[derive(Debug, Serialize)]
443pub struct CloneTrace {
444    #[serde(serialize_with = "serde_path::serialize")]
445    pub file: PathBuf,
446    pub line: usize,
447    pub matched_instance: Option<CloneInstance>,
448    pub clone_groups: Vec<TracedCloneGroup>,
449}
450
451#[derive(Debug, Serialize)]
452pub struct TracedCloneGroup {
453    pub token_count: usize,
454    pub line_count: usize,
455    pub instances: Vec<CloneInstance>,
456}
457
458#[must_use]
459pub fn trace_clone(
460    report: &DuplicationReport,
461    root: &Path,
462    file_path: &str,
463    line: usize,
464) -> CloneTrace {
465    let resolved = root.join(file_path);
466    let mut matched_instance = None;
467    let mut clone_groups = Vec::new();
468
469    for group in &report.clone_groups {
470        let matching = group.instances.iter().find(|inst| {
471            let inst_matches = inst.file == resolved
472                || inst.file.strip_prefix(root).unwrap_or(&inst.file) == Path::new(file_path);
473            inst_matches && inst.start_line <= line && line <= inst.end_line
474        });
475
476        if let Some(matched) = matching {
477            if matched_instance.is_none() {
478                matched_instance = Some(relativize_instance(matched, root));
479            }
480            clone_groups.push(TracedCloneGroup {
481                token_count: group.token_count,
482                line_count: group.line_count,
483                instances: group
484                    .instances
485                    .iter()
486                    .map(|inst| relativize_instance(inst, root))
487                    .collect(),
488            });
489        }
490    }
491
492    CloneTrace {
493        file: PathBuf::from(file_path),
494        line,
495        matched_instance,
496        clone_groups,
497    }
498}
499
500/// Return a copy of `inst` with `file` rewritten relative to `root` (forward-slash normalized
501/// for cross-platform JSON parity with `serde_path::serialize`). If `inst.file` is already
502/// outside `root`, the path is left unchanged.
503fn relativize_instance(inst: &CloneInstance, root: &Path) -> CloneInstance {
504    let rel = inst.file.strip_prefix(root).map_or_else(
505        |_| inst.file.clone(),
506        |p| PathBuf::from(p.to_string_lossy().replace('\\', "/")),
507    );
508    CloneInstance {
509        file: rel,
510        ..inst.clone()
511    }
512}
513
514#[cfg(test)]
515mod tests {
516    use super::*;
517
518    use crate::discover::{DiscoveredFile, EntryPoint, EntryPointSource, FileId};
519    use crate::extract::{ExportInfo, ExportName, ImportInfo, ImportedName, VisibilityTag};
520    use crate::resolve::{ResolveResult, ResolvedImport, ResolvedModule};
521
522    fn build_test_graph() -> ModuleGraph {
523        let files = vec![
524            DiscoveredFile {
525                id: FileId(0),
526                path: PathBuf::from("/project/src/entry.ts"),
527                size_bytes: 100,
528            },
529            DiscoveredFile {
530                id: FileId(1),
531                path: PathBuf::from("/project/src/utils.ts"),
532                size_bytes: 50,
533            },
534            DiscoveredFile {
535                id: FileId(2),
536                path: PathBuf::from("/project/src/unused.ts"),
537                size_bytes: 30,
538            },
539        ];
540
541        let entry_points = vec![EntryPoint {
542            path: PathBuf::from("/project/src/entry.ts"),
543            source: EntryPointSource::PackageJsonMain,
544        }];
545
546        let resolved_modules = vec![
547            ResolvedModule {
548                file_id: FileId(0),
549                path: PathBuf::from("/project/src/entry.ts"),
550                resolved_imports: vec![ResolvedImport {
551                    info: ImportInfo {
552                        source: "./utils".to_string(),
553                        imported_name: ImportedName::Named("foo".to_string()),
554                        local_name: "foo".to_string(),
555                        is_type_only: false,
556                        from_style: false,
557                        span: oxc_span::Span::new(0, 10),
558                        source_span: oxc_span::Span::default(),
559                    },
560                    target: ResolveResult::InternalModule(FileId(1)),
561                }],
562                ..Default::default()
563            },
564            ResolvedModule {
565                file_id: FileId(1),
566                path: PathBuf::from("/project/src/utils.ts"),
567                exports: vec![
568                    ExportInfo {
569                        name: ExportName::Named("foo".to_string()),
570                        local_name: Some("foo".to_string()),
571                        is_type_only: false,
572                        visibility: VisibilityTag::None,
573                        span: oxc_span::Span::new(0, 20),
574                        members: vec![],
575                        is_side_effect_used: false,
576                        super_class: None,
577                    },
578                    ExportInfo {
579                        name: ExportName::Named("bar".to_string()),
580                        local_name: Some("bar".to_string()),
581                        is_type_only: false,
582                        visibility: VisibilityTag::None,
583                        span: oxc_span::Span::new(21, 40),
584                        members: vec![],
585                        is_side_effect_used: false,
586                        super_class: None,
587                    },
588                ],
589                ..Default::default()
590            },
591            ResolvedModule {
592                file_id: FileId(2),
593                path: PathBuf::from("/project/src/unused.ts"),
594                exports: vec![ExportInfo {
595                    name: ExportName::Named("baz".to_string()),
596                    local_name: Some("baz".to_string()),
597                    is_type_only: false,
598                    visibility: VisibilityTag::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        // Build a graph with npm package usage
707        let files = vec![DiscoveredFile {
708            id: FileId(0),
709            path: PathBuf::from("/project/src/app.ts"),
710            size_bytes: 100,
711        }];
712        let entry_points = vec![EntryPoint {
713            path: PathBuf::from("/project/src/app.ts"),
714            source: EntryPointSource::PackageJsonMain,
715        }];
716        let resolved_modules = vec![ResolvedModule {
717            file_id: FileId(0),
718            path: PathBuf::from("/project/src/app.ts"),
719            resolved_imports: vec![ResolvedImport {
720                info: ImportInfo {
721                    source: "lodash".to_string(),
722                    imported_name: ImportedName::Named("get".to_string()),
723                    local_name: "get".to_string(),
724                    is_type_only: false,
725                    from_style: false,
726                    span: oxc_span::Span::new(0, 10),
727                    source_span: oxc_span::Span::default(),
728                },
729                target: ResolveResult::NpmPackage("lodash".to_string()),
730            }],
731            ..Default::default()
732        }];
733
734        let graph = ModuleGraph::build(&resolved_modules, &entry_points, &files);
735        let root = Path::new("/project");
736
737        let trace = trace_dependency(&graph, root, "lodash", &FxHashSet::default());
738        assert!(trace.is_used);
739        assert!(!trace.used_in_scripts);
740        assert_eq!(trace.import_count, 1);
741        assert_eq!(trace.imported_by[0], PathBuf::from("src/app.ts"));
742    }
743
744    #[test]
745    fn trace_dependency_unused() {
746        let files = vec![DiscoveredFile {
747            id: FileId(0),
748            path: PathBuf::from("/project/src/app.ts"),
749            size_bytes: 100,
750        }];
751        let entry_points = vec![EntryPoint {
752            path: PathBuf::from("/project/src/app.ts"),
753            source: EntryPointSource::PackageJsonMain,
754        }];
755        let resolved_modules = vec![ResolvedModule {
756            file_id: FileId(0),
757            path: PathBuf::from("/project/src/app.ts"),
758            ..Default::default()
759        }];
760
761        let graph = ModuleGraph::build(&resolved_modules, &entry_points, &files);
762        let root = Path::new("/project");
763
764        let trace = trace_dependency(&graph, root, "nonexistent-pkg", &FxHashSet::default());
765        assert!(!trace.is_used);
766        assert!(!trace.used_in_scripts);
767        assert_eq!(trace.import_count, 0);
768        assert!(trace.imported_by.is_empty());
769    }
770
771    #[test]
772    fn trace_dependency_used_only_in_scripts() {
773        let files = vec![DiscoveredFile {
774            id: FileId(0),
775            path: PathBuf::from("/project/src/app.ts"),
776            size_bytes: 100,
777        }];
778        let entry_points = vec![EntryPoint {
779            path: PathBuf::from("/project/src/app.ts"),
780            source: EntryPointSource::PackageJsonMain,
781        }];
782        let resolved_modules = vec![ResolvedModule {
783            file_id: FileId(0),
784            path: PathBuf::from("/project/src/app.ts"),
785            ..Default::default()
786        }];
787
788        let graph = ModuleGraph::build(&resolved_modules, &entry_points, &files);
789        let root = Path::new("/project");
790        let mut script_used = FxHashSet::default();
791        script_used.insert("microbundle".to_string());
792
793        let trace = trace_dependency(&graph, root, "microbundle", &script_used);
794        assert!(
795            trace.is_used,
796            "is_used must be true when the package is referenced from package.json scripts"
797        );
798        assert!(trace.used_in_scripts);
799        assert_eq!(trace.import_count, 0);
800        assert!(trace.imported_by.is_empty());
801    }
802
803    #[test]
804    fn trace_clone_finds_matching_group() {
805        use crate::duplicates::{CloneGroup, CloneInstance, DuplicationReport, DuplicationStats};
806        let report = DuplicationReport {
807            clone_groups: vec![CloneGroup {
808                instances: vec![
809                    CloneInstance {
810                        file: PathBuf::from("/project/src/a.ts"),
811                        start_line: 10,
812                        end_line: 20,
813                        start_col: 0,
814                        end_col: 0,
815                        fragment: "fn foo() {}".to_string(),
816                    },
817                    CloneInstance {
818                        file: PathBuf::from("/project/src/b.ts"),
819                        start_line: 5,
820                        end_line: 15,
821                        start_col: 0,
822                        end_col: 0,
823                        fragment: "fn foo() {}".to_string(),
824                    },
825                ],
826                token_count: 60,
827                line_count: 11,
828            }],
829            clone_families: vec![],
830            mirrored_directories: vec![],
831            stats: DuplicationStats {
832                total_files: 2,
833                files_with_clones: 2,
834                total_lines: 100,
835                duplicated_lines: 22,
836                total_tokens: 200,
837                duplicated_tokens: 120,
838                clone_groups: 1,
839                clone_instances: 2,
840                duplication_percentage: 22.0,
841                clone_groups_below_min_occurrences: 0,
842            },
843        };
844        let trace = trace_clone(&report, Path::new("/project"), "src/a.ts", 15);
845        assert!(trace.matched_instance.is_some());
846        assert_eq!(trace.clone_groups.len(), 1);
847        assert_eq!(trace.clone_groups[0].instances.len(), 2);
848    }
849
850    #[test]
851    fn trace_clone_no_match() {
852        use crate::duplicates::{CloneGroup, CloneInstance, DuplicationReport, DuplicationStats};
853        let report = DuplicationReport {
854            clone_groups: vec![CloneGroup {
855                instances: vec![CloneInstance {
856                    file: PathBuf::from("/project/src/a.ts"),
857                    start_line: 10,
858                    end_line: 20,
859                    start_col: 0,
860                    end_col: 0,
861                    fragment: "fn foo() {}".to_string(),
862                }],
863                token_count: 60,
864                line_count: 11,
865            }],
866            clone_families: vec![],
867            mirrored_directories: vec![],
868            stats: DuplicationStats {
869                total_files: 1,
870                files_with_clones: 1,
871                total_lines: 50,
872                duplicated_lines: 11,
873                total_tokens: 100,
874                duplicated_tokens: 60,
875                clone_groups: 1,
876                clone_instances: 1,
877                duplication_percentage: 22.0,
878                clone_groups_below_min_occurrences: 0,
879            },
880        };
881        let trace = trace_clone(&report, Path::new("/project"), "src/a.ts", 25);
882        assert!(trace.matched_instance.is_none());
883        assert!(trace.clone_groups.is_empty());
884    }
885
886    #[test]
887    fn trace_clone_line_boundary() {
888        use crate::duplicates::{CloneGroup, CloneInstance, DuplicationReport, DuplicationStats};
889        let report = DuplicationReport {
890            clone_groups: vec![CloneGroup {
891                instances: vec![
892                    CloneInstance {
893                        file: PathBuf::from("/project/src/a.ts"),
894                        start_line: 10,
895                        end_line: 20,
896                        start_col: 0,
897                        end_col: 0,
898                        fragment: "code".to_string(),
899                    },
900                    CloneInstance {
901                        file: PathBuf::from("/project/src/b.ts"),
902                        start_line: 1,
903                        end_line: 11,
904                        start_col: 0,
905                        end_col: 0,
906                        fragment: "code".to_string(),
907                    },
908                ],
909                token_count: 50,
910                line_count: 11,
911            }],
912            clone_families: vec![],
913            mirrored_directories: vec![],
914            stats: DuplicationStats {
915                total_files: 2,
916                files_with_clones: 2,
917                total_lines: 100,
918                duplicated_lines: 22,
919                total_tokens: 200,
920                duplicated_tokens: 100,
921                clone_groups: 1,
922                clone_instances: 2,
923                duplication_percentage: 22.0,
924                clone_groups_below_min_occurrences: 0,
925            },
926        };
927        let root = Path::new("/project");
928        assert!(
929            trace_clone(&report, root, "src/a.ts", 10)
930                .matched_instance
931                .is_some()
932        );
933        assert!(
934            trace_clone(&report, root, "src/a.ts", 20)
935                .matched_instance
936                .is_some()
937        );
938        assert!(
939            trace_clone(&report, root, "src/a.ts", 21)
940                .matched_instance
941                .is_none()
942        );
943    }
944
945    #[test]
946    fn trace_clone_returns_relative_instance_paths() {
947        use crate::duplicates::{CloneGroup, CloneInstance, DuplicationReport, DuplicationStats};
948        let report = DuplicationReport {
949            clone_groups: vec![CloneGroup {
950                instances: vec![
951                    CloneInstance {
952                        file: PathBuf::from("/project/src/a.ts"),
953                        start_line: 1,
954                        end_line: 10,
955                        start_col: 0,
956                        end_col: 0,
957                        fragment: "code".to_string(),
958                    },
959                    CloneInstance {
960                        file: PathBuf::from("/project/src/b.ts"),
961                        start_line: 1,
962                        end_line: 10,
963                        start_col: 0,
964                        end_col: 0,
965                        fragment: "code".to_string(),
966                    },
967                ],
968                token_count: 50,
969                line_count: 10,
970            }],
971            clone_families: vec![],
972            mirrored_directories: vec![],
973            stats: DuplicationStats {
974                total_files: 2,
975                files_with_clones: 2,
976                total_lines: 50,
977                duplicated_lines: 20,
978                total_tokens: 100,
979                duplicated_tokens: 100,
980                clone_groups: 1,
981                clone_instances: 2,
982                duplication_percentage: 40.0,
983                clone_groups_below_min_occurrences: 0,
984            },
985        };
986        let trace = trace_clone(&report, Path::new("/project"), "src/a.ts", 5);
987        let matched = trace.matched_instance.as_ref().expect("match expected");
988        assert_eq!(matched.file, PathBuf::from("src/a.ts"));
989        for group in &trace.clone_groups {
990            for inst in &group.instances {
991                let as_str = inst.file.to_string_lossy();
992                assert!(
993                    !as_str.starts_with('/'),
994                    "instance file should be relative, got {as_str}",
995                );
996                assert!(
997                    !as_str.contains(":\\") && !as_str.contains(":/"),
998                    "instance file should not have a drive letter, got {as_str}",
999                );
1000            }
1001        }
1002
1003        let json = serde_json::to_string(&trace).expect("serializes");
1004        assert!(
1005            !json.contains("\"/project/"),
1006            "serialized trace should not leak absolute paths: {json}",
1007        );
1008    }
1009
1010    /// Regression for the MCP e2e `trace_export` / `trace_file` Windows
1011    /// failures: the MCP layer passes forward-slashed user input
1012    /// (`src/utils.ts`) but `module_path` on Windows uses backslash
1013    /// separators (`D:\a\fallow\...\src\utils.ts`). The byte-level
1014    /// equality check missed every match. The helper now normalises
1015    /// both sides to forward slashes before comparing.
1016    #[test]
1017    fn path_matches_normalises_windows_module_path_against_posix_user_path() {
1018        let root = Path::new(r"D:\a\fallow\fallow\tests\fixtures\basic-project");
1019        let module_path =
1020            PathBuf::from(r"D:\a\fallow\fallow\tests\fixtures\basic-project\src\utils.ts");
1021        // user_path uses forward slashes (the shape MCP and other
1022        // cross-platform tooling emit), but the stored path uses
1023        // Windows backslashes. The helper should still match.
1024        assert!(path_matches(&module_path, root, "src/utils.ts"));
1025        assert!(path_matches(&module_path, root, r"src\utils.ts"));
1026    }
1027
1028    #[test]
1029    fn path_matches_ends_with_fallback_handles_mixed_separators() {
1030        let root = Path::new("/some/other/root");
1031        let module_path =
1032            PathBuf::from(r"D:\a\fallow\fallow\tests\fixtures\basic-project\src\utils.ts");
1033        // root does not prefix module_path; the ends_with("/src/utils.ts")
1034        // fallback should still match once both sides are forward-slashed.
1035        assert!(path_matches(&module_path, root, "src/utils.ts"));
1036    }
1037
1038    /// Regression for the MCP e2e trace_export / trace_file failures: even
1039    /// after `path_matches` correctly identified the file on Windows, the
1040    /// trace output struct's `file: PathBuf` field serialized the stored
1041    /// backslash-shaped path verbatim. JSON consumers (MCP agents, CI
1042    /// pipelines, the cross-platform trace_file assertion in
1043    /// `e2e_trace_file_returns_json`) expect forward-slash. Pin the
1044    /// contract via raw-string Windows-shaped `PathBuf::from` so the test
1045    /// runs cross-platform.
1046    #[test]
1047    fn export_trace_serializes_windows_path_with_forward_slashes() {
1048        let trace = ExportTrace {
1049            file: PathBuf::from(r"src\utils.ts"),
1050            export_name: "foo".to_string(),
1051            file_reachable: true,
1052            is_entry_point: false,
1053            is_used: true,
1054            direct_references: vec![ExportReference {
1055                from_file: PathBuf::from(r"src\entry.ts"),
1056                kind: "named import".to_string(),
1057            }],
1058            re_export_chains: vec![ReExportChain {
1059                barrel_file: PathBuf::from(r"src\index.ts"),
1060                exported_as: "foo".to_string(),
1061                reference_count: 1,
1062            }],
1063            reason: "ok".to_string(),
1064        };
1065        let json = serde_json::to_string(&trace).expect("serializes");
1066        assert!(
1067            json.contains("\"file\":\"src/utils.ts\""),
1068            "ExportTrace.file must serialize with forward slashes: {json}"
1069        );
1070        assert!(
1071            json.contains("\"from_file\":\"src/entry.ts\""),
1072            "ExportReference.from_file must serialize with forward slashes: {json}"
1073        );
1074        assert!(
1075            json.contains("\"barrel_file\":\"src/index.ts\""),
1076            "ReExportChain.barrel_file must serialize with forward slashes: {json}"
1077        );
1078        assert!(
1079            !json.contains(r"\\"),
1080            "no backslash sequence should remain anywhere in the JSON: {json}"
1081        );
1082    }
1083
1084    #[test]
1085    fn file_trace_serializes_windows_paths_with_forward_slashes() {
1086        let trace = FileTrace {
1087            file: PathBuf::from(r"src\utils.ts"),
1088            is_reachable: true,
1089            is_entry_point: false,
1090            exports: vec![],
1091            imports_from: vec![PathBuf::from(r"src\helpers.ts")],
1092            imported_by: vec![PathBuf::from(r"src\entry.ts")],
1093            re_exports: vec![TracedReExport {
1094                source_file: PathBuf::from(r"src\source.ts"),
1095                imported_name: "foo".to_string(),
1096                exported_name: "foo".to_string(),
1097            }],
1098        };
1099        let json = serde_json::to_string(&trace).expect("serializes");
1100        assert!(json.contains("\"file\":\"src/utils.ts\""), "got {json}");
1101        assert!(
1102            json.contains("\"imports_from\":[\"src/helpers.ts\"]"),
1103            "got {json}"
1104        );
1105        assert!(
1106            json.contains("\"imported_by\":[\"src/entry.ts\"]"),
1107            "got {json}"
1108        );
1109        assert!(
1110            json.contains("\"source_file\":\"src/source.ts\""),
1111            "got {json}"
1112        );
1113        assert!(!json.contains(r"\\"), "no backslash should remain: {json}");
1114    }
1115}