Skip to content

fix(rust): resolve module-qualified calls via qualifier-verified cross-file resolution - #2135

Open
okinoxis wants to merge 1 commit into
Graphify-Labs:v8from
okinoxis:rust-scoped-call-resolution
Open

fix(rust): resolve module-qualified calls via qualifier-verified cross-file resolution#2135
okinoxis wants to merge 1 commit into
Graphify-Labs:v8from
okinoxis:rust-scoped-call-resolution

Conversation

@okinoxis

@okinoxis okinoxis commented Jul 23, 2026

Copy link
Copy Markdown

Problem

For Rust, scoped calls (module::function(...)) are dropped at extraction when
the 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 grep
found 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:

  1. extractors/rust.py: an unresolved scoped call is enqueued into
    raw_calls with its qualifier path instead of being dropped. The extractor
    also records each file's use roots; a call whose leading qualifier segment
    was bound by a use from outside crate/super/self is skipped at enqueue
    (use std::fs; fs::read() names std's fs, not a local fs.rs).
  2. extract.py / symbol_resolution.py (both cross-file passes): scoped
    entries go through one shared helper, rust_scoped_call_survivors, and never
    fall 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>.rs or <seg>/mod.rs), every remaining segment must match the
    successive 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 skipped
    behavior. 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)

  • The motivating case resolves: scan_substrate::accumulate_observations finds
    its caller, EXTRACTED at the exact source line.
  • Graph-wide: +138 EXTRACTED edges (qualifier-verified), -388 INFERRED edges
    (risky bare-name resolutions either promoted with verification or dropped,
    the #908 class shrinking), net +159 edges all with provenance.
  • No regressions on control symbols.

Tests

tests/test_rust_scoped_call_resolution.py, 11 cases: module-qualified and
mod.rs resolution, crate-qualified directory verification, use-bound from the
crate, std-shadowing negative (std::fs::read with a local fs.rs), use-bound
from std negative, cross-language negative (Rust b::helper() vs Python
b.py), two-survivor ambiguity bail, uppercase qualifier, bare super/self,
and a bare-name control.

Known limitations (documented, out of scope here)

  • Bare super::/self:: qualifiers still unresolved (need a real module-tree
    model; segments to their right are directory-verified).
  • use alias resolution (use a::b as c; c::f()) not attempted; external-rooted
    aliases are skipped, never mis-resolved.
  • bin to lib fully-qualified paths still unresolved (crate-name qualifier does
    not match the file stem).

@safishamsi safishamsi left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:

  1. External std calls bind to a local same-named module. std::fs::read("x") with an unrelated local fs.rs that defines read produces an edge to the local read. The std:: 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, and read/write/get are exactly the generic names the trait-method blocklist already exists to keep out.

  2. Cross-language phantom edges. A Rust b::helper() binds to a Python b.py that defines helper, 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. Then std::fs::read vs src/fs.rs fails on std vs src, while crate::util::helper vs src/util.rs still resolves.
  • For single-segment use std::fs; fs::read() with a local fs.rs, use the file's use roots (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>
@okinoxis
okinoxis force-pushed the rust-scoped-call-resolution branch from 0c93f39 to 322a2a9 Compare July 27, 2026 10:31
@okinoxis

okinoxis commented Jul 27, 2026

Copy link
Copy Markdown
Author

@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
candidate's directories, stopping at crate/super/self. std::fs::read() vs a
local src/fs.rs dies on std vs src, and crate::util::helper() vs
src/util.rs resolves, which also removes the crate:: limitation from the
original description. The check lives in one helper, rust_scoped_call_survivors,
shared by both cross-file passes. While testing I accidentally proved your point:
the extractor change plus an unguarded shared pass reproduces the exact #908 edge
(Url::parse binding into crate_a), so both passes sharing the helper is what
keeps the guard honest.

Rust-only candidates: the helper skips any candidate not defined in a .rs
file. The Rust b::helper() vs Python b.py phantom has a test.

Use roots: the extractor records what each use binds and from where. A
scoped call whose leading segment came from outside crate/super/self is skipped
at enqueue: use std::fs; fs::read() with a local fs.rs emits nothing, and
use crate::util; util::helper() still resolves. Both tested.

Tests: tests/test_rust_scoped_call_resolution.py, 11 cases: the positives
(plain, mod.rs, crate-qualified, use-bound from the crate), your two negatives,
the use-root negative, ambiguity bail, uppercase qualifier, bare super/self, and
a bare-name control. Full suite is at parity with v8: everything that fails on
this branch fails the same way on pristine v8 (test_cache and test_terraform
consistently, plus a few order-dependent ones that pass in isolation on both).

Rebase: done on top of v8. The conflict was with the #2141 bash guard in the
shared pass, resolved keeping both guards.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants