Skip to content
quaesitor-scientiamPublic

About

No description, website, or topics provided.

Resources

Stars

5 stars

Watchers

0 watching

Forks

Latest commit

 

History

254 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

graphify

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.

Why

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() { ... }

Measured savings

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 -p A/B on a 2,885-file / 161-module corpus, both arms --safe-mode so 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 query actually surfaces the doc it captures (see below), the model goes straight to one batched explain call 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 the doc field entirely — only explain showed it — while the wired system prompt recommended query first. The model would query, get no doc text back, and fall back to reading a whole function body. Adding a one-line doc preview to query() and steering the prompt toward explain for 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.

Design

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.

Model

  • Symbol{ id, name, kind, signature, file, line, end_line, is_pub, parent, doc } — end_line is what lets get_body read one declaration instead of a whole file; doc is the // block directly above it
  • Edge{ from, to, kind, provenance } — kinds: defines, calls, imports, implements, embeds, references; provenance (extracted/inferred) is set on resolved calls, embeds, and references edges, and undeclared on an unresolved call that has no declaration to resolve to, or on a C. or JS. type reference that nothing declares — see the edge provenance item in Status
  • implements is a declared vocabulary slot, not a planned feature. V's interfaces are satisfied structurally — there is no impl X for Y syntax 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 like v/ast/table.does_type_implement_interface builds — 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 real veb-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 to v/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.

Emitters

  • emit_skeleton() — body-less code view (the token-saver)
  • emit_json() — full graph for tooling

Usage

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 queries
  • manifest.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 next extract instead of being reparsed — only new/changed files hit the parser. The whole cache is also tied to a hash of the graphify binary 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))

MCP server (for Claude Code and Codex)

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.

Claude Code

.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

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 = true

The 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

Operational scripts

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.

bin/update-vlang-graph.vsh — scheduled graph refresh

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.

bin/switch-vlang-graph.vsh — branch-aware graph switching

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.

bench/session_stats.vsh — token-free MCP usage stats

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

bench/extract_bench.vsh — repeatable extraction benchmark

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

Scheduling — Windows / macOS / Linux

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.exe through a hidden pwsh, not v run. Build it first:

    v -prod -o bin\update-vlang-graph.exe build bin\update-vlang-graph.vsh
    

    Then 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 build subcommand. Without it V treats a .vsh as 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 finds graphify.config.json and bin\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 pwsh wrapper 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 of pwsh -WindowStyle Hidden inherits the hidden console through the & call operator (Start-Process would open a new, visible one). -NoProfile keeps your PowerShell profile out of an unattended run.
  • macOS: copy bin/com.graphify.vlang-update.plist.example to ~/Library/LaunchAgents/com.graphify.vlang-update.plist, edit the /path/to/... placeholders, then launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.graphify.vlang-update.plist. Unlike Windows, the plist runs v run on 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 -e and add 0 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 Code wiring

.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 by file:line.
  • .claude/commands/graphify.md — /graphify [path] rebuilds the graph and summarizes GRAPH_REPORT.md.
  • .claude/settings.json (gitignored — copy .claude/settings.json.example, same per-machine pattern as graphify.config.json/.mcp.json) — a SessionStart hook injects standing guidance, and a PreToolUse hook on Read|Grep|Glob reminds Claude to consult the graph before reading or scanning V files. Both call bin/graphify-hook — the compiled cmd/hooks/graphify_hook.vsh above — directly. It's prebuilt rather than run via v run because this hook fires on every Read/Grep/Glob/SessionStart, and v run recompiling 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 .exe on Windows, and the shell Claude Code runs hooks through there (Git Bash) finds graphify-hook.exe from graphify-hook, so the example works unedited on every platform (tested on macOS and Windows).
  • .githooks/post-commit — rebuilds the graph after each commit. Enable with git 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.

Sharing a graph across machines / OSes

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"] } } }

