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