feat(access-control-prover): 8.1 capture call receiver and arity evidence in the semantic IR

This commit is contained in:
Marius Ciepluch
2026-10-29 15:33:18 +01:00
parent 03807f4ddd
commit 9eee4d7f35
4 changed files with 153 additions and 2 deletions
+1 -1
View File
@@ -110,7 +110,7 @@ its final resolution, dominance, fact-acquisition and measurement semantics with
- _Depends: 7.1, 7.2, 7.3_
- [ ] 8. Stable symbols and conservative call resolution
- [ ] 8.1 Capture call receiver and arity evidence in the semantic IR
- [x] 8.1 Capture call receiver and arity evidence in the semantic IR
- Preserve receiver expression and argument count for supported syntax while keeping existing constructors valid.
- Done when language fixtures retain receiver and arity evidence without regressing existing IR consumers.
- _Requirements: 3.2, 3.3_
+36 -1
View File
@@ -206,17 +206,52 @@ def _call(node: Any, kinds: dict[str, frozenset[str]]) -> CallSite:
# PHP wraps positional and named argument values in an `argument` node.
arg_nodes = [arg.named_children[-1] if arg.type == "argument" and arg.named_children else arg for arg in arg_nodes]
extra = len(arg_nodes) >= 2 and arg_nodes[1].type in _SEQUENCE
callee = _callee_name(node, args_node)
return CallSite(
order=int(node.end_byte),
name=_callee_name(node, args_node),
name=callee,
line=_line(node),
arg_texts=tuple(_text(arg) for arg in arg_nodes),
arg_is_constant=tuple(_is_constant(arg, kinds) for arg in arg_nodes),
arg_names=tuple(_identifiers(arg) for arg in arg_nodes),
extra_arg_is_sequence=extra,
receiver=_receiver_text(node, args_node, callee),
arity=len(arg_nodes),
)
_RECEIVER_SEPARATORS = ("->", "::", "?.", ".")
def _receiver_text(node: Any, args_node: Any, callee: str) -> str | None:
parts = [child for child in getattr(node, "named_children", ()) or () if child is not args_node]
final_identifier = callee
for separator in _RECEIVER_SEPARATORS:
final_identifier = final_identifier.rsplit(separator, 1)[-1]
final_identifier = final_identifier.strip()
for child in parts:
receiver = _receiver_before_separator(_text(child), final_identifier)
if receiver is not None:
return receiver
for index, child in enumerate(parts):
if _text(child).strip() == final_identifier:
before = [text for text in (_text(part).strip() for part in parts[:index]) if text]
if before:
return ".".join(before)
return None
return None
def _receiver_before_separator(text: str, final_identifier: str) -> str | None:
for separator in _RECEIVER_SEPARATORS:
position = text.rfind(separator)
if position <= 0:
continue
if text[position + len(separator) :].strip() == final_identifier:
return text[:position].strip() or None
return None
def _callee_name(node: Any, args_node: Any) -> str:
if node.type in _KIND["php"]["call"]:
callee = node.child_by_field_name("name") or node.child_by_field_name("function")
+10
View File
@@ -27,6 +27,8 @@ class CallSite:
arg_names: tuple[tuple[str, ...], ...]
extra_arg_is_sequence: bool
order: int = 0
receiver: str | None = None
arity: int | None = None
@dataclass(frozen=True)
@@ -202,9 +204,17 @@ def _call_site(node: ast.Call) -> CallSite:
arg_is_constant=tuple(arg_const),
arg_names=tuple(arg_names),
extra_arg_is_sequence=extra_sequence,
receiver=_receiver(node.func),
arity=len(node.args) + len(node.keywords),
)
def _receiver(func: ast.expr) -> str | None:
if isinstance(func, ast.Attribute):
return _unparse(func.value)
return None
def _end_position(node: ast.AST) -> int:
return int(getattr(node, "end_lineno", 0) or 0) * 1_000_000 + int(getattr(node, "end_col_offset", 0) or 0)
+106
View File
@@ -0,0 +1,106 @@
"""Task 8.1 -- call receiver and arity evidence in the semantic IR (Reqs 3.2, 3.3)."""
from openultrasast.semantic.ir import CallSite, parse_file
def _calls(path: str, text: str, language: str) -> list[CallSite]:
parsed = parse_file(path, text, language)
assert parsed.parse_ok, parsed.reason
return [call for function in parsed.functions for call in function.calls]
PHP_SOURCE = """<?php
final class Controller {
public function run(): void {
$this->helper('a', 'b');
self::staticHelper('c');
plain('d');
}
}
"""
JAVA_SOURCE = """public final class Job {
void execute() {
getListener().onEvent(item, 1);
}
}
"""
TYPESCRIPT_SOURCE = """export function handle(client: Client) {
client.send(payload);
log("done");
}
"""
PYTHON_SOURCE = """def handle(request):
store.save(request.body)
audit(request, flag=True)
log("done")
"""
def test_php_member_call_retains_receiver_and_arity() -> None:
calls = {call.name: call for call in _calls("controller.php", PHP_SOURCE, "php")}
helper = calls["helper"]
assert helper.receiver == "$this"
assert helper.arity == 2
def test_php_scoped_call_names_the_class_scope_as_receiver() -> None:
calls = {call.name: call for call in _calls("controller.php", PHP_SOURCE, "php")}
assert calls["staticHelper"].receiver == "self"
assert calls["staticHelper"].arity == 1
def test_php_bare_function_call_has_no_receiver() -> None:
calls = {call.name: call for call in _calls("controller.php", PHP_SOURCE, "php")}
assert calls["plain"].receiver is None
assert calls["plain"].arity == 1
def test_java_method_invocation_retains_receiver_expression_and_arity() -> None:
calls = {call.name: call for call in _calls("Job.java", JAVA_SOURCE, "java")}
event = calls["onEvent"]
assert event.receiver == "getListener()"
assert event.arity == 2
def test_typescript_member_call_retains_receiver_and_arity() -> None:
calls = {call.name: call for call in _calls("handle.ts", TYPESCRIPT_SOURCE, "typescript")}
send = calls["client.send"]
assert send.receiver == "client"
assert send.arity == 1
assert calls["log"].receiver is None
def test_python_attribute_call_retains_receiver_and_arity() -> None:
calls = {call.name: call for call in _calls("handle.py", PYTHON_SOURCE, "python")}
save = calls["store.save"]
assert save.receiver == "store"
assert save.arity == 1
def test_python_keyword_argument_counts_toward_arity() -> None:
calls = {call.name: call for call in _calls("handle.py", PYTHON_SOURCE, "python")}
assert calls["audit"].receiver is None
assert calls["audit"].arity == 2
def test_python_bare_call_has_no_receiver() -> None:
calls = {call.name: call for call in _calls("handle.py", PYTHON_SOURCE, "python")}
assert calls["log"].receiver is None
assert calls["log"].arity == 1
def test_existing_positional_construction_stays_valid_with_default_evidence() -> None:
call = CallSite("perform", 7, ("x",), (False,), (("x",),), False, 3)
assert call.receiver is None
assert call.arity is None
def test_receiver_capture_survives_whitespaced_member_syntax() -> None:
source = "export function go(c: Client) {\n c . flush ( );\n}\n"
calls = {call.name: call for call in _calls("go.ts", source, "typescript")}
flush = calls["c . flush"]
assert flush.receiver == "c"
assert flush.arity == 0