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 could not break
Look again at report.py. It says from storage import write_text. It names the file. And at f6e0c06, the version measured throughout this post, the call was still dropped.
That was a real limit of how CGraph resolved: import evidence graded confidence, it did 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 was read, and it was load-bearing for confidence. It simply ran after uniqueness had already decided whether an edge exists.
The ambiguity rule was doing its job — two unrelated write_texts are two different things — but the source had said which one it meant and the resolver did not use that to choose. That is a gap, not a design principle.
Update, later the same day: it is closed. Writing this post is what found it; it went upstream as CGraph#70 and was fixed in #71. The project-wide tier now narrows an ambiguous candidate set to the declarations the caller's file imports by name and re-applies the ordinary rule to the survivors: exactly one resolves as EXTRACTED, several sharing a file resolve as an imported overload set, anything else stays dropped. Importing two declarations of one name still picks neither — an import that names two candidates is not evidence for either. A module-level import pkg deliberately does not break a tie: it names a file rather than a declaration, and the call it would justify is spelled pkg.fn(), a member access this tier never sees.
The payoff is real and smaller than the counts in the next section imply, which is the part worth carrying away. On tokio it recovers 365 calls of 7,162 — 5.1% — and 258 edges. On clap, 16. On ripgrep, 2. On the Java and Go repos, nothing at all. The reason is the same one that makes those counts large: most of what dropped_ambiguous holds is self.foo() and obj.method(), member calls whose receiver type is unknown, where no import names a declaration and this tier is never consulted. A drop counter tells you where a graph gave up. It does not promise the reason is one you can fix.
A change that adds edges is exactly the kind that can quietly remove them elsewhere, so three properties were checked rather than assumed. No repo lost an edge — every count moved up or stayed flat. The balances() invariant from the top of this post still holds on all eight repos, so nothing slipped out of the partition on the way in. And determinism survived: two runs over tokio still produce a byte-identical graph.json. Cost stayed inside the noise, with the resolve phase measuring 169–229 ms before and 182–217 ms after across three paired runs, against a total build of roughly 9.5 seconds.
The new smoke test, import_disambiguation_test.cpp, covers six cases: the tie broken by an import, the tie preserved without one, two imports of one name picking neither, an imported overload set edging to every member through resolve_overload_set, re_exports carrying the same proof, and a module import failing to break a tie. The suite reads 76/76.
More useful than the count is that the test was checked to pin its fix — disabling only the new branch makes it fail, rather than merely passing alongside a change that happens to be present. A test that passes with and without the code it covers is documentation, not verification. The rule and five scenarios also went into the deterministic-graph-pipeline spec, so the behavior is specified rather than only implemented.
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 |
These are the f6e0c06 figures, before the import fix above. After it, tokio's ambiguous column reads 6,797, clap's 4,258, ripgrep's 729, and itsdangerous's 0; every other row is unchanged. The shape of the table is the point, and the fix does not change it.
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.