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: the extractor reads symbols and edges through V's own
compiler frontend, 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, andundeclaredon an unresolved call that has no declaration to resolve to, or on aC.orJS.type reference that nothing declares — see the edge provenance item in Statusimplementsis a declared vocabulary slot, not a planned feature. V's interfaces are satisfied structurally — there is noimpl X for Ysyntax to read off the AST, so detecting it means matching a candidate's resolved method/field set (including through embeds) against every visible interface's, which needs a whole-program, fully-resolved type table exactly likev/ast/table.does_type_implement_interfacebuilds — the opposite of graphify's per-file, parallel-worker, cacheable extraction. Investigated concretely, not just deferred by assumption: real yield data across 3 real corpora (V's own compiler, this project, and a realveb-framework app) found the obvious pitch — a project's own type implementing a project's own interface — in zero of 288 measured pairs, including a corpus that declares 6 of its own interfaces specifically to test this. The one real signal (app code satisfying a framework's interface) scored 2 meaningful pairs in the one corpus built to have it; the other 286 pairs were stdlib-satisfying-stdlib facts (io.ReaderWriter,IError) — the same static fact in every V program, not project-specific insight. Building this would mean a standing compiler-patch dependency (forking or upstreaming a change tov/ast/table.v) for a feature that, on the best evidence gathered, finds a handful of edges per framework-consumer project and nothing in most others.
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 /path/to/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 path http.get net.dial_tcp --routes 20 # list more chains of calls than the 5 shown
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
graphify communities --resolution 1.5 # split the graph into subsystems
graphify export graphml --out graph.graphml # for Gephi, yEd, Cytoscape, NetworkX
graphify export cypher --out graph.cypher # for Neo4j: cypher-shell < graph.cypher
graphify export svg --out graph.svg # static, community-clustered node-link diagram
graphify export html --out graph.html # the same, interactive: hover/lock/trace/zoom, + to drill into large communities
The standard library and installed modules. A project outside the V tree
has no vlib in it, so extract also reads the modules it imports from vlib
(the V checkout of the v on PATH, or VROOT) and from ~/.vmodules (or
VMODULES), and their imports in turn, so its calls into them resolve:
s.contains(x) goes to vlib.builtin.string.contains rather than to the
project's only contains, and os.join_path to its declaration. The library
declarations the project reaches are kept in graph.json's library section,
apart from the project's symbols: explain lists them, marked [library],
and body reads them from VROOT, but they take no part in query, path,
communities, the exports, diff or the most-connected list. The report counts
an edge into the library as library. --no-library turns this off, and so
does VROOT=; extracting the V tree itself reads nothing extra. On
~/.vmodules/gui unresolved calls go from 2,201 to 6 (FUTURE_WORK.md §10).
SVG/HTML. Nodes are laid out by community (see Communities above), not by
a force-directed simulation: communities are arranged around an outer
circle, each community's own members around a smaller circle centered on
its spot. Deterministic trigonometry instead of an iterative physics
simulation that would need its own convergence/quality verification the way
communities.v's Louvain implementation did — genuine physical realism isn't
the goal, showing which symbols cluster into which subsystem (and roughly
how large each one is) is. Node size is degree, color is community, hovering
a node in the HTML version highlights it and its direct neighbors and shows
its name/kind/id — plain DOM/SVG event handling, no framework, no CDN
dependency, matching the rest of graphify's fully local design. Caps at 300
symbols (the highest-degree members of each community, kept proportional to
that community's own size so a few giant communities can't crowd out every
small one) since a large real codebase's full node count is too much to
usefully render or even open; a rendering that got capped says so in an
HTML comment / on-page caption rather than silently passing as complete.
No amount of client-side tuning changes that for a genuinely huge repo
(tens of thousands of symbols) — putting every node on one screen at once
stops being legible long before it stops being possible to draw. For that
scale, export graphml/export cypher are already uncapped and meant to
be opened in a tool actually built for graphs that size (Gephi, Neo4j
Bloom) rather than a hand-rolled SVG.
The HTML version also has scroll-to-zoom (centered on the cursor) and
drag-to-pan on the SVG viewBox, added after direct testing found the first
version impractical to actually use: a real WebDriver-synthesized mouse
move (not a JS-dispatched event — the browser preview tool available during
development renders local files as static snapshots with no JS execution
at all, so a synthetic dispatchEvent had been the only check, and it
passed without exercising the real problem) landed dead-center on a node
and measured its rendered size at ~4×4 CSS pixels — correct in principle,
unusable with an actual mouse. Fixed two ways, both verified the same
way afterward: each node's hover target (.hit in the SVG, invisible,
layered under the visible dot) is now sized independently of the dot's
degree-based visual size, roughly 3× larger; and the added zoom lets a
user close whatever gap remains. Confirmed with real synthesized
interactions, not just reading the JS: a pointer move to the enlarged
target still highlights correctly, a dispatched wheel event visibly
shrinks the viewBox width by the expected factor, and a real
mousedown-move-up drag shifts the viewBox origin.
Semantic zoom and cluster context. The default view shows only one bold
label per community — its name, member count, and where in the source tree
it actually lives (e.g. main (22) — cmd/cli +2 more dirs) — with
individual symbol labels hidden, so the first thing you see is a high-level
map, not a wall of overlapping per-symbol text. Zooming in past a threshold
fades the per-symbol labels in; the cluster label stays anchored throughout
for context. The directory info exists because a community's own label
(its most internally-connected member's name — see label_community)
doesn't say what part of the codebase it corresponds to, and graphify
doesn't otherwise show repo directory structure anywhere; community_location
reports the single directory most members share when there's a strict
majority, or a plain count of distinct directories when there isn't one —
guessing a "best" directory for a genuinely scattered community would
misrepresent it, not describe it. Verified live in a real browser: the
default view shows communities with node labels at opacity:0; scrolling in
on a specific cluster flips svg.zoomed-in and reveals that cluster's full
member names while the rest of the graph stays uncluttered off to the side.
Click to zoom, lock, and trace. Clicking a legend entry, or a cluster's own label on the canvas, zooms the viewBox to fit that community — no need to scroll-zoom by hand to find where a cluster from the legend actually sits in the layout. Clicking a node locks its hover highlight so it survives the mouse moving away: pan or scroll around a node's neighbors at leisure, or click one of those now-visible neighbors to re-lock onto it and trace a path across communities one click at a time (click the same node again, or empty canvas, to drop the lock). The locked node, its neighbors, and the edges connecting them get a distinct stroke — black for the node itself, orange for its neighbors and the edges reaching them — instead of relying on everything else merely getting dimmer to imply connectivity, which was easy to lose against a cluster's own now-dimmed edges.
Verified with real WebDriver clicks, not dispatchEvent, which caught two
real bugs neither a code read nor a synthetic check would have surfaced.
classList.toggle(cls, x) treats an explicit undefined x as "no force
argument given" — falling back to flip-on-current-presence — rather than
"force false"; since keep[nid] && nid !== id evaluates to undefined
(not false) whenever keep[nid] itself is unset, this silently marked
nearly every node in the graph as a "neighbor" on top of correctly being
"dim." And the pan-starter's mousedown handler was calling unlock() on
every mousedown, including a plain click with no drag, nulling out the
lock before the click handler's own "am I re-clicking my own lock?" check
could ever see it — breaking click-the-same-node-to-unlock outright. Both
fixed (a !! coercion before the affected toggle() calls; deferring the
pan-triggered unlock until real pointer movement is detected) and
re-confirmed the same way: real clicks on legend entries and cluster labels
shrinking the viewBox to the expected community, a locked node's
neighbor/dim counts summing exactly to the total node count, re-clicking a
locked node cleanly dropping every highlight, and clicking through to a
neighbor correctly demoting the previous selection while promoting the new
one.
That "defer unlock until real pointer movement" fix later turned out to
overcorrect: it dropped a lock on any drag, including one starting from
a locked node — which defeats the reason locking exists, panning or
scroll-zooming out to see where a highlighted edge actually leads. Fixed
to only clear a transient, never-locked hover highlight on pan; a
deliberate lock now survives it. That exposed a second issue directly
underneath: a real synthesized ~90px drag still made the browser fire a
trailing click event at mouseup, which was immediately re-triggering
unlock right back through the other click handler the moment the pan
fix stopped dropping it directly — invisible without tracing the actual
mousedown/mousemove/mouseup/click sequence a real drag produces, not just
reading the code. Fixed by having the node and background click handlers
ignore a click that's the tail end of a just-finished drag. Confirmed with
a real WebDriver-synthesized drag (not a single click): a locked node's
highlight classes and dim count come out byte-identical before and after
panning, while the viewBox itself genuinely moved.
Drill down into large communities. A large, internally-lumpy community
gets a small "+" badge next to its legend entry and in-canvas label —
computed by communities_within, a scoped re-run of the same Louvain
optimization on just that community's own members and edges, gated on it
actually finding a real split (>= 2 sub-communities), not just on size
alone. Clicking the badge reveals a precomputed nested sub-layout, using
the same deterministic ring layout and node/edge/label rendering as the
top level, complete with its own directory-location context per
sub-cluster; an explicit "back to overview" button (not a re-click toggle
or an implicit zoom-out threshold — this project already learned that
lesson once, see above) reverses it. Capped at the 10 largest qualifying
candidates, bounding both the extra clustering cost (each scoped run has
its own restart loop) and the payload growth, with the truncation
disclosed on-page ("N of M large communities include a detail view") the
same way the 300-node cap already is — never silent.
The size threshold (30 members) is empirically grounded, not a guess:
tuned against the classic Fortunato–Barthelemy "ring of cliques"
resolution-limit case, the textbook example of a community that plain
modularity optimization provably cannot resolve further at the top level
yet still contains real, recoverable sub-structure once analyzed in
isolation — a plain clique-of-cliques (few cliques, one clean bridge)
turned out to always get cleanly separated by communities() at the
top level too, confirmed directly after several other hand-built test
graphs kept failing this exact way, which would leave nothing for
drill-down to ever find.
Two real bugs surfaced only by testing against an actual ~47k-symbol
codebase, not the smaller synthetic test graphs the feature was built
against: the drill badge's fixed small offset from the cluster label
placed it inside a large community's own member ring (whose radius
grows with sqrt(member count)), so a real click landed on a member node
instead — fixed by measuring each community's actual rendered spread and
placing the badge outside it. And a drill-view's hidden .dot circles
stayed independently clickable even at opacity: 0, since only the
larger .hit/.cluster-hit targets had an explicit pointer-events: none
override — opacity never implies pointer-events: none on its own —
fixed by covering .dot too, in both the hidden-drill-view and
hidden-flat-content directions.
Export. Both formats emit one node per unique symbol id via Index.by_id
rather than one per raw Symbol, and only resolved edges (Index.edges) —
the same reasoning as query/explain: an id like main (every standalone
V program's entry module) or graphify (this project's own module, declared
identically in every one of its own files) is shared by several distinct
real declarations, and an unresolved edge has no real node to point at. This
isn't just consistency for its own sake — the Cypher export declares a
uniqueness constraint on id for fast edge lookups, and a naive
one-node-per-raw-symbol version was confirmed to violate that constraint
against this project's own export (its own files collide on graphify),
failing outright rather than degrading gracefully. GraphML escapes
&<>"'; Cypher property strings escape \ and ' only — <, >, &,
" don't need it inside a single-quoted Cypher string, and escaping them
anyway would corrupt the data (this was cross-checked directly: a signature
containing all of <>&" round-trips unescaped in the Cypher output, escaped
in the GraphML one).
Communities. Splits the graph into densely-interconnected subsystems by modularity optimization — the same idea Python Graphify ships as "Communities: the graph split into subsystems (Leiden), with LLM-free labels." What this implements, precisely, since "Leiden" names a specific algorithm and it's worth being exact about the difference: Louvain-style local-moving + multi-level aggregation (the same core as the original Louvain algorithm), followed by a connectivity-guaranteeing pass that splits any resulting community whose induced subgraph turns out disconnected into its connected components. That delivers the same practically-important guarantee the Leiden paper's randomized refinement phase exists to prove — every returned community is one connected piece — via a much simpler, much easier-to-verify mechanism (connected-components is a solved primitive; a randomized multi-way refinement merge is not something to hand-roll and trust without a reference implementation to check against).
Verified against Zachary's Karate Club, the standard 34-node benchmark for
this exact algorithm: correctly separates the graph's two well-documented
rival factions every run, and (since local-moving is randomized and any
single run can settle into a meaningfully weaker local optimum purely by
chance) communities() retries --restarts times and keeps the
highest-modularity result — more restarts is slower but more reliable; the
default balances the two. defines/imports/implements edges don't count
toward community weight, only calls/references/embeds — containment
("this module defines this function") isn't "these two things interact",
and including it pulled unrelated members of the same module toward each
other instead of toward whatever they actually collaborate with.
Fixed: Index.by_id no longer collapses distinct declarations that share
an id. It used to — the same case resolve_edges' unaddressable check
already named for call resolution (every standalone main program's entry
point is main.main; every _test.v file's helpers can collide the same
way) silently dropped whichever declaration wasn't indexed last, everywhere
Index.by_id is used: query/explain/get_node, communities,
GraphML/Cypher export. Measured on the V compiler repo before the fix:
21.03% of all 107,134 symbols were silently discarded this way, 17.11%
of them genuinely distinct declarations, not harmless duplicates. extract
now runs disambiguate_ids before edge resolution: a declaration whose id
collides across separate build units (every standalone main program, or
an id repeated across ≥2 distinct _test.v files — same classification
unaddressable already used, just applied earlier) is renamed to
id@file, file-qualified and so unique for any two declarations V itself
would accept as distinct; a same-declaration repeat inside one ordinary
module (a per-platform variant like os.setenv in both environment.c.v and
environment.js.v) is untouched, since V would reject a genuine
redeclaration there; Index shows such an id's C-backend declaration
(.v/.c.v) ahead of a .wasm.v/.native.v one, and a .js.v one last
(shown_before in graph_ops.v) — 238 ids on the V compiler's tree used to
show the JS or WASM copy. Post-fix on the same repo: 13.54% of symbols still
collapse, entirely the mod_/import_/constant/field kinds this pass
deliberately doesn't touch (matching resolve_edges' own scope) plus
legitimate platform variants — of the 7,646 rows that were genuine
cross-build-unit collisions, 7,638 (99.9%) were fixed here; the remaining 8
were a separate, root-caused bug that a file-qualified suffix can't help
with — a fn C.foo/fn JS.foo extern declaration colliding with a
same-named V wrapper in the same file, since fn_id (backend_v.v) doesn't
preserve the language prefix in short_name — since fixed by skipping
body-less non-V FnDecls as symbols entirely (call resolution to an extern
callee was already non-functional either way, so nothing that worked is
lost); a fresh full-repo extraction now shows 0 duplicate ids across all six
kinds this pass targets. Before excluding containment edges,
the old collapse also produced an artificial "main" supermassive community
spanning every unrelated standalone program in a large corpus; excluding
defines edges from community weight remains independently correct
(containment isn't interaction) even now that the underlying collision
itself is fixed.
Regressed and fixed again (October 2026). When module ids became
directory paths (5170207, so that two machines produce the same ids), the
checks for a standalone program kept comparing a symbol's parent with main.
A program in a subdirectory now has a parent like cmd.tools, so from then on
the 62 programs in vlang's cmd/tools shared one cmd.tools.main and calls
between them crossed programs; 3,081 declarations fell into 1,169 ids. The
same change broke the visibility rule in call and type resolution, which
compared directory ids such as vlib.os with import paths such as os and
with builtin. The module symbol's signature now records what the file
declares (module main), which main_unit_files reads, and imports match a
module id by its trailing segments (import v.ast reaches vlib.v.ast; where
that fits several modules, nothing is resolved). On vlang 1b4ecb9c05:
resolved calls 130,513 → 163,984 and resolved type references 41,296 →
65,702, every remaining multi-declaration id a platform variant inside an
ordinary module. Two older bugs then surfaced: the worker protocol never
carried Edge.file, and the call walker dropped it after any typed local
(x := Foo{}), so a renamed declaration's calls came out from id@, an id
that does not exist. With both fixed, the 3,095 renamed functions keep their
calls (2,841 have outgoing edges; the rest call nothing) and no edge starts at
a missing id (11,409 before).
Fields and consts beside a same-named method or function. V lets a method
share its name with a field of its receiver type (Server.username in
vlib/crypto/scram is both) and a const share its name with a function; each
pair came out as one id, so 429 calls edges on vlang pointed as much at a
field as at the method. separate_member_ids gives such a field or const the
id <parent>::field::<name> or <parent>::const::<name>, the form import ids
already use, and leaves the plain id to the method or function that edges
point at. A C struct has an id of its own, <module>.C.<name>, as its
methods do, so it no longer shares one with a V type of its name (type Size = C.Size, the interface IError, the function os.stat; 9 ids on vlang, October
2026). Left as they are: a module id that equals a member of the parent module
(vlib.rand.seed, both a submodule and a function; 5 ids), a JS binding type
sharing an id with a V type of the same name (vlib.builtin.js.Error and
vlib.builtin.js.Promise), and duplicates written twice in the source itself
(the same C. structs declared twice in vlib/sokol/sapp/sapp_linux.c.v;
import samename next to import samename as sn).
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, and so does get_body:
the merged graph carries a roots map from each source's label chain (the
same label:: prefix its symbol ids carry) back to that source's own
original root, so get_body picks the right root per symbol instead of
needing one root for the whole graph. --source-dir still overrides the
single root field when set, which only matters for a non-merged graph or
for forcing every symbol through one relocated checkout. 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. The whole cache is also tied to a hash of thegraphifybinary that wrote it, so a rebuilt binary with different extraction logic can never silently serve stale results just because the source files themselves didn't change. Safe to delete to force a full reparse.
Library:
import graphify
g := graphify.build_graph(graphify.Options{ root: 'path/to/project' })
// build_graph (unlike build_graph_resilient) doesn't track a binary hash or
// per-file failures, so its ExtractReport is empty.
graphify.write_bundle(g, 'graphify-out', graphify.ExtractReport{})!
println(g.query('auth flow', 2000, false))cmd/mcp is an MCP server over stdio, on V's mcp module, that loads a persisted
graph.json and exposes graph traversal as tools, so the model queries
the graph instead of reading source files. It reloads the graph when an
extract replaces graph.json, so re-extracting needs no client restart. The
same server works with both Claude Code and Codex:
| Tool | Purpose |
|---|---|
overview |
body-less skeleton map of the whole codebase |
query_graph(text, budget?, dfs?) |
token-bounded traversal from the symbols that match every word of text (in name, signature, file or doc), best first |
get_node(node) |
a symbol + its relationships (defines, references, embeds, calls), each with file:line; a call also with the lines it is made on; for a declaration only some builds compile, their condition ([$if use_openssl ?]) and the other builds' declarations of it; for a call made only inside a $if, its condition (at line 39 [$if linux]) |
get_body(node) |
the source of ONE declaration by name (instead of reading its file); notes when that file has changed since the graph was built |
get_neighbors(node) |
names of all directly linked symbols |
shortest_path(a, b, routes?) |
how a reaches b: how many chains of calls lead from a to b (at most 10 calls longer than the shortest, and none through builds that contradict each other, use_openssl ? and !use_openssl ?), and the 5 shortest (routes for up to 50), those the default build has first, each call with the line it is made on and its [$if …] build, each route with the build it needs (or those from b to a); with none either way, the shortest link through any relation |
A node is an id (net.unix.StreamListener.accept), a dotted tail of one
(StreamListener.accept, unix.StreamListener.accept), a file:line inside a
declaration (vlib/net/unix/stream.c.v:290), or a name. A name with several
declarations is each of them: get_node wait_for_read shows all 8, each with its
own callers, and get_body reads up to 4. A name with more than 8, such as str,
is a list of them, the most called first, to name one from.
query_graph seeds from the symbols that match every word of its text, in their name,
signature, file or doc comment, leaving out question words (how, the, does);
with none that match them all, from those that match the most. They come best
first: a word that is the whole name, then one in it, in the signature, then in the
file or doc; a test's declaration counts half. At most 50 seeds.
A call is listed with the lines it is made on, in the caller's file:
called by : read_ptr (vlib/net/tcp.c.v:183) at lines 198, 224. graph.json keeps
them on each calls edge as lines; a graph extracted before has none, and lists
its calls as before until it is extracted again.
Build and register it with the client you use. The binary name gets an
.exe suffix on Windows only; the rest of this doc omits it.
Build with current V (the V3 compiler); run the tests with v test ..
Before a push, v run bin/test-crlf.vsh runs them again on a checkout of HEAD with
CRLF line ends, as Windows CI checks it out, so that a test that would fail only
there (one comparing a multi-line string literal, say) fails here first.
Extracting the V compiler repo peaks at about 1.3 GB. On
macOS 27, V itself has to be built with -cc cc until vlang/v#29744 is
fixed (a V binary linked by its bundled TCC can start with garbage in its
globals); graphify's own builds need nothing extra.
v -prod -o bin/graphify cmd/cli # extract, query, etc.
v -prod -o bin/graphify-hook build cmd/hooks/graphify_hook.vsh # Claude Code hook, see below
v -prod -o bin/graphify-mcp cmd/mcp
bin/graphify extract . # produce graphify-out/graph.json first
Any checkout builds its own sources, including a Claude Code worktree under
.claude/worktrees/: graphify/alias.v points import graphify at the
checkout holding it. From the main checkout, v test . also runs every
worktree's copy of the tests (FUTURE_WORK.md §9).
The hook needs the build subcommand because its source is a .vsh script.
Without it, V treats the file as a script to compile and run. Each build
then executes the hook as a side effect, and the build goes through V's
compile-and-run cache, which can leave an existing bin/graphify-hook in
place instead of rebuilding it — while still exiting 0.
.mcp.json (already in this repo, gitignored — copy .mcp.json.example if
you don't have one) uses Claude Code's ${CLAUDE_PROJECT_DIR} path expansion,
so it works unedited on any machine; the one thing to check per OS is the
.exe suffix on command:
{
"mcpServers": {
"graphify": {
"command": "${CLAUDE_PROJECT_DIR}/bin/graphify-mcp.exe",
"args": ["${CLAUDE_PROJECT_DIR}/graphify-out/graph.json"]
}
}
}Or register globally:
claude mcp add graphify -- /path/to/bin/graphify-mcp /path/to/graph_data/vlang/graph.json
Codex reads MCP registrations from ~/.codex/config.toml (or a trusted
project-scoped .codex/config.toml). Register the same server against the
graph extracted from the V-language repository:
[mcp_servers.graphify]
command = "/path/to/graphify/bin/graphify-mcp"
args = ["/path/to/graph_data/vlang/graph.json"]
enabled = trueThe equivalent CLI command is:
codex mcp add graphify -- /path/to/graphify/bin/graphify-mcp /path/to/graph_data/vlang/graph.json
Restart the client after registering or switching the graph so it reloads the MCP server configuration (a re-extract of the same graph doesn't need one).
Your own projects, layered on vlang. Add --projects <store>, the directory
extract --store writes project graphs into:
claude mcp add graphify -- /path/to/bin/graphify-mcp /path/to/graph_data/vlang/graph.json --projects /path/to/graph_data
A session whose directory is inside a project in the store, or inside a worktree
of one, then gets that project's graph with vlang's vlib under it: one
shortest_path, get_neighbors or get_node goes from the project's code into
gg, sokol or builtin, because the project's calls into vlib already name vlang's
ids. query_graph seeds from vlib's matches as from the project's, the project's
first where they match as well; the overview is the project's, and a vlib declaration is marked [library]. Anywhere else the
server serves the vlang graph alone, as before. The note at the top of a result
also says when the project read vlib from another checkout or commit than the
vlang graph is of. See FUTURE_WORK.md §11.
A session in a git worktree of a project, or of vlang, inside the checkout as
Claude Code makes one (.claude/worktrees/) or beside it, gets the graph of the
worktree's own branch. The session's directory is the client's MCP root
(roots/list), which the server asks for: the desktop app starts a server in the
project's own checkout, not the session's worktree. The server extracts the worktree in the background, with
the bin/graphify beside it, into a store of its own under
~/.cache/graphify/worktrees (or GRAPHIFY_WORKTREE_STORES), seeded with the
checkout's extract cache so that only the files the branch changed are parsed:
about half a second for a project, six seconds for vlang. Until it is there the
checkout's graph is served, with a note saying so. When a V file of the worktree
changes, committed or not, the next tool call starts another extract, and the
graph is replaced when it is done. A store whose worktree is gone is removed when
a server starts.
One daemon answers for every session. Each session's server is a small relay
(about 12 MB) that passes its tool calls over a Unix socket in
~/.cache/graphify (or GRAPHIFY_DAEMON_DIR) to graphify-mcp --daemon, which
the first server starts. The daemon reads vlang's graph and its index once, and
keeps a layered graph for each session's directory, whose index falls through to
the base's, so a session costs a few MB for its project rather than a whole
server's 1.1 GB: four sessions (gui, vebidor, graphify, vxui) took 1.18 GB in the
daemon, against 2.26 GB when each layer copied vlib's symbols. A daemon exits 15 minutes (GRAPHIFY_DAEMON_IDLE seconds) after its
last session goes, or once none is connected when bin/graphify-mcp has been
rebuilt; a rebuilt server starts a daemon of its own. Its log is beside its
socket. A daemon that exits on its own removes its socket and leaves its log
(and on Windows its copy of the binary); a daemon that is running removes those
when it next looks whether it is done (about every minute while no server
connects), once the log is a minute old, and a daemon starting removes a killed
daemon's files at once. On Windows (10 1803 and later, which have Unix sockets) the daemon runs
from a copy of graphify-mcp.exe beside its socket, since a running .exe can't
be rebuilt. It is started detached, with no console and none of the server's
handles, so it outlives the session that started it as it does elsewhere.
GRAPHIFY_MCP_DAEMON=0 has each server read the graphs itself, as one does when
the daemon can't be reached.
Stale-graph notes. Both the CLI (on stderr) and the MCP server (at the top
of a tool result) say when the graph may be out of date, from the
manifest.json beside it: when the source checkout has moved past the commit
the graph was extracted from (... is at 89371e2b13 (3 commits ahead)), or
when the graphify that would extract it again isn't the build that did, or
when the vlib or a module under ~/.vmodules that it read has moved on. A
graph read with --source-dir / GRAPHIFY_SOURCE_DIR, shared from another
machine, is compared by commit only. Uncommitted edits aren't reported. Smoke-test the protocol independently of either
client with:
cat cmd/mcp/test_session.jsonl | bin/graphify-mcp /path/to/graph_data/vlang/graph.json
Beyond the graphify CLI itself, bin/ and bench/ hold V shell scripts
(.vsh, run via v run — no separate scripting language or interpreter to
install, since every machine running this already has V) for running this
against a large external repo (vlang) day to day. The exceptions are the
Claude Code hook (below), which is prebuilt for latency reasons rather than
run via v run, and the scheduled refresh on Windows, which also runs as a
prebuilt exe (see Scheduling).
One-time setup: copy graphify.config.json.example to
graphify.config.json (repo root) and edit the paths for your machine:
{ "vlang_repo": "/path/to/repo/vlang", "store": "/path/to/graph_data" }Add "projects": ["/path/to/a/project", ...] to have update-vlang-graph.vsh
extract your own projects into the store after vlang, for the MCP server's
--projects. A project is re-extracted when its commit moved, graphify was
rebuilt, or vlang changed; it is never pulled.
graphify.config.json is gitignored — each machine keeps its own copy, and
update-vlang-graph.vsh, switch-vlang-graph.vsh, and extract_bench.vsh
(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 graphify and the target repo, rebuilds graphify if it is out of date,
and re-extracts each graph that is out of date. Wired into a scheduler — Windows Task
Scheduler, macOS launchd, or Linux cron, see Scheduling below; can
also be run by hand.
v run bin/update-vlang-graph.vsh # pull both; extract what is out of date
v run bin/update-vlang-graph.vsh -NoPull # pull neither (after your own `v up`)
v run bin/update-vlang-graph.vsh -Force # re-extract every graph, out of date or not
v run bin/update-vlang-graph.vsh -Commit <sha> # move up to that commit, not the newest
v run bin/update-vlang-graph.vsh -NoBuild # never rebuild graphify
v run bin/update-vlang-graph.vsh -PrintStamp # print the build stamp and stop
graphify's binaries depend on graphify's own code and on the V that builds them:
the compiler, the v on PATH, and the vlib and thirdparty (the garbage
collector) it compiles in. The script records what each build saw in
bin/.build-stamp (graphify's commit and uncommitted changes, v version, and
the git trees of vlib and thirdparty with their uncommitted changes) and,
after pulling, rebuilds bin/graphify and any graphify-mcp and
graphify-hook beside it whenever that no longer matches. Each binary is built
under a temporary name and swapped in, so an MCP server that is running it
doesn't block the build on Windows. So every v up rebuilds them, about 36 s:
a new compiler can change how they run even when the graph they extract is the
same (vlang/v#30996, a fix in the C backend alone, stopped graphify-mcp
losing live strings to the garbage collector). If v isn't a git checkout, the
stamp names its vlib and thirdparty directories instead. -PrintStamp
prints the stamp and stops.
A graph, vlang's or a project's, is out of date when it has none yet, or its
manifest.json says it was extracted from another commit of its repo, by
another build of bin/graphify, or, for a project, from vlang's vlib at
another commit, or from a module under ~/.vmodules at another commit of its
clone (v update, or a pull in it: extracting gui (vglyph moved)...).
A pull or a rebuild therefore shows up on its own, and a run
after your own v up with -NoPull extracts what moved and skips the rest,
each with its reason in the log (extracting graph (its commit moved)...).
Uncommitted edits, and a module there that isn't a git clone, aren't seen;
-Force re-extracts every graph.
The extracts read vlib and ~/.vmodules from the V tree in VROOT, which the
script sets when it is empty. It takes the tree of the v on PATH, else of the
v running the script, since a scheduled task's PATH may have no v. Without
a V tree an extract has no libraries: calls into vlib and ~/.vmodules stay
unresolved. The log names the tree on each run (libraries: vlib from …, VMODULES …). A graph extracted without its libraries is out of date once a tree
is found (extracting graph (it was extracted without its libraries)...).
graphify's own checkout is fast-forwarded first, so a change merged to
master reaches every machine on its next scheduled run. A checkout with
uncommitted changes, or not on a branch, is left as it is, and a failed pull
of it only logs a warning. The prebuilt bin/update-vlang-graph.exe the
Windows task runs is rebuilt along with the other binaries, and the
.<name>.v3cc.* directories V's compiler leaves in bin/ are removed once
they're an hour old.
-Commit lets two machines extract exactly the same source: it fetches and
fast-forwards the repo to that commit, and refuses to move it back or off its
branch. Logs to <store>/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>. The
~/.claude.json edit is a targeted text splice of just that one field, not a
full rewrite — see the comment on patch_mcp_args in the script for why (a
full parse+re-encode round-trip was tried first and found to silently corrupt
unrelated floating-point fields elsewhere in the file).
v run bin/switch-vlang-graph.vsh # graph for the current branch
v run bin/switch-vlang-graph.vsh feature-x # graph for a specific branch
v run bin/switch-vlang-graph.vsh feature-x -Checkout # also `git checkout` that branch first
v run bin/switch-vlang-graph.vsh -Force # re-extract even if a graph already exists
v run bin/switch-vlang-graph.vsh <worktree-path> # graph for an existing worktree
v run bin/switch-vlang-graph.vsh <worktree-path> -Force # same, forcing a re-extract
A positional arg that names an existing directory is treated as a worktree
path, not a branch name: it's extracted directly, the stored graph is
labeled after whatever branch that worktree is already checked out to, and
-Checkout is ignored (a worktree is already on its own branch). This is the
normal case for a one-worktree-per-feature-branch convention, where the
shared main checkout is never git checkout-ed away from its own branch —
pass the worktree's path directly rather than a branch name.
A brand-new graph (worktree or plain branch) isn't a full rescan either way:
master's own .gf_cache.ndjson is copied in to prime the target's cache
before extracting, so only files that actually differ from master's current
tree get reparsed — measured on a real branch one commit ahead of master,
~60-90s full scan vs. ~8s primed. Best-effort and silent on a cache miss (no
prior master extract yet just means a normal full scan, same as before this
existed); skip it deliberately (rare — building a graph against some other,
unrelated base) by deleting the target's .gf_cache.ndjson before rerunning
with -Force.
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.
v run bench/session_stats.vsh # last 14 days
v run bench/session_stats.vsh -Days 30 # look back further
v run bench/session_stats.vsh -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.
v run bench/extract_bench.vsh # defaults: configured repo, temp out dir
v run bench/extract_bench.vsh -Runs 3 # average over 3 runs
v run bench/extract_bench.vsh -Source /path/to/myproject -Out /tmp/gf-bench-myproject
All three schedule the same bin/update-vlang-graph.vsh; how often is up to
you. With no new commits it exits right after the pull, and with new ones the
incremental cache keeps a re-extract of the vlang repo to tens of seconds, so
a frequent schedule is cheap (the author's Windows task repeats every 2
hours).
-
Windows: the task runs a prebuilt
bin\update-vlang-graph.exethrough a hiddenpwsh, notv run. Build it first:v -prod -o bin\update-vlang-graph.exe build bin\update-vlang-graph.vshThen create the task in the Task Scheduler GUI with:
- Trigger: time-based, repeating at the interval you want.
- Action: program
pwsh.exe, arguments-NoProfile -WindowStyle Hidden -Command "& 'C:\path\to\graphify\bin\update-vlang-graph.exe'". (The author's task also sets Start in to the repo root, but nothing in the script depends on the working directory.) - Settings: keep "do not start a new instance" as the rule when a run is still going, and set an execution time limit (the author's is 1 hour) so a run that gets stuck is ended instead of blocking every later one.
Three details are easy to get wrong:
- Use the
buildsubcommand. Without it V treats a.vshas compile-and-run, so building the exe would execute a real pull and extract on the spot (see the hook build note above for the stale-binary side of the same problem). - The exe is a snapshot. It bakes in the checkout path (
@VMODROOT), which is how it findsgraphify.config.jsonandbin\graphify.exe. Each run rebuilds it when graphify has changed, so build it by hand only the first time and after moving the checkout. - The
pwshwrapper is what hides the window. Task Scheduler's Hidden option only hides the task from its list; a console program that an interactive-logon task launches directly still opens a window. A child ofpwsh -WindowStyle Hiddeninherits the hidden console through the&call operator (Start-Processwould open a new, visible one).-NoProfilekeeps your PowerShell profile out of an unattended run.
-
macOS: copy
bin/com.graphify.vlang-update.plist.exampleto~/Library/LaunchAgents/com.graphify.vlang-update.plist, edit the/path/to/...placeholders, thenlaunchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.graphify.vlang-update.plist. Unlike Windows, the plist runsv runon the script directly, so there is no separate build step for the script itself. Chosen over cron: modern macOS restricts cron from accessing files outside a few whitelisted locations without manually granting it Full Disk Access, so a stock cron job for this fails silently; launchd needs no such grant. -
Linux: a plain crontab line is enough for one daily command —
crontab -eand add0 3 * * * v run /path/to/graphify/bin/update-vlang-graph.vsh >> /path/to/graph_data/cron.log 2>&1.
On every platform the job rebuilds bin/graphify itself when graphify or V's
vlib has changed (see above). The first extract after a rebuild is a full
re-parse, because the incremental cache is tied to the binary that wrote it.
The Windows task's own update-vlang-graph.exe is rebuilt with them.
.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(gitignored — copy.claude/settings.json.example, same per-machine pattern asgraphify.config.json/.mcp.json) — aSessionStarthook injects standing guidance, and aPreToolUsehook onRead|Grep|Globreminds Claude to consult the graph before reading or scanning V files. Both callbin/graphify-hook— the compiledcmd/hooks/graphify_hook.vshabove — directly. It's prebuilt rather than run viav runbecause this hook fires on every Read/Grep/Glob/SessionStart, andv runrecompiling from source each time costs several seconds per call — the prebuilt binary responds in well under 100ms. The command names the binary without an extension: V always appends.exeon Windows, and the shell Claude Code runs hooks through there (Git Bash) findsgraphify-hook.exefromgraphify-hook, so the example works unedited on every platform (tested on macOS and Windows)..githooks/post-commit— rebuilds the graph after each commit. Enable withgit config core.hooksPath .githooks.
To wire a different project, copy .claude/ (including
settings.json.example → settings.json, editing the .exe suffix for your
OS) and .mcp.json.example (as .mcp.json, no edits needed) into it.
graph.json stores paths with forward slashes, so a graph extracted on one OS
resolves on another (Windows / WSL-Linux / macOS). The graph itself doesn't
depend on the OS that extracts it: every branch of a platform $if is kept,
and CI checks that Linux, macOS and Windows extract identical graphs of the
same V commit. A declaration only some builds compile records which, as a $if
would write it ("build": "use_openssl ?" in graph.json), from its file's name
(_d_use_openssl, _notd_use_openssl, _nix, _linux, _arm64) and the
file-scope $if it is in, and a call in a function body the $if branches
around it ("build" on the edge). A call through an alias that each build declares in
its own file, such as ssl.SSLConn (mbedtls.SSLConn by default,
openssl.SSLConn with -d use_openssl), links to each build's method. See
FUTURE_WORK.md §12. 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.
Known gaps and evidence-gated future work are recorded in FUTURE_WORK.md.
- 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. (Since the V3 port the GC no longer slows the write — 13s with it vs 16s without on Windows, 2.7s vs 2.4s on macOS — so-gc nonewas dropped on 2026-10-07, cutting peak memory from 3.8 GB to 1.3 GB.) - Phase 2: MCP server (
query_graph,get_node,get_neighbors,shortest_path) + Claude Code.mcp.jsonand Codexconfig.tomlregistration - 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). Also tied to the running binary's own hash, not just each file's — see below. - 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. On the full vlang tree (298,513 calls, October 2026), 98.84% of calls resolve to a declaration, 1.03% are undeclared (no declaration in the program binds the name, such as a runtime helper or a C function declared in a header the repo does not hold), and 0.13% (386) stay unresolved. The unresolved ones are mostly method calls on receivers the recipe cannot type: values of a generic type parameter, and some field, call-result and index chains. What is left, and why each part stays, is in FUTURE_WORK.md §1. The earlier 97.4% ceiling assumed the receiver type had to come from the checker; receiver recipes (
recipein backend_v.v, followed byinfer.v) made that unnecessary. Ambiguous names are still resolved at query time from the raw names (index(), andpossibly called byinexplain/get_node), not by fanning out edges to every candidate. - 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_bodyworks against the merged result too: arootsmap from each source's label chain to its own original root lets it pick the right root per symbol; a graph.json from before this map existed simply decodes withrootsempty and falls back to the singlerootfield, unchanged. See Usage. - Phase 5,
communities— Louvain-style modularity optimization (local-moving + multi-level aggregation) plus a connectivity-guaranteeing split pass, standing in for the Leiden paper's randomized refinement phase; see Usage for exactly what that trades away. Verified against Zachary's Karate Club (correctly separates its two documented rival factions every run; modularity reliably well above a random/degenerate partition across dozens of test runs) and against real V codebases, where the resulting communities are recognizable subsystems —Checker,Builder,Parser,Fmt,JsGen,Table,Scopeon the V compiler repo, not noise. Surfaced a real, pre-existing limitation rather than papering over it:Index.by_idcollapsed distinct declarations that share an id across build units (every standalonemainprogram above all) into one node, which withoutdefines-edge exclusion produced an artificial supermassive "main" community. Excluding containment edges (defines/imports/implements) from community weight remains independently well-motivated (containment isn't interaction) even now that the id-collision itself is fixed below — it also matters for the legitimate platform-variant ids that are still intentionally left collapsed. -
Index.by_idid-collision fixed (2026-08-16).disambiguate_ids(graphify.v) runs before edge resolution and gives colliding declarations their own distinct node identity — see the Usage section's Index.by_id note for the full mechanism and real before/after numbers (21.03% of all symbols silently discarded → 13.54%, with the fixable portion — genuine cross-build-unit collisions — going from 7,646 rows to 8). -
fn C.foo/fn JS.fooextern-decl same-file collision fixed (2026-08-16). The last 8 rows left over from theIndex.by_idfix above were a distinct bug:fn_id(backend_v.v) doesn't preserve a declaration'sC./JS.language prefix inshort_name, so a body-less extern binding and a same-named real V wrapper in the same file got an identical id — which a file-qualified suffix can't separate, since they already share a file. Fixed by skipping body-less non-VFnDecls as symbols entirely; call resolution to an extern callee was already non-functional either way (the raw call name carries theC./JS.prefix via a different parser field thanshort_nameever sees), so nothing that worked is lost. A fresh full-repo extraction shows 0 duplicate ids across all six kinds this pass targets, down from 4 ids / 8 symbols. - Incremental cache tied to the running binary, not just file hashes (2026-08-17).
.gf_cache.ndjsononly ever checked each source file's own content hash — with no way to detect that graphify's own extraction logic had changed. The fix above changed which symbols get extracted from a file without changingFileResult's on-disk shape, so a rebuilt binary kept serving pre-fix cached results indefinitely on an unchanged corpus: a daily-scheduled Windows extraction served stale, pre-fix symbol counts for weeks, caught only by comparing against a fresh macOS extraction of the identical commit. Fixed by hashing the running binary itself once per extract and stamping it into the cache header — a cache written by a different binary is discarded wholesale, even when every individual file's hash still matches, since the binary is what actually decides what a file's content extracts to. This subsumes the old hand-maintainedcache_formatversion bump (any wire-format change necessarily changes the compiled binary anyway);cache_formatitself is kept, now scoped to just the header's own envelope shape, so a pre-existing cache from before this fix is safely discarded rather than misparsed. - Scripts, external calls, and the hook example (2026-10-06). A
mainfile with statements at file scope and nofn mainis a V script; the parser accepts one only in script mode and otherwise reports every statement as an error, so these 33 files on the V compiler's tree were flagged as partial although nothing was wrong. Amainfile that fails to parse is now tried in script mode, and taken when that is clean: its statements formmain, calls included (flagged files 253 → 220). The report counts calls toC./JS.functions asexternal(4,939 on vlang), apart from the unresolved..claude/settings.json.examplenow namesbin/graphify-hookwithout.exeand addsReadto the hook matcher. - Method calls on parameters (2026-10-06). A parameter's type is written on its function's declaration, so
s.contains(x)insidefn f(s string)needs no inference, just as a call on the method's own receiver did not. Parameters now seed the call walker's typed locals, and a receiver type is matched to a method by its short name in the caller's file, its module, an import or builtin, sincestringandstrings.Builderare what V writes while ids carryvlib.builtinandvlib.strings. On vlangabcebfc16d: 2,268 more resolved calls (995 of themstringmethods) and 291 corrected, all calls that the same-module step had sent to an unrelated method of the same name (name.containstoScope.contains). Of the 33,940 calls still unresolved, 4,939 are to C or JS functions and most of the rest are method calls on a receiver whose type only the checker knows. -
typedeclarations, generic parameters, and test-file privacy (2026-10-06). V'stypedeclarations (aliases such astype NodeId = i32, sum types, function types) were never extracted, although the model had a kind for them, so every reference to one stayed unresolved or, worse, resolved to a same-named struct elsewhere (Buildertov.ssa.Builderinstead of the aliasstrings.Builder). They are symbols now, with references to the types they are built from. A generic parameter such asTis no longer emitted as a reference, and a declaration in a_test.vfile is visible only inside that file, as V compiles it: anenum Colorinvlib/builtin/map_test.vhad counted as part of builtin and been visible everywhere. On vlangabcebfc16d: 1,983 type declarations; resolved type references 69,430 → 80,442, with 794 retargeted from a wrong declaration. Of the 36,625 still unresolved, 35,072 name built-in primitives (int,bool), which have no declaration. -
embedsedges, and references to generic types (2026-10-06). Noembedsedge had ever been emitted: V 0.5.2's parser lists an embedded struct inStructDecl.embedsrather than among the fields the extractor checked foris_embed, and an interface's embedded interfaces inInterfaceDecl.embeds. Both are read now, and a generic type such asveb.Middleware[Context]is named by its base type instead of coming out empty after the closing bracket. On the V compiler's own tree: 676embedsedges (661 resolved) where there were none, and 1,900 more resolved type references. - Standalone programs, imports and
builtinrecognized again; fields and consts with their own ids (2026-10-06). Directory-based module ids had quietly broken themain-program checks and the import/builtinvisibility rule; restoring them resolves about 33,000 more calls and 24,000 more type references on the V compiler's tree, and 62cmd/toolsprograms no longer share onemain. A field or const that shares its id with a method or function now gets<parent>::field::<name>/<parent>::const::<name>. Details under the Index.by_id note in Usage. - A syntax error costs only its own declaration (2026-10-06). After some errors the V 0.5.2 parser loses track of scopes and braces and drops much of the rest of the file even in recovery mode; one file in the V compiler lost 320 of its 501 functions to a single shadowed loop variable. A file whose parse reports an error is now re-parsed one top-level declaration at a time, keeping its line numbers: 394 more symbols on the V compiler's own tree. Details in FUTURE_WORK.md §6.
- Declarations inside file-level
$ifblocks (2026-10-06). The extractor only looked at a file's direct top-level statements, so anything declared inside a file-scope$if(platform-specific structs, constants, functions) was missing from the graph on every machine. It now includes the branch the parser keeps for the host: 180 more symbols on the V compiler's own tree. Details in FUTURE_WORK.md §8. - Recovering past syntax errors, and flagging the files that have them (2026-10-06). V 0.5.2's parser, which graphify's V1 extractor uses, does not know syntax V has added since, such as the array-literal spread
[a, ...(b)]. Run in its default output mode it aborts at the first such error, so every declaration after it was silently missing from the graph: about 3,500 functions on the V compiler's own tree, while the extract reported only 3 unparseable files. Extraction now runs the parser in.silentmode, which records the error and keeps going (the missing functions relative to V3 dropped from 3,548 to 561), drops the nameless function declarations recovery can produce, and lists every file that parsed with an error inmanifest.json'spartiallist and ingraphify extract's output. Details in FUTURE_WORK.md §6. - The extractor runs on V3 (2026-10-07). The extractor used V's V1 frontend (
v.ast), which upstream V removed; graphify built only through the V 0.5.2 compatibility compiler (-old-compiler), which couldn't parse newer syntax (220 files of the V compiler repo flagged) and saw only the host's$ifbranches. It now reads V3's flat AST (backend_v.v) and builds with plainv; the V 0.5.2 extractor is gone. On the V compiler repo: 214,790 resolved calls to 209,991, 5 files flagged to 220, extraction 2.6 s to 2.9 s, and the same graph on every OS. Ids, signatures and lines keep the old conventions, so existing graphs and queries carry over; a re-extract re-parses every file once, since the cache is tied to the binary. What changed is in FUTURE_WORK.md §6: imports are resolved to the module they mean, imports the parser implies are recorded asimport m (implied), and 232 ids went away (187 imports renamed by resolution, the rest V 0.5.2 artifacts such asvlib.builtin.int literal.str) while 2,250 came in, mostly other platforms' code. - Receiver types inferred through the graph (2026-10-07). A method call such as
name.contains('.')matched dozens of declarations by name, and the locality heuristics often picked the wrong one (v.types.Scope.containsfor a string). Each call whose receiver type the code doesn't write now records how to work it out: from a call's result, a field, an index, a loop variable, a literal orit.resolve_edgesfollows that through the return and field types in the whole graph (infer.v). On the V compiler's tree, unresolved calls fell from 28,642 to 7,733 (11.5% to 3.1%) and 1,974 earlier guesses were corrected; extraction time is unchanged and peak memory rose from 1.3 to 1.5 GB. What's left is mostly interface methods (err.msg()), function values andthread.wait(); see FUTURE_WORK.md §1. - Interface methods, embedded members, and undeclared calls (2026-10-07). An interface's methods and fields are now symbols, so
err.msg()resolves tobuiltin.IError.msgand a call on any interface-typed value to that interface's method rather than to a guessed implementation; methods and fields promoted from an embedded struct or interface are found too. Calls that can have no declaration, of a function value (a variable, parameter or function-typed field),thread.wait()or an array method the compiler provides, get their own provenance,undeclared, rather than counting as unresolved or being matched to an unrelated function of the same name (b.cb()on a field had resolved to a functioncb). On the V compiler's tree: unresolved calls 7,733 → 3,775 (1.5% of calls), 1,631 undeclared. - Stale-graph notes; the MCP server reloads the graph (2026-10-07). The CLI and the MCP server compare the graph's manifest with the source checkout's commit and with the graphify build that would re-extract it, and say so when either has moved on. The MCP server used to load
graph.jsononce, so every re-extract needed a client restart; it now reloads when the file changes. Together withupdate-vlang-graph.vshrebuilding graphify itself, this closes FUTURE_WORK.md §4's freshness check. - Consts, globals and function-typed fields as receivers (2026-10-08). A const now records how to type its value and a
__globalits type (globals are symbols for the first time), soos.args.clone()resolves toarray.cloneandpreface.bytes()on a string const tostring.bytes. A call through a function-typed field resolves to the field, restoring the links #10 had to drop.str()on a type that doesn't declare one and a@[flag]enum's methods are marked undeclared rather than matched by name (a.flags.set(...)had resolved toarray.set). On the V compiler's tree: unresolved calls 3,870 → 2,748 (1.1%). - Builtin calls, guard errors and qualified embeds (2026-10-08). A plain
error()orpanic()resolves to the caller's own module's function or builtin's, as V binds it, rather than being ambiguous with an imported module'slog.error; a function with per-platform variants is placed by each call's file.errin anif x := f() {} else {}isIError. An embeddedveb.Contextkeeps its module, so 51 structs no longer embed themselves and calls throughctx.resresolve; an import names the moduleresolve_importfound, never a same-named standalone program's directory. Array fields (xs.flags.has()), element-returninglast()/pop()and a sum type'stype_name()are handled. On the V compiler's tree: unresolved calls 2,748 → 1,561 (0.61%); see FUTURE_WORK.md §1. - Qualified type references and conditional imports (2026-10-08). A
referencesedge now keeps the module a type is named through (gfx.Image, an alias, or a selective import), so it no longer resolves to a same-named local type (gg.Image, or a struct embeddingveb.Contextreferencing itself). Imports inside a$ifcount like any other, which fixed wrappers such asgg.WindowSglContext.v2fwhosesgl.v2f()call had resolved to the wrapper itself. On the V compiler's tree: unresolved references 1,283 → 880 (180 retargeted), unresolved calls 1,568 → 1,516 (80 retargeted). - Generic call results (2026-10-08). A generic call's return type takes the type arguments written in the call:
json.decode[Config](s)!.load()resolvesloadonConfig, andfold[T, R]'sRis its second argument. A free function's type parameters are read from its header, which V's parser doesn't keep. On the V compiler's tree: unresolved calls 1,516 → 1,441 (0.56%), 64 more resolved, 1 corrected. See FUTURE_WORK.md §1. - Generic receivers (2026-10-08). A method or field of a generic type takes its type arguments from the receiver's type:
q.pop()!on aQueue[models.Config]returnsmodels.Config, andq.items[0]is that type too. A generic type's parameters are read from its declaration, as a function's are. On the V compiler's tree: unresolved calls 1,441 → 1,431 (0.56%), 19 more resolved, 7 corrected. See FUTURE_WORK.md §1. - Smartcasts and match branches (2026-10-08). Inside
if x is T { ... }, to the right ofx is T && ..., and in amatch x { T { ... } }branch,xis aT. A narrowed receiver whose type lacks the method falls back to its declared type, as V does for a sum type's own method. Amatch e { Variant { ... } }inside a method of the sum typeeno longer resolves against the sum type. On the V compiler's tree: unresolved calls 1,431 → 1,293 (0.51%), 116 more resolved, 29 corrected, 8 wrong links removed. See FUTURE_WORK.md §1. - Unsafe blocks and untyped map literals (2026-10-08). An
unsafe { x }block has the type of its last expression, and a map literal written without a type,{ 'name': 'Joe' }, has the type its literal keys and values make. On the V compiler's tree: unresolved calls 1,293 → 1,046 (0.41%), 240 more resolved, 34 corrected, 4 guesses removed. See FUTURE_WORK.md §1. - Files of one program (2026-10-08). A directory of
module mainfiles with onefn mainis one program, so its files see each other's functions. On the V compiler's tree: unresolved calls 1,046 → 913 (0.36%), 141 more resolved, 9 corrected to the program's own declaration. See FUTURE_WORK.md §1. - Shifts and primitive
str()(2026-10-08).x << nand the other bit operators have the type of their left operand, andstr()on a primitive with no declaration (u128in some builds) is one V writes. On the V compiler's tree: unresolved calls 913 → 900 (0.35%), 8 more resolved. - Per-platform functions (2026-10-08). A plain call to a function declared once per platform in the caller's own program links to each platform's version, since the graph must match on every platform. Method calls aren't linked yet, since their receiver type isn't checked. On the V compiler's tree: unresolved calls 900 → 878 (0.34%), 22 more resolved. See FUTURE_WORK.md §1.
- Initializer calls (2026-10-09). A call in a constant's, global's or struct field's initializer is recorded as made by that declaration, so a change to the callee shows it. On the V compiler's tree: 2,023 more call edges; unresolved calls 878 → 879 (0.34%), since 7 new calls (method calls on values of unknown type, and
Type.zero()on flag enums) stay unresolved while 6 others now resolve. See FUTURE_WORK.md §1. - Flag enum
zero()(2026-10-09). V generatesT.zero()for a@[flag]enum, so a call of it to an enum in the caller's module that declares nozeroof its own is undeclared. On the V compiler's tree: unresolved calls 879 → 871 (0.34%), 8 more undeclared. - Receivers from platform copies (2026-10-09). A call on the result of a function declared once per platform is typed when every copy returns the same type, and a call that reaches one copy of a function or method reaches all of them. On the V compiler's tree: unresolved calls 879 → 856 (0.33%), 23 more resolved, 39 more call edges. See FUTURE_WORK.md §1.
- Fixed-size array literals (2026-10-09). A loop variable or call on an element of a fixed-size array literal (
['a', 'b']!) has the type of its elements. On the V compiler's tree: unresolved calls 848 → 819 (0.32%), 29 more resolved or undeclared. See FUTURE_WORK.md §1. - Function values and map type arguments (2026-10-09). A call of a parameter of function type has that function's return type, and a generic call's map or array type argument is named as written, so the result is typed. On the V compiler's tree: unresolved calls 819 → 815 (0.32%), 4 more resolved. See FUTURE_WORK.md §1.
- Comptime field loops (2026-10-09). The variable of a
$for field in T.fieldsloop is a builtinFieldData, so its attributes and name are typed. On the V compiler's tree: unresolved calls 815 → 791 (0.31%), 24 more resolved. See FUTURE_WORK.md §1. - Enum members (2026-10-09). A member of an enum,
Colour.red, is a value of the enum's type, so a method on it is found:str()on an enum that declares none is V's and undeclared, and so is a flag enum'shas()orset(). A local made from a member (flags := ArrayFlags.is_slice | ...) has the enum's type. On the V compiler's tree: unresolved calls 791 → 768 (0.30%), 20 more undeclared, no call newly unresolved. See FUTURE_WORK.md §1. -
str()results (2026-10-09).str()on a type that declares none is one V writes, and it returns astring, so a method called on the result (x.str().contains(...)) is found onstring. On the V compiler's tree (vlang5bd67093f9): unresolved calls 768 → 725 (0.28%), 43 more resolved, none newly unresolved. See FUTURE_WORK.md §1. - Generic type arguments through import aliases (2026-10-09). A generic type argument written out as a map or array type (
json.decode[[]map[string]json.Any](s)) names its module as the file's import gives it (json2, forimport json2 as json), so the values it types are found. On the V compiler's tree (vlang5bd67093f9): unresolved calls 725 → 713 (0.28%), 11 of themstr()calls now resolved tojson2.Anymethods, and none newly unresolved. See FUTURE_WORK.md §1. - Comptime type checks (2026-10-09). Inside
$if field.typ is T(or$if method.return_type is T),x.$(field.name)(orx.$method()) has the type T, so a method on it is found. On the V compiler's tree (vlang5bd67093f9): unresolved calls 692 → 687 (0.27%),str()76 → 72, one more undeclared, no call newly unresolved. See FUTURE_WORK.md §1. - Literal and builtin receivers (2026-10-09). A map literal whose keys and values are runes, nested literals, casts or conversions has the type its entries make.
typeof(x)is a string,dump(x)has x's type, and<-chas the element type of the channelc. On the V compiler's tree (vlang5bd67093f9): unresolved calls 687 → 609 (0.24%),str()72 → 55, 8 more undeclared, no call newly unresolved. See FUTURE_WORK.md §1. -
@[alias]directories (2026-10-09). A directory whosealias.vholds@[alias: '@VMODROOT/<path>']stands for the module at that path, soimport x.json2isvlib/json2, and its calls and types resolve to it. On the V compiler's tree (vlang5bd67093f9): unresolved calls 713 → 692 (0.27%), 21 more resolved, none newly unresolved, including 15x.json2.decodeandx.json2.encodecalls. See FUTURE_WORK.md §1. - Receivers the source declares (2026-10-09). A
sharedoratomicdeclaration binds its name, a qualified enum constant (gg.HorizontalAlign.left) is its enum, asqlblock has the type the ORM gives it, and a map literal whose entries are enum members ({.dog: 1}) has the enum's type. On the V compiler's tree (vlang5bd67093f9): unresolved calls 600 → 578 (0.22%),str()49 → 44, 5 more undeclared, twoMiddleware[T].strlinks corrected to the enum methods they name, no call newly unresolved. See FUTURE_WORK.md §1. - Thread and C receivers (2026-10-09).
spawn f()has the result off, sowait()on it is that type.str()on a thread is V's and undeclared. A field of a C struct (&C.addrinfo'sai_family) takes its type from thestruct C.declaration, anda >>> bhas the unsigned type ofa. On the V compiler's tree (vlang5bd67093f9): unresolved calls 578 → 551 (0.21%),str()44 → 41, onecontainson a[]stringretargeted tostring.contains, no call newly unresolved. See FUTURE_WORK.md §1. - Receivers from sum-type arms and method type parameters (2026-10-09). A
matcharm that names a primitive, a module's type or an array type types its sum-type subject as that type in the arm. A method's own type parameter takes the type argument the call writes, through the batch protocol too. An alias of an array has its element type, andmapon one takes the closure's written return type. On the V compiler's tree (vlang5bd67093f9): unresolved calls 551 → 493 (0.19%),str()41 → 35, 2 fewer undeclared, no call newly unresolved. See FUTURE_WORK.md §1. - A C struct declared in several modules (2026-10-09). A C struct such as
C.Eventis one name for the whole program, and its declarations in other modules, as the terminal UI's Windows files and vlib's compiler tests have, no longer block it: the declaration nearest the field's own file is the one (c_struct, in infer.v). On the V compiler's tree (vlang5bd67093f9): unresolved calls 493 → 492 (0.19%),str()35 → 34, no call newly unresolved. See FUTURE_WORK.md §1. - An
iformatchvalue takes the type of the branch that has one (2026-10-09). Aniformatchexpression took its type from its first branch alone, so a first branch that names no type left the value untyped, though V gives the expression the type its branches share. The first branch with a type now gives it. On the V compiler's tree (vlanga9a2afa710): unresolved calls 492 → 487 (0.19%),str()unchanged, no call newly unresolved. See FUTURE_WORK.md §1. - A map literal with an entry that names no type (2026-10-09). A map literal whose entries are not all literals (a variable value, an empty
[], a negative key such as-1) had no type, so the calls on its values and its keys went unresolved. Its type is now its first key's and its first value's that name one, and a negative number has its literal's type. On the V compiler's tree (vlanga9a2afa710): unresolved calls 487 → 472 (0.18%),str()unchanged, no call newly unresolved. See FUTURE_WORK.md §1. - Calls V generates, and its intrinsics (2026-10-09). V generates
T.from(x)andT.from_string(s)for every enumT, so a call of either to an enum in the caller's module that declares no such method is marked undeclared, as V's ownT.zero()already was. The__addrintrinsic and V3's own__v3_forms (such as__v3_isreftype) keep no edge, since V lowers them. On the V compiler's tree (vlanga9a2afa710): unresolved calls 472 → 442 (0.17%), undeclared 2392 → 2414,str()unchanged, no call newly unresolved. See FUTURE_WORK.md §1. - V's runtime helpers (2026-10-09). A call of one of the runtime helpers V's bare builtin declares in its own source (
__malloc,bare_print,bare_eprint,bare_panic,bare_backtrace,sys_write) or of an arena hook (g_arena_alloc_hook,g_arena_realloc_hook,g_arena_owns_hook), with no declaration in the program that it resolves to, is into the runtime and is marked undeclared. On the V compiler's tree (vlang407c52ed): unresolved calls 442 → 416 (0.16%), undeclared 2414 → 2440,str()unchanged, no call newly unresolved. See FUTURE_WORK.md §1. - Constants with a
$ifvalue (2026-10-09). A constant, or a local, whose value is a$if … { a } $else { b }has the type its branches share, as vlib/builtin'smax_intis anint. On the V compiler's tree (vlang5bd67093f9): unresolved calls 603 → 600 (0.23%),str()51 → 49, no call newly unresolved. See FUTURE_WORK.md §1. - The
@[flag]attribute on an enum (2026-10-09). The extractor reads@[flag]above an enum into its signature (@[flag] pub enum Show), so the graph records the flag bit.T.zero()is marked undeclared only when the enum the caller's file sees is a flag enum; thezero()entry above did not check that. On the V compiler's tree (vlang407c52ed): no edge changes, unresolved stays 416 (0.16%), undeclared 2440; 41 enum signatures gain@[flag]. See FUTURE_WORK.md §1. - A generic struct as a type argument (2026-10-09). In
json.decode[AnyStruct[json.Any]](s)!.val.int()the fieldvalhad no type, because a generic call's type argument was named only when it was a plain name, a primitive, or a map or array;AnyStruct[json.Any]came through empty, soint()linked by name alone, which two visibleintmethods made ambiguous. The argument is now named by its base and its own arguments (type_arg, in backend_v.v). On the V compiler's tree (vlang407c52ed): unresolved calls 416 → 413 (0.16%), undeclared 2440, no resolved edge removed; twoint()calls in json2's tests resolve toAny.int, and oneunix()merges into the edge its caller already has totime.Time.unix. See FUTURE_WORK.md §1. - Array and map methods a module declares (2026-10-09). A module declares a method on
[]Anyormap[string]Any, and a file that imports it names the type[]toml.Any. The lookup tried only the builtinarrayandmapmethods on such a receiver, so the module's method never matched. The owner is now matched by each name's module, through the file's imports (owner_matches, in infer.v), after the exact-text match, which is unchanged. On the V compiler's tree (vlang407c52ed): unresolved calls 413 → 401 (0.16%), undeclared 2440 → 2424, no resolved edge removed; toml'svalue,as_stringsandto_toml, andstringon their results, resolve. See FUTURE_WORK.md §1. -
joinon a mapped array (2026-10-09).xs.map(...)with no stated result gave its receiver's type as builtin'sarray, which is none of the[]stringownersjoinis declared on, solabels.map(it.name).join(',')never resolved. The result is now an array of an unknown element, andjoin, which only[]stringdeclares, resolves to that method (string_array_methods, in infer.v). On the V compiler's tree (vlang407c52ed): unresolved calls 401 → 390 (0.15%), undeclared 2424, and twoHeader.joinedges that ajoinon a[]stringhad been linked to by name now go to[]string.join. See FUTURE_WORK.md §1. -
repeatkeeps its element type (2026-10-09). Builtin'srepeatreturns an untypedarray, which hid the element type, sobs.repeat(2).hex()andvalues.repeat(3)[0].change()lost their receivers. Arepeatof an array now has that array's type (follow_steps, in infer.v). On the V compiler's tree (vlang407c52ed): unresolved calls 390 → 385 (0.15%), undeclared 2424, no resolved edge removed; the twohexcalls merge into[]u8.hexedges their functions already had. See FUTURE_WORK.md §1. - A call through the file's own module name (2026-10-09). Inside
module semver,semver.from(s)was read as a method on a global namedsemver, so it was left unresolved, unless a local or variable of that name shadows the module. It is now a call of the module's own function (callee, in backend_v.v). On the V compiler's tree (vlang407c52ed): unresolved calls 385 → 381 (0.15%), undeclared 2424, no resolved edge removed;same_module_type_name_fn.module_valuenow goes to the module's owni64rather thanstring.i64. See FUTURE_WORK.md §1. - Type-parameter receivers under a type check (2026-10-09). Inside
$if T is X { ... }in a generic function, a value typedTis anXin that block, so a method on it is found. An interface is not narrowed, sinceT is Shapeholds for every type that implements it. On the V compiler's tree (vlang5bd67093f9): unresolved calls 609 → 603 (0.23%),str()55 → 51, no call newly unresolved. See FUTURE_WORK.md §1. - Phase 5, GraphML/Cypher export — both formats emit one node per unique id (via
Index.by_id, not the rawSymbollist) and only resolved edges, for the reasons the Usage section explains. A real bug was caught in the process, not just anticipated: a naive one-node-per-raw-Symbolversion violated the Cypher export's own uniqueness constraint against this project's own files (they all declaremodule graphify), confirmed by actually running the export, not by inspection. - Phase 5, SVG export and
graph.html— both share one layout: communities (see above) arranged around an outer circle, each community's own members around a smaller circle centered on its spot, sized by degree and colored by community. Deterministic trigonometry, not a force-directed simulation — see Usage for why.graph.htmladds a legend, hover-to-highlight-neighbors, and scroll-to-zoom/drag-to-pan — plain DOM/SVG, no framework. Capped at 300 symbols (proportional per-community, highest-degree first) with the cap always disclosed, never silent. Shipped once already believing it was verified, then genuinely wasn't: the first pass checked hover by dispatching a syntheticmouseenterin JS, which passed because it targets the element directly — it can't catch "the real click target is too small to hit," which is exactly what user feedback then reported. Re-verified with a real WebDriver session (vebidor, driving actual Edge) instead: a synthesized pointer move, not a dispatched event, landed dead-center on a node and measured its rendered size at ~4×4 CSS pixels. Fixed by decoupling the hover hit-target from the node's degree-sized visible dot (now independently sized, ~3× larger) and adding real zoom/pan, then re-confirmed the same honest way — synthesized pointer move on the new target, a dispatched wheel event, and a real drag — plus visual screenshots at each step. Followed by a second round of user feedback shaping this feature further: a high-level view first, with detail revealed while exploring, and per-cluster context since graphify shows no repo directory structure anywhere else. Added semantic zoom — the default view shows one bold label per community (name, count, andcommunity_location's summary of where in the source tree it actually lives) with individual symbol labels hidden until zoomed in past a threshold. Verified the same way as everything else in this feature: real WebDriver zoom on an actual cluster, screenshotted, confirming the map is legible by default and a specific cluster's full member names appear on zooming into it. A third round added click-to-zoom (a legend entry or an in-canvas cluster label zooms the viewBox to fit that community, computed from the rendered nodes' own bounding boxes) and click-to-lock (a node's highlight persists pastmouseleave, so clicking through its now-visible neighbors traces a path across clusters one click at a time; connecting edges get a distinct accent color instead of merely staying undimmed). Real-browser verification again earned its keep, catching two genuine bugs invisible to a synthetic dispatch or a code read: aclassList.toggle(cls, undefined)force-argument gotcha that silently inflated the neighbor set to nearly the whole graph, and amousedown-before-clickordering bug that broke unlocking a node by clicking it again. Both fixed and reconfirmed the same way — see Usage for the full story. A fourth round added hierarchical drill-down: a large, internally-lumpy community gets a "+" badge that reveals its own precomputed nested sub-layout (viacommunities_within, a scoped Louvain re-run gated on actually finding real sub-structure, not just size), capped at 10 with the truncation disclosed. The size threshold was tuned empirically against the classic Fortunato–Barthelemy ring-of-cliques resolution-limit case after plain clique-of-cliques test graphs kept getting cleanly separated bycommunities()at the top level too, leaving nothing to drill into — see Usage for why that shape doesn't work. Testing against a real ~47k-symbol codebase (not just the synthetic test graphs) caught two more real bugs: a badge positioned inside a large community's own member ring instead of outside it, and hidden drill-view.dotcircles that stayed clickable atopacity: 0since only their larger.hit/.cluster-hitsiblings had an explicitpointer-events: none. Both fixed and reconfirmed on the real codebase. A related regression also surfaced and was fixed in the same round: panning was unconditionally dropping a locked highlight, defeating the reason locking exists (surviving exploration); fixing that then exposed a trailing-click-after-a-real-drag issue underneath, only found by tracing the actual mousedown/mousemove/mouseup/click sequence a real drag produces — see Usage for the full story. This is the closing item of Phase 5.