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};
33use fallow_types::serde_path;
34use rustc_hash::{FxHashMap, FxHashSet};
35use serde::Serialize;
36
37use crate::discover::FileId;
38use crate::graph::ModuleGraph;
39
40/// Default chain depth when `--depth` is unset. Small by design: symbol-level is
41/// best-effort, so a shallow bound keeps the trace legible and bounded.
42pub const DEFAULT_TRACE_DEPTH: u32 = 2;
43
44/// Which directions to walk.
45#[derive(Debug, Clone, Copy)]
46pub struct TraceDirections {
47    /// Walk UP to callers (modules that import the symbol).
48    pub callers: bool,
49    /// Walk DOWN to callees (modules / call sites the symbol's module reaches).
50    pub callees: bool,
51}
52
53/// The result of a symbol-level call-chain trace. Its own surface (`kind:
54/// "trace"`), NOT folded into the ranked brief.
55#[derive(Debug, Clone, Serialize)]
56#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
57pub struct SymbolChainTrace {
58    /// The file containing the traced symbol (project-root-relative).
59    #[serde(serialize_with = "serde_path::serialize")]
60    pub file: PathBuf,
61    /// The traced symbol name.
62    pub symbol: String,
63    /// Whether the symbol's defining export was found in the graph. When
64    /// `false`, the chains are empty and `reason` explains why.
65    pub symbol_found: bool,
66    /// The chain depth applied to both directions.
67    pub depth: u32,
68    /// Whether this trace is best-effort (always `true`: symbol-level chains are
69    /// labeled best-effort, syntactic per ADR-001).
70    pub best_effort: bool,
71    /// Caller chain hops (UP). Present only when `--callers` was requested.
72    #[serde(default, skip_serializing_if = "Option::is_none")]
73    pub callers: Option<Vec<ChainHop>>,
74    /// Callee chain hops (DOWN) resolved to an import-symbol edge. Present only
75    /// when `--callees` was requested.
76    #[serde(default, skip_serializing_if = "Option::is_none")]
77    pub callees: Option<Vec<ChainHop>>,
78    /// Callees referenced at a call site in the symbol's module that the
79    /// syntactic walk could NOT resolve to an import-symbol edge (locals,
80    /// globals, dynamic dispatch, re-bound callees). Reported, never dropped.
81    /// Present only when `--callees` was requested.
82    #[serde(default, skip_serializing_if = "Option::is_none")]
83    pub unresolved_callees: Option<Vec<UnresolvedCallee>>,
84    /// A human-readable summary of the trace outcome.
85    pub reason: String,
86}
87
88/// One hop in a caller / callee chain.
89#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
90#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
91pub struct ChainHop {
92    /// The file at this hop (project-root-relative). For a caller hop this is
93    /// the importing module; for a callee hop the imported module.
94    #[serde(serialize_with = "serde_path::serialize")]
95    pub file: PathBuf,
96    /// The symbol name as imported across the edge (`default`, `*` for namespace,
97    /// the imported name otherwise).
98    pub imported_as: String,
99    /// The local binding name in the file at this hop.
100    pub local_name: String,
101    /// Whether the import edge is type-only (`import type { ... }`).
102    pub type_only: bool,
103    /// The hop's depth (1 = direct caller/callee of the symbol).
104    pub depth: u32,
105}
106
107/// A callee referenced at a call site that did not resolve to an import-symbol
108/// edge. Surfaced so a missing callee is never silently dropped.
109#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
110#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
111pub struct UnresolvedCallee {
112    /// The callee path as written at the call site (e.g. `helper`,
113    /// `obj.method`).
114    pub callee: String,
115    /// Why it is unresolved (best-effort classification).
116    pub reason: UnresolvedReason,
117}
118
119/// Best-effort classification of why a callee did not resolve to an edge.
120#[derive(Debug, Clone, Copy, Serialize, PartialEq, Eq)]
121#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
122#[serde(rename_all = "kebab-case")]
123pub enum UnresolvedReason {
124    /// A bare identifier call with no matching import binding (a same-module
125    /// local function, a global, or a re-bound callee).
126    LocalOrGlobal,
127    /// A computed / member-expression callee (`obj.method`, dynamic dispatch).
128    MemberOrDynamic,
129}
130
131/// Target + traversal parameters for a symbol-chain trace.
132#[derive(Debug, Clone, Copy)]
133pub struct SymbolChainQuery<'a> {
134    /// File path of the target symbol (root-relative or absolute).
135    pub file: &'a str,
136    /// The exported symbol name to trace.
137    pub symbol: &'a str,
138    /// Maximum traversal depth in each direction.
139    pub depth: u32,
140    /// Which directions to walk (callers UP / callees DOWN).
141    pub directions: TraceDirections,
142}
143
144/// Trace the symbol-level call chain for `query.symbol` in `query.file`.
145///
146/// `modules` is the parsed `ModuleInfo` set (retained from the pipeline) used to
147/// surface unresolved callees; pass an empty slice to skip unresolved-callee
148/// reporting (callers / resolved callees still work from the graph alone).
149#[must_use]
150pub fn trace_symbol_chain(
151    graph: &ModuleGraph,
152    modules: &[ModuleInfo],
153    root: &Path,
154    query: SymbolChainQuery<'_>,
155) -> Option<SymbolChainTrace> {
156    let SymbolChainQuery {
157        file,
158        symbol,
159        depth,
160        directions,
161    } = query;
162    let module = graph
163        .modules
164        .iter()
165        .find(|m| path_matches(&m.path, root, file))?;
166    let rel_file = relativize(&module.path, root);
167
168    let symbol_found = module.exports.iter().any(|e| e.name.to_string() == *symbol);
169
170    let module_by_id: FxHashMap<FileId, &ModuleInfo> =
171        modules.iter().map(|m| (m.file_id, m)).collect();
172
173    let callers = directions
174        .callers
175        .then(|| collect_callers(graph, root, module.file_id, symbol, depth));
176    let callees_walk = directions
177        .callees
178        .then(|| collect_callees(graph, &module_by_id, root, module.file_id, symbol, depth));
179    let (callees, unresolved_callees) = match callees_walk {
180        Some((resolved, unresolved)) => (Some(resolved), Some(unresolved)),
181        None => (None, None),
182    };
183
184    let reason = build_reason(
185        symbol_found,
186        callers.as_deref(),
187        callees.as_deref(),
188        unresolved_callees.as_deref(),
189    );
190
191    Some(SymbolChainTrace {
192        file: rel_file,
193        symbol: symbol.to_string(),
194        symbol_found,
195        depth,
196        best_effort: true,
197        callers,
198        callees,
199        unresolved_callees,
200        reason,
201    })
202}
203
204/// Shared walk state, so the recursive helpers stay under the argument cap.
205struct WalkCtx<'a> {
206    graph: &'a ModuleGraph,
207    root: &'a Path,
208    max_depth: u32,
209}
210
211/// Walk UP: every module that imports `symbol` from `target`, recursed through
212/// each importer's own binding up to `depth`.
213fn collect_callers(
214    graph: &ModuleGraph,
215    root: &Path,
216    target: FileId,
217    symbol: &str,
218    depth: u32,
219) -> Vec<ChainHop> {
220    let ctx = WalkCtx {
221        graph,
222        root,
223        max_depth: depth,
224    };
225    let mut hops = Vec::new();
226    let mut visited: FxHashSet<(FileId, String)> = FxHashSet::default();
227    walk_callers_recursive(&ctx, target, symbol, 1, &mut visited, &mut hops);
228    hops.sort_by(|a, b| {
229        a.depth
230            .cmp(&b.depth)
231            .then_with(|| a.file.cmp(&b.file))
232            .then_with(|| a.local_name.cmp(&b.local_name))
233    });
234    hops
235}
236
237fn walk_callers_recursive(
238    ctx: &WalkCtx<'_>,
239    target: FileId,
240    symbol: &str,
241    current_depth: u32,
242    visited: &mut FxHashSet<(FileId, String)>,
243    hops: &mut Vec<ChainHop>,
244) {
245    if current_depth > ctx.max_depth {
246        return;
247    }
248    for &importer in ctx.graph.importers_of(target) {
249        for (edge_target, symbols) in ctx.graph.outgoing_symbol_edges(importer) {
250            if edge_target != target {
251                continue;
252            }
253            for sym in symbols {
254                if !imported_name_matches(&sym.imported_name, symbol) {
255                    continue;
256                }
257                let key = (importer, sym.local_name.clone());
258                if !visited.insert(key) {
259                    continue;
260                }
261                let importer_path = ctx.graph.modules.get(importer.0 as usize).map_or_else(
262                    || PathBuf::from("<unknown>"),
263                    |m| relativize(&m.path, ctx.root),
264                );
265                hops.push(ChainHop {
266                    file: importer_path,
267                    imported_as: imported_name_label(&sym.imported_name),
268                    local_name: sym.local_name.clone(),
269                    type_only: sym.is_type_only,
270                    depth: current_depth,
271                });
272                // Recurse: the importer's local binding becomes the next symbol
273                // whose callers we walk (covers a re-exported / forwarded symbol).
274                walk_callers_recursive(
275                    ctx,
276                    importer,
277                    &sym.local_name,
278                    current_depth + 1,
279                    visited,
280                    hops,
281                );
282            }
283        }
284    }
285}
286
287/// Walk DOWN: the import-symbol edges OUT of `module_id` (resolved callees),
288/// recursed into each target module's exports up to `depth`, plus the call sites
289/// in `module_id` whose callee did not resolve to an import binding (unresolved).
290fn collect_callees(
291    graph: &ModuleGraph,
292    module_by_id: &FxHashMap<FileId, &ModuleInfo>,
293    root: &Path,
294    module_id: FileId,
295    _symbol: &str,
296    depth: u32,
297) -> (Vec<ChainHop>, Vec<UnresolvedCallee>) {
298    let ctx = WalkCtx {
299        graph,
300        root,
301        max_depth: depth,
302    };
303    let mut resolved = Vec::new();
304    let mut visited: FxHashSet<FileId> = FxHashSet::default();
305    visited.insert(module_id);
306    walk_callees_recursive(&ctx, module_id, 1, &mut visited, &mut resolved);
307    resolved.sort_by(|a, b| {
308        a.depth
309            .cmp(&b.depth)
310            .then_with(|| a.file.cmp(&b.file))
311            .then_with(|| a.local_name.cmp(&b.local_name))
312    });
313
314    let unresolved = module_by_id
315        .get(&module_id)
316        .map(|info| collect_unresolved_callees(info))
317        .unwrap_or_default();
318
319    (resolved, unresolved)
320}
321
322fn walk_callees_recursive(
323    ctx: &WalkCtx<'_>,
324    module_id: FileId,
325    current_depth: u32,
326    visited: &mut FxHashSet<FileId>,
327    hops: &mut Vec<ChainHop>,
328) {
329    if current_depth > ctx.max_depth {
330        return;
331    }
332    for (edge_target, symbols) in ctx.graph.outgoing_symbol_edges(module_id) {
333        let target_path = ctx.graph.modules.get(edge_target.0 as usize).map_or_else(
334            || PathBuf::from("<unknown>"),
335            |m| relativize(&m.path, ctx.root),
336        );
337        for sym in symbols {
338            // Side-effect imports carry no value-level callee; skip them.
339            if matches!(sym.imported_name, ImportedName::SideEffect) {
340                continue;
341            }
342            hops.push(ChainHop {
343                file: target_path.clone(),
344                imported_as: imported_name_label(&sym.imported_name),
345                local_name: sym.local_name.clone(),
346                type_only: sym.is_type_only,
347                depth: current_depth,
348            });
349        }
350        if visited.insert(edge_target) {
351            walk_callees_recursive(ctx, edge_target, current_depth + 1, visited, hops);
352        }
353    }
354}
355
356/// Build the unresolved-callee list from a module's call sites: every
357/// `callee_uses` path whose leading identifier is bound to no import is
358/// surfaced (best-effort classification). A dotted path is `MemberOrDynamic`; a
359/// bare identifier with no import binding is `LocalOrGlobal`.
360fn collect_unresolved_callees(info: &ModuleInfo) -> Vec<UnresolvedCallee> {
361    let import_locals: FxHashSet<&str> =
362        info.imports.iter().map(|i| i.local_name.as_str()).collect();
363
364    let mut out = Vec::new();
365    let mut seen: FxHashSet<&str> = FxHashSet::default();
366    for callee in &info.callee_uses {
367        let path = callee.callee_path.as_str();
368        let leading = path.split('.').next().unwrap_or(path);
369        // A callee whose leading identifier is an imported binding resolves to a
370        // graph edge already (covered by the resolved callee list); skip it.
371        if import_locals.contains(leading) {
372            continue;
373        }
374        if !seen.insert(path) {
375            continue;
376        }
377        let reason = if path.contains('.') {
378            UnresolvedReason::MemberOrDynamic
379        } else {
380            UnresolvedReason::LocalOrGlobal
381        };
382        out.push(UnresolvedCallee {
383            callee: path.to_string(),
384            reason,
385        });
386    }
387    out.sort_by(|a, b| a.callee.cmp(&b.callee));
388    out
389}
390
391fn build_reason(
392    symbol_found: bool,
393    callers: Option<&[ChainHop]>,
394    callees: Option<&[ChainHop]>,
395    unresolved: Option<&[UnresolvedCallee]>,
396) -> String {
397    if !symbol_found {
398        return "symbol not found as an export of this file; chains are file-scoped best-effort and may be empty".to_string();
399    }
400    let mut parts = Vec::new();
401    if let Some(callers) = callers {
402        parts.push(format!("{} caller hop(s)", callers.len()));
403    }
404    if let Some(callees) = callees {
405        parts.push(format!("{} resolved callee hop(s)", callees.len()));
406    }
407    if let Some(unresolved) = unresolved {
408        parts.push(format!(
409            "{} unresolved callee(s) reported",
410            unresolved.len()
411        ));
412    }
413    format!(
414        "best-effort syntactic chain (ADR-001): {}",
415        parts.join(", ")
416    )
417}
418
419/// True when an [`ImportedName`] refers to `symbol`. A namespace import (`*`)
420/// brings every export of the target into scope, so it matches any symbol.
421fn imported_name_matches(name: &ImportedName, symbol: &str) -> bool {
422    match name {
423        ImportedName::Named(n) => n == symbol,
424        ImportedName::Default => symbol == "default",
425        ImportedName::Namespace => true,
426        ImportedName::SideEffect => false,
427    }
428}
429
430fn imported_name_label(name: &ImportedName) -> String {
431    match name {
432        ImportedName::Named(name) => name.clone(),
433        ImportedName::Default => "default".to_string(),
434        ImportedName::Namespace => "*".to_string(),
435        ImportedName::SideEffect => "side-effect".to_string(),
436    }
437}
438
439fn relativize(path: &Path, root: &Path) -> PathBuf {
440    path.strip_prefix(root).unwrap_or(path).to_path_buf()
441}
442
443/// Match a user-provided file path against a module's actual path. Mirrors
444/// `trace::path_matches` (kept local to avoid widening that private helper).
445fn path_matches(module_path: &Path, root: &Path, user_path: &str) -> bool {
446    let user_path_norm = user_path.replace('\\', "/");
447    let rel = module_path.strip_prefix(root).unwrap_or(module_path);
448    let rel_str = rel.to_string_lossy().replace('\\', "/");
449    let module_str = module_path.to_string_lossy().replace('\\', "/");
450    if rel_str == user_path_norm || module_str == user_path_norm {
451        return true;
452    }
453    if dunce::canonicalize(root).is_ok_and(|canonical_root| {
454        module_path
455            .strip_prefix(&canonical_root)
456            .is_ok_and(|rel| rel.to_string_lossy().replace('\\', "/") == user_path_norm)
457    }) {
458        return true;
459    }
460    module_str.ends_with(&format!("/{user_path_norm}"))
461}
462
463#[cfg(test)]
464mod tests {
465    use super::*;
466    use crate::analyze::test_support::empty_module;
467
468    #[test]
469    fn imported_name_matches_named_default_namespace() {
470        assert!(imported_name_matches(
471            &ImportedName::Named("foo".to_string()),
472            "foo"
473        ));
474        assert!(!imported_name_matches(
475            &ImportedName::Named("bar".to_string()),
476            "foo"
477        ));
478        assert!(imported_name_matches(&ImportedName::Default, "default"));
479        assert!(!imported_name_matches(&ImportedName::Default, "foo"));
480        // A namespace import brings every export into scope.
481        assert!(imported_name_matches(&ImportedName::Namespace, "anything"));
482        assert!(!imported_name_matches(&ImportedName::SideEffect, "foo"));
483    }
484
485    #[test]
486    fn unresolved_reason_classifies_member_vs_bare() {
487        let info = ModuleInfo {
488            callee_uses: vec![
489                fallow_types::extract::CalleeUse {
490                    callee_path: "localHelper".to_string(),
491                    span_start: 0,
492                },
493                fallow_types::extract::CalleeUse {
494                    callee_path: "obj.method".to_string(),
495                    span_start: 10,
496                },
497            ],
498            ..empty_module()
499        };
500        let unresolved = collect_unresolved_callees(&info);
501        assert_eq!(unresolved.len(), 2);
502        assert_eq!(unresolved[0].callee, "localHelper");
503        assert_eq!(unresolved[0].reason, UnresolvedReason::LocalOrGlobal);
504        assert_eq!(unresolved[1].callee, "obj.method");
505        assert_eq!(unresolved[1].reason, UnresolvedReason::MemberOrDynamic);
506    }
507
508    #[test]
509    fn imported_callees_are_not_listed_as_unresolved() {
510        let info = ModuleInfo {
511            imports: vec![fallow_types::extract::ImportInfo {
512                source: "./dep".to_string(),
513                imported_name: ImportedName::Named("dep".to_string()),
514                local_name: "dep".to_string(),
515                is_type_only: false,
516                from_style: false,
517                span: oxc_span::Span::default(),
518                source_span: oxc_span::Span::default(),
519            }],
520            callee_uses: vec![
521                fallow_types::extract::CalleeUse {
522                    callee_path: "dep".to_string(),
523                    span_start: 0,
524                },
525                fallow_types::extract::CalleeUse {
526                    callee_path: "ghost".to_string(),
527                    span_start: 5,
528                },
529            ],
530            ..empty_module()
531        };
532        let unresolved = collect_unresolved_callees(&info);
533        // `dep` resolves to an import (covered by resolved callees); only `ghost`
534        // is unresolved.
535        assert_eq!(unresolved.len(), 1);
536        assert_eq!(unresolved[0].callee, "ghost");
537    }
538}