Finish member calls across a repo boundary on merge (#3152) - #3222
Finish member calls across a repo boundary on merge (#3152)#3222xiongjianxu wants to merge 3 commits into
Conversation
A single-repo build binds `obj.method()` only when the receiver's type is declared in the same build. When the type lives in another repository the Java resolver already holds the receiver type but drops the call, so nothing about it reaches graph.json — the only artifact `merge-graphs` and `global add` read — and no merge-time pass can recover what was never written down. Merging two repos produced 7 edges where the same code in one corpus produced 8: exactly the edges that make it a call graph. The resolver now parks such a call on the caller node as a `metadata.unresolved_calls` entry (callee, receiver type, language, line — names only, never node ids, which the id remaps and the repo prefixing rewrite), and a new pass finishes the edge after the graphs are composed. The pass keeps the single-definition guard the single-repo resolvers use, only crosses a repo boundary, and requires the answering declaration to be in the same language, so a name collision fabricates nothing. It adds edges only — no node merging or renaming — and clears its own previous output before recomputing, which is what makes an incremental `global add` land where a single `merge-graphs` of the same inputs does. Parking is only wired into the Java resolver here; the pass is language-keyed so other extractors can opt in the same way.
There was a problem hiding this comment.
Graphify reviewed this change.
Looks safe to merge — no coupling regressions and no blocking issues, checked against the code graph (not a self-assessment).
Formal verification. No changes could be formally verified in this run.
Graphify review — findings
Adds cross-repo member call resolution so obj.method() edges survive a repository boundary. The Java resolver now parks unresolved calls (receiver type, callee, and language, names only) as metadata.unresolved_calls on the caller node via _park_unresolved_member_call when the receiver's type is declared nowhere in the current corpus, and the new link_cross_repo_member_calls reads them back after graphs are composed, emitting a tagged calls edge only when the type resolves to exactly one declaration in a different repo with a matching language suffix. merge-graphs and global add invoke it and report how many calls they resolved; the pass drops its own prior edges before recomputing, so incremental adds and full merges of the same inputs agree and re-runs never duplicate.
No blocking issues surfaced. 10 lower-confidence candidates did not survive cross-model review.
Analysis details — impact, health, verification
Impact & health
Graphify review
Impact — 1899 functions depend on the 329 functions this change touches.
Health — this change adds coupling hotspots:
- new:
extract()— 522 callers, 43 callees - new:
_rebuild_code()— 113 callers, 50 callees - new:
extract_js()— 85 callers, 4 callees - new:
extract_xaml()— 19 callers, 17 callees - new:
dispatch_command()— 2 callers, 124 callees - new:
_get_extractor()— 26 callers, 6 callees - new:
run_pipeline()— 8 callers, 13 callees - new:
collect_files()— 17 callers, 6 callees - …and 33 more — each is listed as a finding
Verification — 1899 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: 1845 function(s) in the blast radius were not formally verified this run
Formal verification
Could not verify: Could not verify dispatch\_command.
The verifier did not have enough to check dispatch\_command, 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: not verifiable: all 23 sampled inputs raised on both versions — the function never executed, so 'no divergence' would be vacuous (mostly SystemExit — names the real obstacle, not a sampling gap)
Could not verify: Could not verify \_resolve\_java\_member\_calls.
The verifier did not have enough to check \_resolve\_java\_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 global\_add.
The verifier did not have enough to check global\_add, 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 `source_path` is annotated `Path` — outside the synthesizable primitive/collection set
· 7 grounded finding(s) anchored inline below; 34 more finding(s) on lines outside this diff (see the check run).
| return by_owner | ||
|
|
||
|
|
||
| def link_cross_repo_member_calls(merged: "nx.Graph") -> int: |
There was a problem hiding this comment.
link_cross_repo_member_calls()
fans out to 6 callees (efferent coupling); 14 callers depend on it (afferent coupling).
Grounded coupling-delta finding (deterministic), not an LLM guess.
| parked.append(entry) | ||
|
|
||
|
|
||
| def _resolve_java_member_calls( |
There was a problem hiding this comment.
_resolve_java_member_calls()
fans out to 6 callees (efferent coupling).
Grounded coupling-delta finding (deterministic), not an LLM guess.
| @@ -79,7 +79,8 @@ def _file_hash(path: Path) -> str: | |||
| def global_add(source_path: Path, repo_tag: str) -> dict: | |||
There was a problem hiding this comment.
global_add()
fans out to 9 callees (efferent coupling); 10 callers depend on it (afferent coupling).
Grounded coupling-delta finding (deterministic), not an LLM guess.
| "skipped": False, "cross_repo_calls": cross_repo_calls} | ||
|
|
||
|
|
||
| def global_remove(repo_tag: str) -> int: |
There was a problem hiding this comment.
global_remove()
high coupling complexity (Ca·Ce = 25).
Grounded coupling-delta finding (deterministic), not an LLM guess.
| PARKED_GREET = [{"callee": "greet", "receiver_type": "Greeter", "lang": "java", "line": "L10"}] | ||
|
|
||
|
|
||
| def test_a_parked_call_binds_to_the_one_declaration_in_another_repo(): |
There was a problem hiding this comment.
test_a_parked_call_binds_to_the_one_declaration_in_another_repo()
fans out to 6 callees (efferent coupling).
Grounded coupling-delta finding (deterministic), not an LLM guess.
| assert data["source_location"] == "L10" | ||
|
|
||
|
|
||
| def test_two_repos_declaring_the_same_name_bind_nothing(): |
There was a problem hiding this comment.
test_two_repos_declaring_the_same_name_bind_nothing()
fans out to 6 callees (efferent coupling).
Grounded coupling-delta finding (deterministic), not an LLM guess.
| assert G.number_of_edges() == edges_after_first | ||
|
|
||
|
|
||
| def test_a_repo_that_stops_declaring_the_type_loses_the_edge(): |
There was a problem hiding this comment.
test_a_repo_that_stops_declaring_the_type_loses_the_edge()
fans out to 6 callees (efferent coupling).
Grounded coupling-delta finding (deterministic), not an LLM guess.
pyproject sets ruff line-length = 100; the added print was 101.
There was a problem hiding this comment.
Graphify reviewed this change.
Looks safe to merge — no coupling regressions and no blocking issues, checked against the code graph (not a self-assessment).
Formal verification. No changes could be formally verified in this run.
Graphify review — findings
Adds cross-repo member-call resolution so obj.method() calls whose receiver type is declared in another repository get their calls edge, which single-repo extraction can only drop. The Java resolver now parks such calls (names only, no node ids) as metadata.unresolved_calls entries via _park_unresolved_member_call, and link_cross_repo_member_calls reads them back after graphs are composed, emitting a tagged INFERRED edge only when the receiver type and callee each resolve to exactly one same-language declaration in a different repo. The pass runs during both merge-graphs and global add, clears its own prior output first so it stays idempotent across incremental adds, and reports the edge count.
No blocking issues surfaced. 9 lower-confidence candidates did not survive cross-model review.
Analysis details — impact, health, verification
Impact & health
Graphify review
Impact — 1899 functions depend on the 329 functions this change touches.
Health — this change adds coupling hotspots:
- new:
extract()— 522 callers, 43 callees - new:
_rebuild_code()— 113 callers, 50 callees - new:
extract_js()— 85 callers, 4 callees - new:
extract_xaml()— 19 callers, 17 callees - new:
dispatch_command()— 2 callers, 124 callees - new:
_get_extractor()— 26 callers, 6 callees - new:
run_pipeline()— 8 callers, 13 callees - new:
collect_files()— 17 callers, 6 callees - …and 33 more — each is listed as a finding
Verification — 1899 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: 1845 function(s) in the blast radius were not formally verified this run
Formal verification
Could not verify: Could not verify dispatch\_command.
The verifier did not have enough to check dispatch\_command, 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: not verifiable: all 23 sampled inputs raised on both versions — the function never executed, so 'no divergence' would be vacuous (mostly SystemExit — names the real obstacle, not a sampling gap)
Could not verify: Could not verify \_resolve\_java\_member\_calls.
The verifier did not have enough to check \_resolve\_java\_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 global\_add.
The verifier did not have enough to check global\_add, 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 `source_path` is annotated `Path` — outside the synthesizable primitive/collection set
· 7 grounded finding(s) anchored inline below; 34 more finding(s) on lines outside this diff (see the check run).
| return by_owner | ||
|
|
||
|
|
||
| def link_cross_repo_member_calls(merged: "nx.Graph") -> int: |
There was a problem hiding this comment.
link_cross_repo_member_calls()
fans out to 6 callees (efferent coupling); 14 callers depend on it (afferent coupling).
Grounded coupling-delta finding (deterministic), not an LLM guess.
| parked.append(entry) | ||
|
|
||
|
|
||
| def _resolve_java_member_calls( |
There was a problem hiding this comment.
_resolve_java_member_calls()
fans out to 6 callees (efferent coupling).
Grounded coupling-delta finding (deterministic), not an LLM guess.
| @@ -79,7 +79,8 @@ def _file_hash(path: Path) -> str: | |||
| def global_add(source_path: Path, repo_tag: str) -> dict: | |||
There was a problem hiding this comment.
global_add()
fans out to 9 callees (efferent coupling); 10 callers depend on it (afferent coupling).
Grounded coupling-delta finding (deterministic), not an LLM guess.
| "skipped": False, "cross_repo_calls": cross_repo_calls} | ||
|
|
||
|
|
||
| def global_remove(repo_tag: str) -> int: |
There was a problem hiding this comment.
global_remove()
high coupling complexity (Ca·Ce = 25).
Grounded coupling-delta finding (deterministic), not an LLM guess.
| PARKED_GREET = [{"callee": "greet", "receiver_type": "Greeter", "lang": "java", "line": "L10"}] | ||
|
|
||
|
|
||
| def test_a_parked_call_binds_to_the_one_declaration_in_another_repo(): |
There was a problem hiding this comment.
test_a_parked_call_binds_to_the_one_declaration_in_another_repo()
fans out to 6 callees (efferent coupling).
Grounded coupling-delta finding (deterministic), not an LLM guess.
| assert data["source_location"] == "L10" | ||
|
|
||
|
|
||
| def test_two_repos_declaring_the_same_name_bind_nothing(): |
There was a problem hiding this comment.
test_two_repos_declaring_the_same_name_bind_nothing()
fans out to 6 callees (efferent coupling).
Grounded coupling-delta finding (deterministic), not an LLM guess.
| assert G.number_of_edges() == edges_after_first | ||
|
|
||
|
|
||
| def test_a_repo_that_stops_declaring_the_type_loses_the_edge(): |
There was a problem hiding this comment.
test_a_repo_that_stops_declaring_the_type_loses_the_edge()
fans out to 6 callees (efferent coupling).
Grounded coupling-delta finding (deterministic), not an LLM guess.
…3152) The merge-time pass only had Java residue to work with, so a C++, C# or Swift call whose receiver type lives in another repo was still dropped with the type already in hand. Each of those three resolvers has the same bail-out Java had — receiver typed, type declared nowhere in this corpus — so each now parks the call by name for the merge to finish. Two details the added languages force: * Swift shares `all_raw_calls` with every other language and its raw_calls carry no `lang` tag, so the parked entry's language comes from the declaring file's suffix rather than from a tag that isn't there. * C# collapses "absent", "ambiguous" and "scoping was decisive" into one `None` from `_resolve_type_name_nid`, and only the first is a cross-repo candidate, so the park re-checks the bare-name index instead of trusting the `None`. On the merge side, a C++ class that only declares `void bar();` in a header owns it through `defines`, not `method`, so the member index is kept per relation: a C++ entry may fall back to `defines` when no `method` of that name exists, and for every other language a `defines` target is a field and cannot answer a call. Tests cover a real two-repo build per language (both C++ receiver shapes), the `defines` owner and its language restriction, the definition winning over a same-named header declaration, and C++ not binding to a C# class of the same name.
There was a problem hiding this comment.
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. No changes could be formally verified in this run.
Graphify review — findings
Adds a merge-time pass that finishes member calls crossing a repository boundary: single-repo resolvers now park unresolved obj.method() calls (receiver type, callee, and language, capped at 64 per node) on the caller node's metadata.unresolved_calls, and link_cross_repo_member_calls reads them back after graphs are composed to emit calls edges when the receiver type resolves to exactly one same-language declaration in another repo. The pass keeps the single-definition guard the in-repo resolvers use, adds edges only (no node merging), and clears its own prior output before recomputing so an incremental global add produces the same result as one merge-graphs of the same inputs. Wires it into dispatch_command for both merge-graphs and global add, printing a count of resolved cross-repo calls.
Worth a look
- Concurrent global_add can lose a repo update —
graphify/global_graph.py:153· Escalate · high- agreed by 2 of 2 members but NOT verified (no proof, no reproducing execution) — consensus is not a verdict; needs human review
- Existing non-call edge suppresses cross-repo call resolution —
graphify/cross_repo_calls.py:197· 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 — 1915 functions depend on the 345 functions this change touches.
Health — this change adds coupling hotspots:
- new:
extract()— 522 callers, 43 callees - new:
_rebuild_code()— 113 callers, 50 callees - new:
extract_js()— 85 callers, 4 callees - new:
extract_xaml()— 19 callers, 17 callees - new:
dispatch_command()— 2 callers, 124 callees - new:
_get_extractor()— 26 callers, 6 callees - new:
link_cross_repo_member_calls()— 18 callers, 7 callees - new:
run_pipeline()— 8 callers, 13 callees - …and 36 more — each is listed as a finding
Verification — 1915 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: 1861 function(s) in the blast radius were not formally verified this run
Formal verification
Could not verify: Could not verify dispatch\_command.
The verifier did not have enough to check dispatch\_command, 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: not verifiable: all 23 sampled inputs raised on both versions — the function never executed, so 'no divergence' would be vacuous (mostly SystemExit — names the real obstacle, not a sampling gap)
Could not verify: Could not verify \_resolve\_cpp\_member\_calls.
The verifier did not have enough to check \_resolve\_cpp\_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 \_resolve\_csharp\_member\_calls.
The verifier did not have enough to check \_resolve\_csharp\_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 \_resolve\_java\_member\_calls.
The verifier did not have enough to check \_resolve\_java\_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 \_resolve\_swift\_member\_calls.
The verifier did not have enough to check \_resolve\_swift\_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 global\_add.
The verifier did not have enough to check global\_add, 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 `source_path` is annotated `Path` — outside the synthesizable primitive/collection set
· 8 grounded finding(s) anchored inline below; 36 more finding(s) on lines outside this diff (see the check run).
| return ("method", "defines") if lang == "cpp" else ("method",) | ||
|
|
||
|
|
||
| def link_cross_repo_member_calls(merged: "nx.Graph") -> int: |
There was a problem hiding this comment.
link_cross_repo_member_calls()
fans out to 7 callees (efferent coupling); 18 callers depend on it (afferent coupling).
Grounded coupling-delta finding (deterministic), not an LLM guess.
| @@ -79,7 +79,8 @@ def _file_hash(path: Path) -> str: | |||
| def global_add(source_path: Path, repo_tag: str) -> dict: | |||
There was a problem hiding this comment.
global_add()
fans out to 9 callees (efferent coupling); 10 callers depend on it (afferent coupling).
Grounded coupling-delta finding (deterministic), not an LLM guess.
| "skipped": False, "cross_repo_calls": cross_repo_calls} | ||
|
|
||
|
|
||
| def global_remove(repo_tag: str) -> int: |
There was a problem hiding this comment.
global_remove()
high coupling complexity (Ca·Ce = 25).
Grounded coupling-delta finding (deterministic), not an LLM guess.
| PARKED_GREET = [{"callee": "greet", "receiver_type": "Greeter", "lang": "java", "line": "L10"}] | ||
|
|
||
|
|
||
| def test_a_parked_call_binds_to_the_one_declaration_in_another_repo(): |
There was a problem hiding this comment.
test_a_parked_call_binds_to_the_one_declaration_in_another_repo()
fans out to 6 callees (efferent coupling).
Grounded coupling-delta finding (deterministic), not an LLM guess.
| assert data["source_location"] == "L10" | ||
|
|
||
|
|
||
| def test_two_repos_declaring_the_same_name_bind_nothing(): |
There was a problem hiding this comment.
test_two_repos_declaring_the_same_name_bind_nothing()
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_a_cpp_header_declaration_answers_through_defines(): |
There was a problem hiding this comment.
test_a_cpp_header_declaration_answers_through_defines()
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(): |
There was a problem hiding this comment.
test_the_definition_answers_before_a_same_named_declaration()
fans out to 6 callees (efferent coupling).
Grounded coupling-delta finding (deterministic), not an LLM guess.
| assert G.number_of_edges() == edges_after_first | ||
|
|
||
|
|
||
| def test_a_repo_that_stops_declaring_the_type_loses_the_edge(): |
There was a problem hiding this comment.
test_a_repo_that_stops_declaring_the_type_loses_the_edge()
fans out to 6 callees (efferent coupling).
Grounded coupling-delta finding (deterministic), not an LLM guess.
Fixes #3152.
What was wrong
A single-repo build binds
obj.method()only when the receiver's type is declared in that same build. When the type lives in another repository, the resolver already has the receiver type in hand and drops the call anyway — so nothing about it reachesgraph.json, the only artifactmerge-graphsandglobal addconsume. No merge-time pass can recover what was never written down.Two repos,
App.run()callingGreeter.greet()whereGreeteris declared in the other repo:What this changes
Park the call at extraction (
extract.py): when the receiver type resolves to zero declarations in this corpus, the resolver records{callee, receiver_type, lang, line}on the caller node undermetadata.unresolved_callsinstead of dropping it silently. The payload carries names only, never node ids — ids are rewritten by the id-disambiguation remap and again by repo prefixing, and a stale id insidemetadatafails silently (ObjC: #2591 self.<field> receiver typing emits zero edges via the CLI — objc_field_types keys are skipped by the #1529 id remap #3150 was that bug); names survive every rewrite.> 1declarations stays dropped: that is a local ambiguity, and merging only widens it.Finish the edge after the merge (
cross_repo_calls.py):link_cross_repo_member_calls(merged)reads the parked entries back and emitsrelation="calls", context="cross_repo", confidence="INFERRED"when the receiver type resolves to exactly one declaration in another repo that owns exactly one member of that name.Hooks:
merge-graphs(next to the merge-graphs: a contract type both repos declare stays two unconnected nodes #3007same_type_aspass) andglobal_add.The pass adds edges only — no node merging, no renaming — so it composes with the prefixing and pruning already in place. Every edge it adds is tagged, and it drops its own previous output before recomputing, which is what makes an incremental
global addland exactly where a singlemerge-graphsof the same inputs does, and how a repo whose types moved stops answering for calls it no longer owns.Languages
Parking is wired into Java, C++, C# and Swift — every resolver that already has a receiver type and the same "declared nowhere in this corpus" bail-out. Three details the non-Java ones force:
all_raw_callswith every other language and its raw_calls carry nolangtag, so the parked entry's language comes from the declaring file's suffix rather than from a tag that isn't there.Nonefrom_resolve_type_name_nid, and only the first is a cross-repo candidate, so the park re-checks the bare-name index instead of trusting theNone.void bar();in a header) as a field carryingdefines, notmethod, so the merge-side member index is kept per relation: a C++ entry may fall back todefineswhen nomethodof that name exists, and for every other language adefinestarget is a field and cannot answer a call. When a class has both — header declaration plus out-of-line definition — the definition wins.Foo::bar()in C++ is also the shape of a namespace-qualified free function, and a Pascal-cased C# receiver may be a local rather than a type; in both cases the entry is parked by name and the merge's guards do the deciding, so the worst case is an entry nothing answers.Guards
Each has a test, because each is what keeps a name collision from fabricating an edge:
source_filesuffix matches the parkedlangGreeterbinds just as happily to a Python class of the same name — or to a C# onesource_filedefinesanswers for C++ onlydefinestarget is a field, and a field cannot be calledlangonly_LANG_SUFFIXEScannot be language-checked, so it is not acted onVerified
tests/test_cross_repo_member_calls.py— 19 tests: the happy path, the guards above, run-twice-no-duplicate, a repo that stops declaring the type losing the edge,merge-graphsthrough the real CLI, and an end-to-end per language that extracts two repos, parks in one and merges (Java field receiver, both C++ receiver shapes, C# field receiver, Swift property receiver). The language cases skip when their tree-sitter grammar is absent.Full suite before and after this branch: identical failure sets — 408 failed / 4563 passed on the base commit, 408 failed / 4582 passed here (+19, the new file). The 408 are pre-existing in my environment, mostly tree-sitter grammars I don't have installed (Python, Ruby, …);
test_serve_http.pyis excluded from both runs for an x86_64/arm64rpdsmismatch.Not included
add_nodenever emits_callable_class, so ObjC classes are invisible to the declaration index, and method labels keep the+/-sigil, so the merge's name key never matches a parkeddoThing. Filed separately rather than folded in here.methodedges (a method-less local class looks "absent") and TS parking would bypass TS: fabricated EXTRACTED calls edge via name-only type matching — third-party-typed field binds to unrelated same-named local class (v0.9.33/v0.9.35) #2553's import-origin gate.