Skip to content

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

Closed

Description

@xiongjianxu

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 persisted

Same 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 at cli.py:2567) reads metadata.namespace after repo-prefixing and nx.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_global carries node metadata through and
    prune_repo_from_graph removes it with the node, so nothing new is needed for global 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 into graph.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

  1. Persist the residue instead of dropping it. At each bail-out, append to the caller node's
    metadata (say metadata.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.
  2. 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.
  3. A merge-time resolver pass, modelled on link_shared_type_declarations and called beside it at
    cli.py:2568 (and on the global add path): for each node carrying residue, look up
    receiver_type among the merged graph's type declarations, keep the same single-definition
    guard
    , then emit calls -> the method node contained by that type whose label matches callee.
    Add a language-family guard so a Java Greeter never binds to a Python one.
  4. 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.
  5. 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

  1. 10 remaining items

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