A code graph that guesses is worse than one that admits it does not know. A wrong CALLS edge is a false dependent forever after: it inflates every blast radius, it tells an agent a change reaches code it never touches, and nothing downstream can tell it apart from a true one. So CGraph resolves a call only when exactly one candidate is provable — and when it can't, it drops the call and counts it, in one of three named buckets written to stats.json on every build. This post is that policy: the rule, the one exception, what it costs per language, and the place where refusing is the end of the road rather than a bug.
A dropped call is not a missing call
The distinction is the whole point. A dropped call is one the resolver considered, could not prove, and recorded. A missing call is one nothing ever saw — and it is 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."
Drops are the opposite. CallResolution in src/engine/include/cgraph/operation_stats.hpp partitions every call the resolver is answerable for:
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
and asserts the partition is exhaustive:
[[nodiscard]] bool balances() const {
return resolved_same_file + resolved_project_unique + resolved_member_method + dropped_unknown +
dropped_ambiguous + dropped_self ==
total; // resolved_overload_first is a subset of same_file + project_unique
}
That invariant is not decoration. It is a spec requirement — "The partition SHALL sum to raw_calls_total, and every field SHALL be serialized to stats.json" — and its result ships in the build output as "balances": true. A call cannot leave resolution without being accounted for in exactly one bucket. If you can't add up what your indexer discarded, you cannot 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 enforces the same test: exactly one candidate, or nothing.
// 2. A project-wide unique label. An unknown name resolves to nothing; an
// ambiguous one is dropped UNLESS every candidate lives in one file — a
// true overload set (idiomatic in C++, Java, C#), which resolves exactly
// as the same-file tier does. A collision spanning files stays dropped:
// picking a module would be a guess, and that exactly-one-module rule is
// what keeps cross-file calls honest.
The exception is the interesting half. When every candidate for a name is declared in one file, they are an overload set on a single type — add(int) and add(String) — and which one a call means cannot be known without type inference we do not do. Rather than pick one, the call edges to all of them, graded INFERRED. Those edges assert possibility, not certainty. Picking arbitrarily would leave the other overloads invisible to any reverse dependency walk, and dropping the call outright was a regression we shipped and had to undo.
That is the line the whole policy draws: edge to every candidate when the candidates are the same thing seen through different signatures; edge to none when they are different things that happen to share a name.
Six calls, four files
Four Python files, six calls, one of each outcome. report.py:
import json
from storage import write_text
def render(rows):
body = json.dumps(rows)
write_text(body)
return body
def countdown(n):
if n <= 0:
return 0
return countdown(n - 1)
storage.py and cache.py both declare write_text, and audit.py calls it without importing anything. Run a build and read the 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 — 2 unknown. countdown calls itself — 1 self. cache.save calls the write_text in its own file — 1 same-file. And both cross-file calls to write_text, from report.render and from audit.sweep, are dropped as ambiguous, because two files declare the name.
Note what is not counted: open() and len() never enter the tally at all. Language built-ins are excluded before total increments, so the resolution rate is not diluted by calls no project-local graph could ever resolve.
The tie an import cannot break
Look again at report.py. It says from storage import write_text. It names the file. And the call is still dropped.
That is worth stating plainly, because it is a real limit of how CGraph resolves today: import evidence grades confidence, it does not break ties. Delete cache.py's copy of the name so only storage.write_text remains, change nothing else, and the same build produces:
sweep -> write_text INFERRED
save -> store EXTRACTED
render -> write_text EXTRACTED
dropped_ambiguous goes 2 → 0 and the resolution rate 0.17 → 0.50. render earns EXTRACTED precisely because its file imports the symbol; sweep, which resolves on a bare name match with no import behind it, is graded INFERRED — same edge, weaker claim. So the import is read, and it is load-bearing for confidence. It simply runs after uniqueness has already decided whether an edge exists.
The honest reading: the ambiguity rule is doing its job (two unrelated write_texts are two different things), but in this case the source said which one it meant and the resolver did not use that to choose. It is a gap, not a design principle, and it is the sharpest one this measurement turned up.
What refusing costs, by language
Every one of these is a real repo, built with CGraph at 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 |
Two things stand out.
Ambiguity is a language property, not a constant. Rust's dropped_ambiguous takes 14.9% of all calls on clap and 17.9% on tokio; Java's takes one tenth of one percent on JSON-java. That gap is evidence, literally. Java call sites frequently name their type at the call — XML.toJSONObject(s) — and the receiver tier added in CGraph#69 scopes the lookup to that class instead of the project-wide method index, so what used to be an ambiguous pile becomes a single provable candidate. Rust method calls are overwhelmingly self.foo() and value.foo(), where the receiver is a binding whose type only inference would give you. No evidence, no edge.
A low resolution rate is mostly not a failure. dropped_unknown dominates every column, and most of it is calls into the standard library and third-party crates — code that is genuinely not in the graph, because the graph is of your repo. But be precise about this bucket: it also absorbs member calls that missed every tier because the receiver's type is unknown. It is not a clean "third-party" counter, and reading it as one would overstate how well resolution is doing.
The C++ zero is the self-measurement caveat in the table — that is CGraph's own src/, where cross-file name collisions are rare and same-file overload sets take the exception path rather than the drop path.
The same rule, above the call level
Call resolution gets the ledger, but the refusal is a house style. File resolution applies it verbatim. A C-style #include "cgraph/types.hpp" resolves by matching the project file whose path ends with the spec — "Returns a match only when exactly one file qualifies, so an ambiguous spec yields no (wrong) edge." A Rust use a::b may live at b.rs or b/mod.rs, and both spellings are matched, "unique across the project or nothing (two candidates would make the edge a guess)." A crate name claimed by more than one src/ root in a workspace resolves to nothing rather than guessing between crates.
It runs the other way too, toward deleting things. An import stub that resolves to no project file is a third-party package; rather than leave a dangling leaf that looks like a dependency, the node and its edges are removed outright. And the semantic-link pass, which proposes non-call relationships by name, skips any name shared by more than eight nodes — constexpr std::size_t kMaxNodesPerName = 8; — on the grounds that a name that common is not evidence of anything.
Where refusing is the answer, not a gap
Some calls are not ambiguous, not unknown, and still unreachable — and no amount of resolution work will change it. The cleanest example we have is in ripgrep. Reverting a flag change breaks 14 tests in tests/index/disallowed.rs, so those tests genuinely depend on that code. But they exercise it by invoking the compiled rg binary as a subprocess — process::Command::new(rg) — not through any function call. There is no static edge to find. The dependency is real and it is mediated by the operating system.
That is the boundary of static reachability, and it is why Blastline treats its test selection as a superset that fails open and never certifies a test as safe to skip. A graph that refuses to guess must be paired with consumers that know what refusal means. The alternative — inventing the edge because a human can see the dependency — puts a fabrication in the one artifact whose value is that it doesn't contain any.
Check your own repo
The ledger is not an internal debug counter; it lands in the build output. Point CGraph at a repo and read the partition:
cgraph --root . --out cgraph-out
jq .call_resolution cgraph-out/stats.json
If dropped_ambiguous is a large fraction of total, your graph is dropping cross-file calls on name collisions and your impact queries are narrower than they look. If balances is ever false, that's a bug worth opening an issue over — it would mean a call left resolution unaccounted for, which is the one thing this design exists to prevent.