The moment before you hit accept
An agent has just proposed a change to a function called normalizeAmount. Three files, four symbols, one of them deleted. The diff reads clean. The two tests it names pass locally. You are one keystroke away from merging a change you have not traced.
Two questions decide whether that keystroke is reasonable, and neither is answerable by reading the diff. Before the edit: what does this function reach? After the edit: what actually moved, and which of the things I believed about this code an hour ago are now out of date?
Both are structural questions. Both have deterministic answers. Neither is a judgment about whether the change is correct.
Before: a bounded blast radius, with the bound stated
impact_of walks the static call graph backwards from a function and reports what transitively calls it. The report is a fixed shape: affected functions, files and modules, a crossesModuleBoundary flag, a band, the owning module's leverage, and — the part that matters most — a lowerBound flag, set whenever any call anywhere in the repository failed to resolve to a definition. The count of those unresolved calls is stated in the summary line itself.
impact_of({ function: "normalizeAmount" })
Blast radius of normalizeAmount (src/money/normalize.ts:41)
[moderate] 5 functions call normalizeAmount (transitively) across 3 modules — money is high-leverage (71); change its interface with care (lower bound — 218 repo-wide calls couldn't be resolved to a definition)
Modules affected: billing, ledger, money
Owning module leverage: 71/100
Callers (transitive):
d1 applyFee — src/billing/fee.ts:88
d1 toMinorUnits — src/money/convert.ts:15
d2 chargeInvoice — src/billing/invoice.ts:130 ~70% confidence
d2 postEntry — src/ledger/post.ts:64
d3 runSettlement — src/ledger/settle.ts:212
Exact call sites with their source lines: find_references({ symbol: "normalizeAmount" }). Read the target: read_function({ function: "src/money/normalize.ts#normalizeAmount" }).
The band is arithmetic, not judgment: zero callers is isolated, three or fewer is local, ten or fewer is moderate, anything more is wide. The depth prefixes (d1, d2) tell you how many hops away each caller sits. The confidence mark appears on any edge resolved below 0.9 — import-scoped, module-scoped and module-local resolutions all get flagged so you weight them differently from a same-file call.
The parenthetical about 218 unresolved calls is the honest part. The resolver refuses to guess across modules: a genuinely ambiguous same-named function produces no edge at all rather than a plausible-looking wrong one. That decision has a named failure behind it — resolving member calls by repo-wide name uniqueness once made every .every() array call in a codebase register as a caller of a middleware function called every, polluting its blast radius. Refusing to guess means some real edges are missed. The lower-bound flag is where that cost gets declared instead of hidden.
The empty answer is the one to get right
Zero callers is the highest-stakes result this tool produces, because "nothing calls it" is the claim an agent acts on when it decides to delete code. So the empty answer is pinned by a regression test:
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.
The test asserts what that string contains, and separately asserts what it must never contain: the sentence "changing this is isolated." That sentence used to be there. It was wrong about a function the graph could not see into, and it was removed by a test that now blocks its return.
After: a diff that describes its own scope
Once the edit lands in the working tree, check_my_changes answers the second question. Its first act is to state what it looked at — and, unusually, what it did not:
check_my_changes()
Changes vs HEAD 9f2c114ab3d1
Scope: tracked changes vs HEAD 9f2c114ab3d1 plus untracked CODE files. Not diffed: binary/oversized blobs, non-code files, unindexed languages, line-ending-only differences. Symbol diff covers FUNCTION declarations — type-only edits appear as file-level changes.
Changed files (3):
[modified] src/money/normalize.ts
[modified] src/billing/fee.ts
[added] src/billing/rounding.ts
Changed symbols (4):
[modified] normalizeAmount — src/money/normalize.ts:41
[modified] applyFee — src/billing/fee.ts:88
[added] roundHalfEven — src/billing/rounding.ts:7
[removed] roundLegacy — was src/billing/fee.ts:120
Boundary: 1 deep-import violation INTRODUCED by this diff (pre-existing, unchanged: 4):
src/billing/fee.ts → src/money/internal/scale.ts (reaches into money internals)
Blast radius (static call graph — dynamic dispatch and unindexed files are invisible): 6 functions transitively call the 2 modified symbols.
chargeInvoice — src/billing/invoice.ts:130
postEntry — src/ledger/post.ts:64
…
Removed symbols (1): roundLegacy — any remaining callers now reference a missing symbol; find_references({ symbol }) locates them.
Relevant tests (2 — route-labeled evidence, not coverage):
test/billing.test.ts [test-call]
test/normalize.test.ts [import-edge, name-convention]
Stale-assumption warning: you were served source from 1 of these files earlier in this session — src/billing/fee.ts. Re-read before relying on what you saw (fresh: true).
This is evidence, not a verdict: whether these changes should ship is a judgment about intent and correctness that stays with you.
(Abridged — the real output also carries a policy section and any files skipped from the symbol diff, named individually.)
Three details in there are load-bearing.
The scope line names its own omissions. A type-only edit does not appear as a symbol change, because the symbol diff covers function declarations. That is stated on every answer rather than left for you to discover when a renamed interface slips through.
Boundary violations are split by origin. One violation was introduced by this diff; four already existed and are counted separately. A tool that reported "5 violations" would make every diff in a codebase with existing debt look equally bad, which is the same as reporting nothing. The old state it diffs against is reconstructed and rebuilt as a graph, so "introduced" means introduced.
The stale-assumption warning is about your context, not the repo. The server tracks which files it served source from this session. If one of them turns up in the diff against HEAD, it says so. An agent that read fee.ts forty turns ago and is still reasoning from that text is reasoning from a file that no longer matches HEAD.
Three sibling tools split the same machinery by question. change_impact gives the blast radius of the whole diff plus the broken-callers note for removed symbols. diff_context returns the spans of up to three added or modified symbols with their direct callers and tests. boundary_check runs the deep-import and cycle check module-scoped or diff-scoped. And when the question is about one symbol rather than a diff, context_bundle composes source, callers, callees, signature types, tests, module context, recent commits and blast radius into a single answer under a 900-token budget, with section order set by an intent flag — understand, change, debug, or review.
Without git, every one of these degrades explicitly rather than silently: the answer names the reason and names a route that still works. check_my_changes points at repo_map, architecture_health and similar_logic_exists; change_impact points at impact_of; diff_context points at context_bundle.
Tests are evidence, and the word "coverage" never appears
tests_for has exactly three routes, and every hit is labeled with the ones that produced it:
2 test files for src/money/normalize.ts#normalizeAmount (route-labeled evidence, not coverage):
test/normalize.test.ts
[import-edge] imports '../src/money/normalize.js' resolving to src/money/normalize.ts
[name-convention] test filename matches normalize.*
test/billing.test.ts
[test-call] expectRounding calls normalizeAmount (name-level match)
Routes: import-edge (structural), name-convention (convention), test-call (static, name-level). Dynamic/parameterized test discovery is invisible — a hit list is evidence, never a coverage claim.
An import edge is structural. A filename convention is a convention. A static call from a test body is the strongest of the three and is still resolved by name. They carry different weight, so they are never merged into one number. And the empty answer is a fixed string that ends: "This is not a claim that the code is untested."
Why the working tree, and not a commit
Everything above depends on one architectural decision. A dirty working tree has no revision — it is not nameable in git's namespace — so a commit cannot be the cache key. The key is (path, content-hash, parser-version). That is the deliberate inverse of server-side code intelligence, which indexes commits and is therefore structurally blind to uncommitted work. As the architecture doc puts it: Euthynos exists for the edit loop, so it must see the edit.
The reason it is keyed to content rather than wall-clock time is a recorded incident. A TTL cache made the server contradict itself: read_function served a new function body while callers_of answered "not found" for the same function, and impact_of asserted isolation about a function that had just been renamed. The note in the source is blunt about why that matters — "No callers" is the claim an agent acts on to delete code, so a stale graph is not a performance detail, it is a wrong answer with consequences. There is no TTL anywhere in the system now.
What stays invisible
This is a static analyser. Dynamic dispatch, reflection, framework wiring, dynamic imports, require-by-variable and string-keyed indirection do not appear in the graph, and every affected answer says so in the answer itself rather than in documentation you would have to go find. Transitive traversal stops at depth 6 — "transitive callers" means transitive to six hops. Member calls are excluded from two of the resolution rules on purpose — unique-name and module-local — trading missed real edges for absent phantom ones. Call-graph precision is strongest in TypeScript.
The benchmark run against this engine found a genuine gap of exactly this kind. In a blast-radius task, all six valid sessions across both arms identified a caller inside a class method that the engine's own callers_of had missed — the parser extracted class method declarations but skipped class properties initialized with arrow functions, so their bodies' calls never entered the graph. That was fixed after the measurement was frozen. The frozen numbers were not re-run; the miss stays recorded as measured.
Does the evidence change anything measurable
On the blast-radius task, median fresh input tokens were 56,481 for an agent working alone against 39,242 with the MCP surface available — 31% fewer — with recall identical at 15 of 15 required answer-key slots in both arms. The preregistered false-positive trap did not fire: all six valid sessions correctly identified serializeSigned as a _serialize sibling rather than a caller, 6 for 6.
That number does not travel alone. One repository (hono at commit 26de7313), one model (claude-opus-5), one permission environment, three valid sessions per arm. Of 42 preregistered measurement sessions, 21 were valid under rules frozen before the first session ran. Two of the seven tasks — guided-edit and orientation — were never measured at all, because an external session limit consumed every session of both. That matters directly here: guided-edit was the edit-loop task, so the second half of this post has no measured token or recall number behind it. The figures above come from a blast-radius question, which is a before-the-edit question; check_my_changes and its siblings were exercised by no measured task. There is also no causal isolation of individual tools — the difference between arms is the whole surface, not impact_of specifically. Medians, no significance claims.
The conclusion this does not support
None of this establishes that a change is correct. The tool descriptions themselves say so, in the text shipped to the agent: check_my_changes is "Evidence and findings only — it never says a change is safe; that judgment stays with you," and change_impact ends with "Evidence only; the ship decision stays with you." A regression test enforces the forbidden phrasing, with a header that says the text is the contract and removals are a launch blocker.
What the two moments actually give you is narrower and more useful than a verdict. Before the edit, a bounded count with the bound declared. After it, a diff that names its own scope, separates what it introduced from what it inherited, and tells you which of your own assumptions expired while you were working. The decision is still yours. It is just made against structure instead of against a diff you skimmed.
Euthynos is an MCP server exposing 23 read-only tools over a local repository index. It is not yet published to npm at the time of writing.