From dc2832564ae623d0ab558381e7dad4cd17c951b6 Mon Sep 17 00:00:00 2001 From: Marius Ciepluch <11855163+norandom@users.noreply.github.com> Date: Sat, 31 Oct 2026 07:10:36 +0100 Subject: [PATCH] feat(detection-core): unique-declaration member-call binding (8.2 final gated round step 1-2) x.method() with a variable receiver binds complete iff exactly one same-name method declaration exists repository-wide; collisions stay a finite candidate set (receiver_type_unknown) and module-level functions never bind. Authorized by maintainer strategy-review 2026-10-31; gated by dev-pool FA non-regression. --- .../semantic/obligations/resolution.py | 8 +++ tests/test_call_resolution.py | 61 +++++++++++++++++++ tests/test_obligation_summaries.py | 4 ++ 3 files changed, 73 insertions(+) diff --git a/src/openultrasast/semantic/obligations/resolution.py b/src/openultrasast/semantic/obligations/resolution.py index e6d29d0..6afedd6 100644 --- a/src/openultrasast/semantic/obligations/resolution.py +++ b/src/openultrasast/semantic/obligations/resolution.py @@ -239,7 +239,15 @@ def _type_receiver_targets(same_name: tuple[SymbolId, ...], type_name: str, arit def _variable_receiver_targets(same_name: tuple[SymbolId, ...], arity: int | None) -> CallTargetSet: + # A variable receiver's type is unknown, but when exactly ONE method with the + # called name exists repository-wide, no second repository-defined target can + # exist, so the call binds complete (unique-declaration member-call binding). + # Two or more same-name declarations are a collision: they stay a finite + # candidate set, never a bare-name merge. A same-name module-level function is + # not a member-call target, so it neither binds nor collides here. methods = tuple(symbol for symbol in same_name if symbol.enclosing_type is not None) + if len(methods) == 1: + return CallTargetSet(targets=methods, completeness=Completeness(is_complete=True)) narrowed = _arity_narrow(methods, arity) if not narrowed: return CallTargetSet(targets=(), completeness=Completeness(is_complete=False, reasons=("resolution:no_candidate",))) diff --git a/tests/test_call_resolution.py b/tests/test_call_resolution.py index 580e54d..829d9ec 100644 --- a/tests/test_call_resolution.py +++ b/tests/test_call_resolution.py @@ -135,6 +135,47 @@ class TestResolveCall: assert len(candidates.targets) == 2 assert candidates.completeness.reasons + def test_unique_declaration_member_call_binds_complete(self) -> None: + index = index_of( + ( + "src/a/Store.java", + """public class Store { + public void persist(String item) { } +} +""", + "java", + ) + ) + call = _call(name="persist", line=1, receiver="store", arity=1) + target_set = resolve_call(call, module="src/a/Use.java", index=index) + assert target_set.targets == (SymbolId("src/a/Store.java", "Store", "persist", ("item",)),) + assert target_set.completeness.reasons == () + + def test_member_call_collision_across_modules_stays_incomplete(self) -> None: + index = index_of( + ("src/a/One.java", "public class Store { public void persist(String item) { } }", "java"), + ("src/b/Two.java", "public class Vault { public void persist(String item) { } }", "java"), + ) + call = _call(name="persist", line=1, receiver="obj", arity=1) + target_set = resolve_call(call, module="src/a/One.java", index=index) + assert len(target_set.targets) == 2 + assert target_set.completeness.reasons == ("resolution:receiver_type_unknown",) + + def test_member_call_never_binds_module_level_function(self) -> None: + index = index_of( + ( + "src/svc.py", + """def persist(item): + pass +""", + "python", + ) + ) + call = _call(name="persist", line=3, receiver="obj", arity=1) + target_set = resolve_call(call, module="src/svc.py", index=index) + assert target_set.targets == () + assert target_set.completeness.reasons + def test_no_candidate_is_unresolved_with_reason(self) -> None: index = index_of(JAVA_ONE_CLASS) call = _call(name="missing", line=3, receiver="helper", arity=1) @@ -233,6 +274,26 @@ class TestBindCalls: assert bindings.exact_callers(target) == () assert bindings.counts["incomplete_calls"] == 1 + def test_unique_declaration_member_call_counts_complete(self) -> None: + index = index_of( + ( + "src/a/Store.java", + """public class Store { + public void persist(String item) { } +} +""", + "java", + ) + ) + bindings = bind_calls( + functions_by_module={"src/a/Use.java": (_ir_stub(calls=(_call(name="persist", line=2, receiver="store", arity=1),)),)}, + index=index, + ) + target = SymbolId("src/a/Store.java", "Store", "persist", ("item",)) + assert bindings.exact_callers(target) == (CallerBinding(module="src/a/Use.java", function="caller", line=2, exact=True),) + assert bindings.counts["complete_calls"] == 1 + assert bindings.counts["incomplete_calls"] == 0 + def _ir_stub(calls: tuple[CallSite, ...], name: str = "caller") -> FunctionIR: return FunctionIR(name=name, params=(), start_line=1, end_line=9, binds=(), calls=calls) diff --git a/tests/test_obligation_summaries.py b/tests/test_obligation_summaries.py index 2ac74c9..2b2f77d 100644 --- a/tests/test_obligation_summaries.py +++ b/tests/test_obligation_summaries.py @@ -540,6 +540,10 @@ JAVA_AUDITOR = """class Auditor { public void check() { } } +class Gate { + public void check() { + } +} """