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.

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
}
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 |
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.