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
+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)