From a10c6caaf1a401710d1cee35da012abce28f3b87 Mon Sep 17 00:00:00 2001 From: Kenny Date: Thu, 28 May 2026 18:49:05 +0100 Subject: [PATCH 1/7] Add Antelope smart contract graph support --- README.md | 4 +- code_review_graph/parser.py | 984 ++++++++++++++++++++++++- docs/FEATURES.md | 2 +- docs/LLM-OPTIMIZED-REFERENCE.md | 2 +- docs/USAGE.md | 4 +- docs/schema.md | 3 + tests/fixtures/sample_antelope.abi | 33 + tests/fixtures/sample_antelope.cpp | 59 ++ tests/fixtures/sample_antelope_dapp.js | 16 + tests/test_multilang.py | 87 +++ 10 files changed, 1183 insertions(+), 11 deletions(-) create mode 100644 tests/fixtures/sample_antelope.abi create mode 100644 tests/fixtures/sample_antelope.cpp create mode 100644 tests/fixtures/sample_antelope_dapp.js diff --git a/README.md b/README.md index 6396f788..fafae2c7 100644 --- a/README.md +++ b/README.md @@ -113,7 +113,7 @@ Large monorepos are where token waste is most painful. The graph cuts through th Language coverage organized by category: Web, Backend, Systems, Mobile, Scripting, Config, plus Jupyter and Databricks notebook support

