Audience: anyone (human or agent) who wants to understand how CodeGraph turns a
codebase into a queryable knowledge graph and serves it to AI agents.
Scope: a mechanism-level walkthrough of the whole pipeline, with file:line
landmarks. For the single source of truth on agent-facing tool guidance see
src/mcp/server-instructions.ts; for design history of specific mechanisms see
docs/design/.
CodeGraph turns any codebase into a SQLite-backed knowledge graph — nodes
(every symbol) + edges (every relationship) — built by deterministic
tree-sitter parsing, never LLM summarization — and serves it to AI agents over
MCP. Per-project data lives in .codegraph/.
Every design decision bends toward one metric (CLAUDE.md):
An agent falls back to Read/Grep the instant a codegraph answer is insufficient. So every change is judged by one question — is codegraph's answer sufficient enough to stop the agent from reading?
The pipeline is four layers. The cleverness is concentrated in two places: resolution (connecting references across files) and dynamic-dispatch synthesis (inventing the edges static parsing can't see).
files → Extraction (tree-sitter) → nodes + `contains` edges + UNRESOLVED references
↓
Resolution → real cross-file edges (calls/imports/extends/…)
↓
Dynamic-dispatch synthesis → `heuristic` calls-edges for callbacks/React/JSX/vtables
↓
Graph queries + Context + MCP → trace / explore / context / node for agents
The public façade that wires all layers together is the CodeGraph class in
src/index.ts (init/open, indexAll, sync, searchNodes, getCallers,
getImpactRadius, buildContext, watch). Library users, the CLI
(src/bin/codegraph.ts), and the MCP server all drive this one class.
Everything reduces to two tables.
Node= a symbol. 22NodeKinds:file,module,class,struct,interface,trait,protocol,function,method,property,field,variable,constant,enum,enum_member,type_alias,namespace,parameter,import,export,route,component.Edge= a typed relationship. 12EdgeKinds:contains,calls,imports,exports,extends,implements,references,type_of,returns,instantiates,overrides,decorates.
Three implementation details drive the whole system:
-
Node IDs are content-addressed (
src/extraction/tree-sitter-helpers.ts:18):<kind>:sha256(filePath:kind:name:startLine)[:32]. Hashing the line means overloads on different lines get distinct IDs; the ID is stable across re-indexes if the symbol doesn't move. (filenodes are the exception — plainfile:<path>.) -
Qualified names (
buildQualifiedName,tree-sitter.ts:544) are built by walking the enclosing-node stack and joining non-fileancestor names with::(e.g.Foo::bar). The file path is deliberately excluded (it lives infilePath) so it doesn't pollute the FTS index. -
Edge
provenance∈'tree-sitter' | 'scip' | 'heuristic'(src/types.ts:192). This single column distinguishes a synthesized dynamic-dispatch edge from a statically-parsed one at render time — the linchpin of the "follow callbacks" feature. Confidence + the strategy that produced an edge live separately inedge.metadata.
Storage notes (src/db/schema.sql):
- FTS5 external-content virtual table
nodes_ftsovername,qualified_name,docstring,signature(content='nodes'), kept in sync by three triggers (schema.sql:108-123) using the FTS5'delete'-with-old-values idiom. - Edge indexes are composite-only:
(source,kind)+(target,kind). The narrowsource/targetindexes were dropped (migration v4) because SQLite's left-prefix scan covers source-only/target-only lookups — narrow indexes were "dead weight on writes" (schema.sql:125-133). - Backed by
better-sqlite3(native), transparently falling back tonode-sqlite3-wasm(src/db/sqlite-adapter.ts).codegraph statussurfaces which backend is live.
ExtractionOrchestrator.indexAll() (index.ts:595) scans files (git-aware fast
path via git ls-files, filesystem .gitignore walk otherwise), then parses each
one.
Parsing is WASM-only tree-sitter (grammars.ts), grammars loaded lazily
per-language. Most come from the tree-sitter-wasms npm package; four
(Pascal/Scala/Lua/Luau) are vendored in src/extraction/wasm/ because the
packaged builds are broken. copy-assets (run from npm run build) copies
schema.sql + *.wasm into dist/ — any new SQL or grammar must be copied or it
won't ship.
Off-main-thread parsing (parse-worker.ts) is used on the bulk path. The
reason is non-obvious: WebAssembly linear memory grows but never shrinks, so
the only way to reclaim a parser's heap is to kill the worker. Hence it's recycled
every 250 parses and process.exit(1)s on a WASM OOM (a corrupted module would
cascade-fail every later parse). The single-file indexFile/sync paths run
in-process.
The core walker is TreeSitterExtractor.visitNode (tree-sitter.ts:269). Each
language is pure configuration, not a subclass — a LanguageExtractor object
(src/extraction/tree-sitter-types.ts:80) listing which AST node-type strings
mean "class"/"function"/"call", plus optional hooks:
// src/extraction/languages/typescript.ts
export const typescriptExtractor: LanguageExtractor = {
functionTypes: ['function_declaration', 'arrow_function', 'function_expression'],
classTypes: ['class_declaration', 'abstract_class_declaration'],
callTypes: ['call_expression'],
nameField: 'name', bodyField: 'body', paramsField: 'parameters',
getSignature: (node, source) => /* "(params): returnType" */,
isExported: (node) => /* walk parent chain for export_statement */,
extractImport:(node, source) => /* read `source` field → { moduleName } */,
};docstring is the contiguous comment block immediately above a declaration
(getPrecedingDocstring, tree-sitter-helpers.ts:49). signature,
visibility, isExported/isAsync/isStatic are all language-defined hooks.
Non-tree-sitter formats have standalone extractors that delegate to the core
(Svelte/Vue slice out <script>, run a fresh TreeSitterExtractor over it, then
shift line numbers back).
The key architectural split — emitted now vs deferred:
- During extraction, essentially only
containsedges are created (parent→ child structure — everycreateNodeattree-sitter.ts:488pushes one). This needs no cross-file knowledge. - Everything semantic is deferred as an
UnresolvedReference(src/types.ts:274, persisted to theunresolved_refstable). A call likeuserService.login()is captured as the string"userService.login"(extractCall,tree-sitter.ts:1605) attached to the caller — because theloginmethod may live in another unparsed file, reachable only through an import alias or framework convention. Resolving needs the whole-graph view, which only exists after every file is parsed.
This deferral is the reason the architecture has a separate resolution pass.
Framework extractors also run here (tree-sitter.ts:3094), contributing
route/component nodes and their (deferred) handler references.
ReferenceResolver drains unresolved_refs and turns each into an edge pointing
at the right target node. resolveOne (index.ts:579) runs a strategy ladder:
- Built-in filter (
isBuiltInOrExternal,index.ts:816) — drop language stdlib names (console.log,list.append) so they never become edges. Cleverly, a name is only treated as builtin if nothing in the codebase declares it — so a Flaskdef get()view keeps its route edge. - Fast pre-filter — skip unless the name exists in the graph
(
hasAnyPossibleMatch), matches a local import, or a frameworkclaimsReference()s it. - Framework → Import-based → Name-matching, collecting candidates. The first two short-circuit at confidence ≥ 0.9; otherwise the highest-confidence candidate wins.
The three strategies:
- Import-based (
import-resolver.ts): resolve the import specifier to a file (relative paths + per-language extension lists, tsconfigpathsaliases viapath-aliases.ts, monorepo/workspace members, Go module paths fromgo.mod, JVM FQN→path-suffix), then find the exported symbol — recursively chasing barrel re-exports (export { foo as bar } from './x',export * from) up to depth 8 (findExportedSymbol,import-resolver.ts:1230). Confidence 0.9. - Name-matching (
name-matcher.ts): when there's no import to follow, score every same-named candidate.findBestMatch(:564) is the heuristic core: same-file +100, +15 per shared directory segment, same-language +50 / cross-language −80, kind-appropriateness (calls→prefer function,instantiates→prefer class), exported +10. Confidence is then gated on proximity so a distant same-name match scores 0.4, not 0.7. Forobj.methodit infers receiver types — scanning backward for C++ declarators, reading Spring@Autowiredfield types out of the field's signature. - Framework resolvers (24 of them — Express, NestJS, Rails, Spring, Django,
Flask, FastAPI, Laravel, Gin, Axum, …): a
detect()+extract()+resolve()contract (src/resolution/frameworks/). During extraction they emitroute/componentnodes; during resolution they map a URL to its handler. NestJS is the showcase — a route's path is split across a class@Controller(prefix)and a method@Get(path), and can be finalized by a third file (RouterModule.registerinapp.module.ts), so there's an idempotentpostExtractcross-file pass (nestjs.ts:217) that prepends module prefixes to route names.
Two resolution-time kind promotions that extraction couldn't do without symbol
info (createEdges, index.ts:640): extends→implements (target is an
interface), and calls→instantiates (target is a class — how Python/Ruby get
instantiates edges without a new keyword).
Memory + incrementality: LRU caches everywhere (lru-cache.ts), nodes never
all loaded into RAM (indexed SQLite lookups). Full-index resolution is batched
(5000 refs at a time, deleting as it goes — resolveAndPersistBatched,
index.ts:717). Incremental sync scopes resolution to changed files via the
git fast path.
Layer 3 — Dynamic-dispatch synthesis: the distinctive feature (src/resolution/callback-synthesizer.ts)
This is what separates CodeGraph from a plain call-graph tool. Static parsing breaks at any call routed through a function value, vtable slot, string-keyed dispatch table, or framework scheduler — exactly where an agent gives up and starts reading. The canonical break:
class Scene {
callbacks = new Set();
onUpdate(cb) { this.callbacks.add(cb); } // registrar
triggerUpdate() { for (const cb of this.callbacks) cb(); } // dispatcher — cb() is anonymous!
}
scene.onUpdate(this.triggerRender); // wiring siteThe runtime edge triggerUpdate → triggerRender does not exist statically.
synthesizeCallbackEdges() (callback-synthesizer.ts:1182) runs after all base
edges are persisted and invents it, across 14 channels:
Channel (synthesizedBy) |
Bridges |
|---|---|
callback |
field-backed observer (onUpdate/triggerUpdate sharing this.callbacks) |
event-emitter |
string-keyed emitter.on('x', fn) ↔ emit('x') |
react-render |
this.setState() → render() |
jsx-render |
<Child/> → the Child component |
vue-handler / closure-collection |
Vue @event handlers / coll.append(fn)…forEach{$0()} |
interface-impl / cpp-override |
interface/virtual base method → concrete override |
flutter-build |
Dart setState() → build() |
go-grpc-stub-impl |
protoc UnimplementedXxxServer → hand-written impl |
rn-event-channel / fabric-native-impl |
React Native native↔JS bridges |
mybatis-java-xml / gin-middleware-chain |
mapper method → XML SQL / Gin handler chain |
Mechanism for the callback channel (fieldChannelEdges,
callback-synthesizer.ts:100): name-gate candidates
(/^(on[A-Z]\w*|subscribe|addListener|…)$/ for registrars), confirm by reading
the body (registrar must write a field, dispatcher must iterate it and call),
pair registrar↔dispatcher by shared file+field, walk incoming calls edges to the
registrar to recover the callback argument by regex, and synthesize:
{ source: triggerUpdate, target: triggerRender, kind: 'calls', provenance: 'heuristic',
metadata: { synthesizedBy: 'callback', via: 'onUpdate', field: 'callbacks',
registeredAt: 'App.tsx:3148' } } // ← the wiring siteThree choices make this work without becoming noise:
- Edges are plain
kind:'calls'— so BFS/findPath/getCalleestraverse them transparently, with no special-casing in the traversal layer. That's literally whytracecan cross a callback boundary. provenance:'heuristic'+metadata.synthesizedBy/registeredAtlet the render layer label the hop and point at the wiring site — the #1 thing an agent would otherwise grep+Read to explain the flow.- Silence beats wrong. Fan-out caps (an event name on >6 emitters is
skipped), resolution gates (a JSX tag must resolve to a real component, dropping
Array<Foo>generics), generated-file gates. When a channel can't pair confidently, it emits nothing.
The governing principle (CLAUDE.md): "partial coverage is WORSE than none."
Bridging hop 1 but not hop 3 just reveals a new break the agent drills into. There
is no runtime invariant enforcing end-to-end closure — it's enforced by a
validation methodology (prove trace(from,to) connects on small/medium/large
repos before shipping). Worked example: on Excalidraw, bridging only React-render
raised reads to 5–7; only completing the flow through the JSX-child hop dropped
it to 0–1. The full flow
mutateElement → triggerUpdate → [callback] triggerRender → [react-render] render → [jsx] StaticCanvas → renderStaticScene traces in 6 hops across three boundaries.
Design + methodology docs: docs/design/callback-edge-synthesis.md,
docs/design/dynamic-dispatch-coverage-playbook.md.
Traversal is application-level over per-node prepared statements — not a
recursive SQL CTE, not load-all-in-memory. GraphTraverser
(src/graph/traversal.ts) pulls edges one frontier-node at a time via
getOutgoingEdges/getIncomingEdges, maintains its own queue + visited Set
(cycle safety), carries depth per queue-entry, and batches neighbor loads
through getNodesByIds (chunked IN-lists, fronted by a 1000-entry LRU) to kill
the N+1 problem.
| Operation | Direction | Edge kinds | Algorithm | Default depth |
|---|---|---|---|---|
getCallers |
incoming | calls, references, imports |
recursive DFS up | 1 |
getCallees |
outgoing | calls, references, imports |
recursive DFS down | 1 |
getImpactRadius |
incoming + contains descent |
all | DFS | 3 |
findPath |
outgoing | caller-supplied (else all) | BFS shortest path | unbounded |
getTypeHierarchy |
both | extends, implements |
DFS up + down | unbounded |
GraphQueryManager (src/graph/queries.ts) layers higher-level queries:
getContext, getFileDependencies/Dependents, findCircularDependencies (DFS
with a recursion stack at file granularity), findDeadCode (no incoming edges
minus contains), getNodeMetrics.
Search is a 4-tier cascade in searchNodes (src/db/queries.ts:737): FTS5
bm25 (name-heavy column weights — name=20, qualified_name=5, signature=2, docstring=1) → LIKE fallback → bounded-edit-distance fuzzy → always-supplement
exact-name matches (BM25 buries short exact names like getBean). User input is
hardened before MATCH (searchNodesFTS, :961): ::→space, strip FTS
metacharacters, strip boolean operators, then each term becomes a "term"* quoted
prefix, OR-joined. A second application-level pass adds kind/path/name bonuses on
top of BM25. parseQuery (src/search/query-parser.ts) handles structured
filters (kind:function path:src/api authenticate).
buildContext() is the hybrid retrieval engine: search-heavy, traversal-light.
findRelevantContext runs ~6 ranking channels — exact-name lookup with
co-location boosting, definition-prefix FTS, free-text FTS, CamelCase-boundary
LIKE matching (so "Search" matches inside TransportSearchAction, which FTS
tokenizes as one token), compound-term, type-hierarchy expansion — merges by
max-score, applies test-file dampening + core-directory boosts, then does a
small traverseBFS(direction:'both', depth 1) expansion with aggressive
budget/diversity/test capping and a final findEdgesBetweenNodes edge-recovery
pass.
getCode() re-reads the file from disk and slices [startLine-1, endLine] — the
DB stores only coordinates, never source text (so output is always verbatim
current), guarded by validatePathWithinRoot. buildCallPathsSection
(context/index.ts:282) derives short execution flows purely in-memory from the
subgraph's calls edges and renders synthesized hops inline
(→[callback via onUpdate @App.tsx:3148]) — baking trace-value into the
always-loaded context tool.
The formatter (src/context/formatter.ts) renders markdown (curated, capped at
~10 related symbols, generated-files-last, truncated code blocks) or JSON (dumps
everything but drops edge metadata/provenance).
This is where "code understanding" reaches the agent. Nine codegraph_* tools
(src/mcp/tools.ts, declared at :418, dispatched at :1056):
| Tool | Returns | Drives |
|---|---|---|
codegraph_search |
locations only (no code) | searchNodes (FTS), generated-files down-ranked |
codegraph_context |
PRIMARY — entry points + related symbols + key code | buildContext + auto-inline trace + routing manifest |
codegraph_callers / _callees |
compact caller/callee list | getCallers / getCallees across name matches |
codegraph_impact |
affected symbols by file | getImpactRadius |
codegraph_node |
one symbol + body + callers/callees Trail | findSymbol + getCode |
codegraph_explore |
source of N files, line-numbered | findRelevantContext + flow synthesis |
codegraph_trace |
HEADLINE — full call path, each hop's body inlined | findPath(from,to,['calls']) + inlining |
codegraph_status / _files |
index health / file tree | getStats / getFiles |
On repos under 500 files, only the 5 core tools (search, context, node, explore, trace) are exposed — the rest "reduce to one grep at this scale" (tools.ts:769).
codegraph_trace(from, to) — the flow tool (handleTrace, tools.ts:1549).
Name-resolves both endpoints, scores every from×to candidate pair (shared
directory + body-substance − penalties for test/example/stub paths) so a
god-named symbol doesn't blow up the cross-product, then findPath (BFS, ≤7 hops).
On success it inlines, hop-by-hop: the edge note (static call-site line, or the
dynamic-dispatch label + ↳ registered at App.tsx:3148), each hop's body
(≤60 lines), and finally the destination's own callees ("the last mile"). The
closing line: "the complete flow — answer from it; a Read is only needed to chase
a specific local variable." On failure (a break the synthesizers don't cover),
instead of telling the agent to chase it, trace inlines the endpoint bodies + the
destination's file-siblings — the exact material the agent's follow-up tools would
have returned.
codegraph_explore(query) — explore-flow (buildFlowFromNamedSymbols,
tools.ts:1974). The insight: an agent's query is usually a bag of symbol names
spanning a flow ("PmsProductController getList PmsProductService list"). Explore
finds the longest call chain among those named symbols (riding synthesized
edges) and leads its output with it — delivering trace-quality flow through the
call the agent reliably makes. Ambiguous names are disambiguated by co-naming
(keep Impl::list only if Impl is also in the query), and a ≤1 unnamed-bridge
rule bridges one missing intermediate but never wanders a god-function's fan-out.
Two things tie the agent surface to the sufficiency thesis:
- The explore budget (
getExploreBudget/getExploreOutputBudget,tools.ts:90/148) scales both call-count (<500→1, <5000→2, <15000→3, <25000→4, ≥25000→5) and per-call output with indexed file count, with a hard invariant: a larger tier must never get a smallermaxCharsPerFilethan a smaller tier. A real regression motivated this — a mid-tier returning 2500 chars/file (below the small tier's 3800) returned <1% of Excalidraw's 415 KBApp.tsxand forced a Read. - The output never says "use Read." Every block is prefaced "Treat each block
as a Read you have already performed"; truncation tails steer to another
codegraph_explore, not Read; everything iscat -nline-numbered so the agent can citefile:linewithout re-reading. The only place Read is encouraged is the staleness banner — when the file watcher has pending edits for specific files in a response (withStalenessNotice,tools.ts:988).
server-instructions.ts (returned in the MCP initialize response) is the single
source of truth for agent guidance: trace-first for flow questions, context as
PRIMARY, "don't reconstruct a path with search+callers — that's what trace does in
one call," trust results (don't re-verify with grep).
Daemon/proxy model (src/mcp/index.ts, daemon.ts, proxy.ts): one detached
daemon shares a single CodeGraph/SQLite-WAL/tree-sitter warm-up across all
clients (refcounted, 5-min idle timeout). A cold-start handshake answers
initialize/tools/list locally from static constants the instant the client
asks — so tools register in process-startup time instead of waiting ~600ms for the
daemon to bind, which previously caused a "No such tool" race that flung agents
back to grep. Wire format is newline-delimited JSON-RPC 2.0.
Three deliberate choices recur at every layer, and they're what make this more than a symbol indexer:
-
Deferred, whole-graph resolution. Extraction is dumb-but-fast and per-file; all the cross-file intelligence (imports, name-matching, frameworks) is a second pass over the complete graph. This is why it can resolve a call to a definition four files away through a renamed barrel export.
-
Synthesized
heuristicedges that look exactly like real ones. Because dynamic-dispatch bridges are stored as ordinarycallsedges, the entire traversal/trace/explore machinery follows them for free — andprovenancelets the render layer still label them as inferred. This is the single trick that lets the graph answer "how does X reach Y" through callbacks, React re-renders, and vtables that grep fundamentally cannot follow. -
Sufficiency over instruction. The system can't reliably change which tool an agent picks (low-salience channel), so it instead makes the tools the agent already calls return enough — inlined bodies, the last-mile callees, monotonic budgets, "already Read" framing — that the agent has no reason to fall back to reading files. Every feature is measured by whether it stops a Read.
| Concern | Entry point |
|---|---|
| Public API façade | src/index.ts (CodeGraph class) |
| Types (NodeKind/EdgeKind) | src/types.ts |
| Storage schema | src/db/schema.sql; queries src/db/queries.ts |
| Extraction orchestrator | src/extraction/index.ts |
| Core tree-sitter walker | src/extraction/tree-sitter.ts (visitNode :269, createNode :442) |
| Per-language config | src/extraction/languages/*.ts |
| Reference resolution | src/resolution/index.ts (resolveOne :579) |
| Import / name resolution | src/resolution/import-resolver.ts, name-matcher.ts |
| Framework resolvers | src/resolution/frameworks/*.ts |
| Dynamic-dispatch synthesis | src/resolution/callback-synthesizer.ts |
| Graph traversal | src/graph/traversal.ts, graph/queries.ts |
| Context building | src/context/index.ts, context/formatter.ts |
| Search | src/search/query-parser.ts, db/queries.ts (searchNodes :737) |
| MCP tools | src/mcp/tools.ts (trace :1549, explore :1974, budgets :90/:148) |
| Agent-facing guidance | src/mcp/server-instructions.ts |
| Daemon / proxy | src/mcp/index.ts, daemon.ts, proxy.ts |