Skip to main content

fallow_core/
trace.rs

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