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
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.
…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. The qualifier is verified right-to-left against the candidate's file path: the last segment must BE the defining module (<seg>.rs or <seg>/mod.rs), every remaining segment must match the successive parent directory, stopping at crate/super/self. Candidates are restricted to Rust sources, and a qualifier bound by a use from outside the crate (use std::fs; fs::read()) is skipped at extraction via the file's use roots. Exactly one survivor or bail. Uppercase qualifiers (Type::method / Enum::Variant) keep their existing skipped behavior. Emitted as EXTRACTED (confidence 1.0): the module is named explicitly in source — the same reasoning as the qualified-class-method passes (Graphify-Labs#1446/Graphify-Labs#1533). The verification lives in one shared helper (rust_scoped_call_survivors) used by both cross-file passes, so scoped entries can never reach bare-name resolution in either. Real-world Rust crate (~200 files, 14k edges): +138 EXTRACTED, -388 INFERRED (the #908 class shrinking), no regressions on bare-name-called control symbols. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
0c93f39 to
322a2a9
Compare
|
@safishamsi Thanks for the review, both false positives were real. Everything is in: Full suffix verification: the qualifier now walks right-to-left against the Rust-only candidates: the helper skips any candidate not defined in a .rs Use roots: the extractor records what each Tests: Rebase: done on top of v8. The conflict was with the #2141 bash guard in the |
Problem
For Rust, scoped calls (
module::function(...)) are dropped at extraction whenthe callee is not defined in the same file. Since the module-qualified call is
the dominant idiomatic intra-crate call form, the call graph is blind to most
cross-file Rust edges: a real production caller of
scan_substrate::accumulate_observations(...)showed 0 dependents while grepfound the call site immediately. Blast-radius queries and dependents listings
silently under-report, which pushes users back to grep.
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 and use it as the
verification key instead:
raw_callswith its qualifier path instead of being dropped. The extractoralso records each file's
useroots; a call whose leading qualifier segmentwas bound by a
usefrom outside crate/super/self is skipped at enqueue(
use std::fs; fs::read()names std's fs, not a localfs.rs).entries go through one shared helper,
rust_scoped_call_survivors, and neverfall through to bare-name resolution. The qualifier is verified right-to-left
against the candidate's file path: the last segment must BE the defining
module (
<seg>.rsor<seg>/mod.rs), every remaining segment must match thesuccessive parent directory, stopping at crate/super/self. Candidates are
Rust-only. Exactly one survivor or bail (god-node guard preserved). Uppercase
qualifiers (
Type::method()/Enum::Variant) keep their old skippedbehavior. Emitted as EXTRACTED (confidence 1.0): the module is named
explicitly in source, same reasoning as the 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).
Results (real-world Rust crate, ~200 source files, 14k-edge graph)
scan_substrate::accumulate_observationsfindsits caller, EXTRACTED at the exact source line.
(risky bare-name resolutions either promoted with verification or dropped,
the #908 class shrinking), net +159 edges all with provenance.
Tests
tests/test_rust_scoped_call_resolution.py, 11 cases: module-qualified andmod.rsresolution, crate-qualified directory verification, use-bound from thecrate, std-shadowing negative (
std::fs::readwith a localfs.rs), use-boundfrom std negative, cross-language negative (Rust
b::helper()vs Pythonb.py), two-survivor ambiguity bail, uppercase qualifier, baresuper/self,and a bare-name control.
Known limitations (documented, out of scope here)
super::/self::qualifiers still unresolved (need a real module-treemodel; segments to their right are directory-verified).
usealias resolution (use a::b as c; c::f()) not attempted; external-rootedaliases are skipped, never mis-resolved.
not match the file stem).