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