diff --git a/graphify/extractors/objc.py b/graphify/extractors/objc.py index 9f978a50..8b2820f4 100644 --- a/graphify/extractors/objc.py +++ b/graphify/extractors/objc.py @@ -6,6 +6,40 @@ from graphify.extractors.engine import _cpp_declarator_name, _semantic_reference from graphify.extractors.resolution import _resolve_c_include_path from pathlib import Path from typing import Any +import re + +# A category stem splits only when both halves look like plain ObjC identifiers, so +# `C++Bridge.h` and `Foo+.h` are left intact. +_OBJC_STEM_PART = re.compile(r"[A-Za-z_][A-Za-z0-9_]*") + + +def _objc_category_base_stem(stem: str) -> str: + """Strip an ObjC category/extension suffix from a file stem (``Foo+Cat`` -> ``Foo``). + + A category (``@interface Foo (Cat)``) or class extension (``@interface Foo ()``) + declares members of an EXISTING class, so its class node must key to the same id + as ``Foo.h``'s ``@interface Foo`` instead of minting a second class node (#1556). + Only the final path segment is considered, and only a well-formed + ``Name+Suffix`` pair is split. + + Mirrors ``_decldef_class_stem``, which already compares sibling header/impl files + by the stem before ``+``. + """ + head, sep, tail = stem.rpartition("/") + base, plus, suffix = tail.partition("+") + if not plus or not _OBJC_STEM_PART.fullmatch(base) or not _OBJC_STEM_PART.fullmatch(suffix): + return stem + return f"{head}{sep}{base}" + + +def _objc_is_category(node) -> bool: + """True for ``@interface/@implementation Foo (Cat)`` and ``Foo ()``. + + The grammar emits the parentheses as anonymous children only for a category or + class extension; a generic class (``@interface Box``) uses + ``parameterized_arguments`` instead, so it is not matched. + """ + return any(c.type == "(" for c in node.children) def _objc_local_var_types(body_node, source: bytes, table: dict[str, str]) -> None: @@ -74,6 +108,12 @@ def extract_objc(path: Path) -> dict: # per-file `var -> ClassName` table from `Foo *f = ...;` local declarations. raw_calls: list[dict] = [] objc_type_table: dict[str, str] = {} + # #1556: per-CLASS `field -> ClassName` tables from `@property Bar *bar;` and + # ivar `Bar *_bar;` declarations, keyed by the class nid the .h/.m pair share + # (preserved by _merge_decl_def_classes), so the cross-file resolver can type a + # `[self.bar doIt]` / `[_bar doIt]` receiver. A conflicting redeclaration of the + # 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: if nid not in seen_ids: @@ -137,6 +177,55 @@ def extract_objc(path: Path) -> dict: }) return nid + def _field_decl_entry(sd) -> tuple[str, str] | None: + """``(field, TypeName)`` from a property/ivar ``struct_declaration``, else None. + + Precision gates (#1556): the type must be a BARE capitalized + ``type_identifier`` DIRECTLY under the struct_declaration — a + ``generic_specifier`` (``NSArray``) or ``typedefed_specifier`` + (``id

