Skip to content

fix(rust): scope a use to the crate it names (#4699) - #4712

Open
Deepjyoti-Sarmah wants to merge 7 commits into
Graphify-Labs:v8from
Deepjyoti-Sarmah:fix/4699-rust-crate-scope
Open

Deepjyoti-Sarmah wants to merge 7 commits into
Graphify-Labs:v8from
Deepjyoti-Sarmah:fix/4699-rust-crate-scope

Conversation

@Deepjyoti-Sarmah

@Deepjyoti-Sarmah Deepjyoti-Sarmah commented Oct 23, 2026 •

Copy link
Copy Markdown
Contributor

What does this PR do?

Fixes #4699. A Rust use could bind to a same-named definition in another crate. On tokio-rs/axum @ 86aa615, examples/customize-extractor-error/src/custom_extractor.rs says use serde_json::Value, and both its import and a Value reference bound to a variant in axum-core/src/response/into_response_parts.rs. The graph showed a cross-crate dependency the source never had.

Three causes, all in the Rust extractor and the corpus rewire:

  1. _rewire_unique_stub_nodes bound a sourceless stub to the only node with that label anywhere in the corpus. The crate the use names was not part of the decision.
  2. The extractor recorded the imported name (_rust_use_name) only for paths rooted in this crate, so an external use carried no scope.
  3. A scoped binding and a label-only guess both reported EXTRACTED.
 extractors/rust.py
   use binding
+    record the first path segment on the stub (`_rust_use_roots`)
+    record a bare `pub use` as a re-export (`_rust_pub_use`)

 extract.py
-  _rewire_unique_stub_nodes(nodes, edges)
+  _rewire_unique_stub_nodes(nodes, edges, root)
+    resolve each .rs file's crate from the nearest Cargo.toml
     bind a stub to a unique same-label definition
+    consider only candidates in a crate the `use` names
+    follow `pub use` re-exports across crates
+    leave the stub unbound when the named crate is outside the scan
+    mark a rewire with no resolving `use` as INFERRED

 cargo_introspect.py
+  package_name(manifest)   # reuses the module's TOML loader

Before (v8, the reported file):

imports_from  custom_extractor.rs  ->  Value @ axum-core/.../into_response_parts.rs
references    handler()            ->  Value @ axum-core/.../into_response_parts.rs

After:

imports_from  custom_extractor.rs  ->  Value  (a local sourceless stub)
references    handler()            ->  Value  (a local sourceless stub)

The re-export handling keeps edges the scoping would otherwise drop. axum-extra/src/extract/cached.rs (use axum::extract::FromRequestParts) and axum/src/extract/connect_info.rs (use super::{.., FromRequestParts}) still reach axum-core/src/extract/mod.rs, and a use axum_core::.. edge stays EXTRACTED.

Type of change

  • Bug fix
  • New feature
  • Documentation
  • Tests or CI
  • Refactor
  • Security fix

Verification & Invariants

A Rust name binds only inside a crate its use names. A crate outside the scan binds to nothing. A cross-crate re-export counts as the named crate. A binding made by label alone is INFERRED.

  • Read the CONTRIBUTING.md guide.
  • Reproduced the issue and identified the invariant.
  • Made the smallest fix necessary.
  • Added a regression test (if bug fix) or isolated boundary test.
  • Kept the PR description synchronized with the final implementation.
  • Documented any limitations / unsupported cases explicitly.

Merge Danger

Door: two-way. The per-file AST payload gained two markers, so the cache schema moves from 22 to 24 and cached .rs extractions re-run on the next build. A revert brings back the label-only binding on the next build. No migration, no config change.

Blast Radius: Rust cross-file resolution.

On tokio-rs/axum @ 86aa615 (302 .rs files):

v8 this PR
nodes 4036 4141
edges 10326 10326
cross-crate imports_from 125 163
cross-crate references 122 168
bug-signature edges (target label is an external use of the source file) 22 13
confidence 10195 EXTRACTED / 131 INFERRED 9828 EXTRACTED / 498 INFERRED
import cycles 20 20
time (median of 3) 2.05 s 1.97 s

The node count rises because a scoped stub that binds to nothing stays as a sourceless node instead of being dropped. Cross-crate edges rise because a name with several same-named definitions can now resolve to the one in the crate it names instead of staying ambiguous. Bug-signature edges drop from 22 to 13. The 20 cycles do not change; the reported Value edge was not part of one.

  • Other languages: scoping only applies when origin_file ends in .rs, so no other extractor changes. The full-suite failure set is identical to v8.
  • Saved graphs: the cache bump forces a re-extraction, so an existing graph.json keeps its old Rust edges until the next build. A rebuild produces the scoped result.
  • Determinism: crate resolution reads only paths and Cargo.toml files, with no environment or ordering input.

Limitations

  • No manifest: a file with no Cargo.toml above it keeps v8's label-only binding and is marked INFERRED. Scoping needs a crate.
  • Glob re-exports: pub use core::*; is not followed; only a named pub use is.
  • Renamed dependencies: foo = { package = "bar" } in Cargo.toml is not mapped, so use foo::.. stays unbound.
  • Macros: a re-export produced by a macro is invisible to the extractor.

How was this tested?

New tests in tests/test_rust_crate_use_scope.py run extract() on small Cargo projects and compare node ids, edge targets, and confidence. None of them calls a private helper.

Test v8 This PR
an external use plus an annotation does not reach a local crate (enum variant) fail pass
an external use plus an annotation does not reach a local crate (struct) fail pass
a grouped external use does not reach a local crate fail pass
an annotation with no use does not reach a local crate fail pass
use a::Value reaches a, not a namesake in c fail pass
use osiris_types::Config does not reach server's Config fail pass
use osiris_types::Config reaches osiris-types when both define it fail pass
a use of a re-exporting crate reaches the defining crate, not an unrelated namesake fail pass
pub(crate) use does not leak out of its crate fail pass
a manifest-less binding is INFERRED, not EXTRACTED fail pass
guard: external use with no reference, std, crate::, same-name types without a use pass pass
guard: use super::Item through a re-export reaches the defining crate pass pass
guard: a scanned-crate use keeps EXTRACTED pass pass
pytest tests/test_rust_crate_use_scope.py (v8)             -> 10 failed, 9 passed
pytest tests/test_rust_crate_use_scope.py (this branch)    -> 19 passed
pytest test_rust_* test_extract.py test_cache.py test_grammar_cache_key.py test_cargo_* \
                                                           -> 576 passed, 4 skipped
pytest -q -p no:randomly (full suite, serial)              -> v8:      39 failed, 7988 passed, 361 skipped
                                                              this PR:  39 failed, 8007 passed, 361 skipped
                                                              (failure sets identical)

Graphify-specific checklist

  • I updated generated skill artifacts (uv run python -m tools.skillgen --bless) when changing their source fragments. (N/A: no skill fragments touched.)
  • I confirmed that AST/structural extraction remains deterministic (no ambient state dependencies like ENV variables).
  • I reviewed changes for security implications (no unsafe interpolation into shell/Python).
  • I confirmed no API keys or local-only graph data are included.
  • (If applicable) I disclosed AI authorship in my commit messages.

…hify-Labs#4699)

The corpus rewire bound a sourceless `Value` stub to the only `Value` in
the scan even when the file said `use serde_json::Value`, so a field named
`value` could absorb an external import and report cycles the source does
not have.

The extractor already knows the first path segment of every `use` binding;
it now carries that on the stub as `_rust_use_roots`, and the rewire only
considers candidates that live in a crate the `use` names. A crate outside
the scan is in no scanned crate, so the stub stays unbound: `use a::Value`
reaches `a`'s definition and not a namesake in `c`. A file with no
Cargo.toml above it keeps v8's label-only binding, because there is no crate
to scope it by.

Manifests are read through cargo_introspect.package_name, which reuses the
module's tomllib loader and its Python 3.10 fallback. The marker is part of
the per-file AST payload, so the cache schema is bumped.
…hify-Labs#4699)

Each fixture writes a Cargo.toml per crate, since that is what makes the
crate scoping possible. The failing cases are the reported one (external
`use` plus an annotation), a grouped external `use`, an annotation with no
`use` at all, and a workspace `use` that should reach the named crate rather
than a namesake. The guards keep an external `use` with no reference, a
`std` import, `crate::` paths, and two same-named types without a `use`
behaving as they did.
…4699)

