# Code Graphs for AI Coding Agents: Better Repository Context and Safer Refactoring

You've probably watched this happen.

You ask an agent to add a rule to checkout. It opens `CheckoutPage.tsx`, then `CheckoutButton.tsx`, then `checkout.css`, because they all have "checkout" in the name, and after forty seconds of enthusiastic reading it writes a change in the wrong place. The actual logic was in `validateOrder`, which the agent never opened, because nobody named it `checkoutValidation`.

What gets me about this isn't the wrong edit. Wrong edits are normal; I make them too.

It's the *confidence*. A human who doesn't know where the logic lives asks, or hedges, or leaves a comment saying they weren't sure. An agent working from bad context does none of that. It produces something clean and plausible and completely misplaced, and hands it to you like it's finished. You can't debug uncertainty that was never expressed.

And the model wasn't the problem here. **Retrieval was.** The model answered the question it was given. Nobody gave it the right one.

In [Part 1](https://blog.ashishkrjha.dev/typescript-code-graph-compiler-api) I built a code graph over a TypeScript codebase: 2,795 nodes, 17,985 edges, reduced to an 873-symbol search index. This article is about pointing that graph at an agent, and what happened when I actually measured it.

Including the case where it made things worse. We'll get there.

## Why "just give it the repo" doesn't work

Take a real task: *add a minimum-order rule to checkout.*

Search by name and you get `CheckoutPage.tsx`, `CheckoutButton.tsx`, `checkoutSlice.ts`, `checkout.css`, `checkout.test.ts`. Everything with the word in it, and almost nothing that matters.

What actually governs the behaviour looks like this:

```text
CheckoutPage → calls → createOrder
createOrder  → calls → calculateOrderTotal
             → calls → validateOrder
validateOrder → used by → POST /orders, POST /order-preview
```

Not one of those names contains "checkout" past the first line.

The context you want isn't *files with a matching name*. It's the **dependency neighbourhood around the behaviour you're changing**, and those two sets overlap far less than feels reasonable.

This is the part I underestimated for a long time. I kept treating bad agent output as a model problem, because that's the framing everyone reaches for: better prompt, better model, more context. Meanwhile the agent was doing competent work on the wrong five files, over and over, and I was rewriting instructions at it.

This is what people have started calling context engineering, and it's where most of the leverage in agentic coding currently sits. Cursor, Claude Code, Aider's repo map and Sourcegraph all attack a version of it. A graph is just the version you can build yourself, tuned to conventions no generic tool knows about.

## Retrieve a neighbourhood, not a directory

Start from a symbol and walk outward. One hop:

```text
validateOrder
├── called by → createOrder
├── called by → previewOrder
├── calls → validateLineItems
└── calls → validateShippingRegion
```

Two hops:

```text
createOrder  → used by → POST /orders
             → writes-state → checkout
previewOrder → used by → CheckoutSummary
```

Now assemble a packet: the target symbol, its callers, its dependencies, the routes affected, the state it touches, the nearby tests, and the architecture rules that apply.

Eight to fifteen files. Which is considerably more useful than 150 files chosen because they share a word, however impressive the token count looks.

![04-retrieval-comparison](https://cdn.hashnode.com/uploads/covers/62db053c38759e6b49828665/e9a3936f-c915-4319-92fc-0c867915f01e.svg align="center")

The traversal itself is unglamorous:

```ts
function expand(graph: Graph, start: string, maxDepth = 2) {
  const visited = new Set([start]);
  let frontier = [start];

  for (let depth = 0; depth < maxDepth; depth++) {
    const next: string[] = [];
    for (const current of frontier) {
      for (const edge of neighbors(graph, current)) {
        const candidate = edge.from === current ? edge.to : edge.from;
        if (!visited.has(candidate)) {
          visited.add(candidate);
          next.push(candidate);
        }
      }
    }
    frontier = next;
  }

  return [...visited];
}
```

The next improvement isn't going deeper. It's being pickier about which edges you follow.

## Weight your edges

For business logic, `calls` and `writes-state` matter enormously. Importing a CSS file or a shared `<Button>` matters approximately not at all. And yet an unweighted traversal treats them identically, which is how you end up with your design system in the context window.

```ts
const edgeWeights = {
  calls: 10,
  "writes-state": 10,
  "publishes-event": 9,
  "handles-route": 9,
  "reads-state": 8,
  renders: 5,
  imports: 2,
};
```

Obvious when written down. Retrieval systems fail to encode obvious things with impressive regularity.

## Tell the agent what *not* to touch

Good retrieval isn't only about surfacing code. It's about surfacing boundaries.

Say your layering is UI → service → adapter → vendor SDK, and the agent proposes this:

```ts
// CheckoutPage.tsx
import paymentSdk from "payment-sdk";
```

A graph-aware check catches the new `component → vendor SDK` edge and rejects it before merge.

You could instead write "never import the SDK directly" in your instruction file and hope.

I've tried hoping. It works most of the time, which is somehow worse than failing outright. You stop checking, and then one Tuesday it doesn't, and the violation is already three commits deep. Prompts help. But an executable constraint has the notable advantage of not forgetting on a Tuesday.

```js
// UI components cannot import repositories
const violations = graph.edges.filter(
  e => e.kind === "imports" && isComponent(e.from) && isRepository(e.to)
);
```

The cheapest rule to implement first: **deprecated modules cannot gain new callers.** Store a baseline count, fail if it grows. You may not get to delete the old module this quarter (you probably won't) but you can stop it spreading, and unlike most architecture rules it needs no classification logic whatsoever.

## Blast radius, before the edit

An agent wants to change `normalizeCustomer`. The graph knows it has five direct callers, and that those callers hang off an HTTP route, a nightly import job, and two event consumers.

```text
Target: normalizeCustomer

Direct callers:           5
Affected HTTP routes:     1
Affected background jobs: 2
Affected event consumers: 2
Nearby tests:            11

Risk indicators:
- high fan-in
- used by async worker
- crosses into the billing boundary
```

A crude score turns that into a ranking signal:

```ts
risk =
  directCallers * 2 +
  affectedRoutes * 4 +
  backgroundJobs * 4 +
  externalApis * 5 +
  stateWrites * 3;
```

Please don't turn this into a sacred number that gates your CI. The question it answers is *which changes deserve extra care*, not *can I mathematically prove this line is dangerous*. Software has never once agreed to be that cooperative.

## Documentation is the other half

Some things the compiler simply cannot know.

If `OrderPreviewService` deliberately uses a cached price snapshot, because recalculating would make previews change after the customer approved them, no amount of static analysis recovers that reasoning. But the graph knows `OrderPreviewService → calls → getPriceSnapshot`, so when a task touches either symbol, that decision record can be pulled into context automatically.

Clean division of labour: **the graph knows what exists and what connects; the docs explain why the weird parts are deliberate.**

### Keep the root doc a router

Your agent instruction file (`AGENTS.md`, `CLAUDE.md`, whatever your tool reads) should hold only what *isn't derivable from the code*, with a hard size limit. Mine is capped around 300 lines, which is about 6,200 tokens paid on every single session. When a section outgrows that, it's become a decision record.

Four things earn their place:

**What does NOT belong where.** A two-column table per folder: what belongs, and what doesn't. The negative column is the one that does the work. Every architecture doc tells you where things go; almost none tell you where things must not go, and a helper in the wrong folder is one the next person will never find, which is where duplication comes from in the first place.

**A domain glossary.** Business term → code term → API name. Without it, neither a new hire nor an agent can map a ticket onto a folder.

**Confusing pairs, written from bugs that actually happened.** In my codebase three different things are called some variant of "event," and two similarly-named entities are genuinely distinct objects that must never be merged. That section exists because both have already cost someone an afternoon.

**Off-limits without discussion.** Generated files, vendored code, anything external config might reference by name.

Don't start from a template. Start from your last five *"why is it like that?"* Slack threads and your last five review comments saying *"we already have this."* That's the content. It's already written; it's just scattered across conversations nobody can search.

### Decision records that end in "Do not"

Not every decision deserves a record. The ones that do are the decisions a reasonable engineer will eventually try to *simplify*. Usually on a Friday, usually with good intentions.

So every ADR I write ends with a **Do not** section, phrased as the sentence that engineer would say to themselves:

```md
# ADR: External API access stays behind adapters

## Context
The vendor SDK has unstable response shapes and inconsistent errors.

## Decision
Feature code calls typed adapter functions.

## Do not
Do not import the SDK directly inside a feature module,
even if your new feature only needs one endpoint.
```

That last section is the difference between a decision log and a guardrail. A normal ADR explains a choice to someone who reads it. A "Do not" line intercepts someone who was never going to read it, because it's the phrase they'll grep, or the phrase retrieval surfaces at the moment it matters.

Two rules keep this honest. **ADRs are append-only.** Supersede, never edit, so a record is either current or explicitly historical with no third state where it quietly disagrees with the code.

### And now the embarrassing part

The second rule is that these documents rot, and a confidently wrong router is worse than none at all.

I can be specific about this, because when I re-ran my measurements I audited the docs against the code at the same time. Here's my own paperwork:

| Document | Claimed | Actually |
| --- | --- | --- |
| Tooling reference | 1,149 source files, 106 state slices | 771 files, 45 slice folders |
| Readiness checklist | Thunks "excluded from the index", filed under *not done yet* | 145 thunks and 73 selectors indexed |
| Rebuild time, in two places | 15–20 seconds | 13.1 seconds |
| A decision record | Slice count off by roughly 5x | n/a |

Every one of those was written by someone careful. I know, because it was me. I'd written the negative-space table and the append-only rule in the same sitting, feeling quite good about myself.

Nothing in my process caught any of it. There was no moment where the docs announced they'd gone wrong. They just sat there, confident and slightly false, for months.

The second row is the one that stings. A "not done yet" list advertising a gap that was closed months ago will send the next person off to rebuild something that already works. That isn't documentation sitting there being harmlessly stale. That's documentation actively wasting someone's afternoon.

Tie doc review to convention changes rather than the calendar, and audit the docs whenever you regenerate the graph. The graph is the only thing in your repo positioned to catch your prose lying.

### Correct for the model's priors, too

One more failure mode, running in the opposite direction.

The first review my instruction file got flagged that the model kept adding memoization everywhere. The reviewer's diagnosis: its training data skews toward older React, where you had to. Newer versions handle much of it automatically.

That isn't the model lacking knowledge of my codebase. It's the model having *too much* knowledge, of the wrong vintage, applied with total confidence.

So an instruction file has two jobs. It encodes what's true about your repository, and it corrects what the model assumes about your framework. The second kind is easy to skip because it doesn't feel like documentation. It feels like arguing with a very self-assured junior. Write it down anyway:

```md
## Version-specific: override your priors

- React 19: do not add useMemo/useCallback defensively.
  The compiler handles most of this. Memoize only with
  a measured reason.
```

Both kinds go stale, in opposite directions. Your code drifts from your docs. The model's assumptions drift from your framework. The router file sits between them and is the only thing watching either.

## Diff the graph after the patch

Before: `Feature → Service → Adapter → External API`. After: same chain, plus a shiny new `Feature → External API`.

It compiles. The tests pass. Nothing is red. And an architectural boundary just moved.

The graph delta catches it:

```text
Graph delta

+ CheckoutPage → calls → validateMinimumOrder
+ validateMinimumOrder → reads-state → pricingRules
- CheckoutPage → imports → legacyValidation

No new cross-layer imports.
No new dependency cycles.
```

Easier to review than a 600-line diff you're scrolling through at 5pm hoping the architecture reveals itself.

The general principle: **check effects, not just generated text.**

## The whole loop, assembled

Put the retrieval half and the verification half together and you get something that looks less like "an AI writes code" and more like a pipeline with the model as one stage in it.

![06-agent-loop](https://cdn.hashnode.com/uploads/covers/62db053c38759e6b49828665/3c8e9c22-2bbe-4213-839a-84e193cd49ff.svg align="center")

Steps one to four decide what the model is allowed to see. Steps six to eight check what it did with that. The agent itself only ever occupies step five, which is a considerably smaller share of the system than the marketing around agents would have you believe.

That framing is useful when something goes wrong, because it gives you somewhere specific to look. Bad output means either the packet was wrong or the model was wrong, and those live in different stages with different fixes.

## So what does it actually save?

Time to stop asserting and measure.

I took six real discovery questions from my own codebase and ran each two ways. Through the symbol index, and through the exploration you do without one. Orient in the tree, grep a guess, grep again because the first guess missed, open files to confirm.

Real commands, real output, three repetitions, median reported.

| Question | Tool calls | Tool output (tokens) |
| --- | --- | --- |
| Is there a helper that runs a paginated API query? | 1 / 4 | 1,000 / 3,488 |
| Is there an existing loading spinner? | 1 / 3 | 267 / 4,380 |
| Where is the role flag derived, and is it authoritative? | 2 / 3 | 603 / 4,690 |
| Is there a date formatting helper already? | 1 / 3 | 759 / 5,108 |
| Is there something that chunks oversized query batches? | 1 / 3 | 643 / 2,103 |
| What shape does a new feature slice follow? | 2 / 5 | 997 / 2,434 |
| **Total** | **8 / 21** | **4,269 / 22,203** |

Cells read *with index / without*.

**81% less output for the model to read. 13 fewer tool calls.**

![05-benchmark](https://cdn.hashnode.com/uploads/covers/62db053c38759e6b49828665/fb9330df-aeb8-4207-8be5-e35bdd75ff55.svg align="center")

The call count is what drives cost, because every call is a round trip that re-sends the whole accumulated conversation as input. Counting the final answer turn: 14 round trips against 27. Model those at current large-model pricing and the cost difference lands near 49%.

I swept the assumptions. Harness context from 4k to 80k tokens, output per turn from 200 to 4,000, latency from 3 to 20 seconds. It stays in a 48-51% band, because the result is driven by measured call counts rather than my guesses.

**Now the caveats, which matter more than the percentage.**

The without-index path is a *modelled* sequence, not an observed agent run. I wrote those commands based on what unaided search looks like here. A sharper agent might do the first task in two calls. A flailing one might take eight. The output volumes are real measurements of what those commands return. The baseline's shape is my judgement.

Token counts come from an estimator, not a token-counting endpoint. Call it ±10–15%. Applied identically to both sides, so the ratio holds even if absolutes drift.

And this prices *discovery only*. It doesn't price the thing the index mainly exists to prevent, which is an agent writing a second `formatDate` because it never found the first. That cost lands in review, or next sprint, or in a bug six months out. No single-session benchmark catches it.

Read 49% as a floor.

## And when it makes things worse

Here's the number I suspect most posts like this quietly skip.

The instruction file loads every session whether anyone searches or not. 6,179 tokens, paid up front, every time. Against an average saving of about $0.076 per discovery task, it pays for itself **within the first task of a session**.

But the worst realistic case, with the instruction file loaded and the index never queried and the agent grepping anyway, comes out **9.1% more expensive** than having no toolchain at all.

That's the downside bound. One avoided duplicate helper repays it many times over, so I'll take that trade all day. But if your own benchmark doesn't contain a number like this, you haven't finished measuring. You've finished marketing.

You may also find plain lexical search handles most of your tasks fine. Good. Use the graph where structure genuinely helps. The goal isn't to justify the elaborate thing you built.

## Separate the retriever from the agent

This distinction does more work than anything else here.

When an agent makes a bad change: was the model weak, or did retrieval never surface the critical dependency? Different failures, different fixes. Track them together and every failure becomes "improve the prompt," including the ones caused by the agent never seeing the file.

| Task | Retrieval | Agent | Tests |
| --- | --- | --- | --- |
| Tax rounding | Pass | Pass | Pass |
| Retry handling | Pass | Fail | Fail |
| Eligibility rule | **Fail** | Fail | Fail |

On row three the model failed because the retriever never surfaced the shared rule. No amount of prompt engineering fixes that, though I'm sure someone has tried for a week.

## Start with retrieval, not autonomy

If I were introducing this somewhere new I wouldn't begin with an agent editing production code.

Start here: developer asks a question → system finds relevant symbols → system shows the neighbourhood → developer picks what to read. Then: agent proposes context → developer reviews → agent proposes a patch. Automate further only after you've measured retrieval quality.

You get useful tooling immediately, and it survives the model layer changing underneath you, which it will, roughly every four months. The graph describes your repository, not somebody's API.

## Quick answers

**Is this just RAG for code?** It's the retrieval half, with structure instead of embeddings. Chunk-and-embed finds text that *reads* similar. A graph finds code that's actually connected. Good systems use both.

**How is this different from a repo map?** A repo map is usually a flat summary handed over up front. A graph is queried per task, so context scopes to the change instead of the codebase.

**Do I need this with Cursor or Claude Code?** On a small codebase, probably not. Their retrieval is good. It starts paying off when your repo has conventions no generic tool knows, which is exactly what those framework-specific edges encode.

**Does this work outside TypeScript?** The concepts do. The implementation leans on the TypeScript type checker for cross-file resolution; you'd need an equivalent.

## Building the graph itself

This article assumed the graph exists. For the implementation, covering `Program`, `TypeChecker`, symbol resolution, framework-specific edges and intent search, start with Part 1:

[**Build a TypeScript Code Graph with the Compiler API**](https://blog.ashishkrjha.dev/build-a-typescript-code-graph-with-the-compiler-api)

## The takeaway

The best use of a code graph in AI-assisted development isn't a prettier architecture diagram. It's **controlling context**.

What should the agent read. What should it not touch. What else might this change break. Which tests matter. Did this patch just move a boundary.

That's a retrieval and verification layer around the agent, and a far better foundation than "AI that can read the repo," which is not a strategy so much as a hope with a large context window.

The part I didn't expect: almost none of this work was about AI. Naming things findably, writing down which decisions are load-bearing, admitting where the docs had drifted. That's the same work that makes a codebase survivable for the humans in it. The agent just made the cost of skipping it visible, and put a number on it.

Which, honestly, is more than any of my previous attempts to care about documentation ever managed.

* * *

*I'm Ashish Kr Jha, a software engineer. I build things in production and then have to live with them, which is the part that actually teaches you something. More at* [*ashishkrjha.dev*](https://ashishkrjha.dev)*.*
