diff --git a/graphify/extractors/objc.py b/graphify/extractors/objc.py index 8b2820f46..5c9568f16 100644 --- a/graphify/extractors/objc.py +++ b/graphify/extractors/objc.py @@ -12,6 +12,14 @@ # `C++Bridge.h` and `Foo+.h` are left intact. _OBJC_STEM_PART = re.compile(r"[A-Za-z_][A-Za-z0-9_]*") +# Declaration markers, matching what the generic engine puts on every other +# language's definitions (#2438): `_callable` says "a real callable, not a +# same-named data symbol", and `_callable_class` narrows that to a type, which is +# callable only through a constructor. Passes that index declarations gate on +# these, so a node without them is invisible to them. +_CALLABLE = ("_callable",) +_CALLABLE_CLASS = ("_callable", "_callable_class") + def _objc_category_base_stem(stem: str) -> str: """Strip an ObjC category/extension suffix from a file stem (``Foo+Cat`` -> ``Foo``). @@ -115,11 +123,14 @@ def extract_objc(path: Path) -> dict: # same (class, field) tombstones the entry (None) — drop, don't guess. objc_field_types: dict[str, dict[str, str | None]] = {} - def add_node(nid: str, label: str, line: int) -> None: + def add_node(nid: str, label: str, line: int, markers: tuple[str, ...] = ()) -> None: if nid not in seen_ids: seen_ids.add(nid) - nodes.append({"id": nid, "label": label, "file_type": "code", - "source_file": str_path, "source_location": f"L{line}"}) + node = {"id": nid, "label": label, "file_type": "code", + "source_file": str_path, "source_location": f"L{line}"} + for marker in markers: + node[marker] = True + nodes.append(node) def add_edge(src: str, tgt: str, relation: str, line: int, confidence: str = "EXTRACTED", weight: float = 1.0, @@ -284,7 +295,7 @@ def walk(node, parent_nid: str | None = None) -> None: # produced fine when the members lived in `Foo.h` (#1556). cls_stem = _objc_category_base_stem(stem) if _objc_is_category(node) else stem cls_nid = _make_id(cls_stem, name) - add_node(cls_nid, name, line) + add_node(cls_nid, name, line, _CALLABLE_CLASS) add_edge(file_nid, cls_nid, "contains", line) # superclass is second identifier after ':' colon_seen = False @@ -349,7 +360,7 @@ def walk(node, parent_nid: str | None = None) -> None: impl_stem = _objc_category_base_stem(stem) if _objc_is_category(node) else stem impl_nid = _make_id(impl_stem, name) if impl_nid not in seen_ids: - add_node(impl_nid, name, line) + add_node(impl_nid, name, line, _CALLABLE_CLASS) add_edge(file_nid, impl_nid, "contains", line) for child in node.children: if child.type == "instance_variables": @@ -367,7 +378,9 @@ def walk(node, parent_nid: str | None = None) -> None: break if name: proto_nid = _make_id(stem, name) - add_node(proto_nid, f"<{name}>", line) + # A protocol is a type declaration like any other interface, and + # the engine marks a Java/C# interface the same way. + add_node(proto_nid, f"<{name}>", line, _CALLABLE_CLASS) add_edge(file_nid, proto_nid, "contains", line) # Adopted protocols: `@protocol Derived `. These # nest under a protocol_reference_list node (distinct from the @@ -402,7 +415,7 @@ def walk(node, parent_nid: str | None = None) -> None: method_name = "".join(parts) if parts else None if method_name: method_nid = _make_id(container, method_name) - add_node(method_nid, f"{prefix}{method_name}", line) + add_node(method_nid, f"{prefix}{method_name}", line, _CALLABLE) add_edge(container, method_nid, "method", line) if t == "method_definition": method_bodies.append((method_nid, node, container)) diff --git a/tests/test_objc_callable_markers.py b/tests/test_objc_callable_markers.py new file mode 100644 index 000000000..eff87efbc --- /dev/null +++ b/tests/test_objc_callable_markers.py @@ -0,0 +1,106 @@ +"""ObjC declarations carry the `_callable` / `_callable_class` markers. + +Every other extractor stamps its definitions with `_callable` — "a real callable, +not a same-named data symbol" (#2438) — and narrows a type to `_callable_class`, +callable only through a constructor (#2137). The generic engine does it for all of +them in one place (`extractors/engine.py`, `callable_def_nids` / +`callable_class_nids`). + +The ObjC extractor builds its nodes by hand and set neither, so an ObjC class was +invisible to every pass that indexes declarations by those markers, and an ObjC +method could never be told apart from a data symbol of the same name. This pins the +markers onto the four node kinds ObjC produces, including the two that must stay +unmarked. +""" +from __future__ import annotations + +from pathlib import Path + +from graphify.extract import extract + +GREETER_H = ( + "@interface Greeter : NSObject\n" + "- (void)greet;\n" + "+ (instancetype)shared;\n" + "@end\n" +) +GREETER_M = ( + "#import \"Greeter.h\"\n" + "@implementation Greeter\n" + "- (void)greet {}\n" + "@end\n" +) + + +def _extract(tmp_path: Path, files: dict[str, str]) -> dict: + paths = [] + for name, body in files.items(): + path = tmp_path / name + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(body, encoding="utf-8") + paths.append(path) + return extract(paths, cache_root=tmp_path / "graphify-out") + + +def _node(result: dict, label: str) -> dict: + matches = [n for n in result["nodes"] if n.get("label") == label] + assert len(matches) == 1, [n.get("label") for n in result["nodes"]] + return matches[0] + + +def test_a_class_is_marked_as_a_type(tmp_path: Path): + result = _extract(tmp_path, {"Greeter.h": GREETER_H}) + greeter = _node(result, "Greeter") + assert greeter.get("_callable") is True + assert greeter.get("_callable_class") is True + + +def test_an_implementation_only_class_is_marked_too(tmp_path: Path): + # A class whose `@interface` is not in this corpus is still a declaration. + result = _extract(tmp_path, {"Greeter.m": "@implementation Greeter\n- (void)greet {}\n@end\n"}) + greeter = _node(result, "Greeter") + assert greeter.get("_callable") is True + assert greeter.get("_callable_class") is True + + +def test_a_header_and_impl_pair_keeps_the_markers_after_folding(tmp_path: Path): + # `_merge_decl_def_classes` folds the `.h`/`.m` pair into one node; the markers + # have to be on whichever node survives. + result = _extract(tmp_path, {"Greeter.h": GREETER_H, "Greeter.m": GREETER_M}) + greeter = _node(result, "Greeter") + assert greeter.get("_callable") is True + assert greeter.get("_callable_class") is True + + +def test_a_method_is_callable_but_is_not_a_type(tmp_path: Path): + result = _extract(tmp_path, {"Greeter.h": GREETER_H}) + for label in ("-greet", "+shared"): + method = _node(result, label) + assert method.get("_callable") is True, label + assert "_callable_class" not in method, label + + +def test_a_protocol_is_marked_like_any_other_interface(tmp_path: Path): + # A Java or C# interface gets `_callable_class` from the generic engine, and a + # protocol is the same kind of declaration. + result = _extract(tmp_path, {"Greeting.h": "@protocol Greeting\n- (void)greet;\n@end\n"}) + protocol = _node(result, "") + assert protocol.get("_callable") is True + assert protocol.get("_callable_class") is True + + +def test_a_dangling_reference_is_not_marked(tmp_path: Path): + # `NSObject` is a stub minted for a name this corpus never declares, so it has + # no source file and no declaration behind it. + result = _extract(tmp_path, {"Greeter.h": GREETER_H}) + stub = _node(result, "NSObject") + assert not stub.get("source_file") + assert "_callable" not in stub + assert "_callable_class" not in stub + + +def test_the_file_node_is_not_marked(tmp_path: Path): + result = _extract(tmp_path, {"Greeter.h": GREETER_H}) + file_node = _node(result, "Greeter.h") + assert "_callable" not in file_node + assert "_callable_class" not in file_node