mirror of
https://github.com/safishamsi/graphify.git
synced 2026-10-21 11:21:27 +00:00
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:
1 parent
10130e79ed
commit
cdcec4510a
4 files changed
+455
-5
No files matched your search
+1
-1
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
Reference in new issue
Block a user