diff --git a/README.md b/README.md index 5fb8b72..ff9d9ba 100644 --- a/README.md +++ b/README.md @@ -35,7 +35,7 @@ flowchart TD A language-specific parser walks the directory, parses each file into a tree-sitter AST, and extracts: - **Nodes** — functions, methods, classes, structs, interfaces, traits, enums, modules, namespaces -- **Edges** — calls, inheritance, implementation, containment, imports +- **Edges** — calls, inheritance, implementation, containment, imports, persistent-state reads/writes (Solidity) - **Metadata** — type annotations, cyclomatic complexity, branches, docstrings, exception types ### Supported Languages @@ -53,7 +53,7 @@ A language-specific parser walks the directory, parses each file into a tree-sit | Java | `.java` | methods, classes, interfaces, enums | | Go | `.go` | functions, methods, structs, interfaces | | Rust | `.rs` | functions, structs, traits, enums, impl blocks, `verus!` blocks | -| Solidity | `.sol` | contracts, interfaces, libraries, functions, modifiers, structs, enums | +| Solidity | `.sol` | contracts, interfaces, libraries, functions, modifiers, structs, enums, persistent-state reads/writes | | Cairo | `.cairo` | functions, traits, structs, enums, impl blocks, StarkNet contracts | | Circom | `.circom` | templates, functions, signals, components | | Haskell | `.hs` | functions, data types, type classes, instances | @@ -141,6 +141,10 @@ The `QueryEngine` provides a high-level API over the indexed graph: | --- | --- | | `callers_of(name)` | Direct callers of the named target | | `callees_of(name)` | Direct callees of the named source | +| `state_read_by(name)` | Persistent-state declarations directly read by the named unit | +| `state_written_by(name)` | Persistent-state declarations directly written by the named unit | +| `readers_of(name)` | Units that directly read the named state declaration | +| `writers_of(name)` | Units that directly write the named state declaration | | `ancestors_of(name)` | Every function that can transitively reach the target (upward slice) | | `reachable_from(name)` | Every function transitively reachable from the source | | `paths_between(src, dst)` | All simple call paths between two nodes | @@ -223,11 +227,11 @@ classDiagram CodeGraph "1" *-- "*" EntrypointTag ``` -**Node kinds:** `function`, `method`, `class`, `module`, `struct`, `interface`, `trait`, `enum`, `namespace`, `contract`, `library`, `template`, `proxy` +**Node kinds:** `function`, `method`, `class`, `module`, `struct`, `interface`, `trait`, `enum`, `namespace`, `contract`, `state_variable`, `library`, `template`, `proxy` **Node origins:** `source`, `proxy`, `binary`, `synthetic` -**Edge kinds:** `calls`, `inherits`, `implements`, `contains`, `imports`, `resolves_to`, `type_uses`, `specializes`, `corresponds_to` +**Edge kinds:** `calls`, `reads`, `writes`, `inherits`, `implements`, `contains`, `imports`, `resolves_to`, `type_uses`, `specializes`, `corresponds_to` **Edge confidence:** `certain`, `inferred`, `uncertain` @@ -238,6 +242,42 @@ parses, including single-language parses, also apply repository links and materialize `TYPE_USES` edges. Binary analysis support imports external JSON call graphs; Trailmark does not disassemble executables itself. +### Persistent State (Solidity) + +The Solidity parser emits `state_variable` nodes for persistent storage declared +in a contract, with IDs such as `token:Token.balances`, a `declared_type` +attribute, and a `contains` edge from the owner. Constants, immutables, and +transient variables are excluded. Functions, constructors, and modifiers have +direct `reads` and `writes` edges to those declarations, with a source location +for each occurrence. Repeated accesses remain separate edges in JSON; the four +state query methods return distinct nodes, ordered by ID, and `[]` for missing +names. + +Assignments and `delete` produce writes; compound assignments and increments +produce both reads and writes. Struct members, mapping entries, and array +elements roll up to their owning state declaration. Index expressions are read +separately. Direct state-array `.push()` and `.pop()` calls produce both kinds +of edge. Parameters, named returns, and local variables respect lexical scope +and shadow state names. For example: + +```python +engine = QueryEngine.from_directory("./contracts", language="solidity") +engine.state_read_by("Token.transfer") +engine.state_written_by("Token.transfer") +engine.readers_of("Token.balances") +engine.writers_of("Token.balances") +``` + +This is syntax-based, declaration-level dependency information, not an EVM +storage-slot or mapping-key analysis. The first implementation resolves only +state declared in the same contract. It does not follow inherited state, +storage aliases, library effects, assembly, delegatecalls, or external calls. +Declaration initializers and compiler-generated public getters are not modeled. +Modifier bodies own their accesses; their effects are not propagated to the +functions using them. State edges do not change the default call traversals. +An absent edge is not proof that a function cannot access storage. Other +language parsers do not yet emit state-access edges. + ### Example Graph Given this Python code: diff --git a/src/trailmark/models/edges.py b/src/trailmark/models/edges.py index c3ccd96..6dc3693 100644 --- a/src/trailmark/models/edges.py +++ b/src/trailmark/models/edges.py @@ -12,6 +12,8 @@ class EdgeKind(Enum): """The kind of relationship between two code units.""" CALLS = "calls" + READS = "reads" + WRITES = "writes" INHERITS = "inherits" CONTAINS = "contains" IMPORTS = "imports" diff --git a/src/trailmark/models/nodes.py b/src/trailmark/models/nodes.py index 979395f..b26e2df 100644 --- a/src/trailmark/models/nodes.py +++ b/src/trailmark/models/nodes.py @@ -22,6 +22,7 @@ class NodeKind(Enum): ENUM = "enum" NAMESPACE = "namespace" CONTRACT = "contract" + STATE_VARIABLE = "state_variable" LIBRARY = "library" TEMPLATE = "template" SCHEMA = "schema" diff --git a/src/trailmark/parsers/solidity/_state.py b/src/trailmark/parsers/solidity/_state.py new file mode 100644 index 0000000..07984fa --- /dev/null +++ b/src/trailmark/parsers/solidity/_state.py @@ -0,0 +1,240 @@ +"""Direct, declaration-level Solidity storage dependencies. + +This pass resolves names within one contract. It does not infer storage aliases, +inheritance, library effects, or accesses through assembly and external calls. +""" + +from __future__ import annotations + +from tree_sitter import Node + +from trailmark.models.edges import CodeEdge, EdgeKind +from trailmark.models.graph import CodeGraph +from trailmark.models.nodes import CodeUnit, NodeKind +from trailmark.parsers._common import add_contains_edge, make_location, node_text + +_READ = (EdgeKind.READS,) +_WRITE = (EdgeKind.WRITES,) +_READ_WRITE = (EdgeKind.READS, EdgeKind.WRITES) +_SKIP = frozenset({"type_name", "parameter", "assembly_statement"}) + + +def extract_state_variables( + body: Node, + file_path: str, + contract_id: str, + graph: CodeGraph, +) -> dict[str, CodeUnit]: + """Materialize persistent storage declarations before visiting functions.""" + states: dict[str, CodeUnit] = {} + for child in body.named_children: + if child.type != "state_variable_declaration": + continue + if any(c.type in {"constant", "immutable"} for c in child.children): + continue + location = child.child_by_field_name("location") + if location is not None and node_text(location) == "transient": + continue + name = child.child_by_field_name("name") + declared_type = child.child_by_field_name("type") + if name is None or declared_type is None: + continue + state_name = node_text(name) + state_id = f"{contract_id}.{state_name}" + unit = CodeUnit( + id=state_id, + name=state_name, + kind=NodeKind.STATE_VARIABLE, + location=make_location(child, file_path), + attributes=(("declared_type", node_text(declared_type)),), + ) + states[state_name] = unit + graph.nodes[state_id] = unit + add_contains_edge(graph, contract_id, state_id) + return states + + +def add_state_access_edges( + function: Node, + source_id: str, + file_path: str, + states: dict[str, CodeUnit], + graph: CodeGraph, +) -> None: + """Record direct accesses in a function, constructor, or modifier.""" + if not states: + return + shadowed: set[str] = set() + _bind_parameters(function, shadowed) + returns = function.child_by_field_name("return_type") + if returns is not None: + _bind_parameters(returns, shadowed) + collector = _AccessCollector(source_id, file_path, states, graph) + for child in function.named_children: + if child.type == "modifier_invocation": + for argument in child.named_children: + if argument.type == "call_argument": + collector.visit(argument, shadowed) + body = function.child_by_field_name("body") + if body is not None: + collector.visit(body, shadowed) + + +def _bind_parameters(node: Node, shadowed: set[str]) -> None: + for child in node.named_children: + if child.type == "parameter": + name = child.child_by_field_name("name") + if name is not None: + shadowed.add(node_text(name)) + + +def _unwrap(node: Node) -> Node: + while node.type == "expression" and len(node.named_children) == 1: + node = node.named_children[0] + return node + + +class _AccessCollector: + def __init__( + self, + source_id: str, + file_path: str, + states: dict[str, CodeUnit], + graph: CodeGraph, + ) -> None: + self.source_id = source_id + self.file_path = file_path + self.states = states + self.graph = graph + + def visit( + self, + node: Node, + shadowed: set[str], + kinds: tuple[EdgeKind, ...] = _READ, + ) -> None: + if node.type in _SKIP: + return + if node.type == "identifier": + name = node_text(node) + state = self.states.get(name) + if state is not None and name not in shadowed: + for kind in kinds: + self.graph.edges.append( + CodeEdge( + source_id=self.source_id, + target_id=state.id, + kind=kind, + location=make_location(node, self.file_path), + ) + ) + return + if node.type in {"function_body", "block_statement", "for_statement", "catch_clause"}: + shadowed = shadowed.copy() + _bind_parameters(node, shadowed) + if node.type == "catch_clause": + body = node.child_by_field_name("body") + if body is not None: + self.visit(body, shadowed) + return + if node.type == "try_statement": + attempt = node.child_by_field_name("attempt") + if attempt is not None: + self.visit(attempt, shadowed) + success_scope = shadowed.copy() + _bind_parameters(node, success_scope) + body = node.child_by_field_name("body") + if body is not None: + self.visit(body, success_scope) + for child in node.named_children: + if child.type == "catch_clause": + self.visit(child, shadowed) + return + if node.type == "variable_declaration_statement": + for child in node.named_children: + if child.type == "variable_declaration_tuple": + for declaration in child.named_children: + self._bind_local(declaration, shadowed) + elif child.type == "variable_declaration": + self._bind_local(child, shadowed) + value = node.child_by_field_name("value") + if value is not None: + self.visit(value, shadowed) + return + if node.type in {"assignment_expression", "augmented_assignment_expression"}: + left = node.child_by_field_name("left") + right = node.child_by_field_name("right") + if left is not None: + self.visit( + left, + shadowed, + _WRITE if node.type == "assignment_expression" else _READ_WRITE, + ) + if right is not None: + self.visit(right, shadowed) + return + if node.type in {"update_expression", "unary_expression"}: + argument = node.child_by_field_name("argument") + if argument is not None: + if node.type == "update_expression": + kinds = _READ_WRITE + elif any(c.type == "delete" for c in node.children): + kinds = _WRITE + else: + kinds = _READ + self.visit(argument, shadowed, kinds) + return + if node.type == "member_expression": + obj = node.child_by_field_name("object") + if obj is not None: + self.visit(obj, shadowed, kinds) + return + if node.type == "array_access": + base = node.child_by_field_name("base") + index = node.child_by_field_name("index") + if base is not None: + self.visit(base, shadowed, kinds) + if index is not None: + self.visit(index, shadowed) + return + if node.type == "call_struct_argument": + value = node.child_by_field_name("value") + if value is not None: + self.visit(value, shadowed) + return + if node.type == "call_expression": + self._visit_call(node, shadowed) + return + for child in node.named_children: + self.visit(child, shadowed, kinds) + + def _bind_local(self, node: Node, shadowed: set[str]) -> None: + name = node.child_by_field_name("name") + if name is not None: + shadowed.add(node_text(name)) + + def _visit_call(self, node: Node, shadowed: set[str]) -> None: + function = node.child_by_field_name("function") + if function is not None: + receiver = self._array_mutation_receiver(_unwrap(function), shadowed) + if receiver is not None: + self.visit(receiver, shadowed, _READ_WRITE) + else: + self.visit(function, shadowed) + for child in node.named_children: + if child.type == "call_argument": + self.visit(child, shadowed) + + def _array_mutation_receiver(self, target: Node, shadowed: set[str]) -> Node | None: + obj = target.child_by_field_name("object") + prop = target.child_by_field_name("property") + if obj is None or prop is None or node_text(prop) not in {"push", "pop"}: + return None + receiver = _unwrap(obj) + name = node_text(receiver) + state = self.states.get(name) + if state is None or name in shadowed: + return None + if not str(dict(state.attributes)["declared_type"]).endswith("]"): + return None + return obj diff --git a/src/trailmark/parsers/solidity/parser.py b/src/trailmark/parsers/solidity/parser.py index dc652b2..d6b3eb0 100644 --- a/src/trailmark/parsers/solidity/parser.py +++ b/src/trailmark/parsers/solidity/parser.py @@ -26,6 +26,7 @@ node_text, parse_directory, ) +from trailmark.parsers.solidity._state import add_state_access_edges, extract_state_variables _BRANCH_NODE_TYPES = frozenset( { @@ -187,6 +188,7 @@ def _visit_contract_body( body = node.child_by_field_name("body") if body is None: return + states = extract_state_variables(body, file_path, contract_id, graph) for child in body.children: if child.type in _FUNCTION_TYPES: _extract_function( @@ -196,6 +198,7 @@ def _visit_contract_body( contract_id, graph, container_kind=graph.nodes[contract_id].kind, + state_variables=states, ) elif child.type == "struct_declaration": _extract_struct(child, file_path, module_id, graph) @@ -255,6 +258,7 @@ def _extract_function( graph: CodeGraph, *, container_kind: NodeKind | None = None, + state_variables: dict[str, CodeUnit] | None = None, ) -> None: """Extract a function, constructor, or modifier definition.""" func_name = _get_function_name(node) @@ -295,6 +299,8 @@ def _extract_function( add_contains_edge(graph, owner, func_id) _add_call_edges(calls, func_id, module_id, contract_id, file_path, graph) + if state_variables: + add_state_access_edges(node, func_id, file_path, state_variables, graph) def _get_function_name(node: Node) -> str: diff --git a/src/trailmark/query/api.py b/src/trailmark/query/api.py index e228762..7edee04 100644 --- a/src/trailmark/query/api.py +++ b/src/trailmark/query/api.py @@ -89,6 +89,34 @@ def callees_of(self, name: str) -> list[dict[str, Any]]: return [] return [_unit_to_dict(u) for u in self._store.callees_of(node_id)] + def state_read_by(self, name: str) -> list[dict[str, Any]]: + """Find persistent state directly read by the named unit.""" + node_id = self._store.find_node_id(name) + if node_id is None: + return [] + return [_unit_to_dict(u) for u in self._store.state_read_by(node_id)] + + def state_written_by(self, name: str) -> list[dict[str, Any]]: + """Find persistent state directly written by the named unit.""" + node_id = self._store.find_node_id(name) + if node_id is None: + return [] + return [_unit_to_dict(u) for u in self._store.state_written_by(node_id)] + + def readers_of(self, name: str) -> list[dict[str, Any]]: + """Find units that directly read the named state node.""" + node_id = self._store.find_node_id(name) + if node_id is None: + return [] + return [_unit_to_dict(u) for u in self._store.readers_of(node_id)] + + def writers_of(self, name: str) -> list[dict[str, Any]]: + """Find units that directly write the named state node.""" + node_id = self._store.find_node_id(name) + if node_id is None: + return [] + return [_unit_to_dict(u) for u in self._store.writers_of(node_id)] + def ancestors_of(self, name: str) -> list[dict[str, Any]]: """Find every function/method that can transitively reach ``name``. @@ -433,6 +461,8 @@ def _edge_to_dict(edge: CodeEdge) -> dict[str, Any]: } if edge.attributes: result["attributes"] = _attributes_to_dict(edge.attributes) + if edge.kind in {EdgeKind.READS, EdgeKind.WRITES} and edge.location is not None: + result["location"] = asdict(edge.location) return result diff --git a/src/trailmark/storage/graph_store.py b/src/trailmark/storage/graph_store.py index 125f8f0..d91a4f2 100644 --- a/src/trailmark/storage/graph_store.py +++ b/src/trailmark/storage/graph_store.py @@ -126,6 +126,37 @@ def callees_of(self, node_id: str) -> list[CodeUnit]: reverse=False, ) + def state_read_by(self, node_id: str) -> list[CodeUnit]: + """Return distinct state nodes directly read by a unit.""" + return self._access_neighbors(node_id, EdgeKind.READS, reverse=False) + + def state_written_by(self, node_id: str) -> list[CodeUnit]: + """Return distinct state nodes directly written by a unit.""" + return self._access_neighbors(node_id, EdgeKind.WRITES, reverse=False) + + def readers_of(self, node_id: str) -> list[CodeUnit]: + """Return distinct units that directly read a state node.""" + return self._access_neighbors(node_id, EdgeKind.READS, reverse=True) + + def writers_of(self, node_id: str) -> list[CodeUnit]: + """Return distinct units that directly write a state node.""" + return self._access_neighbors(node_id, EdgeKind.WRITES, reverse=True) + + def _access_neighbors( + self, + node_id: str, + kind: EdgeKind, + *, + reverse: bool, + ) -> list[CodeUnit]: + idx = self._idx(node_id) + if idx is None: + return [] + neighbors: list[str] = ( + self._digraph.predecessors(idx) if reverse else self._digraph.successors(idx) + ) + return self._filter_by_edge_kind(sorted(set(neighbors)), node_id, kind, reverse=reverse) + def _filter_by_edge_kind( self, neighbor_ids: list[str], diff --git a/tests/fixtures/kat/solidity/taxonomy.expected.json b/tests/fixtures/kat/solidity/taxonomy.expected.json index 291536c..f82c924 100644 --- a/tests/fixtures/kat/solidity/taxonomy.expected.json +++ b/tests/fixtures/kat/solidity/taxonomy.expected.json @@ -18,12 +18,50 @@ "source": "taxonomy", "target": "taxonomy:Animal" }, + { + "confidence": "certain", + "kind": "contains", + "source": "taxonomy:Animal", + "target": "taxonomy:Animal.name" + }, + { + "confidence": "certain", + "kind": "contains", + "source": "taxonomy:Animal", + "target": "taxonomy:Animal.species" + }, { "confidence": "certain", "kind": "contains", "source": "taxonomy:Animal", "target": "taxonomy:Animal.constructor" }, + { + "confidence": "certain", + "kind": "writes", + "location": { + "end_col": 12, + "end_line": 15, + "file_path": "taxonomy.sol", + "start_col": 8, + "start_line": 15 + }, + "source": "taxonomy:Animal.constructor", + "target": "taxonomy:Animal.name" + }, + { + "confidence": "certain", + "kind": "writes", + "location": { + "end_col": 15, + "end_line": 16, + "file_path": "taxonomy.sol", + "start_col": 8, + "start_line": 16 + }, + "source": "taxonomy:Animal.constructor", + "target": "taxonomy:Animal.species" + }, { "confidence": "certain", "kind": "contains", @@ -39,6 +77,32 @@ "source": "taxonomy:Animal.describe", "target": "proxy.unresolved:abi.encodePacked" }, + { + "confidence": "certain", + "kind": "reads", + "location": { + "end_col": 43, + "end_line": 20, + "file_path": "taxonomy.sol", + "start_col": 39, + "start_line": 20 + }, + "source": "taxonomy:Animal.describe", + "target": "taxonomy:Animal.name" + }, + { + "confidence": "certain", + "kind": "reads", + "location": { + "end_col": 61, + "end_line": 20, + "file_path": "taxonomy.sol", + "start_col": 54, + "start_line": 20 + }, + "source": "taxonomy:Animal.describe", + "target": "taxonomy:Animal.species" + }, { "confidence": "certain", "kind": "contains", @@ -57,12 +121,31 @@ "source": "taxonomy:Dog", "target": "taxonomy:IGreeter" }, + { + "confidence": "certain", + "kind": "contains", + "source": "taxonomy:Dog", + "target": "taxonomy:Dog.breed" + }, { "confidence": "certain", "kind": "contains", "source": "taxonomy:Dog", "target": "taxonomy:Dog.constructor" }, + { + "confidence": "certain", + "kind": "writes", + "location": { + "end_col": 13, + "end_line": 35, + "file_path": "taxonomy.sol", + "start_col": 8, + "start_line": 35 + }, + "source": "taxonomy:Dog.constructor", + "target": "taxonomy:Dog.breed" + }, { "confidence": "certain", "kind": "contains", @@ -263,6 +346,48 @@ "name": "string" } }, + "taxonomy:Animal.name": { + "attributes": { + "declared_type": "string" + }, + "branches": [], + "cyclomatic_complexity": null, + "docstring": null, + "exception_types": [], + "id": "taxonomy:Animal.name", + "kind": "state_variable", + "location": { + "end_col": 23, + "end_line": 11, + "file_path": "taxonomy.sol", + "start_col": 4, + "start_line": 11 + }, + "name": "name", + "parameters": [], + "return_type": null + }, + "taxonomy:Animal.species": { + "attributes": { + "declared_type": "string" + }, + "branches": [], + "cyclomatic_complexity": null, + "docstring": null, + "exception_types": [], + "id": "taxonomy:Animal.species", + "kind": "state_variable", + "location": { + "end_col": 26, + "end_line": 12, + "file_path": "taxonomy.sol", + "start_col": 4, + "start_line": 12 + }, + "name": "species", + "parameters": [], + "return_type": null + }, "taxonomy:Dog": { "branches": [], "cyclomatic_complexity": null, @@ -477,6 +602,27 @@ "name": "uint256" } }, + "taxonomy:Dog.breed": { + "attributes": { + "declared_type": "string" + }, + "branches": [], + "cyclomatic_complexity": null, + "docstring": null, + "exception_types": [], + "id": "taxonomy:Dog.breed", + "kind": "state_variable", + "location": { + "end_col": 24, + "end_line": 27, + "file_path": "taxonomy.sol", + "start_col": 4, + "start_line": 27 + }, + "name": "breed", + "parameters": [], + "return_type": null + }, "taxonomy:Dog.constructor": { "attributes": { "solidity_container_kind": "contract" @@ -646,6 +792,6 @@ "entrypoints": 0, "functions": 9, "proxies": 2, - "total_nodes": 15 + "total_nodes": 18 } } diff --git a/tests/test_models.py b/tests/test_models.py index 2ddcfed..e54f5aa 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -158,6 +158,25 @@ def test_proxy_node(self) -> None: class TestCodeEdge: + def test_state_access_edges_merge_with_locations(self) -> None: + state = CodeUnit( + id="token:Token.balance", + name="balance", + kind=NodeKind.STATE_VARIABLE, + location=_make_location(), + attributes=(("declared_type", "uint256"),), + ) + accesses = [ + CodeEdge("token:Token.transfer", state.id, kind, location=_make_location()) + for kind in (EdgeKind.READS, EdgeKind.WRITES) + ] + graph = CodeGraph() + graph.merge(CodeGraph(nodes={state.id: state}, edges=accesses)) + assert graph.nodes[state.id] == state + assert graph.nodes[state.id].kind.value == "state_variable" + assert [edge.kind.value for edge in graph.edges] == ["reads", "writes"] + assert all(edge.location == _make_location() for edge in graph.edges) + def test_call_edge(self) -> None: edge = CodeEdge( source_id="mod:a", diff --git a/tests/test_query.py b/tests/test_query.py index f7ce02b..094f677 100644 --- a/tests/test_query.py +++ b/tests/test_query.py @@ -77,6 +77,31 @@ def _build_engine() -> QueryEngine: return QueryEngine.from_graph(graph) +class TestQueryEngineStateAccess: + def test_state_queries_resolve_names_and_return_serializable_units(self) -> None: + function = _make_node("mod:C.update", "update") + state = _make_node("mod:C.balance", "balance", kind=NodeKind.STATE_VARIABLE) + engine = QueryEngine.from_graph( + CodeGraph( + nodes={function.id: function, state.id: state}, + edges=[ + CodeEdge(function.id, state.id, k) for k in (EdgeKind.READS, EdgeKind.WRITES) + ], + ) + ) + for query in (engine.state_read_by, engine.state_written_by): + assert [node["id"] for node in query("update")] == [state.id] + assert query("C.update") == query(function.id) + assert query("missing") == [] + assert json.loads(json.dumps(query("update")))[0]["kind"] == "state_variable" + for query in (engine.readers_of, engine.writers_of): + assert [node["id"] for node in query("balance")] == [function.id] + assert query("C.balance") == query(state.id) + assert query("missing") == [] + assert engine.readers_of("update") == [] + assert engine.state_written_by("balance") == [] + + class TestQueryEngineCallers: def test_callers_of(self) -> None: engine = _build_engine() diff --git a/tests/test_readme.py b/tests/test_readme.py index 622e7ab..80c2a5c 100644 --- a/tests/test_readme.py +++ b/tests/test_readme.py @@ -183,10 +183,32 @@ def test_verus_support_is_documented(self, readme_text: str) -> None: assert "installing published packages needs neither" in readme_text +class TestPersistentState: + def test_solidity_state_example(self, readme_text: str, tmp_path: Path) -> None: + assert "state_variable" in readme_text + assert "An absent edge is not proof" in readme_text + assert "storage aliases" in readme_text + assert "Other\nlanguage parsers do not yet emit state-access edges" in readme_text + (tmp_path / "token.sol").write_text( + "contract Token { mapping(address => uint) balances;" + "function transfer(address to, uint amount) public { balances[to] += amount; } }" + ) + engine = QueryEngine.from_directory(str(tmp_path), language="solidity") + for query in (engine.state_read_by, engine.state_written_by): + assert [node["id"] for node in query("Token.transfer")] == ["token:Token.balances"] + for query in (engine.readers_of, engine.writers_of): + assert [node["id"] for node in query("Token.balances")] == ["token:Token.transfer"] + assert engine.callees_of("Token.transfer") == [] + + class TestQueryEngineAPI: EXPECTED_METHODS = ( "callers_of", "callees_of", + "state_read_by", + "state_written_by", + "readers_of", + "writers_of", "ancestors_of", "reachable_from", "paths_between", diff --git a/tests/test_solidity_state.py b/tests/test_solidity_state.py new file mode 100644 index 0000000..b26ffd1 --- /dev/null +++ b/tests/test_solidity_state.py @@ -0,0 +1,193 @@ +"""Regression tests for direct Solidity persistent-state dependencies.""" + +from __future__ import annotations + +import json +from pathlib import Path + +import pytest + +from trailmark.models import CodeGraph, EdgeConfidence, EdgeKind, NodeKind +from trailmark.parsers.solidity.parser import SolidityParser +from trailmark.query.api import QueryEngine +from trailmark.storage.graph_store import GraphStore + + +def _parse(tmp_path: Path, source: str) -> CodeGraph: + path = tmp_path / "state.sol" + path.write_text(source) + return SolidityParser().parse_file(str(path)) + + +def _accesses(graph: CodeGraph, function: str = "f") -> set[tuple[str, str]]: + return { + (graph.nodes[e.target_id].name, e.kind.value) + for e in graph.edges + if e.source_id == f"state:C.{function}" and e.kind in {EdgeKind.READS, EdgeKind.WRITES} + } + + +@pytest.mark.parametrize( + ("statement", "expected"), + [ + ("x = y;", {("x", "writes"), ("y", "reads")}), + ("x += y;", {("x", "reads"), ("x", "writes"), ("y", "reads")}), + ("x++;", {("x", "reads"), ("x", "writes")}), + ("--x;", {("x", "reads"), ("x", "writes")}), + ("delete x;", {("x", "writes")}), + ("return x;", {("x", "reads")}), + ("x = ~y;", {("x", "writes"), ("y", "reads")}), + ("items[x] = y;", {("items", "writes"), ("x", "reads"), ("y", "reads")}), + ("items[x]++;", {("items", "reads"), ("items", "writes"), ("x", "reads")}), + ("delete items[x];", {("items", "writes"), ("x", "reads")}), + ("balances[x] = y;", {("balances", "writes"), ("x", "reads"), ("y", "reads")}), + ("delete balances[x];", {("balances", "writes"), ("x", "reads")}), + ("data.x = y;", {("data", "writes"), ("y", "reads")}), + ("items.push(x);", {("items", "reads"), ("items", "writes"), ("x", "reads")}), + ("items.pop();", {("items", "reads"), ("items", "writes")}), + ("items.custom(x);", {("items", "reads"), ("x", "reads")}), + ("getItems().push(x);", {("x", "reads")}), + ("data.push(y);", {("data", "reads"), ("y", "reads")}), + ("uint[] memory items; items.push(x);", {("x", "reads")}), + ("x = items.length;", {("x", "writes"), ("items", "reads")}), + ( + "(x, y) = (y, x);", + {("x", "reads"), ("x", "writes"), ("y", "reads"), ("y", "writes")}, + ), + ("g({x: y});", {("y", "reads")}), + ("assembly { sstore(0, x) }", set()), + ], +) +def test_direct_accesses(tmp_path: Path, statement: str, expected: set[tuple[str, str]]) -> None: + graph = _parse( + tmp_path, + "contract C { uint x; uint y; uint[] items; mapping(uint => uint) balances;" + "struct Data { uint x; } Data data;" + f"function f() public returns (uint result) {{ {statement} }} }}", + ) + assert _accesses(graph) == expected + + +def test_state_declarations_are_persistent_owned_and_order_independent(tmp_path: Path) -> None: + graph = _parse( + tmp_path, + """contract C { + function f() public { x = MAX + start + lock; } + uint public x; + uint constant MAX = 42; + uint immutable start; + uint transient lock; + }""", + ) + states = [node for node in graph.nodes.values() if node.kind == NodeKind.STATE_VARIABLE] + assert [node.id for node in states] == ["state:C.x"] + assert dict(states[0].attributes) == {"declared_type": "uint"} + assert states[0].location.start_line == 3 + assert any( + e.source_id == "state:C" and e.target_id == "state:C.x" and e.kind == EdgeKind.CONTAINS + for e in graph.edges + ) + assert _accesses(graph) == {("x", "writes")} + + +@pytest.mark.parametrize( + ("signature", "body", "expected"), + [ + ("uint x", "x = y;", {("y", "reads")}), + ("", "uint x = y; x++;", {("y", "reads")}), + ("", "{ uint x = y; x++; } x++;", {("x", "reads"), ("x", "writes"), ("y", "reads")}), + ("", "for (uint x = 0; x < y; x++) { x; } x = y;", {("x", "writes"), ("y", "reads")}), + ("", "(uint x, uint z) = (y, y); x = z;", {("y", "reads")}), + ("Data memory other", "other.x = y;", {("y", "reads")}), + ], +) +def test_lexical_shadowing( + tmp_path: Path, signature: str, body: str, expected: set[tuple[str, str]] +) -> None: + graph = _parse( + tmp_path, + "contract C { uint x; uint y; struct Data { uint x; }" + f"function f({signature}) public {{ {body} }} }}", + ) + assert _accesses(graph) == expected + + +def test_named_returns_and_catch_parameters_shadow_state(tmp_path: Path) -> None: + graph = _parse( + tmp_path, + """contract C { + uint x; string reason; uint failures; + function f() public returns (uint x) { x++; } + function attempt() public { + try other() returns (uint x) { x++; } + catch Error(string memory reason) { reason; failures++; } + x++; + } + }""", + ) + assert _accesses(graph) == set() + assert _accesses(graph, "attempt") == { + ("x", "reads"), + ("x", "writes"), + ("failures", "reads"), + ("failures", "writes"), + } + + +def test_constructor_modifier_and_modifier_arguments_keep_direct_ownership(tmp_path: Path) -> None: + graph = _parse( + tmp_path, + """contract C { + uint x; + constructor() { x = 1; } + modifier check(uint value) { require(x > value); _; } + function f() public check(x) {} + function g() public check(0) {} + }""", + ) + assert _accesses(graph, "constructor") == {("x", "writes")} + assert _accesses(graph, "check") == {("x", "reads")} + assert _accesses(graph) == {("x", "reads")} + assert _accesses(graph, "g") == set() + + +def test_contracts_do_not_share_same_named_state(tmp_path: Path) -> None: + graph = _parse( + tmp_path, + "contract C { uint x; function f() public { x = 1; } }" + "contract D { uint x; function f() public { x = 2; } }", + ) + writes = [e for e in graph.edges if e.kind == EdgeKind.WRITES] + assert [(e.source_id, e.target_id) for e in writes] == [ + ("state:C.f", "state:C.x"), + ("state:D.f", "state:D.x"), + ] + + +def test_access_sites_survive_export_and_queries_deduplicate(tmp_path: Path) -> None: + graph = _parse( + tmp_path, + "contract C {\n uint x;\n function f() public {\n x++;\n x++;\n }\n}", + ) + edges = [e for e in graph.edges if e.kind in {EdgeKind.READS, EdgeKind.WRITES}] + assert len(edges) == 4 + assert {e.location.start_line for e in edges if e.location} == {4, 5} + assert all(e.confidence == EdgeConfidence.CERTAIN for e in edges) + engine = QueryEngine.from_graph(graph) + assert [n["id"] for n in engine.state_read_by("C.f")] == ["state:C.x"] + assert [n["id"] for n in engine.state_written_by("C.f")] == ["state:C.x"] + assert [n["id"] for n in engine.readers_of("C.x")] == ["state:C.f"] + assert [n["id"] for n in engine.writers_of("C.x")] == ["state:C.f"] + assert engine.callees_of("C.f") == [] + assert engine.reachable_from("C.f") == [] + assert { + tuple(path) + for path in GraphStore(graph).paths_between( + "state:C.f", "state:C.x", edge_kinds=frozenset({EdgeKind.WRITES}) + ) + } == {("state:C.f", "state:C.x")} + exported = json.loads(engine.to_json()) + assert exported["nodes"]["state:C.x"]["kind"] == "state_variable" + exported_accesses = [e for e in exported["edges"] if e["kind"] in {"reads", "writes"}] + assert len(exported_accesses) == 4 + assert {e["location"]["start_line"] for e in exported_accesses} == {4, 5} diff --git a/tests/test_storage.py b/tests/test_storage.py index c900f38..3b5bcad 100644 --- a/tests/test_storage.py +++ b/tests/test_storage.py @@ -66,6 +66,42 @@ def _build_contains_bridge_graph() -> tuple[CodeGraph, GraphStore]: return graph, GraphStore(graph) +class TestGraphStoreStateAccess: + def test_access_neighbors_validate_endpoints_and_keep_calls_separate(self) -> None: + nodes = {name: _make_node(name, name) for name in ("f", "g", "x", "y")} + graph = CodeGraph( + nodes=nodes, + edges=[ + CodeEdge("f", "x", EdgeKind.READS), + CodeEdge("f", "x", EdgeKind.READS), + CodeEdge("f", "x", EdgeKind.WRITES), + CodeEdge("g", "x", EdgeKind.WRITES), + CodeEdge("f", "y", EdgeKind.READS), + CodeEdge("f", "g", EdgeKind.CALLS), + CodeEdge("f", "missing", EdgeKind.WRITES), + CodeEdge("missing", "x", EdgeKind.READS), + ], + ) + store = GraphStore(graph) + assert [n.id for n in store.state_read_by("f")] == ["x", "y"] + assert [n.id for n in store.state_written_by("f")] == ["x"] + assert [n.id for n in store.readers_of("x")] == ["f"] + assert [n.id for n in store.writers_of("x")] == ["f", "g"] + assert store.readers_of("y") == [nodes["f"]] + assert store.writers_of("y") == [] + assert store.state_read_by("x") == [] + assert store.state_written_by("x") == [] + for query in ( + store.state_read_by, + store.state_written_by, + store.readers_of, + store.writers_of, + ): + assert query("missing") == [] + assert store.callees_of("f") == [nodes["g"]] + assert store.reachable_from("f") == {"g"} + + class TestGraphStoreCallers: def test_callers_of_b(self) -> None: _, store = _build_graph()