``) wraps its type_identifier, so both are naturally excluded and + never type a receiver — and there must be exactly one ``struct_declarator`` + whose name unwraps via the C++ declarator unwrapper (identical grammar). + """ + type_ids = [s for s in sd.children if s.type == "type_identifier"] + declarators = [s for s in sd.children if s.type == "struct_declarator"] + if len(type_ids) != 1 or len(declarators) != 1: + return None + type_name = _read(type_ids[0]).strip() + if not type_name or not type_name[:1].isupper(): + return None + # struct_declarator wraps ONE pointer_declarator (`*bar`) or identifier + # (`bar`); anything else (bitfields, arrays) has more children -> bail. + inner = declarators[0].children + if len(inner) != 1: + return None + field = _cpp_declarator_name(inner[0], source) + if not field: + return None + return field, type_name + + def _record_field_type(cls_nid: str, field: str, type_name: str) -> None: + table = objc_field_types.setdefault(cls_nid, {}) + if field in table: + if table[field] != type_name: + table[field] = None # conflicting redeclaration -> drop, don't guess + else: + table[field] = type_name + + def _collect_instance_variables(ivars_node, cls_nid: str) -> None: + """Record field types from an ``instance_variables`` block (``{ Bar *_b; }``) + under an @interface or @implementation. No nodes/edges are emitted here; + the table only feeds receiver typing in the cross-file resolver (#1556). + """ + for iv in ivars_node.children: + if iv.type != "instance_variable": + continue + for sd in iv.children: + if sd.type == "struct_declaration": + entry = _field_decl_entry(sd) + if entry is not None: + _record_field_type(cls_nid, *entry) + def walk(node, parent_nid: str | None = None) -> None: t = node.type line = node.start_point[0] + 1 @@ -187,7 +276,14 @@ def extract_objc(path: Path) -> dict: walk(child, parent_nid) return name = _read(identifiers[0]) - cls_nid = _make_id(stem, name) + # A category / class extension extends an existing class, so key its + # class node off the BASE stem (`Foo+Cat.h` -> `Foo`). Without this, + # `Foo+Cat.h` minted a SECOND node labelled `Foo`, which made every + # `[Foo ...]` receiver ambiguous and tripped the resolver's + # single-definition god-node guard — destroying edges the same corpus + # 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_edge(file_nid, cls_nid, "contains", line) # superclass is second identifier after ':' @@ -228,6 +324,13 @@ def extract_objc(path: Path) -> dict: type_nid = ensure_named_node(tname, prop_line) edges.append(_semantic_reference_edge( cls_nid, type_nid, "field", str_path, prop_line)) + # #1556: a bare capitalized property type also types the + # `[self.field msg]` receiver in the cross-file resolver. + entry = _field_decl_entry(sub) + if entry is not None: + _record_field_type(cls_nid, *entry) + elif child.type == "instance_variables": + _collect_instance_variables(child, cls_nid) elif child.type == "method_declaration": walk(child, cls_nid) return @@ -243,12 +346,15 @@ def extract_objc(path: Path) -> dict: for child in node.children: walk(child, parent_nid) return - impl_nid = _make_id(stem, name) + 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_edge(file_nid, impl_nid, "contains", line) for child in node.children: - if child.type == "implementation_definition": + if child.type == "instance_variables": + _collect_instance_variables(child, impl_nid) + elif child.type == "implementation_definition": for sub in child.children: walk(sub, impl_nid) return @@ -379,6 +485,28 @@ def extract_objc(path: Path) -> dict: "receiver": _read(recv), "lang": "objc", }) + elif recv is not None and recv.type == "field_expression": + # `[self.bar doIt]`: capture ONLY the exact `self.` + # shape and stamp the BARE field name, typed via the class's + # property/ivar table. Anything else (`obj.prop`, chains, + # `Foo.shared`) stays dropped: passing the dotted text + # through would let a capitalized `Foo.shared` enter the + # explicit-class arm, where _key strips the dot and collides + # with a real class `FooShared` — a fabricated edge (#1556). + kids = recv.children + if (len(kids) == 3 and kids[0].type == "identifier" + and _read(kids[0]) == "self" and kids[1].type == "." + and kids[2].type == "field_identifier"): + raw_calls.append({ + "caller_nid": caller_nid, + "callee": method_name, + "is_member_call": True, + "source_file": str_path, + "source_location": f"L{n.start_point[0] + 1}", + "receiver": _read(kids[2]), + "receiver_kind": "self_field", + "lang": "objc", + }) 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 @@ -427,4 +555,12 @@ def extract_objc(path: Path) -> dict: "input_tokens": 0, "output_tokens": 0} if objc_type_table: result["objc_type_table"] = {"path": str_path, "table": objc_type_table} + # Drop tombstoned (conflicting) entries and empty tables before export. + field_tables = { + cls: {f: t for f, t in tbl.items() if t} + for cls, tbl in objc_field_types.items() + } + field_tables = {cls: tbl for cls, tbl in field_tables.items() if tbl} + if field_tables: + result["objc_field_types"] = {"path": str_path, "tables": field_tables} return result diff --git a/graphify/extractors/resolution.py b/graphify/extractors/resolution.py index b5edd264..38063386 100644 --- a/graphify/extractors/resolution.py +++ b/graphify/extractors/resolution.py @@ -2056,6 +2056,11 @@ def _decldef_class_stem(source_file: str) -> tuple[str, str] | None: return None return (str(p.parent), stem) +def _source_stem(node: dict) -> str: + """Filename stem of a node's ``source_file`` (``""`` when it has none).""" + return Path(str(node.get("source_file", ""))).stem + + def _merge_decl_def_classes( all_nodes: list[dict], all_edges: list[dict], @@ -2137,10 +2142,22 @@ def _merge_decl_def_classes( headers.append(node) if not ok: continue - # All from one (dir, base_stem) sibling family, with a UNIQUE header. - if len(sibling_keys) != 1 or len(headers) != 1: + # All from one (dir, base_stem) sibling family. Pick the declaring header. + # Usually there is exactly one. An ObjC class whose members are split across + # categories has several (`Foo.h`, `Foo+Cat.h`) — fold those too, keeping the + # BASE header (the stem with no `+`), or the lowest-sorting category header + # when the base class lives outside the corpus (`NSString+Trim.h`). Two + # NON-category headers still bail to disambiguation, as before, so an + # unrelated `Foo.h` / `Foo.hpp` pair is untouched. + if len(sibling_keys) != 1 or not headers: continue - keeper = headers[0] + if len(headers) == 1: + keeper = headers[0] + else: + base_headers = [h for h in headers if "+" not in _source_stem(h)] + if len(base_headers) > 1: + continue + keeper = base_headers[0] if base_headers else min(headers, key=_source_stem) for node in group: if node is not keeper: drop_objs.add(id(node)) diff --git a/tests/test_objc_category_interfaces.py b/tests/test_objc_category_interfaces.py new file mode 100644 index 00000000..7b2cbab6 --- /dev/null +++ b/tests/test_objc_category_interfaces.py @@ -0,0 +1,122 @@ +"""ObjC category / class-extension interfaces must fold into the base class (#1556). + +`@interface Foo (Cat)` in `Foo+Cat.h` declares members of an EXISTING class, but the +extractor keyed its class node off the file stem `Foo+Cat`, minting a SECOND node +labelled `Foo`. Every `[Foo ...]` receiver then had two type-def candidates, tripped +the member-call resolver's single-definition god-node guard, and produced NO edge — +so moving a method from `Foo.h` into a category silently destroyed call edges the +same corpus resolved fine before. Categories are pervasive in real ObjC, so this hit +ordinary code, not an edge case. + +The class node now keys off the base stem, and `_merge_decl_def_classes` folds the +category header into the base header instead of bailing on "more than one header". +""" +from __future__ import annotations + +from pathlib import Path + +from graphify.extract import extract + + +def _write(path: Path, text: str) -> Path: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(text, encoding="utf-8") + return path + + +def _label(result: dict, nid: str) -> str: + for n in result["nodes"]: + if n["id"] == nid: + return n.get("label", "") + return f"?{nid}" + + +def _calls(result: dict): + """{(source_label, target_label, confidence)} over `calls` edges.""" + return { + (_label(result, e["source"]), _label(result, e["target"]), e.get("confidence")) + for e in result["edges"] + if e.get("relation") == "calls" + } + + +def _nodes_labelled(result: dict, label: str): + return [n for n in result["nodes"] if n.get("label") == label] + + +_BASE_H = "@interface Base : NSObject\n@end\n" +_CALLER = ( + '#import "Base.h"\n@interface Caller : NSObject\n- (void)go;\n@end\n', + '#import "Caller.h"\n@implementation Caller\n- (void)go { [Base useIt]; }\n@end\n', +) + + +def test_objc_category_method_is_reachable_from_another_class(tmp_path: Path): + """The headline case: `-useIt` declared in a category still resolves. + + Before, `Base+Extra.h` minted a second `Base` node, so `[Base useIt]` was + ambiguous and emitted nothing. + """ + base = tmp_path / "src" + _write(base / "Base.h", _BASE_H) + _write(base / "Base+Extra.h", + '#import "Base.h"\n@interface Base (Extra)\n- (void)useIt;\n@end\n') + _write(base / "Base+Extra.m", + '#import "Base+Extra.h"\n@implementation Base (Extra)\n- (void)useIt {}\n@end\n') + _write(base / "Caller.h", _CALLER[0]) + _write(base / "Caller.m", _CALLER[1]) + result = extract(sorted(base.glob("*")), cache_root=tmp_path / "cache") + + assert len(_nodes_labelled(result, "Base")) == 1 + assert ("-go", "-useIt", "EXTRACTED") in _calls(result) + + +def test_objc_class_extension_folds_into_the_base_class(tmp_path: Path): + """An anonymous class extension (`@interface Base ()`) folds the same way.""" + base = tmp_path / "src" + _write(base / "Base.h", "@interface Base : NSObject\n- (void)pub;\n@end\n") + _write(base / "Base.m", + '#import "Base.h"\n' + "@interface Base ()\n- (void)priv;\n@end\n" + "@implementation Base\n- (void)pub { [self priv]; }\n- (void)priv {}\n@end\n") + result = extract(sorted(base.glob("*")), cache_root=tmp_path / "cache") + + assert len(_nodes_labelled(result, "Base")) == 1 + assert ("-pub", "-priv", "EXTRACTED") in _calls(result) + + +def test_objc_non_category_interface_in_a_plus_named_file_is_untouched(tmp_path: Path): + """The fold is keyed on the CATEGORY SYNTAX, not on the `+` in the filename. + + `Extra+Helpers.h` declaring a plain `@interface Extra` (no parentheses) must keep + its own stem, so it does not silently merge into an unrelated `Extra.h`. + """ + base = tmp_path / "src" + _write(base / "Extra+Helpers.h", "@interface Helper : NSObject\n- (void)help;\n@end\n") + result = extract(sorted(base.glob("*")), cache_root=tmp_path / "cache") + + helper = _nodes_labelled(result, "Helper") + assert len(helper) == 1 + assert helper[0]["id"].endswith("extra_helpers_helper") + + +def test_objc_same_named_categories_in_different_directories_stay_distinct(tmp_path: Path): + """Two unrelated `Thing` classes in different directories must not merge. + + The id embeds the full directory path, so the base-stem rewrite cannot conflate + them; a `[Thing act]` receiver stays ambiguous and yields no edge (god-node guard). + """ + base = tmp_path / "src" + for d in ("a", "b"): + _write(base / d / "Thing.h", "@interface Thing : NSObject\n@end\n") + _write(base / d / "Thing+Ops.h", + '#import "Thing.h"\n@interface Thing (Ops)\n- (void)act;\n@end\n') + _write(base / d / "Thing+Ops.m", + '#import "Thing+Ops.h"\n@implementation Thing (Ops)\n- (void)act {}\n@end\n') + _write(base / "Use.m", + '#import "a/Thing+Ops.h"\n@implementation Use\n- (void)go { [Thing act]; }\n@end\n') + result = extract(sorted(base.rglob("*.[hm]")), cache_root=tmp_path / "cache") + + assert len(_nodes_labelled(result, "Thing")) == 2 + assert [e for e in result["edges"] + if e.get("relation") == "calls" and _label(result, e["source"]) == "-go"] == [] diff --git a/tests/test_objc_member_calls.py b/tests/test_objc_member_calls.py new file mode 100644 index 00000000..836a3d62 --- /dev/null +++ b/tests/test_objc_member_calls.py @@ -0,0 +1,97 @@ +"""ObjC receiver typing must not treat a ``@protocol`` as a message receiver (#1556). + +The ObjC extractor labels a protocol declaration ````, and the member-call +resolver's ``_key()`` strips the angle brackets — so a protocol and a class of the +same name collapse to one index key. ObjC keeps protocol and class names in separate +namespaces (``@protocol NSObject`` and ``@interface NSObject`` both exist in +Foundation), so same-named pairs are ordinary, and both outcomes were wrong: + +* protocol only in the corpus -> ``[Reload reload]`` bound to the PROTOCOL's method + declaration at confidence 1.0 (a WRONG edge, not just a missing one); +* protocol AND class -> two candidates tripped the single-definition god-node guard, + so a call that should resolve produced NO edge. + +Excluding protocols from the receiver-type index fixes both. Protocols remain valid +`implements` targets; only receiver typing ignores them. +""" +from __future__ import annotations + +from pathlib import Path + +from graphify.extract import extract + + +def _write(path: Path, text: str) -> Path: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(text, encoding="utf-8") + return path + + +def _label(result: dict, nid: str) -> str: + for n in result["nodes"]: + if n["id"] == nid: + return n.get("label", "") + return f"?{nid}" + + +def _edges(result: dict, relation: str): + """{(source_label, target_label, confidence)} for edges of one relation.""" + return { + (_label(result, e["source"]), _label(result, e["target"]), e.get("confidence")) + for e in result["edges"] + if e.get("relation") == relation + } + + +def test_objc_protocol_only_receiver_emits_no_call_edge(tmp_path: Path): + """No class named Reload exists, so `[Reload reload]` is untypable -> ZERO edges. + + The decoy is the protocol's own `-reload` declaration: it must NOT be the target. + """ + base = tmp_path / "src" + _write(base / "Reload.h", + "@protocol Reload \n- (void)reload;\n@end\n") + _write(base / "Use.h", '#import "Reload.h"\n@interface Use : NSObject\n- (void)go;\n@end\n') + _write(base / "Use.m", + '#import "Use.h"\n@implementation Use\n- (void)go { [Reload reload]; }\n@end\n') + result = extract(sorted(base.glob("*")), cache_root=tmp_path / "cache") + + from_go = [e for e in result["edges"] + if e.get("relation") == "calls" and _label(result, e["source"]) == "-go"] + assert from_go == [] + + +def test_objc_class_resolves_past_a_same_named_protocol(tmp_path: Path): + """A protocol and a class may share a name; the class must still resolve. + + `[Locking lock]` resolves to the CLASS's `+lock`, never the protocol's `-lock`. + """ + base = tmp_path / "src" + _write(base / "Locking.h", + "@protocol Locking \n- (void)lock;\n@end\n") + _write(base / "LockingImpl.h", + '#import "Locking.h"\n@interface Locking : NSObject\n+ (void)lock;\n@end\n') + _write(base / "LockingImpl.m", + '#import "LockingImpl.h"\n@implementation Locking\n+ (void)lock {}\n@end\n') + _write(base / "Worker.h", + '#import "LockingImpl.h"\n@interface Worker : NSObject\n- (void)run;\n@end\n') + _write(base / "Worker.m", + '#import "Worker.h"\n@implementation Worker\n- (void)run { [Locking lock]; }\n@end\n') + result = extract(sorted(base.glob("*")), cache_root=tmp_path / "cache") + + calls = _edges(result, "calls") + assert ("-run", "+lock", "EXTRACTED") in calls + assert ("-run", "-lock", "EXTRACTED") not in calls + + +def test_objc_protocol_stays_a_valid_implements_target(tmp_path: Path): + """The exclusion is scoped to receiver typing: adoption edges are unaffected.""" + base = tmp_path / "src" + _write(base / "Reload.h", + "@protocol Reload \n- (void)reload;\n@end\n") + _write(base / "Widget.h", + '#import "Reload.h"\n@interface Widget : NSObject \n- (void)reload;\n@end\n') + result = extract(sorted(base.glob("*")), cache_root=tmp_path / "cache") + + implements = {(s, t) for s, t, _ in _edges(result, "implements")} + assert ("Widget", "") in implements diff --git a/tests/test_objc_property_ivar_receivers.py b/tests/test_objc_property_ivar_receivers.py new file mode 100644 index 00000000..e31632a3 --- /dev/null +++ b/tests/test_objc_property_ivar_receivers.py @@ -0,0 +1,218 @@ +"""ObjC property/ivar receivers must type through the class's field table (#1556). + +`[self.bar doIt]` and `[_ivarBar doIt]` are the ordinary ways an ObjC object talks +to a collaborator held in a `@property` or ivar, but neither ever resolved: the +extractor's raw-call gate admitted only bare-identifier receivers (a +`field_expression` was dropped), and the receiver type table held only method-body +locals. The fix records a per-CLASS `field -> ClassName` table from bare capitalized +`@property` / ivar declarations and adds a resolver arm for the exact +`self.` receiver shape plus a field-table fallback for bare identifiers. + +PRECISION over recall, as everywhere in this resolver: + +* only the exact `self.` receiver is captured — `obj.prop`, chains, and + `Foo.shared` stay dropped. Passing the DOTTED text through would let a + capitalized `Foo.shared` enter the explicit-class arm, where `_key` strips the + dot and collides with a real class `FooShared` (a fabricated edge); +* a `generic_specifier` (`NSArray`) or `typedefed_specifier` (`id

`) + property never types a receiver; +* locals shadow fields for bare identifiers, conflicts drop the entry, and the + single-definition god-node guard still gates the type lookup. +""" +from __future__ import annotations + +from pathlib import Path + +from graphify.extract import extract + + +def _write(path: Path, text: str) -> Path: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(text, encoding="utf-8") + return path + + +def _label(result: dict, nid: str) -> str: + for n in result["nodes"]: + if n["id"] == nid: + return n.get("label", "") + return f"?{nid}" + + +def _call_edges(result: dict, relations=("calls",)): + """{(source_label, relation, target_label, confidence)} for the given relations.""" + out = set() + for e in result["edges"]: + if e.get("relation") in relations: + out.add(( + _label(result, e["source"]), + e["relation"], + _label(result, e["target"]), + e.get("confidence"), + )) + return out + + +_BAR_H = "@interface Bar : NSObject\n- (void)doIt;\n@end\n" +_BAR_M = '#import "Bar.h"\n@implementation Bar\n- (void)doIt {}\n@end\n' + + +def test_objc_property_receiver_resolves(tmp_path: Path): + # `[self.bar doIt]` with `@property Bar *bar` in Foo.h -> the field types the + # receiver -> cross-file calls edge to Bar's -doIt (INFERRED). + base = tmp_path / "src" + _write(base / "Bar.h", _BAR_H) + _write(base / "Bar.m", _BAR_M) + _write(base / "Foo.h", + '#import "Bar.h"\n@interface Foo : NSObject\n' + "@property (nonatomic, strong) Bar *bar;\n- (void)viaProperty;\n@end\n") + _write(base / "Foo.m", + '#import "Foo.h"\n@implementation Foo\n' + "- (void)viaProperty { [self.bar doIt]; }\n@end\n") + result = extract(sorted(base.glob("*")), cache_root=tmp_path / "cache") + + calls = _call_edges(result) + assert ("-viaProperty", "calls", "-doIt", "INFERRED") in calls + + +def test_objc_ivar_receiver_resolves(tmp_path: Path): + # `[_ivarBar doIt]` with `Bar *_ivarBar;` in the @implementation ivar block -> + # bare identifier falls back from the (empty) local table to the field table. + base = tmp_path / "src" + _write(base / "Bar.h", _BAR_H) + _write(base / "Bar.m", _BAR_M) + _write(base / "Foo.m", + '#import "Bar.h"\n@implementation Foo {\n Bar *_ivarBar;\n}\n' + "- (void)viaIvar { [_ivarBar doIt]; }\n@end\n") + result = extract(sorted(base.glob("*")), cache_root=tmp_path / "cache") + + calls = _call_edges(result) + assert ("-viaIvar", "calls", "-doIt", "INFERRED") in calls + + +def test_objc_header_ivar_block_receiver_resolves(tmp_path: Path): + # The `@interface Foo { Bar *_bar; }` ivar block records the same way. + base = tmp_path / "src" + _write(base / "Bar.h", _BAR_H) + _write(base / "Bar.m", _BAR_M) + _write(base / "Foo.h", + '#import "Bar.h"\n@interface Foo : NSObject {\n Bar *_bar;\n}\n' + "- (void)viaIvar;\n@end\n") + _write(base / "Foo.m", + '#import "Foo.h"\n@implementation Foo\n' + "- (void)viaIvar { [_bar doIt]; }\n@end\n") + result = extract(sorted(base.glob("*")), cache_root=tmp_path / "cache") + + calls = _call_edges(result) + assert ("-viaIvar", "calls", "-doIt", "INFERRED") in calls + + +def test_objc_dotted_class_receiver_fabricates_nothing(tmp_path: Path): + """The no-fabrication decoy: `[Foo.shared doIt]` next to a REAL class FooShared. + + A naive widen that passed the dotted receiver text through would enter the + explicit-class arm, where `_key("Foo.shared")` == `_key("FooShared")` — binding + the call to an unrelated class at confidence 1.0. Only `self.` is + captured, so this receiver must yield ZERO edges from the caller. + """ + base = tmp_path / "src" + _write(base / "FooShared.h", + "@interface FooShared : NSObject\n- (void)doIt;\n@end\n") + _write(base / "FooShared.m", + '#import "FooShared.h"\n@implementation FooShared\n- (void)doIt {}\n@end\n') + _write(base / "Use.m", + '#import "FooShared.h"\n@implementation Use\n' + "- (void)go { [Foo.shared doIt]; }\n@end\n") + result = extract(sorted(base.glob("*")), cache_root=tmp_path / "cache") + + from_go = [e for e in result["edges"] + if e.get("relation") in ("calls", "references") + and _label(result, e["source"]) == "-go"] + assert from_go == [] + + +def test_objc_local_shadows_property_field(tmp_path: Path): + # A local `Baz *bar` shadows the class's `@property Bar *bar`; `[bar m]` must + # resolve via the LOCAL's type (Baz's -m), never the property's (Bar's -m). + base = tmp_path / "src" + _write(base / "Bar.h", "@interface Bar : NSObject\n- (void)m;\n@end\n") + _write(base / "Bar.m", '#import "Bar.h"\n@implementation Bar\n- (void)m {}\n@end\n') + _write(base / "Baz.h", "@interface Baz : NSObject\n- (void)m;\n@end\n") + _write(base / "Baz.m", '#import "Baz.h"\n@implementation Baz\n- (void)m {}\n@end\n') + _write(base / "Foo.h", + '#import "Bar.h"\n@interface Foo : NSObject\n' + "@property (nonatomic, strong) Bar *bar;\n- (void)go;\n@end\n") + _write(base / "Foo.m", + '#import "Foo.h"\n#import "Baz.h"\n@implementation Foo\n' + "- (void)go {\n Baz *bar = [[Baz alloc] init];\n [bar m];\n}\n@end\n") + result = extract(sorted(base.glob("*")), cache_root=tmp_path / "cache") + + src_by_id = {n["id"]: n.get("source_file") for n in result["nodes"]} + m_calls = [ + e for e in result["edges"] + if e.get("relation") == "calls" + and _label(result, e["source"]) == "-go" + and _label(result, e["target"]) == "-m" + ] + assert len(m_calls) == 1 + assert Path(src_by_id[m_calls[0]["target"]]).name == "Baz.h" + + +def test_objc_ambiguous_field_type_emits_no_edge(tmp_path: Path): + # Two in-corpus classes labelled Bar -> the property receiver's type lookup has + # two candidates and the single-definition god-node guard bails: ZERO edges. + base = tmp_path / "src" + for d in ("a", "b"): + _write(base / d / "Bar.h", _BAR_H) + _write(base / d / "Bar.m", _BAR_M) + _write(base / "Foo.h", + '#import "a/Bar.h"\n@interface Foo : NSObject\n' + "@property (nonatomic, strong) Bar *bar;\n- (void)go;\n@end\n") + _write(base / "Foo.m", + '#import "Foo.h"\n@implementation Foo\n- (void)go { [self.bar doIt]; }\n@end\n') + result = extract(sorted(base.rglob("*.[hm]")), cache_root=tmp_path / "cache") + + from_go = [e for e in result["edges"] + if e.get("relation") == "calls" and _label(result, e["source"]) == "-go"] + assert from_go == [] + + +def test_objc_generic_and_protocol_typed_properties_are_never_recorded(tmp_path: Path): + # `NSArray *items` (generic_specifier) and `id locker` + # (typedefed_specifier) never enter the field table -> no edge, no guess. + base = tmp_path / "src" + _write(base / "Bar.h", _BAR_H) + _write(base / "Bar.m", _BAR_M) + _write(base / "Foo.h", + '#import "Bar.h"\n@interface Foo : NSObject\n' + "@property (nonatomic) NSArray *items;\n" + "@property (nonatomic) id locker;\n" + "- (void)viaGeneric;\n- (void)viaProtocol;\n@end\n") + _write(base / "Foo.m", + '#import "Foo.h"\n@implementation Foo\n' + "- (void)viaGeneric { [self.items doIt]; }\n" + "- (void)viaProtocol { [self.locker doIt]; }\n@end\n") + result = extract(sorted(base.glob("*")), cache_root=tmp_path / "cache") + + typed = [e for e in result["edges"] + if e.get("relation") == "calls" + and _label(result, e["source"]) in ("-viaGeneric", "-viaProtocol")] + assert typed == [] + + +def test_objc_local_receiver_and_alloc_reference_unchanged(tmp_path: Path): + # The pre-existing paths still work next to the new ones: a `Foo *f` local + # types `[f doThing]` (INFERRED) and `[[Foo alloc] init]` still emits the + # `references` edge to the allocated type. + base = tmp_path / "src" + _write(base / "Foo.h", "@interface Foo : NSObject\n- (void)doThing;\n@end\n") + _write(base / "Foo.m", '#import "Foo.h"\n@implementation Foo\n- (void)doThing {}\n@end\n') + _write(base / "Bar.m", + '#import "Foo.h"\n@implementation Bar\n' + "- (void)viaLocal {\n Foo *f = [[Foo alloc] init];\n [f doThing];\n}\n@end\n") + result = extract(sorted(base.glob("*")), cache_root=tmp_path / "cache") + + calls = _call_edges(result) + assert ("-viaLocal", "calls", "-doThing", "INFERRED") in calls + refs = _call_edges(result, relations=("references",)) + assert any(s == "-viaLocal" and t == "Foo" for s, _, t, _ in refs)