Skip to content

[Bug]: Rust: use edges target a bare name, and since 0.9.63 the stub they leave makes affected/explain ambiguous #4439

Closed

Description

@almothafar-maf

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 Invoice

Annotating 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:

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

  1. github-actions commented on Oct 13, 2026

    @github-actions

    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.

  2. liam-mcelhaney122 commented on Oct 14, 2026

    @liam-mcelhaney122
    Contributor

    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 /graphify run flagged thousands of dangling imports_from edges), traced it to the same line in extractors/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_declaration branch emits imports_from to _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 use re-export chains and many use super::* test modules), extracted with extract() on 0.9.90 (041771b) and counting on the pre-build extraction:

    0.9.90 + #4443 (ee27336a) branch below
    dangling endpoint edges (all imports_from) 4,591 2,619 1,131
    external stubs minted at build 1,524 805 214
    sourceless placeholder nodes 1,619 1,619 1,102
    edges touching placeholders 6,965 6,965 4,578
    INFERRED edges 1,518 1,518 1,388

    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:

    # Cargo.toml
    [workspace]
    members = ["core", "app"]
    resolver = "2"
    # core/Cargo.toml
    [package]
    name = "core_lib"
    version = "0.1.0"
    edition = "2021"
    # app/Cargo.toml
    [package]
    name = "app"
    version = "0.1.0"
    edition = "2021"
    [dependencies]
    core_lib = { path = "../core" }
    // core/src/lib.rs
    mod item;
    pub use item::Stack;
    // core/src/item.rs
    pub struct Stack(pub u32);
    // app/src/lib.rs
    use core_lib::Stack;
    
    pub fn make() -> u32 { Stack(1).0 }
    
    #[cfg(test)]
    mod tests {
        use super::*;
        fn t() -> u32 { make() }
    }

    Extracting that tree (no type annotation anywhere, so nothing masks the bug):

    edge 0.9.90 + #4443 branch
    use core_lib::Stack imports_from -> stack (dangling) imports_from -> stack (dangling) imports -> core_src_item_stack, EXTRACTED
    use super::* in mod tests imports_from -> super (dangling) imports_from -> super (dangling) imports_from -> the file itself
    make references Stack -> core_src_item_stack, INFERRED INFERRED EXTRACTED (the import is now evidence)
    1. Cross-crate paths. fix(rust): land a use on the declaration it imports #4443's second commit restricts resolution to paths rooted at crate/self/super, because by-name matching a serde::Serialize against a corpus Serialize would be wrong. That's the right call for name matching, but in a workspace use core_lib::Stack does 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.
    2. Re-exports. Stack is declared in core/src/item.rs and exposed through pub use item::Stack. Resolving by name alone works until two items share a name; following the pub use chain resolves it exactly and keeps the edge EXTRACTED.
    3. Globs. use super::*; / use crate::a::b::*; produce an edge to a bare super/b. 243 on this workspace, 206 of them use super::* inside #[cfg(test)] mod tests.
    4. Placeholder type references (not part of [Bug]: Rust: use edges target a bare name, and since 0.9.63 the stub they leave makes affected/explain ambiguous #4439 as filed, but the same missing resolution): a type that is imported and then used in a signature, field or impl leaves a per-file <file>_rs_<name> node. 5,901 references edges on this workspace still point at those placeholders after fix(rust): land a use on the declaration it imports #4443, instead of at the declaration the file's use line names.

    The fix I wrote

    Branch: https://github.com/liam-mcelhaney122/graphify/tree/fix/rust-use-path-resolution (based on upstream/v8 041771b; three commits).

    • Extractor: each Rust import edge now carries metadata.use_path, the normalised path as written (crate::util::parse as p, super::*, std::io::Read). Inline mod scope is folded in, so mod tests { use super::*; } is recorded as self::*.
    • New pass graphify/rust_resolution.py, called from extract() next to the PHP/Java/Go passes and before _rewire_unique_stub_nodes. It builds the module tree from file layout plus the nearest Cargo.toml [package] name, then resolves each path segment by segment through module files, a module's own use bindings (re-export chains, pub use a::B as C), then its globs. Item targets become imports (file -> symbol, as JS/Python do); module/glob targets stay imports_from to the file node.
    • Confidence: exact tree, re-export and glob resolution keeps EXTRACTED. The only fallback is "defined exactly once in the target crate unit", which is marked INFERRED at 0.85 (this covers #[path] and inline mod). Ambiguous names, external crates, and anything the tree can't reach are left untouched: no edge is deleted, none guessed.
    • Placeholders are resolved per (referencing file, label) through the same machinery and dropped once nothing references them.
    • Incremental builds: watch.py forwards imports/imports_from edges that carry use_path as resolution context, so a changed file resolves through an unchanged file's pub use the same way a full build does (there is a test for watch parity).
    • Cache: _AST_CACHE_SCHEMA 15 -> 16, since cached edges now carry the path (fix(rust): land a use on the declaration it imports #4443 bumps it too; one of the two PRs will need to rebase).
    • Diagnostics (separate commit, can be dropped): diagnose_extraction reports import-family edges with a missing target as external_import_edges instead of dangling_endpoint_edges, because build mints stubs for exactly those. A Rust use rooted at crate/self/super that fails to resolve still counts as dangling. This changes the meaning of dangling_endpoint_edges for 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 with pub use re-export, as alias, grouped imports, super::super:: past the root, an ambiguous name that must NOT resolve, serde/std that 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 in tests/test_rust_use_trees.py pinned 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 check is 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 type aliases (the extractor never makes a node for a type alias), items declared inside a macro_rules! body, generated or inline modules, and test helpers defined in several mod 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.

  3. ayushcodes10 commented on Oct 16, 2026

    @ayushcodes10
    Contributor

    Already fixed on v8: commit ef1d256 (fix(rust): land a use on 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.

  4. omsenjalia commented on Oct 18, 2026

    @omsenjalia
    Contributor

    This looks fixed on the v8 branch by ef1d256e (fix(rust): land a use on the declaration it imports (#4439)). Could a maintainer confirm and close?

  5. almothafar-maf commented on Oct 19, 2026

    @almothafar-maf
    Author

    @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 --force then cluster-only . --no-viz --no-label:

    graphify external stub imports_from target affected Invoice explain Invoice
    0.9.62 none edge dropped resolves resolves
    0.9.90 invoice invoice, the stub No unique node match for Invoice Ambiguous: 'Invoice' matches 2 nodes in different files., exit 3
    0.9.91, 0.9.92 none src_model_invoice resolves resolves

    On 0.9.92 a grouped use with an alias and a nested path (use crate::model::{Invoice, Customer as Client, ids::OrderId};) lands each binding on its declaration too, and std::fmt and serde::Serialize stay external.

    One shape is left: a use whose path starts at a child module, such as pub use item::Invoice; beside mod item;, still dangles into the same tying stub, because the fix names only paths rooted at crate, self or super. I've filed it as #4564 with a repro and measurements, so I'm closing this one as fixed.

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions