diff --git a/.kiro/specs/access-control-prover/tasks.md b/.kiro/specs/access-control-prover/tasks.md index 376ec94..02d89e1 100644 --- a/.kiro/specs/access-control-prover/tasks.md +++ b/.kiro/specs/access-control-prover/tasks.md @@ -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_ diff --git a/src/openultrasast/semantic/cst.py b/src/openultrasast/semantic/cst.py index e8e92e5..4e9ad34 100644 --- a/src/openultrasast/semantic/cst.py +++ b/src/openultrasast/semantic/cst.py @@ -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") diff --git a/src/openultrasast/semantic/ir.py b/src/openultrasast/semantic/ir.py index 2aacb35..4f0d097 100644 --- a/src/openultrasast/semantic/ir.py +++ b/src/openultrasast/semantic/ir.py @@ -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) diff --git a/tests/test_semantic_call_evidence.py b/tests/test_semantic_call_evidence.py new file mode 100644 index 0000000..83df30f --- /dev/null +++ b/tests/test_semantic_call_evidence.py @@ -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 = """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