NxtSoftLabs
← All writing

Why a code graph can't resolve obj.method(): receiver types

September 4, 2026·8 min read

A static call graph can resolve obj.method() only when something in the source proves what obj actually is. Without a type, the callee is any method named method anywhere in the repo, and picking one is a guess. This is the largest remaining hole in CGraph's resolution: on tokio-rs/tokio, 17.9% of all calls land in a drop bucket rather than an edge, and the import-evidence fix that closed the tie-breaking gap recovered 365 of 7,162 — leaving 6,797. This post is what those calls look like, the five tiers of evidence that already exist, and why the remaining ones are hard rather than merely unfinished.

Five tiers, and exactly where they stop

Resolution is not one lookup. resolve_raw_calls in src/engine/graph_builder.cpp tries progressively weaker evidence and stops at the first thing it can prove:

  1. A declaration in the caller's own file. Strongest and cheapest. Same file, same name, done.
  2. A project-wide unique label. One declaration anywhere bears the name. If several do, they resolve only when they all live in one file — a true overload set, idiomatic in C++, Java, and C# — or when an imports/re_exports edge names the exact declaration the caller meant. Otherwise: dropped_ambiguous. Member calls are excluded from this tier entirely, and that exclusion is the whole subject of this post.
  3. A receiver that names its own type. XML.toJSONObject(s) says XML outright. If a declaration by that exact name owns a method with the called name, the lookup scopes to that declaration's methods.
  4. A uniquely-named method. If exactly one method in the repo bears the name, a member call binds to it. Several sharing one file are an overload set and every member gets an edge.
  5. Interface dispatch. implements and dispatches_to edges, built for Go at #47 and for the JVM at #68, route a call through a contract to its implementors.

Tier 2 is where the asymmetry lives. A bare-name call like write_text(...) can be matched project-wide because the name is the whole reference. A member call's name is only a property of some receiver — nextTo collides with every top-level function called nextTo and with every other type's method of that name. Matching it project-wide would not be resolution, it would be a coin flip with an edge attached.

So member calls stay scoped to what can be proven about the receiver. When nothing can be, the call is dropped.

Evidence is a property of the language, not of the analyzer

The published per-language numbers make this concrete. Calls dropped as ambiguous, as a share of all calls in the repo:

RepoLanguageCallsAmbiguous
tokio-rs/tokioRust39,9447,162 (17.9%)
clap-rs/clapRust28,7454,274 (14.9%)
BurntSushi/ripgrepRust15,691731 (4.7%)
google/gsonJava22,545368 (1.6%)
stleary/JSON-javaJava10,42110 (0.1%)
Nxtsoft/CGraph (src/)C++5,2720 (0.0%)

Two orders of magnitude separate Rust from Java on the same resolver. That gap is not a Rust deficiency in CGraph — it is a difference in how much the call site itself discloses.

Java code says the type out loud, constantly. Static calls name their class: XML.toJSONObject(s). Declared locals name their type one line up. That is why tier 3 pays off so dramatically there — in JSON-java's XMLTest.java, every XML.toJSONObject(...) call had produced no edge at all, because toJSONObject is declared in eight files and the project-wide tier saw eight candidates spread across files. Adding the receiver tier took that file from 0 edges to 619 pointing at XML.java, with 0 pointing at the similarly-named JSONML.java. Test reachability went 0.853 to 0.929.

Rust call sites disclose almost nothing. Method calls are overwhelmingly self.foo() and value.foo(), where the receiver is a binding whose type came from inference — let x = something(), and something's return type is itself often generic. self is worse than a variable: it names a type only relative to the impl block containing it, which is structural context the extractor would have to carry, not text at the call site. There is no XML to match. Tier 3 has nothing to bite on, so Rust falls to tier 4, and when a method name is not unique repo-wide, the call is dropped.

Which bucket a lost member call lands in

Worth being precise, because reading the ledger wrong will mislead you about where the work is. An unresolvable member call does not have one fate — it has two, depending on which tier it died in:

  • It reaches dropped_ambiguous when a tier considered a set of candidates and could not narrow it — for instance an overload set in the caller's own file that fails to resolve.
  • It reaches dropped_unknown when it misses every tier outright. The final fallback in the resolver is explicit about why: "A member call that missed its own file and did not uniquely name a method: the receiver type is unknown, so no wider guess applies."

That second bucket is the one that flatters a naive reading. dropped_unknown dominates every column in every repo, and it is tempting to read it as "calls into the standard library and third-party crates" — code genuinely not in the graph, because the graph is of your repo. Much of it is exactly that. But it also absorbs member calls that had a resolvable target sitting in the graph and simply could not be tied to it. dropped_unknown is not a clean third-party counter, and treating it as one will overstate how well resolution is doing.

The guess that was measured and refused

The obvious shortcut here is naming convention. An instance is usually named after its type — jsonWriter.setStrictness() on a class JsonWriter — and CGraph's make_id already folds case, so binding the two is nearly free. It was implemented, measured, and rejected.

The rejection reasoning is worth stealing. That match is a convention, not proof: the identical rule binds a variable named writer, of some unrelated type, to a class named Writer. And a wrong CALLS edge is not a cosmetic error — it is a false dependent forever after, showing up in every impact query and claiming a change reaches code it never touches.

Both variants were measured side by side:

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. Case-folding does additionally lift gson, 0.839 to 0.879 — but it buys that on convention rather than evidence, so it stays a separate opt-in change rather than the default. When the cheap heuristic and the provable rule score the same on the repo you can verify, ship the provable one.

What would actually close it

The honest answer is local type inference, and it is a different class of work than anything above. Every tier that exists today is a lookup over facts already extracted — a name, a file, an import edge, a method owner. Inference is not a lookup. It means tracking bindings through a function body, resolving generic returns, and following impl blocks to know what self is. That is a type checker growing inside a graph extractor.

Three things make that trade unattractive today:

  • It is per-language, not general. Tier 3 generalized across the JVM and Go because "a receiver that literally names a declaration" is a textual property. Inferring let x = foo() requires knowing Rust's type system specifically, then Kotlin's, then TypeScript's. The recurring lesson of multi-language extraction is that the concept generalizes and the grammar never does — and type systems generalize even less than grammars.
  • Extraction is meant to stay cheap and deterministic. The whole diff-on-every-PR story rests on a build that finishes in seconds and produces a byte-identical graph from the same tree. Partial inference is where determinism gets subtle.
  • Partial inference produces confident wrong answers. A resolver that infers correctly 90% of the time emits false dependents the remaining 10%, indistinguishable downstream from true ones. The refuse-to-guess policy exists precisely because that trade is bad at any accuracy short of certainty.

Meanwhile the cheaper wins are not exhausted. Transitive re-export chains are still unfollowed — A → B → C needs a direct edge today. Declared-type locals in Java and C# are textual, not inferred, and would extend tier 3 without a type checker. Each of those is a lookup over evidence that already exists in the source, which is the category that has paid off every time.

What is true today

obj.method() resolves when the receiver names its type, when the method name is unique in the repo, when the candidates form a single-file overload set, or when an interface contract routes it. It does not resolve when the receiver is a binding whose type only inference would supply — and on an idiomatic Rust repo that is most method calls.

The number to hold onto is 6,797: the ambiguous drops that survive on tokio after every tier above has run. That is not a bug list. It is the measured size of the gap between what a call site says and what a call graph needs — and it is counted, in a bucket with a name, in stats.json on every build. A graph that could not tell you that number would be the more worrying tool.

The resolver, all five tiers, and the tests that pin them are open source — read the code on GitHub. If you want the number for your own repo, cgraph --root . --out cgraph-out and read the drop partition in stats.json.