From f752e668acb40b56ae51c99469605462886b06c9 Mon Sep 17 00:00:00 2001 From: HyeJun Kim Date: Fri, 4 Sep 2026 15:56:34 +0900 Subject: [PATCH] fix(verilog): emit referenced modules and packages as stubs (#1402) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Verilog extractor stamps `source_file` on the target of `instantiates` and of `package_import_declaration` — modules and packages the file merely references. A sourced node is a definition to the corpus resolver, so it never enters the stub pool `_rewire_unique_stub_nodes()` consumes, and `_disambiguate_colliding_node_ids` bakes the referencing file's path into its id. `go.py` names this the phantom-duplicate-node bug and emits such targets as sourceless stubs; the Verilog extractor was never converted. The result is a graph with no cross-file edge at all. On a 289-file SystemVerilog RTL tree: connected components 289 -> 83 (289 = the file count) largest component 18 -> 518 cross-file edges 0 -> 175 instantiations bound to a definition 0/308 -> 138/224 labels bound to more than one node 183 -> 45 Every shared primitive existed once per referencing file, and the most widely imported package was 57 separate nodes. For hardware the module hierarchy is the architecture, so none of it survived. Two details beyond the straight port: `type: "module"` is load-bearing. `_node_disambiguation_source_key()` falls back to `origin_file` when `source_file` is empty, so the stubs were still salted per referencing file; the module/namespace anchor exemption (#1327 — the same module imported from three files is one module) is what collapses them. `_make_id` lowercases, but SystemVerilog is case-sensitive. `Widget` and `widget` already collapsed onto one id before this change; once the target is rewirable, the merged node binds to whichever definition matched first, so `widget u_lower` would claim to instantiate `Widget` in another file. `_sv_reference_id` salts the id with the exact-case name when it is not already lowercase, leaving all-lowercase names byte-identical. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01EszWxScCETGBjTc8yvCNuf --- graphify/extractors/verilog.py | 40 ++++++++++++++++++++++++++++++---- 1 file changed, 36 insertions(+), 4 deletions(-) diff --git a/graphify/extractors/verilog.py b/graphify/extractors/verilog.py index 2f5fea49b5..b1e2b49d8e 100644 --- a/graphify/extractors/verilog.py +++ b/graphify/extractors/verilog.py @@ -1,6 +1,7 @@ """Verilog extractor. Moved verbatim from graphify/extract.py.""" from __future__ import annotations +import hashlib import re from pathlib import Path @@ -24,6 +25,19 @@ def _sv_first_identifier(node, source: bytes) -> str | None: return found return None +def _sv_reference_id(name: str) -> str: + """Node ID for a referenced module or package, preserving case. + + `_make_id` lowercases, but SystemVerilog is case-sensitive: `Widget` and + `widget` are different modules and must not share a stub, or the one that + rewires second binds to the other's definition. An all-lowercase name — the + overwhelming majority — keeps its historical id. + """ + nid = _make_id(name) + if name == name.lower(): + return nid + return f"{nid}_{hashlib.sha1(name.encode('utf-8')).hexdigest()[:6]}" + def _sv_child(node, type_name: str) -> object | None: if node is None: return None @@ -234,6 +248,24 @@ def add_node(nid: str, label: str, line: int) -> None: "source_file": str_path, "source_location": f"L{line}", "confidence_score": 1.0}) + def add_reference_node(nid: str, label: str) -> None: + """A module or package this file references but does not define. + + Emitted as a sourceless stub — like the inheritance-base path in the + other extractors — so the corpus-level rewire can collapse it onto the + real definition. A sourced node here makes + _disambiguate_colliding_node_ids bake the referencing file's path into + the id and blocks the rewire, which is the phantom-duplicate-node bug + (#1402). `type` marks it a module anchor (#1327), so the same package + imported by N files stays one node rather than N same-named ones. + """ + if nid not in seen_ids: + seen_ids.add(nid) + nodes.append({"id": nid, "label": label, "file_type": "code", + "source_file": "", "source_location": "", + "origin_file": str_path, "type": "module", + "confidence_score": 1.0}) + def add_edge(src: str, tgt: str, relation: str, line: int, confidence: str = "EXTRACTED", score: float = 1.0) -> None: edges.append({"source": src, "target": tgt, "relation": relation, @@ -293,8 +325,8 @@ def walk(node, module_nid: str | None = None) -> None: pkg_name = pkg_text.split("::")[0].strip() if pkg_name: line = node.start_point[0] + 1 - tgt_nid = _make_id(pkg_name) - add_node(tgt_nid, pkg_name, line) + tgt_nid = _sv_reference_id(pkg_name) + add_reference_node(tgt_nid, pkg_name) src_nid = module_nid or file_nid add_edge(src_nid, tgt_nid, "imports_from", line) @@ -309,8 +341,8 @@ 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) + tgt_nid = _sv_reference_id(inst_type) + add_reference_node(tgt_nid, inst_type) add_edge(module_nid, tgt_nid, "instantiates", line) for child in node.children: