A code graph turns a repository into symbols and relationships, so an AI agent can ask "what breaks if I change this" instead of reading five files and guessing. On my Rust and Go repositories it paid for itself immediately. On Svelte it was broken, and broken in the worst possible way: it answered confidently and wrongly. So I wrote a new extractor, measured it against a separately written checker across every component in two projects, and sent it upstream as a drop-in replacement.
Explaining one Rust function cost 1,111 characters from the graph against 187,512 characters to read the file it lives in, a 169× difference for that question. On Svelte, the median component contributed 1 node to the graph before the fix and 12 after. The new extractor was then checked against a separately written implementation across all 455 components in two projects, one of which I deliberately never looked at while building it: 7,571 symbols, zero discrepancies.
The problem: agents reading code blind
I run several separate repositories: a Rust backend of roughly 500 files, a PBX backend in Go, a CRM backend in NestJS, a SvelteKit frontend and a couple of smaller apps. A number of AI agents work across them daily. The same pattern kept repeating.
An agent needs to change a function signature. To find out what breaks, it reads the file. Then the file next to it. Then it greps the name, gets thirty hits, reads five of them, runs out of context and guesses the rest. That is expensive, slow, and worst of all the answer is often incomplete without anyone noticing.
What the agent actually wants is not file contents. It is a map: what is this symbol called, who calls it, what does it import, what breaks if I touch it. That is a graph, not a wall of text.
Which is what Graphify does. It parses a codebase with tree-sitter and builds a graph whose nodes are files, functions, types and symbols, joined by edges such as imports_from, calls and contains. Then you query the graph instead of reading files.
Five real repositories, no toy examples
I pointed it at my actual repositories, read-only, with nightly rebuilds. No sandbox and no curated demo. The first build of the Rust backend, 505 files, took 54 seconds and produced 7,751 nodes and 23,558 edges, which works out at 15.3 nodes per file. The Go backend came out at 1,731 nodes and 4,510 edges and builds in a couple of seconds.
Three things impressed me.
Asking beats reading
I asked for an explanation of one Rust function. The answer gave me its signature, what it calls, who calls it and which module it lives in, in 1,111 characters. Reading the file it sits in would have been 187,512 characters.
A 169× difference for this one question. Note the unit: I measured characters, because that is what I could count exactly. I did not measure tokens. A tokeniser would compress a dense Rust source file and a short prose answer at different rates, so do not assume the token ratio is also 169.
One measured case, not an average across all questions. The point is the order of magnitude: for "what is this and what does it touch", a graph answer is in a different league from feeding in the file.
It found connections I did not know existed
The query that pays for the whole installation is what breaks if I change this. I ran it on a function that looked like dead code. The graph found four call sites, several in modules I had not connected in my head. Had I trusted my own reading, I would have deleted it.
Density says something about the language
The Rust repository yielded 15.3 nodes per file, the NestJS one 6.6. That is not a quality score for the graph, it is a reasonably honest reflection of how much named structure each language carries per file. And it was here that I saw something was fundamentally wrong with Svelte.
Svelte did not work. At all.
The SvelteKit frontend had 233 .svelte files. The graph averaged 2.79 nodes per file, and the median was 1. Meaning the typical component contributed nothing beyond its own file node. The mean was inflated by import stubs, not by symbols.
One component in my system is 280 lines with 12 props, two $derived values, a function and two snippets. The graph had one node for it.
The cause showed up when I read the extractor. It fed the entire .svelte file to tree-sitter's JavaScript parser. Svelte markup is not valid JavaScript. The parse was dominated by an ERROR node at the top level, the extractor's queries matched nothing inside it, and only a regex pass rescued the imports. Tree-sitter does recover from errors and will often still build partial subtrees, so this is a statement about what this pipeline emitted rather than about what the parser can do.
affected on a function called by four components answered "No affected nodes found." To an agent that reads as "this is dead code". A tool that fails visibly gets checked. A tool that is confident and wrong gets trusted.
The fix: parse Svelte as Svelte
I wrote a new extractor. The core idea is simple.
- Split the file into regions with a real scanner, not a regex, so that a
<script>inside an HTML comment, or tags inside quoted attribute values, cannot fool the split. - Parse the script block separately with tree-sitter TypeScript, then re-offset the resulting positions by the block's byte offset in the original file, so line numbers in the graph point at the real file rather than at a clipped fragment.
- Parse the template on its own. Component usage in markup becomes real edges: which component renders which, with a line number.
- Emit nodes for what is actually navigable: the component itself,
$props()props using the public name with the local alias kept when renamed,$stateand$derivedbindings, every function includingconst f = () => {},{#snippet}blocks, exported constants and local types.
Two details look pedantic and are decisive.
Component usage has to be the uses relation, not renders. Graphify's affected query walks a default list of relations, and renders is not on it. A renders edge would have been completely invisible to precisely the query the whole installation exists for. I put the semantic detail in context instead.
Then the symbol-id bug. Graphify's make_id casefolds the name, which means type State and let state in the same file collapsed to the same id, and the second symbol disappeared silently. No warning, no error line, just one symbol fewer in the graph. I did not find that by looking at the output. I found it because I built a measurement.
Results
Same repository, same command, before and after.
| Measure | Before | After | Factor |
|---|---|---|---|
| Nodes per file (mean) | 2.79 | 16.51 | 5.9× |
| Nodes per file (median) | 1 | 12 | 12× |
| Edges touching a .svelte node | 2,698 | 11,206 | 4.15× |
| The 280-line component | 1 | 18 | — |
| Whole graph, nodes / edges | 5,351 / 8,397 | 8,237 / 17,633 | — |
The median is the number that matters here. Before the fix, the typical component was a single node: the graph knew the file existed and nothing else. The Rust figure is derived from its own build, 7,751 nodes across 505 files. Note that after the fix Svelte edges slightly ahead of Rust on this measure, which says more about how many named things a component declares than about either language.
Why I did not chase a bigger number
My original target was 10× on the mean. It landed on 5.9×. That gap is deliberate.
The 2.79 baseline is mostly import stubs rather than symbols, so multiplying it is simply not the meaningful axis. The median going from 1 to 12 is. And to reach 10× on the mean I would have had to emit a node for every const x = 1 and every anonymous $effect. That makes the graph worse: more noise, worse answers, more expensive queries. Node count is the wrong KPI.
A code graph is not measured by how many nodes it has, but by how often it is right.
So I measured whether it is right
I wrote a second checker, plain regex plus brace matching, sharing no code with the tree-sitter path, and had it count the same symbols. The first run went over a sample of 100 random files and landed on 99.6% precision.
That is where I should have stopped if what I wanted was a good-looking number. I would rather have known why it was not 100.
So I stopped measuring a sample and measured everything: every component in both of my Svelte projects, including a second project I deliberately never opened while building the extractor.
| Corpus | Components | Symbols | Precision | Recall |
|---|---|---|---|---|
| The frontend app | 233 | 6,079 | 100.0% | 100.0% |
| Second project (held out) | 222 | 1,492 | 100.0% | 100.0% |
| Total | 455 | 7,571 | 100.0% | 100.0% |
Zero discrepancies. Not one prop, rune, function, snippet or component link where the two implementations disagree.
What the 100% covers, and what it does not
A headline number that quietly leaves out most of the output is worthless, so I verified the rest separately instead of pretending it was not there.
| Surface | Count | How it was verified | Result |
|---|---|---|---|
| props, runes, functions, snippets, component usage | 7,571 | Separately written checker | 100% |
calls edges | 2,314 | Separate verifier: the cited line must mention the callee, and the target file must actually declare it | 100% |
Dynamic references (INFERRED) | 5 | Checked by hand | All five genuinely dynamic |
| Closures inside functions | ~44 | Excluded by design, not missed | — |
No unmeasured remainder. The five uncertain edges are $derived(role.icon), a lookup in an icon table, and a lazily loaded component. Those are runtime values, impossible to decide statically, which is why they are labelled INFERRED rather than slipped in among the certain ones.
Getting to 100% took four rounds of admitting I was wrong
Widening the measurement from a sample to the whole corpus was not cosmetic. It dug out several real bugs the sample had missed entirely.
The worst was a brace counter. I had made the depth tracking string-aware and comment-aware, because that sounded more correct. It measured worse. The problem is that its mistakes are not local: a single miscount locks the depth above zero, and then every remaining component in that file is dropped silently. One regex literal, where the escaped slashes were read as a comment, cost a single file all 30 of its component links. Plain brace counting can only be fooled by a brace inside a string, and that costs one local wrong decision. Measured across all 455 components, the simple count balanced in every file. The clever one did not. It now counts plainly, and if the braces do not balance it drops the depth filter altogether.
The second project, the one I never opened during the build, earned its place immediately: that is where the bug showed up. In the 100-file sample it did not exist.
Other finds in the same pass: a self-import turned out to be a recursive component whose edge was being thrown away as a self-reference, and quoted destructuring keys kept their quotation marks. Then the four from the first round: the id collision between type State and let state; class: className storing the local alias instead of the public prop name; the less-than sign in {#if i < steps.length} being read as a component tag; and three components from the same barrel module collapsing into a single edge.
What it cannot do, honestly
I wrote the limitations into the tool's own usage text, because they apply to me as much as to anyone else.
- Free text is worse than grep. The graph indexes structure, never text. It does not see string literals, error messages, UI copy, CSS classes, route paths, environment variables or comments. A free-text query does a keyword breadth search and returns a confident answer assembled from loosely related nodes. Rule of thumb: symbol goes to the graph, text goes to
rg. I blocked free-text queries in my own wrapper for exactly that reason. - The graph stops at the process boundary. Every repository is an island. A
fetch('/api/…')is not connected to the handler that answers it.affectedon a backend endpoint does not list the frontend calling it. Cross-repository impact is still a manual question. - An empty
affectedis never proof of dead code. It means the graph is missing an edge, not that no caller exists. Dynamic imports, lookups by string key, dynamic components and anything crossing an HTTP boundary are invisible. Never delete on an empty graph answer without confirming withrg. - Freshness matters. Graphs build nightly, so mid-workday they lag. Every answer in my setup is prefixed with which commit the graph was built from and how far HEAD has moved, and shouts STALE when it needs to.
affectedis the query where lag does the most damage, because callers added since the build simply do not exist.
The general lesson
The extractor is written against Graphify's own extractor contract, with no dependencies back into their extract.py, so it lands as a clean drop-in: the patch removes the old extract_svelte and adds a standalone extractors/svelte.py. Three commits, 1,075 lines added and 56 removed, 18 new tests, and no regressions against the project's own suite.
It is pull request #2275 against Graphify, approved and awaiting merge. The review came back "looks safe to merge, no coupling regressions and no blocking issues, checked against the code graph", which is a pleasing way for a code-graph project to review a change to its own extractor.
-
Pull request #2275: real Svelte 5 extractor
The patch: a standalone
extractors/svelte.py, three commits, +1,075 / −56 lines, 18 new tests. Approved, awaiting merge. - Graphify-Labs/graphify The upstream project. Turns a codebase into a graph of symbols and relationships.
- github.com/zendoj My GitHub, where the rest of my open-source work lives, including GOPBX.
If you are running a code graph over a SvelteKit project today, it is worth checking what your graph actually contains before you trust it. Running the impact query on a component you know is used in five places is a good three-second test.
Graphify is a genuinely good tool and it solved a real problem for me. The Svelte support was broken, and it was broken in the worst possible way, by answering confidently instead of failing. That is fixed, cross-checked against a second implementation over the whole corpus, 100% precision and recall across 7,571 symbols and 2,314 call edges, with the five genuinely undecidable cases written out rather than tidied away, and sent upstream.
The wider lesson I am keeping: when you build a tool that AI agents are supposed to trust, producing output is not enough. It has to be possible to measure whether the output is right, and it has to be honest about what it does not know. Node counts look good in a release note. Precision is what decides whether someone deletes working code.
Frequently asked questions
What is a code graph?
A representation of a codebase as nodes and edges rather than files and text. Nodes are files, functions, types and symbols; edges are relationships such as imports, calls and containment. It lets a tool answer "who calls this" or "what breaks if I change this" with a query instead of by reading files.
How much context does a graph query actually save?
In one measured case, explaining a Rust function took 1,111 characters from the graph against 187,512 characters to read the containing file, a 169× difference for that question. That is a single measurement rather than an average, and the unit is characters. Tokens were not measured, and a tokeniser compresses dense source and short prose at different rates, so the token ratio may differ.
Why was Svelte support broken?
The extractor fed whole .svelte files to tree-sitter's JavaScript parser. Svelte markup is not valid JavaScript, so the parser returned an error node at the top level and no declarations were ever reached. The median component produced a single node, its own file entry, and nothing else.
Why is a confidently wrong answer worse than no answer?
Because affected returned "No affected nodes found" for a function that four components called. An agent reads that as dead code. A tool that fails visibly gets checked; a tool that answers confidently gets trusted.
How was the new extractor validated?
Against a second checker using plain regex and brace matching, sharing no code with the tree-sitter path. The first run used a 100-file sample and scored 99.6% precision. The final run covered every component in two projects, 455 components and 7,571 symbols, with zero discrepancies, plus 2,314 call edges verified separately and five dynamic references checked by hand. One of the two projects was held out and never opened during development, which is where the worst remaining bug turned up. Both implementations were written by the same author, so the safeguard is the held-out corpus and the rule that the reference was corrected against source code rather than against the tool.
Why not maximise the node count?
Because node count is the wrong metric. Reaching a 10× mean would have required emitting a node for every trivial constant and anonymous effect, which adds noise, degrades answers and makes queries more expensive. The median moving from 1 to 12 is the meaningful improvement.