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
-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("", "<\\/")
html = _AGGREGATED_HTML_TEMPLATE.replace("__GRAPH_DATA__", data_json)
+ elif effective_mode == "antelope":
+ agg = _filter_antelope(data)
+ data_json = json.dumps(agg, default=str).replace("", "<\\/")
+ html = _AGGREGATED_HTML_TEMPLATE.replace("__GRAPH_DATA__", data_json)
else:
# full mode — original behavior
data_json = json.dumps(data, default=str).replace("", "<\\/")
@@ -1624,12 +1824,20 @@ def generate_html(
var KIND_COLOR = {
Community: "#1f6feb", File: "#58a6ff", Class: "#f0883e",
- Function: "#3fb950", Test: "#d2a8ff", Type: "#8b949e"
+ Function: "#3fb950", Test: "#d2a8ff", Type: "#8b949e",
+ Contract: "#f0883e", Action: "#3fb950", Table: "#58a6ff",
+ MultiIndex: "#d2a8ff", Singleton: "#a5d6ff", Notification: "#ff7b72",
+ Auth: "#f2cc60", TableRef: "#79c0ff", Notify: "#ff7b72",
+ InlineAction: "#ffa657"
};
var EDGE_COLOR = {
CROSS_COMMUNITY: "#58a6ff", DEPENDS_ON: "#f0883e",
CALLS: "#3fb950", IMPORTS_FROM: "#f0883e",
- INHERITS: "#d2a8ff", CONTAINS: "rgba(139,148,158,0.15)"
+ INHERITS: "#d2a8ff", CONTAINS: "rgba(139,148,158,0.15)",
+ REQUIRES_AUTH: "#f2cc60", READS_TABLE: "#79c0ff",
+ WRITES_TABLE: "#3fb950", ERASES_TABLE: "#ff7b72",
+ MAPS_TO_TABLE: "#d2a8ff", SENDS_INLINE_ACTION: "#ffa657",
+ HANDLES_NOTIFICATION: "#ff7b72", NOTIFIES: "#ff7b72"
};
var EDGE_CFG = {
CROSS_COMMUNITY: { dash: null, width: 2, opacity: 0.6, marker: "" },
@@ -1638,6 +1846,14 @@ def generate_html(
CALLS: { dash: null, width: 1.5, opacity: 0.7, marker: "url(#arrow-calls)" },
IMPORTS_FROM: { dash: "6,3", width: 1.5, opacity: 0.65, marker: "url(#arrow-imports)" },
INHERITS: { dash: "3,4", width: 2, opacity: 0.7, marker: "url(#arrow-inherits)" },
+ REQUIRES_AUTH: { dash: "4,3", width: 1.5, opacity: 0.75, marker: "url(#arrow-auth)" },
+ READS_TABLE: { dash: "6,3", width: 1.3, opacity: 0.55, marker: "url(#arrow-table)" },
+ WRITES_TABLE: { dash: null, width: 1.7, opacity: 0.75, marker: "url(#arrow-calls)" },
+ ERASES_TABLE: { dash: "2,3", width: 1.7, opacity: 0.75, marker: "url(#arrow-danger)" },
+ MAPS_TO_TABLE: { dash: "3,3", width: 1.5, opacity: 0.65, marker: "" },
+ SENDS_INLINE_ACTION: { dash: null, width: 1.7, opacity: 0.75, marker: "url(#arrow-inline)" },
+ HANDLES_NOTIFICATION: { dash: "5,3", width: 1.6, opacity: 0.75, marker: "url(#arrow-danger)" },
+ NOTIFIES: { dash: "5,3", width: 1.6, opacity: 0.75, marker: "url(#arrow-danger)" },
};
function eStyle(d) { return EDGE_CFG[d.kind] || { dash: null, width: 1, opacity: 0.3, marker: "" }; }
function eColor(d) { return EDGE_COLOR[d.kind] || "#484f58"; }
@@ -1702,6 +1918,8 @@ def generate_html(
filterInfo.textContent = "Showing communities. Double-click to drill down.";
} else if (dataMode === "file") {
filterInfo.textContent = "Showing file-level aggregation.";
+} else if (dataMode === "antelope") {
+ filterInfo.textContent = "Showing Antelope contracts, actions, tables, auth, notifications, and inline actions.";
} else {
filterInfo.textContent = "Showing all nodes.";
}
@@ -1774,7 +1992,7 @@ def generate_html(
var glow = defs.append("filter").attr("id","glow").attr("x","-50%").attr("y","-50%").attr("width","200%").attr("height","200%");
glow.append("feGaussianBlur").attr("stdDeviation","3").attr("result","blur");
glow.append("feComposite").attr("in","SourceGraphic").attr("in2","blur").attr("operator","over");
-[{id:"arrow-calls",color:"#3fb950"},{id:"arrow-imports",color:"#f0883e"},{id:"arrow-inherits",color:"#d2a8ff"}].forEach(function(mk) {
+[{id:"arrow-calls",color:"#3fb950"},{id:"arrow-imports",color:"#f0883e"},{id:"arrow-inherits",color:"#d2a8ff"},{id:"arrow-auth",color:"#f2cc60"},{id:"arrow-table",color:"#79c0ff"},{id:"arrow-danger",color:"#ff7b72"},{id:"arrow-inline",color:"#ffa657"}].forEach(function(mk) {
defs.append("marker").attr("id", mk.id)
.attr("viewBox","0 -5 10 10").attr("refX",28).attr("refY",0)
.attr("markerWidth",8).attr("markerHeight",8).attr("orient","auto")
@@ -1793,6 +2011,11 @@ def generate_html(
function nodeRadius(d) {
if (d.kind === "Community") return Math.max(12, Math.min(40, 8 + Math.sqrt(d.member_count || 1) * 3));
+ if (d.kind === "Contract") return 20;
+ if (d.kind === "Action") return 9;
+ if (d.kind === "Notification") return 10;
+ if (d.kind === "Table" || d.kind === "MultiIndex" || d.kind === "Singleton") return 11;
+ if (d.kind === "Auth" || d.kind === "TableRef" || d.kind === "Notify" || d.kind === "InlineAction") return 7;
if (d.kind === "File") {
if (d.symbol_count != null) return Math.max(8, Math.min(30, 6 + Math.sqrt(d.symbol_count || 1) * 2));
return 18;
diff --git a/tests/test_visualization.py b/tests/test_visualization.py
index 4342fac9..cbf14fe0 100644
--- a/tests/test_visualization.py
+++ b/tests/test_visualization.py
@@ -554,6 +554,87 @@ def test_file_mode_html_generation(large_store, tmp_path):
assert "DEPENDS_ON" in content
+def test_antelope_mode_filters_contract_model(tmp_path):
+ """Antelope mode should render contract concepts without test/frontend noise."""
+ from code_review_graph.visualization import generate_html
+
+ db_path = tmp_path / "antelope.db"
+ store = GraphStore(db_path)
+ contract_file = "contracts/oovp.oracle/oovp.oracle.hpp"
+ test_file = "tests/unit/oovp.oracle.test.js"
+
+ store.upsert_node(NodeInfo(
+ kind="File", name=contract_file, file_path=contract_file,
+ line_start=1, line_end=100, language="cpp", parent_name=None,
+ params=None, return_type=None, modifiers=None, is_test=False, extra={},
+ ))
+ store.upsert_node(NodeInfo(
+ kind="Class", name="oracle", file_path=contract_file,
+ line_start=10, line_end=90, language="cpp", parent_name=None,
+ params=None, return_type=None, modifiers=None, is_test=False,
+ extra={"antelope_kind": "contract"},
+ ))
+ store.upsert_node(NodeInfo(
+ kind="Function", name="requestprice", file_path=contract_file,
+ line_start=20, line_end=30, language="cpp", parent_name="oracle",
+ params=None, return_type=None, modifiers=None, is_test=False,
+ extra={"antelope_kind": "action"},
+ ))
+ store.upsert_node(NodeInfo(
+ kind="Type", name="pricerequests", file_path=contract_file,
+ line_start=80, line_end=80, language="cpp", parent_name="oracle",
+ params=None, return_type=None, modifiers=None, is_test=False,
+ extra={
+ "antelope_kind": "multi_index",
+ "antelope_table_name": "requests",
+ "antelope_row_type": "price_request",
+ },
+ ))
+ store.upsert_node(NodeInfo(
+ kind="Test", name="test_request", file_path=test_file,
+ line_start=1, line_end=5, language="javascript", parent_name=None,
+ params=None, return_type=None, modifiers=None, is_test=True, extra={},
+ ))
+ store.upsert_edge(EdgeInfo(
+ kind="REQUIRES_AUTH",
+ source=f"{contract_file}::oracle.requestprice",
+ target="requester",
+ file_path=contract_file,
+ line=22,
+ extra={},
+ ))
+ store.upsert_edge(EdgeInfo(
+ kind="WRITES_TABLE",
+ source=f"{contract_file}::oracle.requestprice",
+ target="pricerequests",
+ file_path=contract_file,
+ line=25,
+ extra={},
+ ))
+ store.upsert_edge(EdgeInfo(
+ kind="CALLS_ACTION",
+ source=f"{test_file}::test_request",
+ target="oracle::requestprice",
+ file_path=test_file,
+ line=3,
+ extra={},
+ ))
+ store.commit()
+
+ output_path = tmp_path / "antelope.html"
+ generate_html(store, output_path, mode="antelope")
+ content = output_path.read_text()
+ assert '"mode": "antelope"' in content
+ assert '"kind": "Contract"' in content
+ assert '"kind": "Action"' in content
+ assert '"kind": "MultiIndex"' in content
+ assert "REQUIRES_AUTH" in content
+ assert "WRITES_TABLE" in content
+ assert "requester" in content
+ assert "test_request" not in content
+ assert "CALLS_ACTION" not in content
+
+
def test_full_mode_backward_compatible(store_with_data, tmp_path):
"""Full mode should produce identical output to the original 2-arg call."""
from code_review_graph.visualization import generate_html
From c102ac804026c046faf2ada4d42030ecfa2f5404 Mon Sep 17 00:00:00 2001
From: Kenny
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.