-Parser support covers functions, classes, imports, call sites, inheritance, and test detection across the current parser surface, using Tree-sitter where available and targeted fallbacks where needed. Current support includes Python, JavaScript/TypeScript/TSX, Go, Rust, Java, C/C++, C#, Ruby, Kotlin, Swift, PHP, Scala, Solidity, Dart, R, Perl, Lua/Luau, Objective-C, shell scripts, Elixir, Zig, PowerShell, Julia, ReScript, GDScript, Nix, Verilog/SystemVerilog, SQL, Vue/Svelte SFCs, Astro files parsed through the TypeScript parser, Jupyter/Databricks notebooks (`.ipynb`), and Perl XS files (`.xs`). +Parser support covers functions, classes, imports, call sites, inheritance, and test detection across the current parser surface, using Tree-sitter where available and targeted fallbacks where needed. Current support includes Python, JavaScript/TypeScript/TSX, Go, Rust, Java, C/C++, Antelope/CDT smart contracts and ABI files (`.abi`), C#, Ruby, Kotlin, Swift, PHP, Scala, Solidity, Dart, R, Perl, Lua/Luau, Objective-C, shell scripts, Elixir, Zig, PowerShell, Julia, ReScript, GDScript, Nix, Verilog/SystemVerilog, SQL, Vue/Svelte SFCs, Astro files parsed through the TypeScript parser, Jupyter/Databricks notebooks (`.ipynb`), and Perl XS files (`.xs`). --- @@ -198,7 +198,7 @@ Blast-radius analysis reaches 100% recall on every one of the 13 evaluation comm | Feature | Details | |---------|---------| | **Incremental updates** | Re-parses only changed files. Subsequent updates complete in under 2 seconds. | -| **Broad language + notebook support** | Python, JavaScript/TypeScript/TSX, Go, Rust, Java, C/C++, C#, Ruby, Kotlin, Swift, PHP, Scala, Solidity, Dart, R, Perl, Lua/Luau, Objective-C, shell scripts, Elixir, Zig, PowerShell, Julia, ReScript, GDScript, Nix, Verilog/SystemVerilog, SQL, Vue/Svelte SFCs, Astro files parsed through the TypeScript parser, Jupyter/Databricks (.ipynb), and Perl XS (.xs) | +| **Broad language + notebook support** | Python, JavaScript/TypeScript/TSX, Go, Rust, Java, C/C++, Antelope/CDT smart contracts and ABI files, C#, Ruby, Kotlin, Swift, PHP, Scala, Solidity, Dart, R, Perl, Lua/Luau, Objective-C, shell scripts, Elixir, Zig, PowerShell, Julia, ReScript, GDScript, Nix, Verilog/SystemVerilog, SQL, Vue/Svelte SFCs, Astro files parsed through the TypeScript parser, Jupyter/Databricks (.ipynb), and Perl XS (.xs) | | **Blast-radius analysis** | Shows which functions, classes, and files are likely affected by a change | | **Auto-update hooks** | Hooks and watch mode can update the graph on file saves and supported commit hooks | | **Semantic search** | Optional vector embeddings via sentence-transformers, Google Gemini, MiniMax, or any OpenAI-compatible endpoint (real OpenAI, Azure, new-api, LiteLLM, vLLM, LocalAI) | diff --git a/code_review_graph/parser.py b/code_review_graph/parser.py index f94519db..e4faceec 100644 --- a/code_review_graph/parser.py +++ b/code_review_graph/parser.py @@ -32,6 +32,40 @@ class CellInfo(NamedTuple): re.IGNORECASE, ) +_ANTELOPE_SIGNAL_RE = re.compile( + rb"\b(?:CONTRACT|ACTION|TABLE|SEND_INLINE_ACTION)\b|" + rb"\[\[\s*eosio::|__attribute__\s*\(\(\s*eosio_|" + rb"\beosio::(?:multi_index|singleton|action|contract)\b|" + rb"\b(?:multi_index|singleton)\s*<", +) + +_ANTELOPE_MACRO_REPLACEMENTS: tuple[tuple[re.Pattern[bytes], bytes], ...] = ( + # Preserve byte length so Tree-sitter byte offsets still line up with the + # original source used for line snippets and metadata extraction. + ( + re.compile(rb"(? tuple[list[NodeInfo], list[E if language == "sql": return self._parse_sql(path, source) + if language == "antelope_abi": + return self._parse_antelope_abi(path, source) + parser = self._get_parser(language) if not parser: return [], [] - tree = parser.parse(source) + parse_source = source + if language == "cpp": + parse_source = self._preprocess_antelope_cpp_macros(source) + + tree = parser.parse(parse_source) nodes: list[NodeInfo] = [] edges: list[EdgeInfo] = [] file_path_str = str(path) @@ -962,7 +1004,7 @@ def parse_bytes(self, path: Path, source: bytes) -> tuple[list[NodeInfo], list[E # Pre-scan for import mappings and defined names import_map, defined_names = self._collect_file_scope( - tree.root_node, language, source, + tree.root_node, language, parse_source, ) # Walk the tree @@ -971,6 +1013,11 @@ def parse_bytes(self, path: Path, source: bytes) -> tuple[list[NodeInfo], list[E import_map=import_map, defined_names=defined_names, ) + if language == "cpp": + self._enrich_antelope_cpp(source, file_path_str, nodes, edges) + elif language in ("javascript", "typescript", "tsx"): + self._enrich_antelope_js(source, file_path_str, nodes, edges) + # Resolve bare call targets to qualified names using same-file definitions edges = self._resolve_call_targets(nodes, edges, file_path_str) @@ -994,6 +1041,212 @@ def parse_bytes(self, path: Path, source: bytes) -> tuple[list[NodeInfo], list[E return nodes, edges + @staticmethod + def _preprocess_antelope_cpp_macros(source: bytes) -> bytes: + """Make CDT helper macros parse like ordinary C++ without moving bytes. + + CDT still exposes ``CONTRACT``, ``ACTION``, and ``TABLE`` as helper + macros over the modern ``[[eosio::*]]`` attributes. Tree-sitter sees + the raw macro tokens, which makes contract classes parse as malformed + function definitions. Replacing the macros with same-length C++ + keywords gives the generic parser a faithful tree while preserving byte + offsets for line numbers and source snippets. + """ + if not _ANTELOPE_SIGNAL_RE.search(source): + return source + rewritten = source + for pattern, replacement in _ANTELOPE_MACRO_REPLACEMENTS: + rewritten = pattern.sub(replacement, rewritten) + return CodeParser._blank_cpp_macro_invocations( + rewritten, + (b"EOSLIB_SERIALIZE", b"EOSLIB_SERIALIZE_DERIVED"), + ) + + @staticmethod + def _blank_cpp_macro_invocations(source: bytes, names: tuple[bytes, ...]) -> bytes: + """Replace statement-like macro calls with whitespace, preserving bytes.""" + data = bytearray(source) + for name in names: + search_from = 0 + while True: + pos = source.find(name, search_from) + if pos == -1: + break + before = source[pos - 1:pos] if pos > 0 else b"" + after = source[pos + len(name):pos + len(name) + 1] + if ( + (before and (before.isalnum() or before == b"_")) + or (after and (after.isalnum() or after == b"_")) + ): + search_from = pos + len(name) + continue + i = pos + len(name) + while i < len(source) and source[i:i + 1] in b" \t\r\n": + i += 1 + if i >= len(source) or source[i:i + 1] != b"(": + search_from = i + continue + end = CodeParser._find_balanced_paren_bytes(source, i) + if end == -1: + search_from = i + 1 + continue + blank_end = end + 1 + if blank_end < len(source) and source[blank_end:blank_end + 1] == b";": + blank_end += 1 + for j in range(pos, blank_end): + if data[j] not in (ord("\n"), ord("\r")): + data[j] = ord(" ") + search_from = blank_end + return bytes(data) + + @staticmethod + def _find_balanced_paren_bytes(source: bytes, open_paren: int) -> int: + depth = 0 + quote: Optional[int] = None + escape = False + for i in range(open_paren, len(source)): + ch = source[i] + if quote is not None: + if escape: + escape = False + elif ch == ord("\\"): + escape = True + elif ch == quote: + quote = None + continue + if ch in (ord("'"), ord('"')): + quote = ch + continue + if ch == ord("("): + depth += 1 + elif ch == ord(")"): + depth -= 1 + if depth == 0: + return i + return -1 + + def _parse_antelope_abi( + self, path: Path, source: bytes, + ) -> tuple[list[NodeInfo], list[EdgeInfo]]: + """Parse Antelope ABI JSON enough to connect source, tests, and dapps.""" + file_path_str = str(path) + try: + abi = json.loads(source.decode("utf-8", errors="replace")) + except (json.JSONDecodeError, UnicodeDecodeError): + return [], [] + + lines = source.count(b"\n") + 1 + contract_name = path.stem + nodes: list[NodeInfo] = [NodeInfo( + kind="File", + name=file_path_str, + file_path=file_path_str, + line_start=1, + line_end=lines, + language="antelope_abi", + )] + edges: list[EdgeInfo] = [] + + nodes.append(NodeInfo( + kind="Class", + name=contract_name, + file_path=file_path_str, + line_start=1, + line_end=lines, + language="antelope_abi", + extra={"antelope_kind": "abi_contract"}, + )) + contract_qn = self._qualify(contract_name, file_path_str, None) + edges.append(EdgeInfo( + kind="CONTAINS", + source=file_path_str, + target=contract_qn, + file_path=file_path_str, + line=1, + )) + + structs = { + s.get("name"): s for s in abi.get("structs", []) + if isinstance(s, dict) and s.get("name") + } + for action in abi.get("actions", []): + if not isinstance(action, dict) or not action.get("name"): + continue + action_name = str(action["name"]) + action_type = str(action.get("type") or action_name) + fields = structs.get(action_type, {}).get("fields", []) + params = None + if isinstance(fields, list): + rendered = [ + f"{f.get('type', '')} {f.get('name', '')}".strip() + for f in fields if isinstance(f, dict) + ] + params = f"({', '.join(rendered)})" if rendered else None + extra = { + "antelope_kind": "abi_action", + "antelope_abi_type": action_type, + } + nodes.append(NodeInfo( + kind="Function", + name=action_name, + file_path=file_path_str, + line_start=1, + line_end=lines, + language="antelope_abi", + parent_name=contract_name, + params=params, + extra=extra, + )) + edges.append(EdgeInfo( + kind="CONTAINS", + source=contract_qn, + target=self._qualify(action_name, file_path_str, contract_name), + file_path=file_path_str, + line=1, + )) + + for table in abi.get("tables", []): + if not isinstance(table, dict) or not table.get("name"): + continue + table_name = str(table["name"]) + table_type = str(table.get("type") or table_name) + extra = { + "antelope_kind": "abi_table", + "antelope_table_name": table_name, + "antelope_row_type": table_type, + } + for key in ("index_type", "key_names", "key_types"): + if key in table: + extra[f"antelope_{key}"] = table[key] + nodes.append(NodeInfo( + kind="Type", + name=table_name, + file_path=file_path_str, + line_start=1, + line_end=lines, + language="antelope_abi", + parent_name=contract_name, + extra=extra, + )) + table_qn = self._qualify(table_name, file_path_str, contract_name) + edges.append(EdgeInfo( + kind="CONTAINS", + source=contract_qn, + target=table_qn, + file_path=file_path_str, + line=1, + )) + edges.append(EdgeInfo( + kind="MAPS_TO_TABLE", + source=table_qn, + target=table_name, + file_path=file_path_str, + line=1, + extra={"row_type": table_type, "table_kind": "abi_table"}, + )) + + return nodes, edges + def _parse_vue( self, path: Path, source: bytes, ) -> tuple[list[NodeInfo], list[EdgeInfo]]: @@ -2283,6 +2536,32 @@ def _extract_from_tree( ): continue + # --- Antelope/CDT action declarations in C++ class bodies --- + # After macro preprocessing, ``ACTION foo(...)`` becomes a C++ + # field_declaration with a function_declarator, not a full + # function_definition. Surface it as an addressable action node so + # header-only ABI declarations participate in graph queries. + if ( + language == "cpp" + and node_type == "field_declaration" + and self._extract_cpp_field_function_declaration( + child, source, language, file_path, nodes, edges, + enclosing_class, import_map, defined_names, + ) + ): + continue + + # --- Antelope/CDT table aliases --- + if ( + language == "cpp" + and node_type in ("alias_declaration", "type_definition") + and self._extract_antelope_table_alias( + child, source, file_path, nodes, edges, + enclosing_class, + ) + ): + continue + # --- Classes --- if node_type in class_types and self._extract_classes( child, source, language, file_path, nodes, edges, @@ -4190,6 +4469,677 @@ def _emit_kafka_edges_from_method( extra={"kafka_type": ann_name}, )) + @staticmethod + def _node_source_text(node, source: bytes) -> str: + return source[node.start_byte:node.end_byte].decode( + "utf-8", errors="replace", + ) + + @staticmethod + def _line_starting_at(source: bytes, byte_offset: int) -> str: + start = source.rfind(b"\n", 0, byte_offset) + 1 + end = source.find(b"\n", byte_offset) + if end == -1: + end = len(source) + return source[start:end].decode("utf-8", errors="replace") + + def _antelope_macro_at_node_start( + self, node, source: bytes, + ) -> Optional[str]: + line = self._line_starting_at(source, node.start_byte) + stripped = line.lstrip() + for macro in ("CONTRACT", "ACTION", "TABLE"): + if re.match(rf"^{macro}\b", stripped): + return macro + return None + + def _antelope_attribute_source(self, node, source: bytes) -> str: + """Return text likely to contain attributes attached to *node*.""" + if node.parent and node.parent.type in ( + "field_declaration", "declaration", "function_definition", + "class_specifier", "struct_specifier", + ): + text = self._node_source_text(node.parent, source) + else: + text = self._node_source_text(node, source) + cut_points = [ + pos for pos in (text.find("{"), text.find(";")) if pos != -1 + ] + if cut_points: + text = text[: min(cut_points)] + return text + + def _get_antelope_cpp_metadata( + self, node, source: bytes, semantic: str, + ) -> dict: + """Detect Antelope/CDT meaning for a parsed C++ node.""" + extra: dict = {} + macro = self._antelope_macro_at_node_start(node, source) + if macro: + extra["antelope_macro"] = macro + extra["antelope_kind"] = { + "CONTRACT": "contract", + "ACTION": "action", + "TABLE": "table", + }[macro] + + attr_text = self._antelope_attribute_source(node, source) + attrs: list[str] = [] + for match in re.finditer( + r"\[\[\s*eosio::([A-Za-z_][A-Za-z0-9_]*)" + r"(?:\s*\((.*?)\))?\s*\]\]", + attr_text, + re.DOTALL, + ): + attr_name = match.group(1) + attrs.append(attr_name) + attr_arg = self._strip_cpp_literal(match.group(2) or "") + if attr_name in ("contract", "action", "table"): + extra["antelope_kind"] = attr_name + if attr_arg: + extra["antelope_abi_name"] = attr_arg + elif attr_name == "on_notify": + extra["antelope_kind"] = "notification" + if attr_arg: + extra["antelope_notify_pattern"] = attr_arg + elif attr_name == "read_only": + extra["antelope_kind"] = "read_only" + if attrs: + extra["antelope_attributes"] = attrs + + if semantic == "class" and not extra.get("antelope_kind"): + text = self._node_source_text(node, source) + if re.search(r":\s*(?:public\s+)?(?:eosio::)?contract\b", text): + extra["antelope_kind"] = "contract" + + return extra + + @staticmethod + def _strip_cpp_literal(value: str) -> str: + value = value.strip() + if value.endswith("_n"): + value = value[:-2].strip() + if ( + len(value) >= 2 + and value[0] == value[-1] + and value[0] in ("'", '"') + ): + value = value[1:-1] + return value.strip() + + def _get_cpp_function_scope(self, node) -> Optional[str]: + """Return the qualifier in ``Contract::action(...)`` definitions.""" + for child in node.children: + if child.type != "function_declarator": + continue + for sub in child.children: + if sub.type != "qualified_identifier": + continue + parts = [ + p.text.decode("utf-8", errors="replace") + for p in sub.children + if p.type in ( + "namespace_identifier", "type_identifier", + "identifier", "field_identifier", + ) + ] + if len(parts) >= 2: + return ".".join(parts[:-1]) + return None + + def _extract_cpp_field_function_declaration( + self, + child, + source: bytes, + language: str, + file_path: str, + nodes: list[NodeInfo], + edges: list[EdgeInfo], + enclosing_class: Optional[str], + import_map: Optional[dict[str, str]], + defined_names: Optional[set[str]], + ) -> bool: + if not any(sub.type == "function_declarator" for sub in child.children): + return False + + extra = self._get_antelope_cpp_metadata(child, source, "function") + if extra.get("antelope_kind") not in ( + "action", "notification", "read_only", + ): + return False + + name = self._get_name(child, language, "function") + if not name: + return False + + func_decl = next( + (sub for sub in child.children if sub.type == "function_declarator"), + child, + ) + params = self._get_params(func_decl, language, source) + parent_name = enclosing_class + qualified = self._qualify(name, file_path, parent_name) + nodes.append(NodeInfo( + kind="Function", + name=name, + file_path=file_path, + line_start=child.start_point[0] + 1, + line_end=child.end_point[0] + 1, + language=language, + parent_name=parent_name, + params=params, + extra=extra, + )) + container = ( + self._qualify(parent_name, file_path, None) + if parent_name + else file_path + ) + edges.append(EdgeInfo( + kind="CONTAINS", + source=container, + target=qualified, + file_path=file_path, + line=child.start_point[0] + 1, + )) + if defined_names is not None: + defined_names.add(name) + return True + + def _extract_antelope_table_alias( + self, + child, + source: bytes, + file_path: str, + nodes: list[NodeInfo], + edges: list[EdgeInfo], + enclosing_class: Optional[str], + ) -> bool: + text = self._node_source_text(child, source) + if "multi_index" not in text and "singleton" not in text: + return False + + alias = table_kind = table_name = row_type = None + using_match = re.search( + r"\busing\s+([A-Za-z_][A-Za-z0-9_]*)\s*=\s*" + r"(?:eosio::)?(multi_index|singleton)\s*<\s*" + r"((?:\"[^\"]+\"|'[^']+')\s*_n)\s*,\s*" + r"([A-Za-z_:][A-Za-z0-9_:]*)", + text, + re.DOTALL, + ) + if using_match: + alias = using_match.group(1) + table_kind = using_match.group(2) + table_name = self._strip_cpp_literal(using_match.group(3)) + row_type = using_match.group(4) + else: + typedef_match = re.search( + r"\btypedef\s+(?:eosio::)?(multi_index|singleton)\s*<\s*" + r"((?:\"[^\"]+\"|'[^']+')\s*_n)\s*,\s*" + r"([A-Za-z_:][A-Za-z0-9_:]*)[\s\S]*?>\s*" + r"([A-Za-z_][A-Za-z0-9_]*)\s*;", + text, + re.DOTALL, + ) + if typedef_match: + table_kind = typedef_match.group(1) + table_name = self._strip_cpp_literal(typedef_match.group(2)) + row_type = typedef_match.group(3) + alias = typedef_match.group(4) + + if not alias or not table_kind or not table_name: + return False + + indices = [ + self._strip_cpp_literal(m.group(1)) + for m in re.finditer( + r"indexed_by\s*<\s*((?:\"[^\"]+\"|'[^']+')\s*_n)", + text, + ) + ] + extra = { + "antelope_kind": table_kind, + "antelope_table_name": table_name, + "antelope_row_type": row_type, + } + if indices: + extra["antelope_secondary_indices"] = indices + + qualified = self._qualify(alias, file_path, enclosing_class) + nodes.append(NodeInfo( + kind="Type", + name=alias, + file_path=file_path, + line_start=child.start_point[0] + 1, + line_end=child.end_point[0] + 1, + language="cpp", + parent_name=enclosing_class, + extra=extra, + )) + container = ( + self._qualify(enclosing_class, file_path, None) + if enclosing_class + else file_path + ) + edges.append(EdgeInfo( + kind="CONTAINS", + source=container, + target=qualified, + file_path=file_path, + line=child.start_point[0] + 1, + )) + edges.append(EdgeInfo( + kind="MAPS_TO_TABLE", + source=qualified, + target=table_name, + file_path=file_path, + line=child.start_point[0] + 1, + extra={"row_type": row_type, "table_kind": table_kind}, + )) + return True + + def _enrich_antelope_cpp( + self, + source: bytes, + file_path: str, + nodes: list[NodeInfo], + edges: list[EdgeInfo], + ) -> None: + if not _ANTELOPE_SIGNAL_RE.search(source): + return + + text = source.decode("utf-8", errors="replace") + line_offsets = self._line_offsets(text) + table_alias_nodes = { + n.name: n + for n in nodes + if n.kind == "Type" + and n.extra.get("antelope_kind") in ("multi_index", "singleton") + } + + for node in nodes: + kind = node.extra.get("antelope_kind") + if kind not in ("action", "notification", "read_only"): + continue + if node.line_start <= 0 or node.line_end < node.line_start: + continue + + start = line_offsets[min(node.line_start - 1, len(line_offsets) - 1)] + end_line = min(node.line_end, len(line_offsets) - 1) + end = line_offsets[end_line] + body = text[start:end] + source_qn = self._qualify(node.name, file_path, node.parent_name) + + self._emit_antelope_auth_edges(body, source_qn, file_path, node.line_start, edges) + self._emit_antelope_inline_action_edges( + body, source_qn, file_path, node.line_start, edges, + ) + self._emit_antelope_table_access_edges( + body, source_qn, file_path, node.line_start, table_alias_nodes, + edges, + ) + pattern = node.extra.get("antelope_notify_pattern") + if pattern: + edges.append(EdgeInfo( + kind="HANDLES_NOTIFICATION", + source=source_qn, + target=pattern, + file_path=file_path, + line=node.line_start, + extra={"antelope": True}, + )) + + @staticmethod + def _line_offsets(text: str) -> list[int]: + offsets = [0] + for match in re.finditer("\n", text): + offsets.append(match.end()) + offsets.append(len(text)) + return offsets + + def _emit_antelope_auth_edges( + self, + body: str, + source_qn: str, + file_path: str, + line_start: int, + edges: list[EdgeInfo], + ) -> None: + for match in re.finditer(r"\b(?:eosio::)?require_auth\s*\(", body): + arg, _ = self._read_balanced_call_arg(body, match.end() - 1) + if not arg: + continue + edges.append(EdgeInfo( + kind="REQUIRES_AUTH", + source=source_qn, + target=self._clean_antelope_expr(arg), + file_path=file_path, + line=line_start + body[:match.start()].count("\n"), + extra={"antelope": True}, + )) + for match in re.finditer(r"\b(?:eosio::)?require_recipient\s*\(", body): + arg, _ = self._read_balanced_call_arg(body, match.end() - 1) + if not arg: + continue + edges.append(EdgeInfo( + kind="NOTIFIES", + source=source_qn, + target=self._clean_antelope_expr(arg), + file_path=file_path, + line=line_start + body[:match.start()].count("\n"), + extra={"antelope": True}, + )) + + def _emit_antelope_inline_action_edges( + self, + body: str, + source_qn: str, + file_path: str, + line_start: int, + edges: list[EdgeInfo], + ) -> None: + for match in re.finditer(r"\b(?:eosio::)?action\s*\(", body): + arg_text, _ = self._read_balanced_call_arg(body, match.end() - 1) + args = self._split_top_level_args(arg_text) + if len(args) < 3: + continue + contract_expr = self._clean_antelope_expr(args[1]) + action_name = self._clean_antelope_expr(args[2]) + target = f"{contract_expr}::{action_name}" if contract_expr else action_name + edges.append(EdgeInfo( + kind="SENDS_INLINE_ACTION", + source=source_qn, + target=target, + file_path=file_path, + line=line_start + body[:match.start()].count("\n"), + extra={"contract": contract_expr, "action": action_name}, + )) + + for match in re.finditer(r"\bSEND_INLINE_ACTION\s*\(", body): + arg_text, _ = self._read_balanced_call_arg(body, match.end() - 1) + args = self._split_top_level_args(arg_text) + if len(args) < 2: + continue + action_name = self._clean_antelope_expr(args[1]) + edges.append(EdgeInfo( + kind="SENDS_INLINE_ACTION", + source=source_qn, + target=action_name, + file_path=file_path, + line=line_start + body[:match.start()].count("\n"), + extra={"action": action_name, "macro": "SEND_INLINE_ACTION"}, + )) + + def _emit_antelope_table_access_edges( + self, + body: str, + source_qn: str, + file_path: str, + line_start: int, + table_alias_nodes: dict[str, NodeInfo], + edges: list[EdgeInfo], + ) -> None: + table_vars: dict[str, str] = {} + for match in re.finditer( + r"\b([A-Za-z_][A-Za-z0-9_:]*)\s+" + r"([A-Za-z_][A-Za-z0-9_]*)\s*\(", + body, + ): + table_type = match.group(1).split("::")[-1] + var_name = match.group(2) + if table_type in { + "action", "asset", "check", "name", "symbol", + "permission_level", "std", "string", + }: + continue + table_vars[var_name] = table_type + + # Secondary index handles should inherit the table variable's target. + for match in re.finditer( + r"\bauto\s+([A-Za-z_][A-Za-z0-9_]*)\s*=\s*" + r"([A-Za-z_][A-Za-z0-9_]*)\.get_index\s*<", + body, + ): + idx_var, table_var = match.group(1), match.group(2) + if table_var in table_vars: + table_vars[idx_var] = table_vars[table_var] + + seen: set[tuple[str, str, int]] = set() + for match in re.finditer( + r"\b([A-Za-z_][A-Za-z0-9_]*)\s*\.\s*" + r"([A-Za-z_][A-Za-z0-9_]*)\b", + body, + ): + var_name, op = match.group(1), match.group(2) + table_target = table_vars.get(var_name) + if not table_target: + continue + if op in _ANTELOPE_TABLE_WRITE_OPS: + edge_kind = "WRITES_TABLE" + elif op == "erase": + edge_kind = "ERASES_TABLE" + elif op in _ANTELOPE_TABLE_READ_OPS: + edge_kind = "READS_TABLE" + else: + continue + line = line_start + body[:match.start()].count("\n") + dedupe = (edge_kind, table_target, line) + if dedupe in seen: + continue + seen.add(dedupe) + alias_node = table_alias_nodes.get(table_target) + if alias_node and alias_node.parent_name: + target = self._qualify( + alias_node.name, alias_node.file_path, alias_node.parent_name, + ) + elif alias_node: + target = self._qualify(alias_node.name, alias_node.file_path, None) + else: + target = table_target + edges.append(EdgeInfo( + kind=edge_kind, + source=source_qn, + target=target, + file_path=file_path, + line=line, + extra={"operation": op, "table_variable": var_name}, + )) + + @staticmethod + def _read_balanced_call_arg(text: str, open_paren: int) -> tuple[str, int]: + if open_paren < 0 or open_paren >= len(text) or text[open_paren] != "(": + return "", open_paren + depth = 0 + quote: Optional[str] = None + escape = False + for i in range(open_paren, len(text)): + ch = text[i] + if quote: + if escape: + escape = False + elif ch == "\\": + escape = True + elif ch == quote: + quote = None + continue + if ch in ("'", '"'): + quote = ch + continue + if ch == "(": + depth += 1 + elif ch == ")": + depth -= 1 + if depth == 0: + return text[open_paren + 1:i], i + return "", open_paren + + @staticmethod + def _split_top_level_args(arg_text: str) -> list[str]: + args: list[str] = [] + start = 0 + depth = 0 + quote: Optional[str] = None + escape = False + bracket_pairs = {"(": ")", "{": "}", "[": "]", "<": ">"} + closers = set(bracket_pairs.values()) + for i, ch in enumerate(arg_text): + if quote: + if escape: + escape = False + elif ch == "\\": + escape = True + elif ch == quote: + quote = None + continue + if ch in ("'", '"'): + quote = ch + continue + if ch in bracket_pairs: + depth += 1 + elif ch in closers and depth > 0: + depth -= 1 + elif ch == "," and depth == 0: + args.append(arg_text[start:i].strip()) + start = i + 1 + tail = arg_text[start:].strip() + if tail: + args.append(tail) + return args + + def _clean_antelope_expr(self, expr: str) -> str: + expr = re.sub(r"\s+", " ", expr).strip() + # name literals: "transfer"_n, 'transfer'_n, transfer_name + expr = self._strip_cpp_literal(expr) + # The constructor target often arrives as name{"foo"} or symbol_code("X"). + literal_match = re.search(r'["\']([^"\']+)["\']\s*_n?', expr) + if literal_match and len(expr) <= len(literal_match.group(0)) + 8: + return literal_match.group(1) + return expr.strip() + + def _enrich_antelope_js( + self, + source: bytes, + file_path: str, + nodes: list[NodeInfo], + edges: list[EdgeInfo], + ) -> None: + text = source.decode("utf-8", errors="replace") + if not any( + marker in text + for marker in ( + ".contract.actions.", ".contract.tables.", "api.transact", + "actions:", + ) + ): + return + + for match in re.finditer( + r"\b(?:[A-Za-z_][A-Za-z0-9_]*\.)*contract\.actions\." + r"([A-Za-z_][A-Za-z0-9_]*)\s*\(\s*([^,\n)]+)", + text, + ): + action_name = match.group(1) + contract_expr = self._clean_js_expr(match.group(2)) + line = text[:match.start()].count("\n") + 1 + edges.append(EdgeInfo( + kind="CALLS_ACTION", + source=self._node_at_line(nodes, file_path, line), + target=f"{contract_expr}::{action_name}" if contract_expr else action_name, + file_path=file_path, + line=line, + extra={"contract": contract_expr, "action": action_name}, + )) + + for match in re.finditer( + r"\b(?:[A-Za-z_][A-Za-z0-9_]*\.)*contract\.tables\." + r"([A-Za-z_][A-Za-z0-9_]*)\s*\(\s*([^,\n)]+)", + text, + ): + table_name = match.group(1) + contract_expr = self._clean_js_expr(match.group(2)) + line = text[:match.start()].count("\n") + 1 + edges.append(EdgeInfo( + kind="READS_TABLE", + source=self._node_at_line(nodes, file_path, line), + target=table_name, + file_path=file_path, + line=line, + extra={ + "contract": contract_expr, + "table": table_name, + "from_dapp": True, + }, + )) + + action_obj_patterns = ( + re.compile( + r"\{\s*account\s*:\s*([^,\n{}]+),\s*" + r"name\s*:\s*(['\"])([^'\"]+)\2", + re.DOTALL, + ), + re.compile( + r"\{\s*name\s*:\s*(['\"])([^'\"]+)\1,\s*" + r"account\s*:\s*([^,\n{}]+)", + re.DOTALL, + ), + ) + for pattern in action_obj_patterns: + for match in pattern.finditer(text): + if pattern is action_obj_patterns[0]: + contract_expr = self._clean_js_expr(match.group(1)) + action_name = match.group(3) + else: + action_name = match.group(2) + contract_expr = self._clean_js_expr(match.group(3)) + line = text[:match.start()].count("\n") + 1 + edges.append(EdgeInfo( + kind="CALLS_ACTION", + source=self._node_at_line(nodes, file_path, line), + target=( + f"{contract_expr}::{action_name}" + if contract_expr else action_name + ), + file_path=file_path, + line=line, + extra={ + "contract": contract_expr, + "action": action_name, + "shape": "eosjs_action_object", + }, + )) + + @staticmethod + def _clean_js_expr(expr: str) -> str: + expr = re.sub(r"\s+", " ", expr).strip() + if ( + len(expr) >= 2 + and expr[0] == expr[-1] + and expr[0] in ("'", '"', "`") + ): + return expr[1:-1] + return expr + + def _node_at_line( + self, nodes: list[NodeInfo], file_path: str, line: int, + ) -> str: + candidates = [ + n for n in nodes + if n.file_path == file_path + and n.kind != "File" + and n.line_start <= line <= n.line_end + ] + if not candidates: + return file_path + candidates.sort( + key=lambda n: ( + n.line_end - n.line_start, + 0 if n.kind == "Test" else 1, + ) + ) + node = candidates[0] + return self._qualify(node.name, file_path, node.parent_name) + def _extract_classes( self, child, @@ -4211,11 +5161,14 @@ def _extract_classes( if not name: return False + extra: dict = {} + if language == "cpp": + extra.update(self._get_antelope_cpp_metadata(child, source, "class")) + # Swift: detect the actual type keyword (class/struct/enum/actor/extension) # and store it in extra["swift_kind"] for richer downstream analysis. # Tree-sitter maps struct/enum/actor/extension all to class_declaration; # protocol uses its own protocol_declaration node type. - extra: dict = {} if language == "swift": if child.type == "class_declaration": _swift_keywords = {"class", "struct", "enum", "actor", "extension"} @@ -4377,12 +5330,24 @@ def _extract_functions( parent_name = enclosing_func container_scope = enclosing_func + antelope_extra: dict = {} + if language == "cpp": + antelope_extra = self._get_antelope_cpp_metadata( + child, source, "function", + ) + scoped_parent = self._get_cpp_function_scope(child) + if scoped_parent and antelope_extra.get("antelope_kind") in ( + "action", "notification", "read_only", + ): + parent_name = scoped_parent + container_scope = scoped_parent + qualified = self._qualify(name, file_path, parent_name) params = self._get_params(child, language, source) ret_type = self._get_return_type(child, language, source) # Java: detect Temporal method-level annotations and Kafka listeners - method_extra: dict = {} + method_extra: dict = dict(antelope_extra) if language == "java" and deco_list: temporal_method_annots = [ a for a in deco_list if a in _TEMPORAL_METHOD_ANNOTATIONS @@ -4494,9 +5459,18 @@ def _extract_functions( break # Recurse to find calls inside the function + recurse_class = enclosing_class + if ( + language == "cpp" + and method_extra.get("antelope_kind") in ( + "action", "notification", "read_only", + ) + ): + recurse_class = parent_name + self._extract_from_tree( child, source, language, file_path, nodes, edges, - enclosing_class=enclosing_class, enclosing_func=name, + enclosing_class=recurse_class, enclosing_func=name, import_map=import_map, defined_names=defined_names, _depth=_depth + 1, ) diff --git a/docs/FEATURES.md b/docs/FEATURES.md index a2d82b30..2575c0e3 100644 --- a/docs/FEATURES.md +++ b/docs/FEATURES.md @@ -22,7 +22,7 @@ - **Graph lookup correctness**: Review, impact, and file-summary tools resolve user-facing paths to stored graph paths; `callers_of` includes cross-file callers even when same-file callers exist. - **Install/runtime reliability**: Generated Codex/Claude hooks drain stdin, bundled docs are available from wheels, missing local embeddings report unavailable status, and `.svn` roots pass validation. - **CLI reliability**: `build --skip-postprocess` and `update --skip-flows` honor the requested post-processing level. -- **Broad parser surface**: Python, JavaScript/TypeScript/TSX, Go, Rust, Java, C/C++, C#, Ruby, Kotlin, Swift, PHP, Scala, Solidity, Dart, R, Perl, Lua/Luau, Objective-C, shell scripts, Elixir, Zig, PowerShell, Julia, ReScript, GDScript, Nix, Verilog/SystemVerilog, SQL, Vue/Svelte SFCs, Astro files parsed through the TypeScript parser, Jupyter/Databricks notebooks, and Perl XS files. +- **Broad parser surface**: Python, JavaScript/TypeScript/TSX, Go, Rust, Java, C/C++, Antelope/CDT smart contracts and ABI files, C#, Ruby, Kotlin, Swift, PHP, Scala, Solidity, Dart, R, Perl, Lua/Luau, Objective-C, shell scripts, Elixir, Zig, PowerShell, Julia, ReScript, GDScript, Nix, Verilog/SystemVerilog, SQL, Vue/Svelte SFCs, Astro files parsed through the TypeScript parser, Jupyter/Databricks notebooks, and Perl XS files. - **Local-first by design**: SQLite graph storage remains local, with no telemetry and no cloud-default behavior. ## v2.0.0 diff --git a/docs/LLM-OPTIMIZED-REFERENCE.md b/docs/LLM-OPTIMIZED-REFERENCE.md index de059022..86d93dd3 100644 --- a/docs/LLM-OPTIMIZED-REFERENCE.md +++ b/docs/LLM-OPTIMIZED-REFERENCE.md @@ -50,7 +50,7 @@ Configure via provider/model parameters, CRG_EMBEDDING_MODEL for local, or CRG_O
-Supported: Python, JavaScript/TypeScript/TSX, Go, Rust, Java, C/C++, C#, Ruby, Kotlin, Swift, PHP, Scala, Solidity, Dart, R, Perl, Lua/Luau, Objective-C, shell scripts, Elixir, Zig, PowerShell, Julia, ReScript, GDScript, Nix, Verilog/SystemVerilog, SQL, Vue/Svelte SFCs, Astro files parsed through the TypeScript parser, Jupyter/Databricks notebooks, and Perl XS files. +Supported: Python, JavaScript/TypeScript/TSX, Go, Rust, Java, C/C++, Antelope/CDT smart contracts and ABI files, C#, Ruby, Kotlin, Swift, PHP, Scala, Solidity, Dart, R, Perl, Lua/Luau, Objective-C, shell scripts, Elixir, Zig, PowerShell, Julia, ReScript, GDScript, Nix, Verilog/SystemVerilog, SQL, Vue/Svelte SFCs, Astro files parsed through the TypeScript parser, Jupyter/Databricks notebooks, and Perl XS files. Parser: Tree-sitter via tree-sitter-language-pack
diff --git a/docs/USAGE.md b/docs/USAGE.md index 3f998063..58e5a0f1 100644 --- a/docs/USAGE.md +++ b/docs/USAGE.md @@ -116,14 +116,14 @@ Since v2.3.4, review and impact tools include compact `context_savings` metadata ## Supported Languages -The parser currently covers Python, JavaScript, TypeScript/TSX, Go, Rust, Java, C/C++, C#, Ruby, Kotlin, Swift, PHP, Scala, Solidity, Dart, R, Perl, Lua/Luau, Objective-C, shell scripts, Elixir, Zig, PowerShell, Julia, ReScript, GDScript, Nix, Verilog/SystemVerilog, SQL, Vue/Svelte single-file components, Astro files parsed through the TypeScript parser, Jupyter/Databricks notebooks (`.ipynb`), and Perl XS files (`.xs`). +The parser currently covers Python, JavaScript, TypeScript/TSX, Go, Rust, Java, C/C++, Antelope/CDT smart contracts and ABI files (`.abi`), C#, Ruby, Kotlin, Swift, PHP, Scala, Solidity, Dart, R, Perl, Lua/Luau, Objective-C, shell scripts, Elixir, Zig, PowerShell, Julia, ReScript, GDScript, Nix, Verilog/SystemVerilog, SQL, Vue/Svelte single-file components, Astro files parsed through the TypeScript parser, Jupyter/Databricks notebooks (`.ipynb`), and Perl XS files (`.xs`). Extension-less scripts are detected by shebang for common bash/sh/zsh/ksh/dash/ash, Python, Node, Ruby, Perl, Lua, Rscript, and PHP interpreters. ## What Gets Indexed - **Nodes**: Files, Classes, Functions/Methods, Types, Tests -- **Edges**: CALLS, IMPORTS_FROM, INHERITS, IMPLEMENTS, CONTAINS, TESTED_BY, DEPENDS_ON +- **Edges**: CALLS, IMPORTS_FROM, INHERITS, IMPLEMENTS, CONTAINS, TESTED_BY, DEPENDS_ON, plus specialised framework edges such as Antelope `REQUIRES_AUTH`, `READS_TABLE`, `WRITES_TABLE`, `ERASES_TABLE`, `SENDS_INLINE_ACTION`, `HANDLES_NOTIFICATION`, and dapp `CALLS_ACTION`. See [schema.md](schema.md) for full details. diff --git a/docs/schema.md b/docs/schema.md index 6c11a5d5..0534ffaf 100644 --- a/docs/schema.md +++ b/docs/schema.md @@ -126,6 +126,9 @@ A dependency-injection relationship, currently used by Java/Spring enrichment fo ### CONSUMES / PRODUCES Data or event flow relationships emitted by specialised parsers when a source consumes or produces a named resource. +### Antelope / CDT edges +Antelope smart contract enrichment emits `REQUIRES_AUTH`, `NOTIFIES`, `HANDLES_NOTIFICATION`, `READS_TABLE`, `WRITES_TABLE`, `ERASES_TABLE`, `MAPS_TO_TABLE`, `SENDS_INLINE_ACTION`, and dapp-side `CALLS_ACTION` edges. These connect CDT `CONTRACT` / `ACTION` / `TABLE` macros, `[[eosio::*]]` attributes, `multi_index` / `singleton` aliases, inline actions, ABI declarations, and eosjs/Hydra-style frontend or test calls. + ### TEMPORAL_STUB Temporal dependency placeholder emitted by specialised parsers when a time/order relationship is detected but cannot be resolved to a stronger edge type. diff --git a/tests/fixtures/sample_antelope.abi b/tests/fixtures/sample_antelope.abi new file mode 100644 index 00000000..b66f109b --- /dev/null +++ b/tests/fixtures/sample_antelope.abi @@ -0,0 +1,33 @@ +{ + "version": "eosio::abi/1.2", + "structs": [ + { + "name": "setprice", + "base": "", + "fields": [ + { "name": "user", "type": "name" }, + { "name": "id", "type": "uint64" } + ] + }, + { + "name": "price_row", + "base": "", + "fields": [ + { "name": "id", "type": "uint64" }, + { "name": "value", "type": "uint64" } + ] + } + ], + "actions": [ + { "name": "setprice", "type": "setprice", "ricardian_contract": "" } + ], + "tables": [ + { + "name": "prices", + "type": "price_row", + "index_type": "i64", + "key_names": ["id"], + "key_types": ["uint64"] + } + ] +} diff --git a/tests/fixtures/sample_antelope.cpp b/tests/fixtures/sample_antelope.cpp new file mode 100644 index 00000000..848df9f6 --- /dev/null +++ b/tests/fixtures/sample_antelope.cpp @@ -0,0 +1,59 @@ +#include +#include + +using namespace eosio; + +CONTRACT oracle : public contract { +public: + using contract::contract; + + ACTION setprice(name user, uint64_t id); + + [[eosio::on_notify("*::transfer")]] + void on_transfer(name from, name to, asset quantity, std::string memo); + + TABLE price_row { + uint64_t id; + uint64_t value; + + uint64_t primary_key() const { return id; } + uint64_t by_value() const { return value; } + + EOSLIB_SERIALIZE(price_row, (id)(value)) + }; + + using prices = eosio::multi_index<"prices"_n, price_row, + indexed_by<"byvalue"_n, const_mem_fun>>; +}; + +ACTION oracle::setprice(name user, uint64_t id) { + require_auth(user); + + prices price_table(get_self(), get_self().value); + auto itr = price_table.find(id); + if (itr == price_table.end()) { + price_table.emplace(user, [&](auto& row) { + row.id = id; + row.value = 42; + }); + } else { + price_table.modify(itr, same_payer, [&](auto& row) { + row.value = 43; + }); + } + + action( + permission_level{get_self(), "active"_n}, + "eosio.token"_n, + "transfer"_n, + std::make_tuple(get_self(), user, asset(1, symbol("WAX", 8)), std::string("memo")) + ).send(); +} + +[[eosio::action]] +void oracle::clear(name user) { + require_auth(get_self()); + prices price_table(get_self(), get_self().value); + auto itr = price_table.require_find(0, "missing"); + price_table.erase(itr); +} diff --git a/tests/fixtures/sample_antelope_dapp.js b/tests/fixtures/sample_antelope_dapp.js new file mode 100644 index 00000000..470c04ec --- /dev/null +++ b/tests/fixtures/sample_antelope_dapp.js @@ -0,0 +1,16 @@ +async function setPrice(session, oracle, user) { + await session.contract.actions.setprice(oracle, { user, id: 7 }, [{ actor: user }]); + const rows = await session.contract.tables.prices(oracle).getTableRows({ scope: oracle }); + return rows; +} + +async function transfer(api) { + await api.transact({ + actions: [{ + account: 'eosio.token', + name: 'transfer', + authorization: [], + data: {} + }] + }, { blocksBehind: 3, expireSeconds: 30 }); +} diff --git a/tests/test_multilang.py b/tests/test_multilang.py index afda355e..791f078a 100644 --- a/tests/test_multilang.py +++ b/tests/test_multilang.py @@ -336,6 +336,93 @@ def test_finds_inheritance(self): assert len(inherits) >= 1 +class TestAntelopeCppParsing: + def setup_method(self): + self.parser = CodeParser() + self.nodes, self.edges = self.parser.parse_file(FIXTURES / "sample_antelope.cpp") + + def test_detects_abi_language(self): + assert self.parser.detect_language(Path("sample.abi")) == "antelope_abi" + + def test_extracts_contract_actions_tables_and_aliases(self): + contracts = [ + n for n in self.nodes + if n.kind == "Class" and n.extra.get("antelope_kind") == "contract" + ] + assert [(n.name, n.line_start, n.line_end) for n in contracts] == [ + ("oracle", 6, 27) + ] + + actions = { + (n.name, n.parent_name) + for n in self.nodes + if n.extra.get("antelope_kind") == "action" + } + assert ("setprice", "oracle") in actions + assert ("clear", "oracle") in actions + assert ("ACTION", "oracle") not in actions + + table = next( + n for n in self.nodes + if n.name == "price_row" and n.extra.get("antelope_kind") == "table" + ) + assert table.parent_name == "oracle" + + alias = next(n for n in self.nodes if n.name == "prices" and n.kind == "Type") + assert alias.extra["antelope_kind"] == "multi_index" + assert alias.extra["antelope_table_name"] == "prices" + assert alias.extra["antelope_row_type"] == "price_row" + assert alias.extra["antelope_secondary_indices"] == ["byvalue"] + + def test_extracts_antelope_semantic_edges(self): + auth_edges = [e for e in self.edges if e.kind == "REQUIRES_AUTH"] + assert any(e.source.endswith("::oracle.setprice") and e.target == "user" for e in auth_edges) + assert any(e.source.endswith("::oracle.clear") and e.target == "get_self()" for e in auth_edges) + + writes = [e for e in self.edges if e.kind == "WRITES_TABLE"] + erases = [e for e in self.edges if e.kind == "ERASES_TABLE"] + reads = [e for e in self.edges if e.kind == "READS_TABLE"] + assert any(e.target.endswith("::oracle.prices") and e.extra["operation"] == "emplace" + for e in writes) + assert any(e.target.endswith("::oracle.prices") and e.extra["operation"] == "modify" + for e in writes) + assert any(e.target.endswith("::oracle.prices") and e.extra["operation"] == "require_find" + for e in reads) + assert any(e.target.endswith("::oracle.prices") and e.extra["operation"] == "erase" + for e in erases) + + inline = [e for e in self.edges if e.kind == "SENDS_INLINE_ACTION"] + assert any(e.target == "eosio.token::transfer" for e in inline) + + notifications = [e for e in self.edges if e.kind == "HANDLES_NOTIFICATION"] + assert any(e.source.endswith("::oracle.on_transfer") and e.target == "*::transfer" + for e in notifications) + + def test_extracts_antelope_abi(self): + nodes, edges = self.parser.parse_file(FIXTURES / "sample_antelope.abi") + actions = [ + n for n in nodes + if n.extra.get("antelope_kind") == "abi_action" + ] + tables = [ + n for n in nodes + if n.extra.get("antelope_kind") == "abi_table" + ] + assert actions[0].name == "setprice" + assert actions[0].params == "(name user, uint64 id)" + assert tables[0].name == "prices" + assert tables[0].extra["antelope_row_type"] == "price_row" + assert any(e.kind == "MAPS_TO_TABLE" and e.target == "prices" for e in edges) + + def test_extracts_dapp_action_and_table_edges(self): + nodes, edges = self.parser.parse_file(FIXTURES / "sample_antelope_dapp.js") + action_edges = [e for e in edges if e.kind == "CALLS_ACTION"] + table_edges = [e for e in edges if e.kind == "READS_TABLE"] + assert any(e.target == "oracle::setprice" for e in action_edges) + assert any(e.target == "eosio.token::transfer" for e in action_edges) + assert any(e.target == "prices" and e.extra["from_dapp"] for e in table_edges) + + class TestHhParsing: def setup_method(self): self.parser = CodeParser() From d8c11307072c335f0ed956df2215bad2207a620e Mon Sep 17 00:00:00 2001 From: Kenny Date: Thu, 28 May 2026 19:16:22 +0100 Subject: [PATCH 2/7] Make visualization auto mode edge-aware --- code_review_graph/visualization.py | 17 ++++++++++++----- tests/test_visualization.py | 27 +++++++++++++++++++++++++-- 2 files changed, 37 insertions(+), 7 deletions(-) diff --git a/code_review_graph/visualization.py b/code_review_graph/visualization.py index c78b5735..85a5a111 100644 --- a/code_review_graph/visualization.py +++ b/code_review_graph/visualization.py @@ -8,7 +8,7 @@ - ``full`` — render every node (default, current behavior) - ``community`` — aggregate by community; double-click to drill down - ``file`` — aggregate by file; each file is a node -- ``auto`` — choose community mode when node count exceeds threshold +- ``auto`` — choose community mode when node or edge count exceeds threshold """ from __future__ import annotations @@ -361,7 +361,8 @@ def generate_html( store: GraphStore, output_path: str | Path, mode: str = "auto", - max_full_nodes: int = 3000, + max_full_nodes: int = 800, + max_full_edges: int = 3000, ) -> Path: """Generate a self-contained interactive HTML visualization. @@ -370,8 +371,9 @@ def generate_html( output_path: Path for the output HTML file. mode: Rendering mode — ``"auto"``, ``"full"``, ``"community"``, or ``"file"``. ``"auto"`` switches to ``"community"`` when - the node count exceeds *max_full_nodes*. - max_full_nodes: Threshold for auto-switching to community mode. + the node or edge count exceeds the full-render thresholds. + max_full_nodes: Node threshold for auto-switching to community mode. + max_full_edges: Edge threshold for auto-switching to community mode. Writes the HTML file to *output_path* and returns the resolved Path. """ @@ -388,7 +390,12 @@ def generate_html( effective_mode = mode if effective_mode == "auto": effective_mode = ( - "community" if stats.total_nodes > max_full_nodes else "full" + "community" + if ( + stats.total_nodes > max_full_nodes + or stats.total_edges > max_full_edges + ) + else "full" ) if effective_mode == "community": diff --git a/tests/test_visualization.py b/tests/test_visualization.py index 780fe367..4342fac9 100644 --- a/tests/test_visualization.py +++ b/tests/test_visualization.py @@ -489,8 +489,14 @@ def test_auto_mode_switches_at_threshold(large_store, tmp_path): from code_review_graph.visualization import generate_html output_path = tmp_path / "auto_low.html" - # Threshold higher than node count -> should use full template - generate_html(large_store, output_path, mode="auto", max_full_nodes=100000) + # Thresholds higher than graph size -> should use full template + generate_html( + large_store, + output_path, + mode="auto", + max_full_nodes=100000, + max_full_edges=100000, + ) content = output_path.read_text() # Full template has btn-community and flow-select assert "btn-community" in content @@ -505,6 +511,23 @@ def test_auto_mode_switches_at_threshold(large_store, tmp_path): assert "community_details" in content2 +def test_auto_mode_switches_at_edge_threshold(large_store, tmp_path): + """Auto mode should aggregate dense graphs even when node count is modest.""" + from code_review_graph.visualization import generate_html + + output_path = tmp_path / "auto_dense.html" + generate_html( + large_store, + output_path, + mode="auto", + max_full_nodes=100000, + max_full_edges=1, + ) + content = output_path.read_text() + assert "btn-back" in content + assert "community_details" in content + + def test_community_mode_html_generation(large_store, tmp_path): """Community mode generates valid HTML with aggregated data.""" from code_review_graph.visualization import generate_html From eaae356d873dfecdb25a110f541c87a697a9dcad Mon Sep 17 00:00:00 2001 From: Kenny Date: Thu, 28 May 2026 19:33:07 +0100 Subject: [PATCH 3/7] Add Antelope-focused graph visualization --- code_review_graph/cli.py | 4 +- code_review_graph/visualization.py | 233 ++++++++++++++++++++++++++++- tests/test_visualization.py | 81 ++++++++++ 3 files changed, 311 insertions(+), 7 deletions(-) diff --git a/code_review_graph/cli.py b/code_review_graph/cli.py index 33de6e38..43b69454 100644 --- a/code_review_graph/cli.py +++ b/code_review_graph/cli.py @@ -556,9 +556,9 @@ def main() -> None: vis_cmd.add_argument("--repo", default=None, help="Repository root (auto-detected)") vis_cmd.add_argument( "--mode", - choices=["auto", "full", "community", "file"], + choices=["auto", "full", "community", "file", "antelope"], default="auto", - help="Rendering mode: auto (default), full, community, or file", + help="Rendering mode: auto (default), full, community, file, or antelope", ) vis_cmd.add_argument( "--serve", diff --git a/code_review_graph/visualization.py b/code_review_graph/visualization.py index 85a5a111..a271d4be 100644 --- a/code_review_graph/visualization.py +++ b/code_review_graph/visualization.py @@ -8,6 +8,7 @@ - ``full`` — render every node (default, current behavior) - ``community`` — aggregate by community; double-click to drill down - ``file`` — aggregate by file; each file is a node +- ``antelope`` — contract-centric CDT view for actions, tables, auth, and inline calls - ``auto`` — choose community mode when node or edge count exceeds threshold """ @@ -120,6 +121,7 @@ def export_graph_data(store: GraphStore) -> dict: d["params"] = gnode.params d["return_type"] = gnode.return_type d["community_id"] = community_map.get(gnode.qualified_name) + d["extra"] = gnode.extra or {} nodes.append(d) name_index = _build_name_index(nodes, seen_qn) @@ -128,6 +130,15 @@ def export_graph_data(store: GraphStore) -> dict: # Resolve short/unqualified edge targets to full qualified names, # then drop edges that still can't be resolved (external/stdlib calls). + # Antelope semantic edges intentionally target non-code concepts such as + # auth actors, table names, notification topics, and inline action names; + # keep those raw targets so the Antelope visualization can materialize them + # as virtual nodes. + antelope_external_edges = { + "REQUIRES_AUTH", "NOTIFIES", "HANDLES_NOTIFICATION", + "READS_TABLE", "WRITES_TABLE", "ERASES_TABLE", "MAPS_TO_TABLE", + "SENDS_INLINE_ACTION", "CALLS_ACTION", + } edges = [] for e in all_edges: src = _resolve_target(e["source"], e["source"], seen_qn, name_index) @@ -136,6 +147,9 @@ def export_graph_data(store: GraphStore) -> dict: e["source"] = src e["target"] = tgt edges.append(e) + elif src and e["kind"] in antelope_external_edges: + e["source"] = src + edges.append(e) stats = store.get_stats() @@ -357,6 +371,187 @@ def _aggregate_file(data: dict) -> dict: } +def _contract_dir(file_path: str) -> str: + parts = file_path.replace("\\", "/").split("/") + if "contracts" in parts: + idx = parts.index("contracts") + if idx + 1 < len(parts): + return parts[idx + 1] + return "" + + +def _antelope_display_kind(node: dict) -> str: + antelope_kind = (node.get("extra") or {}).get("antelope_kind") + return { + "contract": "Contract", + "action": "Action", + "table": "Table", + "multi_index": "MultiIndex", + "singleton": "Singleton", + "notification": "Notification", + "abi_contract": "Contract", + "abi_action": "Action", + "abi_table": "Table", + }.get(antelope_kind, node.get("kind", "Node")) + + +def _antelope_node_label(node: dict) -> str: + kind = (node.get("extra") or {}).get("antelope_kind") + if kind == "action" and node.get("parent_name"): + return f"{node['parent_name']}::{node['name']}" + if kind == "notification" and node.get("parent_name"): + return f"{node['parent_name']}::{node['name']}" + table_name = (node.get("extra") or {}).get("antelope_table_name") + if table_name and table_name != node.get("name"): + return f"{node['name']} ({table_name})" + return node.get("name") or node.get("qualified_name", "") + + +def _antelope_stats(nodes: list[dict], edges: list[dict]) -> dict: + return { + "total_nodes": len(nodes), + "total_edges": len(edges), + "nodes_by_kind": dict(Counter(n.get("kind", "Node") for n in nodes)), + "edges_by_kind": dict(Counter(e.get("kind", "EDGE") for e in edges)), + "languages": sorted({n.get("language", "") for n in nodes if n.get("language")}), + "files_count": len({n.get("file_path", "") for n in nodes if n.get("file_path")}), + "last_updated": None, + } + + +def _filter_antelope(data: dict) -> dict: + """Build a contract-centric Antelope/CDT visualization. + + The community overview is useful for architecture, but Antelope projects + often need the contract model directly: contracts, actions, tables, + notifications, authorization checks, table operations, and inline actions. + This mode keeps only those domain nodes and creates small virtual nodes for + auth actors, table names, notification topics, and external inline targets. + """ + antelope_kinds = { + "contract", "action", "table", "multi_index", "singleton", "notification", + "abi_contract", "abi_action", "abi_table", + } + semantic_edges = { + "REQUIRES_AUTH", "NOTIFIES", "HANDLES_NOTIFICATION", + "READS_TABLE", "WRITES_TABLE", "ERASES_TABLE", "MAPS_TO_TABLE", + "SENDS_INLINE_ACTION", + } + + out_nodes: list[dict] = [] + node_by_qn: dict[str, dict] = {} + qn_by_short: dict[str, list[str]] = defaultdict(list) + contract_by_name: dict[str, str] = {} + + for node in data["nodes"]: + extra = node.get("extra") or {} + kind = extra.get("antelope_kind") + if kind not in antelope_kinds: + continue + file_path = node.get("file_path", "").replace("\\", "/") + if file_path and "/contracts/" not in file_path and not file_path.startswith("contracts/"): + continue + + copied = dict(node) + copied["kind"] = _antelope_display_kind(node) + copied["name"] = _antelope_node_label(node) + copied["contract_dir"] = _contract_dir(node.get("file_path", "")) + copied["antelope_kind"] = kind + out_nodes.append(copied) + node_by_qn[copied["qualified_name"]] = copied + qn_by_short[node.get("name", "")].append(copied["qualified_name"]) + table_name = extra.get("antelope_table_name") + if table_name: + qn_by_short[table_name].append(copied["qualified_name"]) + if kind in {"contract", "abi_contract"}: + contract_by_name[node.get("name", "")] = copied["qualified_name"] + + out_edges: list[dict] = [] + edge_seen: set[tuple[str, str, str, int]] = set() + virtual_nodes: dict[str, dict] = {} + + def add_node(node: dict) -> None: + if node["qualified_name"] in node_by_qn: + return + node_by_qn[node["qualified_name"]] = node + out_nodes.append(node) + + def add_virtual(qn: str, name: str, kind: str) -> str: + if qn not in virtual_nodes: + virtual_nodes[qn] = { + "qualified_name": qn, + "name": name, + "kind": kind, + "file_path": "", + "line_start": None, + "line_end": None, + "language": "antelope", + "parent_name": None, + "is_test": False, + "extra": {}, + } + add_node(virtual_nodes[qn]) + return qn + + def add_edge(kind: str, source: str, target: str, line: int = 0, weight: int = 1) -> None: + key = (kind, source, target, line) + if key in edge_seen: + return + edge_seen.add(key) + out_edges.append({ + "kind": kind, + "source": source, + "target": target, + "line": line, + "weight": weight, + }) + + # Contract containment edges make the top-level model readable without + # requiring users to drill through file communities. + for node in list(out_nodes): + parent = node.get("parent_name") + contract_qn = contract_by_name.get(parent or "") + if contract_qn and contract_qn != node["qualified_name"]: + add_edge("CONTAINS", contract_qn, node["qualified_name"], node.get("line_start") or 0) + + for edge in data["edges"]: + if edge.get("kind") not in semantic_edges: + continue + source = edge["source"] + target = edge["target"] + if source not in node_by_qn: + continue + + resolved_target = target if target in node_by_qn else None + if not resolved_target and target in qn_by_short and len(qn_by_short[target]) == 1: + resolved_target = qn_by_short[target][0] + + if not resolved_target: + ek = edge["kind"] + safe_target = target.replace(" ", "_") + if ek == "REQUIRES_AUTH": + resolved_target = add_virtual(f"__auth__{safe_target}", target, "Auth") + elif ek in {"READS_TABLE", "WRITES_TABLE", "ERASES_TABLE", "MAPS_TO_TABLE"}: + resolved_target = add_virtual(f"__table__{safe_target}", target, "TableRef") + elif ek in {"HANDLES_NOTIFICATION", "NOTIFIES"}: + resolved_target = add_virtual(f"__notify__{safe_target}", target, "Notify") + elif ek == "SENDS_INLINE_ACTION": + resolved_target = add_virtual(f"__inline__{safe_target}", target, "InlineAction") + else: + resolved_target = add_virtual(f"__target__{safe_target}", target, "Target") + + add_edge(edge["kind"], source, resolved_target, edge.get("line") or 0) + + return { + "nodes": out_nodes, + "edges": out_edges, + "stats": _antelope_stats(out_nodes, out_edges), + "flows": [], + "communities": [], + "mode": "antelope", + } + + def generate_html( store: GraphStore, output_path: str | Path, @@ -370,8 +565,9 @@ def generate_html( store: The GraphStore to read graph data from. output_path: Path for the output HTML file. mode: Rendering mode — ``"auto"``, ``"full"``, ``"community"``, - or ``"file"``. ``"auto"`` switches to ``"community"`` when - the node or edge count exceeds the full-render thresholds. + ``"file"``, or ``"antelope"``. ``"auto"`` switches to + ``"community"`` when the node or edge count exceeds the + full-render thresholds. max_full_nodes: Node threshold for auto-switching to community mode. max_full_edges: Edge threshold for auto-switching to community mode. @@ -408,6 +604,10 @@ def generate_html( agg = _aggregate_file(data) data_json = json.dumps(agg, default=str).replace(" Date: Fri, 29 May 2026 00:27:19 +0100 Subject: [PATCH 4/7] Link Antelope table aliases to row structs --- code_review_graph/parser.py | 83 ++++++++++++++++++++++++++++++++----- tests/test_multilang.py | 7 ++++ 2 files changed, 79 insertions(+), 11 deletions(-) diff --git a/code_review_graph/parser.py b/code_review_graph/parser.py index e4faceec..b4f55215 100644 --- a/code_review_graph/parser.py +++ b/code_review_graph/parser.py @@ -10,6 +10,7 @@ import json import logging import re +from collections import defaultdict from dataclasses import dataclass, field from pathlib import Path from typing import NamedTuple, Optional @@ -4729,16 +4730,55 @@ def _extract_antelope_table_alias( file_path=file_path, line=child.start_point[0] + 1, )) + row_target = self._resolve_antelope_row_type_target( + row_type, file_path, enclosing_class, nodes, + ) edges.append(EdgeInfo( kind="MAPS_TO_TABLE", source=qualified, - target=table_name, + target=row_target or table_name, file_path=file_path, line=child.start_point[0] + 1, - extra={"row_type": row_type, "table_kind": table_kind}, + extra={ + "row_type": row_type, + "table_kind": table_kind, + "table_name": table_name, + }, )) return True + def _resolve_antelope_row_type_target( + self, + row_type: Optional[str], + file_path: str, + enclosing_class: Optional[str], + nodes: list[NodeInfo], + ) -> Optional[str]: + if not row_type: + return None + row_name = row_type.split("::")[-1] + candidates = [ + n for n in nodes + if n.kind == "Class" + and n.name == row_name + and n.extra.get("antelope_kind") == "table" + ] + if not candidates: + return None + for node in candidates: + if node.parent_name == enclosing_class and node.file_path == file_path: + return self._qualify(node.name, node.file_path, node.parent_name) + for node in candidates: + if node.parent_name == enclosing_class: + return self._qualify(node.name, node.file_path, node.parent_name) + for node in candidates: + if node.file_path == file_path: + return self._qualify(node.name, node.file_path, node.parent_name) + if len(candidates) == 1: + node = candidates[0] + return self._qualify(node.name, node.file_path, node.parent_name) + return None + def _enrich_antelope_cpp( self, source: bytes, @@ -4751,12 +4791,13 @@ def _enrich_antelope_cpp( text = source.decode("utf-8", errors="replace") line_offsets = self._line_offsets(text) - table_alias_nodes = { - n.name: n - for n in nodes - if n.kind == "Type" - and n.extra.get("antelope_kind") in ("multi_index", "singleton") - } + table_alias_nodes: dict[str, list[NodeInfo]] = defaultdict(list) + for n in nodes: + if ( + n.kind == "Type" + and n.extra.get("antelope_kind") in ("multi_index", "singleton") + ): + table_alias_nodes[n.name].append(n) for node in nodes: kind = node.extra.get("antelope_kind") @@ -4777,7 +4818,7 @@ def _enrich_antelope_cpp( ) self._emit_antelope_table_access_edges( body, source_qn, file_path, node.line_start, table_alias_nodes, - edges, + node.parent_name, edges, ) pattern = node.extra.get("antelope_notify_pattern") if pattern: @@ -4877,7 +4918,8 @@ def _emit_antelope_table_access_edges( source_qn: str, file_path: str, line_start: int, - table_alias_nodes: dict[str, NodeInfo], + table_alias_nodes: dict[str, list[NodeInfo]], + source_parent: Optional[str], edges: list[EdgeInfo], ) -> None: table_vars: dict[str, str] = {} @@ -4928,7 +4970,9 @@ def _emit_antelope_table_access_edges( if dedupe in seen: continue seen.add(dedupe) - alias_node = table_alias_nodes.get(table_target) + alias_node = self._resolve_antelope_table_alias_node( + table_target, table_alias_nodes, source_parent, + ) if alias_node and alias_node.parent_name: target = self._qualify( alias_node.name, alias_node.file_path, alias_node.parent_name, @@ -4946,6 +4990,23 @@ def _emit_antelope_table_access_edges( extra={"operation": op, "table_variable": var_name}, )) + @staticmethod + def _resolve_antelope_table_alias_node( + table_target: str, + table_alias_nodes: dict[str, list[NodeInfo]], + source_parent: Optional[str], + ) -> Optional[NodeInfo]: + candidates = table_alias_nodes.get(table_target) or [] + if not candidates: + return None + if source_parent: + same_parent = [n for n in candidates if n.parent_name == source_parent] + if len(same_parent) == 1: + return same_parent[0] + if len(candidates) == 1: + return candidates[0] + return None + @staticmethod def _read_balanced_call_arg(text: str, open_paren: int) -> tuple[str, int]: if open_paren < 0 or open_paren >= len(text) or text[open_paren] != "(": diff --git a/tests/test_multilang.py b/tests/test_multilang.py index 791f078a..8ed94d4c 100644 --- a/tests/test_multilang.py +++ b/tests/test_multilang.py @@ -373,6 +373,13 @@ def test_extracts_contract_actions_tables_and_aliases(self): assert alias.extra["antelope_table_name"] == "prices" assert alias.extra["antelope_row_type"] == "price_row" assert alias.extra["antelope_secondary_indices"] == ["byvalue"] + assert any( + e.kind == "MAPS_TO_TABLE" + and e.source.endswith("::oracle.prices") + and e.target.endswith("::oracle.price_row") + and e.extra["table_name"] == "prices" + for e in self.edges + ) def test_extracts_antelope_semantic_edges(self): auth_edges = [e for e in self.edges if e.kind == "REQUIRES_AUTH"] From 9fcd19a6dc6ed4a7742b2a20d50df2ebe950b3a7 Mon Sep 17 00:00:00 2001 From: Kenny Date: Fri, 29 May 2026 00:30:49 +0100 Subject: [PATCH 5/7] Resolve Antelope table access to header aliases --- code_review_graph/parser.py | 31 ++++++++++++++++++++++++++++++- tests/fixtures/antelope_split.cpp | 15 +++++++++++++++ tests/fixtures/antelope_split.hpp | 17 +++++++++++++++++ tests/test_multilang.py | 28 ++++++++++++++++++++++++++++ 4 files changed, 90 insertions(+), 1 deletion(-) create mode 100644 tests/fixtures/antelope_split.cpp create mode 100644 tests/fixtures/antelope_split.hpp diff --git a/code_review_graph/parser.py b/code_review_graph/parser.py index b4f55215..df948527 100644 --- a/code_review_graph/parser.py +++ b/code_review_graph/parser.py @@ -4980,7 +4980,9 @@ def _emit_antelope_table_access_edges( elif alias_node: target = self._qualify(alias_node.name, alias_node.file_path, None) else: - target = table_target + target = self._infer_antelope_header_table_alias_target( + table_target, file_path, source_parent, + ) or table_target edges.append(EdgeInfo( kind=edge_kind, source=source_qn, @@ -5007,6 +5009,33 @@ def _resolve_antelope_table_alias_node( return candidates[0] return None + @staticmethod + def _infer_antelope_header_table_alias_target( + table_target: str, + file_path: str, + source_parent: Optional[str], + ) -> Optional[str]: + """Infer a header-declared table alias from a sibling C++ implementation. + + Antelope contracts commonly declare ``using pricerequests = + multi_index<...>`` in ``contract.hpp`` and instantiate that alias in + ``contract.cpp`` action bodies. Each file is parsed independently, so + the ``.cpp`` pass cannot directly see the header alias node. When the + action parent and sibling header are clear, emit the fully qualified + alias target anyway; the graph resolver can then connect the access edge + to the real Type node parsed from the header. + """ + if not source_parent or not table_target: + return None + path = Path(file_path) + if path.suffix.lower() not in {".cc", ".cpp", ".cxx"}: + return None + for suffix in (".hpp", ".h", ".hh", ".hxx"): + header = path.with_suffix(suffix) + if header.exists(): + return f"{header}::{source_parent}.{table_target}" + return None + @staticmethod def _read_balanced_call_arg(text: str, open_paren: int) -> tuple[str, int]: if open_paren < 0 or open_paren >= len(text) or text[open_paren] != "(": diff --git a/tests/fixtures/antelope_split.cpp b/tests/fixtures/antelope_split.cpp new file mode 100644 index 00000000..06b6559d --- /dev/null +++ b/tests/fixtures/antelope_split.cpp @@ -0,0 +1,15 @@ +#include "antelope_split.hpp" + +ACTION split::touch(uint64_t id) { + pricereqs requests(get_self(), get_self().value); + auto itr = requests.find(id); + if (itr == requests.end()) { + requests.emplace(get_self(), [&](auto& row) { + row.id = id; + }); + } else { + requests.modify(itr, same_payer, [&](auto& row) { + row.id = id; + }); + } +} diff --git a/tests/fixtures/antelope_split.hpp b/tests/fixtures/antelope_split.hpp new file mode 100644 index 00000000..edda6bb7 --- /dev/null +++ b/tests/fixtures/antelope_split.hpp @@ -0,0 +1,17 @@ +#include + +using namespace eosio; + +CONTRACT split : public contract { +public: + using contract::contract; + + ACTION touch(uint64_t id); + + TABLE request_row { + uint64_t id; + uint64_t primary_key() const { return id; } + }; + + using pricereqs = eosio::multi_index<"pricereqs"_n, request_row>; +}; diff --git a/tests/test_multilang.py b/tests/test_multilang.py index 8ed94d4c..51e26c7e 100644 --- a/tests/test_multilang.py +++ b/tests/test_multilang.py @@ -429,6 +429,34 @@ def test_extracts_dapp_action_and_table_edges(self): assert any(e.target == "eosio.token::transfer" for e in action_edges) assert any(e.target == "prices" and e.extra["from_dapp"] for e in table_edges) + def test_cpp_access_edges_resolve_sibling_header_alias(self): + nodes, edges = self.parser.parse_file(FIXTURES / "antelope_split.cpp") + action = next( + n for n in nodes + if n.name == "touch" and n.extra.get("antelope_kind") == "action" + ) + assert action.parent_name == "split" + + header_target = str(FIXTURES / "antelope_split.hpp") + "::split.pricereqs" + assert any( + e.kind == "READS_TABLE" + and e.target == header_target + and e.extra["operation"] == "find" + for e in edges + ) + assert any( + e.kind == "WRITES_TABLE" + and e.target == header_target + and e.extra["operation"] == "emplace" + for e in edges + ) + assert any( + e.kind == "WRITES_TABLE" + and e.target == header_target + and e.extra["operation"] == "modify" + for e in edges + ) + class TestHhParsing: def setup_method(self): From 8a6476c1e56ea5ee1d308d6da40d55a4325d4b8f Mon Sep 17 00:00:00 2001 From: Kenny Date: Fri, 29 May 2026 02:50:12 +0100 Subject: [PATCH 6/7] Expose Antelope table access queries --- code_review_graph/tools/query.py | 154 ++++++++++++++++++++++++++++--- tests/test_tools.py | 128 ++++++++++++++++++++++++- 2 files changed, 267 insertions(+), 15 deletions(-) diff --git a/code_review_graph/tools/query.py b/code_review_graph/tools/query.py index 3b442f8a..b6d2ffc5 100644 --- a/code_review_graph/tools/query.py +++ b/code_review_graph/tools/query.py @@ -29,6 +29,17 @@ "tests_for": "Find all tests for a given function or class", "inheritors_of": "Find all classes that inherit from a given class", "file_summary": "Get a summary of all nodes in a file", + "writers_of": "Find functions/actions that write to an Antelope table", + "readers_of": "Find functions/actions that read from an Antelope table", + "erasers_of": "Find functions/actions that erase from an Antelope table", + "accessors_of": "Find functions/actions that read, write, or erase an Antelope table", +} + +_TABLE_ACCESS_PATTERNS = { + "writers_of": {"WRITES_TABLE"}, + "readers_of": {"READS_TABLE"}, + "erasers_of": {"ERASES_TABLE"}, + "accessors_of": {"READS_TABLE", "WRITES_TABLE", "ERASES_TABLE"}, } @@ -152,7 +163,8 @@ def query_graph( Args: pattern: Query pattern. One of: callers_of, callees_of, imports_of, - importers_of, children_of, tests_for, inheritors_of, file_summary. + importers_of, children_of, tests_for, inheritors_of, + file_summary, writers_of, readers_of, erasers_of, accessors_of. target: The node name, qualified name, or file path to query about. repo_root: Repository root path. Auto-detected if omitted. detail_level: "standard" (full output) or "minimal" (summary only). @@ -192,6 +204,62 @@ def query_graph( "results": [], "edges": [], } + if pattern in _TABLE_ACCESS_PATTERNS: + table_qns = _resolve_antelope_table_targets(store, target) + if not table_qns: + return { + "status": "not_found", + "pattern": pattern, + "target": target, + "description": _QUERY_PATTERNS[pattern], + "summary": f"No Antelope table node or alias found matching '{target}'.", + "results": [], + "edges": [], + } + edge_kinds = _TABLE_ACCESS_PATTERNS[pattern] + seen_sources: set[str] = set() + for e in store.get_all_edges(): + if e.kind not in edge_kinds or e.target_qualified not in table_qns: + continue + if e.source_qualified not in seen_sources: + seen_sources.add(e.source_qualified) + source = store.get_node(e.source_qualified) + if source: + results.append(node_to_dict(source)) + else: + results.append({ + "kind": "Node", + "name": e.source_qualified.rsplit("::", 1)[-1], + "qualified_name": e.source_qualified, + }) + edges_out.append(edge_to_dict(e)) + + summary = ( + f"Found {len(results)} result(s) for {pattern}('{target}') " + f"across {len(table_qns)} table target(s)" + ) + response = { + "status": "ok", + "pattern": pattern, + "target": target, + "description": _QUERY_PATTERNS[pattern], + "summary": summary, + "resolved_targets": sorted(table_qns), + "results": results if detail_level != "minimal" else [ + { + k: r[k] + for k in ("name", "kind", "file_path") + if k in r + } + for r in results[:5] + ], + } + if detail_level != "minimal": + response["edges"] = edges_out + else: + response["result_count"] = len(results) + return response + # Resolve target - try as-is, then as absolute path, then search. # file_summary targets are paths, so skip broad node search. node = None @@ -368,6 +436,53 @@ def query_graph( store.close() +def _resolve_antelope_table_targets(store, target: str) -> set[str]: + """Resolve table aliases, table names, and row structs to queryable targets.""" + matches: set[str] = set() + raw = target.strip() + short = raw.rsplit("::", 1)[-1].split(".")[-1] + + for node in store.search_nodes(raw, limit=50): + extra = node.extra or {} + if ( + node.kind in {"Type", "Class"} + and extra.get("antelope_kind") in { + "multi_index", "singleton", "table", "abi_table", + } + ): + matches.add(node.qualified_name) + + for node in store.get_all_nodes(): + extra = node.extra or {} + antelope_kind = extra.get("antelope_kind") + if antelope_kind not in {"multi_index", "singleton", "table", "abi_table"}: + continue + table_name = extra.get("antelope_table_name") + row_type = extra.get("antelope_row_type") + if ( + raw in {node.qualified_name, node.name, table_name, row_type} + or short in {node.name, table_name, row_type} + or node.qualified_name.endswith(f"::{raw}") + or node.qualified_name.endswith(f".{raw}") + ): + matches.add(node.qualified_name) + + # If the user names a row struct, include aliases that MAPS_TO_TABLE to it. + expanded = set(matches) + for edge in store.get_all_edges(): + if edge.kind != "MAPS_TO_TABLE": + continue + if edge.target_qualified in matches: + expanded.add(edge.source_qualified) + extra = edge.extra or {} + if raw in {edge.target_qualified, extra.get("table_name"), extra.get("row_type")}: + expanded.add(edge.source_qualified) + if short in {extra.get("table_name"), extra.get("row_type")}: + expanded.add(edge.source_qualified) + + return expanded + + # --------------------------------------------------------------------------- # Tool 5: semantic_search_nodes # --------------------------------------------------------------------------- @@ -623,17 +738,17 @@ def traverse_graph_func( # BFS / DFS traversal visited: dict[str, int] = {} # qn -> depth - queue: list[tuple[str, int]] = [ - (start_qn, 0), + queue: list[tuple[str, int, dict[str, Any] | None]] = [ + (start_qn, 0, None), ] traversal: list[dict] = [] approx_tokens = 0 while queue: if mode == "bfs": - current_qn, cur_depth = queue.pop(0) + current_qn, cur_depth, via_edge = queue.pop(0) else: - current_qn, cur_depth = queue.pop() + current_qn, cur_depth, via_edge = queue.pop() if current_qn in visited: continue @@ -652,27 +767,38 @@ def traverse_graph_func( "file": node.file_path, "depth": cur_depth, } + if via_edge is not None: + entry["via_edge"] = via_edge approx_tokens += len(str(entry)) // 4 if approx_tokens > token_budget: break traversal.append(entry) - # Get neighbours - out_edges = store.get_edges_by_source( - current_qn - ) - in_edges = store.get_edges_by_target( - current_qn - ) + # Get neighbours. Include edge kind/direction in the queued item so + # traversal output can be filtered without a second graph lookup. + out_edges = store.get_edges_by_source(current_qn) + in_edges = store.get_edges_by_target(current_qn) for e in out_edges: tgt = e.target_qualified if tgt not in visited: - queue.append((tgt, cur_depth + 1)) + queue.append((tgt, cur_depth + 1, { + "kind": e.kind, + "direction": "out", + "source": e.source_qualified, + "target": e.target_qualified, + "line": e.line, + })) for e in in_edges: src = e.source_qualified if src not in visited: - queue.append((src, cur_depth + 1)) + queue.append((src, cur_depth + 1, { + "kind": e.kind, + "direction": "in", + "source": e.source_qualified, + "target": e.target_qualified, + "line": e.line, + })) return { "start_node": start_qn, diff --git a/tests/test_tools.py b/tests/test_tools.py index dc327c37..8a6784f9 100644 --- a/tests/test_tools.py +++ b/tests/test_tools.py @@ -9,6 +9,7 @@ from code_review_graph.graph import GraphStore, _sanitize_name, node_to_dict from code_review_graph.parser import EdgeInfo, NodeInfo from code_review_graph.tools import ( + _validate_repo_root, get_affected_flows_func, get_architecture_overview_func, get_community_func, @@ -19,8 +20,8 @@ list_communities_func, list_flows, query_graph, - _validate_repo_root, ) +from code_review_graph.tools.query import traverse_graph_func class TestTools: @@ -262,6 +263,131 @@ def test_callees_of_includes_resolved_and_bare_target_callees(self): } +class TestAntelopeTableQueries: + def setup_method(self): + self.root = Path(tempfile.mkdtemp()) + (self.root / ".code-review-graph").mkdir() + self.store = GraphStore(self.root / ".code-review-graph" / "graph.db") + self.contract_file = str(self.root / "contracts" / "oovp.oracle" / "oovp.oracle.hpp") + self.impl_file = str(self.root / "contracts" / "oovp.oracle" / "oovp.oracle.cpp") + self.contract_qn = f"{self.contract_file}::oracle" + self.alias_qn = f"{self.contract_file}::oracle.pricerequests" + self.row_qn = f"{self.contract_file}::oracle.price_request" + self.writer_qn = f"{self.impl_file}::oracle.requestprice" + self.reader_qn = f"{self.impl_file}::oracle.settle" + self._seed_data() + + def teardown_method(self): + self.store.close() + + def _seed_data(self): + self.store.upsert_node(NodeInfo( + kind="Class", name="oracle", file_path=self.contract_file, + line_start=1, line_end=100, language="cpp", + extra={"antelope_kind": "contract"}, + )) + self.store.upsert_node(NodeInfo( + kind="Type", name="pricerequests", file_path=self.contract_file, + line_start=80, line_end=80, language="cpp", parent_name="oracle", + extra={ + "antelope_kind": "multi_index", + "antelope_table_name": "requests", + "antelope_row_type": "price_request", + }, + )) + self.store.upsert_node(NodeInfo( + kind="Class", name="price_request", file_path=self.contract_file, + line_start=40, line_end=70, language="cpp", parent_name="oracle", + extra={"antelope_kind": "table"}, + )) + self.store.upsert_node(NodeInfo( + kind="Function", name="requestprice", file_path=self.impl_file, + line_start=10, line_end=20, language="cpp", parent_name="oracle", + extra={"antelope_kind": "action"}, + )) + self.store.upsert_node(NodeInfo( + kind="Function", name="settle", file_path=self.impl_file, + line_start=30, line_end=40, language="cpp", parent_name="oracle", + extra={"antelope_kind": "action"}, + )) + self.store.upsert_edge(EdgeInfo( + kind="MAPS_TO_TABLE", + source=self.alias_qn, + target=self.row_qn, + file_path=self.contract_file, + line=80, + extra={"table_name": "requests", "row_type": "price_request"}, + )) + self.store.upsert_edge(EdgeInfo( + kind="WRITES_TABLE", + source=self.writer_qn, + target=self.alias_qn, + file_path=self.impl_file, + line=15, + extra={"operation": "emplace"}, + )) + self.store.upsert_edge(EdgeInfo( + kind="READS_TABLE", + source=self.reader_qn, + target=self.alias_qn, + file_path=self.impl_file, + line=35, + extra={"operation": "find"}, + )) + self.store.commit() + + def test_query_writers_of_accepts_table_name_alias_and_row_struct(self): + for target in ("requests", "pricerequests", "price_request"): + result = query_graph( + pattern="writers_of", + target=target, + repo_root=str(self.root), + ) + + assert result["status"] == "ok" + assert [r["qualified_name"] for r in result["results"]] == [self.writer_qn] + assert result["edges"][0]["kind"] == "WRITES_TABLE" + assert self.alias_qn in result["resolved_targets"] + + def test_query_readers_and_accessors_are_focused(self): + readers = query_graph( + pattern="readers_of", + target="requests", + repo_root=str(self.root), + ) + assert [r["qualified_name"] for r in readers["results"]] == [self.reader_qn] + assert {e["kind"] for e in readers["edges"]} == {"READS_TABLE"} + + accessors = query_graph( + pattern="accessors_of", + target="requests", + repo_root=str(self.root), + ) + assert {r["qualified_name"] for r in accessors["results"]} == { + self.writer_qn, + self.reader_qn, + } + assert {e["kind"] for e in accessors["edges"]} == { + "READS_TABLE", + "WRITES_TABLE", + } + + def test_traverse_graph_includes_via_edge_kind(self): + result = traverse_graph_func( + query="pricerequests", + depth=1, + repo_root=str(self.root), + ) + + via_edges = [ + row.get("via_edge", {}) + for row in result["traversal"] + if row.get("via_edge") + ] + assert any(edge.get("kind") == "WRITES_TABLE" for edge in via_edges) + assert all("direction" in edge for edge in via_edges) + + def _seed_repo_relative_graph(root: Path) -> None: """Seed graph data with cwd-relative paths, as eval repos currently do.""" graph_dir = root / ".code-review-graph" From 561b1257fe68437dfbe1e9e8a2e4fd849d7b1e63 Mon Sep 17 00:00:00 2001 From: Kenny Date: Fri, 29 May 2026 20:03:00 +0100 Subject: [PATCH 7/7] Document Antelope query tools --- README.md | 5 +++-- code_review_graph/tools/query.py | 8 ++++---- docs/COMMANDS.md | 7 ++++++- docs/schema.md | 2 +- 4 files changed, 14 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index fafae2c7..44946f33 100644 --- a/README.md +++ b/README.md @@ -256,6 +256,7 @@ code-review-graph update # Incremental update (changed files only) code-review-graph status # Graph statistics code-review-graph watch # Auto-update on file changes code-review-graph visualize # Generate interactive HTML graph +code-review-graph visualize --mode antelope # Contract/actions/tables view code-review-graph visualize --format graphml # Export as GraphML code-review-graph visualize --format svg # Export as SVG code-review-graph visualize --format obsidian # Export as Obsidian vault @@ -370,8 +371,8 @@ Your AI assistant uses these automatically once the graph is built. | `get_minimal_context_tool` | Ultra-compact context (~100 tokens) — call this first | | `get_impact_radius_tool` | Blast radius of changed files | | `get_review_context_tool` | Token-optimised review context with structural summary | -| `query_graph_tool` | Callers, callees, tests, imports, inheritance queries | -| `traverse_graph_tool` | BFS/DFS traversal from any node with token budget | +| `query_graph_tool` | Callers, callees, tests, imports, inheritance, and Antelope table access queries | +| `traverse_graph_tool` | BFS/DFS traversal from any node with token budget and edge metadata | | `semantic_search_nodes_tool` | Search code entities by name or meaning | | `embed_graph_tool` | Compute vector embeddings for semantic search | | `list_graph_stats_tool` | Graph size and health | diff --git a/code_review_graph/tools/query.py b/code_review_graph/tools/query.py index b6d2ffc5..548e63b7 100644 --- a/code_review_graph/tools/query.py +++ b/code_review_graph/tools/query.py @@ -217,12 +217,12 @@ def query_graph( "edges": [], } edge_kinds = _TABLE_ACCESS_PATTERNS[pattern] - seen_sources: set[str] = set() + seen_table_sources: set[str] = set() for e in store.get_all_edges(): if e.kind not in edge_kinds or e.target_qualified not in table_qns: continue - if e.source_qualified not in seen_sources: - seen_sources.add(e.source_qualified) + if e.source_qualified not in seen_table_sources: + seen_table_sources.add(e.source_qualified) source = store.get_node(e.source_qualified) if source: results.append(node_to_dict(source)) @@ -238,7 +238,7 @@ def query_graph( f"Found {len(results)} result(s) for {pattern}('{target}') " f"across {len(table_qns)} table target(s)" ) - response = { + response: dict[str, Any] = { "status": "ok", "pattern": pattern, "target": target, diff --git a/docs/COMMANDS.md b/docs/COMMANDS.md index 8d8fa169..cfe5e4b9 100644 --- a/docs/COMMANDS.md +++ b/docs/COMMANDS.md @@ -63,11 +63,15 @@ Relevant responses may include compact estimated `context_savings` metadata. #### `query_graph_tool` ``` pattern: str # callers_of, callees_of, imports_of, importers_of, - # children_of, tests_for, inheritors_of, file_summary + # children_of, tests_for, inheritors_of, file_summary, + # writers_of, readers_of, erasers_of, accessors_of target: str # Node name, qualified name, or file path repo_root: str | None detail_level: str = "standard" # "standard" or "minimal" ``` +The `writers_of`, `readers_of`, `erasers_of`, and `accessors_of` patterns target +Antelope/CDT table nodes and aliases, including `TABLE` row structs, +`multi_index` / `singleton` aliases, and ABI table names. #### `get_review_context_tool` ``` @@ -316,6 +320,7 @@ code-review-graph postprocess # Re-run flows, communities, FTS code-review-graph status # Graph statistics code-review-graph watch # Auto-update on file changes code-review-graph visualize # Generate interactive HTML graph +code-review-graph visualize --mode antelope # Contract/actions/tables view code-review-graph visualize --format graphml # Export GraphML code-review-graph visualize --serve # Serve graph.html on localhost:8765 diff --git a/docs/schema.md b/docs/schema.md index 0534ffaf..b3493d68 100644 --- a/docs/schema.md +++ b/docs/schema.md @@ -127,7 +127,7 @@ A dependency-injection relationship, currently used by Java/Spring enrichment fo Data or event flow relationships emitted by specialised parsers when a source consumes or produces a named resource. ### Antelope / CDT edges -Antelope smart contract enrichment emits `REQUIRES_AUTH`, `NOTIFIES`, `HANDLES_NOTIFICATION`, `READS_TABLE`, `WRITES_TABLE`, `ERASES_TABLE`, `MAPS_TO_TABLE`, `SENDS_INLINE_ACTION`, and dapp-side `CALLS_ACTION` edges. These connect CDT `CONTRACT` / `ACTION` / `TABLE` macros, `[[eosio::*]]` attributes, `multi_index` / `singleton` aliases, inline actions, ABI declarations, and eosjs/Hydra-style frontend or test calls. +Antelope smart contract enrichment emits `REQUIRES_AUTH`, `NOTIFIES`, `HANDLES_NOTIFICATION`, `READS_TABLE`, `WRITES_TABLE`, `ERASES_TABLE`, `MAPS_TO_TABLE`, `SENDS_INLINE_ACTION`, and dapp-side `CALLS_ACTION` edges. These connect CDT `CONTRACT` / `ACTION` / `TABLE` macros, `[[eosio::*]]` attributes, `multi_index` / `singleton` aliases, inline actions, ABI declarations, and eosjs/Hydra-style frontend or test calls. `MAPS_TO_TABLE` links table aliases and ABI table entries back to their `TABLE` row structs and carries table-name metadata when available. ### TEMPORAL_STUB Temporal dependency placeholder emitted by specialised parsers when a time/order relationship is detected but cannot be resolved to a stronger edge type.