fix(objc): protocol not a receiver type, category fold, property/ivar receivers (#2589, #2590, #2591)

#2589 (PR #2500): a @protocol declaration (labeled <Name>) is excluded from
the receiver-type index, so it no longer collides with a same-named class.
#2590 (PR #2501): a category/class-extension interface is keyed off the
base stem and folds into the base class instead of minting a duplicate
node.
#2591 (fresh): @property and ivar declarations are captured into a
per-class field-type table, and a message send to a self.field / _ivar
receiver resolves through it (bare field name only, so Foo.shared cannot
fabricate to a FooShared class). All hold the single-definition guard and
emit INFERRED.

Adapts PRs #2500/#2501 (thanks @xiongjianxu); #2591 fresh.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
safishamsi
2026-08-10 18:06:50 +01:00
co-authored by Claude Opus 4.8
parent 978f87cb67
commit ba8254b3ab
5 changed files with 596 additions and 6 deletions
+139 -3
View File
@@ -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<T>``) 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<Bar *>``) or ``typedefed_specifier``
(``id<P>``) 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.<field>`
# 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
+20 -3
View File
@@ -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))
+122
View File
@@ -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"] == []
+97
View File
@@ -0,0 +1,97 @@
"""ObjC receiver typing must not treat a ``@protocol`` as a message receiver (#1556).
The ObjC extractor labels a protocol declaration ``<Name>``, 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 <NSObject>\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 <NSObject>\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 <NSObject>\n- (void)reload;\n@end\n")
_write(base / "Widget.h",
'#import "Reload.h"\n@interface Widget : NSObject <Reload>\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", "<Reload>") in implements
+218
View File
@@ -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.<field>` receiver shape plus a field-table fallback for bare identifiers.
PRECISION over recall, as everywhere in this resolver:
* only the exact `self.<field>` 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<Bar *>`) or `typedefed_specifier` (`id<P>`)
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.<field>` 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<Bar *> *items` (generic_specifier) and `id<Locking> 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<Bar *> *items;\n"
"@property (nonatomic) id<Locking> 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)