The file in front of you has half the graph
You ask an agent what breaks if you change serialize(). It opens src/utils/cookie.ts, reads the function, and reads the imports at the top of the file. Then it starts guessing.
This is not a model failure. It is a property of the file. The first ten lines of that file are a complete, machine-readable list of its outbound edges: every module it imports, every symbol it pulls in. Its inbound edges appear nowhere in it. No source file in any mainstream language carries a record of who calls it. That record exists only inside the callers, in files the agent has not opened.
So the agent has two moves. Read more files until it feels confident, or extrapolate from a name. Both are unreliable, and the second one is unreliable in a way that looks exactly like being right.
Grep is wrong in both directions at once
The obvious move is a text search for the function name. It over-matches and under-matches simultaneously.
Over-matching: the name appears in a doc comment describing the old behaviour, in a string literal used as a telemetry key, in a changelog entry, in a test fixture, in an unrelated serialize defined in a vendored directory. None of those are call sites. A text search cannot tell you which is which, because it does not know what a call site is.
Under-matching is worse, because it is invisible. import { serialize as ser } renames the symbol at the boundary; every real call site in that file now reads ser(...) and your search returns nothing. Re-export chains hide the name behind an index file. Destructured requires do the same thing.
The result is a list that is too long and incomplete at the same time, with no way to separate the two without reading every hit. That is the work the agent was trying to avoid.
None of this makes text search useless. It is still the correct check for the things a static graph structurally cannot see, which is why Euthynos's own answers tell you to run one. It is just not an answer to "who calls this".
The obvious fix invents edges
The next idea is to resolve calls by name against an index of declarations. If exactly one function named every exists in the repository, then any call to every() must be a call to that one. Clean, cheap, and it produces a real graph.
We shipped that rule. Then we ran it over hono. The comment that now sits above the resolver records what happened:
A member call's name is usually a built-in (
arr.every(),p.then(),list.Select()); resolving it by repo-wide name uniqueness invents edges (hono: every.every()array call became a caller of theeverymiddleware, polluting its blast radius).
Every array iteration in the repository became an inbound dependency of a middleware function it had never touched. The failure mode is what makes it dangerous: the phantom edges arrive at high confidence with a real file:line attached. Nothing about the output looks wrong. An agent reading that blast radius would confidently describe a change as risky for reasons that do not exist, and a human spot-checking one or two entries would find real files with a real .every( on the cited line.
A later variant of the same bug turned up in a different rule and was caught by a benchmark agent: module-local resolution wired Array.prototype.every() in middleware/language.ts to the every middleware sitting in the same module. Same class of phantom, different route in.
Both rules are now restricted to bare calls. A call written foo() and a call written receiver.foo() carry completely different amounts of evidence about what foo is, and the resolver treats them differently.
Five tiers, each with a named reason
Every call edge in the graph carries a confidence weight and the rule that produced it. The resolver tries them in order (src/graph/knowledge.ts:160-230):
| Weight | Reason | Rule |
|---|---|---|
| 1.0 | same-file | A definition in the same file. Unless that hit is the caller itself and other definitions exist, so a method delegating to a same-named service method does not self-shadow its own target. |
| 0.9 | unique-name | Exactly one definition repo-wide. Bare calls only. |
| 0.7 | import-scoped | Several definitions, and exactly one lives in a module the caller's module actually imports. |
| 0.5 | module-scoped | Two to four remaining candidates, all inside one imported module (an interface and its implementations). Edges to each. The graph over-approximates here and says so. |
| 0.6 | module-local | Exactly one candidate inside the caller's own module, recovering sibling calls import-scoping cannot see. Bare calls only. |
And then the line that costs the most:
otherwise ambiguous →
[](we never guess across modules)
Two functions named validate in modules the caller does not import produce no edge at all. Not a low-confidence edge, not a best guess: nothing.
What comes back
callers_of walks the resolved graph and returns direct and transitive edges with locations. On hono at 26de7313:
callers_of({ function: "src/utils/cookie.ts#serialize" })
9 transitive callers of serialize:
d1 fetch — src/client/client.ts:58
d1 generateCookie — src/helper/cookie/index.ts:78
d2 hc — src/client/client.ts:137
d2 setCookie — src/helper/cookie/index.ts:99
d3 testClient — src/helper/testing/index.ts:21
d3 deleteCookie — src/helper/cookie/index.ts:141
d3 cacheLanguage — src/middleware/language/language.ts:221
d4 detectLanguage — src/middleware/language/language.ts:238
d5 languageDetector — src/middleware/language/language.ts:292
For the exact call-site lines with their source: find_references({ symbol: "serialize" }).
To read one of these: read_function({ function: "file#name" }).
Not one of those rows carries a confidence mark, which is itself information: every hop on every one of those paths resolved at 0.9 or better. Marks appear only when they are earned. Any edge weaker than 0.9 is tagged inline, and on a transitive row the tag carries the weakest edge along the path — so a ~50% confidence row means at least one hop came from the over-approximating tier, not necessarily the last one.
find_references is the lower-level view. It labels each hit by kind — definition, call, member-call, import — instead of merging them, because those are different facts:
find_references({ symbol: "serialize" })
8 references to 'serialize':
import src/client/client.ts:3
import { serialize } from '../utils/cookie'
call src/client/client.ts:101 in fetch()
cookies.push(serialize(key, value))
import src/helper/cookie/index.ts:7
import { parse, parseSigned, serialize, serializeSigned } from '../../utils/cookie'
call src/helper/cookie/index.ts:85 in generateCookie()
cookie = serialize('__Secure-' + name, value, { path: '/', ...opt, secure: true })
call src/helper/cookie/index.ts:87 in generateCookie()
cookie = serialize('__Host-' + name, value, {
call src/helper/cookie/index.ts:94 in generateCookie()
cookie = serialize(name, value, { path: '/', ...opt })
import src/utils/cookie.test.ts:2
import { parse, parseSigned, serialize, serializeSigned } from './cookie'
definition src/utils/cookie.ts:261
export const serialize = <Name extends string>(
The source line travels with each hit, so triaging a reference list does not cost one file read per row. Worth noticing what is absent: serializeSigned, nine lines below serialize in the same file, is nowhere in either answer. It routes through the shared internal _serialize, not through serialize. A text search for the name puts it near the top of the hits.
The uncomfortable half
A graph that refuses to guess produces a floor, not a total. Everything below is a consequence of that, and all of it is stated in the tool output rather than only in the docs.
Unresolved calls are counted and reported. impact_of carries a lowerBound flag and prints the repo-wide unresolved-call count directly in its summary, in the form (lower bound — N repo-wide calls couldn't be resolved to a definition). That number is the size of the blind spot, in-band, on the same line as the answer. In one M2 blast-radius session on hono it was roughly 1,673, and the session carried the figure into its own answer.
Transitive means six hops. The BFS in src/graph/query.ts defaults to maxDepth = 6 for both directions. A caller seven levels up is not in the list.
An empty answer is scoped to the instrument. When nothing indexed calls a function, impact_of returns:
No callers found in the static call graph — nothing indexed calls it.
Boundary: dynamic dispatch, reflection, framework wiring and unindexed
files are invisible to the graph; confirm with a text search for the
name before treating a change as isolated.
callers_of does the same, naming "no callers in the graph", "static calls in indexed source only", and telling you to verify with a text search. Those exact strings are pinned by executable tests, not a style guide, so a refactor that quietly drops the boundary fails the suite.
Resolution quality is not uniform across languages. It is strongest in TypeScript. Other languages parse and contribute symbols and imports, but resolution varies, and some parsers do not record import lines yet, which shows up as file:? in a reference list rather than as a silent omission.
The hole our own benchmark found
In M2 we measured agent sessions on hono against frozen answer keys. Every one of the six valid blast-radius sessions, in both arms, identified src/client/client.ts:101 as a direct caller of serialize. Our callers_of had missed it.
The root cause, pinned by a regression test written before the fix: the class-member loop extracted method declarations and skipped class properties initialized with arrow functions or function expressions. In hono's ClientRequestImpl, fetch = async (…) => { … serialize(…) } is a property, not a method declaration, so it was never extracted as a node at all — and the calls in its body never entered the graph. Not an ambiguity refusal. A parser gap.
It was fixed after M2, in the launch candidate at engine 5b4b6dc, and verified live on hono at 26de7313: callers_of serialize now reports the client.ts fetch property function — the d1 row at the top of the listing above. The frozen M2 numbers were deliberately not re-run, so the miss stays recorded as measured.
Scope for anything quoted from that run: one repository (hono), one model, one permission environment, 21 of 42 sessions valid under preregistered rules, and 2 of the 7 tasks (guided-edit and orientation) unmeasured because an external session limit consumed every session of both. There is no causal isolation of individual tools; the arm difference is the whole MCP surface. Within those bounds, on the three fully measured tasks, recall against the answer keys was perfect and identical in both arms (42 of 42 required slots each, from 4 + 5 + 5 key slots across 3 reps) while the Euthynos arm used 13–31% fewer fresh tokens at the per-task medians.
The useful lesson is not that precision-first resolution prevents bugs. It obviously did not prevent this one. It is that a missing edge in a graph that refuses to guess is findable. One absent row against a frozen key is a defect with an address. The same miss buried in two hundred grep hits of mixed provenance is indistinguishable from noise, and nobody would ever have filed it.
A floor is a useful number
Euthynos is a static analyser. It does not see dynamic dispatch, reflection, string-built symbol names, string-keyed indirection, framework wiring, or files it did not index, and it says so on the answers where that matters. It provides evidence. It does not certify anything about a change, and its own test suite forbids the vocabulary that would imply otherwise.
What it gives you instead is an inbound edge list where each edge names the rule that produced it, the ambiguous cases are absent rather than fabricated, and the size of what it could not resolve is printed next to the answer. That is a lower bound you can reason about. A confident list of plausible-looking phantoms is not.