From d2e56e932f5a667de0ed0d1e9cb63af0a4a178de Mon Sep 17 00:00:00 2001 From: rajatnagda45 Date: Tue, 29 Sep 2026 01:44:24 +0530 Subject: [PATCH] fix(solidity): extract file-level free functions and their calls MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- graphify/extractors/solidity.py | 65 ++++++++++++++++++++++++++++++++ tests/test_solidity_extractor.py | 19 ++++++++++ 2 files changed, 84 insertions(+) diff --git a/graphify/extractors/solidity.py b/graphify/extractors/solidity.py index 6fde89d26..361d042ce 100644 --- a/graphify/extractors/solidity.py +++ b/graphify/extractors/solidity.py @@ -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 diff --git a/tests/test_solidity_extractor.py b/tests/test_solidity_extractor.py index 979e1e59b..31ca6433c 100644 --- a/tests/test_solidity_extractor.py +++ b/tests/test_solidity_extractor.py @@ -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"