From bca8bbac72536d6e22106f2a9ed6abbc00eda792 Mon Sep 17 00:00:00 2001 From: rajatnagda45 Date: Sat, 19 Sep 2026 19:59:32 +0530 Subject: [PATCH] fix(verilog): link a module instantiation to the local definition, not a phantom MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A module instantiation minted its target id with a bare, unscoped _make_id(name), while a module definition uses _make_id(stem, name). The two never matched, so instantiating a module defined in the same file: module counter(...); endmodule module top; counter u1(...); endmodule split `counter` into a real definition node and a second, sourced phantom that carried the `instantiates` edge. `top`'s dependency on the module you actually defined was invisible, and because the phantom was sourced it also baked this file's path into the id and blocked the corpus-level rewire that would otherwise collapse it (the #1402 duplicate shape the other extractors guard against). Pre-scan module names so a same-file instantiation (Verilog allows forward/any-order instantiation) resolves to the definition id. A module defined in another file becomes a SOURCELESS stub instead, so the corpus-level rewire collapses it onto the real definition rather than a per-file duplicate — mirroring the Go extractor's ensure_named_node. Adds regression coverage for both the local-definition link and the cross-file sourceless stub. --- graphify/extractors/verilog.py | 39 ++++++++++++++++++++++++++++++++-- tests/test_languages.py | 34 +++++++++++++++++++++++++++++ 2 files changed, 71 insertions(+), 2 deletions(-) diff --git a/graphify/extractors/verilog.py b/graphify/extractors/verilog.py index 2f5fea49b5..563f45fe72 100644 --- a/graphify/extractors/verilog.py +++ b/graphify/extractors/verilog.py @@ -243,6 +243,21 @@ def add_edge(src: str, tgt: str, relation: str, line: int, file_nid = _make_id(str(path)) add_node(file_nid, path.name, 1) + # Pre-scan module names so an instantiation resolves to a module defined + # later in the same file — Verilog allows instantiating a module declared + # further down the source (or in any order across the compilation unit). + local_modules: set[str] = set() + + def _scan_modules(node) -> None: + if node.type == "module_declaration": + mn = _sv_first_identifier(_sv_child(node, "module_header"), source) + if mn: + local_modules.add(mn) + for child in node.children: + _scan_modules(child) + + _scan_modules(root) + def walk(node, module_nid: str | None = None) -> None: t = node.type @@ -309,8 +324,28 @@ def walk(node, module_nid: str | None = None) -> None: else _sv_first_identifier(node, source)) if inst_type: line = node.start_point[0] + 1 - tgt_nid = _make_id(inst_type) - add_node(tgt_nid, inst_type, line) + if inst_type in local_modules: + # The instantiated module is defined in this file: point + # at its definition id (`_make_id(stem, name)`) instead of + # minting a second, bare-id node for the same module. A + # bare `_make_id(name)` never matches the scoped definition + # id, so the module was split into a real definition node + # and a phantom instantiation-target duplicate. + tgt_nid = _make_id(stem, inst_type) + else: + # Defined in another file: emit a SOURCELESS stub so the + # corpus-level rewire collapses it onto the real + # definition. A sourced stub bakes this file's path into + # the id and blocks the rewire — the #1402 phantom + # duplicate the other extractors avoid the same way. + tgt_nid = _make_id(inst_type) + if tgt_nid not in seen_ids: + seen_ids.add(tgt_nid) + nodes.append({ + "id": tgt_nid, "label": inst_type, + "file_type": "code", "source_file": "", + "source_location": "", "confidence_score": 1.0, + }) add_edge(module_nid, tgt_nid, "instantiates", line) for child in node.children: diff --git a/tests/test_languages.py b/tests/test_languages.py index bfdc836f80..4508bbfdd4 100644 --- a/tests/test_languages.py +++ b/tests/test_languages.py @@ -3600,6 +3600,40 @@ def test_systemverilog_preserves_existing_module_extraction(): assert "instantiates" in _relations(r) +def test_systemverilog_instantiation_resolves_to_local_module(): + """`top` instantiating same-file `leaf` links to leaf's definition, not a + bare-id phantom duplicate. The instantiation target used an unscoped id + (`_make_id(name)`) while the definition used `_make_id(stem, name)`, so + they never matched: the module was split into a real definition node and a + sourced phantom that also blocked the corpus-level rewire (#1402 shape).""" + r = extract_verilog(FIXTURES / "sample.sv") + leaf_nodes = [n for n in r["nodes"] if n["label"] == "leaf"] + assert len(leaf_nodes) == 1, f"leaf split into duplicates: {leaf_nodes}" + leaf_id = leaf_nodes[0]["id"] + # The surviving node is the real, sourced definition. + assert leaf_nodes[0].get("source_file") + inst_targets = {e["target"] for e in r["edges"] if e["relation"] == "instantiates"} + assert leaf_id in inst_targets, ( + f"instantiation did not link to the leaf definition {leaf_id}: {inst_targets}" + ) + + +def test_systemverilog_cross_file_instantiation_is_sourceless_stub(tmp_path): + """A module defined in another file is a SOURCELESS stub so the corpus-level + rewire can collapse it onto the real definition, rather than a sourced node + whose id bakes in this file's path and blocks the rewire.""" + f = tmp_path / "wrapper.sv" + f.write_text("module wrapper;\n external_ip u_ip();\nendmodule\n") + r = extract_verilog(f) + stubs = [n for n in r["nodes"] if n["label"] == "external_ip"] + assert len(stubs) == 1, stubs + assert stubs[0].get("source_file") == "", ( + f"cross-file instantiation target must be sourceless: {stubs[0]}" + ) + inst_targets = {e["target"] for e in r["edges"] if e["relation"] == "instantiates"} + assert stubs[0]["id"] in inst_targets + + def test_systemverilog_missing_file_returns_empty(): r = extract_verilog(Path("nonexistent.sv")) assert r["nodes"] == []