feat(solidity): resolve calls, super calls and modifiers inherited from base contracts

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B8gwEpjajiWHVW3c9smuKf
This commit is contained in:
rohit-jsfreakyandClaude Opus 5.5 committed 2026-10-15 22:23:15 +05:30
1 parent 10130e79ed
commit cdcec4510a
4 files changed
+455 -5

No files matched your search

+1 -1
View File
@@ -37,7 +37,7 @@ except Exception:
_EXTRACTOR_VERSION = "unknown"
# Bump when AST cache-key semantics change independently of the package version.
_AST_CACHE_SCHEMA = 16 # Rust `use` edges carry the imported name as written (`_rust_use_name`, #4439); prior (15): Go generic receiver types normalized (`Box[T]` -> `Box`) so methods attach to the type node; prior (14): C# receiver resolution for nameless/`new X()`/`foreach` receivers (defer + receiver-chain markers) and `_`/case-distinct node ids (underscore-salted); prior (13): Kotlin/Scala/C++/Swift generic & template type params no longer mint sourceless stubs; Objective-C NS_ENUM/NS_OPTIONS extraction and `_callable`/`_callable_class` markers; Go and PHP receiver-typed member calls park cross-repo (`go_type_table`/`php_type_table`, receiver fields); prior (12): Java inherited-supertype (`receiver_unbound`) and chained/new/cast receiver typing (`java_receiver_chain`, `_java_ret`); C/C++ quoted cross-directory include provenance (`include_kind`); JS external imports anchored to the package, not the path leaf; prior (11): Python property-accessor decorators / `Self`, Swift `Self`, Go generic type params, Python forward type-hint file node, PowerShell dot-source, C# verbatim-interpolated string recovery; (10): C# partial types carry `type_path`; (9): Python property-read raw calls and `_py_property` nodes; (8): Rust `use`-group per-binding edges; (7): Python opaque-base/super(args) markers + nested-class enclosing ids.
_AST_CACHE_SCHEMA = 17 # Solidity callables carry `metadata.arity` and inherited-call/modifier raw calls; prior (16): Rust `use` edges carry the imported name as written (`_rust_use_name`, #4439); (15): Go generic receiver types normalized (`Box[T]` -> `Box`) so methods attach to the type node; prior (14): C# receiver resolution for nameless/`new X()`/`foreach` receivers (defer + receiver-chain markers) and `_`/case-distinct node ids (underscore-salted); prior (13): Kotlin/Scala/C++/Swift generic & template type params no longer mint sourceless stubs; Objective-C NS_ENUM/NS_OPTIONS extraction and `_callable`/`_callable_class` markers; Go and PHP receiver-typed member calls park cross-repo (`go_type_table`/`php_type_table`, receiver fields); prior (12): Java inherited-supertype (`receiver_unbound`) and chained/new/cast receiver typing (`java_receiver_chain`, `_java_ret`); C/C++ quoted cross-directory include provenance (`include_kind`); JS external imports anchored to the package, not the path leaf; prior (11): Python property-accessor decorators / `Self`, Swift `Self`, Go generic type params, Python forward type-hint file node, PowerShell dot-source, C# verbatim-interpolated string recovery; (10): C# partial types carry `type_path`; (9): Python property-read raw calls and `_py_property` nodes; (8): Rust `use`-group per-binding edges; (7): Python opaque-base/super(args) markers + nested-class enclosing ids.
# Per-process memo: distributions() scans site-packages metadata, which is
+12
View File
@@ -74,6 +74,7 @@ from graphify.extractors.rust import extract_rust # noqa: F401
from graphify.extractors.sln import extract_sln # noqa: F401
from graphify.extractors.solidity import ( # noqa: F401
extract_solidity,
resolve_solidity_inherited_calls,
resolve_solidity_type_references,
)
from graphify.extractors.sql import extract_sql # noqa: F401
@@ -7332,6 +7333,12 @@ register_language_resolver(
"solidity_type_references", frozenset({".sol"}), resolve_solidity_type_references
)
)
# Runs after the type pass: it walks the `inherits` edges that pass binds.
register_language_resolver(
LanguageResolver(
"solidity_inherited_calls", frozenset({".sol"}), resolve_solidity_inherited_calls
)
)
register_language_resolver(
LanguageResolver(
"erlang_remote_calls",
@@ -10325,6 +10332,11 @@ def extract(
# edge from a heading to whatever shares the name.
if rc.get("language") == "markdown":
continue
# A Solidity name is visible only through the caller contract's own
# bases; resolve_solidity_inherited_calls binds those. A corpus-wide
# name match would link `_transfer()` to any contract's `_transfer`.
if rc.get("solidity_call"):
continue
# A Go predeclared function is never a cross-file call: the extractor
# already drops bare `append(s, x)` (extractors/go.py), so this is the
# backstop for Go raw_calls minted on any other path. Language-gated
+230 -4
View File
@@ -23,6 +23,202 @@ def _unquote(value: str) -> str:
return value
def _callee_text(callee: Node, source: bytes) -> str:
"""The callee as written, e.g. ``super.supportsInterface`` or ``_check``.
tree-sitter-solidity parses ``a || super.f(x)`` as a call of the member
``(a || super).f``: the binary expression is swallowed into the member's
object. Read the right-most operand as the receiver instead, so the call
is seen as ``super.f`` (and ``a && b.c(x)`` as ``b.c``).
"""
inner = callee.named_children[0] if callee.type == "expression" and callee.named_children else callee
if inner.type == "member_expression":
obj = inner.child_by_field_name("object")
prop = inner.child_by_field_name("property")
if obj is not None and prop is not None:
base = obj
while True:
unwrapped = base.named_children[0] if base.type == "expression" and base.named_children else base
right = unwrapped.child_by_field_name("right") if unwrapped.type == "binary_expression" else None
if right is None:
break
base = right
if base is not obj:
return f"{_read_text(base, source)}.{_read_text(prop, source)}"
return _read_text(callee, source)
def _inherited_call(
caller_id: str, callee: str, arity: int | None, kind: str, source_file: str, node: Node,
) -> dict[str, Any]:
"""A call or modifier the contract itself does not declare, for
:func:`resolve_solidity_inherited_calls`. ``kind`` is ``inherited`` (bare
``f()`` / ``this.f()``), ``super`` (``super.f()``) or ``modifier``."""
return {
"caller_nid": caller_id,
"callee": callee,
"arity": arity,
"solidity_call": kind,
"language": "solidity",
"source_file": source_file,
"source_location": f"L{node.start_point[0] + 1}",
}
def _c3_linearization(type_id: str, bases: dict[str, list[str]]) -> list[str]:
"""Solidity's C3 linearization of ``type_id``, most derived first.
``bases`` lists each type's direct bases in declaration order
(``contract C is A, B`` -> ``[A, B]``); Solidity treats the LAST one as
the most derived, so they are merged right to left. Falls back to a
right-to-left depth-first order when the bases cannot be merged (a cycle
or an inconsistent hierarchy), so a broken graph still resolves.
"""
memo: dict[str, list[str]] = {}
def linearize(tid: str, active: frozenset[str]) -> list[str] | None:
if tid in memo:
return memo[tid]
if tid in active:
return None
parents = list(reversed(bases.get(tid, [])))
sequences: list[list[str]] = []
for parent in parents:
lin = linearize(parent, active | {tid})
if lin is None:
return None
sequences.append(list(lin))
sequences.append(list(parents))
result = [tid]
while any(sequences):
head = None
for seq in sequences:
if seq and not any(seq[0] in other[1:] for other in sequences):
head = seq[0]
break
if head is None:
return None
result.append(head)
for seq in sequences:
if seq and seq[0] == head:
del seq[0]
memo[tid] = result
return result
linear = linearize(type_id, frozenset())
if linear is not None:
return linear
order: list[str] = []
stack = [type_id]
while stack:
tid = stack.pop()
if tid in order:
continue
order.append(tid)
stack.extend(bases.get(tid, []))
return order
def resolve_solidity_inherited_calls(
per_file: list[dict], all_nodes: list[dict], all_edges: list[dict]
) -> None:
"""Bind calls, ``super`` calls and modifiers a contract inherits.
The extractor binds ``f()`` and ``onlyX`` only inside the declaring
contract, so a call to a function or modifier declared in a base contract
(often in another file) got no edge. Runs after
:func:`resolve_solidity_type_references` has bound each ``inherits`` edge
to the base's real node, and walks the caller contract's C3 linearization
as the compiler does: the first contract that declares a matching member
wins (``super.f()`` starts after the caller's own contract, as the plain
lookup does once the extractor found nothing there); several same-arity
overloads in that contract are ambiguous and get no edge.
"""
calls = [
rc
for result in per_file
for rc in result.get("raw_calls", []) or []
if rc.get("solidity_call")
]
if not calls:
return
node_by_id = {node.get("id"): node for node in all_nodes}
def meta(nid: Any) -> dict:
node = node_by_id.get(nid)
value = node.get("metadata") if node else None
return value if isinstance(value, dict) else {}
type_kinds = set(_TYPE_DECLARATIONS.values())
owner: dict[str, str] = {}
functions: dict[str, dict[str, list[tuple[str, Any]]]] = {}
modifiers: dict[str, dict[str, list[str]]] = {}
bases: dict[str, list[str]] = {}
existing: set[tuple[Any, Any, Any]] = set()
for edge in all_edges:
source, target, relation = edge.get("source"), edge.get("target"), edge.get("relation")
existing.add((source, target, relation))
if not isinstance(source, str) or not isinstance(target, str):
continue
source_meta = meta(source)
if source_meta.get("language") != "solidity" or source_meta.get("kind") not in type_kinds:
continue
if relation == "method":
member = meta(target)
name = str(node_by_id.get(target, {}).get("label", ""))
name = name[:-2] if name.endswith("()") else name
owner[target] = source
if member.get("kind") == "function":
functions.setdefault(source, {}).setdefault(name, []).append(
(target, member.get("arity"))
)
elif member.get("kind") == "modifier":
modifiers.setdefault(source, {}).setdefault(name, []).append(target)
elif relation == "inherits" and meta(target).get("kind") in type_kinds:
bases.setdefault(source, []).append(target)
def find(linear: list[str], name: str, arity: Any, kind: str) -> str | None:
for type_id in linear:
if kind == "modifier":
found = modifiers.get(type_id, {}).get(name, [])
else:
found = [
nid for nid, declared in functions.get(type_id, {}).get(name, [])
if arity is None or declared is None or declared == arity
]
if found:
return found[0] if len(found) == 1 else None
return None
linear_cache: dict[str, list[str]] = {}
for rc in calls:
caller = rc.get("caller_nid")
contract = owner.get(caller)
name = rc.get("callee")
if not contract or not name:
continue
if contract not in linear_cache:
linear_cache[contract] = _c3_linearization(contract, bases)
kind = rc["solidity_call"]
# The extractor already looked in the caller's own contract.
target = find(linear_cache[contract][1:], name, rc.get("arity"), kind)
if target is None:
continue
relation = "uses" if kind == "modifier" else "calls"
if (caller, target, relation) in existing:
continue
existing.add((caller, target, relation))
all_edges.append({
"source": caller,
"target": target,
"relation": relation,
"confidence": "EXTRACTED",
"source_file": rc.get("source_file"),
"source_location": rc.get("source_location"),
"weight": 1.0,
})
def resolve_solidity_type_references(
_per_file: list[dict], all_nodes: list[dict], all_edges: list[dict]
) -> None:
@@ -99,15 +295,21 @@ def extract_solidity(path: Path) -> dict:
kind: str,
source_backed: bool = True,
callable_node: bool = False,
arity: int | None = None,
) -> str:
if nid not in seen_ids:
seen_ids.add(nid)
metadata: dict[str, Any] = {"language": "solidity", "kind": kind}
if arity is not None:
# Parameter count, so the cross-contract pass can pick the
# same overload the in-contract lookup does (name, arity).
metadata["arity"] = arity
item: dict[str, Any] = {
"id": nid,
"label": label,
"file_type": "code",
"source_location": f"L{node.start_point[0] + 1}",
"metadata": {"language": "solidity", "kind": kind},
"metadata": metadata,
}
if source_backed:
item["source_file"] = source_file
@@ -218,6 +420,7 @@ def extract_solidity(path: Path) -> dict:
relation: str = "contains",
signature: str = "",
callable_node: bool = False,
arity: int | None = None,
) -> str:
# Callables carry an arity:types signature (distinguishes overloads).
# Non-callable members (struct/enum/event/error/state-var) have no
@@ -231,6 +434,7 @@ def extract_solidity(path: Path) -> dict:
member,
kind=kind,
callable_node=callable_node,
arity=arity,
)
add_edge(type_id, member_id, relation, member)
return member_id
@@ -353,6 +557,7 @@ def extract_solidity(path: Path) -> dict:
relation="method",
signature=f"{arity}:{signature}",
callable_node=True,
arity=arity,
)
if callable_kind == "modifier":
modifiers[name] = member_id
@@ -361,7 +566,7 @@ def extract_solidity(path: Path) -> dict:
member_body = member.child_by_field_name("body")
if member_body is not None:
bodies.append((member_body, member_id))
if callable_kind == "function":
if callable_kind != "modifier":
for child in member.named_children:
if child.type != "modifier_invocation":
continue
@@ -380,6 +585,13 @@ def extract_solidity(path: Path) -> dict:
target = modifiers.get(modifier_name)
if target:
add_edge(caller_id, target, "uses", modifier_node)
else:
# Declared in a base contract (`onlyOwner` from Ownable), or a
# base-constructor call (`ERC20("n", "s")`), which the
# inheritance pass finds no modifier for and drops.
raw_calls.append(_inherited_call(
caller_id, modifier_name, None, "modifier", source_file, modifier_node,
))
for function_body, caller_id in bodies:
stack = [function_body]
@@ -388,7 +600,7 @@ def extract_solidity(path: Path) -> dict:
if node.type == "call_expression":
callee_node = node.child_by_field_name("function")
if callee_node is not None:
written = _read_text(callee_node, source)
written = _callee_text(callee_node, source)
callee = written.split(".")[-1]
arity = sum(child.type == "call_argument" for child in node.named_children)
candidates: list[str] = []
@@ -400,6 +612,20 @@ def extract_solidity(path: Path) -> dict:
referenced = reference_targets.get((callee, arity))
if referenced is not None and "." not in written:
add_edge(caller_id, referenced, "references", node)
elif not candidates and callee and (
"." not in written or written.startswith("this.")
):
# Not declared in this contract: an inherited
# function (`_msgSender()` from Context), bound
# by resolve_solidity_inherited_calls through
# the contract's linearized bases.
raw_calls.append(_inherited_call(
caller_id, callee, arity, "inherited", source_file, node,
))
elif written.startswith("super.") and written.count(".") == 1:
raw_calls.append(_inherited_call(
caller_id, callee, arity, "super", source_file, node,
))
elif callee and "." in written:
raw_calls.append({
"caller_nid": caller_id,
@@ -458,7 +684,7 @@ def extract_solidity(path: Path) -> dict:
if node.type == "call_expression":
callee_node = node.child_by_field_name("function")
if callee_node is not None:
written = _read_text(callee_node, source)
written = _callee_text(callee_node, source)
callee = written.split(".")[-1]
arity = sum(child.type == "call_argument" for child in node.named_children)
if "." not in written:
+212
View File
@@ -0,0 +1,212 @@
"""Solidity: calls, `super` calls and modifiers a contract inherits from a base
contract, usually declared in another file, bind through the contract's C3
linearization the way solc resolves them."""
from __future__ import annotations
import importlib.util as _ilu
import json
from pathlib import Path
import pytest
from graphify.extract import extract
pytestmark = pytest.mark.skipif(
_ilu.find_spec("tree_sitter_solidity") is None,
reason="tree-sitter-solidity not installed (optional [solidity] extra)",
)
_CONTEXT = (
"pragma solidity ^0.8.20;\n"
"abstract contract Context {\n"
" function _msgSender() internal view virtual returns (address) { return msg.sender; }\n"
"}\n"
)
_OWNABLE = (
"pragma solidity ^0.8.20;\n"
'import {Context} from "./Context.sol";\n'
"abstract contract Ownable is Context {\n"
" address private _owner;\n"
" modifier onlyOwner() { _checkOwner(); _; }\n"
" function owner() public view returns (address) { return _owner; }\n"
" function _checkOwner() internal view { require(owner() == _msgSender()); }\n"
"}\n"
)
def _edges(tmp_path: Path, files: dict[str, str]) -> set[tuple[str, str, str, str]]:
"""(caller contract, caller, relation, callee contract.callee) for calls/uses edges."""
for name, text in files.items():
(tmp_path / name).write_text(text, encoding="utf-8")
result = extract(sorted(tmp_path.glob("*.sol")), cache_root=tmp_path)
nodes = {n["id"]: n for n in result["nodes"]}
owner = {
e["target"]: nodes[e["source"]]["label"]
for e in result["edges"]
if e["relation"] == "method" and e["source"] in nodes
}
return {
(owner.get(e["source"], "?"), nodes[e["source"]]["label"], e["relation"],
f"{owner.get(e['target'], '?')}.{nodes[e['target']]['label']}")
for e in result["edges"]
if e["relation"] in ("calls", "uses") and e["source"] in nodes and e["target"] in nodes
}
def test_call_to_a_function_declared_in_a_base_contract_in_another_file(tmp_path: Path):
edges = _edges(tmp_path, {"Context.sol": _CONTEXT, "Ownable.sol": _OWNABLE})
assert ("Ownable", "_checkOwner()", "calls", "Context._msgSender()") in edges
# the same-contract call keeps its edge
assert ("Ownable", "_checkOwner()", "calls", "Ownable.owner()") in edges
def test_modifier_declared_in_a_base_contract(tmp_path: Path):
edges = _edges(tmp_path, {
"Context.sol": _CONTEXT,
"Ownable.sol": _OWNABLE,
"Vault.sol": (
"pragma solidity ^0.8.20;\n"
'import {Ownable} from "./Ownable.sol";\n'
"contract Vault is Ownable {\n"
" function sweep() external onlyOwner { _msgSender(); }\n"
"}\n"
),
})
assert ("Vault", "sweep()", "uses", "Ownable.onlyOwner()") in edges
# two levels up: Vault -> Ownable -> Context
assert ("Vault", "sweep()", "calls", "Context._msgSender()") in edges
def test_super_call_binds_to_the_next_contract_in_the_linearization(tmp_path: Path):
edges = _edges(tmp_path, {
"Bases.sol": (
"pragma solidity ^0.8.20;\n"
"contract A { function hook(uint x) internal virtual {} }\n"
"contract B is A { function hook(uint x) internal virtual override { super.hook(x); } }\n"
),
"C.sol": (
"pragma solidity ^0.8.20;\n"
'import {A, B} from "./Bases.sol";\n'
"contract C is B {\n"
" function hook(uint x) internal override { super.hook(x); }\n"
"}\n"
),
})
assert ("C", "hook()", "calls", "B.hook()") in edges
assert ("B", "hook()", "calls", "A.hook()") in edges
assert ("C", "hook()", "calls", "A.hook()") not in edges
def test_super_call_inside_a_boolean_expression(tmp_path: Path):
# tree-sitter-solidity parses `x || super.f(y)` as a call of `(x || super).f`.
edges = _edges(tmp_path, {
"Iface.sol": (
"pragma solidity ^0.8.20;\n"
"contract ERC165 { function supportsInterface(bytes4 id) public view virtual returns (bool) { return false; } }\n"
),
"Token.sol": (
"pragma solidity ^0.8.20;\n"
'import {ERC165} from "./Iface.sol";\n'
"contract Token is ERC165 {\n"
" function supportsInterface(bytes4 id) public view override returns (bool) {\n"
" return id == 0x01ffc9a7 || super.supportsInterface(id);\n"
" }\n"
"}\n"
),
})
assert ("Token", "supportsInterface()", "calls", "ERC165.supportsInterface()") in edges
def test_the_last_listed_base_wins_like_solc(tmp_path: Path):
# `contract D is A, B`: B is the more derived base, so a bare `g()` that
# both declare runs B.g (C3 linearization D, B, A).
edges = _edges(tmp_path, {
"AB.sol": (
"pragma solidity ^0.8.20;\n"
"contract A { function g() internal virtual {} }\n"
"contract B { function g() internal virtual {} }\n"
),
"D.sol": (
"pragma solidity ^0.8.20;\n"
'import {A, B} from "./AB.sol";\n'
"contract D is A, B { function run() external { g(); this.g(); } }\n"
),
})
assert ("D", "run()", "calls", "B.g()") in edges
assert ("D", "run()", "calls", "A.g()") not in edges
def test_same_arity_overloads_in_the_base_are_left_unbound(tmp_path: Path):
edges = _edges(tmp_path, {
"Base.sol": (
"pragma solidity ^0.8.20;\n"
"contract Base {\n"
" function put(uint x) internal {}\n"
" function put(address x) internal {}\n"
" function put(uint x, uint y) internal {}\n"
"}\n"
),
"Child.sol": (
"pragma solidity ^0.8.20;\n"
'import {Base} from "./Base.sol";\n'
"contract Child is Base { function run() external { put(1); put(1, 2); } }\n"
),
})
calls = {e for e in edges if e[1] == "run()"}
# arity 2 is unique; arity 1 has two overloads, so no guess
assert calls == {("Child", "run()", "calls", "Base.put()")}
def test_a_name_no_base_declares_is_never_matched_corpus_wide(tmp_path: Path):
edges = _edges(tmp_path, {
"Other.sol": "pragma solidity ^0.8.20;\ncontract Other { function helper() internal {} }\n",
"Lone.sol": (
"pragma solidity ^0.8.20;\n"
"contract Lone { function run() external { helper(); require(true); } }\n"
),
})
assert not any(e[1] == "run()" for e in edges), edges
def test_changed_files_rebuild_keeps_inherited_calls(tmp_path: Path):
from graphify.watch import _rebuild_code
files = {
"Context.sol": _CONTEXT,
"Ownable.sol": _OWNABLE,
"Vault.sol": (
"pragma solidity ^0.8.20;\n"
'import {Ownable} from "./Ownable.sol";\n'
"contract Vault is Ownable {\n"
" function sweep() external onlyOwner { _msgSender(); }\n"
"}\n"
),
}
roots = []
for name in ("inc", "full"):
root = tmp_path / name
root.mkdir()
for fname, text in files.items():
(root / fname).write_text(text, encoding="utf-8")
roots.append(root)
inc, full = roots
assert _rebuild_code(inc, no_cluster=True, acquire_lock=False, force=True)
for root in roots:
p = root / "Vault.sol"
p.write_text(p.read_text(encoding="utf-8") + "// touched\n", encoding="utf-8")
assert _rebuild_code(inc, changed_paths=[inc / "Vault.sol"], no_cluster=True, acquire_lock=False)
assert _rebuild_code(full, no_cluster=True, acquire_lock=False, force=True)
def edges_of(root: Path) -> set[tuple[object, object, object]]:
g = json.loads((root / "graphify-out" / "graph.json").read_text(encoding="utf-8"))
label = {n["id"]: n.get("label") for n in g["nodes"]}
links = g.get("links", g.get("edges", []))
return {
(label.get(e["source"]), e["relation"], label.get(e["target"]))
for e in links if e["relation"] in ("calls", "uses")
}
full_edges = edges_of(full)
assert ("sweep()", "uses", "onlyOwner()") in full_edges
assert ("sweep()", "calls", "_msgSender()") in full_edges
assert edges_of(inc) == full_edges