Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 44 additions & 3 deletions graphify/extract.py
Original file line number Diff line number Diff line change
Expand Up @@ -9723,7 +9723,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:
Expand Down Expand Up @@ -9937,7 +9937,7 @@ def walk(node, parent_nid: str | None = None) -> None:
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:
Expand All @@ -9947,8 +9947,13 @@ def walk(node, parent_nid: str | None = None) -> None:

# 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
Expand Down Expand Up @@ -9992,6 +9997,42 @@ def walk_calls(n) -> None:
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].
# Restrict resolution to sibling methods within the same class to
# prevent fan-out when multiple classes declare a property with the
# same name (#1475).
for child in n.children:
if child.type == "field_identifier":
field_name = _read(child)
needle = _make_id("", field_name).lstrip("_")
matches = [c for c in sibling_nids
if c.endswith(needle) and c != 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], "accesses",
n.start_point[0] + 1,
confidence="EXTRACTED", weight=1.0)
elif n.type == "selector_expression":
# @selector(doSomething:withParam:) — compile-time method ref.
# Only emit when exactly one method matches across the entire file
# to avoid ambiguous fan-out (#1475).
sel_parts = [_read(c) for c in n.children if c.type == "identifier"]
sel_name = "".join(sel_parts)
if sel_name:
needle = _make_id("", sel_name).lstrip("_")
matches = [c for c in all_method_nids
if c.endswith(needle) and c != 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)
Expand Down
90 changes: 90 additions & 0 deletions tests/test_languages.py
Original file line number Diff line number Diff line change
Expand Up @@ -1214,6 +1214,96 @@ 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}"


# ---------------------------------------------------------------------------
# Go
# ---------------------------------------------------------------------------
Expand Down
Loading