Skip to content

Finish Objective-C member calls across a repository boundary (#3152) - #3385

Open
xiongjianxu wants to merge 2 commits into
Graphify-Labs:v8from
xiongjianxu:feat/objc-cross-repo-member-calls
Open

Finish Objective-C member calls across a repository boundary (#3152)#3385
xiongjianxu wants to merge 2 commits into
Graphify-Labs:v8from
xiongjianxu:feat/objc-cross-repo-member-calls

Conversation

@xiongjianxu

Copy link
Copy Markdown
Contributor

Fixes #3384. Extends #3152 (cross-repo member calls) to Objective-C.

The change

Extraction (graphify/extract.py). _resolve_objc_member_calls ended each of its
three typing arms with a single if len(type_defs) != 1: continue, so "the receiver's
type is declared nowhere in this corpus" — the parking case — was indistinguishable
from "declared more than once", which must stay dropped. Each arm now splits the guard
the way Java's does: zero declarations parks {callee, receiver_type, lang: "objc", line} on the caller node, more than one still bails on the god-node guard. The
self/super arm is untouched: its type is the caller's own class, which is by
definition in this corpus.

A builtin receiver type is filtered out at the parking sites, mirroring what the Swift
and C++ resolvers already skip before resolving — a NSString or DispatchQueue is
not what another repo declares, so parking one would only let a same-named user class
answer for it.

Merge (graphify/cross_repo_calls.py). Adds "objc": {".h", ".m", ".mm"} to
_LANG_SUFFIXES and a _member_keys(lang, callee) hook next to the existing
_member_relations. An ObjC method label keeps its +/- sigil (-greet) where every
other extractor writes .greet(), and a parked selector does not record whether the
send was to a class or an instance, so both spellings are tried; a class declaring both
+greet and -greet counts as ambiguous and binds nothing.

The sigil is deliberately kept rather than normalized away: .h is in the C++ suffix
set too, and the sigil is what keeps an ObjC -greet and a C++ .greet() from
answering for each other inside one shared header. It also means @protocol
declarations self-exclude — they are labelled <Greeter>, which no parked receiver type
spells — so no extra guard is needed there.

The _callable markers. _index_declarations indexes declarations by
_callable_class, which extractors/objc.py never wrote, so every ObjC type would be
skipped. The first commit here is #3229 (7a6b0e5) cherry-picked unchanged so this
branch stands alone; if #3229 lands first, that commit drops out on rebase.

Tests

tests/test_cross_repo_member_calls.py, +5 cases:

  • a parked selector answers through either sigil;
  • a class declaring both sigils binds nothing;
  • an ObjC call does not bind to a C++ .greet() in a shared .h, and a C++ call does
    not bind to an ObjC -greet in one either;
  • an ObjC @protocol does not answer a parked call;
  • objc-property-receiver added to the end-to-end parametrization: two real repos
    extracted and merged, [self.greeter greet] across the boundary.

Full suite: 4 failed, 5321 passed, 93 skipped. The 4 failures are the pre-existing
tests/test_ollama_retry_cap.py ones, identical to the origin/v8 baseline
(4 failed, 5309 passed, 93 skipped) on the same venv. ruff check graphify tests
passes.

Ceiling, measured

On a 15,754-file ObjC corpus in 26 units, 12,866 sends have a typed receiver whose
class is absent from its own unit. Of those, 5,127 are framework classes, 1,911 are
business classes declared in no unit, 2,634 find the class but not the selector on it,
2,769 are ambiguous across units, and 425 are genuinely cross-unit resolvable. This
PR is aimed at those 425; the builtin filter keeps the framework names from being parked
at all.

…s#3228)

Every other extractor stamps its definitions through the generic engine:
`_callable` for "a real callable, not a same-named data symbol" (Graphify-Labs#2438),
narrowed to `_callable_class` for a type, which is callable only through a
constructor (Graphify-Labs#2137). The ObjC extractor builds its nodes by hand and set
neither, so an ObjC class was invisible to every pass that indexes
declarations by those markers — the `same_type_as` pass from Graphify-Labs#3007, the
qualified-name indexes, and anything new that needs to tell a declaration
from a reference — and an ObjC method could not be told apart from a data
symbol of the same name.

`@interface`, `@implementation` and `@protocol` nodes now carry both
markers (a protocol is a type declaration, the same way the engine marks a
Java or C# interface), and method nodes carry `_callable`. The file node
and the stubs minted for dangling references stay unmarked: a stub has no
declaration behind it.

@graphify-labs graphify-labs Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Graphify reviewed this change.

Worth a look — the grounded gate found no coupling regressions or blocking issues, but 1 advisory finding(s) below merit a look before merge.

Formal verification. 2 change(s) tested, no difference found (not proven).


Graphify review — findings

Adds Objective-C support to cross-repo member-call linking: registers .h/.m/.mm suffixes for the objc language, and resolves parked ObjC selectors against both -/+ sigil variants of a member key while treating a type that declares both as an ambiguity rather than a hit. Parks typed ObjC receivers whose class is declared nowhere in the corpus via _park_absent so a merged graph can answer them later, while skipping builtin types (NSString, DispatchQueue) that no other repo owns.

Worth a look

  • Already-sigiled Objective-C callees are double-prefixedgraphify/cross_repo_calls.py:157 · Escalate · medium
    • agreed by 2 of 2 members but NOT verified (no proof, no reproducing execution) — consensus is not a verdict; needs human review
Analysis details — impact, health, verification

Impact & health

Graphify review

Impact — 1942 functions depend on the 324 functions this change touches.

Health — this change adds coupling hotspots:

  • new: extract() — 546 callers, 43 callees
  • new: _rebuild_code() — 115 callers, 51 callees
  • new: extract_js() — 85 callers, 4 callees
  • new: extract_xaml() — 19 callers, 17 callees
  • new: dispatch_command() — 2 callers, 124 callees
  • new: extract_objc() — 27 callers, 9 callees
  • new: link_cross_repo_member_calls() — 22 callers, 8 callees
  • new: _get_extractor() — 26 callers, 6 callees
  • …and 37 more — each is listed as a finding

Verification — 1942 functions in the blast radius were not formally verified this run (proofs are advisory here).

Gate & verification

graphify gate

PASS — objectively clean (no health regressions, tests not run — proofs not run this pass (advisory)). Grounded, not self-assessed.

Advisory (not blocking):

  • verification_scope: 1777 function(s) in the blast radius were not formally verified this run

Formal verification

No difference found (not proven): No behavior difference found in \_key (not a proof).

The verifier ran both versions of \_key on many inputs and saw identical behavior every time. Strong evidence the change is safe, but evidence, not a proof.

Guarantee: Empirical: concolic exploration (CrossHair). A divergence on an untested input remains possible, so this is 'no counterexample found', not 'proven equivalent'.

Note: An input the sampler did not try could still differ.

No difference found (not proven): No behavior difference found in link\_cross\_repo\_member\_calls (not a proof).

The verifier ran both versions of link\_cross\_repo\_member\_calls on many inputs and saw identical behavior every time. Strong evidence the change is safe, but evidence, not a proof.

Guarantee: Empirical: differential testing (both versions run on many generated inputs). A divergence on an untested input remains possible, so this is 'no counterexample found', not 'proven equivalent'.

Note: An input the sampler did not try could still differ.

Could not verify: Could not verify \_resolve\_objc\_member\_calls.

The verifier did not have enough to check \_resolve\_objc\_member\_calls, so it is saying so rather than guessing. No false assurance is the whole point.

Guarantee: No guarantee either way, this is an honest abstention, not a pass.

Note: Reason: non-vacuity: domain too small (only 1 distinct inputs exercised, need 3) — 'no divergence' would be near-vacuous

Could not verify: Could not verify extract\_objc.

The verifier did not have enough to check extract\_objc, so it is saying so rather than guessing. No false assurance is the whole point.

Guarantee: No guarantee either way, this is an honest abstention, not a pass.

Note: Reason: parameter `path` is annotated `Path` — outside the synthesizable primitive/collection set

· 3 grounded finding(s) anchored inline below; 42 more finding(s) on lines outside this diff (see the check run).

return (f"-{callee}", f"+{callee}")
return (callee,)


def link_cross_repo_member_calls(merged: "nx.Graph") -> int:

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Health regressionlink_cross_repo_member_calls()

fans out to 8 callees (efferent coupling); 22 callers depend on it (afferent coupling).

Grounded coupling-delta finding (deterministic), not an LLM guess.

PARKED_OBJC = [{"callee": "greet", "receiver_type": "Greeter", "lang": "objc", "line": "L5"}]


def test_an_objc_selector_answers_through_either_sigil():

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Health regressiontest_an_objc_selector_answers_through_either_sigil()

fans out to 6 callees (efferent coupling).

Grounded coupling-delta finding (deterministic), not an LLM guess.

)
assert link_cross_repo_member_calls(G) == 0


def test_the_definition_answers_before_a_same_named_declaration():

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Health regressiontest_the_definition_answers_before_a_same_named_declaration()

fans out to 6 callees (efferent coupling).

Grounded coupling-delta finding (deterministic), not an LLM guess.

…y-Labs#3152)

`_resolve_objc_member_calls` types a receiver from `self`/`super`, an explicit
class name, the file's local table or the class's @property/ivar table, then
requires exactly one declaration of that type. Zero declarations and more than
one shared a single bail, so a call into a class another repository declares was
dropped with the receiver type already in hand — the case Graphify-Labs#3152 parks for the
merge in Java, C++, C# and Swift.

Split the three type-known bails: zero declarations parks `{callee,
receiver_type, lang: "objc", line}` on the caller, more than one stays dropped as
before (a local ambiguity that merging only widens). A builtin receiver type is
filtered out at the parking sites, matching what the Swift and C++ resolvers
already skip before resolving.

On the merge side an ObjC method label keeps its `+`/`-` sigil where every other
extractor writes `.name()`, and a parked selector does not say which was meant,
so both spellings are tried and a class declaring both counts as ambiguous. The
sigil is what keeps ObjC and C++ members disjoint inside a shared `.h`, which is
in both languages' suffix sets.

Depends on the `_callable` / `_callable_class` markers (Graphify-Labs#3228) that
`_index_declarations` indexes declarations by; the commit before this one carries
them.
@xiongjianxu
xiongjianxu force-pushed the feat/objc-cross-repo-member-calls branch from 1416986 to dab3a70 Compare September 7, 2026 09:44
@xiongjianxu

Copy link
Copy Markdown
Contributor Author

Addressed the one advisory finding ("already-sigiled Objective-C callees are double-prefixed", _member_keys).

Not reachable from graphify's own output — _park_absent takes the callee from the ObjC extractor's raw_calls, where the selector is the concatenation of the send's method identifiers and never carries a sigil. But the payload is read back out of graph.json, and a sigiled entry would have asked the member index for --greet and silently missed. _member_keys now strips a leading sigil before re-adding both, which an ObjC selector can never legitimately have, so the function is idempotent whatever the payload says. New case: test_an_objc_callee_that_already_carries_a_sigil_still_binds.

Also dropped a local test log (baseline_0955.txt) that a git add -A had swept into the commit; the branch was amended and force-pushed, so the diff is now the three intended files only.

Full suite after the change: 4 failed, 5322 passed, 93 skipped — the 4 are the pre-existing tests/test_ollama_retry_cap.py failures, same set as the origin/v8 baseline on this venv. ruff check graphify tests passes.

The three inline findings are coupling-delta advisories on link_cross_repo_member_calls and two test functions; they reflect the new _member_keys call and the test helpers, not a behavior concern, so I left those as they are.

@graphify-labs graphify-labs Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Graphify reviewed this change.

Worth a look — the grounded gate found no coupling regressions or blocking issues, but 2 advisory finding(s) below merit a look before merge.

Formal verification. 2 change(s) tested, no difference found (not proven).


Graphify review — findings

Adds Objective-C support to the cross-repo member-call flow: the ObjC resolver now parks a call whose receiver type is declared nowhere in the corpus (skipping builtins like NSString), and link_cross_repo_member_calls resolves it after a merge, trying both - and + selector spellings via _member_keys since a parked selector carries no class/instance sigil. Marks ObjC classes, implementations, protocols, and methods with the _callable/_callable_class node markers the generic engine puts on every other language's declarations, so they become visible to the passes that index declarations. Keeps the single-definition guard and builtin exclusion so a name collision or ambiguous +greet/-greet pair still won't fabricate an edge.

Worth a look

  • objc protocol label '' breaks bare-name declaration index lookupgraphify/extractors/objc.py:381 · Escalate · medium
    • agreed by 2 of 2 members but NOT verified (no proof, no reproducing execution) — consensus is not a verdict; needs human review
  • Protocol declarations now indexed as receiver types via _callable_class, contradicting documented exclusiongraphify/extractors/objc.py:381 · Escalate · medium
    • agreed by 2 of 2 members but NOT verified (no proof, no reproducing execution) — consensus is not a verdict; needs human review
Analysis details — impact, health, verification

Impact & health

Graphify review

Impact — 1943 functions depend on the 325 functions this change touches.

Health — this change adds coupling hotspots:

  • new: extract() — 546 callers, 43 callees
  • new: _rebuild_code() — 115 callers, 51 callees
  • new: extract_js() — 85 callers, 4 callees
  • new: extract_xaml() — 19 callers, 17 callees
  • new: dispatch_command() — 2 callers, 124 callees
  • new: extract_objc() — 27 callers, 9 callees
  • new: link_cross_repo_member_calls() — 23 callers, 8 callees
  • new: _get_extractor() — 26 callers, 6 callees
  • …and 38 more — each is listed as a finding

Verification — 1943 functions in the blast radius were not formally verified this run (proofs are advisory here).

Gate & verification

graphify gate

PASS — objectively clean (no health regressions, tests not run — proofs not run this pass (advisory)). Grounded, not self-assessed.

Advisory (not blocking):

  • verification_scope: 1778 function(s) in the blast radius were not formally verified this run

Formal verification

No difference found (not proven): No behavior difference found in \_key (not a proof).

The verifier ran both versions of \_key on many inputs and saw identical behavior every time. Strong evidence the change is safe, but evidence, not a proof.

Guarantee: Empirical: concolic exploration (CrossHair). A divergence on an untested input remains possible, so this is 'no counterexample found', not 'proven equivalent'.

Note: An input the sampler did not try could still differ.

No difference found (not proven): No behavior difference found in link\_cross\_repo\_member\_calls (not a proof).

The verifier ran both versions of link\_cross\_repo\_member\_calls on many inputs and saw identical behavior every time. Strong evidence the change is safe, but evidence, not a proof.

Guarantee: Empirical: differential testing (both versions run on many generated inputs). A divergence on an untested input remains possible, so this is 'no counterexample found', not 'proven equivalent'.

Note: An input the sampler did not try could still differ.

Could not verify: Could not verify \_resolve\_objc\_member\_calls.

The verifier did not have enough to check \_resolve\_objc\_member\_calls, so it is saying so rather than guessing. No false assurance is the whole point.

Guarantee: No guarantee either way, this is an honest abstention, not a pass.

Note: Reason: non-vacuity: domain too small (only 1 distinct inputs exercised, need 3) — 'no divergence' would be near-vacuous

Could not verify: Could not verify extract\_objc.

The verifier did not have enough to check extract\_objc, so it is saying so rather than guessing. No false assurance is the whole point.

Guarantee: No guarantee either way, this is an honest abstention, not a pass.

Note: Reason: parameter `path` is annotated `Path` — outside the synthesizable primitive/collection set

· 4 grounded finding(s) anchored inline below; 42 more finding(s) on lines outside this diff (see the check run).

return (callee,)


def link_cross_repo_member_calls(merged: "nx.Graph") -> int:

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Health regressionlink_cross_repo_member_calls()

fans out to 8 callees (efferent coupling); 23 callers depend on it (afferent coupling).

Grounded coupling-delta finding (deterministic), not an LLM guess.

PARKED_OBJC = [{"callee": "greet", "receiver_type": "Greeter", "lang": "objc", "line": "L5"}]


def test_an_objc_selector_answers_through_either_sigil():

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Health regressiontest_an_objc_selector_answers_through_either_sigil()

fans out to 6 callees (efferent coupling).

Grounded coupling-delta finding (deterministic), not an LLM guess.

assert _added_calls(G) == {("a::app_run", "b::greeter_greet")}


def test_an_objc_callee_that_already_carries_a_sigil_still_binds():

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Health regressiontest_an_objc_callee_that_already_carries_a_sigil_still_binds()

fans out to 6 callees (efferent coupling).

Grounded coupling-delta finding (deterministic), not an LLM guess.

assert link_cross_repo_member_calls(G) == 0


def test_the_definition_answers_before_a_same_named_declaration():

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Health regressiontest_the_definition_answers_before_a_same_named_declaration()

fans out to 6 callees (efferent coupling).

Grounded coupling-delta finding (deterministic), not an LLM guess.

@xiongjianxu

Copy link
Copy Markdown
Contributor Author

Self-audit pass over the bot review. No code change on this branch: the two correctness findings do not reproduce, and the remaining notes are coupling deltas.

Already handled — a callee arriving with its own +/- sigil. _member_keys strips one with lstrip("+-") before trying -sel then +sel, so a parked entry that already carries a sigil cannot ask the index for --greet. test_an_objc_callee_that_already_carries_a_sigil_still_binds pins it.

Does not reproduce — @protocol nodes contradicting the documented exclusion. Two separate mechanisms keep them out. _resolve_objc_member_calls excludes them explicitly via _is_protocol_declaration (the extractor labels a protocol <Name>), and the merge-side index cannot match one either: cross_repo_calls._key() preserves the angle brackets, so <Greeter> is never the key a parked receiver type Greeter looks up. The _callable_class marker the extractor now stamps on protocols is read by _is_type_like_definition, which is label-based and ignores it — the marker exists so protocols are visible to the passes that index declarations at all, exactly as a Java/C# interface is.

On the coupling deltas for link_cross_repo_member_calls and the two new tests: the fan-out is the shape of the pass, one small helper per decision (suffix family, member relations, member keys, key normalisation), and the tests fan out because each one builds two repos and merges them. Splitting either to move the metric would make both harder to read.

Verification: ruff check graphify tests clean; full suite 5322 passed / 93 skipped, with only the four pre-existing tests/test_ollama_retry_cap.py env failures that v8 also shows here.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Objective-C member calls are never parked for the cross-repo merge (#3152)

1 participant