Skip to content

ObjC: message sends to a @property or ivar receiver never resolve — only method-body locals are typed #2591

Closed

Description

@xiongjianxu

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;
@end

Foo.h

#import "Bar.h"
@interface Foo : NSObject
@property (nonatomic, strong) Bar *bar;
- (void)viaProperty;
- (void)viaIvar;
- (void)viaLocal;
@end

Foo.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]; }
@end

One 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)
  • _ivarBar does reach raw_calls, but objc_type_table has no entry for it, so the
    resolver's third arm bails.
  • self.bar never becomes a raw_call at 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:

  1. Collect an ObjC member type table keyed by class nid from @property type nodes and
    instance_variable declarations (the ObjC twin of java_field_types / csharp_field_types).
  2. Compose it with the existing method-body locals as the base scope, locals shadowing, and
    conflicts dropped — the _java_method_receiver_types contract.
  3. Admit a field_expression receiver only when it is self.<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.
  4. Stamp receiver_type on the raw call the way Java/C# do, and add the corresponding resolver
    arm at extract.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

  1. safishamsi commented on Aug 10, 2026

    @safishamsi
    Member

    Fixed in v0.9.39. @property and ivar declarations are now captured into a per-class field-type table, and a message send to a self.<field> or _ivar receiver resolves through the fields declared type. Only the exact self.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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions