merge-graphs / global add can never produce cross-repo member-call edges: the resolved receiver type is discarded at bail-out instead of persisted #3152
Description
Summary
Merging two graphs can never produce an instance/member call edge across the repo boundary, because
the receiver type is computed and then thrown away at every resolver bail-out. When the receiver's
type has no definition in the current corpus, if len(type_defs) != 1: continue drops the whole call
site, and nothing about it reaches graph.json — which is the only thing merge-graphs and
global add consume. So the information needed to complete the edge exists at extraction time, in a
variable, and is discarded milliseconds before the only artifact that outlives the run is written.
This is the one thing standing between per-repo graphs and a usable multi-repo call graph: A calling
b.method() on a type owned by B is the normal shape of a cross-repo dependency, and it is exactly
the shape that vanishes.
graphifyy 0.9.51 (v8), Python 3.10, macOS 14.
Reproduction
Two repos, plus a control that puts the same code in one corpus.
// repo-a/src/appa/App.java
package appa;
import libb.Greeter;
public class App {
private Greeter greeter = new Greeter();
void run() {
Greeter local = new Greeter();
local.greet("world");
this.greeter.greet("field");
}
}
// repo-b/src/libb/Greeter.java
package libb;
public class Greeter {
public String greet(String who) {
return "hi " + who;
}
}$ (cd repo-a && graphify update . --no-cluster)
$ (cd repo-b && graphify update . --no-cluster)
$ (cd both && graphify update . --no-cluster) # control: both files, one corpus
$ graphify merge-graphs repo-a/graphify-out/graph.json repo-b/graphify-out/graph.json --out merged.json
Merged 2 graphs -> 8 nodes, 7 edges[repo-a] nodes=5 edges=5
calls: .run() -> Greeter [EXTRACTED] <- to a SOURCELESS STUB, the type not the method
sourceless stub: repo-a::greeter / Greeter
sourceless stub: repo-a::libb_greeter / libb.Greeter
[both] nodes=6 edges=8 <- control
calls: .run() -> Greeter [EXTRACTED]
calls: .run() -> .greet() [INFERRED] <- THE edge that matters
[merged] nodes=8 edges=7
calls: .run() -> Greeter [EXTRACTED]
cross-repo edges: 0 <- .greet() unobtainable, both call sites
8 edges when the code lives together, 7 after merging the same code from two repos. The missing one is
App.run -> Greeter.greet, and no merge-time pass can recover it: repo-a's graph.json has no record
that a greet call ever happened. The type reference survives as a sourceless stub; the method call
does not.
The extractor knew the answer. From repo-a/graphify-out/cache/ast/v0.9.51-s2/*.json, both call sites:
{"callee": "greet", "caller_nid": "$graphify-root$_src_appa_app_app_run", "is_member_call": true,
"lang": "java", "receiver": "local", "receiver_type": "Greeter",
"source_file": "src/appa/App.java", "source_location": "L10"}
{"callee": "greet", "caller_nid": "$graphify-root$_src_appa_app_app_run", "is_member_call": true,
"lang": "java", "receiver": "this.greeter", "receiver_type": "Greeter",
"source_file": "src/appa/App.java", "source_location": "L11"}receiver_type: "Greeter" — already resolved, already serialized. Just not into graph.json.
Root cause
Every language's member-call resolver ends the same way. Java, extract.py:3600-3615:
type_name = raw_call.get("receiver_type")
if not type_name and receiver[:1].isupper():
type_name = receiver
if not type_name:
continue
type_defs = type_def_nids.get(key(type_name), [])
if len(type_defs) != 1:
continue # <- receiver_type known, call site dropped, nothing persistedSame bail-out: extract.py:2895, :3308, :3318 (C++), :3043 (Python), :3760, :3771, :3785
(ObjC), _resolve_csharp_member_calls at :3341. At each of those continues the frame holds
caller_nid, the receiver type name, callee, source_file and source_location — a complete
description of an unresolved call, discarded.
The single-definition guard itself is right: guessing between two same-named types would be worse than
nothing. The problem is that "cannot resolve here" is treated as "does not exist", when in a
multi-repo setup it usually means "defined in a repo not yet in this corpus".
Why the machinery for the fix is already there
- Node metadata survives the merge.
cross_repo_types.link_shared_type_declarations(merge-graphs: a contract type both repos declare stays two unconnected nodes #3007,
called atcli.py:2567) readsmetadata.namespaceafter repo-prefixing andnx.compose, and
adds edges from it. That is a working precedent for exactly this: carry a fact on the node, resolve
it after merge. - Lifecycle is free.
prefix_graph_for_globalcarries node metadata through and
prune_repo_from_graphremoves it with the node, so nothing new is needed forglobal add/
global remove. - The residue is already computed and already written to the AST cache shards. Shards are not
merge inputs, so the only missing step is getting the same tuple intograph.json.
Prior art worth a look: CodeGraph persists an unresolved_refs table and resolves it on later
indexing passes, which is the same idea with a database instead of node metadata.
Suggested shape
- Persist the residue instead of dropping it. At each bail-out, append to the caller node's
metadata (saymetadata.unresolved_calls) one entry per call site:
{"callee": "greet", "receiver_type": "Greeter", "line": "L10", "lang": "java"}. Only when a
receiver type was actually derived — an untypable receiver stays dropped, so no new guessing.
Dedup by(callee, receiver_type)and cap per node, so growth is proportional to genuinely
unresolved member calls rather than to corpus size. - Persist per-class receiver tables on the class node (
metadata.field_types), so a merge-time
pass can also type receivers the single-repo run could not type at all. This half is optional and
can land later — (1) + (3) alone covers the concretely-typed receivers, which is most of them. - A merge-time resolver pass, modelled on
link_shared_type_declarationsand called beside it at
cli.py:2568(and on theglobal addpath): for each node carrying residue, look up
receiver_typeamong the merged graph's type declarations, keep the same single-definition
guard, then emitcalls-> the method node contained by that type whose label matchescallee.
Add a language-family guard so a JavaGreeternever binds to a Python one. - Idempotent. Tag the emitted edges and clear-then-recompute on every merge, so re-merging or
adding a third repo does not duplicate. Keep the residue on the node after resolving — a repo added
next month may be the one that closes a still-open entry. - Edges only. The pass adds edges between existing nodes and never creates or renames nodes, which
keeps it composable with the prefixing and pruning already in place.
The result is language-neutral, and note it should yield more on concretely-typed languages than on
ObjC/Swift, since Java/C#/C++ already stamp receiver_type on the vast majority of member calls.
Happy to send a PR — (1) for one language plus (3) as a self-contained pass, so the shape can be
reviewed before it is repeated across the other resolvers.
Impact
Measured on a private 26-repo Objective-C/Swift codebase (0.9.39, per-repo graphs merged with
global add): 3,198 additional cross-repo call edges are recoverable from residue that today is
discarded — 2,007 from explicitly named receivers, 766 from locals typed by a new/alloc binding,
425 from @property/ivar-typed receivers. Against that, 12,866 call sites (29.0% of all [self.x …]
sites) have a receiver whose type is outside the unit's corpus, so 3,198 is what survives the
single-definition guard, not the gross figure. One iOS codebase, not a public-corpus number, and iOS is
close to the worst case for this — 27.9% of its receivers are id<Protocol>-typed and untypable in
principle, which is a share a Java or C# codebase would not have.
Note
Two narrower siblings from the same investigation, both independent of this one: #3150 (ObjC
field-table keys go stale in the id remap, so #2591's self.<field> typing is inert via the CLI) and
#3151 (field-typed receivers are not inherited from a superclass, Java and ObjC). Fixing those two
widens how many receivers get a receiver_type at all; this issue is about not throwing it away once
they do.
Activity
Park cross-repo member calls in C++, C# and Swift too (Graphify-Labs#…
feat(merge): finish member calls across a repo boundary (#3152)
Finish Objective-C member calls across a repository boundary (Graphif…
Park PHP member calls a merged graph can finish (Graphify-Labs#3152)
Park Kotlin member calls a merged graph can finish (Graphify-Labs#3152)
Park Kotlin member calls a merged graph can finish (Graphify-Labs#3152)
Park PHP member calls a merged graph can finish (Graphify-Labs#3152)
feat(go): park a member call whose receiver type is in another repo (G…
Park PHP member calls a merged graph can finish (Graphify-Labs#3152)
Park Kotlin member calls a merged graph can finish (Graphify-Labs#3152)
Park TS/JS member calls whose receiver type is declared in another re…
Finish Objective-C member calls across a repository boundary (Graphif…
Finish member calls across a repo boundary on merge (Graphify-Labs#3152)
Park cross-repo member calls in C++, C# and Swift too (Graphify-Labs#…
fix(csharp): resolve a member access to the receiver type's property (G…
10 remaining items
fix(csharp): resolve a member access to the receiver type's property (G…
Park PHP member calls a merged graph can finish (Graphify-Labs#3152)
Park TS/JS member calls whose receiver type is declared in another re…
fix(csharp): resolve a member access to the receiver type's property (G…
Park TS/JS member calls whose receiver type is declared in another re…
feat(cross-repo): link parked PHP member calls across a repo boundary
Finish Objective-C member calls across a repository boundary (Graphif…
feat(go): park a member call whose receiver type is in another repo (G…
feat(go): park a member call whose receiver type is in another repo (#…
feat(cross-repo): link parked PHP member calls across a repo boundary
Park TS/JS member calls whose receiver type is declared in another re…
Finish Objective-C member calls across a repository boundary (#3152)
fix(cross-repo): point a cross-repo call edge at its call site, not t…
fix(security): [HIMMEL-7187] sync to upstream 0.9.94 and floor the pa…
chore: [HIMMEL-7394] sync to upstream 0.9.95 and carry the himmel del…