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