Skip to main content

fallow_core/
trace.rs

1use std::path::{Path, PathBuf};
2
3use rustc_hash::FxHashSet;
4use serde::Serialize;
5
6use crate::duplicates::{CloneInstance, DuplicationReport};
7use crate::graph::{ModuleGraph, ReferenceKind};
8
9/// Match a user-provided file path against a module's actual path.
10///
11/// Handles monorepo scenarios where module paths may be canonicalized
12/// (symlinks resolved) while user-provided paths are not.
13fn path_matches(module_path: &Path, root: &Path, user_path: &str) -> bool {
14    let rel = module_path.strip_prefix(root).unwrap_or(module_path);
15    let rel_str = rel.to_string_lossy();
16    if rel_str == user_path || module_path.to_string_lossy() == user_path {
17        return true;
18    }
19    if dunce::canonicalize(root).is_ok_and(|canonical_root| {
20        module_path
21            .strip_prefix(&canonical_root)
22            .is_ok_and(|rel| rel.to_string_lossy() == user_path)
23    }) {
24        return true;
25    }
26    let module_str = module_path.to_string_lossy();
27    module_str.ends_with(&format!("/{user_path}"))
28}
29
30/// Result of tracing an export: why is it considered used or unused?
31#[derive(Debug, Serialize)]
32pub struct ExportTrace {
33    /// The file containing the export.
34    pub file: PathBuf,
35    /// The export name being traced.
36    pub export_name: String,
37    /// Whether the file is reachable from an entry point.
38    pub file_reachable: bool,
39    /// Whether the file is an entry point.
40    pub is_entry_point: bool,
41    /// Whether the export is considered used.
42    pub is_used: bool,
43    /// Files that reference this export directly.
44    pub direct_references: Vec<ExportReference>,
45    /// Re-export chains that pass through this export.
46    pub re_export_chains: Vec<ReExportChain>,
47    /// Reason summary.
48    pub reason: String,
49}
50
51/// A direct reference to an export.
52#[derive(Debug, Serialize)]
53pub struct ExportReference {
54    pub from_file: PathBuf,
55    pub kind: String,
56}
57
58/// A re-export chain showing how an export is propagated.
59#[derive(Debug, Serialize)]
60pub struct ReExportChain {
61    /// The barrel file that re-exports this symbol.
62    pub barrel_file: PathBuf,
63    /// The name it's re-exported as.
64    pub exported_as: String,
65    /// Number of references on the barrel's re-exported symbol.
66    pub reference_count: usize,
67}
68
69/// Result of tracing all edges for a file.
70#[derive(Debug, Serialize)]
71pub struct FileTrace {
72    /// The traced file.
73    pub file: PathBuf,
74    /// Whether this file is reachable from entry points.
75    pub is_reachable: bool,
76    /// Whether this file is an entry point.
77    pub is_entry_point: bool,
78    /// Exports declared by this file.
79    pub exports: Vec<TracedExport>,
80    /// Files that this file imports from.
81    pub imports_from: Vec<PathBuf>,
82    /// Files that import from this file.
83    pub imported_by: Vec<PathBuf>,
84    /// Re-exports declared by this file.
85    pub re_exports: Vec<TracedReExport>,
86}
87
88/// An export with its usage info.
89#[derive(Debug, Serialize)]
90pub struct TracedExport {
91    pub name: String,
92    pub is_type_only: bool,
93    pub reference_count: usize,
94    pub referenced_by: Vec<ExportReference>,
95}
96
97/// A re-export with source info.
98#[derive(Debug, Serialize)]
99pub struct TracedReExport {
100    pub source_file: PathBuf,
101    pub imported_name: String,
102    pub exported_name: String,
103}
104
105/// Result of tracing a dependency: where is it used?
106#[derive(Debug, Serialize)]
107pub struct DependencyTrace {
108    /// The dependency name being traced.
109    pub package_name: String,
110    /// Files that import this dependency.
111    pub imported_by: Vec<PathBuf>,
112    /// Files that import this dependency with type-only imports.
113    pub type_only_imported_by: Vec<PathBuf>,
114    /// Whether the dependency is invoked from package.json scripts or CI configs
115    /// (e.g., `microbundle build`, `vitest run` in `scripts`, or binary names in
116    /// `.github/workflows/*.yml` / `.gitlab-ci.yml`). Mirrors how the unused-deps
117    /// detector classifies tooling usage so trace output stays consistent with it.
118    pub used_in_scripts: bool,
119    /// Whether the dependency is used at all (imports OR script/CI invocations).
120    pub is_used: bool,
121    /// Total import count.
122    pub import_count: usize,
123}
124
125/// Pipeline performance timings.
126#[derive(Debug, Clone, Serialize)]
127pub struct PipelineTimings {
128    pub discover_files_ms: f64,
129    pub file_count: usize,
130    pub workspaces_ms: f64,
131    pub workspace_count: usize,
132    pub plugins_ms: f64,
133    pub script_analysis_ms: f64,
134    pub parse_extract_ms: f64,
135    pub module_count: usize,
136    /// Number of files whose parse results were loaded from cache (skipped parsing).
137    pub cache_hits: usize,
138    /// Number of files that required a full parse (new or changed content).
139    pub cache_misses: usize,
140    pub cache_update_ms: f64,
141    pub entry_points_ms: f64,
142    pub entry_point_count: usize,
143    pub resolve_imports_ms: f64,
144    pub build_graph_ms: f64,
145    pub analyze_ms: f64,
146    pub total_ms: f64,
147}
148
149/// Trace why an export is considered used or unused.
150#[must_use]
151pub fn trace_export(
152    graph: &ModuleGraph,
153    root: &Path,
154    file_path: &str,
155    export_name: &str,
156) -> Option<ExportTrace> {
157    // Find the file in the graph
158    let module = graph
159        .modules
160        .iter()
161        .find(|m| path_matches(&m.path, root, file_path))?;
162
163    // Find the export
164    let export = module.exports.iter().find(|e| {
165        let name_str = e.name.to_string();
166        name_str == export_name || (export_name == "default" && name_str == "default")
167    })?;
168
169    let direct_references: Vec<ExportReference> = export
170        .references
171        .iter()
172        .map(|r| {
173            let from_path = graph.modules.get(r.from_file.0 as usize).map_or_else(
174                || PathBuf::from(format!("<unknown:{}>", r.from_file.0)),
175                |m| m.path.strip_prefix(root).unwrap_or(&m.path).to_path_buf(),
176            );
177            ExportReference {
178                from_file: from_path,
179                kind: format_reference_kind(r.kind),
180            }
181        })
182        .collect();
183
184    // Find re-export chains involving this export
185    let re_export_chains: Vec<ReExportChain> = graph
186        .modules
187        .iter()
188        .flat_map(|m| {
189            m.re_exports
190                .iter()
191                .filter(|re| {
192                    re.source_file == module.file_id
193                        && (re.imported_name == export_name || re.imported_name == "*")
194                })
195                .map(|re| {
196                    let barrel_export = m.exports.iter().find(|e| {
197                        if re.exported_name == "*" {
198                            e.name.to_string() == export_name
199                        } else {
200                            e.name.to_string() == re.exported_name
201                        }
202                    });
203                    ReExportChain {
204                        barrel_file: m.path.strip_prefix(root).unwrap_or(&m.path).to_path_buf(),
205                        exported_as: re.exported_name.clone(),
206                        reference_count: barrel_export.map_or(0, |e| e.references.len()),
207                    }
208                })
209        })
210        .collect();
211
212    let is_used = !export.references.is_empty();
213    let reason = if !module.is_reachable() {
214        "File is unreachable from any entry point".to_string()
215    } else if is_used {
216        format!(
217            "Used by {} file(s){}",
218            export.references.len(),
219            if re_export_chains.is_empty() {
220                String::new()
221            } else {
222                format!(", re-exported through {} barrel(s)", re_export_chains.len())
223            }
224        )
225    } else if module.is_entry_point() {
226        "No internal references, but file is an entry point (export is externally accessible)"
227            .to_string()
228    } else if !re_export_chains.is_empty() {
229        format!(
230            "Re-exported through {} barrel(s) but no consumer imports it through the barrel",
231            re_export_chains.len()
232        )
233    } else {
234        "No references found — export is unused".to_string()
235    };
236
237    Some(ExportTrace {
238        file: module
239            .path
240            .strip_prefix(root)
241            .unwrap_or(&module.path)
242            .to_path_buf(),
243        export_name: export_name.to_string(),
244        file_reachable: module.is_reachable(),
245        is_entry_point: module.is_entry_point(),
246        is_used,
247        direct_references,
248        re_export_chains,
249        reason,
250    })
251}
252
253/// Trace all edges for a file.
254#[must_use]
255pub fn trace_file(graph: &ModuleGraph, root: &Path, file_path: &str) -> Option<FileTrace> {
256    let module = graph
257        .modules
258        .iter()
259        .find(|m| path_matches(&m.path, root, file_path))?;
260
261    let exports: Vec<TracedExport> = module
262        .exports
263        .iter()
264        .map(|e| TracedExport {
265            name: e.name.to_string(),
266            is_type_only: e.is_type_only,
267            reference_count: e.references.len(),
268            referenced_by: e
269                .references
270                .iter()
271                .map(|r| {
272                    let from_path = graph.modules.get(r.from_file.0 as usize).map_or_else(
273                        || PathBuf::from(format!("<unknown:{}>", r.from_file.0)),
274                        |m| m.path.strip_prefix(root).unwrap_or(&m.path).to_path_buf(),
275                    );
276                    ExportReference {
277                        from_file: from_path,
278                        kind: format_reference_kind(r.kind),
279                    }
280                })
281                .collect(),
282        })
283        .collect();
284
285    // Edges FROM this file (what it imports)
286    let imports_from: Vec<PathBuf> = graph
287        .edges_for(module.file_id)
288        .iter()
289        .filter_map(|target_id| {
290            graph
291                .modules
292                .get(target_id.0 as usize)
293                .map(|m| m.path.strip_prefix(root).unwrap_or(&m.path).to_path_buf())
294        })
295        .collect();
296
297    // Reverse deps: who imports this file
298    let imported_by: Vec<PathBuf> = graph
299        .reverse_deps
300        .get(module.file_id.0 as usize)
301        .map(|deps| {
302            deps.iter()
303                .filter_map(|fid| {
304                    graph
305                        .modules
306                        .get(fid.0 as usize)
307                        .map(|m| m.path.strip_prefix(root).unwrap_or(&m.path).to_path_buf())
308                })
309                .collect()
310        })
311        .unwrap_or_default();
312
313    let re_exports: Vec<TracedReExport> = module
314        .re_exports
315        .iter()
316        .map(|re| {
317            let source_path = graph.modules.get(re.source_file.0 as usize).map_or_else(
318                || PathBuf::from(format!("<unknown:{}>", re.source_file.0)),
319                |m| m.path.strip_prefix(root).unwrap_or(&m.path).to_path_buf(),
320            );
321            TracedReExport {
322                source_file: source_path,
323                imported_name: re.imported_name.clone(),
324                exported_name: re.exported_name.clone(),
325            }
326        })
327        .collect();
328
329    Some(FileTrace {
330        file: module
331            .path
332            .strip_prefix(root)
333            .unwrap_or(&module.path)
334            .to_path_buf(),
335        is_reachable: module.is_reachable(),
336        is_entry_point: module.is_entry_point(),
337        exports,
338        imports_from,
339        imported_by,
340        re_exports,
341    })
342}
343
344/// Trace where a dependency is used.
345///
346/// `script_used_packages` carries the package names recorded as binary invocations
347/// in package.json scripts (`build: microbundle ...`) and CI configs
348/// (`.github/workflows/*.yml`, `.gitlab-ci.yml`). The same set the unused-deps
349/// detector consults; passing it in lets the trace output match the detector's
350/// view of "used" instead of reporting `is_used=false` for tools invoked only
351/// through scripts.
352#[expect(
353    clippy::implicit_hasher,
354    reason = "fallow standardizes on FxHashSet across the workspace"
355)]
356#[must_use]
357pub fn trace_dependency(
358    graph: &ModuleGraph,
359    root: &Path,
360    package_name: &str,
361    script_used_packages: &FxHashSet<String>,
362) -> DependencyTrace {
363    let imported_by: Vec<PathBuf> = graph
364        .package_usage
365        .get(package_name)
366        .map(|ids| {
367            ids.iter()
368                .filter_map(|fid| {
369                    graph
370                        .modules
371                        .get(fid.0 as usize)
372                        .map(|m| m.path.strip_prefix(root).unwrap_or(&m.path).to_path_buf())
373                })
374                .collect()
375        })
376        .unwrap_or_default();
377
378    let type_only_imported_by: Vec<PathBuf> = graph
379        .type_only_package_usage
380        .get(package_name)
381        .map(|ids| {
382            ids.iter()
383                .filter_map(|fid| {
384                    graph
385                        .modules
386                        .get(fid.0 as usize)
387                        .map(|m| m.path.strip_prefix(root).unwrap_or(&m.path).to_path_buf())
388                })
389                .collect()
390        })
391        .unwrap_or_default();
392
393    let import_count = imported_by.len();
394    let used_in_scripts = script_used_packages.contains(package_name);
395    DependencyTrace {
396        package_name: package_name.to_string(),
397        imported_by,
398        type_only_imported_by,
399        used_in_scripts,
400        is_used: import_count > 0 || used_in_scripts,
401        import_count,
402    }
403}
404
405fn format_reference_kind(kind: ReferenceKind) -> String {
406    match kind {
407        ReferenceKind::NamedImport => "named import".to_string(),
408        ReferenceKind::DefaultImport => "default import".to_string(),
409        ReferenceKind::NamespaceImport => "namespace import".to_string(),
410        ReferenceKind::ReExport => "re-export".to_string(),
411        ReferenceKind::DynamicImport => "dynamic import".to_string(),
412        ReferenceKind::SideEffectImport => "side-effect import".to_string(),
413    }
414}
415
416/// Result of tracing a clone: all groups containing the code at a given location.
417#[derive(Debug, Serialize)]
418pub struct CloneTrace {
419    pub file: PathBuf,
420    pub line: usize,
421    pub matched_instance: Option<CloneInstance>,
422    pub clone_groups: Vec<TracedCloneGroup>,
423}
424
425#[derive(Debug, Serialize)]
426pub struct TracedCloneGroup {
427    pub token_count: usize,
428    pub line_count: usize,
429    pub instances: Vec<CloneInstance>,
430}
431
432#[must_use]
433pub fn trace_clone(
434    report: &DuplicationReport,
435    root: &Path,
436    file_path: &str,
437    line: usize,
438) -> CloneTrace {
439    let resolved = root.join(file_path);
440    let mut matched_instance = None;
441    let mut clone_groups = Vec::new();
442
443    for group in &report.clone_groups {
444        let matching = group.instances.iter().find(|inst| {
445            let inst_matches = inst.file == resolved
446                || inst.file.strip_prefix(root).unwrap_or(&inst.file) == Path::new(file_path);
447            inst_matches && inst.start_line <= line && line <= inst.end_line
448        });
449
450        if let Some(matched) = matching {
451            if matched_instance.is_none() {
452                matched_instance = Some(relativize_instance(matched, root));
453            }
454            clone_groups.push(TracedCloneGroup {
455                token_count: group.token_count,
456                line_count: group.line_count,
457                instances: group
458                    .instances
459                    .iter()
460                    .map(|inst| relativize_instance(inst, root))
461                    .collect(),
462            });
463        }
464    }
465
466    CloneTrace {
467        file: PathBuf::from(file_path),
468        line,
469        matched_instance,
470        clone_groups,
471    }
472}
473
474/// Return a copy of `inst` with `file` rewritten relative to `root` (forward-slash normalized
475/// for cross-platform JSON parity with `serde_path::serialize`). If `inst.file` is already
476/// outside `root`, the path is left unchanged.
477fn relativize_instance(inst: &CloneInstance, root: &Path) -> CloneInstance {
478    let rel = inst.file.strip_prefix(root).map_or_else(
479        |_| inst.file.clone(),
480        |p| PathBuf::from(p.to_string_lossy().replace('\\', "/")),
481    );
482    CloneInstance {
483        file: rel,
484        ..inst.clone()
485    }
486}
487
488#[cfg(test)]
489mod tests {
490    use super::*;
491
492    use crate::discover::{DiscoveredFile, EntryPoint, EntryPointSource, FileId};
493    use crate::extract::{ExportInfo, ExportName, ImportInfo, ImportedName, VisibilityTag};
494    use crate::resolve::{ResolveResult, ResolvedImport, ResolvedModule};
495
496    fn build_test_graph() -> ModuleGraph {
497        let files = vec![
498            DiscoveredFile {
499                id: FileId(0),
500                path: PathBuf::from("/project/src/entry.ts"),
501                size_bytes: 100,
502            },
503            DiscoveredFile {
504                id: FileId(1),
505                path: PathBuf::from("/project/src/utils.ts"),
506                size_bytes: 50,
507            },
508            DiscoveredFile {
509                id: FileId(2),
510                path: PathBuf::from("/project/src/unused.ts"),
511                size_bytes: 30,
512            },
513        ];
514
515        let entry_points = vec![EntryPoint {
516            path: PathBuf::from("/project/src/entry.ts"),
517            source: EntryPointSource::PackageJsonMain,
518        }];
519
520        let resolved_modules = vec![
521            ResolvedModule {
522                file_id: FileId(0),
523                path: PathBuf::from("/project/src/entry.ts"),
524                resolved_imports: vec![ResolvedImport {
525                    info: ImportInfo {
526                        source: "./utils".to_string(),
527                        imported_name: ImportedName::Named("foo".to_string()),
528                        local_name: "foo".to_string(),
529                        is_type_only: false,
530                        from_style: false,
531                        span: oxc_span::Span::new(0, 10),
532                        source_span: oxc_span::Span::default(),
533                    },
534                    target: ResolveResult::InternalModule(FileId(1)),
535                }],
536                ..Default::default()
537            },
538            ResolvedModule {
539                file_id: FileId(1),
540                path: PathBuf::from("/project/src/utils.ts"),
541                exports: vec![
542                    ExportInfo {
543                        name: ExportName::Named("foo".to_string()),
544                        local_name: Some("foo".to_string()),
545                        is_type_only: false,
546                        visibility: VisibilityTag::None,
547                        span: oxc_span::Span::new(0, 20),
548                        members: vec![],
549                        super_class: None,
550                    },
551                    ExportInfo {
552                        name: ExportName::Named("bar".to_string()),
553                        local_name: Some("bar".to_string()),
554                        is_type_only: false,
555                        visibility: VisibilityTag::None,
556                        span: oxc_span::Span::new(21, 40),
557                        members: vec![],
558                        super_class: None,
559                    },
560                ],
561                ..Default::default()
562            },
563            ResolvedModule {
564                file_id: FileId(2),
565                path: PathBuf::from("/project/src/unused.ts"),
566                exports: vec![ExportInfo {
567                    name: ExportName::Named("baz".to_string()),
568                    local_name: Some("baz".to_string()),
569                    is_type_only: false,
570                    visibility: VisibilityTag::None,
571                    span: oxc_span::Span::new(0, 15),
572                    members: vec![],
573                    super_class: None,
574                }],
575                ..Default::default()
576            },
577        ];
578
579        ModuleGraph::build(&resolved_modules, &entry_points, &files)
580    }
581
582    #[test]
583    fn trace_used_export() {
584        let graph = build_test_graph();
585        let root = Path::new("/project");
586
587        let trace = trace_export(&graph, root, "src/utils.ts", "foo").unwrap();
588        assert!(trace.is_used);
589        assert!(trace.file_reachable);
590        assert_eq!(trace.direct_references.len(), 1);
591        assert_eq!(
592            trace.direct_references[0].from_file,
593            PathBuf::from("src/entry.ts")
594        );
595        assert_eq!(trace.direct_references[0].kind, "named import");
596    }
597
598    #[test]
599    fn trace_unused_export() {
600        let graph = build_test_graph();
601        let root = Path::new("/project");
602
603        let trace = trace_export(&graph, root, "src/utils.ts", "bar").unwrap();
604        assert!(!trace.is_used);
605        assert!(trace.file_reachable);
606        assert!(trace.direct_references.is_empty());
607    }
608
609    #[test]
610    fn trace_unreachable_file_export() {
611        let graph = build_test_graph();
612        let root = Path::new("/project");
613
614        let trace = trace_export(&graph, root, "src/unused.ts", "baz").unwrap();
615        assert!(!trace.is_used);
616        assert!(!trace.file_reachable);
617        assert!(trace.reason.contains("unreachable"));
618    }
619
620    #[test]
621    fn trace_nonexistent_export() {
622        let graph = build_test_graph();
623        let root = Path::new("/project");
624
625        let trace = trace_export(&graph, root, "src/utils.ts", "nonexistent");
626        assert!(trace.is_none());
627    }
628
629    #[test]
630    fn trace_nonexistent_file() {
631        let graph = build_test_graph();
632        let root = Path::new("/project");
633
634        let trace = trace_export(&graph, root, "src/nope.ts", "foo");
635        assert!(trace.is_none());
636    }
637
638    #[test]
639    fn trace_file_edges() {
640        let graph = build_test_graph();
641        let root = Path::new("/project");
642
643        let trace = trace_file(&graph, root, "src/entry.ts").unwrap();
644        assert!(trace.is_entry_point);
645        assert!(trace.is_reachable);
646        assert_eq!(trace.imports_from.len(), 1);
647        assert_eq!(trace.imports_from[0], PathBuf::from("src/utils.ts"));
648        assert!(trace.imported_by.is_empty());
649    }
650
651    #[test]
652    fn trace_file_imported_by() {
653        let graph = build_test_graph();
654        let root = Path::new("/project");
655
656        let trace = trace_file(&graph, root, "src/utils.ts").unwrap();
657        assert!(!trace.is_entry_point);
658        assert!(trace.is_reachable);
659        assert_eq!(trace.exports.len(), 2);
660        assert_eq!(trace.imported_by.len(), 1);
661        assert_eq!(trace.imported_by[0], PathBuf::from("src/entry.ts"));
662    }
663
664    #[test]
665    fn trace_unreachable_file() {
666        let graph = build_test_graph();
667        let root = Path::new("/project");
668
669        let trace = trace_file(&graph, root, "src/unused.ts").unwrap();
670        assert!(!trace.is_reachable);
671        assert!(!trace.is_entry_point);
672        assert!(trace.imported_by.is_empty());
673    }
674
675    #[test]
676    fn trace_dependency_used() {
677        // Build a graph with npm package usage
678        let files = vec![DiscoveredFile {
679            id: FileId(0),
680            path: PathBuf::from("/project/src/app.ts"),
681            size_bytes: 100,
682        }];
683        let entry_points = vec![EntryPoint {
684            path: PathBuf::from("/project/src/app.ts"),
685            source: EntryPointSource::PackageJsonMain,
686        }];
687        let resolved_modules = vec![ResolvedModule {
688            file_id: FileId(0),
689            path: PathBuf::from("/project/src/app.ts"),
690            resolved_imports: vec![ResolvedImport {
691                info: ImportInfo {
692                    source: "lodash".to_string(),
693                    imported_name: ImportedName::Named("get".to_string()),
694                    local_name: "get".to_string(),
695                    is_type_only: false,
696                    from_style: false,
697                    span: oxc_span::Span::new(0, 10),
698                    source_span: oxc_span::Span::default(),
699                },
700                target: ResolveResult::NpmPackage("lodash".to_string()),
701            }],
702            ..Default::default()
703        }];
704
705        let graph = ModuleGraph::build(&resolved_modules, &entry_points, &files);
706        let root = Path::new("/project");
707
708        let trace = trace_dependency(&graph, root, "lodash", &FxHashSet::default());
709        assert!(trace.is_used);
710        assert!(!trace.used_in_scripts);
711        assert_eq!(trace.import_count, 1);
712        assert_eq!(trace.imported_by[0], PathBuf::from("src/app.ts"));
713    }
714
715    #[test]
716    fn trace_dependency_unused() {
717        let files = vec![DiscoveredFile {
718            id: FileId(0),
719            path: PathBuf::from("/project/src/app.ts"),
720            size_bytes: 100,
721        }];
722        let entry_points = vec![EntryPoint {
723            path: PathBuf::from("/project/src/app.ts"),
724            source: EntryPointSource::PackageJsonMain,
725        }];
726        let resolved_modules = vec![ResolvedModule {
727            file_id: FileId(0),
728            path: PathBuf::from("/project/src/app.ts"),
729            ..Default::default()
730        }];
731
732        let graph = ModuleGraph::build(&resolved_modules, &entry_points, &files);
733        let root = Path::new("/project");
734
735        let trace = trace_dependency(&graph, root, "nonexistent-pkg", &FxHashSet::default());
736        assert!(!trace.is_used);
737        assert!(!trace.used_in_scripts);
738        assert_eq!(trace.import_count, 0);
739        assert!(trace.imported_by.is_empty());
740    }
741
742    #[test]
743    fn trace_dependency_used_only_in_scripts() {
744        let files = vec![DiscoveredFile {
745            id: FileId(0),
746            path: PathBuf::from("/project/src/app.ts"),
747            size_bytes: 100,
748        }];
749        let entry_points = vec![EntryPoint {
750            path: PathBuf::from("/project/src/app.ts"),
751            source: EntryPointSource::PackageJsonMain,
752        }];
753        let resolved_modules = vec![ResolvedModule {
754            file_id: FileId(0),
755            path: PathBuf::from("/project/src/app.ts"),
756            ..Default::default()
757        }];
758
759        let graph = ModuleGraph::build(&resolved_modules, &entry_points, &files);
760        let root = Path::new("/project");
761        let mut script_used = FxHashSet::default();
762        script_used.insert("microbundle".to_string());
763
764        let trace = trace_dependency(&graph, root, "microbundle", &script_used);
765        assert!(
766            trace.is_used,
767            "is_used must be true when the package is referenced from package.json scripts"
768        );
769        assert!(trace.used_in_scripts);
770        assert_eq!(trace.import_count, 0);
771        assert!(trace.imported_by.is_empty());
772    }
773
774    #[test]
775    fn trace_clone_finds_matching_group() {
776        use crate::duplicates::{CloneGroup, CloneInstance, DuplicationReport, DuplicationStats};
777        let report = DuplicationReport {
778            clone_groups: vec![CloneGroup {
779                instances: vec![
780                    CloneInstance {
781                        file: PathBuf::from("/project/src/a.ts"),
782                        start_line: 10,
783                        end_line: 20,
784                        start_col: 0,
785                        end_col: 0,
786                        fragment: "fn foo() {}".to_string(),
787                    },
788                    CloneInstance {
789                        file: PathBuf::from("/project/src/b.ts"),
790                        start_line: 5,
791                        end_line: 15,
792                        start_col: 0,
793                        end_col: 0,
794                        fragment: "fn foo() {}".to_string(),
795                    },
796                ],
797                token_count: 60,
798                line_count: 11,
799            }],
800            clone_families: vec![],
801            mirrored_directories: vec![],
802            stats: DuplicationStats {
803                total_files: 2,
804                files_with_clones: 2,
805                total_lines: 100,
806                duplicated_lines: 22,
807                total_tokens: 200,
808                duplicated_tokens: 120,
809                clone_groups: 1,
810                clone_instances: 2,
811                duplication_percentage: 22.0,
812            },
813        };
814        let trace = trace_clone(&report, Path::new("/project"), "src/a.ts", 15);
815        assert!(trace.matched_instance.is_some());
816        assert_eq!(trace.clone_groups.len(), 1);
817        assert_eq!(trace.clone_groups[0].instances.len(), 2);
818    }
819
820    #[test]
821    fn trace_clone_no_match() {
822        use crate::duplicates::{CloneGroup, CloneInstance, DuplicationReport, DuplicationStats};
823        let report = DuplicationReport {
824            clone_groups: vec![CloneGroup {
825                instances: vec![CloneInstance {
826                    file: PathBuf::from("/project/src/a.ts"),
827                    start_line: 10,
828                    end_line: 20,
829                    start_col: 0,
830                    end_col: 0,
831                    fragment: "fn foo() {}".to_string(),
832                }],
833                token_count: 60,
834                line_count: 11,
835            }],
836            clone_families: vec![],
837            mirrored_directories: vec![],
838            stats: DuplicationStats {
839                total_files: 1,
840                files_with_clones: 1,
841                total_lines: 50,
842                duplicated_lines: 11,
843                total_tokens: 100,
844                duplicated_tokens: 60,
845                clone_groups: 1,
846                clone_instances: 1,
847                duplication_percentage: 22.0,
848            },
849        };
850        let trace = trace_clone(&report, Path::new("/project"), "src/a.ts", 25);
851        assert!(trace.matched_instance.is_none());
852        assert!(trace.clone_groups.is_empty());
853    }
854
855    #[test]
856    fn trace_clone_line_boundary() {
857        use crate::duplicates::{CloneGroup, CloneInstance, DuplicationReport, DuplicationStats};
858        let report = DuplicationReport {
859            clone_groups: vec![CloneGroup {
860                instances: vec![
861                    CloneInstance {
862                        file: PathBuf::from("/project/src/a.ts"),
863                        start_line: 10,
864                        end_line: 20,
865                        start_col: 0,
866                        end_col: 0,
867                        fragment: "code".to_string(),
868                    },
869                    CloneInstance {
870                        file: PathBuf::from("/project/src/b.ts"),
871                        start_line: 1,
872                        end_line: 11,
873                        start_col: 0,
874                        end_col: 0,
875                        fragment: "code".to_string(),
876                    },
877                ],
878                token_count: 50,
879                line_count: 11,
880            }],
881            clone_families: vec![],
882            mirrored_directories: vec![],
883            stats: DuplicationStats {
884                total_files: 2,
885                files_with_clones: 2,
886                total_lines: 100,
887                duplicated_lines: 22,
888                total_tokens: 200,
889                duplicated_tokens: 100,
890                clone_groups: 1,
891                clone_instances: 2,
892                duplication_percentage: 22.0,
893            },
894        };
895        let root = Path::new("/project");
896        assert!(
897            trace_clone(&report, root, "src/a.ts", 10)
898                .matched_instance
899                .is_some()
900        );
901        assert!(
902            trace_clone(&report, root, "src/a.ts", 20)
903                .matched_instance
904                .is_some()
905        );
906        assert!(
907            trace_clone(&report, root, "src/a.ts", 21)
908                .matched_instance
909                .is_none()
910        );
911    }
912
913    #[test]
914    fn trace_clone_returns_relative_instance_paths() {
915        use crate::duplicates::{CloneGroup, CloneInstance, DuplicationReport, DuplicationStats};
916        let report = DuplicationReport {
917            clone_groups: vec![CloneGroup {
918                instances: vec![
919                    CloneInstance {
920                        file: PathBuf::from("/project/src/a.ts"),
921                        start_line: 1,
922                        end_line: 10,
923                        start_col: 0,
924                        end_col: 0,
925                        fragment: "code".to_string(),
926                    },
927                    CloneInstance {
928                        file: PathBuf::from("/project/src/b.ts"),
929                        start_line: 1,
930                        end_line: 10,
931                        start_col: 0,
932                        end_col: 0,
933                        fragment: "code".to_string(),
934                    },
935                ],
936                token_count: 50,
937                line_count: 10,
938            }],
939            clone_families: vec![],
940            mirrored_directories: vec![],
941            stats: DuplicationStats {
942                total_files: 2,
943                files_with_clones: 2,
944                total_lines: 50,
945                duplicated_lines: 20,
946                total_tokens: 100,
947                duplicated_tokens: 100,
948                clone_groups: 1,
949                clone_instances: 2,
950                duplication_percentage: 40.0,
951            },
952        };
953        let trace = trace_clone(&report, Path::new("/project"), "src/a.ts", 5);
954        let matched = trace.matched_instance.as_ref().expect("match expected");
955        assert_eq!(matched.file, PathBuf::from("src/a.ts"));
956        for group in &trace.clone_groups {
957            for inst in &group.instances {
958                let as_str = inst.file.to_string_lossy();
959                assert!(
960                    !as_str.starts_with('/'),
961                    "instance file should be relative, got {as_str}",
962                );
963                assert!(
964                    !as_str.contains(":\\") && !as_str.contains(":/"),
965                    "instance file should not have a drive letter, got {as_str}",
966                );
967            }
968        }
969
970        let json = serde_json::to_string(&trace).expect("serializes");
971        assert!(
972            !json.contains("\"/project/"),
973            "serialized trace should not leak absolute paths: {json}",
974        );
975    }
976}