Repository navigation
[Bug]: Rust: use edges target a bare name, and since 0.9.63 the stub they leave makes affected/explain ambiguous #4439
Description
Pre-flight checks
- I have checked the Troubleshooting section in the README
What happened?
A Rust use adds an imports_from edge whose target is the bare, case-folded name it imports (invoice for use crate::model::Invoice;), not the declaration's id. Nothing resolves that target directly. It reaches the declaration only when a sourceless node with the same id exists for _rewire_unique_stub_nodes to remap, which is what an unresolved type annotation such as fn f(i: &Invoice) creates. When the type is only constructed, called or not used at all, or when its annotations resolve straight to the declaration, no such node exists and the edge dangles.
Up to 0.9.62 the dangling edge was dropped, so the import was silently lost. Since 0.9.63 (#2873) its target is minted as an external stub labelled invoice, which ties with the real Invoice in name lookups: graphify affected Invoice prints No unique node match for Invoice and exits 0, and graphify explain Invoice reports Ambiguous: 'Invoice' matches 2 nodes in different files.
Expected: the edge lands on Invoice in src/model.rs, no stub is minted, and both commands resolve the name.
Steps to reproduce
# Cargo.toml
[package]
name = "repro"
version = "0.1.0"
edition = "2021"// src/lib.rs
pub mod billing;
pub mod model;
// src/model.rs
pub struct Invoice(pub u64);
// src/billing.rs
use crate::model::Invoice;
pub fn make() -> u64 {
Invoice(1).0
}graphify extract . --code-only --cargo --force
graphify cluster-only . --no-viz --no-label
graphify affected Invoice
graphify explain InvoiceAnnotating the type instead, with pub fn total(i: &Invoice) -> u64 { i.0 } in billing.rs, removes the stub and lands the edge on src_model_invoice.
Error output or graph output
| graphify | external stub | imports_from target |
affected Invoice |
explain Invoice |
|---|---|---|---|---|
| 0.9.62 | none | edge dropped | resolves | resolves |
| 0.9.63 | id: invoice, label: invoice, type: external, source_file: "" |
invoice, the stub |
No unique node match for Invoice, exit 0 |
Ambiguous: 'Invoice' matches 2 nodes in different files. |
| 0.9.89 | same | same | same | same |
On a private ~440-file Cargo workspace, 0.9.89 mints 1,007 external stubs. 426 share a case-folded label with a real declaration, 407 of them with exactly one candidate, and 403 names that affected resolves on 0.9.62 no longer resolve. Annotations do not rescue them there: one of those types is annotated at 13 sites, its 9 references edges land on the declaration directly, and all 7 of its imports_from edges point at its stub.
Graphify version
0.9.63 through 0.9.89, the latest. 0.9.62 is not affected.
Operating System
macOS 26.5.2 (arm64)
Python Version
3.14.7
Installation Method
uv: uv tool run --from 'graphifyy==<version>' graphify …
Additional Environment Details
No provider environment variables are set, every run uses --code-only, and the repro is a clean directory holding only the four files above.
Additional context
From the 0.9.89 source:
graphify/extractors/rust.py,use_declarationbranch:add_edge(file_nid, _make_id(<last path segment>), "imports_from", …)for eachuseand each binding of a groupeduse.graphify/extract.py,_rewire_unique_stub_nodes: remaps sourceless nodes, so an edge whose target no node carries is never considered. Instrumented on the repro: with the annotation, a sourcelessinvoicenode is present and the import edge moves frominvoicetosrc_model_invoice; without it, there is no such node and the edge stays oninvoice.graphify/build.py,_mint_external_stub([BUG] graph.json contains edges to undeclared nodes, so loaders materialise attribute-less phantom nodes (and merge-graphs fragments external modules per repo) #2873): mints the danglinginvoiceas thetype: "external"node that collides.
Related: #4134 makes affected prefer source-backed nodes. On the workspace graph above it resolves 401 of the 403 names to their declarations and none wrongly, the other 2 being genuine same-name declarations, but explain keeps the tie. #330 proposed resolving Rust use targets with a cross-file pass, and #2280 (Python) and #3565 (Elixir) are the same unresolved-import class in other extractors.
Activity
github-actions commentedon Oct 13, 2026
Thanks for opening this issue, @almothafar-maf. A maintainer will take a look soon.
If you would like to discuss it in real time, come say hi on our Discord server. For longer-form questions and ideas there is also GitHub Discussions.
To help us triage, please make sure the report includes what you expected, what actually happened, and the steps (and a small sample) to reproduce it.
liam-mcelhaney122 commentedon Oct 14, 2026
Adding measurements from a second, much larger Cargo workspace, and a note on where #4443 stops. I hit the same bug independently (a health check on a
/graphifyrun flagged thousands of danglingimports_fromedges), traced it to the same line inextractors/rust.py, and wrote a fix before finding this issue. Most of the evidence below is what I'd have put in a new issue, so it seemed better here than as a duplicate.What I'm seeing
The same root cause as the report: the
use_declarationbranch emitsimports_fromto_make_id(<last segment>), so the module path is discarded and the target only reaches a declaration by accident, when a type annotation happened to leave a sourceless node under the same id for_rewire_unique_stub_nodes.On a 519-file, multi-crate Cargo workspace (about ten crates, with
pub usere-export chains and manyuse super::*test modules), extracted withextract()on 0.9.90 (041771b) and counting on the pre-build extraction:ee27336a)imports_from)I ran #4443 as written against the same file copy, so the middle column is measured, not estimated. It does what it says: it roughly halves the stubs. What it leaves is the part it deliberately scopes out.
What remains after #4443, with a minimal repro
Three shapes still dangle, and each is visible in a four-file workspace:
Extracting that tree (no type annotation anywhere, so nothing masks the bug):
use core_lib::Stackimports_from->stack(dangling)imports_from->stack(dangling)imports->core_src_item_stack, EXTRACTEDuse super::*inmod testsimports_from->super(dangling)imports_from->super(dangling)imports_from-> the file itselfmakereferencesStackcore_src_item_stack, INFERREDuseon the declaration it imports #4443's second commit restricts resolution to paths rooted atcrate/self/super, because by-name matching aserde::Serializeagainst a corpusSerializewould be wrong. That's the right call for name matching, but in a workspaceuse core_lib::Stackdoes name a corpus item, and the Cargo manifests say so. 616 of the edges my branch retargets on the workspace above are of this form.Stackis declared incore/src/item.rsand exposed throughpub use item::Stack. Resolving by name alone works until two items share a name; following thepub usechain resolves it exactly and keeps the edge EXTRACTED.use super::*;/use crate::a::b::*;produce an edge to a baresuper/b. 243 on this workspace, 206 of themuse super::*inside#[cfg(test)] mod tests.useedges target a bare name, and since 0.9.63 the stub they leave makesaffected/explainambiguous #4439 as filed, but the same missing resolution): a type that is imported and then used in a signature, field orimplleaves a per-file<file>_rs_<name>node. 5,901referencesedges on this workspace still point at those placeholders after fix(rust): land auseon the declaration it imports #4443, instead of at the declaration the file'suseline names.The fix I wrote
Branch: https://github.com/liam-mcelhaney122/graphify/tree/fix/rust-use-path-resolution (based on
upstream/v8041771b; three commits).metadata.use_path, the normalised path as written (crate::util::parse as p,super::*,std::io::Read). Inlinemodscope is folded in, somod tests { use super::*; }is recorded asself::*.graphify/rust_resolution.py, called fromextract()next to the PHP/Java/Go passes and before_rewire_unique_stub_nodes. It builds the module tree from file layout plus the nearestCargo.toml[package] name, then resolves each path segment by segment through module files, a module's ownusebindings (re-export chains,pub use a::B as C), then its globs. Item targets becomeimports(file -> symbol, as JS/Python do); module/glob targets stayimports_fromto the file node.#[path]and inlinemod). Ambiguous names, external crates, and anything the tree can't reach are left untouched: no edge is deleted, none guessed.watch.pyforwardsimports/imports_fromedges that carryuse_pathas resolution context, so a changed file resolves through an unchanged file'spub usethe same way a full build does (there is a test for watch parity)._AST_CACHE_SCHEMA15 -> 16, since cached edges now carry the path (fix(rust): land auseon the declaration it imports #4443 bumps it too; one of the two PRs will need to rebase).diagnose_extractionreports import-family edges with a missing target asexternal_import_edgesinstead ofdangling_endpoint_edges, because build mints stubs for exactly those. A Rustuserooted atcrate/self/superthat fails to resolve still counts as dangling. This changes the meaning ofdangling_endpoint_edgesfor other languages' raw extractions (import os), so I kept it separate on purpose.Tests: 14 new tests in
tests/test_rust_use_path_resolution.py(two-crate fixture withpub usere-export,asalias, grouped imports,super::super::past the root, an ambiguous name that must NOT resolve,serde/stdthat must stay external, the INFERRED 0.85 fallback, placeholder dropped, watch parity, and a stale schema-15 cache entry that must not be served). They fail on the base. Three existing tests intests/test_rust_use_trees.pypinned the old bare-name targets and are updated. Full suite: 32 failed / 7,498 passed on the branch against 32 failed / 7,483 passed on the base, with an empty set difference of failing test ids. All 32 are pre-existing and unrelated (HCL grammar tests in my environment, plus one wheel-payload test and one markdown test).ruff checkis clean.What's still unresolved on the 519-file workspace
1,045 of the 1,131 leftover dangling edges are genuine external crates (std 668, bevy 161, serde 59, ...). The other 89 are
pub typealiases (the extractor never makes a node for a type alias), items declared inside amacro_rules!body, generated or inline modules, and test helpers defined in severalmod tests, which are left ambiguous on purpose. Not modelled, and documented in the module docstring:[lib] path, dependency renames (package = ".."), macro paths, and fully-qualified types at the use site (fn f(x: a::b::Foo)).Proposal
I don't want to land a 500-line resolver on top of a narrow fix that is already under review. #4443 fixes this issue as filed and fits the "small, auditable diff" preference in CONTRIBUTING.md, so I'd suggest it lands first. I'd then open a follow-up PR rebased onto it that covers the cross-crate, re-export, glob and placeholder cases above, with the diagnostics change as its own commit. If the maintainers would rather take the resolver directly, or want it split differently, say so and I'll adjust.
The fix was developed with Claude Code assistance; the measurements above were run on the exact trees named.
fix(rust): land a `use` on the declaration it imports (#4439)
ayushcodes10 commentedon Oct 16, 2026
Already fixed on v8: commit ef1d256 (
fix(rust): land auseon the declaration it imports (#4439)). Landed via #4443 (closed, not auto linked since it was cherry picked rather than merged through the PR button, same pattern as #2780/#2779, #4413/#4414, and #4435/#4442). Might be worth closing this one too.omsenjalia commentedon Oct 18, 2026
This looks fixed on the
v8branch by ef1d256e (fix(rust): land auseon the declaration it imports (#4439)). Could a maintainer confirm and close?almothafar-maf commentedon Oct 19, 2026
@ayushcodes10 @omsenjalia thanks for tracking down the cherry-pick. Confirmed: the repro as filed is fixed from 0.9.91, by ef1d256 and 5faa05c. Each version below was run cold through
uv tool run --from 'graphifyy==<version>' graphify,extract . --code-only --cargo --forcethencluster-only . --no-viz --no-label:imports_fromtargetaffected Invoiceexplain Invoiceinvoiceinvoice, the stubNo unique node match for InvoiceAmbiguous: 'Invoice' matches 2 nodes in different files., exit 3src_model_invoiceOn 0.9.92 a grouped
usewith an alias and a nested path (use crate::model::{Invoice, Customer as Client, ids::OrderId};) lands each binding on its declaration too, andstd::fmtandserde::Serializestay external.One shape is left: a
usewhose path starts at a child module, such aspub use item::Invoice;besidemod item;, still dangles into the same tying stub, because the fix names only paths rooted atcrate,selforsuper. I've filed it as #4564 with a repro and measurements, so I'm closing this one as fixed.fix: a Rust use rooted at a child mod reaches its declaration (#4564)