fix(rust): resolve module-qualified calls via qualifier-verified cross-file resolution#2135
fix(rust): resolve module-qualified calls via qualifier-verified cross-file resolution#2135okinoxis wants to merge 1 commit into
Conversation
…s-file resolution Unresolved scoped calls (module::function()) were dropped at extraction, making the dominant idiomatic intra-crate call form invisible to the graph (a real production caller showed 0 dependents while grep found it). Keep the #908 bare-name guard, but stop discarding the qualifier — use it as the verification key: enqueue scoped calls with their qualifier path, then resolve strictly against it in the shared cross-file pass (defining file must BE the named module: <seg>.rs or <seg>/mod.rs; exactly one survivor or bail). Uppercase qualifiers (Type::method / Enum::Variant) and crate/super/self keep their existing skipped behavior. Emitted as EXTRACTED (confidence 1.0) — the module is named explicitly in source, same reasoning as the qualified-class-method passes (Graphify-Labs#1446/Graphify-Labs#1533). Defense-in-depth: the same branch in resolve_cross_file_raw_calls so scoped entries never reach bare-name resolution. Real-world Rust crate (~200 files, 14k edges): +138 EXTRACTED, -388 INFERRED (the #908 class shrinking), no regressions on bare-name-called control symbols.
safishamsi
left a comment
There was a problem hiding this comment.
Thanks @okinoxis — the problem is real and the qualifier-verified direction is right (the ambiguity bail-out and the untouched unqualified-call path are both good). Before this can land, two false positives need fixing, because as written it emits wrong edges at EXTRACTED (highest) confidence:
-
External std calls bind to a local same-named module.
std::fs::read("x")with an unrelated localfs.rsthat definesreadproduces an edge to the localread. Thestd::prefix is dropped and only the last segment is matched against the file basename. Shadowing std module names (fs,io,mem,time) with local wrapper modules is common Rust, andread/write/getare exactly the generic names the trait-method blocklist already exists to keep out. -
Cross-language phantom edges. A Rust
b::helper()binds to a Pythonb.pythat defineshelper, because the new branch runs before the existing cross-language guard and matches on stem across all languages.
Suggested changes:
- Restrict candidates to Rust sources (
.rs/ rust language family). - Verify the full qualifier suffix right-to-left against parent directory names, not just the last segment against the basename; stop at
crate/super/self. Thenstd::fs::readvssrc/fs.rsfails onstdvssrc, whilecrate::util::helpervssrc/util.rsstill resolves. - For single-segment
use std::fs; fs::read()with a localfs.rs, use the file'suseroots (already parsed) to skip when the qualifier is imported from a non-crate/self/super path. - Please add tests — the PR body suggests them but none are included — including the two negatives above.
- Needs a rebase; it currently conflicts with v8.
Happy to re-review once these are in.
Problem
For Rust, scoped calls (
module::function(...)) are dropped at extraction when the callee is not defined in the same file (extractors/rust.py). Since the module-qualified call is the DOMINANT idiomatic intra-crate call form, this makes the call graph blind to most cross-file Rust edges: a real production caller ofscan_substrate::accumulate_observations(...)showed 0 dependents whilegrepfound the call site immediately. Every downstream consumer (blast-radius queries, dependents listings) silently under-reports, which pushes users back to grep — defeating graph-first navigation exactly where it matters.The drop was intentional: resolving scoped calls by BARE last-segment lookup across crate boundaries produced spurious INFERRED edges (#908). The guard is right; the lost signal is not.
Solution
Keep the #908 guard, but stop discarding the qualifier — use it as the verification key:
raw_callswithis_scoped_call: Trueand itsscope_qualifier(thepathfield text of thescoped_identifier), instead of being dropped.is_scoped_callBEFORE bare-name resolution and never falls through to it:snake_case, typesUpperCamelCase— rustc warns otherwise) cheaply separatesmodule::func()fromType::method()/Enum::Variant; uppercase qualifiers keep their existing behavior (skipped).crate/super/selfqualifiers require real module-tree resolution and are conservatively skipped.<seg>.rsor<seg>/mod.rs); require exactly one survivor or bail (god-node guard preserved).EXTRACTED(confidence 1.0): the module is named explicitly in source — the same reasoning as the existing qualified-class-method passes (Python qualified class-method calls miss EXTRACTED edges #1446/Swift: static method calls (MemberAccessExpr) don't create caller→type edges + overloaded methods collapsed into single node #1533).resolve_cross_file_raw_calls, so scoped entries can never reach that path's bare-name resolution either (defense in depth; pair-dedup makes double emission impossible).Results (real-world Rust crate, ~200 source files, 14k-edge graph)
scan_substrate::accumulate_observations→ its caller,EXTRACTEDat the exact source line.Suggested tests
a.rs/b.rs,b::helper()called froma→ one EXTRACTED edge tob.rs'shelper, none to a same-namedhelperinc.rs.mod.rsform: callutil::helper()withutil/mod.rsdefininghelper→ resolves.helperdefined in BOTHb.rsandb/mod.rs(pathological) → no edge (two survivors).Type::method()/Enum::Variant(uppercase qualifier) → unchanged behavior (no new edge).crate::x::helper()/super::helper()/self::helper()→ skipped (no spurious edge).m::<T>-adjacent shapes → qualifier normalization strips generics.Known limitations (documented, out of scope here)
crate::/super::-qualified paths still unresolved (needs module-tree walking).usealias resolution (use a::b as c; c::f()) not attempted.