Skip to content

fix(rust): resolve module-qualified calls (revives #2135) - #4944

Closed
nothariharan wants to merge 2 commits into
Graphify-Labs:v8from
nothariharan:fix/rust-scoped-calls
Closed

nothariharan wants to merge 2 commits into
Graphify-Labs:v8from
nothariharan:fix/rust-scoped-calls

Conversation

@nothariharan

Copy link
Copy Markdown
Contributor

Revives #2135 by @okinoxis, rebased onto current v8 with authorship kept, plus review follow-ups.

Problem

A Rust call written with a module path (crate::formatter::format_line(), formatter::format_line(), super::formatter::format_line()) never became a calls edge, so explain showed 0 callers. A user reported this on a 100k-line Rust repo, and I reproduced it on v8 32e5efe.

Change

  • okinoxis's commit (from fix(rust): resolve module-qualified calls via qualifier-verified cross-file resolution #2135): enqueue scoped calls with their qualifier. Resolve one only when the qualifier matches the candidate's file path right to left and exactly one candidate survives. This addresses both false positives from the July review (std::fs::read vs a local fs.rs, and Rust b::helper() vs Python b.py), with 11 tests. Rebase note: v8 now has its own use_roots (sets, aware of crate-local mods), so I dropped the PR's copy and ported its scoped-call step onto v8's.
  • Follow-ups:
    • a candidate must be in the caller's crate (nearest Cargo.toml, else the directory above src), so crate::util::f() can't reach another workspace crate;
    • crate::b::helper() in a.rs no longer binds to a.rs's own helper. A module-path call binds in-file only for self::f() or an inline mod m { };
    • _-prefixed module names are accepted;
    • methods are skipped as candidates. Its test passes with or without the guard, so treat it as protection against regressions.

Evidence

  • Repro crate: 0 → 3 caller edges for the three path forms. Two builds give identical graphs.
  • 15 tests in tests/test_rust_scoped_call_resolution.py. The new ones fail with the follow-up reverted, except the method guard.
  • Full suite: 9006 passed. The 14 failures also fail on plain v8 on this Windows machine (test_ext_tagged_id_clash, test_code_only_update_all_hosts[devin/aider], the memoization getcwd tests, and a flaky test_partial_extraction_warning). Ruff is clean, and the pyright count is unchanged from v8.
  • I didn't bump the AST cache schema, so it can go into the next batch bump.

Not covered: m::f() inside a macro (see the macro-argument PR). The scoped resolution is also still duplicated across extract.py and symbol_resolution.py. That was already the case in #2135, and a shared edge builder would be a follow-up.

Supersedes #2135.

okinoxis and others added 2 commits November 2, 2026 08:35
…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>
…f local names

Review follow-ups on the rebased Graphify-Labs#2135.

- `crate::util::f()` stopped matching at `crate` without checking which
  crate the candidate is in, so in a workspace it bound to another crate's
  util.rs. A candidate must now share the caller's crate root (nearest
  Cargo.toml, else the directory above `src`).
- `crate::b::helper()` in a.rs went through the in-file bare-name lookup
  first and bound to a.rs's own helper. A module-path call binds in-file only
  for `self::f()` or an inline `mod m { .. }` declared in that file.
- Module names starting with `_` were rejected (`'_'.islower()` is False);
  only an UpperCamelCase segment means a type.
- Methods are skipped as candidates: a module path names a free function.
@github-actions

github-actions Bot commented Nov 2, 2026

Copy link
Copy Markdown

Thanks for the pull request, @nothariharan. A maintainer will review it soon.

Want to talk it through while it is in review? Come join us on our Discord server. For longer-form discussion there is also GitHub Discussions.

A couple of things that speed up review: make sure the test suite passes on Python 3.10 and 3.13, and that the change keeps extraction deterministic.

@github-actions github-actions Bot added the contributor Pull request from a returning community contributor label Nov 2, 2026

