mirror of
https://github.com/safishamsi/graphify.git
synced 2026-10-13 15:40:05 +00:00
fix(rust): resolve a forward-referenced type to its local declaration (#3782)
ensure_named_node only resolved names already in seen_ids, so a struct,
enum or trait used above its declaration in the same file got a
sourceless stub:
fn before(s: &Sink) {}
struct Sink { n: u32 }
The corpus-level rewire folds that stub back onto the real struct only
when the name is unique across the corpus. Once another file declares a
`Sink` as well, the reference stays on the stub, and `affected` or
`explain` on the local struct miss that user.
Pre-scan the struct/enum/trait names the file declares, as the Verilog,
SQL and Bash extractors already pre-scan their definitions, and let
ensure_named_node resolve against them, so a use above a declaration
resolves exactly as a use below it already does. Only these three items
are pre-registered: walk() never makes a node of a `type` alias or
`union`, so registering one would leave its references to be dropped by
the final dangling-edge filter. The scan never descends further than
walk(), so every registered id ends up as a node.
Adds regression coverage for a struct, enum and trait used above their
declarations, a type declared in an inline module, the two-file
ambiguous case (cold and warm cache), and the items that must not
resolve a forward reference (type alias, union, function body, const
and static initializers).
Assisted-by: assayer harness on Claude Code (https://github.com/Yyunozor/assayer-memory-mcp)
(cherry picked from commit d73c4adf59)
This commit is contained in:
1 parent
9e291b2c50
commit
5c8df1566b
2 files changed
+157
-1
No files matched your search
@@ -162,9 +162,34 @@ def extract_rust(path: Path) -> dict:
|
||||
file_nid = _make_id(str(path))
|
||||
add_node(file_nid, path.name, 1)
|
||||
|
||||
# Pre-scan the struct/enum/trait names declared in this file so a type used
|
||||
# above its declaration resolves exactly as a use below it already does,
|
||||
# rather than to a sourceless stub that the corpus-level rewire only folds
|
||||
# back when the name is unique across the corpus (#3782). Only these three
|
||||
# items are pre-registered: walk() never makes a node of a `type` alias or a
|
||||
# `union`, so registering one would leave its references pointing at no node.
|
||||
# The scan never descends further than walk() does, so every id registered
|
||||
# here ends up as a node.
|
||||
local_type_ids: set[str] = set()
|
||||
|
||||
def _scan_type_items(node) -> None:
|
||||
t = node.type
|
||||
if t in ("struct_item", "enum_item", "trait_item"):
|
||||
name_node = node.child_by_field_name("name")
|
||||
if name_node:
|
||||
local_type_ids.add(_make_id(stem, _read_text(name_node, source)))
|
||||
return
|
||||
if t in ("function_item", "function_signature_item", "static_item",
|
||||
"const_item", "impl_item", "use_declaration"):
|
||||
return
|
||||
for child in node.children:
|
||||
_scan_type_items(child)
|
||||
|
||||
_scan_type_items(root)
|
||||
|
||||
def ensure_named_node(name: str, line: int) -> str:
|
||||
nid = _make_id(stem, name)
|
||||
if nid in seen_ids:
|
||||
if nid in seen_ids or nid in local_type_ids:
|
||||
return nid
|
||||
nid = _make_id(name)
|
||||
if nid not in seen_ids:
|
||||
|
||||
@@ -475,6 +475,137 @@ def test_rust_no_cross_crate_spurious_edges():
|
||||
)
|
||||
|
||||
|
||||
def test_rust_forward_type_reference_resolves_to_local_declaration(tmp_path):
|
||||
"""#3782: a struct, enum or trait used above its declaration in the same file
|
||||
must resolve to that declaration, not to a sourceless stub."""
|
||||
p = tmp_path / "engine.rs"
|
||||
p.write_text(
|
||||
"fn before(s: &Sink) -> Mode { Mode::A }\n"
|
||||
"\n"
|
||||
"struct Sink {\n"
|
||||
" n: u32,\n"
|
||||
"}\n"
|
||||
"\n"
|
||||
"impl Handler for Sink {}\n"
|
||||
"\n"
|
||||
"enum Mode {\n"
|
||||
" A,\n"
|
||||
"}\n"
|
||||
"\n"
|
||||
"trait Handler {}\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
r = extract_rust(p)
|
||||
assert [n["label"] for n in r["nodes"] if not n["source_file"]] == []
|
||||
assert ("before", "Sink") in _edge_labels(r, "references", "parameter_type")
|
||||
assert ("before", "Mode") in _edge_labels(r, "references", "return_type")
|
||||
assert ("Sink", "Handler") in _edge_labels(r, "implements")
|
||||
|
||||
|
||||
def test_rust_forward_reference_prescan_skips_items_that_are_not_nodes(tmp_path):
|
||||
"""#3782: only struct/enum/trait declarations become nodes, so only they may
|
||||
resolve a forward reference. A `type` alias, a `union`, a struct declared in a
|
||||
function body and a name declared nowhere in the file produce no node here;
|
||||
references to them must keep their sourceless stub instead of being dropped."""
|
||||
p = tmp_path / "shapes.rs"
|
||||
p.write_text(
|
||||
"fn user(a: &Alias, u: &Bits, l: &Local, e: &Elsewhere) {}\n"
|
||||
"\n"
|
||||
"type Alias = u32;\n"
|
||||
"\n"
|
||||
"union Bits { a: u32, b: f32 }\n"
|
||||
"\n"
|
||||
"fn host() {\n"
|
||||
" struct Local;\n"
|
||||
"}\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
r = extract_rust(p)
|
||||
by_id = {n["id"]: n for n in r["nodes"]}
|
||||
targets = {
|
||||
by_id[e["target"]]["label"]: by_id[e["target"]]
|
||||
for e in r["edges"]
|
||||
if e["relation"] == "references" and e["target"] in by_id
|
||||
}
|
||||
for name in ("Alias", "Bits", "Local", "Elsewhere"):
|
||||
assert name in targets, f"reference to {name} was dropped"
|
||||
assert targets[name]["source_file"] == "", name
|
||||
|
||||
|
||||
def test_rust_forward_reference_resolves_into_an_inline_module(tmp_path):
|
||||
"""#3782: the pre-scan reaches the same items walk() does, so a type declared
|
||||
in an inline `mod` below its use resolves like a use placed after it."""
|
||||
p = tmp_path / "nested.rs"
|
||||
p.write_text(
|
||||
"use inner::Sink;\n"
|
||||
"\n"
|
||||
"fn before(s: &Sink) {}\n"
|
||||
"\n"
|
||||
"mod inner {\n"
|
||||
" pub struct Sink;\n"
|
||||
"}\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
r = extract_rust(p)
|
||||
sink = next(n for n in r["nodes"] if n["label"] == "Sink")
|
||||
before = next(n for n in r["nodes"] if n["label"] == "before()")
|
||||
assert sink["source_file"] == str(p)
|
||||
assert any(
|
||||
e["relation"] == "references" and e["source"] == before["id"] and e["target"] == sink["id"]
|
||||
for e in r["edges"]
|
||||
)
|
||||
|
||||
|
||||
def test_rust_forward_reference_skips_items_in_const_and_static_initializers(tmp_path):
|
||||
"""#3782: walk() never enters a const or static initializer, so a struct
|
||||
declared there is no node; a reference to it keeps its sourceless stub."""
|
||||
p = tmp_path / "initializers.rs"
|
||||
p.write_text(
|
||||
"fn before(a: &Hidden, b: &Guarded) {}\n"
|
||||
"\n"
|
||||
"const _: () = {\n"
|
||||
" struct Hidden;\n"
|
||||
"};\n"
|
||||
"\n"
|
||||
"static GUARD: () = {\n"
|
||||
" struct Guarded;\n"
|
||||
"};\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
r = extract_rust(p)
|
||||
by_id = {n["id"]: n for n in r["nodes"]}
|
||||
targets = {
|
||||
by_id[e["target"]]["label"]: by_id[e["target"]]["source_file"]
|
||||
for e in r["edges"]
|
||||
if e["relation"] == "references" and e["target"] in by_id
|
||||
}
|
||||
assert targets == {"Hidden": "", "Guarded": ""}
|
||||
|
||||
|
||||
def test_rust_forward_reference_to_ambiguous_type_name_stays_local(tmp_path):
|
||||
"""#3782: when two files declare the same struct, the corpus-level rewire
|
||||
declines the ambiguous stub, so the reference above the declaration must
|
||||
already point at the local struct. Checked cold, then from the warm cache."""
|
||||
(tmp_path / "engine_a.rs").write_text(
|
||||
"fn before(s: &Sink) {}\n\nstruct Sink {\n n: u32,\n}\n\nfn after(s: &Sink) {}\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
(tmp_path / "engine_b.rs").write_text(
|
||||
"struct Sink {\n n: u32,\n}\n", encoding="utf-8",
|
||||
)
|
||||
files = sorted(tmp_path.glob("*.rs"))
|
||||
for _ in range(2):
|
||||
r = extract(files, cache_root=tmp_path, parallel=False)
|
||||
sinks = [n for n in r["nodes"] if n["label"] == "Sink"]
|
||||
assert all(n["source_file"] for n in sinks), "sourceless Sink stub left behind"
|
||||
local = next(n["id"] for n in sinks if Path(n["source_file"]).name == "engine_a.rs")
|
||||
before = next(n["id"] for n in r["nodes"] if n["label"] == "before()")
|
||||
after = next(n["id"] for n in r["nodes"] if n["label"] == "after()")
|
||||
refs = {(e["source"], e["target"]) for e in r["edges"] if e["relation"] == "references"}
|
||||
assert (before, local) in refs
|
||||
assert (after, local) in refs
|
||||
|
||||
|
||||
# ── extract() dispatch ────────────────────────────────────────────────────────
|
||||
|
||||
def test_extract_dispatches_all_languages():
|
||||
|
||||
Reference in new issue
Block a user