diff --git a/graphify/extract.py b/graphify/extract.py index 7d45a6329..88a503ad3 100644 --- a/graphify/extract.py +++ b/graphify/extract.py @@ -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: @@ -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: @@ -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 @@ -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) diff --git a/tests/test_languages.py b/tests/test_languages.py index a7d3ea9aa..2e7f79286 100644 --- a/tests/test_languages.py +++ b/tests/test_languages.py @@ -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 # ---------------------------------------------------------------------------