Skip to main content

fallow_engine/
trace.rs

1//! Read-only trace helpers exposed through the engine boundary.
2
3use std::path::Path;
4
5use fallow_types::trace::PeerClosureSeeds;
6use rustc_hash::FxHashSet;
7
8use crate::duplicates::DuplicationReport;
9use crate::module_graph::RetainedModuleGraph;
10
11#[path = "trace_impl.rs"]
12pub(crate) mod trace_impl;
13#[path = "trace_usage_impl.rs"]
14mod trace_usage_impl;
15
16pub use fallow_types::trace_usage::{DependencyUsage, DependencyUsageQuery, SitePageRequest};
17pub use trace_usage_impl::UsageError;
18
19pub use fallow_types::trace::{
20    ClassMemberTrace, CloneTrace, DependencyTrace, ExportReference, ExportTrace, FileTrace,
21    ImpactClosureGap, ImpactClosureTrace, ImportPathHop, ImportPathTrace, PipelineTimings,
22    ReExportChain, TraceProvenance, TraceSource, TracedCloneGroup, TracedExport, TracedReExport,
23};
24pub use trace_impl::{ImportPathEndpoint, SemanticClassMethodResolutionError};
25
26/// Trace why an export is considered used or unused.
27#[must_use]
28pub fn trace_export(
29    graph: &RetainedModuleGraph,
30    root: &Path,
31    file_path: &str,
32    export_name: &str,
33) -> Option<ExportTrace> {
34    trace_impl::trace_export(graph.as_graph(), root, file_path, export_name)
35}
36
37/// Reconcile checker-backed trace evidence with graph reachability while
38/// retaining non-crediting evidence for inspection.
39pub fn reconcile_semantic_trace_reachability(
40    graph: &RetainedModuleGraph,
41    root: &Path,
42    target_reachable: bool,
43    trace: &mut fallow_types::semantic::SemanticSymbolTrace,
44) {
45    trace_impl::reconcile_semantic_trace_reachability(
46        graph.as_graph(),
47        root,
48        target_reachable,
49        trace,
50    );
51}
52
53/// Resolve the source identity for an exact semantic export query.
54#[must_use]
55pub fn semantic_symbol_for_export(
56    graph: &RetainedModuleGraph,
57    root: &Path,
58    file_path: &str,
59    export_name: &str,
60) -> Option<fallow_types::semantic::SemanticSymbol> {
61    trace_impl::semantic_symbol_for_export(graph.as_graph(), root, file_path, export_name)
62}
63
64/// Resolve the source identity for an exact semantic class-member query.
65#[must_use]
66pub fn semantic_symbol_for_class_member(
67    graph: &RetainedModuleGraph,
68    root: &Path,
69    file_path: &str,
70    member_name: &str,
71) -> Option<fallow_types::semantic::SemanticSymbol> {
72    trace_impl::semantic_symbol_for_class_member(graph.as_graph(), root, file_path, member_name)
73}
74
75/// Resolve one exact exported class method for semantic impact analysis.
76pub fn semantic_symbol_for_exact_class_method(
77    graph: &RetainedModuleGraph,
78    root: &Path,
79    file_path: &str,
80    owner_name: &str,
81    member_name: &str,
82) -> Result<fallow_types::semantic::SemanticSymbol, SemanticClassMethodResolutionError> {
83    trace_impl::semantic_symbol_for_exact_class_method(
84        graph.as_graph(),
85        root,
86        file_path,
87        owner_name,
88        member_name,
89    )
90}
91
92/// Trace a class / enum / store member (the `--trace FILE:MEMBER` fallback when
93/// `MEMBER` is not a top-level export). See issue #1744.
94#[must_use]
95pub fn trace_class_member(
96    graph: &RetainedModuleGraph,
97    root: &Path,
98    file_path: &str,
99    member_name: &str,
100) -> Option<ClassMemberTrace> {
101    trace_impl::trace_class_member(graph.as_graph(), root, file_path, member_name)
102}
103
104/// Trace all graph edges for a file.
105#[must_use]
106pub fn trace_file(graph: &RetainedModuleGraph, root: &Path, file_path: &str) -> Option<FileTrace> {
107    trace_impl::trace_file(graph.as_graph(), root, file_path)
108}
109
110/// Trace where a dependency is used.
111///
112/// `workspace_roots` are the roots of the workspaces, so the peer-dependency
113/// credit runs one closure per workspace, as the unused-dependency check does.
114/// `ignore_patterns` are the configured `ignorePatterns`. A workspace
115/// `package.json` that they match does not turn on an optional peer, as in the
116/// unused-dependency check.
117#[must_use]
118#[expect(
119    clippy::implicit_hasher,
120    reason = "fallow standardizes on FxHashSet across the workspace"
121)]
122pub fn trace_dependency(
123    graph: &RetainedModuleGraph,
124    root: &Path,
125    workspace_roots: &[&Path],
126    ignore_patterns: &fallow_config::IgnorePatternSet,
127    package_name: &str,
128    script_used_packages: &FxHashSet<String>,
129) -> DependencyTrace {
130    trace_impl::trace_dependency(
131        graph.as_graph(),
132        root,
133        workspace_roots,
134        ignore_patterns,
135        package_name,
136        &trace_impl::DependencyCredit {
137            script_used_packages,
138            peer_seeds: &PeerClosureSeeds::from_shared(script_used_packages.iter().cloned()),
139        },
140    )
141}
142
143/// The analysis facts that a dependency trace reads.
144pub struct DependencyTraceInputs<'a> {
145    /// The retained module graph.
146    pub graph: &'a RetainedModuleGraph,
147    /// The project root.
148    pub root: &'a Path,
149    /// The roots of the workspaces, for the peer-dependency credit.
150    pub workspace_roots: &'a [&'a Path],
151    /// The configured `ignorePatterns`.
152    pub ignore_patterns: &'a fallow_config::IgnorePatternSet,
153    /// Packages invoked from package.json scripts, CI configs and git hooks.
154    pub script_used_packages: &'a FxHashSet<String>,
155    /// Which configs name which files and dependency names.
156    pub provenance: &'a TraceProvenance,
157    /// The findings of the analysis, which name the manifests that the
158    /// unused-dependency check flags.
159    pub results: &'a fallow_types::results::AnalysisResults,
160}
161
162/// Build the complete dependency trace: the importers, the config sources,
163/// the tooling credit and the flagged manifests.
164///
165/// `fallow dead-code --trace-dependency`, `fallow trace --dependency` and the
166/// programmatic API share this builder, so the base fields agree on every
167/// surface.
168#[must_use]
169pub fn build_dependency_trace(
170    inputs: &DependencyTraceInputs<'_>,
171    package_name: &str,
172) -> DependencyTrace {
173    let mut trace = trace_impl::trace_dependency(
174        inputs.graph.as_graph(),
175        inputs.root,
176        inputs.workspace_roots,
177        inputs.ignore_patterns,
178        package_name,
179        &trace_impl::DependencyCredit {
180            script_used_packages: inputs.script_used_packages,
181            peer_seeds: inputs.provenance.peer_seeds(),
182        },
183    );
184    trace.sources = inputs.provenance.dependency_sources(package_name);
185    trace.apply_tooling_credit(inputs.provenance.tooling_credit(package_name));
186    trace.apply_unused_declarations(inputs.results, inputs.root);
187    trace
188}
189
190/// Trace a dependency with its per-specifier usage through an existing
191/// analysis session.
192///
193/// The inner `Result` carries a query error, such as a cursor that belongs
194/// to another query.
195///
196/// # Errors
197///
198/// Returns an error if parsing, graph construction, or analysis fails.
199pub fn trace_dependency_with_session(
200    session: &crate::session::AnalysisSession,
201    package_name: &str,
202    query: &DependencyUsageQuery,
203) -> crate::EngineResult<Result<DependencyTrace, UsageError>> {
204    // Keep the parsed modules: the usage walk reads their import facts.
205    let output = session.analyze_dead_code_with_shared_artifacts(true, true)?;
206    let graph = output.graph.as_ref().ok_or_else(|| {
207        crate::EngineError::new("trace --dependency requires a retained module graph")
208    })?;
209    let modules = output.modules.as_deref().unwrap_or(&[]);
210    let workspace_roots: Vec<&Path> = session
211        .workspaces()
212        .iter()
213        .map(|workspace| workspace.root.as_path())
214        .collect();
215    let mut trace = build_dependency_trace(
216        &DependencyTraceInputs {
217            graph,
218            root: session.root(),
219            workspace_roots: &workspace_roots,
220            ignore_patterns: &session.config().ignore_patterns,
221            script_used_packages: &output.script_used_packages,
222            provenance: &output.trace_provenance,
223            results: &output.results,
224        },
225        package_name,
226    );
227    match trace_usage_impl::dependency_usage(
228        graph.as_graph(),
229        modules,
230        session.root(),
231        package_name,
232        &trace.imported_by,
233        query,
234    ) {
235        Ok(usage) => {
236            trace.usage = Some(usage);
237            Ok(Ok(trace))
238        }
239        Err(err) => Ok(Err(err)),
240    }
241}
242
243/// Trace duplicate-code groups that contain a source location.
244#[must_use]
245pub fn trace_clone(
246    report: &DuplicationReport,
247    root: &Path,
248    file_path: &str,
249    line: usize,
250) -> CloneTrace {
251    trace_impl::trace_clone(report, root, file_path, line)
252}
253
254/// Trace a duplicate-code group by its stable content fingerprint.
255#[must_use]
256pub fn trace_clone_by_fingerprint(
257    report: &DuplicationReport,
258    root: &Path,
259    fingerprint: &str,
260) -> CloneTrace {
261    trace_impl::trace_clone_by_fingerprint(report, root, fingerprint)
262}
263
264/// Trace the impact closure for a file.
265#[must_use]
266pub fn trace_impact_closure(
267    graph: &RetainedModuleGraph,
268    root: &Path,
269    file_path: &str,
270) -> Option<ImpactClosureTrace> {
271    trace_impl::trace_impact_closure(graph.as_graph(), root, file_path)
272}
273
274/// Trace the impact closure for a file without the literal-search check.
275///
276/// The coordination gaps have no search fields, and no consumer source is
277/// read. Use it when only the affected set is needed.
278#[must_use]
279pub fn trace_impact_closure_without_search(
280    graph: &RetainedModuleGraph,
281    root: &Path,
282    file_path: &str,
283) -> Option<ImpactClosureTrace> {
284    trace_impl::trace_impact_closure_without_search(graph.as_graph(), root, file_path)
285}
286
287/// Trace the shortest import path between two modules.
288///
289/// # Errors
290///
291/// Returns the endpoint that did not resolve to exactly one module in the graph.
292pub fn trace_import_path(
293    graph: &RetainedModuleGraph,
294    root: &Path,
295    from_path: &str,
296    to_path: &str,
297) -> Result<ImportPathTrace, ImportPathEndpoint> {
298    trace_impl::trace_import_path(graph.as_graph(), root, from_path, to_path, false)
299}
300
301/// Trace the shortest import path over eager edges only: static imports that
302/// carry a runtime value. The route explains why `to_path` loads before
303/// `from_path` runs.
304///
305/// # Errors
306///
307/// Returns the endpoint that did not resolve to exactly one module in the graph.
308pub fn trace_eager_import_path(
309    graph: &RetainedModuleGraph,
310    root: &Path,
311    from_path: &str,
312    to_path: &str,
313) -> Result<ImportPathTrace, ImportPathEndpoint> {
314    trace_impl::trace_import_path(graph.as_graph(), root, from_path, to_path, true)
315}
316
317/// Trace the shortest import path through an existing analysis session.
318/// With `eager_only`, the walk follows static value imports only.
319///
320/// The inner `Result` carries the endpoint that did not resolve to a module.
321///
322/// # Errors
323///
324/// Returns an error if parsing or graph construction fails.
325pub fn trace_import_path_with_session(
326    session: &crate::session::AnalysisSession,
327    from_path: &str,
328    to_path: &str,
329    eager_only: bool,
330) -> crate::EngineResult<Result<ImportPathTrace, ImportPathEndpoint>> {
331    let output = session.analyze_dead_code_with_shared_artifacts(false, true)?;
332    let graph = output
333        .graph
334        .as_ref()
335        .ok_or_else(|| crate::EngineError::new("trace --path requires a retained module graph"))?;
336    Ok(trace_impl::trace_import_path(
337        graph.as_graph(),
338        session.root(),
339        from_path,
340        to_path,
341        eager_only,
342    ))
343}