The crate pass leaves a Rust stub unscoped when there is no Cargo.toml to
scope it by (a loose .rs scan) or when its `use` names a crate the corpus
does not contain. Those stubs still bound by label alone, and the edge was
reported as EXTRACTED even though the binding is a guess.

An edge that rewires a Rust stub is now INFERRED unless an in-crate `use`
of the name (`_rust_use_name`) vouches for it. The wrong edge is still gone
where the crate pass can scope it; this only stops presenting a guess as
extracted in the cases it cannot.
…Labs#4699)

With no Cargo.toml there is no crate to scope the external `use` by, so the
rewire still binds by label; the test pins that this guess is INFERRED. The
guard pins that a `crate::` `use` keeps its binding EXTRACTED.
…ify-Labs#4699)

The crate scoping only allowed the crate named by the `use`, so
`use axum::extract::FromRequestParts` (the type lives in axum-core and axum
re-exports it) stayed unbound and the correct edge was lost. The same pass
left a correct `use axum_core::..` at INFERRED, because only an in-crate
`use` counted as a voucher.

A bare `pub use` now records the crate it re-exports from on its import edge;
the rewire follows those mappings across crates and allows the defining crate
after the directly named one, which still wins. `pub(crate)`/`pub(super)`
stay inside their crate. A stub whose `use` names a scanned crate counts as
vouched, so its edge keeps EXTRACTED.

