Skip to content

Instantly share code, notes, and snippets.

@HerringtonDarkholme
Created June 1, 2026 19:06
Show Gist options
  • Select an option

  • Save HerringtonDarkholme/0c91526e805300abfae3e9bd8c4d4ab6 to your computer and use it in GitHub Desktop.

Select an option

Save HerringtonDarkholme/0c91526e805300abfae3e9bd8c4d4ab6 to your computer and use it in GitHub Desktop.
CodeGraph Architecture — How the Code-Understanding Features Are Implemented

CodeGraph Architecture — How the Code-Understanding Features Are Implemented

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/.


The mental model

CodeGraph turns any codebase into a SQLite-backed knowledge graphnodes (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.


The data model — the atoms (src/types.ts, src/db/schema.sql)

Everything reduces to two tables.

  • Node = a symbol. 22 NodeKinds: 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. 12 EdgeKinds: contains, calls, imports, exports, extends, implements, references, type_of, returns, instantiates, overrides, decorates.

Three implementation details drive the whole system:

  1. 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. (file nodes are the exception — plain file:<path>.)

  2. Qualified names (buildQualifiedName, tree-sitter.ts:544) are built by walking the enclosing-node stack and joining non-file ancestor names with :: (e.g. Foo::bar). The file path is deliberately excluded (it lives in filePath) so it doesn't pollute the FTS index.

  3. 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 in edge.metadata.

Storage notes (src/db/schema.sql):

  • FTS5 external-content virtual table nodes_fts over name, 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 narrow source/target indexes 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 to node-sqlite3-wasm (src/db/sqlite-adapter.ts). codegraph status surfaces which backend is live.

Layer 1 — Extraction: source → nodes + deferred references (src/extraction/)

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 contains edges are created (parent→ child structure — every createNode at tree-sitter.ts:488 pushes one). This needs no cross-file knowledge.
  • Everything semantic is deferred as an UnresolvedReference (src/types.ts:274, persisted to the unresolved_refs table). A call like userService.login() is captured as the string "userService.login" (extractCall, tree-sitter.ts:1605) attached to the caller — because the login method 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.


Layer 2 — Resolution: references → real edges (src/resolution/)

ReferenceResolver drains unresolved_refs and turns each into an edge pointing at the right target node. resolveOne (index.ts:579) runs a strategy ladder:

  1. 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 Flask def get() view keeps its route edge.
  2. Fast pre-filter — skip unless the name exists in the graph (hasAnyPossibleMatch), matches a local import, or a framework claimsReference()s it.
  3. 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, tsconfig paths aliases via path-aliases.ts, monorepo/workspace members, Go module paths from go.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. For obj.method it infers receiver types — scanning backward for C++ declarators, reading Spring @Autowired field 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 emit route/component nodes; 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.register in app.module.ts), so there's an idempotent postExtract cross-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): extendsimplements (target is an interface), and callsinstantiates (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 site

The 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 site

Three choices make this work without becoming noise:

  1. Edges are plain kind:'calls' — so BFS/findPath/getCallees traverse them transparently, with no special-casing in the traversal layer. That's literally why trace can cross a callback boundary.
  2. provenance:'heuristic' + metadata.synthesizedBy/registeredAt let 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.
  3. 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.


Layer 4 — Storage, traversal & search (src/graph/, src/db/, src/search/)

Traversal is application-level over per-node prepared statementsnot 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).


Layer 5 — Context building (src/context/)

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).


Layer 6 — The MCP agent surface (src/mcp/)

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 smaller maxCharsPerFile than 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 KB App.tsx and 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 is cat -n line-numbered so the agent can cite file:line without 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.


The thread that ties it together

Three deliberate choices recur at every layer, and they're what make this more than a symbol indexer:

  1. 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.

  2. Synthesized heuristic edges that look exactly like real ones. Because dynamic-dispatch bridges are stored as ordinary calls edges, the entire traversal/trace/explore machinery follows them for free — and provenance lets 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.

  3. 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.


Quick file map

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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment