mirror of
https://github.com/norandom/OpenUltraSAST.git
synced 2026-11-13 01:48:17 +00:00
feat(access-control-prover): 8.1 capture call receiver and arity evidence in the semantic IR
This commit is contained in:
@@ -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_
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user