The per-file edge payload changed, so the cache schema moves to 24.
…bs#4699)

Covers `use reexporting_crate::Item` reaching the defining crate, an unrelated
namesake staying out, `use super::Item` through a re-export (the axum
connect_info shape), `pub(crate) use` not leaking out of its crate, and the
confidence staying EXTRACTED for a scanned-crate `use`.
@github-actions github-actions Bot added the contributor Pull request from a returning community contributor label Oct 23, 2026
@github-actions

Copy link
Copy Markdown

Thanks for the pull request, @Deepjyoti-Sarmah. 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.

@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.

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 unsupported).

Not verified on this run: \_rewire\_unique\_stub\_nodes (unsupported).


Graphify review — findings

Scopes Rust use stub rewiring to the crate the import names, so use other_crate::Thing binds to a definition in other_crate instead of any same-labelled node in the corpus. Crate membership comes from the nearest Cargo.toml via the new package_name, which falls back to a regex when no TOML parser is available. Files with no manifest keep the old label-only binding. A pub use chain lets an import of a re-exporting crate reach the defining one, but the named crate always wins when it has the definition, and the AST cache schema bumps to 24 so .rs extractions re-run. The hardcoded ["crate"] roots on stubs, the use of Cargo package names rather than Rust crate names, and the alias-suffix handling on single-segment imports all decide whether workspace imports bind to the right crate.

Worth a look

  • Rust use stubs hardcode _rust_use_roots to ["crate"], so imports of other workspace crates stop resolving — graphify/extract.py:10371 · Escalate · medium
    • NOT verified (no proof, no reproducing execution) — needs human review
  • Rust crate scoping uses Cargo package names instead of Rust crate names — graphify/extract.py:3949 · Escalate · medium
    • NOT verified (no proof, no reproducing execution) — needs human review
  • Unrelated non-Rust definitions can block a Rust import scoped to a workspace crate — graphify/extract.py:3988 · Escalate · medium
    • NOT verified (no proof, no reproducing execution) — needs human review
  • Use root keeps the as alias suffix for single-segment aliased imports — graphify/extractors/rust.py:793 · Escalate · medium
    • NOT verified (no proof, no reproducing execution) — needs human review
Analysis details — impact, health, verification

Impact & health

Graphify review

Impact — 3696 functions depend on the 484 functions this change touches.

Health — this change adds coupling hotspots:

  • worse: extract() — 1002 callers, 60 callees
  • worse: _rewire_unique_stub_nodes() — 14 callers, 16 callees

Verification — 3696 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: 3447 function(s) in the blast radius were not formally verified this run

Test selection

Test selection

233 of 444 test file(s) selected (52%) via static blast radius.

  • 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_cache_stale_import_target.py — impact
  • tests/test_cargo_introspect.py — impact
  • tests/test_case_sensitive_resolution.py — impact
  • tests/test_charmap_encoding.py — impact
  • tests/test_chunking.py — impact
  • tests/test_cjs_module_extension.py — impact
  • tests/test_cobol_extractor.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_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_object_creation.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_ejs_extractor.py — impact
  • tests/test_elixir_defdelegate.py — impact
  • tests/test_elixir_import_resolution.py — impact
  • tests/test_elixir_keyword_def_calls.py — impact
  • tests/test_elixir_qualified_calls.py — impact
  • tests/test_elixir_unqualified_call_scope.py — impact
  • … and 183 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

Could not verify: Could not verify \_rewire\_unique\_stub\_nodes.

The verifier did not have enough to check \_rewire\_unique\_stub\_nodes, 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: not verifiable: the input domain has 81 values but only 11 distinct were tested — a small finite domain must be EXHAUSTED, not sampled (an untested input could invert the result)

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.

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

Comment thread graphify/extract.py


def _rewire_unique_stub_nodes(nodes: list[dict], edges: list[dict]) -> None:
def _rewire_unique_stub_nodes(

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 — _rewire_unique_stub_nodes()

fans out to 16 callees (efferent coupling); 14 callers depend on it (afferent coupling).

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

This branch has not been deployed

No deployments
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.

Rust: use of an external crate item resolves to an unrelated same-named local symbol (false imports_from edges, phantom import cycles)

1 participant