What a dependency graph finds that your coding agent misses
7m read time

What a dependency graph finds that your coding agent misses

I pointed a scanner at an internal app that agents had been working in for months. It took under two seconds and produced four tickets. Not one of the defects was in a file, which is exactly why nothing reading files had found them.

Yesterday I pointed a scanner at an internal customer-insights app I maintain. Coding agents have been working in it for months: two languages, just under seventy endpoints, a test suite that passes.

The scan took under two seconds. It read 588 files into roughly 6,700 components and 17,700 relationships, and by the end of the afternoon I had filed four tickets off the back of it.

Not one of those defects was in a file.

That is the whole post, so let me be precise about what it means.

Four of the resilience module's five exports reach only its test file, one reaches the running code

The agent reads files, and it reads them well

Give a coding agent a file and it will do a genuinely good job on it. It spots the off-by-one, the unhandled rejection, the wrong comparison operator. Point it at a function and ask what breaks, and it will trace the call sites it can grep for and usually get there.

I mean that as praise. Most defects are file-local, and file-local is where the current generation of tooling is strong. The limit shows up somewhere else: an agent in a large repository holds whatever it decided to open, and the repository around that stays out of view. It reads a neighbourhood and reasons about the city.

Some properties of a codebase only exist between files. Those are the ones nobody was going to find by reading carefully.

The four tickets

A circuit breaker that nothing imports. The resilience module exports five things: a retry helper, a timeout wrapper, the breaker class, and two configured breaker instances, one per upstream, each with its own failure threshold and cooldown. Somebody thought about this. Somebody picked five failures and sixty seconds for the CRM and three failures and thirty for the model API.

Production imports exactly one of the five. The retry. The timeout wrapper, the breaker class and both instances are reachable only from their own test file.

So the integration retries a hanging upstream with no timeout around it, and the breaker built and named for that upstream never sees a call. Retry without timeout is the worse of the two orderings: a slow dependency stops being one hung request and becomes several.

An output validator wired to nothing. The sanitiser module exports two functions: one cleans user text before it goes into a prompt and is wired into the chat endpoint, the other validates what the model sends back and is wired into nothing at all. Input to the model is sanitised. Output from the model is trusted.

A migration runner that only tests run. The module exists, it works, its tests are thorough, and its only inbound references come from the test file. Migrations are applied by hand and always have been. The runner reads as though that has been solved for a year.

Eight API wrappers with no callers, plus a second database pool module referenced by nothing anywhere. An integration client that was built wide and wired narrow.

The count underneath those four was the part that stayed with me: 76 components in the repository are reachable only from test code. Coverage looks healthy. Some of that coverage is testing code that nothing runs.

The defect is a missing edge

Open the resilience module and read it. It is fine. It is better than fine, it is careful, it has documentation comments showing you how to use the breaker. Nothing in that file is wrong.

Grep for the breaker and every hit reassures you: the class, the two instances, the export line, the test that proves it opens after five failures. An agent reasoning correctly from all of that concludes the feature is there.

The bug is the import that was never written. An edge that does not exist appears in no file, so no amount of reading files will surface it. You cannot grep for an absence.

That is why the answer is a graph rather than a better prompt. Once the repository is a set of nodes and edges, "which exported functions have no inbound edge from non-test code" is a query, and it takes milliseconds. It is the same move fitness functions make for boundaries, applied to reachability: stop asking people to notice, and compute it.

The same query shape answers the other question I actually care about before a refactor. The database pool has 149 dependants. The authentication check has 137. When an agent offers to "just clean up the auth helper", that number is the reason the answer is no until I have read the diff twice.

The confidence label is the point

There was a fifth thing, and it is the reason I trust the other four.

The scan flagged a dependency cycle in the frontend auth service, at certain confidence: token acquisition schedules a proactive refresh, and the scheduled refresh calls token acquisition. Two functions calling each other, textbook cycle.

I opened it, and it is deliberate. The token refreshes itself at 75% of its lifetime, the previous timer is cleared before a new one is set, and logout cancels it. The cycle is the design.

A tool that reported that as a defect would have burned the credibility of the other four. This one reports what it can prove and labels the rest: certain for a resolved AST edge, probable for the dead-code candidates. The warning prints next to the result: reflection, dispatch tables and framework conventions can reference something without leaving a static edge. The four tickets survived because I checked them by hand and the evidence pointed at the exact line.

Confident and wrong is the failure mode of everything else in this stack. A tool that hands an agent architecture facts is the one part that cannot afford it.

Doing this yourself

None of this needs my scanner. Knossos-MCP is what I built because I wanted the answers available to the agent rather than to a CI report, and it is pre-release, so build it from source or do not bother with it at all. The underlying move is older than any of this and available in whatever you already run.

Ask your codebase three questions it cannot answer by being read:

  • What is exported and never imported outside its own tests? knip answers it as asked: knip --production drops test files from the graph, so an export only its own test reaches surfaces instead of hiding behind a green suite. dependency-cruiser needs its reachable rule pointed at your production entry point plus a pathNot on your test glob, and the entry point is the half that does the work, so a pathNot alone reads as a clean bill of health. no-orphans asks a stricter module-level question and would have missed the module in this post. Vulture finds dead Python code but does not draw the test-only line, so on that stack the distinction is yours to build.
  • What has the most dependants? Not the biggest file. The most-depended-on symbol, which is rarely the same thing, and is the number that should govern how carefully a change to it gets reviewed.
  • What does this diff actually touch? The transitive set reached from the files it edits, so a review knows where to look.

Run the first one today on something an agent has been working in for a while. The four things I found had been sitting there long before any agent touched the repository, and the agents did not put them there. They just could not see them either, and now there are more files, faster, for a reader that only ever holds a few of them at a time.

The safety net was written, tested and never plugged in. Everyone read that file. Nobody read the space around it.

(7 of 7)