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                        span: oxc_span::Span::new(0, 10),
531                        source_span: oxc_span::Span::default(),
532                    },
533                    target: ResolveResult::InternalModule(FileId(1)),
534                }],
535                ..Default::default()
536            },
537            ResolvedModule {
538                file_id: FileId(1),
539                path: PathBuf::from("/project/src/utils.ts"),
540                exports: vec![
541                    ExportInfo {
542                        name: ExportName::Named("foo".to_string()),
543                        local_name: Some("foo".to_string()),
544                        is_type_only: false,
545                        visibility: VisibilityTag::None,
546                        span: oxc_span::Span::new(0, 20),
547                        members: vec![],
548                        super_class: None,
549                    },
550                    ExportInfo {
551                        name: ExportName::Named("bar".to_string()),
552                        local_name: Some("bar".to_string()),
553                        is_type_only: false,
554                        visibility: VisibilityTag::None,
555                        span: oxc_span::Span::new(21, 40),
556                        members: vec![],
557                        super_class: None,
558                    },
559                ],
560                ..Default::default()
561            },
562            ResolvedModule {
563                file_id: FileId(2),
564                path: PathBuf::from("/project/src/unused.ts"),
565                exports: vec![ExportInfo {
566                    name: ExportName::Named("baz".to_string()),
567                    local_name: Some("baz".to_string()),
568                    is_type_only: false,
569                    visibility: VisibilityTag::None,
570                    span: oxc_span::Span::new(0, 15),
571                    members: vec![],
572                    super_class: None,
573                }],
574                ..Default::default()
575            },
576        ];
577
578        ModuleGraph::build(&resolved_modules, &entry_points, &files)
579    }
580
581    #[test]
582    fn trace_used_export() {
583        let graph = build_test_graph();
584        let root = Path::new("/project");
585
586        let trace = trace_export(&graph, root, "src/utils.ts", "foo").unwrap();
587        assert!(trace.is_used);
588        assert!(trace.file_reachable);
589        assert_eq!(trace.direct_references.len(), 1);
590        assert_eq!(
591            trace.direct_references[0].from_file,
592            PathBuf::from("src/entry.ts")
593        );
594        assert_eq!(trace.direct_references[0].kind, "named import");
595    }
596
597    #[test]
598    fn trace_unused_export() {
599        let graph = build_test_graph();
600        let root = Path::new("/project");
601
602        let trace = trace_export(&graph, root, "src/utils.ts", "bar").unwrap();
603        assert!(!trace.is_used);
604        assert!(trace.file_reachable);
605        assert!(trace.direct_references.is_empty());
606    }
607
608    #[test]
609    fn trace_unreachable_file_export() {
610        let graph = build_test_graph();
611        let root = Path::new("/project");
612
613        let trace = trace_export(&graph, root, "src/unused.ts", "baz").unwrap();
614        assert!(!trace.is_used);
615        assert!(!trace.file_reachable);
616        assert!(trace.reason.contains("unreachable"));
617    }
618
619    #[test]
620    fn trace_nonexistent_export() {
621        let graph = build_test_graph();
622        let root = Path::new("/project");
623
624        let trace = trace_export(&graph, root, "src/utils.ts", "nonexistent");
625        assert!(trace.is_none());
626    }
627
628    #[test]
629    fn trace_nonexistent_file() {
630        let graph = build_test_graph();
631        let root = Path::new("/project");
632
633        let trace = trace_export(&graph, root, "src/nope.ts", "foo");
634        assert!(trace.is_none());
635    }
636
637    #[test]
638    fn trace_file_edges() {
639        let graph = build_test_graph();
640        let root = Path::new("/project");
641
642        let trace = trace_file(&graph, root, "src/entry.ts").unwrap();
643        assert!(trace.is_entry_point);
644        assert!(trace.is_reachable);
645        assert_eq!(trace.imports_from.len(), 1);
646        assert_eq!(trace.imports_from[0], PathBuf::from("src/utils.ts"));
647        assert!(trace.imported_by.is_empty());
648    }
649
650    #[test]
651    fn trace_file_imported_by() {
652        let graph = build_test_graph();
653        let root = Path::new("/project");
654
655        let trace = trace_file(&graph, root, "src/utils.ts").unwrap();
656        assert!(!trace.is_entry_point);
657        assert!(trace.is_reachable);
658        assert_eq!(trace.exports.len(), 2);
659        assert_eq!(trace.imported_by.len(), 1);
660        assert_eq!(trace.imported_by[0], PathBuf::from("src/entry.ts"));
661    }
662
663    #[test]
664    fn trace_unreachable_file() {
665        let graph = build_test_graph();
666        let root = Path::new("/project");
667
668        let trace = trace_file(&graph, root, "src/unused.ts").unwrap();
669        assert!(!trace.is_reachable);
670        assert!(!trace.is_entry_point);
671        assert!(trace.imported_by.is_empty());
672    }
673
674    #[test]
675    fn trace_dependency_used() {
676        // Build a graph with npm package usage
677        let files = vec![DiscoveredFile {
678            id: FileId(0),
679            path: PathBuf::from("/project/src/app.ts"),
680            size_bytes: 100,
681        }];
682        let entry_points = vec![EntryPoint {
683            path: PathBuf::from("/project/src/app.ts"),
684            source: EntryPointSource::PackageJsonMain,
685        }];
686        let resolved_modules = vec![ResolvedModule {
687            file_id: FileId(0),
688            path: PathBuf::from("/project/src/app.ts"),
689            resolved_imports: vec![ResolvedImport {
690                info: ImportInfo {
691                    source: "lodash".to_string(),
692                    imported_name: ImportedName::Named("get".to_string()),
693                    local_name: "get".to_string(),
694                    is_type_only: false,
695                    span: oxc_span::Span::new(0, 10),
696                    source_span: oxc_span::Span::default(),
697                },
698                target: ResolveResult::NpmPackage("lodash".to_string()),
699            }],
700            ..Default::default()
701        }];
702
703        let graph = ModuleGraph::build(&resolved_modules, &entry_points, &files);
704        let root = Path::new("/project");
705
706        let trace = trace_dependency(&graph, root, "lodash", &FxHashSet::default());
707        assert!(trace.is_used);
708        assert!(!trace.used_in_scripts);
709        assert_eq!(trace.import_count, 1);
710        assert_eq!(trace.imported_by[0], PathBuf::from("src/app.ts"));
711    }
712
713    #[test]
714    fn trace_dependency_unused() {
715        let files = vec![DiscoveredFile {
716            id: FileId(0),
717            path: PathBuf::from("/project/src/app.ts"),
718            size_bytes: 100,
719        }];
720        let entry_points = vec![EntryPoint {
721            path: PathBuf::from("/project/src/app.ts"),
722            source: EntryPointSource::PackageJsonMain,
723        }];
724        let resolved_modules = vec![ResolvedModule {
725            file_id: FileId(0),
726            path: PathBuf::from("/project/src/app.ts"),
727            ..Default::default()
728        }];
729
730        let graph = ModuleGraph::build(&resolved_modules, &entry_points, &files);
731        let root = Path::new("/project");
732
733        let trace = trace_dependency(&graph, root, "nonexistent-pkg", &FxHashSet::default());
734        assert!(!trace.is_used);
735        assert!(!trace.used_in_scripts);
736        assert_eq!(trace.import_count, 0);
737        assert!(trace.imported_by.is_empty());
738    }
739
740    #[test]
741    fn trace_dependency_used_only_in_scripts() {
742        let files = vec![DiscoveredFile {
743            id: FileId(0),
744            path: PathBuf::from("/project/src/app.ts"),
745            size_bytes: 100,
746        }];
747        let entry_points = vec![EntryPoint {
748            path: PathBuf::from("/project/src/app.ts"),
749            source: EntryPointSource::PackageJsonMain,
750        }];
751        let resolved_modules = vec![ResolvedModule {
752            file_id: FileId(0),
753            path: PathBuf::from("/project/src/app.ts"),
754            ..Default::default()
755        }];
756
757        let graph = ModuleGraph::build(&resolved_modules, &entry_points, &files);
758        let root = Path::new("/project");
759        let mut script_used = FxHashSet::default();
760        script_used.insert("microbundle".to_string());
761
762        let trace = trace_dependency(&graph, root, "microbundle", &script_used);
763        assert!(
764            trace.is_used,
765            "is_used must be true when the package is referenced from package.json scripts"
766        );
767        assert!(trace.used_in_scripts);
768        assert_eq!(trace.import_count, 0);
769        assert!(trace.imported_by.is_empty());
770    }
771
772    #[test]
773    fn trace_clone_finds_matching_group() {
774        use crate::duplicates::{CloneGroup, CloneInstance, DuplicationReport, DuplicationStats};
775        let report = DuplicationReport {
776            clone_groups: vec![CloneGroup {
777                instances: vec![
778                    CloneInstance {
779                        file: PathBuf::from("/project/src/a.ts"),
780                        start_line: 10,
781                        end_line: 20,
782                        start_col: 0,
783                        end_col: 0,
784                        fragment: "fn foo() {}".to_string(),
785                    },
786                    CloneInstance {
787                        file: PathBuf::from("/project/src/b.ts"),
788                        start_line: 5,
789                        end_line: 15,
790                        start_col: 0,
791                        end_col: 0,
792                        fragment: "fn foo() {}".to_string(),
793                    },
794                ],
795                token_count: 60,
796                line_count: 11,
797            }],
798            clone_families: vec![],
799            mirrored_directories: vec![],
800            stats: DuplicationStats {
801                total_files: 2,
802                files_with_clones: 2,
803                total_lines: 100,
804                duplicated_lines: 22,
805                total_tokens: 200,
806                duplicated_tokens: 120,
807                clone_groups: 1,
808                clone_instances: 2,
809                duplication_percentage: 22.0,
810            },
811        };
812        let trace = trace_clone(&report, Path::new("/project"), "src/a.ts", 15);
813        assert!(trace.matched_instance.is_some());
814        assert_eq!(trace.clone_groups.len(), 1);
815        assert_eq!(trace.clone_groups[0].instances.len(), 2);
816    }
817
818    #[test]
819    fn trace_clone_no_match() {
820        use crate::duplicates::{CloneGroup, CloneInstance, DuplicationReport, DuplicationStats};
821        let report = DuplicationReport {
822            clone_groups: vec![CloneGroup {
823                instances: vec![CloneInstance {
824                    file: PathBuf::from("/project/src/a.ts"),
825                    start_line: 10,
826                    end_line: 20,
827                    start_col: 0,
828                    end_col: 0,
829                    fragment: "fn foo() {}".to_string(),
830                }],
831                token_count: 60,
832                line_count: 11,
833            }],
834            clone_families: vec![],
835            mirrored_directories: vec![],
836            stats: DuplicationStats {
837                total_files: 1,
838                files_with_clones: 1,
839                total_lines: 50,
840                duplicated_lines: 11,
841                total_tokens: 100,
842                duplicated_tokens: 60,
843                clone_groups: 1,
844                clone_instances: 1,
845                duplication_percentage: 22.0,
846            },
847        };
848        let trace = trace_clone(&report, Path::new("/project"), "src/a.ts", 25);
849        assert!(trace.matched_instance.is_none());
850        assert!(trace.clone_groups.is_empty());
851    }
852
853    #[test]
854    fn trace_clone_line_boundary() {
855        use crate::duplicates::{CloneGroup, CloneInstance, DuplicationReport, DuplicationStats};
856        let report = DuplicationReport {
857            clone_groups: vec![CloneGroup {
858                instances: vec![
859                    CloneInstance {
860                        file: PathBuf::from("/project/src/a.ts"),
861                        start_line: 10,
862                        end_line: 20,
863                        start_col: 0,
864                        end_col: 0,
865                        fragment: "code".to_string(),
866                    },
867                    CloneInstance {
868                        file: PathBuf::from("/project/src/b.ts"),
869                        start_line: 1,
870                        end_line: 11,
871                        start_col: 0,
872                        end_col: 0,
873                        fragment: "code".to_string(),
874                    },
875                ],
876                token_count: 50,
877                line_count: 11,
878            }],
879            clone_families: vec![],
880            mirrored_directories: vec![],
881            stats: DuplicationStats {
882                total_files: 2,
883                files_with_clones: 2,
884                total_lines: 100,
885                duplicated_lines: 22,
886                total_tokens: 200,
887                duplicated_tokens: 100,
888                clone_groups: 1,
889                clone_instances: 2,
890                duplication_percentage: 22.0,
891            },
892        };
893        let root = Path::new("/project");
894        assert!(
895            trace_clone(&report, root, "src/a.ts", 10)
896                .matched_instance
897                .is_some()
898        );
899        assert!(
900            trace_clone(&report, root, "src/a.ts", 20)
901                .matched_instance
902                .is_some()
903        );
904        assert!(
905            trace_clone(&report, root, "src/a.ts", 21)
906                .matched_instance
907                .is_none()
908        );
909    }
910
911    #[test]
912    fn trace_clone_returns_relative_instance_paths() {
913        use crate::duplicates::{CloneGroup, CloneInstance, DuplicationReport, DuplicationStats};
914        let report = DuplicationReport {
915            clone_groups: vec![CloneGroup {
916                instances: vec![
917                    CloneInstance {
918                        file: PathBuf::from("/project/src/a.ts"),
919                        start_line: 1,
920                        end_line: 10,
921                        start_col: 0,
922                        end_col: 0,
923                        fragment: "code".to_string(),
924                    },
925                    CloneInstance {
926                        file: PathBuf::from("/project/src/b.ts"),
927                        start_line: 1,
928                        end_line: 10,
929                        start_col: 0,
930                        end_col: 0,
931                        fragment: "code".to_string(),
932                    },
933                ],
934                token_count: 50,
935                line_count: 10,
936            }],
937            clone_families: vec![],
938            mirrored_directories: vec![],
939            stats: DuplicationStats {
940                total_files: 2,
941                files_with_clones: 2,
942                total_lines: 50,
943                duplicated_lines: 20,
944                total_tokens: 100,
945                duplicated_tokens: 100,
946                clone_groups: 1,
947                clone_instances: 2,
948                duplication_percentage: 40.0,
949            },
950        };
951        let trace = trace_clone(&report, Path::new("/project"), "src/a.ts", 5);
952        let matched = trace.matched_instance.as_ref().expect("match expected");
953        assert_eq!(matched.file, PathBuf::from("src/a.ts"));
954        for group in &trace.clone_groups {
955            for inst in &group.instances {
956                let as_str = inst.file.to_string_lossy();
957                assert!(
958                    !as_str.starts_with('/'),
959                    "instance file should be relative, got {as_str}",
960                );
961                assert!(
962                    !as_str.contains(":\\") && !as_str.contains(":/"),
963                    "instance file should not have a drive letter, got {as_str}",
964                );
965            }
966        }
967
968        let json = serde_json::to_string(&trace).expect("serializes");
969        assert!(
970            !json.contains("\"/project/"),
971            "serialized trace should not leak absolute paths: {json}",
972        );
973    }
974}