diff --git a/code_review_graph/parser.py b/code_review_graph/parser.py index 9b6d8685..c7a64f2b 100644 --- a/code_review_graph/parser.py +++ b/code_review_graph/parser.py @@ -126,6 +126,11 @@ class EdgeInfo: ".res": "rescript", ".resi": "rescript", ".gd": "gdscript", + # SystemVerilog/Verilog + ".sv": "verilog", + ".svh": "verilog", + ".v": "verilog", + ".vh": "verilog", ".nix": "nix", } @@ -177,6 +182,7 @@ class EdgeInfo: "zig": ["container_declaration"], "powershell": ["class_statement"], "julia": ["struct_definition", "abstract_definition"], + "verilog": ["module_declaration", "interface_declaration", "class_declaration"], } _FUNCTION_TYPES: dict[str, list[str]] = { @@ -230,6 +236,7 @@ class EdgeInfo: "function_definition", "short_function_definition", ], + "verilog": ["task_declaration", "function_declaration", "always_construct"], } _IMPORT_TYPES: dict[str, list[str]] = { @@ -274,6 +281,7 @@ class EdgeInfo: "powershell": [], # Julia: import/using are import_statement nodes. "julia": ["import_statement", "using_statement"], + "verilog": ["package_import_declaration"], } _CALL_TYPES: dict[str, list[str]] = { @@ -315,6 +323,9 @@ class EdgeInfo: "zig": ["call_expression", "builtin_call_expr"], "powershell": ["command_expression"], "julia": ["call_expression"], + "verilog": [ + "module_instantiation", "function_subroutine_call", "subroutine_call", "system_tf_call" + ], } # Patterns that indicate a test function @@ -3571,6 +3582,11 @@ def _extract_classes( import_map=import_map, defined_names=defined_names, _depth=_depth + 1, ) + if language == "verilog" and child.type in ("module_declaration", "interface_declaration"): + self._extract_verilog_module_members( + child, name, self._qualify(name, file_path, enclosing_class), + file_path, nodes, edges, + ) return True def _extract_functions( @@ -3809,39 +3825,50 @@ def _extract_calls( ) return True - if call_name and enclosing_func: - caller = self._qualify( - enclosing_func, file_path, enclosing_class, - ) - - # Java method_invocation: extract actual method name and receiver - # separately so the Spring DI resolver can rewrite the target. - call_extra: dict = {} - if language == "java" and child.type == "method_invocation": - method_name, receiver = self._get_java_method_and_receiver(child) - if method_name: - call_name = method_name - if receiver: - call_extra["receiver"] = receiver - - # When a receiver is present, skip scope-based resolution: the method - # lives on the receiver's type, not in the current file's scope. - # The spring_resolver post-pass will do the correct cross-type lookup. - if call_extra.get("receiver"): - target = call_name - else: - target = self._resolve_call_target( - call_name, file_path, language, - import_map or {}, defined_names or set(), + # For Verilog module instantiations, create CALLS edges from the enclosing module + # (enclosing_class), not just from functions + if call_name: + caller = None + if enclosing_func: + caller = self._qualify( + enclosing_func, file_path, enclosing_class, ) - edges.append(EdgeInfo( - kind="CALLS", - source=caller, - target=target, - file_path=file_path, - line=child.start_point[0] + 1, - extra=call_extra, - )) + elif language == "verilog" and enclosing_class: + # Verilog module instantiation happen at module level + caller = self._qualify( + enclosing_class, file_path, None + ) + if caller: + # Java method_invocation: extract actual method name and receiver + # separately so the Spring DI resolver can rewrite the target. + call_extra: dict = {} + if language == "java" and child.type == "method_invocation": + method_name, receiver = self._get_java_method_and_receiver(child) + if method_name: + call_name = method_name + if receiver: + call_extra["receiver"] = receiver + + # When a receiver is present, skip scope-based resolution: the method + # lives on the receiver's type, not in the current file's scope. + # The spring_resolver post-pass will do the correct cross-type lookup. + if call_extra.get("receiver"): + target = call_name + else: + target = self._resolve_call_target( + call_name, file_path, language, + import_map or {}, defined_names or set(), + ) + edges.append(EdgeInfo( + kind="CALLS", + source=caller, + target=target, + file_path=file_path, + line=child.start_point[0] + 1, + extra=call_extra, + )) + if language == "verilog" and child.type == "module_instantiation": + self._verilog_emit_connects(child, caller, target, file_path, edges) return False @@ -4893,6 +4920,41 @@ def _get_name(self, node, language: str, kind: str) -> Optional[str]: for sub in child.children: if sub.type == "type_identifier": return sub.text.decode("utf-8", errors="replace") + # Verilog/SystemVerilog: names are nested differently per construct type. + if language == "verilog": + # module_declaration: name is in module_header > simple_identifier + if node.type == "module_declaration": + for child in node.children: + if child.type == "module_header": + for sub in child.children: + if sub.type == "simple_identifier": + return sub.text.decode("utf-8", errors="replace") + # interface_declaration: name is in interface_ansi_header > interface_identifier + if node.type == "interface_declaration": + for child in node.children: + if child.type in ("interface_header", "interface_ansi_header"): + for sub in child.children: + if sub.type == "simple_identifier": + return sub.text.decode("utf-8", errors="replace") + if sub.type == "interface_identifier": + for ss in sub.children: + if ss.type == "simple_identifier": + return ss.text.decode("utf-8", errors="replace") + return sub.text.decode("utf-8", errors="replace") + # task_declaration: name is in task_body_declaration > task_identifier + if node.type == "task_declaration": + for child in node.children: + if child.type == "task_body_declaration": + for sub in child.children: + if sub.type == "task_identifier": + return sub.text.decode("utf-8", errors="replace") + # function_declaration: name is in function_body_declaration > function_identifier + if node.type == "function_declaration": + for child in node.children: + if child.type == "function_body_declaration": + for sub in child.children: + if sub.type == "function_identifier": + return sub.text.decode("utf-8", errors="replace") # Most languages use a 'name' child. # field_identifier covers C++ class member function names inside # function_declarator (e.g. virtual std::string get_name() = 0). @@ -4909,6 +4971,352 @@ def _get_name(self, node, language: str, kind: str) -> Optional[str]: return self._get_name(child, language, kind) return None + # --- Verilog/SystemVerilog member extraction helpers --- + + def _verilog_text(self, node) -> str: + return node.text.decode("utf-8", errors="replace") + + def _verilog_find_child(self, node, *types): + for child in node.children: + if child.type in types: + return child + return None + + def _verilog_find_descendant(self, node, *types): + for child in node.children: + if child.type in types: + return child + found = self._verilog_find_descendant(child, *types) + if found: + return found + return None + + def _verilog_modport_ports(self, modport_item) -> list: + """Return [{name, direction}] for all ports in a modport_item node.""" + ports: list = [] + current_dir = "" + + def _collect(node): + nonlocal current_dir + for child in node.children: + t = child.type + if t == "port_direction": + current_dir = self._verilog_text(child).strip() + elif t == "modport_simple_port": + id_node = self._verilog_find_descendant(child, "simple_identifier") + if id_node: + ports.append({ + "name": self._verilog_text(id_node), + "direction": current_dir, + }) + elif t in ( + "modport_ports_declaration", + "modport_simple_ports_declaration", + "modport_tf_ports_declaration", + ): + _collect(child) + + _collect(modport_item) + return ports + + def _verilog_extract_header( + self, + header_node, + module_name: str, + qualified_module: str, + file_path: str, + nodes: list, + edges: list, + ) -> None: + """Extract Parameter and Port nodes from module_ansi_header/interface_ansi_header.""" + args = (module_name, qualified_module, file_path, nodes, edges) + for child in header_node.children: + if child.type == "parameter_port_list": + self._verilog_extract_params(child, *args) + elif child.type == "list_of_port_declarations": + for port in child.children: + if port.type == "ansi_port_declaration": + self._verilog_extract_port(port, *args) + + def _verilog_extract_params( + self, + param_list, + module_name: str, + qualified_module: str, + file_path: str, + nodes: list, + edges: list, + ) -> None: + """Extract Parameter nodes from a parameter_port_list node.""" + for ppd in param_list.children: + if ppd.type != "parameter_port_declaration": + continue + for pd in ppd.children: + if pd.type != "parameter_declaration": + continue + type_text = "" + for sub in pd.children: + if sub.type == "data_type_or_implicit1": + dt = self._verilog_find_descendant(sub, "data_type") or sub + type_text = self._verilog_text(dt).strip() + break + for lpa in pd.children: + if lpa.type != "list_of_param_assignments": + continue + for pa in lpa.children: + if pa.type != "param_assignment": + continue + name = None + default = "" + for part in pa.children: + if part.type == "parameter_identifier": + id_node = ( + self._verilog_find_descendant(part, "simple_identifier") or part + ) + name = self._verilog_text(id_node).strip() + elif part.type == "constant_param_expression": + default = self._verilog_text(part).strip() + if not name: + continue + nodes.append(NodeInfo( + kind="Parameter", + name=name, + file_path=file_path, + line_start=pa.start_point[0] + 1, + line_end=pa.end_point[0] + 1, + language="verilog", + parent_name=module_name, + extra={"param_type": type_text, "default_value": default}, + )) + edges.append(EdgeInfo( + kind="CONTAINS", + source=qualified_module, + target=f"{qualified_module}.{name}", + file_path=file_path, + line=pa.start_point[0] + 1, + )) + + def _verilog_extract_port( + self, + port_node, + module_name: str, + qualified_module: str, + file_path: str, + nodes: list, + edges: list, + ) -> None: + """Extract a Port node from an ansi_port_declaration.""" + direction = "" + data_type = "" + for child in port_node.children: + if child.type in ("variable_port_header", "net_port_header", "interface_port_header"): + for sub in child.children: + if sub.type == "port_direction": + direction = self._verilog_text(sub).strip() + elif sub.type in ( + "data_type", "data_type_or_implicit1", + "net_port_type", "variable_port_type", "implicit_data_type", + ): + data_type = self._verilog_text(sub).strip() + for child in port_node.children: + if child.type == "port_identifier": + id_node = self._verilog_find_descendant(child, "simple_identifier") or child + name = self._verilog_text(id_node).strip() + nodes.append(NodeInfo( + kind="Port", + name=name, + file_path=file_path, + line_start=port_node.start_point[0] + 1, + line_end=port_node.end_point[0] + 1, + language="verilog", + parent_name=module_name, + extra={"direction": direction, "data_type": data_type}, + )) + edges.append(EdgeInfo( + kind="CONTAINS", + source=qualified_module, + target=f"{qualified_module}.{name}", + file_path=file_path, + line=port_node.start_point[0] + 1, + )) + break + + def _verilog_extract_signals( + self, + data_decl, + module_name: str, + qualified_module: str, + file_path: str, + nodes: list, + edges: list, + ) -> None: + """Extract Signal nodes from a data_declaration node (handles multiple names).""" + type_text = "" + for child in data_decl.children: + if child.type == "data_type_or_implicit1": + dt = self._verilog_find_descendant(child, "data_type") or child + type_text = self._verilog_text(dt).strip() + break + for child in data_decl.children: + if child.type == "list_of_variable_decl_assignments": + for vda in child.children: + if vda.type == "variable_decl_assignment": + id_node = self._verilog_find_child(vda, "simple_identifier") + if id_node: + name = self._verilog_text(id_node).strip() + nodes.append(NodeInfo( + kind="Signal", + name=name, + file_path=file_path, + line_start=vda.start_point[0] + 1, + line_end=vda.end_point[0] + 1, + language="verilog", + parent_name=module_name, + extra={"data_type": type_text}, + )) + edges.append(EdgeInfo( + kind="CONTAINS", + source=qualified_module, + target=f"{qualified_module}.{name}", + file_path=file_path, + line=vda.start_point[0] + 1, + )) + + def _verilog_extract_modport( + self, + modport_item, + module_name: str, + qualified_module: str, + file_path: str, + nodes: list, + edges: list, + ) -> None: + """Extract a Modport node from a modport_item node.""" + name_node = None + for child in modport_item.children: + if child.type == "modport_identifier": + name_node = self._verilog_find_descendant(child, "simple_identifier") or child + break + if not name_node: + for child in modport_item.children: + if child.type == "simple_identifier": + name_node = child + break + if not name_node: + return + name = self._verilog_text(name_node).strip() + ports = self._verilog_modport_ports(modport_item) + nodes.append(NodeInfo( + kind="Modport", + name=name, + file_path=file_path, + line_start=modport_item.start_point[0] + 1, + line_end=modport_item.end_point[0] + 1, + language="verilog", + parent_name=module_name, + extra={"ports": ports}, + )) + edges.append(EdgeInfo( + kind="CONTAINS", + source=qualified_module, + target=f"{qualified_module}.{name}", + file_path=file_path, + line=modport_item.start_point[0] + 1, + )) + + def _extract_verilog_module_members( + self, + module_node, + module_name: str, + qualified_module: str, + file_path: str, + nodes: list, + edges: list, + ) -> None: + """Extract Port, Signal, Parameter, Modport nodes from a module/interface declaration.""" + _skip = frozenset({ + "task_declaration", "function_declaration", "always_construct", + "module_instantiation", "module_header", "interface_header", + }) + _args = (module_name, qualified_module, file_path, nodes, edges) + + def _walk(node) -> None: + t = node.type + if t in _skip: + return + if t in ("module_ansi_header", "interface_ansi_header"): + self._verilog_extract_header(node, *_args) + return + if t == "data_declaration": + self._verilog_extract_signals(node, *_args) + return + if t == "modport_declaration": + for child in node.children: + if child.type == "modport_item": + self._verilog_extract_modport( + child, module_name, qualified_module, file_path, nodes, edges, + ) + return + for child in node.children: + _walk(child) + + for child in module_node.children: + _walk(child) + + def _verilog_emit_connects( + self, + module_inst_node, + source: str, + module_type_target: str, + file_path: str, + edges: list, + ) -> None: + """Emit CONNECTS edges for each named_port_connection in a module instantiation.""" + for inst in module_inst_node.children: + if inst.type != "hierarchical_instance": + continue + instance_name = "" + for n in inst.children: + if n.type == "name_of_instance": + for sub in n.children: + if sub.type == "instance_identifier": + for ss in sub.children: + if ss.type == "simple_identifier": + instance_name = self._verilog_text(ss) + elif sub.type == "simple_identifier": + instance_name = self._verilog_text(sub) + break + for n in inst.children: + if n.type != "list_of_port_connections": + continue + for conn in n.children: + if conn.type != "named_port_connection": + continue + port_name = "" + signal_expr = "" + for part in conn.children: + if part.type == "port_identifier": + id_node = self._verilog_find_descendant(part, "simple_identifier") + port_name = ( + self._verilog_text(id_node) if id_node + else self._verilog_text(part).strip() + ) + elif part.type == "expression": + signal_expr = self._verilog_text(part) + if port_name: + edges.append(EdgeInfo( + kind="CONNECTS", + source=source, + target=f"{module_type_target}.{port_name}", + file_path=file_path, + line=conn.start_point[0] + 1, + extra={ + "instance": instance_name, + "port": port_name, + "signal_expr": signal_expr, + }, + )) + def _get_go_receiver_type(self, node) -> Optional[str]: """Extract the receiver type from a Go method_declaration. @@ -5180,6 +5588,14 @@ def _find_string_literal(n) -> Optional[str]: val = _find_string_literal(node) if val: imports.append(val) + elif language == "verilog": + # import pkg::*; or import pkg::item; + # Node structure: package_import_declaration > package_import_item > package_identifier + for child in node.children: + if child.type == "package_import_item": + for subchild in child.children: + if subchild.type == "package_identifier": + imports.append(subchild.text.decode("utf-8", errors="replace")) else: # Fallback: just record the text imports.append(text) @@ -5227,6 +5643,12 @@ def _get_call_name(self, node, language: str, source: bytes) -> Optional[str]: return txt or None return None + # Verilog/SystemVerilog: module_instantiation's first child is the module name + if language == "verilog" and node.type == "module_instantiation": + if first.type == "simple_identifier": + return first.text.decode("utf-8", errors="replace") + return None + # Solidity wraps call targets in an 'expression' node – unwrap it if language == "solidity" and first.type == "expression" and first.children: first = first.children[0] diff --git a/tests/fixtures/sample.sv b/tests/fixtures/sample.sv new file mode 100644 index 00000000..3cd22b5f --- /dev/null +++ b/tests/fixtures/sample.sv @@ -0,0 +1,77 @@ +// sample.sv - SystemVerilog fixture for parser tests +`timescale 1ns / 1ps + +// File-level package import +import utils_pkg::*; + +// Interface declaration +interface BusIf #(parameter int WIDTH = 8); + logic [WIDTH-1:0] data; + logic valid; + logic ready; + modport master(output data, valid, input ready); + modport slave(input data, valid, output ready); +endinterface + +// Submodule to be instantiated by FIFOController +module Adder #(parameter int WIDTH = 8) (input logic [WIDTH-1:0] a, b, output logic [WIDTH-1:0] sum); + assign sum = a + b; +endmodule + +// Main module with tasks, functions, always blocks, and module instantiation +// Parameters on one line to avoid grammar parse errors +module FIFOController #(parameter int DEPTH = 16, parameter int WIDTH = 8) ( + input logic clk, + input logic rst_n, + input logic [WIDTH-1:0] data_in, + input logic wr_en, + input logic rd_en, + output logic [WIDTH-1:0] data_out, + output logic full, + output logic empty +); + + // Intra-module package import + import arith_pkg::counter_t; + + logic [WIDTH-1:0] mem [0:DEPTH-1]; + logic [$clog2(DEPTH):0] wr_ptr, rd_ptr, count; + + // Module instantiation - creates CALLS edge from FIFOController to Adder + Adder #(.WIDTH(WIDTH)) ptr_adder (.a(wr_ptr[WIDTH-1:0]), .b(rd_ptr[WIDTH-1:0]), .sum()); + + // Task declaration + task automatic do_write(input logic [WIDTH-1:0] din); + mem[wr_ptr] <= din; + wr_ptr <= wr_ptr + 1; + count <= count + 1; + endtask + + // Function declaration + function automatic logic is_full(); + return (count >= DEPTH); + endfunction + + // Always block (sequential logic) - flattened to avoid nested begin/end + // grammar limitation: if(x) begin..end inside else begin..end causes parse errors + always_ff @(posedge clk or negedge rst_n) begin + if (!rst_n) begin + wr_ptr <= 0; + rd_ptr <= 0; + count <= 0; + end + if (rst_n && wr_en && !full) do_write(data_in); + if (rst_n && rd_en && !empty) begin + data_out <= mem[rd_ptr]; + rd_ptr <= rd_ptr + 1; + count <= count - 1; + end + end + + // Always block (combinational logic) + always_comb begin + full = is_full(); + empty = (count == 0); + end + +endmodule diff --git a/tests/test_multilang.py b/tests/test_multilang.py index f75c8d5e..52f93ce6 100644 --- a/tests/test_multilang.py +++ b/tests/test_multilang.py @@ -1941,6 +1941,180 @@ def test_resolver_is_idempotent(self, tmp_path): assert second["calls_resolved"] == 0 assert second["imports_resolved"] == 0 +# --------------------------------------------------------------------------- +# Verilog / SystemVerilog +# --------------------------------------------------------------------------- + + +def _has_verilog_parser(): + try: + import tree_sitter_language_pack as tslp + tslp.get_parser("verilog") + return True + except (LookupError, ImportError): + return False + + +@pytest.mark.skipif(not _has_verilog_parser(), reason="verilog tree-sitter grammar not installed") +class TestVerilogParsing: + def setup_method(self): + self.parser = CodeParser() + self.nodes, self.edges = self.parser.parse_file(FIXTURES / "sample.sv") + + def test_detects_language(self): + assert self.parser.detect_language(Path("top.sv")) == "verilog" + assert self.parser.detect_language(Path("pkg.svh")) == "verilog" + assert self.parser.detect_language(Path("cpu.v")) == "verilog" + assert self.parser.detect_language(Path("header.vh")) == "verilog" + + def test_finds_modules(self): + classes = [n for n in self.nodes if n.kind == "Class"] + names = {c.name for c in classes} + assert "FIFOController" in names + assert "Adder" in names + + def test_finds_interfaces(self): + classes = [n for n in self.nodes if n.kind == "Class"] + names = {c.name for c in classes} + assert "BusIf" in names + + def test_finds_tasks(self): + funcs = [n for n in self.nodes if n.kind == "Function"] + names = {f.name for f in funcs} + assert "do_write" in names + + def test_finds_functions_in_module(self): + funcs = [n for n in self.nodes if n.kind == "Function"] + names = {f.name for f in funcs} + assert "is_full" in names + + def test_task_and_function_parent_is_module(self): + funcs = {f.name: f for f in self.nodes if f.kind == "Function"} + assert funcs["do_write"].parent_name == "FIFOController" + assert funcs["is_full"].parent_name == "FIFOController" + + def test_finds_package_imports(self): + imports = [e for e in self.edges if e.kind == "IMPORTS_FROM"] + targets = {e.target for e in imports} + assert "utils_pkg" in targets + assert "arith_pkg" in targets + + def test_module_instantiation_creates_call_edge(self): + calls = [e for e in self.edges if e.kind == "CALLS"] + targets = {e.target for e in calls} + assert any("Adder" in t for t in targets) + + def test_module_instantiation_caller_is_enclosing_module(self): + # module_instantiation CALLS must be attributed to the containing + # module, not a function — Verilog-specific fallback in _extract_calls. + calls = [e for e in self.edges if e.kind == "CALLS"] + adder_calls = [e for e in calls if "Adder" in e.target] + assert adder_calls, "Expected a CALLS edge for Adder instantiation" + assert any("FIFOController" in e.source for e in adder_calls) + + def test_file_node_language(self): + file_nodes = [n for n in self.nodes if n.kind == "File"] + assert len(file_nodes) == 1 + assert file_nodes[0].language == "verilog" + + # --- Ports --- + + def test_finds_module_ports(self): + ports = [n for n in self.nodes if n.kind == "Port"] + fifo_ports = {p.name for p in ports if p.parent_name == "FIFOController"} + assert {"clk", "rst_n", "data_in", "wr_en", "rd_en", "data_out", "full", "empty"} <= fifo_ports + + def test_port_directions(self): + by_name = {p.name: p for p in self.nodes + if p.kind == "Port" and p.parent_name == "FIFOController"} + assert by_name["clk"].extra["direction"] == "input" + assert by_name["rst_n"].extra["direction"] == "input" + assert by_name["data_out"].extra["direction"] == "output" + assert by_name["full"].extra["direction"] == "output" + + def test_port_data_type_recorded(self): + by_name = {p.name: p for p in self.nodes + if p.kind == "Port" and p.parent_name == "FIFOController"} + assert "WIDTH-1:0" in by_name["data_in"].extra["data_type"] + assert "logic" in by_name["clk"].extra["data_type"] + + def test_adder_ports_recognized(self): + ports = {p.name for p in self.nodes if p.kind == "Port" and p.parent_name == "Adder"} + assert {"a", "b", "sum"} <= ports + + # --- Signals --- + + def test_finds_internal_signals(self): + sigs = {s.name for s in self.nodes + if s.kind == "Signal" and s.parent_name == "FIFOController"} + assert {"mem", "wr_ptr", "rd_ptr", "count"} <= sigs + + def test_signal_data_type_recorded(self): + by_name = {s.name: s for s in self.nodes + if s.kind == "Signal" and s.parent_name == "FIFOController"} + assert "logic" in by_name["mem"].extra["data_type"] + + def test_multi_signal_declaration_split_into_separate_nodes(self): + sigs = [s for s in self.nodes + if s.kind == "Signal" and s.parent_name == "FIFOController" + and s.name in {"wr_ptr", "rd_ptr", "count"}] + assert len(sigs) == 3 + + def test_interface_signals_recognized(self): + sigs = {s.name for s in self.nodes if s.kind == "Signal" and s.parent_name == "BusIf"} + assert {"data", "valid", "ready"} <= sigs + + # --- Parameters --- + + def test_finds_module_parameters(self): + params = {p.name: p for p in self.nodes + if p.kind == "Parameter" and p.parent_name == "FIFOController"} + assert "DEPTH" in params + assert "WIDTH" in params + assert params["DEPTH"].extra["default_value"].strip() == "16" + assert params["WIDTH"].extra["default_value"].strip() == "8" + + # --- Modports --- + + def test_finds_modports(self): + modports = {m.name for m in self.nodes if m.kind == "Modport" and m.parent_name == "BusIf"} + assert {"master", "slave"} == modports + + def test_modport_port_directions(self): + master = next(m for m in self.nodes if m.kind == "Modport" and m.name == "master") + by_name = {p["name"]: p for p in master.extra["ports"]} + assert by_name["data"]["direction"] == "output" + assert by_name["ready"]["direction"] == "input" + + # --- CONTAINS edges --- + + def test_module_contains_its_ports_and_signals(self): + contains = [e for e in self.edges if e.kind == "CONTAINS"] + fifo_contained = {e.target.split(".")[-1] for e in contains if "FIFOController" in e.source} + assert "clk" in fifo_contained + assert "wr_ptr" in fifo_contained + assert "DEPTH" in fifo_contained + + # --- Port connections --- + + def test_module_instantiation_emits_connects_edges(self): + conn = [e for e in self.edges if e.kind == "CONNECTS"] + by_port = {e.extra["port"]: e for e in conn if e.extra.get("instance") == "ptr_adder"} + assert set(by_port) == {"a", "b", "sum"} + assert "wr_ptr" in by_port["a"].extra["signal_expr"] + assert "rd_ptr" in by_port["b"].extra["signal_expr"] + + def test_connects_target_points_at_child_port(self): + conn = [e for e in self.edges if e.kind == "CONNECTS"] + a_edge = next(e for e in conn + if e.extra.get("instance") == "ptr_adder" and e.extra["port"] == "a") + assert a_edge.target.endswith("Adder.a") or a_edge.target == "Adder.a" + + def test_connects_source_is_enclosing_module(self): + conn = [e for e in self.edges if e.kind == "CONNECTS"] + assert conn, "expected CONNECTS edges from FIFOController" + assert all("FIFOController" in e.source for e in conn) + class TestNixParsing: """Flake-aware Nix parser — see the Nix language-support epic."""