From 425a402bd7c9802f8c609ca034f188912dd229a5 Mon Sep 17 00:00:00 2001 From: rohit-jsfreaky Date: Mon, 12 Oct 2026 23:16:13 +0530 Subject: [PATCH] feat(solidity): resolve calls, `super` calls and modifiers inherited from base contracts Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01B8gwEpjajiWHVW3c9smuKf (cherry picked from commit cdcec4510afdf3ca716effd3678cb33e000a7877) --- graphify/cache.py | 2 +- graphify/extract.py | 12 ++ graphify/extractors/solidity.py | 234 ++++++++++++++++++++++++- tests/test_solidity_inherited_calls.py | 212 ++++++++++++++++++++++ 4 files changed, 455 insertions(+), 5 deletions(-) create mode 100644 tests/test_solidity_inherited_calls.py diff --git a/graphify/cache.py b/graphify/cache.py index ae9eeca13..0ffe13e1c 100644 --- a/graphify/cache.py +++ b/graphify/cache.py @@ -37,7 +37,7 @@ except Exception: _EXTRACTOR_VERSION = "unknown" # Bump when AST cache-key semantics change independently of the package version. -_AST_CACHE_SCHEMA = 17 # Python pytest-fixture markers (`_py_fixture`, `_python_fixture_param`); 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. +_AST_CACHE_SCHEMA = 17 # Batch: Python pytest-fixture markers (`_py_fixture`/`_python_fixture_param`, #4332) and module-level class-id collision salting (#2810); TS `this.field` typed from any method incl. static (#4473); Solidity callables carry `metadata.arity` + inherited-call/`super`/modifier raw calls (#4379); Go type-decl and Rust file-item case-distinct node ids (#4444); 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 diff --git a/graphify/extract.py b/graphify/extract.py index bae4f6a57..39ebfd62e 100644 --- a/graphify/extract.py +++ b/graphify/extract.py @@ -75,6 +75,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 @@ -7426,6 +7427,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", @@ -10419,6 +10426,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 diff --git a/graphify/extractors/solidity.py b/graphify/extractors/solidity.py index b79aa374a..d8ccd6dc3 100644 --- a/graphify/extractors/solidity.py +++ b/graphify/extractors/solidity.py @@ -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: diff --git a/tests/test_solidity_inherited_calls.py b/tests/test_solidity_inherited_calls.py new file mode 100644 index 000000000..106d4c603 --- /dev/null +++ b/tests/test_solidity_inherited_calls.py @@ -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