AI engineering
Why grep loses and a graph wins
Ask an assistant to rename a function and it will search for the name. That sounds reasonable until you notice it has answered a completely different question from the one you asked.
You ask an assistant to rename getUser. It runs a search, finds eleven matches,
changes them, and reports success.
It missed the one inside a template string. It changed an unrelated method on
another class that happened to share the name. And it never touched the export
that three other packages depend on, because that file doesn’t contain the word
getUser at all — it re-exports something else entirely.
The tool worked perfectly. It answered the wrong question.
Before you read on
What does `grep -r 'getUser' .` actually tell you?
-
It cannot know that. Grep has no idea what a function is. It found a sequence of characters — some are that function, some are a comment, a different class's method, or a string.
-
Exactly, and that's the whole problem. Text search operates on characters. Your question was about a symbol and its relationships, which characters cannot express.
-
That's the question you wanted answered and the one furthest out of reach. It needs the call graph and the import graph, neither of which exists in a text match.
What’s actually happening?
To a search tool, a file is a flat run of characters. But the code inside it has structure: this function calls that one, this module imports that module, this route handler touches that table.
That structure is a graph — a word that sounds more academic than it is. A graph is just things, and the connections between them. Nodes are the things: files, functions, classes, routes, tables. Edges are the connections: calls, imports, extends, reads-from.
Think of the difference between a phone book and a contact tracing map. The phone book tells you every person named Smith. The map tells you who actually spoke to whom. Only one of those helps you work out what spreads.
Flat matches, no relationships
Eleven hits, with no way to tell which are the same symbol, which are unrelated, or what depends on any of them.
$ grep -rn "getUser" .
api/users.js:14 export function getUser(id)
api/users.js:52 return getUser(req.params.id)
admin/panel.js:8 import { getUser } from '../api/users'
legacy/Session.js:91 getUser() { return this._u }
docs/api.md:30 `getUser(id)` returns a user
# which are the same function?
# grep cannot say. Things, and what connects them
One symbol, its definition, everything that reaches it and everything it reaches. The unrelated method is simply a different thing.
function:getUser (api/users.js:14)
<- called by route:GET /users/:id
<- called by admin/panel.js
<- imported 3 modules
-> calls db.query
-> reads table:users
class:Session.getUser (legacy/Session.js:91)
# separate node. no edge to the above. How does it get built?
You don’t write it by hand. It’s derived from the code, which is why it can’t drift out of date the way documentation always does.
Each file is turned into a syntax tree — the structural form of the code rather than its text. This is the step that knows a function declaration is a different thing from the same word sitting inside a comment.
This is the root of why a graph beats a regex: the parser understands the language's grammar.
Walk the tree and record the things: every function, class, module, export, route. Each gets an identity that stays stable however it's referred to elsewhere.
function:getUser@api/users.js:14
class:Session@legacy/Session.js:3
module:admin/panel.js
route:GET /users/:id The harder half. A call to getUser has to be tied to the specific definition it means — following imports, scope and aliases. Getting this wrong is what produces the confidently incorrect rename.
panel.js: import { getUser } from '../api/users'
-> resolves to function:getUser@api/users.js:14
-> NOT class:Session.getUser Resolution is where cheap tools quietly give up and fall back to matching names.
With edges in place, communities appear — groups of nodes that talk to each other far more than to anything else. These are the real modules of your system, as opposed to the folder layout, which is a filing decision somebody made two years ago.
Folders are where files live. Clusters are how the code behaves.
Now the useful part: ask for the impact of a change, the path between two things, or the subgraph for one feature — and hand exactly that to the assistant instead of the whole repository.
impact("function:getUser")
-> 3 modules, 1 route, 6 tests
path("route:POST /checkout", "table:orders")
-> handler -> CartService.commit
-> db.insert -> table:orders Why does it matter?
Here is where it stops being a nice diagram and starts being the difference between an assistant that works and one that doesn’t.
A model has a finite context window — a hard limit on how much it can hold at once. You cannot paste a large repository into it. So the real engineering question is never “how clever is the model”, it is which few thousand tokens go in.
Search answers that badly. It hands over the twenty files containing the word, ranked by nothing much. Half are irrelevant, and the file that matters most — the caller two hops away that never mentions the word at all — isn’t there.
A graph answers it well: start at the thing, walk two hops, take that subgraph. Small, complete, connected.
Before you read on
You're changing a database column. What should go into the assistant's context?
-
This misses code that touches it through an ORM model or a `select *`, and includes migrations from three years ago that are historically interesting and currently irrelevant.
-
It doesn't fit, and where it technically does, the signal gets buried. More context is not better context: retrieval quality falls as the irrelevant fraction rises.
-
That's the blast radius, and it's exactly what a graph returns. Small, complete, and it includes the caller that never mentions the column by name — the one search would have missed.
What are the limitations?
A graph is overhead, and it is not free. On a small codebase an assistant can simply read end to end, it buys you very little and costs you a build step.
It starts paying when the codebase is too large to read in one go, when changes have non-obvious blast radius, when you’re doing repeated structural work like migrations or dead-code removal, or when several agents work in parallel and each needs a different correct slice.
The honest costs: parsing and resolution take real engineering, resolution is genuinely hard in dynamic languages, and the graph has to be kept fresh as the code changes. A stale graph is worse than no graph, because it is confidently wrong in a way grep never is.
What this means for you
- Before asking an assistant for a structural change, ask how it decided which files to look at. If the answer is a text search, expect it to miss the callers that don't mention the name.
- Judge context by relevance, not volume. Twenty loosely-matching files is worse than four connected ones — the irrelevant fraction is what degrades the answer.
- A graph earns its keep on size and blast radius. On a small repo, skip it; on one nobody can hold in their head, it's the thing that makes 'what will this break?' answerable.
Next time an assistant offers a rename across your codebase, ask it what depends on the thing it's renaming. If it can only tell you where the word appears, review that diff by hand.