Skip to main content

fallow_core/
trace_chain.rs

1//! Symbol-level call chains (`fallow trace <symbol> --callers --callees`).
2//!
3//! Best-effort, syntactic (ADR-001), EXPLICITLY OFF the ranked path. This walk
4//! NEVER feeds the focus map / ranking (verified by a dedicated test in the
5//! CLI crate). It reports resolved-vs-unresolved callees HONESTLY: a referenced
6//! callee that the syntactic walk cannot resolve to an import-symbol edge is
7//! surfaced in [`SymbolChainTrace::unresolved_callees`], never silently dropped.
8//!
9//! ## What "resolved" means (honest scoping)
10//!
11//! The graph models import-symbol edges: module A imports binding `foo` from
12//! module B. From those edges this walk reconstructs:
13//!
14//! - **Callers (UP):** for a symbol `S` defined in module `M`, every module that
15//!   imports `S` from `M` (via [`ModuleGraph::importers_of`] + the per-edge
16//!   `ImportedSymbol` set), recursed through the importer's own binding up to
17//!   `depth`.
18//! - **Callees (DOWN):** the import-symbol edges OUT of `M` (resolved callees,
19//!   each a `(local, imported, target_module)` triple), recursed into the target
20//!   module's exports up to `depth`, PLUS the call sites in `M`
21//!   (`ModuleInfo.callee_uses`) whose leading identifier is bound to no import
22//!   (unresolved callees: locals, globals, dynamic dispatch, re-bound callees).
23//!
24//! NOT resolved (reported as unresolved or absent, the same class of limits the
25//! security taint walk carries): computed-member calls, dynamic dispatch,
26//! re-bound callees, methods reached only through type inference, and any callee
27//! with no import-symbol edge. The walk is MODULE-scoped, not per-function: a
28//! true intra-function dataflow is beyond ADR-001 syntactic scope.
29
30use std::path::{Path, PathBuf};
31
32use fallow_types::extract::{ImportedName, ModuleInfo};
33pub use fallow_types::trace_chain::{
34    ChainHop, DEFAULT_TRACE_DEPTH, SymbolChainQuery, SymbolChainTrace, TraceDirections,
35    UnresolvedCallee, UnresolvedReason,
36};
37use rustc_hash::{FxHashMap, FxHashSet};
38
39use crate::discover::FileId;
40use crate::graph::ModuleGraph;
41
42/// Trace the symbol-level call chain for `query.symbol` in `query.file`.
43///
44/// `modules` is the parsed `ModuleInfo` set (retained from the pipeline) used to
45/// surface unresolved callees; pass an empty slice to skip unresolved-callee
46/// reporting (callers / resolved callees still work from the graph alone).
47#[must_use]
48pub fn trace_symbol_chain(
49    graph: &ModuleGraph,
50    modules: &[ModuleInfo],
51    root: &Path,
52    query: SymbolChainQuery<'_>,
53) -> Option<SymbolChainTrace> {
54    let SymbolChainQuery {
55        file,
56        symbol,
57        depth,
58        directions,
59    } = query;
60    let module = graph
61        .modules
62        .iter()
63        .find(|m| path_matches(&m.path, root, file))?;
64    let rel_file = relativize(&module.path, root);
65
66    let symbol_found = module.exports.iter().any(|e| e.name.to_string() == *symbol);
67
68    let module_by_id: FxHashMap<FileId, &ModuleInfo> =
69        modules.iter().map(|m| (m.file_id, m)).collect();
70
71    let callers = directions
72        .callers
73        .then(|| collect_callers(graph, root, module.file_id, symbol, depth));
74    let callees_walk = directions
75        .callees
76        .then(|| collect_callees(graph, &module_by_id, root, module.file_id, symbol, depth));
77    let (callees, unresolved_callees) = match callees_walk {
78        Some((resolved, unresolved)) => (Some(resolved), Some(unresolved)),
79        None => (None, None),
80    };
81
82    let reason = build_reason(
83        symbol_found,
84        callers.as_deref(),
85        callees.as_deref(),
86        unresolved_callees.as_deref(),
87    );
88
89    Some(SymbolChainTrace {
90        file: rel_file,
91        symbol: symbol.to_string(),
92        symbol_found,
93        depth,
94        best_effort: true,
95        callers,
96        callees,
97        unresolved_callees,
98        reason,
99    })
100}
101
102/// Shared walk state, so the recursive helpers stay under the argument cap.
103struct WalkCtx<'a> {
104    graph: &'a ModuleGraph,
105    root: &'a Path,
106    max_depth: u32,
107}
108
109/// Walk UP: every module that imports `symbol` from `target`, recursed through
110/// each importer's own binding up to `depth`.
111fn collect_callers(
112    graph: &ModuleGraph,
113    root: &Path,
114    target: FileId,
115    symbol: &str,
116    depth: u32,
117) -> Vec<ChainHop> {
118    let ctx = WalkCtx {
119        graph,
120        root,
121        max_depth: depth,
122    };
123    let mut hops = Vec::new();
124    let mut visited: FxHashSet<(FileId, String)> = FxHashSet::default();
125    walk_callers_recursive(&ctx, target, symbol, 1, &mut visited, &mut hops);
126    hops.sort_by(|a, b| {
127        a.depth
128            .cmp(&b.depth)
129            .then_with(|| a.file.cmp(&b.file))
130            .then_with(|| a.local_name.cmp(&b.local_name))
131    });
132    hops
133}
134
135fn walk_callers_recursive(
136    ctx: &WalkCtx<'_>,
137    target: FileId,
138    symbol: &str,
139    current_depth: u32,
140    visited: &mut FxHashSet<(FileId, String)>,
141    hops: &mut Vec<ChainHop>,
142) {
143    if current_depth > ctx.max_depth {
144        return;
145    }
146    for &importer in ctx.graph.importers_of(target) {
147        for (edge_target, symbols) in ctx.graph.outgoing_symbol_edges(importer) {
148            if edge_target != target {
149                continue;
150            }
151            for sym in symbols {
152                if !imported_name_matches(&sym.imported_name, symbol) {
153                    continue;
154                }
155                let key = (importer, sym.local_name.clone());
156                if !visited.insert(key) {
157                    continue;
158                }
159                let importer_path = ctx.graph.modules.get(importer.0 as usize).map_or_else(
160                    || PathBuf::from("<unknown>"),
161                    |m| relativize(&m.path, ctx.root),
162                );
163                hops.push(ChainHop {
164                    file: importer_path,
165                    imported_as: imported_name_label(&sym.imported_name),
166                    local_name: sym.local_name.clone(),
167                    type_only: sym.is_type_only,
168                    depth: current_depth,
169                });
170                // Recurse: the importer's local binding becomes the next symbol
171                // whose callers we walk (covers a re-exported / forwarded symbol).
172                walk_callers_recursive(
173                    ctx,
174                    importer,
175                    &sym.local_name,
176                    current_depth + 1,
177                    visited,
178                    hops,
179                );
180            }
181        }
182    }
183}
184
185/// Walk DOWN: the import-symbol edges OUT of `module_id` (resolved callees),
186/// recursed into each target module's exports up to `depth`, plus the call sites
187/// in `module_id` whose callee did not resolve to an import binding (unresolved).
188fn collect_callees(
189    graph: &ModuleGraph,
190    module_by_id: &FxHashMap<FileId, &ModuleInfo>,
191    root: &Path,
192    module_id: FileId,
193    _symbol: &str,
194    depth: u32,
195) -> (Vec<ChainHop>, Vec<UnresolvedCallee>) {
196    let ctx = WalkCtx {
197        graph,
198        root,
199        max_depth: depth,
200    };
201    let mut resolved = Vec::new();
202    let mut visited: FxHashSet<FileId> = FxHashSet::default();
203    visited.insert(module_id);
204    walk_callees_recursive(&ctx, module_id, 1, &mut visited, &mut resolved);
205    resolved.sort_by(|a, b| {
206        a.depth
207            .cmp(&b.depth)
208            .then_with(|| a.file.cmp(&b.file))
209            .then_with(|| a.local_name.cmp(&b.local_name))
210    });
211
212    let unresolved = module_by_id
213        .get(&module_id)
214        .map(|info| collect_unresolved_callees(info))
215        .unwrap_or_default();
216
217    (resolved, unresolved)
218}
219
220fn walk_callees_recursive(
221    ctx: &WalkCtx<'_>,
222    module_id: FileId,
223    current_depth: u32,
224    visited: &mut FxHashSet<FileId>,
225    hops: &mut Vec<ChainHop>,
226) {
227    if current_depth > ctx.max_depth {
228        return;
229    }
230    for (edge_target, symbols) in ctx.graph.outgoing_symbol_edges(module_id) {
231        let target_path = ctx.graph.modules.get(edge_target.0 as usize).map_or_else(
232            || PathBuf::from("<unknown>"),
233            |m| relativize(&m.path, ctx.root),
234        );
235        for sym in symbols {
236            // Side-effect imports carry no value-level callee; skip them.
237            if matches!(sym.imported_name, ImportedName::SideEffect) {
238                continue;
239            }
240            hops.push(ChainHop {
241                file: target_path.clone(),
242                imported_as: imported_name_label(&sym.imported_name),
243                local_name: sym.local_name.clone(),
244                type_only: sym.is_type_only,
245                depth: current_depth,
246            });
247        }
248        if visited.insert(edge_target) {
249            walk_callees_recursive(ctx, edge_target, current_depth + 1, visited, hops);
250        }
251    }
252}
253
254/// Build the unresolved-callee list from a module's call sites: every
255/// `callee_uses` path whose leading identifier is bound to no import is
256/// surfaced (best-effort classification). A dotted path is `MemberOrDynamic`; a
257/// bare identifier with no import binding is `LocalOrGlobal`.
258fn collect_unresolved_callees(info: &ModuleInfo) -> Vec<UnresolvedCallee> {
259    let import_locals: FxHashSet<&str> =
260        info.imports.iter().map(|i| i.local_name.as_str()).collect();
261
262    let mut out = Vec::new();
263    let mut seen: FxHashSet<&str> = FxHashSet::default();
264    for callee in &info.callee_uses {
265        let path = callee.callee_path.as_str();
266        let leading = path.split('.').next().unwrap_or(path);
267        // A callee whose leading identifier is an imported binding resolves to a
268        // graph edge already (covered by the resolved callee list); skip it.
269        if import_locals.contains(leading) {
270            continue;
271        }
272        if !seen.insert(path) {
273            continue;
274        }
275        let reason = if path.contains('.') {
276            UnresolvedReason::MemberOrDynamic
277        } else {
278            UnresolvedReason::LocalOrGlobal
279        };
280        out.push(UnresolvedCallee {
281            callee: path.to_string(),
282            reason,
283        });
284    }
285    out.sort_by(|a, b| a.callee.cmp(&b.callee));
286    out
287}
288
289fn build_reason(
290    symbol_found: bool,
291    callers: Option<&[ChainHop]>,
292    callees: Option<&[ChainHop]>,
293    unresolved: Option<&[UnresolvedCallee]>,
294) -> String {
295    if !symbol_found {
296        return "symbol not found as an export of this file; chains are file-scoped best-effort and may be empty".to_string();
297    }
298    let mut parts = Vec::new();
299    if let Some(callers) = callers {
300        parts.push(format!("{} caller hop(s)", callers.len()));
301    }
302    if let Some(callees) = callees {
303        parts.push(format!("{} resolved callee hop(s)", callees.len()));
304    }
305    if let Some(unresolved) = unresolved {
306        parts.push(format!(
307            "{} unresolved callee(s) reported",
308            unresolved.len()
309        ));
310    }
311    format!(
312        "best-effort syntactic chain (ADR-001): {}",
313        parts.join(", ")
314    )
315}
316
317/// True when an [`ImportedName`] refers to `symbol`. A namespace import (`*`)
318/// brings every export of the target into scope, so it matches any symbol.
319fn imported_name_matches(name: &ImportedName, symbol: &str) -> bool {
320    match name {
321        ImportedName::Named(n) => n == symbol,
322        ImportedName::Default => symbol == "default",
323        ImportedName::Namespace => true,
324        ImportedName::SideEffect => false,
325    }
326}
327
328fn imported_name_label(name: &ImportedName) -> String {
329    match name {
330        ImportedName::Named(name) => name.clone(),
331        ImportedName::Default => "default".to_string(),
332        ImportedName::Namespace => "*".to_string(),
333        ImportedName::SideEffect => "side-effect".to_string(),
334    }
335}
336
337fn relativize(path: &Path, root: &Path) -> PathBuf {
338    path.strip_prefix(root).unwrap_or(path).to_path_buf()
339}
340
341/// Match a user-provided file path against a module's actual path. Mirrors
342/// `trace::path_matches` (kept local to avoid widening that private helper).
343fn path_matches(module_path: &Path, root: &Path, user_path: &str) -> bool {
344    let user_path_norm = user_path.replace('\\', "/");
345    let rel = module_path.strip_prefix(root).unwrap_or(module_path);
346    let rel_str = rel.to_string_lossy().replace('\\', "/");
347    let module_str = module_path.to_string_lossy().replace('\\', "/");
348    if rel_str == user_path_norm || module_str == user_path_norm {
349        return true;
350    }
351    if dunce::canonicalize(root).is_ok_and(|canonical_root| {
352        module_path
353            .strip_prefix(&canonical_root)
354            .is_ok_and(|rel| rel.to_string_lossy().replace('\\', "/") == user_path_norm)
355    }) {
356        return true;
357    }
358    module_str.ends_with(&format!("/{user_path_norm}"))
359}
360
361#[cfg(test)]
362mod tests {
363    use super::*;
364    use crate::analyze::test_support::empty_module;
365
366    #[test]
367    fn imported_name_matches_named_default_namespace() {
368        assert!(imported_name_matches(
369            &ImportedName::Named("foo".to_string()),
370            "foo"
371        ));
372        assert!(!imported_name_matches(
373            &ImportedName::Named("bar".to_string()),
374            "foo"
375        ));
376        assert!(imported_name_matches(&ImportedName::Default, "default"));
377        assert!(!imported_name_matches(&ImportedName::Default, "foo"));
378        // A namespace import brings every export into scope.
379        assert!(imported_name_matches(&ImportedName::Namespace, "anything"));
380        assert!(!imported_name_matches(&ImportedName::SideEffect, "foo"));
381    }
382
383    #[test]
384    fn unresolved_reason_classifies_member_vs_bare() {
385        let info = ModuleInfo {
386            callee_uses: vec![
387                fallow_types::extract::CalleeUse {
388                    callee_path: "localHelper".to_string(),
389                    span_start: 0,
390                },
391                fallow_types::extract::CalleeUse {
392                    callee_path: "obj.method".to_string(),
393                    span_start: 10,
394                },
395            ],
396            ..empty_module()
397        };
398        let unresolved = collect_unresolved_callees(&info);
399        assert_eq!(unresolved.len(), 2);
400        assert_eq!(unresolved[0].callee, "localHelper");
401        assert_eq!(unresolved[0].reason, UnresolvedReason::LocalOrGlobal);
402        assert_eq!(unresolved[1].callee, "obj.method");
403        assert_eq!(unresolved[1].reason, UnresolvedReason::MemberOrDynamic);
404    }
405
406    #[test]
407    fn imported_callees_are_not_listed_as_unresolved() {
408        let info = ModuleInfo {
409            imports: vec![fallow_types::extract::ImportInfo {
410                source: "./dep".to_string(),
411                imported_name: ImportedName::Named("dep".to_string()),
412                local_name: "dep".to_string(),
413                is_type_only: false,
414                from_style: false,
415                span: oxc_span::Span::default(),
416                source_span: oxc_span::Span::default(),
417            }],
418            callee_uses: vec![
419                fallow_types::extract::CalleeUse {
420                    callee_path: "dep".to_string(),
421                    span_start: 0,
422                },
423                fallow_types::extract::CalleeUse {
424                    callee_path: "ghost".to_string(),
425                    span_start: 5,
426                },
427            ],
428            ..empty_module()
429        };
430        let unresolved = collect_unresolved_callees(&info);
431        // `dep` resolves to an import (covered by resolved callees); only `ghost`
432        // is unresolved.
433        assert_eq!(unresolved.len(), 1);
434        assert_eq!(unresolved[0].callee, "ghost");
435    }
436}