fix(rust): land a use on the declaration it imports (#4439)

A Rust `use` targets the bare, case-folded name it imports. That reached the
declaration only when a type annotation in the corpus had left a sourceless
node under the same id for the stub rewire to remap. Otherwise the edge
dangled, and since 0.9.63 build mints the target as an external stub that
ties with the real declaration in `affected` and `explain`.

The `use` edge now carries the name as written, and the rewire is lent a
sourceless node for each imported name that has none. The lent nodes are not
kept, so an import with no unique declaration dangles exactly as before.
Standard-library paths are left out.

An incremental build re-extracts only the changed files, so the rewire also
takes the unchanged ones as candidates; without that it resolved less than a
full build.

Bumps the AST cache schema: cached edges lack the name.

(cherry picked from commit 672a2b69b1)
This commit is contained in:
berce authored and safishamsi committed 2026-10-14 22:34:17 +01:00
1 parent 1af5104b68
commit ef1d256ec8
4 files changed
+63 -6

No files matched your search

+1 -1
View File
@@ -37,7 +37,7 @@ except Exception:
_EXTRACTOR_VERSION = "unknown"
# Bump when AST cache-key semantics change independently of the package version.
_AST_CACHE_SCHEMA = 15 # Go generic receiver types normalized (`Box[T]` -> `Box`) so methods attach to the type node; prior (14): C# receiver resolution for nameless/`new X()`/`foreach` receivers (defer + receiver-chain markers) and `_`/case-distinct node ids (underscore-salted); prior (13): Kotlin/Scala/C++/Swift generic & template type params no longer mint sourceless stubs; Objective-C NS_ENUM/NS_OPTIONS extraction and `_callable`/`_callable_class` markers; Go and PHP receiver-typed member calls park cross-repo (`go_type_table`/`php_type_table`, receiver fields); prior (12): Java inherited-supertype (`receiver_unbound`) and chained/new/cast receiver typing (`java_receiver_chain`, `_java_ret`); C/C++ quoted cross-directory include provenance (`include_kind`); JS external imports anchored to the package, not the path leaf; prior (11): Python property-accessor decorators / `Self`, Swift `Self`, Go generic type params, Python forward type-hint file node, PowerShell dot-source, C# verbatim-interpolated string recovery; (10): C# partial types carry `type_path`; (9): Python property-read raw calls and `_py_property` nodes; (8): Rust `use`-group per-binding edges; (7): Python opaque-base/super(args) markers + nested-class enclosing ids.
_AST_CACHE_SCHEMA = 16 # Rust `use` edges carry the imported name as written (`_rust_use_name`, #4439); prior (15): Go generic receiver types normalized (`Box[T]` -> `Box`) so methods attach to the type node; prior (14): C# receiver resolution for nameless/`new X()`/`foreach` receivers (defer + receiver-chain markers) and `_`/case-distinct node ids (underscore-salted); prior (13): Kotlin/Scala/C++/Swift generic & template type params no longer mint sourceless stubs; Objective-C NS_ENUM/NS_OPTIONS extraction and `_callable`/`_callable_class` markers; Go and PHP receiver-typed member calls park cross-repo (`go_type_table`/`php_type_table`, receiver fields); prior (12): Java inherited-supertype (`receiver_unbound`) and chained/new/cast receiver typing (`java_receiver_chain`, `_java_ret`); C/C++ quoted cross-directory include provenance (`include_kind`); JS external imports anchored to the package, not the path leaf; prior (11): Python property-accessor decorators / `Self`, Swift `Self`, Go generic type params, Python forward type-hint file node, PowerShell dot-source, C# verbatim-interpolated string recovery; (10): C# partial types carry `type_path`; (9): Python property-read raw calls and `_py_property` nodes; (8): Rust `use`-group per-binding edges; (7): Python opaque-base/super(args) markers + nested-class enclosing ids.
# Per-process memo: distributions() scans site-packages metadata, which is
+17 -1
View File
@@ -9933,7 +9933,22 @@ def extract(
except Exception as exc:
import logging
logging.getLogger(__name__).warning("Cross-file import resolution failed, skipping: %s", exc)
_rewire_unique_stub_nodes(all_nodes, all_edges)
# An incremental build re-extracts only the changed files, so the rewire also
# needs the unchanged ones as candidates, or it resolves less than a full build.
_fresh_ids = {n.get("id") for n in all_nodes}
# #4439: a Rust `use` reached its declaration only when a type annotation had left
# a sourceless node under the same id. Lend the rewire one for each imported name
# that has none; they are not kept, so an unresolved import dangles as before.
_use_stubs = {
e["target"]: {"id": e["target"], "label": e["_rust_use_name"], "file_type": "code", "source_file": ""}
for e in all_edges
if e.get("_rust_use_name", "").isidentifier() and e["target"] not in _fresh_ids
}
_rewire_view = all_nodes + list(_use_stubs.values()) + [
n for n in resolution_context_nodes or [] if n.get("id") not in _fresh_ids]
_rewire_unique_stub_nodes(_rewire_view, all_edges)
_kept = {id(n) for n in _rewire_view} - {id(n) for n in _use_stubs.values()}
all_nodes[:] = [n for n in all_nodes if id(n) in _kept]
# Cross-file Java import resolution
java_paths = [p for p in paths if effective_suffix(p) == ".java"]
@@ -10561,6 +10576,7 @@ def extract(
all_edges[:] = [e for e in all_edges if not e.pop("_rust_use_group", False)]
for e in all_edges:
e.pop("_rust_use_binding", None)
e.pop("_rust_use_name", None)
# Relativize source_file fields so paths are portable across machines (#555).
# When the node's id was itself minted from the absolute path, remap it to a
+7
View File
@@ -721,17 +721,24 @@ def extract_rust(path: Path) -> dict:
if t == "use_declaration":
arg = node.child_by_field_name("argument")
if arg:
def name_use(written: str, path: str) -> None:
# The name as written, for the corpus rewire (#4439): the id folds its
# case. Not for the standard library, whose `File` is never the corpus's.
if path.lstrip(": ").split("::")[0].strip() not in ("std", "core", "alloc"):
edges[-1]["_rust_use_name"] = written.split(" as ")[0].strip()
module_name = _rust_use_module(_read_text(arg, source))
grouped = arg.type in ("use_list", "scoped_use_list")
if module_name:
tgt_nid = _make_id(module_name)
add_edge(file_nid, tgt_nid, "imports_from", node.start_point[0] + 1, context="import")
name_use(module_name, _read_text(arg, source))
if grouped: # call evidence as in v8, then dropped (extract.py)
edges[-1]["_rust_use_group"] = True
# A group imports what the plain `use` of each of its bindings would.
for binding, line in _rust_use_bindings(arg, source) if grouped else []:
if binding_name := _rust_use_module(binding):
add_edge(file_nid, _make_id(binding_name), "imports_from", line, context="import")
name_use(binding_name, binding)
edges[-1]["_rust_use_binding"] = True # not call evidence: v8 had no such edge
return
+38 -4
View File
@@ -102,11 +102,12 @@ def test_each_binding_gets_the_plain_use_target(tmp_path: Path):
edges = sorted((e["relation"], e["target"], e["source_location"]) for e in result["edges"]
if e.get("context") == "import")
assert edges == sorted([
("imports_from", "util", "L1"), ("imports_from", "parse", "L1"), ("imports_from", "render_as_draw", "L1"),
# `parse` and `render` are declared once in the corpus, so they land there (#4439)
("imports_from", "util", "L1"), ("imports_from", "src_util_parse", "L1"), ("imports_from", "src_util_render", "L1"),
("imports_from", "deep", "L1"), ("imports_from", "nested", "L1"), ("imports_from", "b", "L2"),
("imports_from", "c", "L2"),
# plain declarations, exactly as v8 emits them
("imports_from", "parse", "L3"), ("imports_from", "read", "L4"), ("imports_from", "bar_as_baz", "L5"),
("imports_from", "src_util_parse", "L3"), ("imports_from", "read", "L4"), ("imports_from", "bar_as_baz", "L5"),
("imports_from", "util", "L6"), ("imports_from", "fmt", "L7")])
sources = {e["source"] for e in result["edges"] if e.get("context") == "import"}
file_node = next(n["id"] for n in result["nodes"] if n.get("label") == "user.rs")
@@ -120,7 +121,7 @@ def test_a_file_whose_only_use_is_a_root_group_gets_edges_from_its_file_node(tmp
result = _extract(tmp_path, {**_FILES, "src/user.rs": "use {crate::util::parse, std::io};\n"})
file_node = next(n["id"] for n in result["nodes"] if n.get("label") == "user.rs")
assert sorted((e["source"], e["target"]) for e in result["edges"] if e.get("context") == "import") == [
(file_node, "io"), (file_node, "parse")]
(file_node, "io"), (file_node, "src_util_parse")]
# Files at the scan root, so file node ids are bare stems: the binding `util`
@@ -206,4 +207,37 @@ def test_a_cache_entry_from_before_the_bindings_is_not_served(tmp_path: Path, mo
result = extract(sorted(tmp_path / name for name in files), root=tmp_path, cache_root=tmp_path / "graphify-out",
parallel=False)
assert sorted(e["target"] for e in result["edges"]
if e.get("context") == "import" and e["source_file"].endswith("user.rs")) == ["parse", "render"]
if e.get("context") == "import" and e["source_file"].endswith("user.rs")) == ["src_util_parse", "src_util_render"]
def _imports(result: dict, name: str) -> list[str]:
return sorted(e["target"] for e in result["edges"]
if e.get("context") == "import" and e["source_file"].endswith(name))
def test_a_use_lands_on_the_declaration_without_an_annotation(tmp_path: Path):
"""#4439: the import reached `Invoice` only when a type annotation left a
sourceless node for the rewire. Constructing the type left the edge on a
bare `invoice`, which build then minted as an external stub tied with it."""
result = _extract(tmp_path, {
"src/lib.rs": "pub mod billing;\npub mod model;\n", "src/model.rs": "pub struct Invoice(pub u64);\n",
"src/billing.rs": "use crate::model::Invoice;\nuse std::fmt;\n\npub fn make() -> u64 {\n Invoice(1).0\n}\n"})
invoice = next(n["id"] for n in result["nodes"] if n["label"] == "Invoice")
# `fmt` has no declaration here: it stays a dangling target, with no node left behind.
assert _imports(result, "billing.rs") == sorted(["fmt", invoice])
assert [n["label"] for n in result["nodes"] if not n.get("source_file")] == []
assert not any(k.startswith("_rust_use") for n in result["nodes"] for k in n)
def test_a_use_of_a_name_declared_twice_stays_unresolved(tmp_path: Path):
result = _extract(tmp_path, {
"src/lib.rs": "pub mod a;\npub mod b;\npub mod user;\n", "src/a.rs": "pub struct Thing;\n",
"src/b.rs": "pub struct Thing;\n", "src/user.rs": "use crate::a::Thing;\n"})
assert _imports(result, "user.rs") == ["thing"]
def test_a_std_use_does_not_land_on_a_same_named_declaration(tmp_path: Path):
result = _extract(tmp_path, {
"src/lib.rs": "pub mod a;\npub mod user;\n", "src/a.rs": "pub struct File;\npub struct Command;\n",
"src/user.rs": "use std::fs::File;\nuse std::{process::Command, io};\n"})
assert _imports(result, "user.rs") == ["command", "file", "io"]