feat(objc): dot-syntax property accesses and @selector() call edges (#1475, #1543)

`self.product.name` dot-syntax now emits an `accesses` edge and
`@selector(method)` emits a `calls` edge, both resolved only to an
unambiguous in-scope definition (a sibling method of the same class for
dot-syntax; exactly one method by exact selector name for @selector) so
no false-edge fan-out occurs when multiple classes share a name.

Hardened over the original PR: resolution now matches the method node id
EXACTLY (a method id is _make_id(container, name)) rather than by
`endswith` suffix. The substring match would mis-resolve `self.name` to a
sibling `-surname` (false positive) and, when a substring-colliding
sibling existed, suppress the correct edge (false negative); exact
matching fixes both. Adds substring-collision regression tests
(`-name`/`-surname`, `-doThing`/`-reallyDoThing`).

Completes the #1475 ObjC follow-ups (Bug 5 dot-syntax accesses, Bug 6b
@selector target-action).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
guy oron
2026-06-30 09:54:28 +01:00
committed by safishamsi
co-authored by Claude Opus 4.8
parent 1801da0634
commit 0792b419fc
2 changed files with 178 additions and 3 deletions
+48 -3
View File
@@ -9959,7 +9959,7 @@ def extract_objc(path: Path) -> dict:
nodes: list[dict] = []
edges: list[dict] = []
seen_ids: set[str] = set()
method_bodies: list[tuple[str, Any]] = []
method_bodies: list[tuple[str, Any, str]] = []
def add_node(nid: str, label: str, line: int) -> None:
if nid not in seen_ids:
@@ -10173,7 +10173,7 @@ def extract_objc(path: Path) -> dict:
add_node(method_nid, f"{prefix}{method_name}", line)
add_edge(container, method_nid, "method", line)
if t == "method_definition":
method_bodies.append((method_nid, node))
method_bodies.append((method_nid, node, container))
return
for child in node.children:
@@ -10183,8 +10183,13 @@ def extract_objc(path: Path) -> dict:
# Second pass: resolve calls inside method bodies
all_method_nids = {n["id"] for n in nodes if n["id"] != file_nid}
class_method_nids: dict[str, set[str]] = {}
for m_nid, _, container_nid in method_bodies:
class_method_nids.setdefault(container_nid, set()).add(m_nid)
seen_calls: set[tuple[str, str]] = set()
for caller_nid, body_node in method_bodies:
for caller_nid, body_node, container_nid in method_bodies:
sibling_nids = class_method_nids.get(container_nid, set())
def walk_calls(n) -> None:
if n.type == "message_expression":
# `[[Foo alloc] init]` is a message_expression whose method is the
@@ -10228,6 +10233,46 @@ def extract_objc(path: Path) -> dict:
seen_calls.add(pair)
add_edge(caller_nid, candidate, "calls", n.start_point[0] + 1,
confidence="EXTRACTED", weight=1.0, context="call")
elif n.type == "field_expression":
# self.name / self.product.name — dot-syntax sugar for [self name].
# Resolve to a sibling method of the SAME class, matched by EXACT
# node id (a method id is _make_id(container, name)). A suffix
# substring match would mis-resolve self.name -> -surname and would
# let a substring-colliding sibling (-surname) suppress the real
# -name edge, so it must be an exact match (#1475).
for child in n.children:
if child.type == "field_identifier":
field_name = _read(child)
target = _make_id(container_nid, field_name)
if target in sibling_nids and target != caller_nid:
pair = (caller_nid, target)
if pair not in seen_calls:
seen_calls.add(pair)
add_edge(caller_nid, target, "accesses",
n.start_point[0] + 1,
confidence="EXTRACTED", weight=1.0)
elif n.type == "selector_expression":
# @selector(doSomething:withParam:) — compile-time method ref.
# Match the selector name EXACTLY (a method id is
# _make_id(container, name)) against every class's methods, and emit
# only when exactly one method matches, to avoid ambiguous fan-out.
# Exact match (not a suffix) keeps -doThing distinct from
# -reallyDoThing (#1475).
sel_parts = [_read(c) for c in n.children if c.type == "identifier"]
sel_name = "".join(sel_parts)
if sel_name:
matches = sorted({
m for m, _, cont in method_bodies
if m == _make_id(cont, sel_name) and m != caller_nid
})
if len(matches) == 1:
pair = (caller_nid, matches[0])
if pair not in seen_calls:
seen_calls.add(pair)
add_edge(caller_nid, matches[0], "calls",
n.start_point[0] + 1,
confidence="EXTRACTED", weight=1.0,
context="call")
for child in n.children:
walk_calls(child)
walk_calls(body_node)
+130
View File
@@ -1214,6 +1214,136 @@ def test_objc_alloc_init_unknown_class_no_resolved_edge(tmp_path):
assert e["target"] not in sourced_ids, f"unexpected resolved ref: {e}"
def test_objc_dot_syntax_property_accesses_edge(tmp_path):
"""self.name dot-syntax resolves to an accesses edge within the same class."""
p = tmp_path / "Dog.m"
p.write_text(
"@implementation Dog\n"
"- (NSString *)name { return @\"Rex\"; }\n"
"- (void)greet { NSLog(@\"%@\", self.name); }\n"
"@end\n"
)
r = extract_objc(p)
accesses = [(e["source"], e["target"]) for e in r["edges"]
if e["relation"] == "accesses"]
nid2label = {n["id"]: n["label"] for n in r["nodes"]}
assert len(accesses) == 1
assert nid2label[accesses[0][1]] == "-name"
def test_objc_dot_syntax_no_fanout_two_same_named_properties(tmp_path):
"""Two classes each declaring -name: self.name in A must NOT fan out to B's -name."""
p = tmp_path / "AB.m"
p.write_text(
"@implementation A\n"
"- (NSString *)name { return @\"A\"; }\n"
"- (void)show { NSLog(@\"%@\", self.name); }\n"
"@end\n"
"@implementation B\n"
"- (NSString *)name { return @\"B\"; }\n"
"- (void)show { NSLog(@\"%@\", self.name); }\n"
"@end\n"
)
r = extract_objc(p)
accesses = [e for e in r["edges"] if e["relation"] == "accesses"]
assert len(accesses) == 2, f"expected 2 scoped accesses, got {len(accesses)}: {accesses}"
nid2label = {n["id"]: n["label"] for n in r["nodes"]}
for e in accesses:
src_label = nid2label[e["source"]]
tgt_label = nid2label[e["target"]]
assert src_label == "-show" and tgt_label == "-name"
def test_objc_dot_syntax_unresolvable_property_zero_edges(tmp_path):
"""Accessing a property not defined in the current class produces zero accesses edges."""
p = tmp_path / "X.m"
p.write_text(
"@implementation X\n"
"- (void)run { NSLog(@\"%@\", self.missing); }\n"
"@end\n"
)
r = extract_objc(p)
accesses = [e for e in r["edges"] if e["relation"] == "accesses"]
assert len(accesses) == 0
def test_objc_selector_expression_calls_edge(tmp_path):
"""@selector(uniqueMethod) with exactly one match produces a calls edge."""
p = tmp_path / "Sched.m"
p.write_text(
"@implementation Sched\n"
"- (void)fetch { }\n"
"- (void)schedule { [self performSelector:@selector(fetch)]; }\n"
"@end\n"
)
r = extract_objc(p)
nid2label = {n["id"]: n["label"] for n in r["nodes"]}
sel_calls = [(nid2label.get(e["source"]), nid2label.get(e["target"]))
for e in r["edges"]
if e["relation"] == "calls" and e.get("context") == "call"]
assert ("-schedule", "-fetch") in sel_calls
def test_objc_selector_no_fanout_two_same_named_methods(tmp_path):
"""@selector(doThing) with two doThing methods must emit zero calls edges."""
p = tmp_path / "Dual.m"
p.write_text(
"@implementation A\n"
"- (void)doThing { }\n"
"- (void)run { [self performSelector:@selector(doThing)]; }\n"
"@end\n"
"@implementation B\n"
"- (void)doThing { }\n"
"@end\n"
)
r = extract_objc(p)
nid2label = {n["id"]: n["label"] for n in r["nodes"]}
sel_edges = [e for e in r["edges"]
if e["relation"] == "calls"
and nid2label.get(e["target"], "").endswith("doThing")]
assert len(sel_edges) == 0, f"expected 0 selector edges with ambiguous name, got {sel_edges}"
def test_objc_dot_syntax_substring_sibling_exact_match(tmp_path):
"""A substring-colliding sibling must neither be falsely matched nor suppress
the real match: `self.name` with both `-name` and `-surname` present resolves
to `-name` ONLY (exact id, not a `endswith` suffix) (#1475)."""
p = tmp_path / "Person.m"
p.write_text(
"@implementation Person\n"
"- (NSString *)name { return @\"n\"; }\n"
"- (NSString *)surname { return @\"s\"; }\n"
"- (void)show { NSLog(@\"%@\", self.name); }\n"
"@end\n"
)
r = extract_objc(p)
nid2label = {n["id"]: n["label"] for n in r["nodes"]}
accesses = [(nid2label.get(e["source"]), nid2label.get(e["target"]))
for e in r["edges"] if e["relation"] == "accesses"]
assert ("-show", "-name") in accesses, f"self.name must resolve to -name: {accesses}"
assert ("-show", "-surname") not in accesses, f"self.name must NOT match -surname: {accesses}"
def test_objc_selector_substring_method_exact_match(tmp_path):
"""@selector(doThing) must resolve to `-doThing` exactly, not be suppressed by
a substring-colliding `-reallyDoThing` (exact match, not suffix) (#1475)."""
p = tmp_path / "Worker.m"
p.write_text(
"@implementation Worker\n"
"- (void)doThing { }\n"
"- (void)reallyDoThing { }\n"
"- (void)run { [self performSelector:@selector(doThing)]; }\n"
"@end\n"
)
r = extract_objc(p)
nid2label = {n["id"]: n["label"] for n in r["nodes"]}
sel_calls = [(nid2label.get(e["source"]), nid2label.get(e["target"]))
for e in r["edges"]
if e["relation"] == "calls" and e.get("context") == "call"]
assert ("-run", "-doThing") in sel_calls, f"@selector(doThing) must resolve to -doThing: {sel_calls}"
assert ("-run", "-reallyDoThing") not in sel_calls
# ---------------------------------------------------------------------------
# Go
# ---------------------------------------------------------------------------