@graphify-labs graphify-labs Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

View in Graphify

Graphify reviewed this change.

Worth a look — the grounded gate found no coupling regressions or blocking issues, but 4 advisory finding(s) below merit a look before merge.

Not checked: tests were not run; formal verification proved 0 of 3 changed function(s) (2 sampled, not proven, 1 not verified).

Formal verification. PR-changed functions: 2/3 verified (0 proven, 2 may-equivalent, 0 distinguished) · 1 not verified (1 vacuous).

Not verified on this run: resolve\_cross\_file\_raw\_calls (vacuous: never exercised).


Graphify review — findings

Adds resolution of Rust module-qualified calls (module::function()), which were previously dropped at extraction and left idiomatic intra-crate callers with no dependents. In extract_rust, a scoped call binds in-file only for self::f() or an inline mod m { .. }, otherwise it is queued with its qualifier (dropped when the first segment is imported from anything other than crate/super/self), and the unknown-root default of crate-local is where extern or std paths could still slip through. In extract(), rust_scoped_call_survivors checks the qualifier right-to-left against each candidate's .rs path and emits an EXTRACTED calls edge only when exactly one candidate survives, so ambiguous or unmatched calls stay unresolved and never reach the bare-name tie-breakers that the #908 guard protects against.

Worth a look

  • Unknown path roots default to crate-local, so extern/std calls can bind to local files — graphify/extractors/rust.py:989 · Escalate · medium
    • NOT verified (no proof, no reproducing execution) — needs human review
  • Single-segment qualifier binds an imported std module to a same-named local file — graphify/symbol_resolution.py:390 · Escalate · medium
    • NOT verified (no proof, no reproducing execution) — needs human review
  • Scoped inline-module calls bind by bare function name — graphify/extractors/rust.py:957 · Escalate · medium
    • NOT verified (no proof, no reproducing execution) — needs human review
  • Scoped-call branch in extract() is not gated to Rust — graphify/extract.py:11102 · Escalate · medium
    • NOT verified (no proof, no reproducing execution) — needs human review
Analysis details — impact, health, verification

Impact & health

Graphify review

Impact — 3671 functions depend on the 432 functions this change touches.

Health — this change adds coupling hotspots:

  • worse: extract() — 1099 callers, 65 callees
  • worse: resolve_cross_file_raw_calls() — 9 callers, 6 callees

Verification — 3671 functions in the blast radius were not formally verified this run (proofs are advisory here).

Gate & verification

graphify gate

PASS — objectively clean (no health regressions, tests not run — proofs not run this pass (advisory)). Grounded, not self-assessed.

Advisory (not blocking):

  • verification_scope: 3409 function(s) in the blast radius were not formally verified this run

Test selection

Test selection

