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