Builds a compact symbol graph of a codebase — declarations plus the relationships between them — so an AI tool can understand a project's structure at a fraction of the tokens of the raw source.
Feeding whole files to an AI is expensive. A skeleton view keeps the module
layout, imports, and every declaration's signature while dropping function
bodies — the part that dominates token count but rarely matters for navigation.
// demo.v
module demo
import os
struct User
const max_users
pub fn (u User) greeting() string { ... }
fn greet(name string) string { ... }
fn main() { ... }
Benchmarked on real external V codebases (vlib/v/ast, vlib/v/parser, c2v)
with cmd/bench — see BENCH.md:
- Artifact ceiling: ~10–20× fewer tokens for navigation/mapping (full source vs skeleton, apples-to-apples) — 89–95% reduction. This is a theoretical maximum, not what a session gets.
- Live end-to-end (real
claude -pA/B on a 2,885-file / 161-module corpus, both arms--safe-modeso neither inherits the user's graphify hooks):- structural questions ("where is X, what calls it, what's in this module"), n=3, opus: ~1.3× fewer unique-input tokens.
- purpose/rationale questions ("what is X for, why does it exist"),
n=3, sonnet, warm-cache rounds: ~1.33× fewer unique-input tokens,
consistently — once
queryactually surfaces the doc it captures (see below), the model goes straight to one batchedexplaincall per symbol instead of a full-body read. - implementation walkthroughs ("explain step by step what this function does internally"): no gain — these require reading the actual logic, and the graph is an extra hop before that read, not a substitute for it.
That's the honest headline — ~1.3× on structure, ~1.3× on rationale (once wired correctly), ~1× on "explain this code". Turn count and total tokens looked better still but varied more run to run, so unique-input is the number to quote.
Two findings are worth knowing before optimizing further:
- Call resolution went 53.7% → 82.9% and live savings did not move — for these tasks the bottleneck was never resolution accuracy.
- The rationale task initially lost (0.77× avg, 0.44×–1.06× round to
round) despite being built to favor docs. Root cause:
query()'s rendered output omitted thedocfield entirely — onlyexplainshowed it — while the wired system prompt recommendedqueryfirst. The model would query, get no doc text back, and fall back to reading a whole function body. Adding a one-line doc preview toquery()and steering the prompt towardexplainfor named-symbol lookups turned that into the consistent ~1.33× win above. A benchmark claiming "docs help" is only as honest as the wiring that actually exercises the docs.
For the gold-standard test — real Claude Code sessions, same task wired vs. not,
comparing actual API tokens — see bench/live/PROTOCOL.md
and bench/live/count_tokens.ps1.
V only, by design: backend_v.v extracts symbols and edges via V's own
compiler frontend (v.parser + v.ast), which gives full fidelity with no
separate grammar to maintain and no gaps from a minimal binding. An earlier
draft sketched a second, tree-sitter-backed path for other languages
(SourceFile.lang, a language filter on Options, a backend_ts.v stub);
it was never implemented beyond that stub and has been removed rather than
kept as unused scaffolding for a project that doesn't exist yet — see
tree-sitter's own line in Status.
Pipeline: walk files -> extract_v_file(file) -> []Symbol + []Edge -> Graph -> emit.
Symbol{ id, name, kind, signature, file, line, end_line, is_pub, parent, doc }—end_lineis what letsget_bodyread one declaration instead of a whole file;docis the//block directly above itEdge{ from, to, kind, provenance }— kinds:defines,calls,imports,implements,embeds,references;provenance(extracted/inferred) is set on resolvedcalls,embeds, andreferencesedges — see the edge provenance item in Status
emit_skeleton()— body-less code view (the token-saver)emit_json()— full graph for tooling
Mirrors the Python Graphify command surface: extract once, then traverse instead of re-reading files.
graphify extract . --out S:\graph_data # build graph -> a central dir
graphify overview # compact summary (counts + key symbols)
graphify query "auth flow" --budget 2000 # token-bounded traversal
graphify path build_graph emit_skeleton # shortest path between symbols
graphify explain build_graph # a symbol + its relationships
graphify body build_graph # just that declaration's source
graphify skeleton . # body-less skeleton of a path
graphify merge-graphs a/graph.json b/graph.json --out merged.json # combine two graphs
Merging graphs. Every id is namespaced by its source graph's label (its
root directory name by default, or --labels a,b,...), unconditionally and
regardless of merge order, because two unrelated projects sharing a module
name is the common case, not an edge case — main is the implicit module of
every standalone V program, so any two merged programs collide on
main.main unless kept apart. query/explain/path/shortest_path/
overview work normally against the merged result; get_body does not —
s.file is relative to each original root, and a merged graph has no
single root to resolve it against, so --source-dir can only ever serve one
of the inputs correctly. Content that's genuinely duplicated across inputs
(the same file extracted into two of them) is not deduplicated — it appears
twice, once per label.
Queries read ./graphify-out/graph.json by default. Override with --graph <file>.
Central store (recommended for multiple projects). Set GRAPHIFY_STORE once;
then extract auto-files each project under <store>/<project-name>/, and queries
resolve the graph for whatever project directory you're in — no path juggling:
$env:GRAPHIFY_STORE = 'S:\graph_data'
cd my-project ; graphify extract . # -> S:\graph_data\my-project\graph.json
graphify overview # resolves S:\graph_data\my-project\graph.json
Resolution order: --graph > GRAPHIFY_GRAPH > GRAPHIFY_STORE/ > ./graphify-out.
extract writes a Graphify-style bundle to graphify-out/:
graph.json— persistent, queryable graph (read by query/path/explain)GRAPH_REPORT.md— counts, most-connected symbols, suggested queriesmanifest.json— metadata + counts.gf_cache.ndjson— incremental cache: each file's content hash plus its extracted symbols/edges. A file whose hash matches the cache is reused as-is on the nextextractinstead of being reparsed — only new/changed files hit the parser. Safe to delete to force a full reparse.
Library:
import graphify
g := graphify.build_graph(graphify.Options{ root: 'path/to/project' })
graphify.write_bundle(g, 'graphify-out')!
println(g.query('auth flow', 2000, false))cmd/mcp is a JSON-RPC-over-stdio MCP server that loads a persisted
graph.json once and exposes graph traversal as tools, so the model queries
the graph instead of reading source files:
| Tool | Purpose |
|---|---|
overview |
body-less skeleton map of the whole codebase |
query_graph(text, budget?, dfs?) |
token-bounded traversal from matching symbols |
get_node(node) |
a symbol + its relationships (defines, references, embeds, calls), each with file:line |
get_body(node) |
the source of ONE declaration by name (instead of reading its file) |
get_neighbors(node) |
names of all directly linked symbols |
shortest_path(a, b) |
relationship path between two symbols |
Build and register (project-scoped via .mcp.json, already in this repo):
v -prod -gc none -o bin/graphify.exe cmd/cli # -gc none: extract is one-shot and short-lived;
# Boehm GC caused a ~500x slowdown on the graph.json
# write path (616s -> 32s on the full vlang repo).
# Do NOT add -gc none to graphify-mcp below — it's a
# long-lived server process and needs bounded memory.
v -prod -o bin/graphify-mcp.exe cmd/mcp
bin\graphify.exe extract . # produce graphify-out/graph.json first
{
"mcpServers": {
"graphify": {
"command": "S:\\vProjects\\graphify\\bin\\graphify-mcp.exe",
"args": ["S:\\vProjects\\graphify\\graphify-out\\graph.json"]
}
}
}Or register globally: claude mcp add graphify -- S:\vProjects\graphify\bin\graphify-mcp.exe <path-to-graph.json>.
Smoke-test the protocol without Claude: Get-Content cmd/mcp/test_session.jsonl | bin\graphify-mcp.exe graphify-out/graph.json.
Beyond the graphify CLI itself, bin/ and bench/ hold PowerShell helpers
built for running this against a large external repo (vlang) day to day.
One-time setup: copy graphify.config.ps1.example to graphify.config.ps1
(repo root) and edit the two paths for your machine:
$GraphifyVlangRepo = 'S:\repo\vlang' # the external repo these scripts track
$GraphifyStore = 'S:\graph_data' # central store for extracted graphs
graphify.config.ps1 is gitignored — each machine keeps its own copy, and
update-vlang-graph.ps1, switch-vlang-graph.ps1, and extract_bench.ps1
(its default -Source) all read from it. If the file is missing, each script
errors with a pointer back to the .example template instead of silently
using the wrong paths.
Pulls the target repo and re-extracts only if there were new commits. Wired into Windows Task Scheduler for a nightly run; can also be run by hand.
bin\update-vlang-graph.ps1 # git pull, extract only if new commits
bin\update-vlang-graph.ps1 -NoPull # skip the pull, always re-extract
Logs to S:\graph_data\update.log.
Extracts (or reuses) a graph for a specific branch and repoints the MCP
server's ~/.claude.json config at it. master/main keep the graph at
<store>\vlang; other branches get their own <store>\vlang-<branch>.
bin\switch-vlang-graph.ps1 # graph for the current branch
bin\switch-vlang-graph.ps1 -Branch feature-x # graph for a specific branch
bin\switch-vlang-graph.ps1 -Branch feature-x -Checkout # also `git checkout` that branch first
bin\switch-vlang-graph.ps1 -Force # re-extract even if a graph already exists
Restart Claude Code after switching so the MCP server picks up the new graph.
Scans Claude Code's session transcripts and reports graphify tool-call counts per session (main-agent vs. subagent split), at zero token/API cost.
bench\session_stats.ps1 # last 14 days
bench\session_stats.ps1 -Days 30 # look back further
bench\session_stats.ps1 -IncludeBench # include graphify's own bench sessions
Times graphify extract end-to-end against a target repo, clearing the
output directory first so every run starts cold.
bench\extract_bench.ps1 # defaults: vlang repo, temp out dir
bench\extract_bench.ps1 -Runs 3 # average over 3 runs
bench\extract_bench.ps1 -Source S:\myproject -Out S:\temp\gf-bench-myproject
.claude/ makes Claude Code reach for the graph automatically (this repo
dogfoods it on itself):
.claude/skills/graphify/SKILL.md— tells Claude to query the graph (MCP tools or CLI) for structural questions and drill into bodies byfile:line..claude/commands/graphify.md—/graphify [path]rebuilds the graph and summarizesGRAPH_REPORT.md..claude/settings.json— aSessionStarthook injects standing guidance, and aPreToolUsehook onGrep|Globreminds Claude to consult the graph before scanning files. Both call.claude/hooks/graphify_hook.ps1(pwsh, non-blocking, emitshookSpecificOutput.additionalContext)..githooks/post-commit— rebuilds the graph after each commit. Enable withgit config core.hooksPath .githooks.
To wire a different project, copy .claude/ (and .mcp.json) into it and
update the absolute paths to point at this repo's bin/.
graph.json stores paths with forward slashes, so a graph extracted on one OS
resolves on another (Windows / WSL-Linux / macOS). All structural queries
(query_graph, get_node, shortest_path, overview, skeleton) need only
the graph file — no source. get_body reads the actual source, so on a different
machine point it at the local checkout:
graphify body <symbol> --graph shared\graph.json --source-dir /path/to/local/checkout
For the MCP server, pass the local source root as a 2nd arg (or set
GRAPHIFY_SOURCE_DIR):
{ "mcpServers": { "graphify": {
"command": "…/graphify-mcp", "args": ["…/graph.json", "/path/to/local/checkout"] } } }Phase 1 (engine) — done; V only.
- V backend: module, imports, structs, enums, interfaces, consts, fns/methods, body-less signatures, call edges
-
graphify-out/bundle:graph.json,GRAPH_REPORT.md,manifest.json -
extract/query(BFS/DFS + token budget) /path/explain/body/skeleton - resilient extraction — files parsed in worker-process batches across
nr_cpus()concurrent workers, so a file that panics V's parser is skipped + reported, not fatal. Torture-tested on the full V compiler repo: 103,253 symbols in ~12s cold, 3 unparseable files isolated. - extraction performance — the full V compiler repo went 616s → ~12s, via two independent fixes. (1)
graphify.exeis built-gc none: V's default Boehm GC costs roughly 590µs per call on the repeated smallstrings.Builderappends that writinggraph.jsonis made of, which is not an allocation problem — isolated benchmarks showed the same loop run instantly with the writes stripped. Safe because the CLI is a one-shot process that exits;graphify-mcp.exedeliberately keeps the GC, being long-lived. (2) batch workers run concurrently rather than one at a time (32s → 12s), verified byte-identical against the sequential path. - Phase 2: MCP server (
query_graph,get_node,get_neighbors,shortest_path) +.mcp.json - Phase 3: Claude Code wiring —
SKILL.md,/graphify,SessionStart+PreToolUsehooks, git rebuild hook - SHA256 incremental cache (
.gf_cache.ndjson) — a file whose content hash is unchanged since the lastextractis reused as-is; only new/changed files are reparsed (~11s → ~4s on a full no-op re-run of the V compiler repo) - call-edge disambiguation. Call edges record the raw callee name and are matched to a declaration afterwards (
resolve_edges). Ambiguity — not missing data — is the limit. Resolution narrows progressively, strongest signal first: globally unique name → method-vs-function (x.foo()can only be a method) → the caller's own file → its module → what the calling file can actually see, meaning itsimports plusbuiltin, which V auto-imports. On the V compiler repo that takes 53.7% → 82.9% of 167.6k call edges. Two ideas do most of the work. Candidates are compared by id, not by symbol count, so a function declared once per platform —os.setenvin bothenvironment.c.vandenvironment.js.v— stops looking ambiguous: it is one function, and the shared id addresses it correctly. And an id is refused only when its declarations sit in separate build units that merely share a module name: every standalonemainprogram, and every_test.vfile, each of which V compiles as its own executable. Inside an ordinary module a repeat cannot be two different functions, because V would reject the redeclaration. Guessing there would point every consumer at whichever declaration was indexed first. - doc/rationale capture — the
//block directly above a declaration becomes itsdoc, surfaced in full (capped at 8 lines) byexplain/get_node, and as a one-line preview per result byquery/query_graph. A blank line between comment and declaration means it is a header or a note about the code above, not documentation, so it is dropped. Read from source rather than the AST: in.toplevel_commentsmode V reports only the first line of a contiguous block as a top-level node, and reading directly also keeps the parse in the cheaper.skip_commentsmode. 15.8k declarations documented on the V compiler repo, 5.8k of them multi-line. Thequerypreview was added after a live benchmark round showed it omitteddocentirely, which a suggested "queryfirst" workflow turned into full-body reads instead of ever tryingexplain— see Measured savings. - edge provenance — every resolved
calls,embeds, andreferencesedge is tagged onEdge.provenance, persisted ingraph.json:extractedwhen the name was globally unique (or, forcallsonly, the receiver's type came straight from the parser),inferredwhen narrowed among several real candidates by the referencing declaration's file/module/import-visibility locality —resolve_type_refmirrorsresolve_calleeminus the calls-specific kind-match and self-receiver steps, since a type name has neither.embeds/referencesused to resolve only through the cruder, non-local, by-name-only check inIndex.resolveat query time; giving them the same locality narrowing as calls is a real accuracy gain, not just a label —referencesresolution on the V compiler repo went 20.4% → 31.6% (4,065 → 6,298 of 19,931). Full breakdown inGRAPH_REPORT.md's "Edges by provenance" section: calls 18,341 extracted / 1,287 inferred / 16,236 unresolved; references 4,065 / 2,233 / 13,633. Surfaced inexplain'scalls/called by/references/referenced by/embedslines as a per-entry[inferred]tag, with a one-line footnote the first time one appears. - tree-sitter removed. The multi-language scaffolding (
backend_ts.v's stub,SourceFile.lang,Options.languages,lang_by_ext) never did real work — see the Design note — and existed only for a second backend that was never built. Removed rather than left as dead abstraction for a hypothetical future project; other languages would need their own tool, not a mode of this one. - remaining call-edge ambiguity.
Most of what is left needs the receiver's inferred type (
stralone has 300 candidate declarations), and inference is a checker job:CallExpr.left_type/receiver_typeare populated inv/checker, never inv/parser. Sharing oneast.Tableacross files — the obvious-looking fix — only improvestype_to_strrendering in signatures; it does not infer receivers. But inferred is the operative word, and not every receiver needs inferring. The parser does hand usCallExpr.left, the receiver expression, and some shapes are typed syntactically: a method calling another method on its own receiver (fn (t &Transformer) a() { t.b() }— the type is written on the enclosing declaration), a literal receiver (Foo{}.bar()), or a local whose initializer names its type. graphify resolves all three now — self-receiver (~4% of the remaining ambiguity), literal receiver (small: +5 calls resolved on this project's ownvlib/vbenchmark corpus (2,885 files) — a different, smaller corpus than the full-repo figures elsewhere in this note, since most methods chain off a named variable, not a fresh literal), and local receiver (x := Foo{}thenx.bar(); bigger, as expected since this is the common idiom: +42 net calls resolved on the same corpus, some of them upgrading frominferredstraight toextracted). The literal-receiver fix caught a real trap worth recording:ast.StructInit.typ_stris not trustworthy for this — despite its// 'Foo'doc comment, the parser always prefixes it with whichever module is currently being parsed, not the module actually written, soother.Bar{}referenced from insidedemosilently reports itself asdemo.Bar(confirmed by direct probing ofv.parser, not by trusting the comment). Resolving through.typvia the table, the same routeclean_typealready uses elsewhere, does not have that bug. The local-receiver tracking is deliberately conservative about what it tracks: only a:=(not a plain=) with exactly one name on the left and a directly-typed literal on the right, and a nested block's local never leaks to a sibling branch or to code after the block — ordinary lexical scoping, which V's insistence on an explicit.clone()for any map copy gives for free once each block's tracked-locals are threaded by value rather than mutated in place. A later plain reassignment (x = something_uncertain()) is deliberately not treated as invalidating the tracked type, because V is statically typed: a:=-declared local's type cannot change for the rest of its scope no matter what a later right side looks like, so there is nothing to invalidate. What genuinely requires the checker is a receiver that is a call's return value, a generic, an interface, an alias, or a chained expression. A real fix means running the checker over the whole project in a single process, which collides with worker-process crash isolation (V's parser panics on some files, uncatchably), with parallel batch dispatch, and with the per-file cache (a file's output would depend on global table state, so an unchanged hash could serve stale results). It would also restrict graphify to projects that typecheck, when navigating half-broken code is exactly when it is most useful. If wanted, this belongs behind an opt-in--deepmode, not in the default path. 17.1% of call edges (28.7k) are still unattributed, but only 4,377 of those are genuinely unresolvable — calls into C, closures, and function variables, which nothing short of running the program could pin down. That puts the real ceiling at 97.4%, with ~24.3k edges between here and there. Those remaining ones are at least not silent.index()drops any edge whose raw callee name matches several declarations, which makes a called function look uncalled;explain/get_nodelist them underpossibly called by, with the declaration count that makes them uncertain. That is resolved at query time from the raw names already stored in the graph, not by emittingAMBIGUOUSedges to every candidate as originally sketched: measured on the V compiler repo, real fan-out adds 1.15M edges (4.2× the whole graph), and those guessed links would then leak intoshortest_pathandquery_graphtraversal. Keeping it query-side costs no graph growth and leaves traversal untouched. - Phase 5,
merge-graphs— combines any number of previously-extracted graphs into one, with every id namespaced by its source's label (default: root directory name, deduplicated with-2/-3/... on repeat) so a shared module name likemain— the implicit module of every standalone V program — can never collide across inputs.get_bodyis the one operation that doesn't carry over cleanly to a merged graph; see Usage. - Phase 5 remaining: Leiden communities, GraphML/Cypher/SVG,
graph.html