22. September 2026
A tool that says “I don’t know” is annoying. A tool that guesses, and returns the guess in the same shape and the same confident tone as a fact, is worse than useless — because the thing reading it is an agent, and an agent has no way to tell the two apart.
symgraph indexes a codebase into a symbol graph — functions, types, the calls and imports between them — and serves it to a coding agent over MCP, the protocol Claude Code and similar tools use to reach external capabilities. I’ve written about why I built it and about using the same graph for review rather than generation. This post is about the month since, which went somewhere I didn’t plan.
The plan was features. What actually happened was a review — docs/review-2026-09-20.md, written against a codebase whose lint was clean and whose 186 tests were green — and then two days of working the findings off. Nineteen commits since 22 August, seventeen of them on the 19th and 20th of September, 57 files, +8,534 / −2,319. Almost none of it is new capability. Nearly all of it is the same capabilities, answering honestly.
Here is the function that everything routed through:
SELECT * FROM nodes WHERE name = ?1 LIMIT 1
No ORDER BY. Resolution of a reference to a definition was by name alone, with no check that the thing found could be the thing called. So a call to build could resolve to a struct field named build, or a module, or an import, or an enum member. Whichever row SQLite happened to hand back first — and which one that was could change between reindexes.
On symgraph’s own index, 603 of 3,777 calls edges — 16% — pointed at something that cannot be called. Those edges weren’t inert. They fed fan-in and fan-out, the coupling score, and cycle detection, all as if they were real dependencies.
A reference now resolves only to a kind it could plausibly denote. If there’s no compatible candidate it stays unresolved, and the health report counts it. The result I didn’t expect: after the filter, symgraph’s index has more call edges than before, 3,563 against 3,174, because rejecting the wrong candidate lets the resolver find the right callable instead of stopping at the first name match.
Resolution also now prefers a definition in a file the caller actually imports from, which cut arbitrary resolutions by a third, and prefers production code over tests and generated code with a stable tiebreak so the same index gives the same answer twice.
symgraph-implementations finds every implementation of an interface or trait. It had shipped, been documented, and been callable since the beginning.
Extraction never emitted a single implements or extends edge, in any language. The tool had therefore always answered “none found,” which is a perfectly well-formed response and reads exactly like a codebase with no implementations in it. Nobody reported it, including me, because “none found” is what you get when there’s nothing to find.
It now reads inheritance clauses across Rust, Java, TypeScript, JavaScript, Python, C++, C#, Kotlin, Scala, Ruby and Groovy. Go has no declared inheritance to read, so it has none to report. Indexes built before this are recognised as stale and rebuild themselves.
I keep coming back to this one because of what it says about testing. The tool had tests. The tests asserted the shape of the response, and the shape was right.
The coupling tools — module-graph, coupling-score, god-struct — folded test code into the architecture graph. About a third of the edges on symgraph’s own index came from test files.
The effect is worse than a bit of noise. A single test file that imports twenty modules to exercise them reads as a module that twenty production modules depend on. It lands at the top of the fan-in ranking, it joins cycles it has no business being in, and it distorts the coupling score for everything it touches.
Those three tools now report production code by default. --include-tests restores the old behaviour, and every report states which scope it used, because a coupling number without a stated scope is just a number. Deliberately unchanged: symgraph-callers and symgraph-references still show test callers. When you’re asking “who uses this?”, the tests are usually the point.
callers, callees and unused capped their results at twenty and returned "count": 20.
count meant “how many we’re giving you.” Every reader — human or agent — understood it as “how many there are.” For impact analysis, which is the entire reason you ask who calls a function, those two readings differ by exactly the amount that matters. There was no truncated flag and no way to ask for the next page.
It’s now total, shown and truncated, with limit and offset to page through. That’s a breaking change to the JSON, and it’s the change I’d make first if I were starting over.
Alongside it, every single-symbol result now says how many definitions shared that name and where the others live, and file / qualified_name narrow the lookup when the answer is ambiguous.
The through-line across all of it: several tools answered in a way that read as complete and exact when it was neither. A handful of the remaining fixes are the same disease in different organs.
delete_file dropped nodes before unresolved_refs, which holds a foreign key to it, so every reindex with a file list returned FOREIGN KEY constraint failed — into a warnings field that nothing surfaced."Error: " rather than setting the MCP isError flag. To a client, that’s a successful call returning a string.git log mis-keyed non-ASCII paths, so churn read zero for those files. Zero is a number. It looks like an answer.And status now reports what the index knows about itself: whether it’s current, whether the full-text index is intact, how many references went unresolved, and what share of names more than one definition answers to.
Symbol resolution is still name-based. It’s deterministic now, it reports its own ambiguity, it prefers imported files, and it won’t resolve a call to a struct field — but a method call on a receiver whose type can’t be inferred still matches any same-named definition.
The honest numbers on symgraph’s own codebase: 10.6% of names are shared by more than one definition, and 216 of 837 cross-file production calls — 26% — resolve to a file the caller doesn’t import. That residue is enough to merge modules that aren’t really cyclic. symgraph’s own module graph still reports one cycle spanning 33 of its 39 files, which is not a finding about symgraph’s architecture. It’s a finding about symgraph.
So the guidance shipped in the changelog is: fan-in, fan-out and the coupling ranking are reliable; treat a reported cycle as a prompt to go and look, not as a verdict. Writing that down is less satisfying than fixing it. It’s also the only thing that makes the rest of the output usable, because a consumer who knows which number to distrust can still use the other four.
Briefly, because it’s the ordinary half. One binary instead of two — symgraph-cli is gone, and symgraph serve runs the MCP server. The split had been quietly costing us: reindex, watch, completions and man existed only in the CLI binary, which meant they were missing from the binary the installers and the MCP bundle actually delivered. --format json now exists on all sixteen request types, seven of which were markdown-only. The CLI and the MCP server share their implementations, with a test that runs the real binary against the handler to keep them from drifting again. The database moved from an RwLock behind an unsafe impl Sync to a Mutex, and the crate now contains no unsafe at all. Tool calls run on the blocking pool instead of stalling a tokio worker. Both installers verify the download against the release’s published SHA256SUMS, cargo deny runs in CI, and the 1.90 MSRV is enforced by a job that builds against exactly that toolchain.
On performance: indexing streams in chunks rather than holding the repository in memory, a no-op incremental pass skips files whose size and mtime haven’t changed, and coupling analysis aggregates in SQL — 581 rows instead of 7,711 on symgraph’s own index, a ratio that grows with the codebase.
Every one of these defects produced a well-formed, confident, plausible answer. Not one of them crashed, and not one of them was caught by a test asserting the shape of a response — because the shape was always right.
That’s the recurring failure mode in tooling built for agents, and it’s sharper here than it is for human-facing tools. A human reading “Found 20 callers” for a function they wrote has a rough sense of whether twenty is the real number. An agent has no prior. It takes the answer, acts on it, and the wrongness surfaces three steps later as a change that broke something the impact analysis said was safe.
The fix isn’t better accuracy, or not only. It’s making the confidence of the answer track the confidence of the underlying evidence: say how many you returned and how many exist, say how many definitions shared that name, say whether the index is current, say which scope the number was computed over, and say plainly which of your own outputs you wouldn’t bet on. An agent given an ambiguity count can ask a second question. An agent given a single arbitrary answer can’t tell there was ever a choice to make.
Over-reporting is recoverable. Silent misattribution is not.