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