mirror of
https://github.com/safishamsi/graphify.git
synced 2026-10-15 00:19:33 +00:00
fix(solidity): extract file-level free functions and their calls
Free (file-level) functions have been valid Solidity since 0.7, but the extractor only descended into named type declarations (contract, library, interface, abstract contract). A function declared at source-unit scope — and every call made from its body — was dropped entirely, so a file of free utility functions produced no nodes at all. Walk source-unit function_definition nodes, emit each as a file-contained callable node, and resolve an unambiguous same-file free-function call to a calls edge. Member calls (Lib.f()) are parked as raw_calls for cross-file resolution; calls that cross into contract scope are left unresolved rather than guessed (fail-closed).
This commit is contained in:
1 parent
d6eaa8aae8
commit
d2e56e932f
2 files changed
+84
No files matched your search
@@ -405,6 +405,71 @@ def extract_solidity(path: Path) -> dict:
|
||||
})
|
||||
stack.extend(reversed(node.named_children))
|
||||
|
||||
# Free (file-level) functions live at source-unit scope, outside any
|
||||
# contract/library/interface. Solidity >=0.7 allows them, but the
|
||||
# type-declaration loop above only descends into named type declarations,
|
||||
# so a free function — and every call it makes — was dropped entirely.
|
||||
free_functions: dict[tuple[str, int], list[str]] = {}
|
||||
free_bodies: list[tuple[Node, str]] = []
|
||||
for declaration in root.named_children:
|
||||
if declaration.type != "function_definition":
|
||||
continue
|
||||
name_node = declaration.child_by_field_name("name")
|
||||
if name_node is None:
|
||||
continue
|
||||
name = _read_text(name_node, source)
|
||||
parameters = [
|
||||
child for child in declaration.named_children
|
||||
if child.type == "parameter"
|
||||
]
|
||||
signature = ",".join(
|
||||
_read_text(child.child_by_field_name("type"), source)
|
||||
for child in parameters
|
||||
if child.child_by_field_name("type") is not None
|
||||
)
|
||||
arity = len(parameters)
|
||||
func_id = add_node(
|
||||
_make_id(stem, "function", name, f"{arity}:{signature}"),
|
||||
f"{name}()",
|
||||
declaration,
|
||||
kind="function",
|
||||
callable_node=True,
|
||||
)
|
||||
add_edge(file_id, func_id, "contains", declaration)
|
||||
free_functions.setdefault((name, arity), []).append(func_id)
|
||||
body = declaration.child_by_field_name("body")
|
||||
if body is not None:
|
||||
free_bodies.append((body, func_id))
|
||||
|
||||
# Resolve calls made from free-function bodies. Only an unambiguous
|
||||
# same-file free function is linked directly; a member call (Lib.f()) is
|
||||
# left for cross-file resolution. Contract methods stay out of scope so we
|
||||
# never guess a relationship across a scope we cannot see (fail-closed).
|
||||
for function_body, caller_id in free_bodies:
|
||||
stack = [function_body]
|
||||
while stack:
|
||||
node = stack.pop()
|
||||
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)
|
||||
callee = written.split(".")[-1]
|
||||
arity = sum(child.type == "call_argument" for child in node.named_children)
|
||||
if "." not in written:
|
||||
candidates = free_functions.get((callee, arity), [])
|
||||
if len(candidates) == 1:
|
||||
add_edge(caller_id, candidates[0], "calls", node)
|
||||
elif callee:
|
||||
raw_calls.append({
|
||||
"caller_nid": caller_id,
|
||||
"callee": callee,
|
||||
"is_member_call": True,
|
||||
"language": "solidity",
|
||||
"source_file": source_file,
|
||||
"source_location": f"L{node.start_point[0] + 1}",
|
||||
})
|
||||
stack.extend(reversed(node.named_children))
|
||||
|
||||
clean_edges = [
|
||||
edge for edge in edges
|
||||
if edge["source"] in seen_ids
|
||||
|
||||
@@ -57,6 +57,25 @@ def test_solidity_contract_members_and_calls_are_extracted(tmp_path):
|
||||
assert ("increment()", "record()") in _edge_labels(result, "calls")
|
||||
|
||||
|
||||
def test_solidity_free_functions_and_their_calls_are_extracted(tmp_path):
|
||||
# File-level (free) functions — legal since Solidity 0.7 — live outside any
|
||||
# contract. Before the fix the extractor only descended into named type
|
||||
# declarations, so both free functions and the call between them vanished.
|
||||
source = tmp_path / "Utils.sol"
|
||||
source.write_text(
|
||||
"pragma solidity ^0.8.0;\n"
|
||||
"function halve(uint x) pure returns (uint) { return x / 2; }\n"
|
||||
"function quarter(uint x) pure returns (uint) { return halve(halve(x)); }\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
result = extract([source], cache_root=tmp_path)
|
||||
|
||||
labels = {node["label"] for node in result["nodes"]}
|
||||
assert {"halve()", "quarter()"} <= labels
|
||||
assert ("quarter()", "halve()") in _edge_labels(result, "calls")
|
||||
|
||||
|
||||
def test_solidity_types_imports_inheritance_overloads_and_modifiers(tmp_path):
|
||||
(tmp_path / "Base.sol").write_text(
|
||||
"contract Base { function baseRun() internal {} }\n", encoding="utf-8"
|
||||
|
||||
Reference in new issue
Block a user