Answers the questions an agent asks before it edits: what does this module export, who imports it,
what does a change here reach, and which exports does nothing use? Over about 1,700 tracked .mjs
sources (330,870 lines on 2026-10-07), that was previously a matter of grep and hope.
Zero runtime dependencies; Node as package.json declares (measured on 26.7.0). Built after reviewing
CodeGraph — a Rust binary that indexes 38 languages
with tree-sitter into RocksDB and ranks with ONNX embeddings. None of that fits here, and the parts
worth having did not need it: this repository is about 1,700 .mjs files of about 2,300 tracked, V8 already
ships a parser, and veld already holds embeddings behind an HTTP API this repo has a client for.
node codegraph/report.mjs # build, measure, write the store
node codegraph/report.mjs about flow/lexer.mjs # exports, importers, symbols, witnesses
node codegraph/report.mjs importers flow/lexer.mjs # static and dynamic importers, apart
node codegraph/report.mjs blast lib/secrets.mjs # everything a change there can reach
node codegraph/report.mjs dead # exported symbols nothing binds
node codegraph/report.mjs entries # modules nothing imports
node codegraph/report.mjs callers flow/lexer.mjs classify
node codegraph/report.mjs search "masks string interiors" # veld ranks; the graph answers
node --test 'codegraph/test/*.test.mjs'--head reads the committed tree instead of the working one. The store records which, because a
verdict about what a clone gets — a dead export above all — is a different question from a verdict
about the tree you are editing.
The extraction is hand-rolled, and this repository has already paid for that twice: a comment
stripper that swallowed 1,375 lines of admin/serve.mjs and reported a clean tree it had never
read, and four rounds of specifier false positives before bin/lib/tracked-imports.mjs was right.
What repaired the first was not a better regex but V8 cross-checking the result in both directions.
So there are two witnesses, and the thing they disagree about is stated rather than summed.
- W1
lexical.mjs— declarations, call sites, import bindings and re-exports, read offflow/lexer.mjs's per-character classification viaflow/verify.mjs's mask. Every regex here runs over the mask, where string, template, regex-literal and comment interiors are already filler of the same length, so// export function ghost()cannot mint a symbol and offsets still index the original file. - W2
v8-surface.mjs— V8's own answer to what a module exports, what names it demands of each import and what each re-export resolves to, obtained without running a line.new vm.SourceTextModule(src)parses;link()instantiates, which resolves specifiers and binds names and is not evaluation;evaluate()is never called. That distinction is what makes this witness usable on this tree at all —flow/report.mjsrecords why several modules here publish or rewrite a directory when merely loaded.
How W2 learns the import names, and why it is not circular. Nothing tells a linker which names the importer wants. So every specifier is stubbed with no exports, V8 refuses the link and names the one binding it could not find, that name is added to the stub, and the link is retried. Every name in the result came out of V8's parser; W1 is never consulted. If it were, this would be W1 with extra steps and the two would share the failure mode that makes a second witness worth having.
How W2 learns what a re-export resolves to, still without evaluating. Once the link succeeds,
every stub binding is given a unique token with setExport — legal on a linked SyntheticModule,
and not evaluate() — and the module's namespace is read back. A name whose value is a token, or a
stub's namespace object, got there through V8's own export resolution; a local binding reads as a
hoisted function, undefined or a TDZ error, never as a token. That one read covers
export { x as y } from, export * as ns from, and import { x } …; export { x }, which the
language makes the same indirect export. export * is the separate star probe, which carries one
sentinel per specifier: a single shared one is ambiguous as soon as two specifiers star, V8 drops an
ambiguous name, and a module with two export * read back as having none.
What each witness decides, because getting this backwards is how a floored extractor becomes an unfloored one with more ceremony:
| decided by | floored by | |
|---|---|---|
| export surface | W2 — V8 linked it, it is not guessing | — (W1's list is checked against it, never used for a finding) |
local aliases (formatReport as fmtOrphans) |
W1 — a namespace has no opinion about the alias | W2, on the exported half |
| declarations | W1 | W2 where they are also exports; an internal helper carries witness lexical, never both |
| call targets | W1, resolved through the two above | nothing — unresolved is its own bucket |
| re-exports | W2 — V8 resolved each one | W1, compared exactly in both directions; a W1-only reading mints no edge |
Measured 2026-10-07: zero divergence in both directions across all 1,591 analysed files. That
number is worth reading correctly. It does not say W1 is sound; it says W1 has a floor, and getting
there took closing 27 real holes W2 found — anonymous export default in 22 files, and aliased
re-exports (export { baseUrlFor as llmBaseUrl }) in the rest. A single extractor would have called
every one of those a clean read.
Re-exports are compared the same way, as exported <- spec#imported per file: zero divergence
either way over the 111 re-export edges at a commit. The comparison sees what the surface one cannot.
In export var v; export { v as w } from './b'; export * from './b', W1 misses v's declaration and
so credits the star with a v that V8 knows is local. Both witnesses then report the same surface.
Only the re-export comparison disagrees.
And it is a gate, not a measurement. codegraph/test/divergence.test.mjs rebuilds the graph
over HEAD on every suite run and fails on either direction of divergence, on any unreadable file,
and on any partial file that bin/lib/parse-gate.mjs has not already declared a non-module. A
number measured once is a streak, and this repository's own import guard is the cautionary case: it "had been
right all along, and that was never the problem. It had no floor — no reason it HAD to be right,
and therefore no way to notice when it stopped being."
It reads HEAD rather than the working tree, because eight-plus sessions write this one at once and a
peer's in-flight edit is not a defect. That costs ~6s, which is why report.mjs reads every blob in
one git cat-file --batch: the obvious git show per file spawns 1,592 subprocesses and took
29.3s against 5.3s for the batch. A cost that shapes what you are willing to check is a cost
worth removing.
| state | what it means | what it contributes |
|---|---|---|
| analysed | both witnesses answered | nodes, edges, an export surface |
| partial | W1 read it, W2 refused it | its outgoing references only — no surface, no symbols |
| unreadable | W1 failed | nothing, and it makes every reachability answer a lower bound |
partial is not a fudge, it is the state that stops a false positive. workflows/adversarial-review.mjs
is a Workflow script whose top-level return is legal there and illegal in a module, so V8 refuses
it — but it still contains import statements, and a symbol it imports is not a dead export.
"Nothing imports this" and "nothing I could read imports this" are different claims. A query surface that returns the same shape for both has already lost the argument, because the caller cannot tell them apart and will present the second as the first. So:
importers,blastRadiusanddeadExportscarryunknownFrom, andblastRadiuscarrieslowerBound(entryPointsandcallersOfreturn bare lists);deadExportsreturnsdeadandundeterminedseparately, anddeadis empty whenever any file in the population could not be read. A binding is followed through every re-export it passes — named, renamed,export *, transitively, cycles included — so a symbol imported by name through another module is used. A re-export nobody binds is not a use: it passes one on, and leaves its target as dead as it was. A symbol isundetermined— not dead — when its module is reached by a dynamic import (which names no bindings), bound as a namespace or default object (member access is not tracked), or re-exports withexport *, or when a re-export hands it out from a module in one of those states (export * as nsincluded);- unresolved calls are split into
external-import,globalandunknown, because a call toMapand a call to a name nothing introduces are both "no edge" and only the second is a gap.
Measured on this repository at a commit: 625 dead exports, 291 undetermined, 0 unreadable files,
against 650 and 289 on the same tree before re-exports were followed. Of the 25 that left dead,
22 are imported by name through a re-export (lib/docsite-roots.mjs#withRoot among them), and 3
are re-exported by monitor/extractors.mjs, which is dynamically imported, so they are undetermined.
An independent text scan agrees: 26 of the 650 were re-exported somewhere, and the 26th
(bin/lib/scan-target.mjs#SYSTEM_PATHS) is still dead because nothing binds its re-export.
Six of the 625 were spot-checked against an independent grep. Five held: no other file names the
symbol, or no importer of its module does. The sixth, admin/integrations.mjs#getSourceKey, is
called by its test through import(`${MOD}?t=…`); see the computed-specifier limit below.
- Only module-level declarations and class members become symbols. A function declared inside a
function is real and is not here; calls from it attribute to the enclosing top-level symbol.
This is why ~30% of call sites resolve to nothing —
send,run,fn,walk,mkare local helpers, and they are listed by name and site rather than absorbed into a total. - Scope analysis is not done. When a local declaration and an import both provide a name, the call is reported as ambiguous and no edge is emitted — a likelier guess is still a guess wearing a verdict's clothes. (Measured on this repository: 0.)
- Object-literal methods are not class members.
{ foo() {} }andclass X { foo() {} }are indistinguishable to a brace counter, and minting a symbol for the first invents a class. - Member calls (
ns.thing()) are not resolved, which is exactly why a namespace-imported module's whole surface isundeterminedrather than dead. - A dynamic import with a computed specifier (
import(`${MOD}?t=${Math.random()}`)) resolves to nothing, so its target is neither reached nor protected, and what it destructures can be listed as dead. Found in the spot-check above. How many dead exports this affects has not been measured. - A module that carries
export *still has its own declared exports held asundetermined. That rule predates following stars and is conservative rather than wrong: it hides a dead export and never invents one. No module at a commit usesexport *.
CodeGraph's semantic half is an embedding model, an HNSW index and a key-value store. None of that
can live in a zero-dependency repository, and none of it needs to: veld.mjs publishes one
locator per exported symbol through lib/memory-layer-client.mjs (the contract-bound client)
and resolves every hit back against the local graph. veld ranks; the graph answers.
The records are locators and not code on purpose — veld summarises a long record to its first ~50
words and discards the rest, which is the measured fact lib/memory-layer-pointer.mjs exists for.
Each record therefore puts symbol, kind, path, repo and commit inside the budget, and reports
survives rather than promising it.
node codegraph/report.mjs veld-publish # a DRY RUN — what would be sent
node codegraph/report.mjs veld-publish --apply # actually send itPublishing sends this repository's structure to a service, so it is opt-in: describing is not
applying. search degrades to a substring scan when veld cannot answer and returns via: 'local'
with the reason — a degraded answer that does not announce the degradation is the grey-as-green
failure in miniature.
Measured against a live veld 0.7.39+229 on 127.0.0.1:3030, 2026-09-04. Four facts worth having written down, three of which contradicted an assumption made while building this:
- The record must identify itself.
/api/recall/tagsreturns rows carryingexternal_id;/api/recall— the semantic one — returns{ id, experience, score, … }with noexternal_id, notags, and nocontentfield at all. So a hit's only identity is what its own text says, which is why the external id sits in brackets one token into the locator. Recovering it by parsing the prose was tried first and broke on the.ingithub.com, attributing five real hits to a repo calledhttps://githuband marking every one stale. The tests passed throughout, because their fake fetch returned a friendlier shape than the server does —codegraph/test/veld.test.mjsnow pins the measured one. - Records store FULL, not preview.
storedForm: 'full', coverage 1.0, on this build — the ~410-byte truncationlib/memory-layer-pointer.mjswas written for is fixed here. The locator-first design stays anyway: it costs nothing and it is what makes the record survive a server that does truncate. - The identity is stable. A second publish of the same five records reported
inserted: 0, updated: 5. A movingexternal_idwould turn every update into a fresh, perfectly-verifying insert, which is exactly what the client's tally splits those counts to catch. - It writes at about 2 records/second — an upsert plus a verifying read-back each — so the full 2,615-symbol publish takes roughly 20 minutes. The loop is sequential on purpose; a local service would tolerate concurrency, but ordered writes are what the client is written for.
Measured over 1,143 indexed records: zebra quantum umbrella nonsense and render an html table
came back with byte-identical score sequences — 0.9496, 0.8329, 0.7163, 0.5996, 0.4829, 0.3663.
It is a linear decay from rank 1. A nonsense query scores 0.9496 at the top exactly like a good one,
so the number says nothing about how well anything matched.
So search returns rank, keeps the server's number under the honestly-named veldScore, and
every ranked answer carries a scoreNote saying what that number is not. Calling a position a
similarity would be this repository's own defect in miniature — a descriptive signal wearing a
verdict's clothes.
Re-measured after the index was complete, because the first read of it — taken at 1,143 records — was too flattering and would have shipped as a claim.
hybridis not deterministic, and it is veld's default. Four identical queries returned four different result sets with zero names common to all four.semanticreturned the same five every time.searchtherefore passesmode: 'semantic'explicitly and a test pins it: same inputs, same outputs is a house invariant, and a search nobody can re-run to the same answer cannot be checked by anyone — including by whoever is deciding whether it works.- Rank 1 is responsive; the tail is not.
redactSnippet→redactSnippet,braceIndex→braceIndex, and — genuinely —parse a github actions workflow→parseWorkflow. Butredact credentials before sendingreturnsschemaPathat rank 1, so natural-language hit rate is mixed, and ranks 2+ decay into noise fast, with a few symbols (writeJson,ISS_RE_LEGACY) recurring across unrelated queries. - The 5s client default times out at this corpus size, falling through to the substring scan —
correctly announced as
via: 'local', which is the degradation path doing its job.searchraises the default to 30s; an env var the caller set still wins.
Use it as a locator hint, not a ranked list. It is worth having — top-1 finds a symbol you can half-remember the name or purpose of, across 2,615 of them — and it is not the semantic code search CodeGraph advertises.
The same measurement caught a defect in the RECORD. Retrieval was returning four near-identical
neighbours from one module, because every record named its module's entire export list — all 35
records for admin/auth.mjs shared ~90% of their text, so the embedding described the module rather
than the symbol. The body now carries counts instead of the sibling names.
| file | what it is |
|---|---|
schema.mjs |
node/edge kinds, ids that exclude the line, witness held apart from existence |
lexical.mjs |
W1 — declarations, calls, import bindings, re-exports, over the mask |
v8-surface.mjs |
W2 — export surface, demanded import names and re-exports, without evaluating |
build.mjs |
joins them; three population states; divergence in both directions |
query.mjs |
the answers, each carrying its own uncertainty |
store.mjs |
CW_CODEGRAPH_STORE paths; fail-closed IO imported from flow/store.mjs |
veld.mjs |
Tier C — locators out, ranking in, the graph still the source of truth |
report.mjs |
the CLI; re-execs itself with --experimental-vm-modules |
Three MCP tools read the store (never build it): code_about, code_blast_radius,
code_dead_exports. An absent store is reported as absent with the command that makes one — never
as an empty graph, which would answer "nothing imports this" about a repository nobody had read.
flow/ is dataflow oversight: which artifacts nothing reads, over nodes that are modules,
artifacts, stores, env keys and processes. codegraph/ is module and symbol structure. They share
the lexer, the mask, the fail-closed store IO and mergeEdges, and they deliberately do not share a
schema — widening flow/graph.mjs's closed kind sets would loosen a contract that exists to be
tight.