Repository navigation
ObjC: message sends to a @property or ivar receiver never resolve — only method-body locals are typed #2591
Description
Summary
_resolve_objc_member_calls (#1556) can only type a receiver that is a method-body local
declaration. The two other everyday ways an ObjC method reaches a collaborator — a
@property ([self.bar doIt]) and an instance variable ([_ivarBar doIt]) — never resolve,
so a cross-file calls edge that Java and C# both produce is silently missing for ObjC.
These are not rare shapes. On two public ObjC corpora, counting every message_expression
receiver by syntactic form:
| receiver form | AFNetworking (783 sends) | SDWebImage (2197 sends) | resolvable today |
|---|---|---|---|
self / super |
19.0% | 25.9% | yes (EXTRACTED) |
ClassName |
27.2% | 26.5% | yes (EXTRACTED) |
| lowercase identifier (local/param) | 26.3% | 22.6% | only if a Foo *x declaration is in the same body |
self.property |
7.9% | 8.7% | no |
_ivar |
0.1% | 1.7% | no |
nested send [[..] ..], casts, subscripts |
rest | rest | out of scope here |
So roughly 8–10% of all message sends in ordinary ObjC are structurally unresolvable, and
in delegate/manager-heavy code a property is the usual way a class calls its collaborator.
graphifyy 0.9.38 (v8 @ 10ad921), Python 3.12, macOS.
Reproduction
One class, three receiver forms, same callee.
Bar.h / Bar.m
@interface Bar : NSObject
- (void)doIt;
@endFoo.h
#import "Bar.h"
@interface Foo : NSObject
@property (nonatomic, strong) Bar *bar;
- (void)viaProperty;
- (void)viaIvar;
- (void)viaLocal;
@endFoo.m
#import "Foo.h"
@implementation Foo {
Bar *_ivarBar;
}
- (void)viaProperty { [self.bar doIt]; }
- (void)viaIvar { [_ivarBar doIt]; }
- (void)viaLocal { Bar *localBar = [[Bar alloc] init]; [localBar doIt]; }
@endOne of three resolves:
--- calls edges ---
'-viaLocal' -> '-doIt' INFERRED L7
(viaProperty and viaIvar produce nothing)
The extractor's own intermediate state shows the two failures are different:
objc_type_table (Foo.m): {'localBar': 'Bar'} <- locals only
raw_calls (Foo.m):
receiver='_ivarBar' callee='doIt' is_member=True L6 <- reaches the resolver, untypeable
receiver='Bar' callee='alloc' is_member=True L7
receiver='localBar' callee='doIt' is_member=True L7 <- the only one that resolves
(no entry for self.bar at all)
_ivarBardoes reachraw_calls, butobjc_type_tablehas no entry for it, so the
resolver's third arm bails.self.barnever becomes araw_callat all — it is dropped at extraction time.
Root cause
(a) the receiver gate only admits identifier. [self.bar doIt] parses with a
field_expression receiver (['identifier', '.', 'field_identifier']), which the gate rejects:
# graphify/extractors/objc.py:372
if recv is not None and recv.type == "identifier":
raw_calls.append({...})(b) the type table only holds method-body locals. _objc_local_var_types
(graphify/extractors/objc.py:11) walks a method body for declaration nodes; it is called
once per method body (objc.py:319) and produces a flat per-file table
(objc.py:428-429). @property and ivars are never collected — even though the extractor
already visits both: the property_declaration branch (objc.py:210) reads the property's
type and emits references … ctx="field" from it, and instance_variables /
instance_variable nodes are in the tree. The type fact is present, it is just never turned
into a receiver type table:
Foo.h: references 'Foo' -> 'Bar' ctx=field L3 <- from @property Bar *bar
grep -c 'field_types' graphify/extractors/objc.py → 0.
Prior art in this repo
Java and C# already solve exactly this, and ObjC is the odd one out:
| class-member type table | this.field receiver |
ObjC equivalent | |
|---|---|---|---|
| Java | java_field_types, keyed by class nid (engine.py:2626, populated :3445-3454) |
yes (engine.py:4735-4740) |
— |
| C# | csharp_field_types, keyed by class nid (engine.py:2632, populated :3380-3426, #2299) |
yes (engine.py:4633-4646) |
— |
| ObjC | none | none | objc_type_table, flat per-file, locals only |
Java also composes the two: _java_method_receiver_types starts from the class's
field_types as the base scope and lets params/locals shadow it, with conflicts dropped rather
than guessed (engine.py:473-548). C# does the same positionally (#2472). That is the shape
ObjC is missing.
Cross-language analogue: #2234 (fix(rust): receiver-method calls (self.method()) are dropped cross-file) is the same class of gap in Rust.
Important: widening the gate alone makes things worse
I measured this, so it is worth stating up front. Changing only objc.py:372 to
if recv is not None and recv.type in ("identifier", "field_expression"):does not fix [self.bar doIt] (still 0 edges — "self.bar" is lowercase, so it takes the
file-local-table arm and misses), and it adds a false EXTRACTED edge. Fixture: Foo with
@property Bar *bar, a method containing [Foo.shared doIt], and an unrelated real class
FooShared that has its own -doIt:
baseline v8: (no calls edges)
gate widened: '-go2' -> '-doIt' EXTRACTED L6
src=..._foo_foo_go2
tgt=..._fooshared_fooshared_doit <- WRONG class
Because "Foo.shared" is capitalized it enters the explicit-class arm at extract.py:3357,
and _key() (extract.py:3311) strips the dot, so "Foo.shared" → "fooshared" and collides
with the real class FooShared. The full test suite stays green with this false edge present
(4175 passed, 3 skipped on 10ad921), so tests alone will not catch it.
Java/C# avoid this by structurally unwrapping the receiver instead of passing the dotted
text through: this.field.M() is reduced to the bare field name before it is ever used as a
lookup key. Whatever the ObjC fix looks like, a receiver containing . must not reach the
capitalized-class-name arm as a whole string.
Suggested shape
Following the Java/C# precedent, in one change:
- Collect an ObjC member type table keyed by class nid from
@propertytype nodes and
instance_variabledeclarations (the ObjC twin ofjava_field_types/csharp_field_types). - Compose it with the existing method-body locals as the base scope, locals shadowing, and
conflicts dropped — the_java_method_receiver_typescontract. - Admit a
field_expressionreceiver only when it isself.<field>, stamping the bare
field name (not"self.bar") — so the_key()-strips-the-dot collision above cannot occur,
and[Foo.shared doIt]stays unresolved as it is today. - Stamp
receiver_typeon the raw call the way Java/C# do, and add the corresponding resolver
arm atextract.py:3363; keep the single-definition god-node guard so an ambiguous type
still produces no edge.
Confidence tier should be INFERRED (the type is declared, not named at the call site), matching
the existing local-variable arm and Java/C#'s non-this arm.
I'm happy to send a PR for this, or to leave it entirely to you — the reproduction and the
false-edge counterexample are the parts that matter.
Note
Two other ObjC defects that trip the same single-definition guard in this resolver are filed
separately: #2589 (a @protocol declaration treated as a receiver type) and #2590 (a
category/class-extension interface minting a duplicate class node). They are independent of
this one, but a corpus with several of these shapes needs each fix to get an edge out.
Activity
fix(objc): protocol not a receiver type, category fold, property/ivar…
fix(extract): affected traverses in-function dynamic imports; Python …
safishamsi commentedon Aug 10, 2026
Fixed in v0.9.39.
@propertyand ivar declarations are now captured into a per-class field-type table, and a message send to aself.<field>or_ivarreceiver resolves through the fields declared type. Only the exactself.shape is typed and only the bare field name is used, so a[Foo.shared doIt]` cannot fabricate an edge to a same-named class; ambiguous/unknown types stay unresolved. Thanks @xiongjianxu.fix(objc): protocol not a receiver type, category fold, property/ivar…
fix(extract): affected traverses in-function dynamic imports; Python …