Status

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.exe is built -gc none: V's default Boehm GC costs roughly 590µs per call on the repeated small strings.Builder appends that writing graph.json is 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.exe deliberately 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 none was 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.json and Codex config.toml registration
  • Phase 3: Claude Code wiring — SKILL.md, /graphify, SessionStart + PreToolUse hooks, git rebuild hook
  • SHA256 incremental cache (.gf_cache.ndjson) — a file whose content hash is unchanged since the last extract is 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 its imports plus builtin, 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.setenv in both environment.c.v and environment.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 standalone main program, and every _test.v file, 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 its doc, surfaced in full (capped at 8 lines) by explain/get_node, and as a one-line preview per result by query/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_comments mode 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_comments mode. 15.8k declarations documented on the V compiler repo, 5.8k of them multi-line. The query preview was added after a live benchmark round showed it omitted doc entirely, which a suggested "query first" workflow turned into full-body reads instead of ever trying explain — see Measured savings.
  • edge provenance — every resolved calls, embeds, and references edge is tagged on Edge.provenance, persisted in graph.json: extracted when the name was globally unique (or, for calls only, the receiver's type came straight from the parser), inferred when narrowed among several real candidates by the referencing declaration's file/module/import-visibility locality — resolve_type_ref mirrors resolve_callee minus the calls-specific kind-match and self-receiver steps, since a type name has neither. embeds/references used to resolve only through the cruder, non-local, by-name-only check in Index.resolve at query time; giving them the same locality narrowing as calls is a real accuracy gain, not just a label — references resolution on the V compiler repo went 20.4% → 31.6% (4,065 → 6,298 of 19,931). Full breakdown in GRAPH_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 in explain's calls/called by/references/referenced by/embeds lines 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 (recipe in backend_v.v, followed by infer.v) made that unnecessary. Ambiguous names are still resolved at query time from the raw names (index(), and possibly called by in explain / 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 like main — the implicit module of every standalone V program — can never collide across inputs. get_body works against the merged result too: a roots map 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 with roots empty and falls back to the single root field, 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, Scope on the V compiler repo, not noise. Surfaced a real, pre-existing limitation rather than papering over it: Index.by_id collapsed distinct declarations that share an id across build units (every standalone main program above all) into one node, which without defines-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_id id-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.foo extern-decl same-file collision fixed (2026-08-16). The last 8 rows left over from the Index.by_id fix above were a distinct bug: fn_id (backend_v.v) doesn't preserve a declaration's C./JS. language prefix in short_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-V FnDecls as symbols entirely; call resolution to an extern callee was already non-functional either way (the raw call name carries the C./JS. prefix via a different parser field than short_name ever 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.ndjson only 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 changing FileResult'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-maintained cache_format version bump (any wire-format change necessarily changes the compiled binary anyway); cache_format itself 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 main file with statements at file scope and no fn main is 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. A main file that fails to parse is now tried in script mode, and taken when that is clean: its statements form main, calls included (flagged files 253 → 220). The report counts calls to C./JS. functions as external (4,939 on vlang), apart from the unresolved. .claude/settings.json.example now names bin/graphify-hook without .exe and adds Read to 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) inside fn 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, since string and strings.Builder are what V writes while ids carry vlib.builtin and vlib.strings. On vlang abcebfc16d: 2,268 more resolved calls (995 of them string methods) and 291 corrected, all calls that the same-module step had sent to an unrelated method of the same name (name.contains to Scope.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.
  • type declarations, generic parameters, and test-file privacy (2026-10-06). V's type declarations (aliases such as type 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 (Builder to v.ssa.Builder instead of the alias strings.Builder). They are symbols now, with references to the types they are built from. A generic parameter such as T is no longer emitted as a reference, and a declaration in a _test.v file is visible only inside that file, as V compiles it: an enum Color in vlib/builtin/map_test.v had counted as part of builtin and been visible everywhere. On vlang abcebfc16d: 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.
  • embeds edges, and references to generic types (2026-10-06). No embeds edge had ever been emitted: V 0.5.2's parser lists an embedded struct in StructDecl.embeds rather than among the fields the extractor checked for is_embed, and an interface's embedded interfaces in InterfaceDecl.embeds. Both are read now, and a generic type such as veb.Middleware[Context] is named by its base type instead of coming out empty after the closing bracket. On the V compiler's own tree: 676 embeds edges (661 resolved) where there were none, and 1,900 more resolved type references.
  • Standalone programs, imports and builtin recognized again; fields and consts with their own ids (2026-10-06). Directory-based module ids had quietly broken the main-program checks and the import/builtin visibility rule; restoring them resolves about 33,000 more calls and 24,000 more type references on the V compiler's tree, and 62 cmd/tools programs no longer share one main. 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 $if blocks (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 .silent mode, 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 in manifest.json's partial list and in graphify 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 $if branches. It now reads V3's flat AST (backend_v.v) and builds with plain v; 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 as import m (implied), and 232 ids went away (187 imports renamed by resolution, the rest V 0.5.2 artifacts such as vlib.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.contains for 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 or it. resolve_edges follows 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 and thread.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 to builtin.IError.msg and 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 function cb). 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.json once, so every re-extract needed a client restart; it now reloads when the file changes. Together with update-vlang-graph.vsh rebuilding 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 __global its type (globals are symbols for the first time), so os.args.clone() resolves to array.clone and preface.bytes() on a string const to string.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 to array.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() or panic() resolves to the caller's own module's function or builtin's, as V binds it, rather than being ambiguous with an imported module's log.error; a function with per-platform variants is placed by each call's file. err in an if x := f() {} else {} is IError. An embedded veb.Context keeps its module, so 51 structs no longer embed themselves and calls through ctx.res resolve; an import names the module resolve_import found, never a same-named standalone program's directory. Array fields (xs.flags.has()), element-returning last()/pop() and a sum type's type_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 references edge 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 embedding veb.Context referencing itself). Imports inside a $if count like any other, which fixed wrappers such as gg.WindowSglContext.v2f whose sgl.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() resolves load on Config, and fold[T, R]'s R is 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 a Queue[models.Config] returns models.Config, and q.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 of x is T && ..., and in a match x { T { ... } } branch, x is a T. A narrowed receiver whose type lacks the method falls back to its declared type, as V does for a sum type's own method. A match e { Variant { ... } } inside a method of the sum type e no 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 main files with one fn main is 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 << n and the other bit operators have the type of their left operand, and str() on a primitive with no declaration (u128 in 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 generates T.zero() for a @[flag] enum, so a call of it to an enum in the caller's module that declares no zero of 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.fields loop is a builtin FieldData, 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's has() or set(). 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 a string, so a method called on the result (x.str().contains(...)) is found on string. On the V compiler's tree (vlang 5bd67093f9): 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, for import json2 as json), so the values it types are found. On the V compiler's tree (vlang 5bd67093f9): unresolved calls 725 → 713 (0.28%), 11 of them str() calls now resolved to json2.Any methods, 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) (or x.$method()) has the type T, so a method on it is found. On the V compiler's tree (vlang 5bd67093f9): 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 <-c has the element type of the channel c. On the V compiler's tree (vlang 5bd67093f9): 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 whose alias.v holds @[alias: '@VMODROOT/<path>'] stands for the module at that path, so import x.json2 is vlib/json2, and its calls and types resolve to it. On the V compiler's tree (vlang 5bd67093f9): unresolved calls 713 → 692 (0.27%), 21 more resolved, none newly unresolved, including 15 x.json2.decode and x.json2.encode calls. See FUTURE_WORK.md §1.
  • Receivers the source declares (2026-10-09). A shared or atomic declaration binds its name, a qualified enum constant (gg.HorizontalAlign.left) is its enum, a sql block 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 (vlang 5bd67093f9): unresolved calls 600 → 578 (0.22%), str() 49 → 44, 5 more undeclared, two Middleware[T].str links 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 of f, so wait() on it is that type. str() on a thread is V's and undeclared. A field of a C struct (&C.addrinfo's ai_family) takes its type from the struct C. declaration, and a >>> b has the unsigned type of a. On the V compiler's tree (vlang 5bd67093f9): unresolved calls 578 → 551 (0.21%), str() 44 → 41, one contains on a []string retargeted to string.contains, no call newly unresolved. See FUTURE_WORK.md §1.
  • Receivers from sum-type arms and method type parameters (2026-10-09). A match arm 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, and map on one takes the closure's written return type. On the V compiler's tree (vlang 5bd67093f9): 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.Event is 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 (vlang 5bd67093f9): unresolved calls 493 → 492 (0.19%), str() 35 → 34, no call newly unresolved. See FUTURE_WORK.md §1.
  • An if or match value takes the type of the branch that has one (2026-10-09). An if or match expression 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 (vlang a9a2afa710): 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 (vlang a9a2afa710): 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) and T.from_string(s) for every enum T, so a call of either to an enum in the caller's module that declares no such method is marked undeclared, as V's own T.zero() already was. The __addr intrinsic and V3's own __v3_ forms (such as __v3_isreftype) keep no edge, since V lowers them. On the V compiler's tree (vlang a9a2afa710): 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 (vlang 407c52ed): unresolved calls 442 → 416 (0.16%), undeclared 2414 → 2440, str() unchanged, no call newly unresolved. See FUTURE_WORK.md §1.
  • Constants with a $if value (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's max_int is an int. On the V compiler's tree (vlang 5bd67093f9): 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; the zero() entry above did not check that. On the V compiler's tree (vlang 407c52ed): 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 field val had 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, so int() linked by name alone, which two visible int methods 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 (vlang 407c52ed): unresolved calls 416 → 413 (0.16%), undeclared 2440, no resolved edge removed; two int() calls in json2's tests resolve to Any.int, and one unix() merges into the edge its caller already has to time.Time.unix. See FUTURE_WORK.md §1.
  • Array and map methods a module declares (2026-10-09). A module declares a method on []Any or map[string]Any, and a file that imports it names the type []toml.Any. The lookup tried only the builtin array and map methods 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 (vlang 407c52ed): unresolved calls 413 → 401 (0.16%), undeclared 2440 → 2424, no resolved edge removed; toml's value, as_strings and to_toml, and string on their results, resolve. See FUTURE_WORK.md §1.
  • join on a mapped array (2026-10-09). xs.map(...) with no stated result gave its receiver's type as builtin's array, which is none of the []string owners join is declared on, so labels.map(it.name).join(',') never resolved. The result is now an array of an unknown element, and join, which only []string declares, resolves to that method (string_array_methods, in infer.v). On the V compiler's tree (vlang 407c52ed): unresolved calls 401 → 390 (0.15%), undeclared 2424, and two Header.join edges that a join on a []string had been linked to by name now go to []string.join. See FUTURE_WORK.md §1.
  • repeat keeps its element type (2026-10-09). Builtin's repeat returns an untyped array, which hid the element type, so bs.repeat(2).hex() and values.repeat(3)[0].change() lost their receivers. A repeat of an array now has that array's type (follow_steps, in infer.v). On the V compiler's tree (vlang 407c52ed): unresolved calls 390 → 385 (0.15%), undeclared 2424, no resolved edge removed; the two hex calls merge into []u8.hex edges 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 named semver, 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 (vlang 407c52ed): unresolved calls 385 → 381 (0.15%), undeclared 2424, no resolved edge removed; same_module_type_name_fn.module_value now goes to the module's own i64 rather than string.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 typed T is an X in that block, so a method on it is found. An interface is not narrowed, since T is Shape holds for every type that implements it. On the V compiler's tree (vlang 5bd67093f9): 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 raw Symbol list) 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-Symbol version violated the Cypher export's own uniqueness constraint against this project's own files (they all declare module 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.html adds 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 synthetic mouseenter in 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, and community_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 past mouseleave, 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: a classList.toggle(cls, undefined) force-argument gotcha that silently inflated the neighbor set to nearly the whole graph, and a mousedown-before-click ordering 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 (via communities_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 by communities() 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 .dot circles that stayed clickable at opacity: 0 since only their larger .hit/.cluster-hit siblings had an explicit pointer-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.

About

No description, website, or topics provided.

Resources

Stars

5 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages