NxtSoftLabs
← All writing

Java interface dispatch: resolving s.area() in a code graph

August 25, 2026·8 min read

Shape s = ...; s.area() used to produce no edge at all in a CGraph code graph. area is declared on the interface and again on every implementor, so name-based resolution saw a pile of equally plausible candidates, correctly refused to guess, and dropped the call. The consequence is worse than a missing arrow on a diagram: a test that exercises an implementation through its interface has no path to the code it covers. CGraph#68 and #69 close that gap for Java. This post is what it took, including the regression the first fix caused.

Refusing to guess is the right default, and it is expensive

A call graph that guesses is worse than one that admits ambiguity. A wrong CALLS edge is a false dependent forever after — it shows up in impact, it inflates blast radius, and it tells you a change reaches code it never touches. So when resolution finds several candidates with no way to choose, dropping the call is the correct behavior.

The cost is that "several candidates" describes every polymorphic call in an object-oriented codebase. Refusing to guess, applied consistently, means an interface-heavy repo gets a graph with a hole exactly where its architecture is.

The fix is not to start guessing. It is to find evidence that was there all along and being thrown away.

Why Go's fix did not simply work for Java

CGraph already had interface dispatch. #47 built it for Go, with implements and dispatches_to edges, and the resolver resolve_interface_dispatch was written to be largely language-agnostic. Pointing it at Java still saw nothing, because two Java-specific facts blocked it upstream:

1. Java reuses method_declaration for interface bodies. Go has a distinct method_elem for contract methods, so they arrive tagged and recognizable. Java's arrive as ordinary method nodes, indistinguishable from concrete ones. Emitting separate contract nodes the way go_extra_walk does would have duplicated them.

The resolution was to let the owning declaration carry the distinction instead of the method: interface_node_types marks interface_declaration, the walk tags the class node, and dispatch resolution reads a method owned by an interface as a promise rather than an implementation. A side effect worth having — those methods also stop counting toward a concrete type's own method set, which is what they always should have been.

2. obj.method() is not a member access in Java's grammar. It parses as a method_invocation with object and name fields and no member-access wrapper, so call_member_node_types never matched and the call was recorded as a plain one. Since the dispatch rescue requires is_member_call, it never fired. A new call_receiver_field marks a call whose receiver is named by a field on the call node itself.

This is the recurring shape of multi-language extraction: the concept generalizes, the grammar never does. The same lesson turned up when Kotlin's grammar exposed no named fields at all.

The regression: fixing member calls broke every overload

Gap 2's fix reclassified obj.method() as a member call, which routed it through the method-only resolution tier. That tier resolved only an exactly-one candidate — so every call to an overloaded method lost its edge. Measured on stleary/JSON-java: 346 lost CALLS, including real ones like tokener.nextTo(char) and nextTo(String).

Tier 2 had always rescued this for non-member calls, using a rule from issue #52: if the candidates all live in one file, they are an overload set on a single type, so edge to all of them — without type information any member may be the callee, and the union is honest where a pick would be a guess. Tier 2b now applies the same rule to member calls. Candidates spread across different files stay dropped, because that is a genuine cross-type collision.

Worth noting how this was caught. It was not a failing unit test — it was a net-edge count moving the wrong way on a real repository. A change that adds a capability while silently deleting 346 edges elsewhere passes every test that only asserts the new thing works.

When the receiver names the type

#69 closes a second, related hole. In JSON-java's XMLTest.java, every XML.toJSONObject(...) call produced no edge — 0 edges from XMLTest.java to XML.java, out of 863 outgoing calls in that file. toJSONObject is declared in eight files, so the project-wide tier saw eight candidates spread across files and dropped them all.

But the call site says XML. The receiver names the class outright, and that was evidence being discarded. RawCall now carries a receiver_label — the receiver's text when it is a bare identifier, empty for a chain or computed receiver, since those name no single thing. Resolution gained a tier ahead of the blind project-wide one: if the receiver names a declaration that owns a method with the called name, scope the lookup to that declaration's methods.

The owner is selected by "owns a method with this name" rather than by global name-uniqueness, and that detail is load-bearing. XML labels both the class and its same-named constructor node, so a uniqueness test finds two candidates and gives up — which is exactly why an earlier attempt at this changed nothing.

Result: XMLTest.java to XML.java went from 0 edges to 619, with 0 edges to JSONML.java. The receiver scoped it precisely rather than broadening it.

Exact case, on purpose

Here is the design decision I find most worth stealing. CGraph's make_id folds case. Reusing it for the receiver match would also bind an instance named after its type — jsonWriter.setStrictness() to the class JsonWriter — which looks like a free win. It was rejected.

That match is a naming convention, not proof. The same rule equally binds a variable named writer, of some unrelated type, to a class named Writer. And a wrong CALLS edge is a false dependent in impact. Both variants were measured:

variantJSON-java reachgson reachJSON-java links
before0.8530.82911,767
exact case (shipped)0.9290.83913,172
case-folded0.9290.87914,013

Exact case captures the entire JSON-java win with 841 fewer edges. The case-folded variant does additionally lift gson — but on convention rather than proof, so it stays a separate, opt-in change if it is ever wanted.

The reachability gains from the dispatch work itself, measured with blastline's testReachability on the same binary before and after:

repobeforeafterdispatches_toimplements
google/gson (interface-heavy)0.7580.8290 → 1820 → 155
stleary/JSON-java (constructor-heavy)0.8480.8530 → 220 → 10

Net edges: gson +1001 gained / 58 lost; JSON-java +58 / 7 lost. Every remaining loss is a guess the design intends to drop — it.hasNext() on a java.util.Iterator had been binding to XML.java's private hasNext, which was never right.

What true edges cost downstream

More correct edges make test selection broader, and that is the honest outcome rather than a regression. On Blastline's 20-commit replay of JSON-java, co-changed recall went 8/14 to 14/14 — all five behaviorally-confirmed dependency misses, the ones a dependency oracle proved real by reverting the code and watching tests break, are now selected. Mean subset grew 0.435 to 0.499: the price of edges that should have been there all along.

There is a known second-order effect here. When Go gained interface dispatch at #47, one implementation's edit began cascading through all its sibling implementers, and Blastline needed a dispatch barrier in v0.8 to stop it — mean subset fell from 45.9% to 30.3% with every co-changed test still selected. Java now has the same edge shape, so it is a reasonable guess that it will want the same treatment.

What is true today

Java interface dispatch resolves, overloads survive it, and a receiver that names its class scopes the lookup. Verification was 74/74 and then 75/75 ctest with no regressions, and both new tests were checked to pin their fix — disabling only the relevant tier makes each fail at exactly its own assertion, rather than merely passing alongside the change.

Being precise about status: this work is what moved Java to replay-verified in Blastline, joining TypeScript, Python, Go, and C/C++. The bar is one benchmark repo where every semantically selectable author-co-changed test is selected, and JSON-java's 20-commit replay now reads 14/14.

Kotlin did not come with it. Its graph is legible — cashapp/turbine reaches 0.625, well clear of the 0.25 floor — but reachability is not the bar, and no Kotlin replay has been run. So Kotlin stays advisory, informing rather than gating. The Blastline project page tracks what each family has earned.

If you have an interface-heavy Java repo, the fastest way to see the difference is to graph it and look for dispatches_to edges: cgraph --root . --out cgraph-out, then open cgraph-out/graph.html. The resolver, the tiers, and the tests that pin them are open source — read the code on GitHub.