239 of 490 test file(s) selected (49%) via static blast radius.

  • tests/test_4874_jvm_resolution.py — impact
  • tests/test_affected_overrides.py — impact
  • tests/test_astro_extraction.py — impact
  • tests/test_astro_import_ids.py — impact
  • tests/test_blade_extractor.py — impact
  • tests/test_build.py — impact
  • tests/test_builtin_global_type_refs.py — impact
  • tests/test_c_include_resolution.py — impact
  • tests/test_cache.py — impact
  • tests/test_case_only_decl_ids_4904.py — impact
  • tests/test_case_sensitive_resolution.py — impact
  • tests/test_cjs_module_extension.py — impact
  • tests/test_cobol_extractor.py — impact
  • tests/test_confidence_score_output.py — impact
  • tests/test_cpp_method_declarations.py — impact
  • tests/test_cpp_nested_and_cli.py — impact
  • tests/test_cpp_objc_cross_file_calls.py — impact
  • tests/test_cpp_template_type_params.py — impact
  • tests/test_cpp_type_alias.py — impact
  • tests/test_cross_extension_reexport_self_cycle.py — impact
  • tests/test_cross_language_call_resolution.py — impact
  • tests/test_cross_repo_external_call_guards.py — impact
  • tests/test_cross_repo_member_calls.py — impact
  • tests/test_csharp_call_site_generic_args.py — impact
  • tests/test_csharp_delegate_declarations.py — impact
  • tests/test_csharp_enum_members.py — impact
  • tests/test_csharp_field_generic_args.py — impact
  • tests/test_csharp_foreach_loop_variable.py — impact
  • tests/test_csharp_generic_callsites.py — impact
  • tests/test_csharp_interface_dispatch.py — impact
  • tests/test_csharp_member_calls.py — impact
  • tests/test_csharp_member_chain_calls.py — impact
  • tests/test_csharp_member_nodes.py — impact
  • tests/test_csharp_method_owner_labels.py — impact
  • tests/test_csharp_object_creation.py — impact
  • tests/test_csharp_overloads.py — impact
  • tests/test_csharp_partial_classes.py — impact
  • tests/test_csharp_tuple_type_refs.py — impact
  • tests/test_csharp_type_resolution.py — impact
  • tests/test_csharp_underscore_collisions.py — impact
  • tests/test_csharp_var_call_return_type.py — impact
  • tests/test_csharp_verbatim_interpolation.py — impact
  • tests/test_definition_file_portability.py — impact
  • tests/test_detect.py — impact
  • tests/test_dotnet.py — impact
  • tests/test_duplicate_annotation_edges.py — impact
  • tests/test_edge_also_seen.py — impact
  • tests/test_ejs_extractor.py — impact
  • tests/test_elixir_defdelegate.py — impact
  • tests/test_elixir_import_resolution.py — impact
  • … and 189 more

Selection is safe under the controlled-regression assumption; always-run tests + a periodic full run are the backstops. Advisory — it never changes the check verdict.

Docs that may be stale (advisory)

…and 10 more.

Formal verification

No difference found (not proven): No behavior difference found in extract (not a proof).

The verifier ran both versions of extract on many inputs and saw identical behavior every time. Strong evidence the change is safe, but evidence, not a proof.

Guarantee: Empirical: differential testing (both versions run on many generated inputs). A divergence on an untested input remains possible, so this is 'no counterexample found', not 'proven equivalent'.

Note: An input the sampler did not try could still differ.

No difference found (not proven): No behavior difference found in extract\_rust (not a proof).

The verifier ran both versions of extract\_rust on many inputs and saw identical behavior every time. Strong evidence the change is safe, but evidence, not a proof.

Guarantee: Empirical: differential testing (both versions run on many generated inputs). A divergence on an untested input remains possible, so this is 'no counterexample found', not 'proven equivalent'.

Note: An input the sampler did not try could still differ.

Could not verify: Could not verify resolve\_cross\_file\_raw\_calls.

The verifier did not have enough to check resolve\_cross\_file\_raw\_calls, so it is saying so rather than guessing. No false assurance is the whole point.

Guarantee: No guarantee either way, this is an honest abstention, not a pass.

Note: Reason: no capturable inputs from the test suite; property tier: non-vacuity: domain too small (only 1 distinct inputs exercised, need 3) — 'no divergence' would be near-vacuous

· 1 grounded finding(s) anchored inline below; 1 more finding(s) on lines outside this diff (see the check run).

return survivors


def resolve_cross_file_raw_calls(

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Health regression — resolve_cross_file_raw_calls()

fans out to 6 callees (efferent coupling); 9 callers depend on it (afferent coupling).

Grounded coupling-delta finding (deterministic), not an LLM guess.

@safishamsi safishamsi closed this in 739f34d Nov 2, 2026
@safishamsi

Copy link
Copy Markdown
Member

Shipped in 0.9.101 (published to PyPI), with your authorship preserved. Revives #2135. Thanks @nothariharan.

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

Labels

contributor Pull request from a returning community contributor

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants