← Back to blog
·8 min read

When a code graph should refuse to guess a call target

A wrong call edge is a false dependent forever. Why CGraph resolves a call only when one target is provable, drops the rest, and counts every drop it makes.

code-intelligenceai-agentsdeveloper-tooling

A caller node on the left reaches toward two identical candidate targets on the right; both dashed lines stop short with no edge drawn, while a single provable target below is joined by one solid line.

A code graph that guesses is worse than one that admits it doesn't know. A wrong CALLS edge is a false dependent forever after: it inflates every blast radius, it tells an AI coding agent that a change reaches code it never touches, and nothing downstream can tell it apart from a true one. So our open-source CGraph engine resolves a call only when exactly one candidate is provable. When it can't, it drops the call and counts it, in one of three named buckets written to stats.json on every build.

The problem: a dropped call is not a missing call

That distinction is the whole design. A dropped call is one the resolver considered, couldn't prove, and recorded. A missing call is one nothing ever saw, and it's invisible, which is what makes it dangerous.

We have shipped that failure. Kotlin was listed as supported and extracted nothing at all, reporting success and exiting zero on an empty graph. Nothing in the output distinguished "this repo has no calls" from "we parsed none of them." If an agent's impact query runs against a graph like that, it gets a confident, empty answer.

Drops are the opposite. Every call the resolver is answerable for lands in exactly one bucket, and three of those buckets are refusals:

std::size_t dropped_unknown = 0;    // nothing callable bears the name
std::size_t dropped_ambiguous = 0;  // candidates span more than one file
std::size_t dropped_self = 0;       // resolved to the caller itself

The resolved and dropped buckets must sum to the total. That's a spec requirement, not a convention, and the result ships in the build output as "balances": true. A call can't leave resolution without being accounted for. If you can't add up what your indexer discarded, you can't tell a quiet extraction failure from an honest refusal.

The rule, and its one exception

Resolution tries the caller's own file first, then a project-wide unique name, then, for member calls, the method-only index and the receiver's type. Every tier applies the same test: exactly one candidate, or nothing.

The exception is the interesting half. When every candidate for a name is declared in one file, they're an overload set on a single type, add(int) and add(String), and which one a call means can't be known without type inference the graph doesn't do. Rather than pick one, the call edges to all of them, graded INFERRED: the edges assert possibility, not certainty.

We rejected both obvious alternatives. Picking an overload arbitrarily leaves the others invisible to any reverse-dependency walk. Dropping the call outright was a regression we actually shipped and had to undo. The line the policy draws is this: edge to every candidate when the candidates are the same thing seen through different signatures; edge to none when they're different things that happen to share a name.

Six calls, one ledger

Here's the rule on four small Python files with six calls. storage.py and cache.py both declare write_text; report.py imports it from storage, and audit.py calls it without importing anything. A build writes this ledger:

{
  "balances": true,
  "dropped_ambiguous": 2,
  "dropped_self": 1,
  "dropped_unknown": 2,
  "resolved_member_method": 0,
  "resolved_overload_first": 0,
  "resolved_project_unique": 0,
  "resolved_rate": 0.16666666666666666,
  "resolved_same_file": 1,
  "total": 6
}

The six calls sorted into the ledger: cache.save to write_text in its own file is the one resolved call; json.dumps and fh.write are dropped as unknown because their receiver types are unknown; countdown calling itself is dropped as self; and report.render and audit.sweep calling write_text are dropped as ambiguous because two files declare the name. The four buckets sum to six, so balances is true.

Every number is explainable. json.dumps and fh.write are member calls on receivers whose types are unknown, so 2 unknown. countdown calls itself, so 1 self. cache.save calls the write_text in its own file, so 1 same-file. Both cross-file calls to write_text are dropped as ambiguous because two files declare the name. Built-ins like open() and len() never enter the tally, so the resolution rate isn't diluted by calls no project-local graph could ever resolve.

What refusing costs, by language

These are real repositories, built with CGraph at commit f6e0c06:

Repo Language Calls Resolved Unknown Ambiguous Self
stleary/JSON-java Java 10,421 3,897 6,329 10 (0.1%) 185
google/gson Java 22,545 6,023 15,945 368 (1.6%) 209
gorilla/mux Go 2,062 819 1,208 16 (0.8%) 19
pallets/itsdangerous Python 299 116 172 2 (0.7%) 9
BurntSushi/ripgrep Rust 15,691 8,984 5,665 731 (4.7%) 311
clap-rs/clap Rust 28,745 10,819 13,416 4,274 (14.9%) 236
tokio-rs/tokio Rust 39,944 11,481 19,948 7,162 (17.9%) 1,353
Nxtsoft/CGraph (src/) C++ 5,272 1,770 3,482 0 (0.0%) 20

Bar chart of the share of all calls dropped as ambiguous, per repository: tokio 17.9%, clap 14.9%, ripgrep 4.7%, gson 1.6%, mux 0.8%, itsdangerous 0.7%, JSON-java 0.1%, and CGraph's own source 0.0%. The three Rust repos sit far above the Java, Go and Python ones.

Ambiguity is a language property, not a constant. Rust's ambiguous bucket takes 14.9% of all calls on clap and 17.9% on tokio; Java's takes a tenth of a percent on JSON-java. Java call sites often name their type at the call, as in XML.toJSONObject(s), and CGraph's receiver tier scopes the lookup to that class, so the pile becomes a single provable candidate. Rust method calls are overwhelmingly self.foo() and value.foo(), where the receiver's type only comes from inference. No evidence, no edge.

A low resolution rate is mostly not a failure. The unknown bucket dominates every row, and most of it is calls into the standard library and third-party crates, code that's genuinely not in a graph of your repo. It also absorbs member calls whose receiver type is unknown, so it isn't a clean "third-party" counter, and reading it as one would overstate how well resolution is doing. The C++ zero is CGraph measuring its own source, where cross-file name collisions are rare.

When the source already says which one

Look at report.py again: it says from storage import write_text. It names the file, and at f6e0c06 the call was still dropped. Import evidence graded an edge's confidence; it didn't break ties. Delete cache.py's copy of the name and the ambiguous count goes 2 → 0 and the resolution rate 0.17 → 0.50, so the import was being read. It just ran after uniqueness had already decided whether an edge exists.

That was a gap, not a principle, and writing up this policy is what found it. It went upstream as CGraph#70 and was fixed in #71: an ambiguous candidate set is now narrowed to the declarations the caller's file imports by name, and the ordinary rule is re-applied to the survivors. Importing two declarations of one name still picks neither.

The payoff is real and smaller than the table implies. On tokio the fix recovers 365 calls of 7,162, or 5.1%, and 258 edges. On clap, 16. On ripgrep, 2. On the Java and Go repos, nothing. Most of what the ambiguous bucket holds is self.foo() and obj.method(), where no import names a declaration. A drop counter tells you where a graph gave up; it doesn't promise the reason is one you can fix.

A change that adds edges can quietly remove them elsewhere, so we checked rather than assumed: no repo lost an edge, balances still holds on all eight repos, and two runs over tokio still produce a byte-identical graph.json. The resolve phase measured 169–229 ms before and 182–217 ms after across three paired runs, against a total build of roughly 9.5 seconds. The new test was also checked to pin the fix: disabling only the new branch makes it fail.

Where refusing is the answer, not a gap

Some dependencies have no static edge to find. Reverting a flag change in ripgrep breaks 14 tests in tests/index/disallowed.rs, so those tests really depend on that code, but they exercise it by running the compiled rg binary as a subprocess, not through any function call. The dependency is real, and the operating system mediates it.

That boundary is why our Blastline test selection fails open and never certifies a test as safe to skip, and why checking an agent's refactor can refute a claim but never certify one. A graph that refuses to guess has to be paired with consumers that know what refusal means. Inventing the edge because a human can see the dependency would put a fabrication in the one artifact whose value is that it doesn't contain any.

Takeaway

When you put AI coding agents on a real codebase, the code-intelligence layer behind them decides what they believe a change touches. Ask of it the question this ledger answers: can it tell you what it discarded? With CGraph you can check your own repo in two commands:

cgraph --root . --out cgraph-out
jq .call_resolution cgraph-out/stats.json

If you're building agent tooling, retrieval, or impact analysis that has to be right rather than plausible, that's the AI and intelligent-systems work we do, and CGraph is MIT-licensed if you'd rather start on your own.

Building something like this?