File: README.md
Contents
- Get Started
- Language Support
- Why CodeGraph?
- Key Features
- Framework-aware Routes
- Mixed iOS / React Native / Expo bridging
- Quick Start
- How It Works
- CLI Reference
- MCP Tools
- Library Usage
- Configuration
- Telemetry
- Verified releases
- Supported Platforms
- Supported Agents
- Supported Languages
- Measured cross-file coverage
- Troubleshooting
- License
Get Started
1. Install the CLI
No Node.js required — one command grabs the right build for your OS:
# macOS / Linux
curl -fsSL https://raw.githubusercontent.com/colbymchenry/codegraph/main/install.sh | sh
# Windows (PowerShell)
irm https://raw.githubusercontent.com/colbymchenry/codegraph/main/install.ps1 | iexAlready have Node? Use npm instead (works on any version)
npm i @colbymchenry/codegraphCodeGraph bundles its own runtime — nothing to compile, no native build, works the same everywhere. The installer puts codegraph on your PATH but doesn't change your current shell — open a new terminal before the next step so the command resolves.
Upgrade any time with codegraph upgrade — it detects how you installed (bundle, npm, or npx) and updates in place. Add --check to see if an update is available, or codegraph upgrade <version> to pin one.
2. Wire up your agent(s)
In a new terminal, run the installer to connect CodeGraph to the agents you use:
codegraph installDetects and auto-configures Claude Code, Cursor, Codex CLI, opencode, Hermes Agent, Gemini CLI, Antigravity IDE, Kiro, and GitHub Copilot (VS Code, Copilot CLI, JetBrains IDEs) — wiring the CodeGraph MCP server into each. This is the step that connects CodeGraph to your agent; installing the CLI in step 1 does not do it on its own. It only wires up your agent — it does not index any code; building each project's graph is the separate codegraph init in step 3. (Shortcut: npx @colbymchenry/codegraph downloads and runs this in one go.)
3. Initialize each project
cd your-project
codegraph initcodegraph init creates the local .codegraph/ directory and builds the full graph in the same step — one command, done.
4. No more syncing!
Auto-sync is enabled by default. CodeGraph watches the project and updates the graph on every file change — while your agent edits code, or you add, modify, or delete files. The index is never stale, and there is nothing to re-run.
Uninstall
Changed your mind? One command removes CodeGraph from every agent it configured and the CLI itself — every install it finds (standalone bundle, npm global package, launcher link), shown to you before anything is deleted:
codegraph uninstallPass --keep-cli to remove only the agent configurations and keep the CLI installed.
Reverses the installer — strips CodeGraph's MCP server config, instructions, and permissions from each configured agent. Your project indexes (.codegraph/) are left untouched; remove those per-project with codegraph uninit. Use --target to remove from specific agents, or --yes to run non-interactively.
Language Support
Every language below gets the same treatment — full structural extraction and cross-file resolution into one graph, no per-language setup:
Per-language details — extensions, frameworks, and what exactly gets extracted — in Supported Languages.
Why CodeGraph?
When an AI agent needs to understand code — to answer a question or make a change — it discovers structure the slow way: grep, glob, and Read, one file at a time, rebuilding call paths and dependencies by hand. That's a pile of tool calls and round-trips before it even starts the real work.
CodeGraph hands the agent the exact code it needs in one call. It's a pre-built knowledge graph of every symbol, call edge, and dependency in your codebase — so instead of crawling files, the agent asks one question and gets back the relevant source, the call paths between those symbols (including dynamic-dispatch hops grep can't follow), and the blast radius of a change. Surgical context, not a file-by-file search — which means fewer tool calls and faster answers on every codebase, large or small.
A note on cost: CodeGraph's win on every codebase is precision — the agent stops crawling files and answers from the graph. On current models that precision is also a large direct saving: the 2026-08 re-measurement, on a harness that blocks the CLI in both arms, put it at 44% lower cost and 62% fewer tokens on average across the seven benchmark repos, because a strong model without the graph burns its budget re-deriving structure. Cost tracks how much discovery a question demands more than raw repo size: 57–78% on questions the file-reading agent needed 28–43 tool calls to answer, near-even where it got there in 7.
A note on context: the numbers above measure throughput — tokens processed, tools called, dollars spent to reach one answer. They don't measure what is still sitting in your context window afterward, and on that axis CodeGraph costs more, not less. Across the same seven repos in multi-turn sessions, CodeGraph's responses leave about 80% more retrieval context resident at the end of a session than a file-reading agent's do — on VS Code, 67k tokens against 18k. The mechanism is the same one that makes it fast: CodeGraph returns one dense, verbatim payload that answers the question and then stays in the window, where a grep-and-read agent churns through many small results that get evicted. Fewer tokens processed and a larger persistent footprint are both real at once. If you run long sessions in a small window, budget for it. Measured per-repo:
docs/benchmarks/residual-context-occupancy.md.
Benchmark Results
Tested across 7 real-world open-source codebases spanning 7 languages, comparing an agent (Claude Code, headless) answering one architecture question with and without CodeGraph, at the median of 4 runs per arm. Re-measured 2026-08-05 on Claude Opus 4.8 against the current build, on a harness that blocks the codegraph CLI in both arms — contamination row: 0 of 28 without-arm runs.
The universal win — every repo, every size: 88% fewer tool calls · 53% faster · 62% fewer tokens · 44% cheaper · file reads cut to zero on all seven repos.
With the index available, the agent answers from one to four codegraph_explore calls and stops. Without it, the agent burns its budget on discovery — up to 43 tool calls and 19 file reads re-deriving what the graph already knew. Every repo was faster with CodeGraph in this measurement — by 35% on the narrowest question, by 3.6× on the widest.
| Codebase | Language | Tool calls | Time | File reads | Tokens | Cost |
|---|---|---|---|---|---|---|
| VS Code | TypeScript · ~11k files | 2 vs 28 | 2.2× faster (58s vs 2m 10s) | 0 vs 12 | 77% fewer | 71% cheaper |
| Excalidraw | TypeScript · ~640 | 2 vs 43 | 3.6× faster (45s vs 2m 42s) | 0 vs 18 | 84% fewer | 78% cheaper |
| Django | Python · ~3k | 3 vs 14 | 35% faster (54s vs 1m 23s) | 0 vs 8.5 | 41% fewer | 13% cheaper¹ |
| Tokio | Rust · ~790 | 3 vs 29 | 2.6× faster (1m 3s vs 2m 43s) | 0 vs 19 | 65% fewer | 64% cheaper |
| OkHttp | Java · ~645 | 1 vs 6 | 43% faster (33s vs 58s) | 0 vs 2 | 54% fewer | 21% cheaper |
| Gin | Go · ~110 | 1 vs 7 | 39% faster (28s vs 46s) | 0 vs 4 | 52% fewer | ~even¹ |
| Alamofire | Swift · ~110 | 4 vs 33 | 2.6× faster (54s vs 2m 22s) | 0 vs 16.5 | 59% fewer | 57% cheaper |
¹ Cost tracks how much discovery the question demanded, which is why it varies far more than the other columns: 57–78% on repos where the file-reading arm needed 28–43 tool calls, but only 13% on Django and even on Gin, where it got there in 14 and 7. The with-arm still answered in 3 and 1 calls with zero file reads. File reads = median files opened — the surgical-context win in one column: the agent never reads a file on any of the seven repos when CodeGraph is present.
Per-repo breakdown — WITH vs WITHOUT (median of 4)
| Codebase | Metric | WITH cg | WITHOUT cg |
|---|---|---|---|
| VS Code | Time / Tools / Tokens / Cost | 58s / 2 / 155k / $0.53 | 2m 10s / 28 / 670k / $1.80 |
| Excalidraw | Time / Tools / Tokens / Cost | 45s / 2 / 156k / $0.54 | 2m 42s / 43 / 991k / $2.43 |
| Django | Time / Tools / Tokens / Cost | 54s / 3 / 183k / $0.55 | 1m 23s / 14 / 309k / $0.63 |
| Tokio | Time / Tools / Tokens / Cost | 1m 3s / 3 / 201k / $0.66 | 2m 43s / 29 / 573k / $1.83 |
| OkHttp | Time / Tools / Tokens / Cost | 33s / 1 / 107k / $0.39 | 58s / 6 / 230k / $0.50 |
| Gin | Time / Tools / Tokens / Cost | 28s / 1 / 87k / $0.31 | 46s / 7 / 180k / $0.31 |
| Alamofire | Time / Tools / Tokens / Cost | 54s / 4 / 209k / $0.54 | 2m 22s / 33 / 505k / $1.27 |
Full benchmark details
Methodology. Each arm is claude -p (Claude Opus 4.8, claude-opus-4-8) run headlessly against the repo with --strict-mcp-config: WITH = CodeGraph's MCP server enabled, WITHOUT = an empty MCP config. Built-in Read/Grep/Bash stay available to both. Same question per repo, 4 runs per arm, median reported. Cost = the run's total_cost_usd; Tokens = total tokens processed, summed per assistant turn (input incl. cache reads + cache creation + output); Time = wall-clock; Tool calls = every tool invocation, including those inside any sub-agents the model spawns. Repos cloned at --depth 1 and indexed by the same CodeGraph build that served them. Re-measured 2026-08-05 on the current build.
The codegraph CLI is blocked in both arms. A sanitized PATH plus a PreToolUse hook denies any Bash invocation of the CLI, in the WITHOUT arm as well as the WITH arm. This matters: without that block the control arm is not a control. On an unblocked harness we measured the WITHOUT agent finding the CLI on PATH and reaching CodeGraph through Bash in 26 of 28 runs — which distorts the comparison in both directions, since a CLI call is not counted as a tool call and its output still enters the window. Earlier published figures were produced without this block. In the run reported above, all 28 WITHOUT runs attempted the CLI and all 28 were blocked — 0 contaminated.
Queries:
| Codebase | Query |
|---|---|
| VS Code | "How does the extension host communicate with the main process?" |
| Excalidraw | "How does Excalidraw render and update canvas elements?" |
| Django | "How does Django's ORM build and execute a query from a QuerySet?" |
| Tokio | "How does tokio schedule and run async tasks on its runtime?" |
| OkHttp | "How does OkHttp process a request through its interceptor chain?" |
| Gin | "How does gin route requests through its middleware chain?" |
| Alamofire | "How does Alamofire build, send, and validate a request?" |
Why CodeGraph wins: with the index available, the agent answers directly — usually one codegraph_explore returns the relevant source — and stops, with zero file reads on every benchmark repo. Without it, the agent spends most of its budget on discovery (find/ls/grep) before reading the right code. CodeGraph only helps when queried directly, so its instructions steer agents to answer directly rather than delegate exploration to file-reading sub-agents — otherwise a sub-agent reads files regardless and CodeGraph becomes overhead.
Built for speed — the Rust kernel
CodeGraph's parsing engine is a native Rust kernel: 20 languages — TypeScript, JavaScript, Java, Python, Go, C, C++, Rust, C#, Ruby, PHP, Swift, Kotlin, Scala, Dart, R, Lua, Luau (Metal and CUDA ride the C++ path) — parse in compiled code with one boundary crossing per file. Every language shipped only after its graphs proved byte-for-byte identical to the reference engine on real repositories, from small libraries up to the Linux kernel; platforms without a prebuilt binary and files with syntax errors fall back per-file automatically, same graph either way.
And it scales itself to the machine it's on. Worker pools, parallel resolution, and analysis caches are sized from what the system actually has — real core counts (container/cgroup-aware, so a VPS that grants 2 cores gets sized for 2, not the host's 64), honestly-measured available RAM on macOS and Linux, and the measured cost of your project's resolution work:
- On a workstation: the full parallel pipeline — native parse workers, a multi-worker resolver pool that engages the moment it pays for itself, memory-gated analysis caches. The Swift compiler repository (27k files of Swift and C++) fresh-indexes in about 100 seconds; a one-file edit re-syncs in ~4.
- On a 2-core / 6GB VPS: the same graph, from a pipeline tuned to finish — the Linux kernel (70k files, 2M symbols, 6.4M relationships) indexes to completion in under 12 minutes where RAM-first designs run out of memory before reaching 1%.
- Every day after day one: saving a file updates the graph in well under a second — the watcher fires 300ms after a lone save and syncs exactly what changed (~0.3s of work on a 4,400-file project, ~0.4s on the 27,000-file Swift compiler repo), never re-scanning the tree. Measured against the fastest competing indexer's re-index-on-change: 2–7× faster on medium and larger repos across a 31-repo, 30-language benchmark — and the gap widens with repo size, because their cost grows with the repository and ours grows with the change.
Key Features
| Native Rust Kernel | Parsing and extraction run in a compiled Rust engine for 20 languages — with graphs verified byte-for-byte identical to the reference engine, and automatic per-file fallback so nothing ever breaks |
| Adapts to Your Machine | Sizes its worker pools and caches from what the system actually has — real core counts (container-aware), honest available RAM, measured per-project cost. A workstation gets the full parallel pipeline; a 2-core VPS gets one tuned to finish reliably |
| Surgical Context | One tool call returns entry points, related symbols, and code snippets — no slow file-by-file exploration |
| Full-Text Search | Find code by name instantly across your entire codebase, powered by FTS5 |
| Impact Analysis | Trace callers, callees, and the full impact radius of any symbol before making changes |
| Always Fresh | File watcher uses native OS events (FSEvents/inotify/ReadDirectoryChangesW) with debounced auto-sync — the graph stays current as you code, zero config |
| 20+ Languages | TypeScript, JavaScript, ArkTS, Python, Go, Rust, Java, C#, VB.NET, PHP, Ruby, C, C++, CUDA, Objective-C, Metal, Swift, Kotlin, Scala, Dart, Lua, Luau, R, Nix, Erlang, CFML, COBOL, Solidity, Terraform/OpenTofu, Svelte, Vue, Astro, Liquid, Pascal/Delphi |
| Framework-aware Routes | Recognizes web-framework routing files and links URL patterns to their handlers across 17 frameworks |
| Mixed iOS / React Native / Expo | Closes cross-language flows that static parsing misses: Swift ↔ ObjC bridging, React Native legacy bridge + TurboModules + Fabric view components, native → JS event emitters, Expo Modules |
| 100% Local | No data leaves your machine. No API keys. No external services. SQLite database only |
How auto-syncing works — and why you don't need to run codegraph sync manually
When your agent (Claude Code, Cursor, Codex, opencode) launches codegraph serve --mcp, three layers keep the index in step with your code — and make sure the agent never gets a silent wrong answer in the brief window between an edit and the next sync:
File watcher with debounced auto-sync. A native FSEvents / inotify / ReadDirectoryChangesW watcher captures every source-file create / modify / delete and triggers a re-index after a debounce window (default
2000ms, tunable viaCODEGRAPH_WATCH_DEBOUNCE_MS, clamped to[100ms, 60s]). Bursts of edits collapse into a single sync.Per-file staleness banner. During the brief debounce window, MCP tool responses that would reference a still-pending file prepend a
⚠️banner naming it and telling the agent toReadit directly. Pending files NOT referenced by the response surface as a small footer instead. Either way, the agent gets an explicit signal — validated with Claude Code, where the agent literally says "Reading the file directly for the live content" before opening it.Connect-time catch-up. When the MCP server (re)connects, codegraph runs a fast
(size, mtime)+ content-hash reconciliation against the working tree before answering the first query — so edits made while no MCP server was running (agit pullfrom the terminal, edits from another editor, a previous agent session that exited) get absorbed on the next session's first tool call.
agent writes src/Widget.ts
→ watcher fires (<100ms)
→ debounce (default 2s)
→ sync; Widget.ts is in the index
→ next agent query sees itVerify any time with codegraph status (CLI). If anything is pending, you'll see a ### Pending sync: section naming the files and their edit age.
The handful of cases where manual codegraph sync makes sense: the watcher is disabled (sandboxed environments, or CODEGRAPH_NO_DAEMON=1), or you're scripting against the index outside an agent session and want a pre-flight sync at the start of your script.
→ Full deep-dive in Guides → Indexing a Project.
Framework-aware Routes
CodeGraph detects web-framework routing files and emits route nodes linked by references edges to their handler classes or functions. Querying callers of a view/controller now surfaces the URL pattern that binds it.
| Framework | Shapes recognized |
|---|---|
| Django | path(), re_path(), url(), include() in urls.py (CBV .as_view(), dotted paths) |
| Flask | @app.route('/path', methods=[...]), blueprint routes |
| FastAPI | @app.get(...), @router.post(...), all standard methods |
| Express | app.get(...), router.post(...) with middleware chains |
| NestJS | @Controller + @Get/@Post/... (with RouterModule prefixes, setGlobalPrefix and URI versioning), GraphQL @Resolver + @Query/@Mutation, @MessagePattern/@EventPattern, @SubscribeMessage |
| Laravel | Route::get(), Route::resource(), Controller@action, tuple syntax |
| Drupal | *.routing.yml routes (_controller, _form, entity handlers); hook_* implementations in .module/.theme/.install/.inc |
| Rails | get '/x', to: 'users#index', hash-rocket => syntax |
| Spring | @GetMapping, @PostMapping, @RequestMapping on methods |
| Play | GET/POST/… verb routes in conf/routes → Controller.method actions (Scala + Java) |
| Gin / chi / gorilla / mux | r.GET(...), router.HandleFunc(...) |
| Axum / actix / Rocket | .route("/x", get(handler)) |
| ASP.NET | [HttpGet("/x")] attributes on action methods |
| Vapor | app.get("x", use: handler) |
| Astro | src/pages/ file-based routes (.astro pages + .ts endpoints, [param]/[...rest] syntax) |
Routers — routes *and* the navigation between them
These frameworks additionally emit navigates edges: the function that sends a user somewhere is linked to the screen it names, so "where does tapping this go" is one hop in the graph rather than a search. Each reads a literal destination — a computed one, or a path no route serves, is left unresolved rather than guessed — and a link written in markup is marked as inferred.
| Router | Routes from | Navigation from |
|---|---|---|
| Expo Router | Every screen file under app/ (app/item/[id].tsx → /item/[id], groups stripped), bound to its default-export component; +api files are endpoints (GET /hello) bound to their handlers |
router.push / replace / navigate, template hrefs, { pathname } objects, and a helper's returned href |
| Next.js | App Router app/**/page.tsx and Pages Router pages ((group) stripped, [slug] → :slug); app/api/**/route.ts exports and pages/api/* are endpoints, not screens |
router.push / replace / prefetch, redirect() / permanentRedirect() in a server action or page, NextResponse.redirect(new URL(…)) in middleware, <Link href> and internal <a href> |
| React Router | <Route path component/element> (v5 and v6) and createBrowserRouter([{ path, element }]) |
history.push / replace, useNavigate's navigate, a loader's redirect, <Link to> / <NavLink to> / <Navigate to> / v5's <Redirect to> / react-router-bootstrap's <LinkContainer to>, and a styled(Link) wrapper |
| TanStack Router | createFileRoute('/posts/$postId') (file-based) and createRoute({ path, getParentRoute }) composed up its parent chain (code-based); _pathless segments, (group) folders, __root and <Outlet/> layouts are not addresses |
navigate({ to }), a thrown redirect({ to }), <Link to> / <Navigate to> — where to is the route PATTERN and the values ride beside it in params |
| Vue Router / Nuxt | createRouter({ routes: [...] }) / new Router(...) and the route tables it's given (export const constantRoutes = [...], per-module route files), with the view each entry names — a lazy () => import(…) bound to its file — and children joined onto their parent's path, the parent being the layout around them; plus Nuxt pages/ file-based routes, server/api/ endpoints and route middleware |
router.push / replace, this.$router.push, Nuxt's navigateTo, <router-link> / <RouterLink> / <NuxtLink> — by route name (push({ name: 'profile' })) as well as by path |
| SvelteKit | src/routes/**/+page.svelte ([slug] → :slug, [[opt]] → :opt?, [id=matcher] → :id, (group) folders stripped), joined to the +page.server.js beside it so a loader's guard belongs to its page |
goto('/x'), redirect(status, '/x') from a load or form action, and the plain <a href> that is a link in a SvelteKit app |
| Angular | Routes arrays (RouterModule.forRoot / forChild, provideRouter, a routes file's default export) with component or a lazy loadComponent; children and lazy loadChildren (an NgModule's through its routing module) joined into full paths; paths written as route constants or $localize strings; a route with children is a layout around the screens inside it |
router.navigate([...]), navigateByUrl, a guard's createUrlTree / parseUrl — a command array, a route constant, or a component property holding one — routerLink / [routerLink] in the component's template, and redirectTo. Each template's child components (<app-foo>) are linked to the component that renders them |
In a repository holding several apps, each app's routes are matched only against navigation written inside that app.
Mixed iOS / React Native / Expo bridging
Real iOS and React Native codebases live across multiple languages — a Swift caller invokes an Objective-C selector that's been auto-bridged, a JS file calls into a native module via the React Native bridge, a JSX component delegates to a native view manager. Static tree-sitter extraction stops at each language boundary. CodeGraph bridges them so codegraph_explore connects the flow end-to-end across the gap — call paths and blast radius cross the boundary instead of stopping at it.
| Boundary | JS / Swift side | Native side | How |
|---|---|---|---|
| Swift → ObjC | Swift obj.foo(bar:) |
ObjC selector -fooWithBar: |
@objc auto-bridging rules (including init/property/protocol forms) + Cocoa preposition prefixes (With/For/By/In/On/At/…) |
| ObjC → Swift | ObjC [obj fooWithBar:] |
Swift @objc func foo(bar:) |
Reverse-bridge name candidates; verifies @objc exposure from source |
| React Native legacy bridge | JS NativeModules.X.fn(...) |
ObjC RCT_EXPORT_METHOD / RCT_REMAP_METHOD · Java/Kotlin @ReactMethod |
Parses macro/annotation declarations to build a JS-name → native-method map |
| React Native TurboModules | JS import M from './NativeM'; M.fn(...) |
Native impl matching the Codegen spec | Treats the Native<X>.ts spec interface as ground truth |
| RN native → JS events | JS new NativeEventEmitter(...).addListener('e', cb) |
ObjC [self sendEventWithName:@"e" body:...] · Swift sendEvent(withName: "e", ...) · Java/Kotlin .emit("e", ...) |
Synthesized cross-language event channel keyed by literal event name |
| Expo Modules | JS requireNativeModule('X').fn(...), directly or through a binding (export default requireNativeModule<T>('X')) |
Swift / Kotlin Module { Name("X"); AsyncFunction("fn") { ... } } |
Parses the Expo DSL literals into method nodes; a call on a binding resolves to module X's fn on both platforms, else to the method on the binding's declared type |
| Fabric view components | JSX <MyView prop={v}/> |
TS Codegen spec + native impl class | Spec → component node; convention-based name+suffix lookup (View/ComponentView/Manager/ViewManager) bridges to native |
| Legacy Paper view managers | JSX <MyView prop={v}/>, through a requireNativeComponent('X') module |
ObjC RCT_EXPORT_VIEW_PROPERTY · Java/Kotlin @ReactProp |
Same as Fabric — requireNativeComponent('X') is a JS component node, and Paper-era declarations also produce component + property nodes |
Validated on real codebases (small + medium + large for each bridge):
| Bridge | Small | Medium | Large |
|---|---|---|---|
| Swift ↔ ObjC | Charts | realm-swift | Wikipedia-iOS |
| RN legacy bridge | AsyncStorage | react-native-svg | react-native-firebase |
| RN native → JS events | RNGeolocation | — | react-native-firebase |
| Expo Modules | expo-haptics | expo-camera | expo SDK sweep (7 packages) |
| Fabric / Paper views | react-native-segmented-control | react-native-screens | react-native-skia |
Every bridge hop says how it got into the graph. A hop matched by a bridge resolver carries metadata.resolvedBy: 'framework' and metadata.framework naming the resolver (swift-objc-bridge, react-native-bridge, expo-modules-js, fabric-view). A synthesized channel is tagged provenance:'heuristic' with metadata.synthesizedBy (rn-event-channel, fabric-native-impl).
Quick Start
1. Run the Installer
npx @colbymchenry/codegraphThe installer will:
- Ask which agent(s) to configure — auto-detects installed ones from: Claude Code, Cursor, Codex CLI, opencode, Hermes Agent, Gemini CLI, Antigravity IDE, Kiro, GitHub Copilot (VS Code, Copilot CLI, JetBrains IDEs)
- Prompt to install
codegraphon your PATH (so agents can launch the MCP server) - Ask whether configs apply to all your projects or just this one
- Write each chosen agent's MCP server config, plus a small marker-fenced CodeGraph section in the agent's instructions file (
CLAUDE.md/AGENTS.md/GEMINI.md) — that's how subagents and non-MCP agents learn thecodegraph explorecommand, since the MCP server's own guidance only reaches the main agent. Removed cleanly bycodegraph uninstall. - Set up auto-allow permissions when Claude Code is one of the targets
The installer wires up your agents only — it does not index your code. After it finishes, build each project's graph yourself with codegraph init (step 3). One global codegraph install covers every project; you run codegraph init once per project.
Non-interactive (scripting / CI):
codegraph install --yes # auto-detect agents, install global
codegraph install --yes --init # same, then build the current project's index (one-shot bootstrap)
codegraph install --target=cursor,claude --yes # explicit target list
codegraph install --target=auto --location=local # detected agents, project-local
codegraph install --target=copilot-vscode,copilot-cli,copilot-jetbrains --yes # GitHub Copilot everywhere
codegraph install --print-config codex # print snippet, no file writes
codegraph install --print-config copilot-vscode # same, for Copilot in VS Code| Flag | Values | Default |
|---|---|---|
--target |
auto, all, none, or csv (claude,cursor,...) |
prompt |
--location |
global, local |
prompt |
--yes |
(boolean) | prompt every step |
--init |
(boolean) run codegraph init in the current directory after wiring agents |
— |
--no-permissions |
(boolean) skip Claude auto-allow list | permissions on |
--print-config |
dump snippet for one agent and exit | — |
2. Restart Your Agent
Restart your agent (Claude Code / Cursor / Codex CLI / opencode / Hermes Agent / Gemini CLI / Antigravity IDE / Kiro / VS Code, the Copilot CLI, or your JetBrains IDE for GitHub Copilot) for the MCP server to load.
3. Initialize Projects
cd your-project
codegraph initBuilds the per-project knowledge graph index, which then auto-syncs on every file change. A single global codegraph install works in every project you open — no need to re-run the installer per project. Add --yes to skip every prompt (scripts / CI / container bootstraps).
That's it — your agent will use CodeGraph tools automatically when a .codegraph/ directory exists.
Manual Setup (Alternative)
Install globally:
npm install @colbymchenry/codegraphAdd to ~/.claude.json:
{
"mcpServers": {
"codegraph": {
"type": "stdio",
"command": "codegraph",
"args": ["serve", "--mcp"],
"alwaysLoad": true
}
}
}alwaysLoad keeps codegraph_explore loaded from the first prompt. Claude Code otherwise defers every MCP tool behind a tool-search step, so a fresh session sees only the tool's name until the model searches for it.
Add to ~/.claude/settings.json (optional, for auto-allow):
{
"permissions": {
"allow": [
"mcp__codegraph__*"
]
}
}One wildcard auto-approves every CodeGraph tool — codegraph_explore is the only one listed by default, but if you re-enable others via CODEGRAPH_MCP_TOOLS they're already permitted, no prompt.
Agent Tool Guidance
CodeGraph's MCP server delivers its usage guidance to your agent automatically, in the MCP initialize response. In short, it tells the agent to:
- Answer structural questions directly with CodeGraph — it is the pre-built index, so a grep/read loop just repeats work it already did. Treat the returned source as already read.
- Reach for
codegraph_explorefor almost anything — "how does X work", a flow/"how does X reach Y", or surveying an area. One call returns the relevant symbols' verbatim source grouped by file, the call paths between them (dynamic-dispatch hops included), and a blast-radius summary. Name a file or symbol in the query to read its current line-numbered source. - Trust the results — don't re-verify with grep, and check the staleness banner after edits.
- Works per project: query any project that has a
.codegraph/index by passingprojectPath— so a monorepo where only some services are indexed, or a second repo, works in one session. A path with no index returns clean guidance to use built-in tools; indexing stays your decision.
The exact text is src/mcp/server-instructions.ts — the single source of truth for the main agent. Because subagents and non-MCP harnesses never see the MCP guidance, the installer also writes a short marker-fenced section into the agent's instructions file pointing at the codegraph explore CLI equivalent.
How It Works
┌───────────────────────────────────────────────────────────────────┐
│ Claude Code │
│ │
│ "How does a request reach the database?" │
│ calls CodeGraph tools directly — no Explore sub-agent │
│ │ │
└─────────────────────────────────┬─────────────────────────────────┘
│
▼
┌───────────────────────────────────────────────────────────────────┐
│ CodeGraph MCP Server │
│ │
│ explore · one call → verbatim source + call flow + blast radius │
│ │ │
│ ▼ │
│ SQLite knowledge graph │
│ symbols · edges · files · FTS5 full-text search │
└───────────────────────────────────────────────────────────────────┘Extraction — a native Rust kernel parses source with tree-sitter grammars compiled into it, extracting nodes (functions, classes, methods) and edges (calls, imports, extends, implements) for 20 languages; remaining languages and per-file fallbacks use the same extraction logic on the portable engine, producing identical graphs.
Storage — Everything goes into a local SQLite database (
.codegraph/codegraph.db) with FTS5 full-text search.Resolution — After extraction, references are resolved: function calls → definitions, imports → source files, class inheritance, and framework-specific patterns.
Auto-Sync — The MCP server watches your project using native OS file events. Changes are debounced (2-second quiet window), filtered to source files only, and incrementally synced. The graph stays fresh as you code — no configuration needed.
CLI Reference
/* Detailed source-code truncated for AI context efficiency. */`codegraph affected`
Traces import dependencies transitively to find which test files are affected by changed source files.
codegraph affected src/utils.ts src/api.ts # Pass files as arguments
git diff --name-only | codegraph affected --stdin # Pipe from git diff
codegraph affected src/auth.ts --filter "e2e/*" # Custom test file pattern| Option | Description | Default |
|---|---|---|
--stdin |
Read file list from stdin | false |
-d, --depth <n> |
Max dependency traversal depth | 5 |
-f, --filter <glob> |
Custom glob to identify test files | auto-detect |
-j, --json |
Output as JSON | false |
-q, --quiet |
Output file paths only | false |
CI/hook example:
#!/usr/bin/env bash
AFFECTED=$(git diff --name-only HEAD | codegraph affected --stdin --quiet)
if [ -n "$AFFECTED" ]; then
npx vitest run $AFFECTED
fiMCP Tools
When running as an MCP server, CodeGraph exposes a single tool — codegraph_explore. Measured agent behavior showed that one strong tool steers agents better than a menu of narrower ones — fewer mis-picks, and it saves context every session:
| Tool | Purpose |
|---|---|
codegraph_explore |
Answer almost any question in one call — "how does X work", a flow ("how does X reach Y"), or surveying an area — returning the relevant symbols' verbatim source grouped by file, plus the call paths between them and a blast-radius summary. Surfaces dynamic-dispatch hops (callbacks, React re-render, interface→impl) grep can't follow. Name a file or symbol in the query to read its current line-numbered source, the same shape the Read tool gives you. |
The other tools (codegraph_node, codegraph_search, codegraph_callers, codegraph_callees, codegraph_impact, codegraph_files, codegraph_status) stay fully functional but unlisted by default — everything they return already arrives inline on codegraph_explore (its blast-radius section, the relationship map, a symbol's body as its callee list). Re-enable any of them for the MCP surface with the CODEGRAPH_MCP_TOOLS environment variable (e.g. CODEGRAPH_MCP_TOOLS=explore,node,search,callers), or use their CLI equivalents (codegraph node / query / callers / callees / impact / files / status).
Even when the server's own root has no .codegraph/ index, the tools stay available: pass projectPath to query any indexed project — a sub-service in a monorepo, or a second repo — in the same session. A path that has no index returns clean guidance to use built-in tools instead, so nothing fails loudly, and indexing stays your decision.
Library Usage
CodeGraph can be embedded directly. The npm package re-exports its programmatic
API, so both import and require resolve the CodeGraph class in your own
process — handy for embedding it in an app (e.g. an Electron main process).
import CodeGraph from '@colbymchenry/codegraph';
// CommonJS works too:
// const { CodeGraph } = require('@colbymchenry/codegraph');
const cg = await CodeGraph.init('/path/to/project');
// Or: const cg = await CodeGraph.open('/path/to/project');
await cg.indexAll({
onProgress: (p) => console.log(`${p.phase}: ${p.current}/${p.total}`)
});
const results = cg.searchNodes('UserService');
const callers = cg.getCallers(results[0].node.id);
const context = await cg.buildContext('fix login bug', { maxNodes: 20, includeCode: true, format: 'markdown' });
const impact = cg.getImpactRadius(results[0].node.id, 2);
cg.watch(); // auto-sync on file changes
cg.unwatch(); // stop watching
cg.close();Lower-level building blocks are exported from the same entry point for callers
that drive the graph directly: DatabaseConnection, QueryBuilder,getDatabasePath, initGrammars / loadGrammarsForLanguages, and FileLock.
Embedding requirements
- Install from npm (
npm i @colbymchenry/codegraph) so the matching
per-platform package — which carries the compiled library and its
dependencies — is fetched alongside the shim. - The API runs on your runtime, so it needs Node 22.5+ for the built-in
node:sqlite(Electron qualifies when its bundled Node is 22.5+). The CLI and
MCP server are unaffected — they run on the self-contained bundled runtime. - TypeScript types ship with the package. As with any Node-targeting library,
keep@types/nodeavailable andskipLibCheck: true(the common default).
Configuration
Next to none — CodeGraph is zero-config by default, with nothing to write or
keep in sync to get started. Language support is automatic from the file
extension; there's nothing to wire up per language. The one optional file is for
mapping custom file extensions.
What it skips out of the box:
- Dependency, build, and cache directories —
node_modules,vendor,dist,build,target,.venv,Pods,.next, and the like across every
supported stack — so the graph is your code, not
third-party noise. This holds even with no.gitignore. - Anything in your
.gitignore— honored in git repos via git, and in
non-git projects by reading.gitignoredirectly (root and nested). - Files larger than 1 MB — generated bundles, minified JS, vendored blobs.
To keep something else out, add it to .gitignore. To pull a default-excluded
directory back in (say you really do want a vendored dependency indexed),
add a negation — !vendor/. The defaults apply uniformly, so committing a
dependency or build directory doesn't force it into the graph; the .gitignore
negation is the explicit opt-in.
.gitignore can't drop a directory you've committed, though. For a vendored
theme or SDK that's checked into the repo (e.g. a Metronic theme understatic/), list it under exclude in codegraph.json — gitignore-style
patterns, matched against repo-root-relative paths, honored on index, sync, and
watch:
{
"exclude": ["static/", "**/vendor/**"]
}Conversely, when real source is gitignored on purpose — a project under a second
VCS (SVN, Perforce) that .gitignores its own source so it stays out of Git —
force it back in with include (the opposite of exclude; includeIgnored
only revives embedded git repos, not plain source):
{
"include": ["Tools/", "Local/typescript/"]
}CodeGraph discovers those files off disk, overriding .gitignore, on index,
sync, and watch. An explicit exclude still wins, and built-in skips
(node_modules, dist, .git) are never re-included.
Sometimes a directory shouldn't leave the index — you still want to find things
in it — it just shouldn't outrank your real code. A scripts/ oroptional-skills/ tree whose helpers use generic names (usage, status,run) can win on an exact name match and crowd out the product code that
actually answers the query. Name those trees under deprioritize:
{
"deprioritize": ["optional-skills/", "scripts/"]
}This is the ranking counterpart to exclude: those paths stay indexed and
findable — searching for them directly still works — they just stop winning
against first-party code. It applies to query / search and to explore's
ranking. It is not a filter: unlike the built-in example/, sample/,fixture/, benchmark/ and demo/ handling — which also drops those files
from some result sets outright — deprioritize only ever changes rank. Reach
for exclude when you want something gone.
Custom file extensions
If your project uses a non-standard extension for a supported
language — say .dota_lua for Lua, or .tpl for PHP —
those files are skipped by default, because the extension isn't one CodeGraph
recognizes. Map them with an optional codegraph.json at your project root:
{
"extensions": {
".dota_lua": "lua",
".tpl": "php"
}
}Each value is a supported language id. The mappings merge on top of the built-in
defaults and win on conflict, so you can also re-point a built-in (e.g.".h": "cpp"). Commit the file to share the mapping with your team. A typo'd
language or a malformed file is warned about and skipped — it never breaks
indexing — and a project with no codegraph.json behaves exactly as before.
Re-index (codegraph index) after adding or changing mappings.
Telemetry
CodeGraph collects anonymous usage statistics — which tools and commands get
used, which languages get indexed — to guide where language and agent support
work goes. Never any code, paths, file or symbol names, queries, or IP
addresses; usage is aggregated locally into daily totals before anything is
sent, and the ingest endpoint is public code in this repo
that enforces the documented field list. The installer asks up front; turn it
off any time:
codegraph telemetry off # or: CODEGRAPH_TELEMETRY=0, or DO_NOT_TRACK=1TELEMETRY.md lists every field, with the off-switches and the
full data-handling story.
Verified releases
Every artifact is built and published by the public
Release workflow — never from a laptop — and
carries cryptographic proof of it:
npm packages are published via trusted publishing
(OIDC — no long-lived npm tokens exist that could be stolen) with
provenance attestations
linking every version to the exact commit and workflow run that built it.
Verify what's installed:bashnpm audit signaturesGitHub Release bundles (and
SHA256SUMS) carry signed
build attestations
(SLSA v1.0 Build Level 2). Verify any downloaded bundle:bashgh attestation verify codegraph-darwin-arm64.tar.gz -R colbymchenry/codegraph
Releases published before July 2026 predate this pipeline and don't carry
attestations.
Supported Platforms
Every release ships a self-contained build (bundled Node runtime — nothing to
compile) for all three desktop OSes, on both Intel/AMD (x64) and ARM (arm64):
| Platform | Architectures | Install |
|---|---|---|
| Windows | x64, arm64 | PowerShell installer or npm |
| macOS | x64, arm64 | shell installer or npm |
| Linux | x64, arm64 | shell installer or npm |
See Get Started for the one-line install commands.
Supported Agents
The interactive installer auto-detects and configures each of these — wiring up
the MCP server (which delivers its own usage guidance, so no instructions file
is written):
- Claude Code
- Cursor
- Codex CLI
- opencode — MCP entry is OpenCode 2's
mcp.servers.codegraphwithcodemode: false(keepscodegraph_exploreon the native tool list;codegraph installmigrates the oldermcp.codegraphshape) - Hermes Agent
- Gemini CLI
- Antigravity IDE
- Kiro
- GitHub Copilot — Copilot Chat in VS Code (
copilot-vscode), the Copilot CLI (copilot-cli), and the Copilot plugin in JetBrains IDEs (copilot-jetbrains)
Supported Languages
| Language | Extension | Status |
|---|---|---|
| TypeScript | .ts, .tsx |
Full support |
| JavaScript | .js, .jsx, .mjs |
Full support |
| ArkTS (HarmonyOS) | .ets |
Full support (everything TypeScript has, plus @Component/@ComponentV2 structs with their ArkUI decorators (@State/@Prop/@Link/@Local/@Builder/…), build() view trees — parent→child component edges, chained-attribute links to @Extend/@Styles functions, .onClick(this.handler) event bindings — dynamic-dispatch bridges for state→build() re-renders, @ohos.events.emitter emit→subscriber pairs (static event keys only), and router.pushUrl literal urls → the target page struct; ohpm workspace modules resolve bare import { X } from "data" through oh-package.json5 file: dependencies, honoring each module's main entry) |
| Python | .py |
Full support |
| Go | .go |
Full support |
| Rust | .rs |
Full support |
| Java | .java |
Full support |
| C# | .cs |
Full support |
| PHP | .php |
Full support |
| Ruby | .rb |
Full support |
| C | .c, .h |
Full support |
| C++ | .cpp, .hpp, .cc |
Full support |
| Objective-C | .m, .mm, .h |
Partial support (classes, protocols, methods, @property, #import, message sends; .mm ObjC++ may parse incompletely) |
| Metal | .metal |
Full support (vertex/fragment/kernel functions, structs, type aliases, call edges — MSL parses as C++, with [[attribute]] annotations handled) |
| CUDA | .cu, .cuh |
Full support (kernels and device/host functions, structs, classes, host→kernel call edges through <<<grid, block>>> launch syntax — templated launches, function-pointer launches (auto kernel = &fn<...>), dim3{...} configs, and macro-defined kernels included; __global__/__device__/__launch_bounds__ specifiers handled; CUDA in plain .h/.hpp headers recognized by content) |
| Swift | .swift |
Full support |
| Kotlin | .kt, .kts |
Full support |
| Scala | .scala, .sc |
Full support (classes, traits, methods, type aliases, Scala 3 enums) |
| Dart | .dart |
Full support |
| Svelte | .svelte |
Full support (script extraction, Svelte 5 runes, SvelteKit routes) |
| Vue | .vue |
Full support (script + script-setup extraction, Nuxt page/API/middleware routes) |
| Astro | .astro |
Full support (frontmatter + script extraction, template component/call references, src/pages/ routes) |
| Liquid | .liquid |
Full support |
| Pascal / Delphi | .pas, .dpr, .dpk, .lpr |
Full support (classes, records, interfaces, enums, DFM/FMX form files) |
| Lua | .lua |
Full support (functions, methods with receivers, local variables, require imports, call edges) |
| R | .R .r |
Full support (functions in every assignment form, S4/R5/R6 classes with methods, library/require imports, source() file references, call edges) |
| Luau | .luau |
Full support (everything in Lua, plus type/export type aliases, typed signatures, and Roblox instance-path require) |
| CFML | .cfc, .cfm, .cfs |
Full support (tag-based <cfcomponent>/<cffunction> and bare-script component { ... } styles, extends/implements, embedded <cfscript> delegation, call edges) |
| COBOL | .cbl, .cob, .cpy |
Full support (programs, sections/paragraphs with PERFORM/GO TO call edges, CALL 'literal' cross-program calls, COPY copybook imports — including standalone .cpy files — DATA DIVISION records/fields/88-levels, EXEC CICS LINK/XCTL and EXEC SQL INCLUDE targets; fixed and free format) |
| Visual Basic .NET | .vb |
Full support (classes, Modules, interfaces, structures, enums, properties, events, Declare P/Invoke, Handles/WithEvents, Inherits/Implements edges, call edges through VB's call/index paren ambiguity, As New instantiation, interpolated strings, LINQ, Unicode identifiers) |
| Erlang | .erl, .hrl, .escript, .app.src, .app |
Full support (functions with multi-clause/multi-arity grouping, -spec signatures, records with fields, -type/-opaque aliases, -define macros, -include/-include_lib/-import edges, local and mod:fn remote call edges, fun name/arity references, spawn/apply/proc_lib/timer/rpc MFA-argument call edges, gen_server:call/cast(?MODULE) → own handle_call/handle_cast links, -behaviour links, -export-based visibility) |
| Solidity | .sol |
Full support (contracts, libraries, interfaces, structs, enums, modifiers, events, errors, state variables, import/using directives, emit/revert calls) |
| Terraform / OpenTofu | .tf, .tfvars, .tofu |
Full support (resources, data sources, modules, variables, outputs, providers incl. aliases, locals; var./local./module./resource references with Terraform's per-directory scoping enforced; module calls bridged across the boundary — inputs to the child module's variables, module.M.out to the child's output, source to the module's files; cloudposse/atmos remote-state cross-component wiring when the component is statically named; provider = aws.east selections resolved up the module tree; moved/import/removed/check block references; .tfvars assignments linked to the variables they set) |
| Nix | .nix |
Full support (functions with simple/destructured/curried params, let/attrset bindings, inherit, import ./path file edges — ./dir resolving through default.nix — plus NixOS module imports = [ ./x.nix ] lists and callPackage ./pkg.nix file edges; call edges; module-system option wiring — a config write like launchd.user.agents.x = { ... } links to the module declaring options.launchd.user.agents, so option flows trace across modules) |
Measured cross-file coverage
Impact and blast-radius queries are only as good as the dependency graph behind them, so coverage is measured rather than asserted. Fair coverage = the share of symbol-bearing source files that have at least one resolved cross-file dependent — something that imports, calls, references, or (through a framework convention) routes to them — on a real-world benchmark repo per language. The residual is always a genuine static-analysis frontier (runtime dynamic dispatch, reflection / DI containers, framework-convention entry points, vendored third-party code), never hidden by gaming the denominator.
| Language | Benchmark repo | Coverage |
|---|---|---|
| TypeScript / JavaScript | this repo | 95.8% |
| Python | psf/requests | 100% |
| Go | gin-gonic/gin | 96.6% |
| Rust | BurntSushi/ripgrep | 86.7% |
| Java | google/gson | 93.3% |
| C# | jbogard/MediatR | 85.2% |
| PHP | guzzle/guzzle | 100% |
| Ruby | sidekiq/sidekiq | 100% |
| C | redis/redis | 92.2% |
| C++ | google/leveldb | 94.8% |
| Objective-C | SDWebImage | 91.6% |
| Swift | Alamofire | 95.3% |
| Kotlin | square/okhttp | 96.2% |
| Scala | gatling/gatling | 91.2% |
| Dart | flutter/packages | 92.4% |
| Svelte / SvelteKit | sveltejs/realworld | 100% |
| Vue / Nuxt | nuxt/movies | 93.5% |
| Astro | xingwangzhe/stalux | 93.0% |
| Lua | nvim-telescope/telescope.nvim | 84.2% |
| Luau | dphfox/Fusion | 92.2% |
| Liquid | Shopify/dawn | 73.8% |
| Pascal / Delphi | PascalCoin | 77.4% |
Framework routing is validated the same way, on a canonical app per framework: Express 100%, FastAPI 98%, Flask 100%, NestJS 96.8%, Gin 96.5%, Axum 100%, Rocket 93.8%, Vapor 100%, Laravel 92%, Rails 89.6%, React Router 100% — and the convention/reflection-heavy ones at their honest static-analysis ceiling: ASP.NET 83.9%, Spring 83.3%, Drupal 78.9%, Play 76.3%, Django 74.1%. SvelteKit, Vue/Nuxt, and Astro use file-based routing, so their page/endpoint coverage is the Svelte/SvelteKit (100%), Vue/Nuxt (93.5%), and Astro (93.0% — every src/pages/ file maps to a route node on the two validation repos) figures in the table above.
Troubleshooting
"CodeGraph not initialized" — Run codegraph init in your project directory first.
Indexing is slow — Check that node_modules and other large directories are excluded. Use --quiet to reduce output overhead.
MCP hits database is locked — current builds shouldn't: CodeGraph bundles its own Node runtime and uses Node's built-in node:sqlite in WAL mode, where concurrent reads never block on a writer. If you still see it:
- You're on an old (pre-0.9) install. Reinstall to get the bundled runtime —
curl -fsSL https://raw.githubusercontent.com/colbymchenry/codegraph/main/install.sh | sh(macOS/Linux),irm https://raw.githubusercontent.com/colbymchenry/codegraph/main/install.ps1 | iex(Windows), ornpm i @colbymchenry/codegraph@latest. codegraph statusshowsJournal:other thanwal— WAL couldn't be enabled on this filesystem (common on network shares and WSL2/mnt), so reads can block on writes. Move the project (with its.codegraph/folder) onto a local disk.
MCP server not connecting — Your agent starts the server itself, so you don't launch it by hand. Make sure the project is initialized and indexed (codegraph status) and that the path in your MCP config is correct. If it still won't connect, re-run codegraph install to rewrite the config.
Two codegraph serve --mcp on one project fight over the index / auto-sync stops — CodeGraph allows one live MCP writer per project (the shared background daemon, or a single direct-mode process). Extra clients should proxy to that daemon. If you set CODEGRAPH_NO_DAEMON=1, run only one serve --mcp for that project; a second instance exits with a clear writer-lock error (see writer.pid under .codegraph/). Prefer leaving the daemon enabled so multiple MCP hosts share one watcher.
MCP tool calls fail with Transport closed while codegraph status/sync are healthy — almost always WSL2 with the project on a Windows drive (a /mnt/c or /mnt/d path), where the local socket CodeGraph uses to share one background server across sessions is unreliable. CodeGraph now falls back to serving the session in-process instead of dropping the connection, but if you still hit it, set CODEGRAPH_NO_DAEMON=1 in your MCP server's environment to skip the shared server entirely (each session runs in its own process). Moving the project onto the Linux-native filesystem (e.g. under ~/ instead of /mnt/) restores the shared server.
Missing symbols — The MCP server auto-syncs on save (wait a couple seconds). Run codegraph sync manually if needed. Check that the file's language is supported and isn't inside a .gitignored or default-excluded directory (e.g. node_modules, dist).
Sharing one checkout between Windows and WSL — Don't point both at the same .codegraph/: the background-server lock and the SQLite index are tied to the OS that wrote them, and SQLite locking across the WSL2/Windows filesystem boundary is unreliable (WSL reports it as a disk I/O error). For a project on a Windows drive (a /mnt/c/… path), WSL keeps its own index automatically: an index first built from WSL goes in .codegraph-wsl/, leaving .codegraph/ to Windows. An index already in .codegraph/ stays where it is, so if Windows built that one, give WSL its own by setting CODEGRAPH_DIR=.codegraph-wsl in WSL and running codegraph init there. CODEGRAPH_DIR always picks the name when set, on either side. CodeGraph skips any sibling .codegraph-* directory when indexing and watching, so the two never trip over each other.
Very large repositories (hundreds of thousands of files), or a large .codegraph/codegraph.db-wal file — The -wal file is SQLite's write-ahead log: writes waiting to be folded into codegraph.db. While a big index is being built, CodeGraph lets it grow in proportion to the index (soft threshold = the larger of 256 MB and a quarter of the index size, up to 2 GB) before folding it back, because folding too often is what made large indexes slow on ordinary disks. At rest it is trimmed to 64 MB, and a leftover from a killed session is folded and trimmed the next time the project opens — the index itself has no size limit. Two environment variables tune this: CODEGRAPH_WAL_VALVE_MB (the soft threshold during indexing) and CODEGRAPH_WAL_HEAL_MB (the resting size and the trim threshold). CODEGRAPH_WAL_VALVE_DEBUG=1 prints every decision to stderr.
License
MIT
File: docs/plans/2026-04-24-framework-resolver-extract.md
Framework Resolver `extract()` Wiring Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Wire up the dead FrameworkResolver.extractNodes hook so every framework resolver can contribute route nodes AND route-to-handler edges to the graph, and update all 13 existing framework resolvers to use it correctly.
Architecture: Replace the unused extractNodes?(filePath, content): Node[] hook with a single extract?(filePath, content): { nodes, references } method. Call it once per file during the extraction phase (after tree-sitter parses the file) for any framework whose language matches the file. Extracted nodes go into the DB alongside tree-sitter nodes; extracted references flow into the existing unresolved-references pipeline so the existing name-matcher / import-resolver / framework resolve() machinery creates the final edges. Net effect: path('/users', UserListView.as_view()) produces a route node linked by a references edge to the UserListView class node — and the equivalent holds for Flask, FastAPI, Express, Rails, Laravel, Spring, Gin, Axum, ASP.NET, Vapor, React Router, and SvelteKit.
Tech Stack: TypeScript, vitest, tree-sitter (existing), better-sqlite3 (existing). No new dependencies.
Background
Today, every FrameworkResolver ships with an extractNodes?(filePath, content) method (express, laravel, python/django, python/flask, python/fastapi, ruby/rails, java/spring, go, rust, csharp, swift × 3, react, svelte). None of them are ever called. Empirical proof: grep across src/ finds exactly one reference to extractNodes — the interface definition at src/resolution/types.ts:99. As a result the graph has zero route kind nodes in practice, and the link between a URL entry in a routing file and its view/controller/handler doesn't exist.
Separately, the Django extractor's regex captures the view name in group 2 but the destructure in src/resolution/frameworks/python.ts discards it, so even if the hook were alive it wouldn't link the route to the view. Similar shape bugs exist across most frameworks.
This plan fixes both problems in one coherent change.
File Structure
src/resolution/types.ts— addextract?()toFrameworkResolver; removeextractNodes?().src/resolution/frameworks/index.ts— keepdetectFrameworkssignature; addgetApplicableFrameworks(language)helper.src/resolution/frameworks/python.ts— rewrite Django/Flask/FastAPI extractors.src/resolution/frameworks/express.ts/laravel.ts/ruby.ts/java.ts/go.ts/rust.ts/csharp.ts/swift.ts/react.ts/svelte.ts— migrate to new interface.src/extraction/index.ts— plug framework extraction intoExtractionOrchestrator.indexAllafter per-file tree-sitter parse.src/extraction/parse-worker.ts— pass detected-framework names into the worker so the worker can invoke framework extractors itself (needed because main-threadextractFromSourceand worker-thread parse path both have to cover this).__tests__/frameworks.test.ts— NEW. Onedescribeper framework, checking that representative fixtures produce the expected{nodes, references}.__tests__/frameworks-integration.test.ts— NEW. End-to-end test: index a tiny Django project fixture, assert aroute -> classedge with kindreferencesexists fromurlpatternsentry toUserListView.
Rationale for splitting the two test files: the unit tests are deterministic string-in / array-out and run in milliseconds; the integration test boots a CodeGraph DB and is slower but gives the strongest behavioral guarantee.
Scope Note
This plan does NOT move Django extraction from regex to AST. The regex approach is fine for the shapes this PR targets (path(...), url(...), re_path(...), include(...), DRF router.register(...), CBV .as_view(), dotted module paths). A follow-up PR can swap the regex for AST walking using tree-sitter's existing Python parser. That's a larger change and doesn't block this one.
Task 1: Update the `FrameworkResolver` interface
Files:
Modify:
src/resolution/types.ts:88-100Step 1: Write the failing test
Create __tests__/frameworks.test.ts:
import { describe, it, expect } from 'vitest';
import type { FrameworkResolver, UnresolvedRef } from '../src/resolution/types';
import type { Node } from '../src/types';
describe('FrameworkResolver.extract interface', () => {
it('extract() returns { nodes, references }', () => {
const resolver: FrameworkResolver = {
name: 'fake',
detect: () => true,
resolve: () => null,
languages: ['python'],
extract: (_filePath: string, _content: string) => ({
nodes: [] as Node[],
references: [] as UnresolvedRef[],
}),
};
const result = resolver.extract!('foo.py', '');
expect(result).toEqual({ nodes: [], references: [] });
});
});- Step 2: Run test to verify it fails
Run: npx vitest run __tests__/frameworks.test.ts
Expected: FAIL — extract is not a property of FrameworkResolver; languages is not a property of FrameworkResolver.
- Step 3: Update the interface
Replace src/resolution/types.ts:88-100 with:
/**
* Result of framework-specific file extraction.
*/
export interface FrameworkExtractionResult {
/** Framework-specific nodes (e.g. routes) */
nodes: Node[];
/** Framework-specific unresolved references (e.g. route -> handler) */
references: UnresolvedRef[];
}
/**
* Framework-specific resolver
*/
export interface FrameworkResolver {
/** Framework name */
name: string;
/** Languages this framework applies to. If omitted, applies to all languages. */
languages?: Language[];
/** Detect if project uses this framework (project-level, called once at startup) */
detect(context: ResolutionContext): boolean;
/** Resolve a reference using framework-specific patterns */
resolve(ref: UnresolvedRef, context: ResolutionContext): ResolvedRef | null;
/**
* Extract framework-specific nodes and references from a file.
*
* Returns route nodes, middleware nodes, etc., plus unresolved references
* that link those nodes to handlers (view classes, controller methods,
* included modules). Unresolved references flow into the normal resolution
* pipeline; the framework's own `resolve()` is one of the strategies tried.
*/
extract?(filePath: string, content: string): FrameworkExtractionResult;
}- Step 4: Run test to verify it passes
Run: npx vitest run __tests__/frameworks.test.ts
Expected: PASS.
- Step 5: Run typecheck to catch downstream breakage
Run: npx tsc --noEmit
Expected: FAIL — every src/resolution/frameworks/*.ts will error on extractNodes not existing on FrameworkResolver. That's expected; subsequent tasks fix each one.
- Step 6: Commit
git add src/resolution/types.ts __tests__/frameworks.test.ts
git commit -m "feat(resolution): replace extractNodes with extract() returning nodes and references"Task 2: Add `getApplicableFrameworks` helper and keep detection correct
Files:
Modify:
src/resolution/frameworks/index.tsStep 1: Write the failing test
Append to __tests__/frameworks.test.ts:
import { getApplicableFrameworks } from '../src/resolution/frameworks';
import type { FrameworkResolver } from '../src/resolution/types';
describe('getApplicableFrameworks', () => {
const pyFw: FrameworkResolver = { name: 'py', languages: ['python'], detect: () => true, resolve: () => null };
const jsFw: FrameworkResolver = { name: 'js', languages: ['javascript', 'typescript'], detect: () => true, resolve: () => null };
const anyFw: FrameworkResolver = { name: 'any', detect: () => true, resolve: () => null };
it('filters by language', () => {
const result = getApplicableFrameworks([pyFw, jsFw, anyFw], 'python');
expect(result.map(r => r.name)).toEqual(['py', 'any']);
});
it('returns anyFw-only when language has no matches', () => {
const result = getApplicableFrameworks([pyFw, jsFw, anyFw], 'rust');
expect(result.map(r => r.name)).toEqual(['any']);
});
});- Step 2: Run test to verify it fails
Run: npx vitest run __tests__/frameworks.test.ts
Expected: FAIL — getApplicableFrameworks is not exported.
- Step 3: Add helper to
src/resolution/frameworks/index.ts
Add after the existing detectFrameworks function:
import type { Language } from '../../types';
/**
* Filter a list of detected frameworks down to ones that apply to a given language.
* Frameworks without an explicit `languages` list are treated as universal.
*/
export function getApplicableFrameworks(
detected: FrameworkResolver[],
language: Language
): FrameworkResolver[] {
return detected.filter(
(fw) => !fw.languages || fw.languages.includes(language)
);
}- Step 4: Run test to verify it passes
Run: npx vitest run __tests__/frameworks.test.ts
Expected: PASS.
- Step 5: Commit
git add src/resolution/frameworks/index.ts __tests__/frameworks.test.ts
git commit -m "feat(resolution): add getApplicableFrameworks helper for per-language dispatch"Task 3: Port Django resolver to new `extract()` with proper route→view references
Files:
Modify:
src/resolution/frameworks/python.ts(djangoResolver section, ~line 1-100)Step 1: Write the failing tests
Append to __tests__/frameworks.test.ts:
/* Detailed source-code truncated for AI context efficiency. */- Step 2: Run tests to verify they fail
Run: npx vitest run __tests__/frameworks.test.ts -t djangoResolver
Expected: FAIL — djangoResolver.extract is undefined.
- Step 3: Rewrite djangoResolver
Replace the djangoResolver object in src/resolution/frameworks/python.ts (approximately lines 7-100) with:
/* Detailed source-code truncated for AI context efficiency. */Also ensure the top of the file imports UnresolvedRef and Node:
import type { FrameworkResolver, UnresolvedRef } from '../types';
import type { Node } from '../../types';- Step 4: Run tests to verify they pass
Run: npx vitest run __tests__/frameworks.test.ts -t djangoResolver
Expected: PASS (6 tests).
- Step 5: Commit
git add src/resolution/frameworks/python.ts __tests__/frameworks.test.ts
git commit -m "feat(django): emit route nodes and route->view references in extract()"Task 4: Port Flask and FastAPI resolvers
Files:
Modify:
src/resolution/frameworks/python.ts(flaskResolver and fastapiResolver sections)Step 1: Write the failing tests
Append to __tests__/frameworks.test.ts:
import { flaskResolver, fastapiResolver } from '../src/resolution/frameworks/python';
describe('flaskResolver.extract', () => {
it('extracts route and reference from @app.route', () => {
const src = `
@app.route('/users')
def list_users():
return []
`;
const { nodes, references } = flaskResolver.extract!('app.py', src);
expect(nodes).toHaveLength(1);
expect(nodes[0].kind).toBe('route');
expect(nodes[0].name).toBe('GET /users');
expect(references[0].referenceName).toBe('list_users');
});
it('extracts blueprint routes', () => {
const src = `
@users_bp.route('/', methods=['POST'])
def create_user(id):
pass
`;
const { nodes, references } = flaskResolver.extract!('routes.py', src);
expect(nodes[0].name).toBe('POST /');
expect(references[0].referenceName).toBe('create_user');
});
});
describe('fastapiResolver.extract', () => {
it('extracts route and reference from @app.get', () => {
const src = `
@app.get('/users')
async def list_users():
return []
`;
const { nodes, references } = fastapiResolver.extract!('main.py', src);
expect(nodes[0].name).toBe('GET /users');
expect(references[0].referenceName).toBe('list_users');
});
it('extracts route from router.post', () => {
const src = `
@router.post('/items')
def create_item(item: Item):
pass
`;
const { nodes, references } = fastapiResolver.extract!('items.py', src);
expect(nodes[0].name).toBe('POST /items');
expect(references[0].referenceName).toBe('create_item');
});
});- Step 2: Run tests to verify they fail
Run: npx vitest run __tests__/frameworks.test.ts -t "flaskResolver|fastapiResolver"
Expected: FAIL — both resolvers' extract are undefined.
- Step 3: Rewrite flaskResolver and fastapiResolver
Replace flaskResolver in src/resolution/frameworks/python.ts with:
/* Detailed source-code truncated for AI context efficiency. */And add this shared helper at the bottom of python.ts:
/* Detailed source-code truncated for AI context efficiency. */- Step 4: Run tests to verify they pass
Run: npx vitest run __tests__/frameworks.test.ts -t "flaskResolver|fastapiResolver"
Expected: PASS (4 tests).
- Step 5: Commit
git add src/resolution/frameworks/python.ts __tests__/frameworks.test.ts
git commit -m "feat(flask,fastapi): emit route nodes and route->handler references"Task 5: Port Express resolver
Files:
Modify:
src/resolution/frameworks/express.ts(extractNodes section, ~line 83-117)Step 1: Write failing tests
Append to __tests__/frameworks.test.ts:
import { expressResolver } from '../src/resolution/frameworks/express';
describe('expressResolver.extract', () => {
it('extracts route with inline handler reference', () => {
const src = `app.get('/users', listUsers);\n`;
const { nodes, references } = expressResolver.extract!('routes.ts', src);
expect(nodes).toHaveLength(1);
expect(nodes[0].name).toBe('GET /users');
expect(references[0].referenceName).toBe('listUsers');
});
it('extracts route with router.post', () => {
const src = `router.post('/items', auth, createItem);\n`;
const { nodes, references } = expressResolver.extract!('items.ts', src);
expect(nodes[0].name).toBe('POST /items');
// Multiple handlers: prefer the LAST one (convention: middleware comes first, handler last)
expect(references[0].referenceName).toBe('createItem');
});
it('extracts route with controller method reference', () => {
const src = `app.get('/x', userController.list);\n`;
const { nodes, references } = expressResolver.extract!('routes.ts', src);
expect(references[0].referenceName).toBe('list');
});
});- Step 2: Run tests to verify they fail
Run: npx vitest run __tests__/frameworks.test.ts -t expressResolver
Expected: FAIL.
- Step 3: Rewrite expressResolver.extract
Replace the existing extractNodes method on expressResolver (in src/resolution/frameworks/express.ts) with:
/* Detailed source-code truncated for AI context efficiency. */And add near the top of the file:
import type { FrameworkResolver, UnresolvedRef } from '../types';
import type { Node } from '../../types';
function extractTailIdent(expr: string): string | null {
const cleaned = expr.replace(/\s+/g, '').replace(/\(\)$/, '');
const m = cleaned.match(/(?:\.|^)([A-Za-z_][A-Za-z0-9_]*)$/);
return m ? m[1] : null;
}Remove the old extractNodes method.
- Step 4: Run tests to verify they pass
Run: npx vitest run __tests__/frameworks.test.ts -t expressResolver
Expected: PASS.
- Step 5: Commit
git add src/resolution/frameworks/express.ts __tests__/frameworks.test.ts
git commit -m "feat(express): emit route nodes and route->handler references"Task 6: Port Laravel, Rails, Spring, Gin (Go), Axum (Rust), ASP.NET (C#), Swift resolvers
Files:
- Modify:
src/resolution/frameworks/laravel.ts/ruby.ts/java.ts/go.ts/rust.ts/csharp.ts/swift.ts
Each framework follows the same pattern as Tasks 3–5 above:
- Add
languages: [...]field. - Replace
extractNodes(filePath, content)withextract(filePath, content): { nodes, references }. - Inside
extract(), for each matched route regex: create a route node (reuse existing shape) AND emit aUnresolvedReffor the handler/controller withfromNodeId = routeNode.id. - For each framework, add one unit test to
__tests__/frameworks.test.tsthat verifies at least one route shape produces both a node and a handler reference.
Per-framework specifics:
Laravel (
laravel.ts):Route::get('/x', [Ctrl::class, 'method'])→ handler ref name =method;Route::get('/x', 'Ctrl@method')→ handler ref name =method;Route::resource('users', UserController::class)→ handler ref name =UserController.languages: ['php'].Rails (
ruby.ts):get '/x', to: 'users#index'→ handler ref name =index(scope byusers);resources :users→ one node per CRUD action, each referencing the corresponding method name onUsersController.languages: ['ruby'].Spring (
java.ts):@GetMapping("/x")on method → handler is the following method name (scan forward past the decorator).languages: ['java'].Gin / chi / gorilla (
go.ts):r.GET("/x", handler)→ handler ref = last ident in the last arg.languages: ['go'].Axum / actix (
rust.ts):.route("/x", get(handler))→ handler ref = ident insideget(...).languages: ['rust'].ASP.NET (
csharp.ts):[HttpGet("/x")] public ActionResult Method()→ handler ref = method name on same class.languages: ['csharp'].Swift / Vapor (
swift.ts):app.get("/x", use: handler)→ handler ref = ident afteruse:.languages: ['swift'].
Each of these gets its own commit in the form:
git add src/resolution/frameworks/<framework>.ts __tests__/frameworks.test.ts
git commit -m "feat(<framework>): emit route nodes and route->handler references"Important: keep each framework's commit independent so any one of them can be reverted if it causes regressions.
Task 6a: Laravel
- Step 1: Write test for
Route::get('/users', [UserController::class, 'index'])→{nodes[0].name='GET /users', references[0].referenceName='index'}. - Step 2: Run test, see fail.
- Step 3: Implement
extract()following the Express pattern. Regex:/Route::(get|post|put|patch|delete|options|any)\s*\(\s*['"]([^'"]+)['"]\s*,\s*([^)]+)\)/g. Extract handler from third group viaresolveLaravelHandler(): strip[/]/::class, take second element of comma-split array orCtrl@method. - Step 4: Run test, see pass.
- Step 5: Commit.
Task 6b: Rails
- Step 1: Write test for
get '/users', to: 'users#index'→{references[0].referenceName='index'}. - Step 2: Run test, see fail.
- Step 3: Implement
extract(). Regex:/\b(get|post|put|patch|delete|match)\s+['"]([^'"]+)['"]\s*,\s*to:\s*['"]([^'"]+)['"]/g→controller#methodsplit on#gives handler =method. - Step 4: Run test, see pass.
- Step 5: Commit.
Task 6c: Spring
- Step 1: Write test for
@GetMapping("/x")\npublic String list() {...}→{references[0].referenceName='list'}. - Step 2: Run test, see fail.
- Step 3: Implement
extract()using the sharedextractDecoratorRouteshelper (move it to a newsrc/resolution/frameworks/shared.tsif cleaner). Find the nextpublicorprivatemethod declaration's name after each mapping annotation. - Step 4: Run test, see pass.
- Step 5: Commit.
Task 6d: Go
- Step 1: Write test for
r.GET("/x", handler)androuter.Handle("/x", handler)→{references[0].referenceName='handler'}. - Step 2: Run test, see fail.
- Step 3: Implement
extract(). Regex:/\b(?:router|r|mux|app)\.(GET|POST|PUT|PATCH|DELETE|Handle|HandleFunc)\s*\(\s*["]([^"]+)["]\s*,\s*([^)]+)\)/g. Handler = last ident in third group. - Step 4: Run test, see pass.
- Step 5: Commit.
Task 6e: Rust
- Step 1: Write test for
.route("/x", get(list_users))→{references[0].referenceName='list_users'}. - Step 2: Run test, see fail.
- Step 3: Implement
extract(). Regex:/\.route\s*\(\s*"([^"]+)"\s*,\s*(get|post|put|patch|delete)\s*\(\s*(\w+)/g→ handler = group 3. - Step 4: Run test, see pass.
- Step 5: Commit.
Task 6f: C# (ASP.NET)
- Step 1: Write test for
[HttpGet("/x")]\npublic IActionResult List()→{references[0].referenceName='List'}. - Step 2: Run test, see fail.
- Step 3: Implement
extract(). Find attributes, then scan forward to firstpublic|private|protectedmethod declaration and take its name. - Step 4: Run test, see pass.
- Step 5: Commit.
Task 6g: Swift / Vapor
- Step 1: Write test for
app.get("/users", use: list)→{references[0].referenceName='list'}. - Step 2: Run test, see fail.
- Step 3: Implement
extract(). Regex:/\b(app|router|routes)\.(get|post|put|patch|delete)\s*\(\s*"([^"]+)"\s*,\s*use:\s*([A-Za-z_][A-Za-z0-9_.]*)/g→ handler = group 4's last segment. - Step 4: Run test, see pass.
- Step 5: Commit.
Task 6h: React & Svelte
These are UI frameworks where routes map to components, not handlers in the server sense. Keep the existing behavior but migrate the interface:
- Step 1: Migrate
reactResolver(src/resolution/frameworks/react.ts) — addlanguages: ['javascript', 'typescript'], renameextractNodestoextract, make it return{ nodes, references: [] }(the existing logic only emits nodes, no handler references needed yet — a follow-up can add<Route element={}/>→Pagereferences). - Step 2: Migrate
svelteResolver(src/resolution/frameworks/svelte.ts) — same pattern;languages: ['svelte']. - Step 3: Add a smoke test for each that verifies
extract()returns the same node shape it used to. - Step 4: Run tests, see pass.
- Step 5: Commit.
Task 7: Wire framework extraction into `ExtractionOrchestrator`
Files:
- Modify:
src/extraction/index.ts(the per-file extraction result merging path) - Modify:
src/extraction/parse-worker.ts(pass detected frameworks to worker if extraction runs there)
This is the core wiring change. It runs after each file is parsed by tree-sitter.
- Step 1: Write an integration test
Create __tests__/frameworks-integration.test.ts:
/* Detailed source-code truncated for AI context efficiency. */- Step 2: Run test to verify it fails
Run: npx vitest run __tests__/frameworks-integration.test.ts
Expected: FAIL — no route nodes get created (framework extract isn't wired in yet).
- Step 3: Add the wiring
In src/extraction/index.ts, locate the extractFromSource function (around line 600; the function that runs tree-sitter on a single file and returns ExtractionResult). Add framework extraction as a post-tree-sitter augmentation.
Find where ExtractionResult is built at the end of extractFromSource (around line 1000-1015). Just before return result, add:
// Framework-specific extraction (routes, etc.)
if (detectedFrameworks && detectedFrameworks.length > 0) {
const applicable = getApplicableFrameworks(detectedFrameworks, language);
for (const fw of applicable) {
if (!fw.extract) continue;
try {
const fwResult = fw.extract(filePath, content);
result.nodes.push(...fwResult.nodes);
result.unresolvedReferences.push(...fwResult.references);
} catch (err) {
result.errors.push({
message: `Framework extractor '${fw.name}' failed: ${err instanceof Error ? err.message : String(err)}`,
filePath,
severity: 'warning',
});
}
}
}Also add detectedFrameworks?: FrameworkResolver[] as a parameter to extractFromSource.
In ExtractionOrchestrator.indexAll (around line 412), before kicking off the parse workers, detect frameworks once:
// Detect frameworks once per indexing run (project-level signal)
const resolutionContext = buildResolutionContext(this.rootDir, this.queries);
const detectedFrameworks = detectFrameworks(resolutionContext);Pass detectedFrameworks into the parse worker batch config (or, if the parse worker doesn't invoke extractFromSource directly, into the main-thread merge step that invokes framework extract on the raw file content). If the parse worker already has access to file content, pass the framework NAMES and re-resolve to resolver objects inside the worker from getAllFrameworkResolvers().filter(f => detectedNames.includes(f.name)) — objects with functions can't cross worker_threads postMessage boundaries.
- Step 4: Run test to verify it passes
Run: npx vitest run __tests__/frameworks-integration.test.ts
Expected: PASS.
- Step 5: Run the full test suite to check for regressions
Run: npx vitest run
Expected: All existing tests still pass.
- Step 6: Commit
git add src/extraction/index.ts src/extraction/parse-worker.ts __tests__/frameworks-integration.test.ts
git commit -m "feat(extraction): run framework extractors after tree-sitter parse"Task 8: Remove dead regex code + update README
Files:
Modify:
src/resolution/frameworks/*.ts— confirm no danglingextractNodesremainsModify:
README.md— add a section on framework route extractionStep 1: grep for any lingering references
Run: grep -rn "extractNodes" src/ __tests__/
Expected: zero matches. If any remain, delete or rename them.
- Step 2: Run the full build and test
Run: npm run build && npm test
Expected: Build succeeds; all tests pass.
- Step 3: Add a README section
Append to README.md after the features list:
### Framework-aware Routes
CodeGraph recognizes web framework routing files and links URL patterns to their handlers:
- **Django**: `urlpatterns` entries in `urls.py` — `path()`, `re_path()`, `url()`, `include()`
- **Flask / FastAPI**: `@app.route` / `@app.get` / `@router.post` decorators
- **Express**: `app.get(...)`, `router.post(...)`
- **Laravel**: `Route::get()`, `Route::resource()`
- **Rails**: `resources :users`, `get 'x', to: 'y#z'`
- **Spring**: `@GetMapping`, `@RequestMapping`
- **Gin / chi / gorilla**: `r.GET(...)`
- **Axum / actix**: `.route("/x", get(handler))`
- **ASP.NET**: `[HttpGet]` + action method
- **Vapor**: `app.get("x", use: handler)`
Query `codegraph_callers(YourView)` and the route pattern will appear as an incoming edge.- Step 4: Commit
git add README.md
git commit -m "docs: document framework route extraction"Task 9: Open the PR
- Step 1: Push branch to fork
git push -u origin feat/framework-extract-wiring- Step 2: Create PR
gh pr create \
--repo colbymchenry/codegraph \
--base main \
--head timomeara:feat/framework-extract-wiring \
--title "feat: wire up framework route extraction" \
--body "$(cat <<'EOF'
## Problem
`FrameworkResolver.extractNodes` is declared in the type but never called anywhere in `src/`. As a result, the graph has zero `route` nodes for any framework, and the URL-to-handler link (e.g. Django `urls.py` entry -> view class) doesn't exist. This makes `codegraph_callers(MyView)` silently miss its most important caller.
## Fix
- Replaces the dead `extractNodes?(filePath, content): Node[]` hook with `extract?(filePath, content): { nodes, references }`.
- Calls `extract()` inside the extraction pipeline for every framework whose declared `languages` include the current file's language.
- Updates all 13 existing framework resolvers (Django, Flask, FastAPI, Express, Laravel, Rails, Spring, Gin, Axum, ASP.NET, Vapor, React Router, SvelteKit) to emit both route nodes AND handler references. The references flow through the existing resolution pipeline (name matching, import resolution, framework-specific `resolve()`) to produce `route -> handler` edges.
## Tests
- Unit tests per framework in `__tests__/frameworks.test.ts`.
- End-to-end Django test in `__tests__/frameworks-integration.test.ts` that verifies a real `urls.py -> views.py` edge.
## Stats
| Category | Lines |
|----------|------:|
| Production code | ~X |
| Tests | ~Y |
| Docs | ~Z |
EOF
)"- Step 3: Link PR in task tracker (if one exists).
Self-Review Checklist
- Spec coverage: each framework in the original codebase has a migration task. Django has the richest test coverage because it was the motivating case.
- No placeholders: every task shows actual code. The "same pattern as Task X" phrasing in Task 6 is backed by full implementations in Tasks 3-5 as referents.
- Type consistency:
FrameworkExtractionResultis defined once in Task 1 and used by every resolver'sextractsignature. - Realistic stats placeholders (X/Y/Z) are filled in at PR time, not plan time.
Known gaps (intentionally out of scope)
- AST-based extraction. Regex is good enough for the common shapes. Swap to tree-sitter AST in a follow-up.
- DRF router expansion.
router.register(r'users', UserViewSet)produces a single route node pointing at the viewset. Expanding to 6 CRUD action nodes can be a follow-up. - React Router handler edges.
<Route element={}/>currently only produces a route node. Follow-up can addroute -> Pagereferences. - Spring Controller-class scoping. Method-scoped mappings work; class-level
@RequestMappingbase path composition is a follow-up.
File: docs/design/adaptive-explore-sizing.md
Design + status: adaptive `codegraph_explore` sizing (sibling skeletonization)
Status: Implemented & validated, default-on, on branchfeat/adaptive-explore-sizing (initial commit d6d059f; refined 2026-05-29
after a real-agent A/B exposed a read-back regression — see
"Refinement" below). Escape hatch: CODEGRAPH_ADAPTIVE_EXPLORE=0.
Motivation: make codegraph_explore size its output to the answer rather
than always filling the budget cap — so a "sibling-heavy" flow (many
interchangeable implementations of one interface) stops costing more than
plain grep/read, without starving "diffuse" flows that genuinely need broad
source.
Refinement (2026-05-29) — the read-back regression. The first cut gated
only on off-spine + polymorphic-sibling. A real-agent A/B (not the
deterministic probe) showed that this skeletonized two files the agent then
Read back, defeating the point: OkHttp'sRealCall(it implements the
9-implLockablemixin, so it tripped the sibling signal even though it's
the orchestrator) and Django'scompiler.py(it definesSQLCompilerand
co-locates its subclasses). Two conditions fixed it — a file skeletonizes only
if it is not spared, where spared = the agent NAMED a callable in it
(getResponseWithInterceptorChain,SQLCompiler.execute_sql→ keep it full)
UNLESS the file DEFINES a ≥3-impl supertype (a base+subclasses "family"
file is huge and Read-anyway, so skeletonizing it frees explore budget for
the sibling files the agent would otherwise Read). Result: OkHttp 3%
costlier → ~10% cheaper (RealCall full, 0 read-backs); Django 10% costlier
→ ~10% cheaper (compiler.py skeleton frees ~6.5 KB of the 28 KB budget; half
the runs answer with 0 reads). The supertype signal was initially used as a
spare — that was backwards and regressed Django to 9% costlier by starving
its budget; it is now an override of the named-callable spare. The
single-condition history below is kept for context.
Further refinement (2026-05-29) — per-symbol focused view + named-cluster
survival. Whole-file skeleton/spare was still too coarse on a real Django
A/B: the agent Read backcompiler.py(collapsed → itsexecute_sql/as_sql
bodies elided) andquery.py(a non-sibling god-file whose_fetch_allcluster
got trimmed). Four changes took both repos from ~9–10% to ~14–17% cheaper
with median 0 reads:
- Uniqueness-aware spare — only a (near-)UNIQUE named callable spares a
file.as_sqlhas 110 defs across every Compiler/Expression subclass;
naming it must not keep every backend variant full (it was flooding Django's
budget).getResponseWithInterceptorChain(1 def) still spares RealCall.- Per-symbol focused view — a collapsed family file shows the full body
of on-spine / unique-named / canonical-base-supertype methods and only
signatures for the rest. SoSQLCompiler.execute_sql/as_sqlsurvive
while the 80 other symbols + redundant subclasses collapse → no Read-back.- Test-file exclusion on all tiers — a test file (
custom_lookups/tests.py)
was eating 2.3 KB of Django's 28 KB budget; tests rarely answer an
architecture question. (Previously only the <500-file tiers excluded them.)- Named-cluster survival in non-sibling files — inject agent-named method
defs into a file's clusters even when the gather missed them, rank them at
importance 9, and cap cluster selection atmin(per-file, remaining-total)
so high-importance named clusters survive instead of being source-order
trimmed (Django's_fetch_all, L2237, the last of four big files emitted).
Controls held: OkHttp 14% cheaper / 0 RealCall read-backs; Excalidraw 31%
cheaper / 0 reads (god-file clustering unaffected — its big file is emitted
first, so the budget cap never binds it). OkHttp's interceptors stay a pure
signature skeleton (no named callable in them, don't define a supertype).
Refinement (2026-09-28) — exact targets. The per-symbol view above still
lost the one method the agent asked for by its qualified name. On a fresh django
indexSQLCompiler.as_sql pre_sql_setup get_selectreturnedget_selectandpre_sql_setupin full andSQLCompiler.as_sql— step 1 of the rendered Flow,
226 lines — as its signature line, on main and on every branch since. Bodies
were chosen in SOURCE order within a tier, so the unnamed bridgeget_qualify_sqlandget_select(both aboveas_sqlin the file) took the
cap. The agents' follow-ups (SQLCompiler.as_sql full body compiler.py:776,compiler.py lines 900-1003 as_sql tail) pinned the file but dropped the line
numbers, returningSQLInsertCompiler.as_sqland friends instead; every run
Read compiler.py. The fix:
- Exact targets — a qualified name that resolves to ≤3 callables
(SQLCompiler.as_sql: 1 of 110), or the callable enclosing a line anchor
(compiler.py:776). A bareas_sqlis NOT exact, so the family rules above
still keep the 110 overrides as signatures.- Tiers in the focused view: exact → spine step the agent named → unnamed
spine bridge → uniquely-named off-spine → family co-named; the spine in CALL
order within its tier.- Window, never signature, for tiers 0–1. A body too big for what is left
is cut to its head plus the lines where it calls the question's other
symbols, and each hole names the explore query that returns it
(codegraph_explore \compiler.py:871-1001``).- Line anchors (
query-paths.ts):file:line,file:a-b,#La-Lb, and
proselines a-b/L a-L bnext to a path. A range renders as exactly that
span (cluster path, ranked first).- Priced in rendered chars. The view counted raw source while it rendered
line numbers and ~20 signature lines; once windows fill the cap the
undercount became a 19% overshoot that zeroed four excalidraw files below it.Deterministic replay (54 queries × 8 repos): 43 byte-identical;
as_sql
complete in all 5 distinct django queries that single it out (was 1, 0, 175,
0 and 226 of 226 lines), thelines 900-1003span 104/104 (was 1); named-body
lines 8,706 → 9,443. The trade: where the file's budget cannot hold everything, a large named
step now wins over several small off-spine bodies (pb-compiler:as_sqlwhole,execute_sql/results_iterto signatures).Agent A/B (
ab-new-vs-baseline.sh, django, README question pair, Sonnet /--effort high, RUNS=3, baselinee63fe2ec): Read 0 in 3/3 new runs vs a
Read of compiler.py in 2/3 baseline runs — each right after the agent's ownSQLCompiler.as_sql … compiler.py:776explore, the exact follow-up the report
described. Tool calls 3 → 4 median, retrieval residual 19.5K vs 22.9K tokens,
median cost $0.195 vs $0.229 and 30s vs 35s (n=3, the last two within noise).Rebased onto #2062 (protected members paid before incidental ones across
clusters), which leaves this gap open —as_sql1/226 on that main. The two
compose: #2062's protected/incidental rule is the cluster path's base, and
exact targets sit on top of it (ranked first, every member priced in rendered
chars, held back for lower exact clusters). Re-measured against that main, 59
queries: 43 byte-identical, named-body lines 9,804 → 10,731, every target span
above complete.
TL;DR
codegraph_explore returned full source for every relevant file up to its
char budget. On a question whose answer spans many same-shaped classes — e.g.
"how does OkHttp process a request through its interceptor chain?", which touches
~14 class … : Interceptor implementations — that meant ~28 KB of mostly
redundant full bodies. Because those bodies ride in the context window for
the rest of the session, the WITH-CodeGraph arm cost more than the WITHOUT arm
(which answers the well-named interceptor question in ~10 cheap greps). OkHttp
was the benchmark's cost outlier (−3% — i.e. costlier than native search).
Fix: when a file is both (a) off the synthesized flow spine and (b) a
polymorphic sibling, render it as a skeleton (class + member signatures,
bodies elided) instead of full source — keeping the on-spine exemplar and the
mechanism in full.
- OkHttp: the interceptor-chain flow skeletonizes the 5 redundant
: Interceptorimpls while keepingRealInterceptorChain(the dispatch
mechanism) andRealCall(the orchestrator the agent named) full → ~10%
cheaper than native, 0 RealCall read-backs (see Refinement for the corrected
numbers; the original28.5k → 16.6k/ "reads 1 vs 3" figures came from a
deterministic probe query, not the agent's real query). - Django: the QuerySet→SQL flow skeletonizes
compiler.py(a
base+subclasses family file), freeing budget → ~10% cheaper. (The earlier
claim that Django was "byte-identical / 0 skeletons" was an artifact of the
probe query; the agent's real query DOES surface the SQLCompiler family.) - Excalidraw / Tokio / VS Code / Gin: explore output is byte-identical
with the flag on/off (0 skeletons) — their flows have no off-spine
≥3-implementer sibling group. The corrected gate only adds a spare
condition, so it skeletonizes a strict subset of the original gate → these
repos provably stay at 0 skeletons (verified by probe).
The problem in one picture
handleExplore gathers relevant files, sorts by relevance, and fills up tomaxOutputChars (the "whole-small-file rule" dumps any relevant file ≤220 lines
in full). The budget is a target, not a ceiling:
OkHttp explore (shipped): RealCall (full) + RealInterceptorChain (full)
+ CallServerInterceptor (full, 8.7k)
+ Bridge/Connect/Cache/… (full, ~4-5k each) ← all ~same shape
= ~28k, most of it redundant interceptor bodiesThe agent only needs the mechanism (RealInterceptorChain.proceed iterating
the chain) + the contract every interceptor implements + maybe one concrete
example. The other five full bodies are padding — but only because they're
interchangeable. On a diffuse question (Excalidraw's render pipeline:mutateElement → … → renderStaticScene), the off-spine files are distinct
steps, and their bodies do real work — eliding them just makes the agent
reconstruct them from signatures (more reasoning, net costlier; see "Dead ends").
So the whole game is: tell "interchangeable sibling" apart from "distinct
step," cheaply.
The gate (refined)
A file is skeletonized iff all hold (and CODEGRAPH_ADAPTIVE_EXPLORE != 0):
A spine exists.
buildFlowFromNamedSymbolsreturns its path node set
(pathNodeIds) and the full set of agent-named callables (namedNodeIds). If
no spine forms, nothing skeletonizes.Off the flow spine. No symbol in the file is on the traced chain — that
chain is the mechanism the agent is walking, always kept full.A polymorphic sibling. The file's class
implements/extendsa supertype
with ≥ 3 implementers (MIN_SIBLINGS) — the signal that it's one of many
interchangeable impls. From realimplements/extendsedges, cached.Not spared. A file is spared (kept full) iff the agent named a
callable in it — a named method/function is something the agent asked to
see (getResponseWithInterceptorChain,SQLCompiler.execute_sql), not an
interchangeable leaf — UNLESS the file itself DEFINES a ≥3-impl supertype.
That last clause is the override: a base+subclasses "family" file (Django'scompiler.py) is huge and Read-anyway, so a full copy just eats explore
budget; skeletonizing it frees that budget for the sibling files the agent
would otherwise Read. So: named ⇒ spare, unless it's a family file ⇒
skeletonize anyway.
Worked through the two repos:
RealInterceptorChain—proceedis on the spine → kept full (cond. 2).RealCall— off-spine, and it trips the sibling signal via the 9-implLockablemixin (not because it's an interchangeable interceptor). But the
agent namedgetResponseWithInterceptorChain/execute/enqueuein it, and it
defines no ≥3-impl supertype → spared, kept full (cond. 4). This is the fix
for the read-back: before cond. 4 it skeletonized and the agent Read it back.BridgeInterceptor& the other 4 — off-spine, ≥3-impl siblings, named only
by type, define no supertype → skeletonized. The win.- Django
compiler.py— off-spine, a sibling (its subclasses extendSQLCompiler), the agent namedexecute_sqlin it — but it defines theSQLCompilersupertype, so the override fires → skeletonized (frees
budget). Sparing it instead (the wrong first attempt) cost MORE and Read MORE.
Why "shared supertype with ≥3 implementers" is the signal
The thing that makes OkHttp's interceptors interchangeable is precisely that
they're N implementations of one interface, invoked polymorphically. That is
a structural property the graph records as implements/extends edges:
14 classes ──implements──▶ Interceptor (BridgeInterceptor, CacheInterceptor,
CallServerInterceptor, … )Excalidraw's renderStaticScene, Scene, Collab share no common
supertype — the ≥3-implementer query returns nothing for them. So the signal
cleanly separates the two repos, and (validated below) leaves every non-sibling
flow untouched.
The ≥ 3 threshold matters: 1:1 "service interface → single impl" pairs (the
common Spring/Java shape) are not siblings and stay full. Only genuine
many-impl families (interceptor chains, strategy/visitor families, codec
registries) trip the gate.
Skeleton rendering
For a skeletonized file we emit the class + member signature lines (not
bodies). Because a symbol node's startLine can point at a decorator/annotation
(@Throws, @Override, @objc), we scan forward up to 4 lines for the line
that actually names the symbol, so the skeleton shows the real signature:
#### …/CallServerInterceptor.kt — CallServerInterceptor, intercept, … · skeleton (signatures only; Read for a full body)
```kotlin
30 object CallServerInterceptor : Interceptor {
32 override fun intercept(chain: Interceptor.Chain): Response {
194 private fun shouldIgnoreAndWaitForRealResponse(code: Int): Boolean =The header still lists the file's symbols and says `Read for a full body`, so the
agent can pull one specific implementation if it truly needs it.
## Validation (refined gate)
Headless `claude -p`, Opus 4.8, **WITH vs WITHOUT** CodeGraph (the real benchmark
arm, not the on/off probe the first cut used). Cost = median `total_cost_usd`.
| Repo | WITH→WITHOUT cost | WITH reads | WITHOUT reads | RealCall/compiler read-back |
|---|---|---|---|---|
| **OkHttp** (n=4) | **$0.45 → $0.50** (~10% cheaper) | 2 | 3.5 | **0 / —** (RealCall full) |
| **Django** (n=6) | **$0.56 → $0.63** (~10% cheaper) | 2 | 8.5 | half the runs read 0 |
Both were the README's **cost outliers** (OkHttp 3% costlier, Django 10%
costlier) and both flipped to clear wins. OkHttp WITH was cheaper in all 4 runs;
Django in 5 of 6 (n=6 to see through its high variance). WITHOUT baselines match
the README ($0.50/$0.63 vs $0.57/$0.64), so the gain is the WITH-arm improving.
The **decisive check now passes for the right reason**: with the named-callable
spare, OkHttp's `RealCall` stays full and is **never** Read back (it was Read
back in 3/4 runs before the fix). The inert repos (Excalidraw / Tokio / VS Code /
Gin) stay at **0 skeletons** — verified by probe — because the refined gate
skeletonizes a strict subset of the original. (The first cut's "on vs off, reads
flat 1 vs 3" claim came from a deterministic probe query and did **not** hold for
the agent's real query — that mismatch is what this refinement corrects.)
## Dead ends (don't re-attempt these)
1. **Demote/rank low-value files** (e.g. broaden `isLowValuePath` to drop
`*-testing-support/` fixtures). Improves *content quality* but **not size** —
explore refills the freed budget with other full bodies (28,478 → 28,424).
Ranking ≠ shrinking; you must *skeletonize* to shrink.
2. **Gate on entry-node membership.** A precise symbol-bag explore query *names*
every chain participant, so they're all "entry nodes" — no separation, nothing
skeletonizes.
3. **Rely on interface-impl synthesizer edges** (`synthesizedBy:'interface-impl'`)
for the sibling signal. They were **not** created for OkHttp's `Interceptor`
(a Kotlin `fun interface`), so the signal must come from the real
`implements`/`extends` edges, not synth edges.
4. **A plain "core-floor" gate** (keep first N full, skeletonize the rest) —
skeletonized Excalidraw's *distinct* steps → **+17% cost regression**. The
sibling condition is what makes it safe.
5. **Sparing a file because it DEFINES the supertype** (the first refinement
attempt). Backwards: a base+subclasses *family* file (Django's `compiler.py`,
2,266 lines) is huge and Read-anyway, so keeping it full just **eats the 28 KB
explore budget and starves the sibling files** the agent then Reads — it
regressed Django to **9% costlier** ($0.71). Defining a supertype is instead
an **override** that lets a named family file skeletonize anyway.
6. **Validating skeletonization with the deterministic probe query only.** The
probe (`probe-explore.mjs "<symbol bag>"`) and the *agent's* real explore
query name symbols differently, so they form different spines and skeletonize
different files. The probe said "Django: 0 skeletons / reads flat"; the real
agent query skeletonized `compiler.py` and Read it back. **Always confirm with
a real-agent A/B (`run-all.sh`), not just the probe.**
## Code
- `src/mcp/tools.ts`
- `adaptiveExploreEnabled()` — the flag (default on).
- `buildFlowFromNamedSymbols()` — returns `{ text, pathNodeIds, namedNodeIds }`.
`namedNodeIds` is every callable the agent named (a superset of the spine) —
the named-callable spare reads it.
- `handleExplore()` — two cached helpers: `isPolymorphicSibling()` (a node has
an outgoing `implements`/`extends` to a ≥3-impl supertype) and
`definesPolymorphicSupertype()` (a node HAS ≥3 incoming `implements`/`extends`
— i.e. the file is the family base). The skeleton branch:
`off-spine && isPolymorphicSibling && !(namedInFile && !definesSupertype)`.
`exactNodeIds` / `anchorSpans` (the exact targets) and `bodyFocusLines()`
(what a window of an oversize body must reach) are shared by the focused
view and the cluster path.
- `src/search/query-paths.ts` — `lineAnchors`: `file:line` / range parsing.
- `__tests__/adaptive-explore-sizing.test.ts` — 7 cases incl. the named-callable
spare (RealCall) and the supertype-family override (compiler.py).
- `__tests__/explore-exact-target.test.ts` — the django shape in miniature:
qualified body whole, subclass overrides still signatures, blast-radius lead,
`file:line` and `lines a-b` anchors, and a windowed body keeping its tail call.
## Frontier / future work
- **Per-symbol skeletonization within a family file.** `compiler.py` is
skeletonized whole, so `SQLCompiler.execute_sql` (the base mechanism) becomes a
signature too and *is* Read back in ~half the Django runs. The ideal is to keep
the base class's methods full and elide only the redundant subclass bodies —
shrinking the payload without eliding the answer. Whole-file skeletonization
can't express that yet.
- **Big non-sibling files dominate Django's residual reads.** `query.py` (3,040
lines) and `sql/query.py` are not polymorphic families, so skeletonization
can't touch them; the agent Reads them when the 28 KB clustered view is
insufficient. That's the explore-budget / big-file-clustering frontier, not
skeletonization.
- **Non-interface sibling families** (Go `HandlerFunc` slices, function-pointer
registries) aren't caught — they have no `implements`/`extends` edge. Gin's
middleware chain, for instance, doesn't trip the gate (its handlers are funcs,
not interface impls).
- **Exemplar selection** when *no* interceptor is on the spine: today all siblings
skeletonize and the agent leans on the interface contract; showing one as a
forced exemplar might read slightly better (untested).
---
## File: docs/design/agent-codegraph-adoption.md
# Getting agents to actually use codegraph (not Read) — design notes & handoff
> Working doc for a fresh session. Two problems to crack:
> **(P1)** agents still reach for `Read`/`grep` during implementation instead of codegraph;
> **(P2)** on startup the codegraph MCP server can be `pending` when the agent's first turn fires, so the agent runs with *no* codegraph at all.
>
> Read `codegraph/CLAUDE.md` → "Retrieval performance & dynamic-dispatch coverage" first — it's the doctrine these ideas must respect.
---
## Context — what already shipped (so you don't repeat it)
- **#733 (`7175dc4`)** — reframed the agent-facing steering (`src/mcp/server-instructions.ts` + the `codegraph_node`/`codegraph_explore` descriptions in `src/mcp/tools.ts`) to cover *implementation*, not just Q&A; and added **file-view mode**: `codegraph_node` now accepts a bare `file` (no `symbol`) → returns that file's symbol map + its dependents (blast radius) + verbatim bodies (`includeCode`). `handleFileView` in `src/mcp/tools.ts`.
- **Clean A/B result** (new build vs baseline build, both codegraph-connected, same fully-implemented task — `kindExclude` added to `codegraph_search`):
- **baseline:** 0 codegraph calls, 8 Reads (agent *ignored* available codegraph).
- **new:** 2 `codegraph_explore` calls, 5 Reads.
- So the reframe *did* move tool-choice — but the agent used `codegraph_explore`, **never the file-view**, and still Read 5×. n=1/arm.
- **Eval harness fix** (`#735`): nested attach is a *startup-latency* problem, not a hard block. `scripts/agent-eval/ab-new-vs-baseline.sh` now pre-warms a daemon + skips the re-exec; use it (run non-nested for cleanest results).
**Doctrine constraints (from CLAUDE.md — do not relitigate):**
- *Adapt the tool to the agent.* Changing tool descriptions / `server-instructions.ts` is **low-salience** and has *regressed* wall-clock before. Wording alone won't reliably move tool-choice.
- *New tools fare worse than extending an existing one* (the agent under-picks even `trace`; `codegraph_context` was removed).
- The real levers that landed historically: **coverage** (more flows connect statically → `explore` surfaces them) and **sufficiency** (output complete enough that the agent *stops* reading).
- The optimization target is **wall-clock + tool-call count + Read=0**, not token cost (cost is lower as a side effect).
---
## P1 — Agents under-use codegraph during implementation
### STATUS — 2026-06-08 (RESOLVED via Read-parity, not a hook)
**The fix: make `codegraph_node` read a file *exactly like the Read tool*, only
faster — so the agent reaches for it naturally. No forcing.** The owner's steer
settled the direction: *"codegraph should be able to Read just like the Read
tool… make it as good as Read. Read is slow and old; querying the index is fast.
You keep diverging away from using codegraph rather than pursuing the fix."*
**DONE — `handleFileView` (`src/mcp/tools.ts`) is now full Read parity:**
- A `file` with no `symbol` returns the file's current source numbered
**byte-for-byte the way Read does — `<n>\t<line>`, no padding, trailing empty
line kept** (verified by reading the same file with both and diffing). The only
addition is a **one-line blast-radius header** (`used by N files: …`).
- **`offset` / `limit` mean exactly what they do on Read** (1-based start; max
lines; default whole file capped at 2000 lines like Read). Large files paginate
honestly (`(lines X–Y of N — pass offset/limit…)`), never the 15k `truncateOutput` chop.
- Content is the **default** (no `includeCode` needed); `symbolsOnly: true` returns
the cheap structural map instead. Security preserved: `yaml`/`properties`
summarized by key, never dumped (#383); reads via `validatePathWithinRoot` (#527).
- Tests: `__tests__/node-file-view.test.ts` (9, incl. strict format parity
`^1000\t const v998 = 998;` and unpadded `^1\timport …`). Full suite green
(1270). Descriptions / `server-instructions.ts` / CHANGELOG reframed: "read a
source file with codegraph_node instead of Read — same bytes, faster."
**The hook (idea 1) — A/B'd and REJECTED. Do not ship.** Kept only as an eval
artifact (`scripts/agent-eval/redirect-read-hook.sh` + `ab-hook.sh`).
- Clean A/B (2 runs/arm, devpit "add `dp ping`, build it"; both arms codegraph-attached):
- **nohook:** 0 codegraph calls, 1 Read, **5–7 tool calls, 6–8 turns, 55–77s.** (Reproduces P1: agent ignores codegraph — but read-once-and-edit is *efficient* here.)
- **hook (deny-redirect):** 0 *successful* Reads + 1 file-view call (parity worked, edit compiled), but **8–9 tool calls, 9–10 turns, 200–239s**, and the agent **fought the deny** — `ToolSearch` to find the tool, reflexive re-Read (denied), then **`Bash python3` to read the file around the block.**
- Verdict: a blanket Read-deny **regresses the target metrics (~2× tool calls, more turns) on a simple edit** and the agent routes around it. Forcing is the wrong lever; making the tool genuinely better than Read is the right one.
- If routing is ever revisited: not a blanket hook. Either a narrow trigger (large
files only / after-N-reads) **with a clean A/B on a Read-heavy multi-file task**
(the hook's best case, untested), or just keep widening coverage + sufficiency.
---
**Symptom:** even with codegraph attached + the new steering, the agent reflexively `Read`s/`grep`s mid-implementation, and never reaches for the file-view. Descriptions can't fix this (low-salience wall).
### Ideas, ranked by expected leverage
1. **PreToolUse(Read/Grep) hook that redirects to codegraph** — *highest leverage; the only channel that actually changes behavior.*
- Claude Code **hooks** can intercept a tool call and inject context or block it — unlike descriptions, this is *not* low-salience. We already have `scripts/agent-eval/block-read-hook.sh` + `hook-settings.json` (used to force Read=0 in evals).
- Ship a **recommended (opt-in) hook**: on `Read` (or `Grep`) of a path that's *indexed*, inject "this file is indexed — `codegraph_node {file}` returns it + its blast radius for fewer tokens; treat its output as already-Read." Soft nudge (don't hard-block, or it'll frustrate users on configs/docs codegraph doesn't index).
- The installer (`src/installer/targets/claude.ts`) could offer to add this hook (opt-in, like the auto-allow permissions).
- **Validate** with `ab-new-vs-baseline.sh` (Read count, with vs without the hook). This is the experiment most likely to move the needle.
- Open Qs: how to know a path is indexed from inside a hook (query `codegraph files`/`status`, or a fast local check against `.codegraph`); avoiding noise on non-indexed files; per-language false positives.
2. **Sufficiency: make the file-view the obvious Read replacement so the agent *wants* it.**
- The A/B showed the agent never passed a `file` to `codegraph_node`. Why? It doesn't think "Read this file" → "codegraph_node file=X". Investigate: is the file-view's value (symbols + dependents + bodies) actually *better than Read* for the agent's next step (an `Edit`)? It returns bodies — but does it return enough surrounding context to `Edit` confidently? If not, the agent Reads anyway.
- Consider: when the agent *does* Read an indexed file, is there a way to make codegraph's prior `explore`/`node` output have *already* given it what it needed? (i.e. fix the upstream sufficiency, not the Read itself.)
3. **Coverage — the durable lever.** Every flow that connects statically is one the agent doesn't Read to reconstruct. Keep closing dynamic-dispatch gaps (`src/resolution/`). Less about "stop Reading," more about "never need to."
4. **Naming / affordance experiments (low confidence, cheap).** The file-view is buried inside `codegraph_node`. A dedicated, obviously-named affordance might get picked more — *but* "new tools fare worse," so this likely loses. If tried, A/B it; don't assume.
**Recommendation:** prototype **idea 1 (the Read-redirect hook)** and A/B it. It's the one lever with a real chance of moving behavior. Everything else is incremental.
---
## P2 — Agent runs without codegraph because the server is `pending` at startup
**Symptom:** `serve --mcp` isn't ready when the agent's first turn fires (the host marks the MCP server `status:"pending"` / 0 tools), so the agent starts Read/grep and never uses codegraph. We saw this hard in nested evals (~2-3s startup vs the agent's turn-1); **real users hit a milder version** — the first query of a session may not have codegraph.
### Root cause
`serve --mcp` does a `--liftoff-only` **re-exec** (for a node memory flag) **and** spawns/binds a detached **daemon** before tools are usable. Under load that exceeds the host's MCP-startup window. (`CODEGRAPH_WASM_RELAUNCHED=1` skips the re-exec; pre-warming a daemon removes the bind latency — both proven in `ab-new-vs-baseline.sh`. But a real user can't pre-warm.)
### Ideas, ranked
1. **CODEGRAPH-SIDE — expose the static tool list INSTANTLY, decoupled from the daemon. *Biggest shippable win; helps every user.***
- Hypothesis: the host marks codegraph `pending` because `tools/list` (tool exposure) waits on the daemon connect. The local handshake already answers `initialize` fast (~107ms; `runLocalHandshakeProxy` in `src/mcp/proxy.ts`, `getStaticTools` is imported there). **Investigate: does `serve --mcp` answer `tools/list` *locally and instantly* from `getStaticTools`, or does it forward it to the still-connecting daemon?** If the latter, decouple it: advertise the static tools the moment the client asks, mark connected, and resolve the daemon in the background for actual tool *calls*.
- Verify with: `printf '\n\n<tools/list>\n' | node dist/bin/codegraph.js serve --mcp --path <repo>` and time the `tools/list` response, daemon-mode vs in-process. In-process answered in ~165ms; daemon-mode is the suspect.
- If this lands, `pending`-at-startup largely disappears without any host change.
2. **CODEGRAPH-SIDE — speed/skip the re-exec on the MCP serve path.** The re-exec exists for a V8 memory flag (`src/extraction/wasm-runtime-flags.ts`, `RELAUNCH_GUARD_ENV = CODEGRAPH_WASM_RELAUNCHED`). For MCP serving on a normal repo the flag may be unnecessary, or settable without a full process re-exec. Removing one process spawn from the cold path shaves the startup window.
3. **CODEGRAPH-SIDE — a SessionStart hook that pre-warms the daemon.** Ship an opt-in Claude Code `SessionStart` hook (installer-added) that spawns/warms the daemon for the project at session start, so it's bound before the first query. Mitigation if (1) is hard.
4. **HOST-SIDE — "wait/retry on pending" — this is what you asked about, but it's a Claude Code (MCP client) behavior, not codegraph's to fix.** codegraph can't make the agent retry. Options: (a) raise it with Anthropic as an MCP-client improvement (don't let the agent's first turn proceed until configured MCP servers finish connecting, or retry `pending` servers); (b) note `MCP_TIMEOUT` exists but did **not** help here, because the problem is *tool exposure timing*, not a connection timeout. Frame this as a request, and lean on (1)–(3) for what we control.
**Recommendation:** chase **idea 1** (decouple `tools/list` from the daemon). It's the fix that makes codegraph "connected" instantly for everyone. Ship **idea 3** (pre-warm SessionStart hook) as a cheap mitigation in parallel. File the host-side request (4) but don't depend on it.
---
## Key files / pointers
- **Steering / tools:** `src/mcp/server-instructions.ts` (the `initialize` instructions — single source of truth), `src/mcp/tools.ts` (tool descriptions + handlers; `handleNode`/`handleFileView`/`handleSearch`, `getStaticTools`).
- **Startup / daemon / proxy:** `src/mcp/proxy.ts` (`runProxy`, `connectWithHello`, `runLocalHandshakeProxy`, PPID watchdog), `src/mcp/index.ts` (`runProxyWithLocalHandshake`, `spawnDetachedDaemon`), `src/mcp/daemon.ts`.
- **Runtime flags:** `src/extraction/wasm-runtime-flags.ts` (`RELAUNCH_GUARD_ENV=CODEGRAPH_WASM_RELAUNCHED`, `HOST_PPID_ENV=CODEGRAPH_HOST_PPID`).
- **Hooks (existing):** `scripts/agent-eval/block-read-hook.sh`, `scripts/agent-eval/hook-settings.json` (the eval's force-Read-0 hook — basis for the P1 redirect hook).
- **Installer (where to add a recommended hook):** `src/installer/targets/claude.ts`.
- **Eval harness:** `scripts/agent-eval/ab-new-vs-baseline.sh` (new-vs-baseline, pre-warm baked in), `run-all.sh` (with-vs-without), `parse-run.mjs` (tool-by-type counts; `codegraph tools exposed: 0` + 0 codegraph calls = ran without).
- **Doctrine:** `CLAUDE.md` → "Retrieval performance & dynamic-dispatch coverage" + the agent-eval note under "Validation methodology".
## How to validate anything here
- **P1 (Read displacement):** `bash scripts/agent-eval/ab-new-vs-baseline.sh "" [baseline-ref]` — compare `Read` vs `mcp__codegraph__*` counts. ≥2 runs/arm (n=1 is noisy). Run non-nested for cleanest results. Use a *genuinely new* feature task (verify it doesn't already exist — the first A/B attempt wasted a run on an already-implemented `--quiet`).
- **P2 (startup):** time `tools/list` from `serve --mcp` (above); and count cold-start runs where `init` shows `connected` + tools > 0. Don't trust a single `pending` init snapshot — confirm by whether the agent actually called codegraph.
## Constraints / gotchas to remember
- Descriptions/instructions are low-salience — **A/B every behavioral claim**, don't ship wording on faith.
- New tools < extending existing ones.
- The host's `init` snapshot can say `pending` even when the server then connects — judge by actual usage.
- Don't run evals nested for "clean" numbers unless pre-warmed; even then, a real terminal is better.
## Suggested start order for the fresh session
1. **P2 idea 1** — verify whether `serve --mcp` answers `tools/list` locally/instantly; if not, decouple it from the daemon. (Highest-value, shippable, helps all users, no behavioral guesswork.)
2. **P1 idea 1** — prototype the PreToolUse(Read) redirect hook; A/B it. (Highest-value behavioral lever.)
3. Ship the P2 SessionStart pre-warm hook as a mitigation; file the host-side wait/retry request.
---
## File: docs/design/callback-edge-synthesis.md
# Design + status: general callback / observer edge synthesis
**Status:** SHIPPED (the synthesizer in `callback-synthesizer.ts` is merged and on
`main`). This doc records the original design.
**Motivation:** close the dynamic-dispatch hole that static extraction leaves for
observer / event-emitter / signal patterns, where a *dispatcher* invokes callbacks
registered elsewhere through a shared store — so flows like "how does an update
reach the screen" actually exist in the graph.
> **Update (2026-06-01):** the `codegraph_trace` and `codegraph_context` MCP tools
> were since **removed** — `codegraph_explore` is the single surfacing tool now. Its
> "Flow" section (`buildFlowFromNamedSymbols`) and the `codegraph_node` trail surface
> these synthesized edges; the `trace(a, b)` notation below means "the a→b flow,"
> which you now verify with `codegraph_explore` / `probe-explore.mjs` (the
> `probe-trace.mjs` / `probe-context.mjs` dev probes went away with the tools).
---
## TL;DR for a new session
We synthesize `dispatcher → callback` edges that static parsing misses. It works:
- **Field observer** (excalidraw `Scene.onUpdate`/`triggerUpdate`): synthesizes
`triggerUpdate → triggerRender`. `trace(mutateElement, triggerRender)` now = 3 hops.
- **EventEmitter** (express `on('mount', …)`/`emit('mount')`): synthesizes `use → onmount`.
- Precision is high: excalidraw got **1** synthesized edge out of 27k (the correct one);
node count moved +3 after Phase 3 (no explosion).
**Files touched (all uncommitted on `main`):**
- `src/resolution/callback-synthesizer.ts` — the whole-graph synthesis pass (Phase 1 + 2).
- `src/resolution/index.ts` — calls `synthesizeCallbackEdges()` at the end of
`resolveAndPersistBatched()` (after base edges are persisted) + the import.
- `src/extraction/tree-sitter.ts` — `visitFunctionBody` now extracts **named** nested
functions (Phase 3), so inline named handlers become linkable nodes.
**How to reproduce / test:**
```bash
npm run build
rm -rf /tmp/codegraph-corpus/excalidraw/.codegraph
( cd /tmp/codegraph-corpus/excalidraw && codegraph init -i )
# synthesized edges (provenance='heuristic', metadata.synthesizedBy in {callback,event-emitter,…,http-client,queue-job,event-bus}):
sqlite3 /tmp/codegraph-corpus/excalidraw/.codegraph/codegraph.db \
"select s.name||' → '||t.name||' '||coalesce(e.metadata,'') from edges e \
join nodes s on e.source=s.id join nodes t on e.target=t.id where e.provenance='heuristic';"
# end-to-end flow (the synthesized edge shows up in explore's Flow section + node trail):
node scripts/agent-eval/probe-explore.mjs /tmp/codegraph-corpus/excalidraw "triggerUpdate triggerRender"Probe scripts (dev-only, in scripts/agent-eval/): probe-node.mjs (symbol + trail),probe-explore.mjs (relevant source + the flow among named symbols). EventEmitter
fixture lives at /tmp/cb-fixture/bus.js (ephemeral — recreate or move into __tests__/).
Cross-tier channels (`src/resolution/tier-synthesizer.ts`, 2026-08-28)
The web's RN bridge: one pass, registered before the in-process emitter pass (the more specific edge wins a duplicatesource>target pair in the merge), gated on JS-family files, never sourced from a test suite or a generated file.
Three channels, each keyed on a literal on both sides, each edge kind:'calls', provenance:'heuristic', withsynthesizedBy, channel (http | queue | event | socket), tier (client→server / server→client) when the
direction is known, the event / queue / method / href it paired on, line + column of the call, andregisteredAt = the other side (route registration, decorator, .on):
http-client—fetch/$fetch/ofetch/axios/ky/got/useFetch/useSWR,<client>.get|post|…(
where the receiver is a known client name or a binding made byaxios.create(…)/ky.extend(…)(same file or the file it
is imported from —resolveImportPath, since import mappings carry no resolved path), with a literal / template first
argument (new URL('/x', base)and{ url, method }configs read too) → the ONE routeMETHOD pathin the index it
denotes. A hole fills a:param/{id}/[id]/ catch-all segment and never a literal one; a hole in front of the path
(${API_URL}/users) matches by the route's tail; a line a framework resolver made a route node on is a registration, not a
client call; a tie between routes is nothing. No fan-out cap — the match is exact.queue-job—<queue>.add('job', …)where the queue is named (new Queue('email'),@InjectQueue('email') x, in the
file or its import) or queue-shaped → the@Process('job')method of the@Processor('email')class (a WorkerHost'sprocesswhen there is none),new Worker('email', handler)(an inline handler → the enclosing function, else the
enclosing constant), Bull'squeue.process('job', handler). Most specific pairing wins (queue+job > job > the queue's
default); an unnamed queue pairs only on a unique job name. Fan-out cap 6.event-bus—.emit|emitAsync('x')on a bus-shaped receiver (eventEmitter,bus,pubsub, …) →@OnEvent
handlers,*/**globs honoured; on a socket-shaped receiver (socket,io,server,client,.to(room), …) from a
file without a socket server →@SubscribeMessage('x')and server-sidesocket.on('x')(client→server); from a file
with one (@WebSocketGateway,io.on('connection'),new Server) → client-sidesocket.on('x', …), named or inline
(→ the enclosing component) (server→client). Plain.on↔.emitstays the emitter pass's. Fan-out cap 6.
The Steps view (ui-server/api/steps.ts) reads tier / channel before the languages in crossing(), so a hop between
two TS files draws as a bridge (⇢ POST /api/users, a boundary like another screen) or an event (⇠ welcome); explore's
Flow section labels them (context/index.ts, mcp/tools.ts). A Next 'use server' action needs no edge: steps.ts marks
the call at request time from the directive. Validated on bradtraversy/proshop_mern (30 routes, 23 client→route edges,
all correct on inspection, after Express mounts + chained router.route() landed) and nestjs/nest (sample/26-queues,sample/30-event-emitter); test __tests__/ui-steps-cross-tier.test.ts.
Next.js links (`src/resolution/next-router-synthesizer.ts`, 2026-08-28)
<Link href="/x">, <Link href={/users/${id}}>, <Link href={{ pathname }}> and an internal <a href> are JSX
attributes — no reference is ever extracted for them — so this pass reads them from the source, attributes each to the
component it is written in, matches the href against the Next page table (frameworks/nextjs.ts) and synthesizes anavigates edge (synthesizedBy:'next-link', href, navMethod: 'link' | 'a', registeredAt = the JSX site). Only files
under a Next app's root, never test files; ≤ 24 links per component (a navigation menu is not a decision); an external <a>
is nothing. The Screens view walks back from these edges exactly as from router.push; they draw dashed.
The hole
class Scene {
private callbacks = new Set<Callback>();
onUpdate(cb: Callback) { this.callbacks.add(cb); } // REGISTRAR
triggerUpdate() { for (const cb of this.callbacks) cb(); } // DISPATCHER
}
this.scene.onUpdate(this.triggerRender); // REGISTRATION SITEThe runtime edge triggerUpdate → triggerRender does not exist statically:triggerUpdate's only literal call is cb() (anonymous). Measured: triggerUpdate's
only callee was randomInteger; trace(triggerUpdate, triggerRender) returned no path.
Why it's a whole-graph pass, not a `FrameworkResolver.resolve()`
resolve(ref) answers "what does this named ref point to," one ref at a time. The
callback edge has no ref to resolve (cb() is anonymous) and needs cross-file,
multi-site correlation (registrar, registration, dispatcher). So it's a whole-graph
pass after base resolution, language-level (any OO observer), living insrc/resolution/callback-synthesizer.ts — not under frameworks/.
Sibling mechanism for the other dynamic-dispatch class — named attribute/
descriptor dispatch (e.g. djangoself._iterable_class(...)) — is theclaimsReferencehook (resolution/types.ts+resolution/index.tspre-filter)
- a
FrameworkResolver.resolve()(django ORM resolver inframeworks/python.ts).
That one does fitresolve()because the ref is named. Both are part of the same
coverage effort; see the "Related work" section.
As-built algorithm (and where it diverged from the original design)
Field-observer channels (`fieldChannelEdges`, Phase 1)
- Candidates by method/function name — registrar
^(on[A-Z]\w*|subscribe| addListener|addEventListener|register|watch|listen|addCallback)$; dispatcher
contains(emit|trigger|notify|dispatch|fire|publish|flush). - Confirm by body (read via
ctx.readFile+ slice node lines): registrar hasthis.<F>.add|push|set(; dispatcher hasfor (… of [Array.from(]this.<F>)+ a call,
orthis.<F>.forEach(. - Pairing — DIVERGENCE: the design said pair by class; the build pairs by
same file + same fieldF(file as a class proxy — getting the containing class
reliably was harder). Works for the common 1-class-per-file case; revisit for
multi-class files. - Registrations:
queries.getIncomingEdges(registrar.id, ['calls'])→ for each,
read the caller's source at the edge line and regex-recover the arg
(<registrarName>\s*\(\s*(?:this\.)?(\w+)). DIVERGENCE: design preferred tree-sitter
re-parse; build uses regex (named refs only — arrows/inline args are missed here). - Synthesize
dispatcher → fn(getNodesByName(arg)→ method|function). Capped atMAX_CALLBACKS_PER_CHANNEL = 40.
EventEmitter channels (`eventEmitterEdges`, Phase 2)
- File-oriented scan (
ctx.getAllFiles()+readFile, substring pre-filter on.emit(/.on(/etc).ON_RE=\.(?:on|once|addListener)\(\s*['"]([^'"]+)['"]\s*,\s* (?:function\s+(\w+)|(?:this\.)?(\w+));EMIT_RE=\.(?:emit|fire|dispatchEvent)\(\s*['"]([^'"]+)['"]. - Dispatcher = enclosing function of the
emit('e')call (enclosingFnfinds the
tightest function/method/component node containing the line). Handler =getNodesByName
of the on-handler name. - Correlate by event-name literal; synthesize dispatcher → handler.
- Precision — DIVERGENCE: design proposed receiver-type matching; build uses an
event fan-out cap (EVENT_FANOUT_CAP = 6) — skip events with >6 handlers or
dispatchers (generic names likeerror/changewould over-link without type info).
Provenance — DIVERGENCE
Edge.provenance is a fixed enum ('tree-sitter'|'scip'|'heuristic'), so synthesized
edges use provenance: 'heuristic' + metadata: { synthesizedBy: 'callback'| 'event-emitter', via/event/field }. The design's 'callback-synthesis' provenance and
high/medium/low confidence tiers were NOT implemented — the fan-out cap +
registrar-name uniqueness + named-only handlers are the precision guards instead.
Phase 3 — inline callback extraction (`tree-sitter.ts`)
The real blocker for EventEmitter on real repos: inline handlers
(on('mount', function onmount(){})) weren't nodes, so nothing could link to them.
Root cause: visitFunctionBody walked through nested functions without extracting them.
Fix: in visitForCallsAndStructure, when a body node is a functionType andextractName returns a real name, call extractFunction (which extracts it and walks
its own body) and return. Named only — anonymous arrows fall through to the existing
recursion (so their inner calls stay attributed to the enclosing fn). This bounded it:
excalidraw +3 nodes, no explosion, no regression.
Validation results (actual)
| Repo | Result |
|---|---|
| excalidraw | 1 synthesized edge triggerUpdate → triggerRender (of 27,214); trace(mutateElement, triggerRender) = 3 hops; nodes 9,286 → 9,289 |
| express | after Phase 3: use → onmount {event-emitter, event:"mount"} (onmount now extracted at application.js:109) |
/tmp/cb-fixture/bus.js |
tick → handleRefresh, persist → handleSave (named-method EventEmitter handlers) |
| excalidraw / express | no Phase-1 regression; node counts stable |
Remaining work (prioritized for the next session)
- Anonymous-arrow handlers —
on('e', () => foo())still produce no edge (no node,
intentionally not extracted in Phase 3). The fix is synthesizer link-through-body:
parse the arrow's body and linkdispatcher → (calls inside the arrow). Highest
remaining recall win; handles the most common modern callback shape. - Wire into
resolveAndPersist(incremental sync) — synthesis currently runs only
inresolveAndPersistBatched(full index). Incremental re-index won't refresh
synthesized edges. - Receiver-type matching for EventEmitter precision (replace/augment the fan-out
cap) — usetype_ofedges sox.emit('change')only links toy.on('change', fn)
whenx,yare the same type. Lets the fan-out cap relax. - Tree-sitter arg recovery (replace the regex in field-channel Stage 4) — robust for
arrows, multi-arg, line-wrapped calls. - Single-callback fields (
this.onChange = cb; … this.onChange()) — scalar-store
variant of the field observer; not built. - Broad precision/recall audit — run across the full corpus; tally synthesized edges
per repo, spot-check, confirm no explosion on EventEmitter-heavy repos. - Tests + CHANGELOG — the fixture is a ready vitest case for the synthesizer; add
extractor tests for Phase 3 (named-nested-fn extraction; confirm other languages
unaffected — the change is in the shared walker), resolver tests for the django side.
Edge cases / model
- Over-approximation across instances is accepted (reachability, not instance
precision).unregister/offignored. - Synthesized edges are additive — never replace static edges; tooling can filter on
provenance='heuristic'+metadata.synthesizedBy.
Related work (same coverage effort)
This is one half of closing dynamic-dispatch coverage. The other artifacts on main:
- Named attribute/descriptor resolver:
claimsReference(resolution/types.ts,
pre-filter inresolution/index.ts) + django ORM resolver (frameworks/python.ts,_iterable_class→ModelIterable.__iter__). - Retrieval/UX changes (separate from coverage):
explorewhole-small-file + glue
fixes, theexploreFlow section (buildFlowFromNamedSymbols), andnode-with-trail
— all insrc/mcp/tools.ts. (codegraph_trace/codegraph_contextwere later
removed; explore is the one surfacing tool.) - Full investigation context + findings: auto-memory
project_codegraph_read_displacement(why coverage — not prompting/hooks/new-tools —
is the lever for getting agents to use codegraph over Read).
File: docs/design/dynamic-dispatch-coverage-playbook.md
Dynamic-Dispatch Coverage Playbook
Audience: a Claude agent continuing this work.
Mission: systematically close static-extraction coverage holes for dynamic
dispatch across every language and framework codegraph supports, and validate
each one the same way, so cross-symbol flows exist in the graph everywhere.
This is the top-level playbook. The deep design for one mechanism (the callback
synthesizer) is incallback-edge-synthesis.md.
The cross-cutting dispatch-shape queue (Redux/RTK Query/NgRx/MediatR/registries —
organized by indirection shape, not language×framework) is indispatch-synthesizer-backlog.md.
Full investigation context + findings: auto-memoryproject_codegraph_read_displacement.
Update (2026-06-01): the
codegraph_traceandcodegraph_contextMCP tools were
removed —codegraph_exploreis the single surfacing tool now. Its "Flow" section
(buildFlowFromNamedSymbols) surfaces the synthesized edges this playbook is about, and
you validate coverage withcodegraph_explore/scripts/agent-eval/probe-explore.mjs.
Where the text below writestrace(a, b)or liststrace/contextamong the tools,
read it as "the a→b flow, now surfaced and verified via explore." The synthesizers and
the coverage matrix are unchanged.
1. The goal (why this matters)
codegraph's value is being the map — answering structural/flow questions
(trace, impact, callers, "how does X reach Y") that grep/Read cannot. Agents
will use codegraph instead of Read only when it is sufficient. We proved
empirically (see memory) that the lever for sufficiency is coverage, not
prompting/hooks/new-tools: when a flow is missing from the graph, the agent reads
the files to reconstruct it; when the flow is in the graph, the agent can answer
completely without reading.
Validated end-to-end on excalidraw: after closing the update-flow hole, 2/3
headless agent runs answered the "how does an update reach the screen" question with
Read 0 and a complete answer — impossible before, because the key edge wasn't in
the graph. (Caveat: coverage enables the no-read path; agent confirm-by-reading
variance means it doesn't force it. Completeness improves unconditionally.)
The mission is to make that true for all languages/frameworks.
2. The problem class: dynamic dispatch
Static tree-sitter extraction captures explicit calls (foo(), this.bar()). It
misses any call whose target is computed/indirect. Four recurring shapes, with a
difficulty gradient (do the cheap ones first):
| # | Shape | Example | Fix mechanism | Cost |
|---|---|---|---|---|
| 1 | Named attribute / descriptor | django self._iterable_class(self) |
framework resolver (claimsReference + resolve()) |
cheap |
| 2 | Field-backed observer | onUpdate(cb) + for(cb of cbs)cb() |
callback synthesizer (whole-graph pass) | medium |
| 3 | String-keyed EventEmitter | on('e',fn) / emit('e') |
callback synthesizer (event-keyed) | medium |
| 4 | Inline callback handler | on('e', function h(){}) / () => {} |
extraction (named) + synthesizer link-through-body (anon) | named: cheap · anon: hard |
| 5 | Closure-collection dispatch | Swift validators.write{$0.append(v)} … validators.forEach{$0()} |
callback synthesizer (closureCollectionEdges, element-invoke gated) |
medium |
Key distinction driving the mechanism choice:
- A named ref exists to resolve (
_iterable_classis an attribute name) → resolver. - No ref exists (
cb()is anonymous; needs registrar↔dispatcher correlation) → synthesizer.
3. Worked examples (the two mechanisms, end to end)
3a. Django ORM descriptor — the **resolver** pattern (Python)
- Hole:
QuerySet._fetch_allcallsself._iterable_class(self)(a runtime-chosen
iterable, defaultModelIterable), whose__iter__runs the SQL compiler. Static
parsing can't resolve the attribute-as-callable →_fetch_all's only callee was_prefetch_related_objects;trace(_fetch_all, execute_sql)returned no path. - Fix:
djangoResolverclaims the unresolved_iterable_classref through the
name-exists pre-filter, then resolves it toModelIterable.__iter__. - Files:
src/resolution/types.ts(claimsReference?onFrameworkResolver),src/resolution/index.ts(pre-filter inresolveOneconsultsclaimsReference),src/resolution/frameworks/python.ts(djangoResolver.resolve+claimsReference+resolveModelIterableIter). - Result:
trace(_fetch_all, execute_sql)→_fetch_all → __iter__ → execute_sql(3 hops).
3b. Excalidraw observer + EventEmitter — the **synthesizer** (TS)
- Hole:
Scene.triggerUpdatedoesfor (cb of this.callbacks) cb();triggerRender
is registered viascene.onUpdate(this.triggerRender). ThetriggerUpdate → triggerRenderedge is dynamic →tracereturned no path; the whole update flow broke. - Fix: a whole-graph pass that detects registrar/dispatcher channels, correlates
registration sites, and synthesizesdispatcher → callbackedges. Plus extraction of
named inline callbacks so handlers like express'sfunction onmount(){}are nodes. - Files:
src/resolution/callback-synthesizer.ts(the pass — field observers +
EventEmitter),src/resolution/index.ts(callssynthesizeCallbackEdges()at the end
ofresolveAndPersistBatched),src/extraction/tree-sitter.ts(visitFunctionBody
extracts named nested functions). - Result:
trace(mutateElement, triggerRender)→ 3 hops; expressuse → onmount.
3c. Alamofire deferred validation — closure-collection dispatch (Swift)
- Hole:
DataRequest.validate(_:)builds a closure andvalidators.write { $0.append(validator) };
the baseRequest.didCompleteTaskruns them viavalidators.forEach { $0() }. Append and
dispatch live in different files and classes (a subclass appends, the base iterates) and the
field is a SwiftProtected<[@Sendable () -> Void]>— so neither same-file pairing nor the
name-based registrar match (onX/subscribe/…) reaches it.trace(didCompleteTask, validate)
returned no path; the agent greppedvalidatorsand read three files to reconstruct it. - Fix:
closureCollectionEdges(callback-synthesizer.ts). A dispatcher iterates a collection
invoking each element (coll.forEach { $0() }/{ it() }); a registrar appends a closure to
the same-named field (.append/.add/.push/.insert, incl. Swift.write { $0.append }). The
element-invoke ($0(/it() is the precision gate — it proves the collection holds closures —
so a repo with no closure-collection dispatch yields 0 edges regardless of how many.append
sites it has. Pairs dispatcher → registrar globally by field name (cross-file/class required),
fan-out-capped. Surfaced two ways: inline intrace, and as a "Dynamic-dispatch links among your
symbols" section incodegraph_explore(buildFlowFromNamedSymbols) so the relationship shows even
when the agent named onlyvalidate, not thedidCompleteTaskthat drains the list. - Files:
src/resolution/callback-synthesizer.ts(closureCollectionEdges),src/mcp/tools.ts(synthEdgeNoteclosure-collection case + the explore synth-links section). - Result:
trace(didCompleteTask, validate)connects with the closure-collection hop + thevalidators.write { $0.append }wiring site inlined. 9 precise edges on Alamofire
(validators/streams/finishHandlers/requestsToRetry), 0 on every non-Swift control.
Forced codegraph-only (Read+Grep+Bash blocked): 3/3 runs answer build/send/validate correctly.
3d. Insight — an "adoption floor" can hide a trace-endpoint bug (Alamofire)
Alamofire (110 files) was the README's weakest repo and was written off as the "small-repo floor"
(native grep is cheap, so the agent reads anyway). It wasn't. Reading the transcripts — everyRead's file_path+offset and the assistant text right before it — surfaced the agent's own words:
"the trace collided with same-named symbols (44 requests, 8 tasks), let me read by line."codegraph_trace's endpoint disambiguation (scorePair, shared-dir-prefix only) was resolving an
overloaded name to an empty delegate/protocol stub — request → EventMonitor.request(){}
(a 1-line no-op) over the real Session.request, because two unrelated Source/Features/ stubs
shared a deeper dir prefix than the correct Source/Core/ pair. Garbage trace → manual reading,
sometimes a spiral (12 reads / 11 greps in one run). Fix: a nodeRelevance term in handleTrace
pair scoring that penalizes empty stubs (≤1 body line) and test-file symbols; among real methods it's
flat, so path-proximity (cosmos EndBlocker) is unaffected. Result (n=8): WITH-arm tool calls
12 → 8 median, and the read variance collapsed (0–12 → 1–4 — the meltdowns were the
trace-collision flounder). General bug: protocol/delegate-stub flooding hits Swift/Java/C#/Go.
Methodology lesson: when the agent reads on a small repo, don't conclude "adoption floor" — diff
what it read against what the tool returned immediately before. A read of content the tool already
gave = adoption; a read after the tool returned the wrong thing (stub endpoints, collided names) =
a fixable bug. The transcript reasoning, not the median, tells you which. The forced codegraph-only
hook (block Read+Grep+Glob+Bash-search) is the variance-free way to confirm sufficiency separately
from adoption.
4. The repeatable methodology (run this per language/framework)
Step 1 — Pick the framework's canonical *flow* question
Every framework has a signature data/control flow. Pick the "how does X reach/become Y"
question and a real repo (add to .claude/skills/agent-eval/corpus.json). Examples:
- React state→DOM, Vue reactive→render, Svelte store→update
- Rails request→controller→view, Spring request→
@Controller→service - Express/Koa request→middleware→handler, FastAPI request→route→dependency
- Redux action→reducer→store, RxJS subscribe→operator→observer
- Any ORM: query builder → SQL execution (django pattern)
Step 2 — Measure the hole (deterministic, no agent)
rm -rf <repo>/.codegraph && ( cd <repo> && codegraph init -i )
node scripts/agent-eval/probe-trace.mjs <repo> <from-symbol> <to-symbol> # does the flow break? where?
node scripts/agent-eval/probe-node.mjs <repo> # trail: is the next hop missing?A "No direct call path … breaks at dynamic dispatch" + a sparse trail at the break
point locates the hole (this is exactly how _iterable_class and triggerUpdate
were found). Confirm it's dynamic by reading the break symbol's body.
Step 3 — Classify → choose the mechanism (use the §2 table)
self.<attr>(...)/ descriptor / metaclass → resolver (§3a).for(cb of store)cb()/store.forEach(cb=>cb())→ field-observer synthesizer (§3b).on('e',fn)+emit('e')→ EventEmitter synthesizer (§3b).- Inline handler not a node → named: extraction (already done generically in
tree-sitter.ts); anonymous: synthesizer link-through-body (not yet built). - Dispatch that CAN'T be precision-gated as a class (runtime-keyed
table[key](...),getattr(self, expr), reflection, typed mediator buses,new Proxy) → boundary
surfacing (src/mcp/dynamic-boundaries.ts, #687): explore ANNOUNCES the dispatch
site where the static path ends — file:line, form, and candidate targets when the
key is statically visible — instead of synthesizing an edge. Query-time only, zero
graph mutation, fires only when the asked-about flow fails to connect. This is the
deliberate floor for the frontier: a wrong edge poisons the map (silent beats
wrong), but an honest "the flow continues at THIS site, likely into THESE
candidates" still saves the read-reconstruction spiral. When a boundary form later
proves precision-gateable on real repos (e.g. a same-repo literal-key command bus),
promote it to a synthesizer channel and the boundary note disappears on its own —
the flow then connects.
Step 4 — Implement
- Resolver: add to
src/resolution/frameworks/<lang>.ts— aresolve()branch +claimsReference(name)if the ref name isn't a declared symbol. CopydjangoResolver. - Synthesizer channel: extend
src/resolution/callback-synthesizer.ts— add the
framework's registrar/dispatcher name patterns and body patterns (e.g. signals
use.connect()/.emit(); Rx uses.subscribe()/.next()). - Reindex (Step 2 command) and re-run
probe-trace— the flow should now connect.
Step 5 — Validate (the same way every time)
- Deterministic:
probe-trace(from,to)finds the path;probe-nodeshows the
bridged hop. The previously-broken hop is closed. - Precision: count + spot-check synthesized/resolved edges — no explosion, correct targets:(Resolver edges aren'tbash
sqlite3 <repo>/.codegraph/codegraph.db \ "select s.name||' → '||t.name||' '||coalesce(e.metadata,'') from edges e \ join nodes s on e.source=s.id join nodes t on e.target=t.id where e.provenance='heuristic';"heuristic; verify via the trace + callees instead.) - Regression: node count stable (
select count(*) from nodes;before/after — a big
jump means an extraction change over-fired); existing traces on a control repo intact. - End-to-end agent eval: run the flow question with codegraph and measure
reads / answer-completeness / cost vs a pre-fix baseline:Then parse:bash# headless (exact cost + clean tool sequence) bash scripts/agent-eval/run-agent.sh <repo> with "<flow question>" # or the full A/B + interactive Explore-subagent path: scripts/agent-eval/audit.sh local <name> "<flow question>" allReadcount, codegraph-tool count, cost, and whether the answer now
contains the glue symbols (the ones that previously required a read).
Success criteria (per language/framework)
tracefinds the canonical flow end-to-end (no dynamic-dispatch break).- Agent can answer the flow question with Read 0 (achievable in ≥ some runs) and the
glue symbols appear in the answer. - No node explosion and no regression on a control repo.
- Synthesized edges are precise on a spot-check (no generic-name over-linking).
5. Validation toolkit (reference)
| Tool | Purpose |
|---|---|
scripts/agent-eval/probe-trace.mjs <repo> <from> <to> |
call-path between two symbols (the hole detector) |
scripts/agent-eval/probe-node.mjs <repo> <sym> [code] |
symbol + trail (callers/callees); code adds the body |
scripts/agent-eval/probe-context.mjs <repo> "<task>" |
context output incl. call-paths |
scripts/agent-eval/probe-explore.mjs <repo> "<query>" |
explore output |
scripts/agent-eval/{audit,run-agent,itrun}.sh |
agent A/B (headless + interactive); also the /agent-eval skill |
sqlite3 <repo>/.codegraph/codegraph.db |
direct edge/node inspection (provenance, metadata, counts) |
Probe scripts use the built dist/ — run npm run build first. Reindex after any
extraction or resolution change (rm -rf <repo>/.codegraph && codegraph init -i) — the
synthesizer/resolvers run at index time. Test fixtures: keep a tiny per-pattern fixture
(see /tmp/cb-fixture/bus.js; move into __tests__/ when shipping).
6. Coverage matrix (fill in as you go)
Status legend: ✅ done+validated · 🔬 hole identified · ⬜ not started.Mechanism: R = resolver, S = synthesizer channel, X = extraction.
| Language | Framework(s) | Canonical flow to test | Mechanism | Status |
|---|---|---|---|---|
| TypeScript/JS | React / observer / EventEmitter / React Router | state→render; dispatch→callback; route→component | S + X | ✅ rendering+dispatch (excalidraw); React Router JSX routing <Route path component={C}/> (v5) + element={<C/>} (v6) → component (react-realworld 0→10, 10/10). + object data-router createBrowserRouter([{path, element/Component}]) (literal form); Next.js config/nextjs-pages false-positives FIXED. 🔬 lazy data-router (path: paths.x.path, lazy: () => import() — variable paths + lazy modules) |
| TypeScript/JS | Vue / Nuxt | template events (@click→handler); component composition; reactive→render | S + X | ✅ events + composition (vitepress S / vben M / element-plus L); 🔬 reactive→render (vue-core Proxy runtime — frontier, deferred) |
| TypeScript/JS | Svelte / SvelteKit | template calls/composition; SvelteKit action→api; store→DOM | X | ✅ already strong (realworld S / skeleton M / shadcn L): template {fn()} calls, `` composition, import * as api namespace, load→api all work out of the box. + exported-const object-of-functions extraction (SvelteKit actions). 🔬 $lib-namespace-from-action + store/reactive frontier |
| TypeScript/JS | Express / Koa | request → route → handler → service | R + X | ✅ named handlers + middleware + controller/service (resolver) + inline arrow handlers → service body calls (realworld S 19 / parse M / ghost L 65 edges). 🔬 custom routers (payload had 0 routes — not app.get-style) |
| TypeScript/JS | NestJS | request → @Controller → DI service → repo | R | ✅ already well-covered (realworld S / immich M-L / amplication L): @decorator routes (HTTP/GraphQL/microservice/WS) via resolver + DI this.svc.method() controller→service resolves correctly at scale (name + co-location). No dynamic-dispatch hole. 🔬 committed dist/ build output gets indexed (realworld) — general build-dir-ignore follow-up |
| TypeScript/JS | RxJS / signals | subscribe → operator → observer | S | ⬜ |
| Python | Django ORM | QuerySet → SQL compiler | R | ✅ |
| Python | Django / DRF (views) | url → view → model | R + X | ✅ url→view (path/url/as_view) + DRF router.register→ViewSet (realworld S / wagtail M / saleor L); ORM QuerySet→SQL (prior work). 🔬 signals (post_save→receiver), DRF viewset CRUD actions (inherited), saleor GraphQL resolvers |
| Python | Flask / FastAPI | request → route → handler → dependency | R + X | ✅ Flask: handler resolved across intervening decorators (@login_required) + stacked @x.route lines (microblog S 6→27, redash L decorator routes 6/6); FastAPI: empty-path router-root routes @router.get("") incl. multi-line (realworld S 12→20 / Netflix dispatch L 290/290 100%) + bare-name builtin guard — a handler named after a Python builtin method (index/get/update/count…) was filtered as a builtin and lost its route→handler edge. + Flask-RESTful add_resource(Resource,'/x') → Resource class (redash 6→77) + tuple methods=('GET',) (was mislabeled GET) + broadened detection (requirements/Pipfile/setup + subdir app-factory entrypoints — flask-realworld 0→19). 🔬 FastAPI Depends() dependency edges (light validation) |
| Go | Gin / chi / gorilla/mux / net-http | request → route → handler → service; middleware chain (Use→Next) |
S + X | ✅ routes on ANY group var (v1.GET, PublicGroup.GET) not just r/router (gin-vue-admin S→M 4→259 / realworld S / gitness L) — was missing all group-routed apps; named handlers resolve precisely. gorilla/mux confirmed covered by the any-receiver HandleFunc/Handle handling (subrouter-var s.HandleFunc(...) + namespaced handlers; .Methods() chain ignored). + gin middleware-chain synthesizer (ginMiddlewareChainEdges): gin runs its entire chain through one dynamic line — (*Context).Next does c.handlers[c.index](c), a slice-index dispatch tree-sitter can't resolve, so callees(Next) dead-ended at the len() helper (safeInt8) and the agent rabbit-holed re-querying it. Find the dispatcher (a Go method invoking a handlers slice by index) and link it → every HandlerFunc registered via .Use/.GET/…/.Handle; gated on the dispatcher existing (inert on non-gin Go repos), named handlers only (closures skipped), capped. gin L: callees(Next) now surfaces Logger/Recovery/ErrorLogger+handlers (node count stable 2,544; 5 precise edges with registeredAt wiring sites). Agent A/B (headless median-of-4, Opus 4.8): gin flipped from codegraph −58% cost / −129% time (the rabbit-hole, incl. a stray Workflow mis-fire on 2/4 WITH runs) → +7% cost / +35% tokens / +8% time / 38% tool calls, all 4 WITH runs clean (0 Read/Grep/Bash, no Workflow, no duplicate calls). 🔬 inline func(c){} handlers (anonymous, body lost); subrouter/PathPrefix path-prefix not prepended (label only); gitness chi custom (26/321) |
| Go | GoFrame (standard router) | request-type g.Meta route → controller method (reflective group.Bind) |
R (extract) + S | ✅ GoFrame g.Meta route coverage (#747) — extractor (frameworks/goframe.ts, detect gogf/gf in go.mod) turns each g.Meta `path:.. method:..` request-type tag into a route node (requires path:, so a response mime:-only g.Meta is skipped), encoding the package-qualified request type in qualifiedName. goframeRouteEdges synthesizer joins route → the controller method whose signature takes that request type — NOT by name (DeptSearchReq is served by List, GetDictReq by GetDictData) — keyed pkg.Type to separate the dozens of identical bare names a big app defines one-per-module (cash.ListReq vs order.ListReq), with an addon-root tiebreak so a cloned demo addon (addons/hgexample/) binds within itself and never cross-links to core. Validated: gf-demo-user S 7/7, gfast M 65/68 (3 genuinely handler-less DbInit), hotgo L (697 files) 242/247 (98%), 100% precision (0 non-controller handlers, 0 core/addon cross-binding); node count stable on re-index; surfaces in the handler's caller trail (POST /dept/add → Add via goframe-route). Agent A/B (gfast M, sonnet/high, 2 runs/arm): WITH = 1 codegraph_explore / 0 Read / 0 Grep / ~20s / correct; WITHOUT = 7.5 Read avg + grep-hunting for the non-existent literal /dept/add string + re-reading sys_dept.go up to 12× / ~42s — reads eliminated, −83% tool calls, 2.1× faster, cost a wash; both arms reach the same correct call path. 🔬 group prefix from reflective Bind not prepended (route shows the g.Meta path, not /system/dept/list); the 4×-cloned index sub-packages inside one addon are left unlinked (needs import-path resolution, not just package name) |
| Rust | Axum / actix / Rocket | request → route → handler | R + X | ✅ Axum chained methods + namespaced handlers — .route("/x", get(h1).post(h2)) emitted only the first method+handler, and get(mod::handler) captured the module not the fn (realworld-axum S 12→19, 19/19); balanced-paren scan + per-method nodes + last-::-segment handler. Rocket attribute macros 550/556 (99%) (Rocket repo L) — already strong. crates.io named axum routes resolve (6/8; rest are closures/var handlers; its API is mostly the utoipa routes! macro = frontier). Cargo-workspace module resolution (prior work). actix builder API web::resource("/x").route(web::get().to(h)) / .to(h) / App .route("/x", web::get().to(h)) (actix-examples 51→128 routes, 35→112 resolved) — was the dominant actix style and fully missed (the handler is in .to(h), not get(h)). 🔬 actix web::scope("/api") prefix (not prepended to nested resource paths) + anonymous .to closure handlers |
| Java | Spring | request → @RestController → @Autowired service → repo | R + X | ✅ bare @GetMapping/@PostMapping + class @RequestMapping prefix join → route→method (realworld S / mall M / halo L) — was missing all path-less method mappings; DI controller→service resolves (name + dir) + interface→impl dispatch synthesizer (interfaceOverrideEdges: a class's implements/extends → link each interface/base method → its same-name override; JVM-gated, capped, overload-aware; mall 310 / halo 734 synth edges, node count unchanged) so trace follows controller→service-interface→impl instead of dead-ending at the abstract method — trace("PmsProductController.getList","PmsProductServiceImpl.list") connects in 3 hops (probe-validated). + field-injected concrete-bean trace (#389): this.<field>.method() strips the this. receiver at extraction, and the resolver looks up the receiver name in the enclosing class's field declarations to get the declared type, then resolves the method on it — closes the controller→bean hop when the field-name doesn't capitalize to the type (@Resource(name="userBO") UserBO userbo → userbo.toLogin2() reaches UserBO.toLogin2). + @Value("${k}") / @ConfigurationProperties(prefix="X") → application.{yml,yaml,properties} binding with Spring's relaxed binding (kebab↔camel↔snake), incl. ${k:default}. mall-tiny S: 11/11 @Value resolved. ⚠️ agent A/B null (n=2: the agent went context→explore→Read and never invoked trace, so the synth edges weren't exercised — adoption-gated, the recurring wall; see docs/benchmarks/call-sequence-analysis.md). The fix is correct + improves trace/callees/impact/context connectivity regardless; agent-visible read reduction needs trace adoption. 🔬 Spring Data JPA derived queries (findByEmail) — metaprogramming frontier; @PropertySource external files; Spring Cloud Config; mapper-class simple-name collisions across packages (dropped to avoid mis-resolution) |
| Java | MyBatis (XML mappers) | DAO interface method → <select|insert|update|delete id="X"> SQL |
R (XML extract) + S (Java↔XML synthesizer) | ✅ XML mapper as first-class language (#389) — src/extraction/mybatis-extractor.ts parses files containing <mapper namespace="...">; emits one method-shaped node per statement qualified <namespace>:: + <sql id="X"> fragments + references. Non-mapper XML (pom, log4j) → file node only. `mybatisJavaXmlEdges` synthesizer indexes Java methods by `<ClassName>::<methodName>` and joins to XML qualified names by suffix-match — ambiguous simple-name collisions dropped (precision over recall). mall-tiny S **6/6 custom-SQL mapper methods bridge** to their XML statements; full enterprise chain `trace(controller.action → mapper.method-xml)` connects across controller / service-iface / impl / mapper / XML. 🔬 cross-mapper via unqualified refid; MyBatis Plus dynamic methods (BaseMapper<T> CRUD inherited from framework, not in project); annotation-driven mappers (@Select("SELECT ...") on Java methods — the SQL lives in the annotation, not XML) |
| Kotlin | Spring Boot / Jetpack Compose | request → @RestController → service; @Composable → child | R + X | ✅ Spring Boot Kotlin — the Spring resolver was ['java']-only with a Java-syntax method regex (public X name()); extended to .kt + Kotlin fun name( handler matching (petclinic-kotlin 0→18, 18/18; class-prefix joins; DI controller→repo resolves — showOwner ← GET /owners/{ownerId} → OwnerRepository.findById). Compose composition already static (@Composable→child are plain function calls — Jetcaster PodcastInformation→HtmlTextContainer). Java Spring unchanged (realworld 19/19). 🔬 Ktor routing { get("/x"){…} } lambda handlers (anonymous) + Compose recomposition (implicit mutableStateOf, no setState gate) + coroutines/Flow |
| Swift | Vapor | request → route → controller | R + X | ✅ was 0 routes on every real app — the extractor required an app/router/routes receiver + a "path" literal, but real Vapor routes on grouped builders (let todos = routes.grouped("todos"); todos.get(use: index)) with NO path arg. Rewrote: any receiver, optional/non-string path segments, .grouped/.group{} prefix tracking, use: discriminator. vapor-template S 0→3 (3/3, nested /todos/:todoID), SteamPress M 0→27 (27/27), SwiftPackageIndex-Server L 0→14 (14/14 handler resolution). 🔬 typed-route enums (SPI SiteURL.x.pathComponents — path label only, handler still resolves) + closure handlers app.get("x"){ } (anonymous) |
| Swift | Alamofire / closure-collection | request → build → send → validate (deferred closures) | S | ✅ closure-collection dispatch synthesizer (closureCollectionEdges): the Swift deferred-handler pattern DataRequest.validate validators.write{$0.append(v)} … base Request.didCompleteTask validators.forEach{$0()} (append + dispatch in different files/classes, field is Protected<[() -> Void]>). The element-invoke $0(/it( is the precision gate → 9 edges on Alamofire (validators/streams/finishHandlers/requestsToRetry), 0 on every non-closure-collection control. Surfaced inline in trace + as an explore "Dynamic-dispatch links" section (so it shows when the agent named only validate, not the didCompleteTask that drains the list). Forced codegraph-only: 3/3 build/send/validate correct. + trace endpoint relevance (nodeRelevance): overloaded request/task (44/8 defs, mostly empty EventMonitor delegate stubs) now resolve to the real Session.request, not a 1-line no-op — WITH-arm tool calls 12→8 median, read variance 0–12→1–4 (the meltdowns were all the trace-collision flounder); control-safe (excalidraw/okhttp/gin traces intact, gin A/B 0 reads). + god-file multi-phase rendering (handleExplore): a flow whose necessary code spans a god-file (Session.swift build chain ~11K) PLUS other files (validate logic) used to truncate at the fixed maxOutputChars and drop whichever phase came last. Six coordinated layers make it render all phases: (1) on-spine god-files render spine-full + off-path methods as signatures (true-spine), (2) every NAMED token's substantive def is seeded into the subgraph (FTS buried validate under the build terms → Validation.swift was never gathered), (3) a file that DEFINES a named symbol outranks one that merely references the flow (Validation=50 > incidental Combine=23), (4) the 90%-budget early-break and (5) the total cap both exempt necessary (named/spine) files — incidental files stay capped, (6) the final ceiling is 1.5× so it doesn't slice the necessary content the loop assembled. Alamofire now renders build+validators-exec+validate in ONE explore (~16K); A/B reads med 2→0.5, tools 8→5.5; excalidraw control held at 0 reads (no bloat). Sequential-flow spine is irreducible (no redundant siblings to collapse) — the fix is to render it, not cap it. |
| C# | ASP.NET Core | request → [Http*] action → DI service → EF | X | ✅ feature-folder detection (realworld 0→19 — was undetected) + bare [HttpGet] + class [Route] prefix (eShopOnWeb 9→33 / jellyfin L) — co-located so no claimsReference needed. 🔬 EF Core LINQ/DbSet (metaprogramming frontier) |
| Ruby | Rails / Sinatra | request → routes.rb → Controller#action → model | R | ✅ RESTful resources/resource routing → controller#action (realworld S 16 / spree M / forem L), pluralization + only/except + claimsReference; explicit routes fixed to precise controller#action too. 🔬 ActiveRecord dynamic finders (Article.find_by_slug) — metaprogramming frontier |
| PHP | Laravel | request → route → controller → Eloquent | R | ✅ precise Route::get([Ctrl::class,'m']) / 'Ctrl@m' → Ctrl@method (realworld S / firefly M / bookstack L) — was resolving the bare method name to the WRONG controller (every index→ArticleController); Route::resource→controller. 🔬 Eloquent dynamic finders/relationships (metaprogramming frontier) |
| PHP | Drupal | request → *.routing.yml → _controller/_form | R | ✅ claimsReference for FQCN handlers (\Drupal\…\Class::method passed the pre-filter only because the ::method name was known; bare _form FQCNs \…\FormClass and single-colon Class:method controller-services were dropped before resolve()) + single-colon controller match + detect via composer type:drupal-* / name:drupal/* + *.info.yml fallback (a contrib module with empty require was undetected → 0 routes). admin_toolbar S 0→14 (14/14) / webform M 208 (144) / core L 836 (536→731, 87%). Remainder is the entity-annotation handler frontier (_entity_form: type.op resolves via the entity's PHP #[ContentEntityType] handlers, not a direct class). 🔬 OOP #[Hook] attributes — Drupal 11 moved ~all procedural hooks to attribute methods (core: 418 #[Hook] files vs 3 procedural), so the resolver's docblock/module_hook detection is obsolete for modern core (0 hook edges) |
| C/C++ | C++ vtables / inheritance | virtual call → override; general direct dispatch | S + X | ✅ general dispatch strong (redis C 29k cross-file calls / leveldb C++ 1.4k) + C++ inheritance extraction fix (base_class_clause was unhandled, so C++ extends edges were missing — leveldb 219→298) + cpp-override synthesizer (base virtual method → subclass override, gated to C++, capped — leveldb 12 precise: Iterator::Next→MergingIterator). 🔬 C callback structs (s->fn() → 422-way fan-out, too noisy to synthesize) + C++ pure-virtual base methods (virtual void f()=0; declarations aren't extracted as nodes, so those overrides can't bridge) |
| Dart | Flutter | setState → build; build → child widgets | S + X | ✅ setState→build synthesizer (Dart analog of react-render: a State method whose body calls setState( → build) gated to .dart + foundational Dart method-range fix — Dart models a method body as a sibling of the signature, so method nodes were signature-only (end==start); now endLine spans the body (required for ALL body analysis: callees, context slices, the synthesizer's body scan). counter initState→build, books build→BookDetail/BookForm; widget composition already static (compass_app build→ErrorIndicator/HomeButton). Controls unchanged (excalidraw 9,290 / django 302 — the range fix only extends sibling-body grammars). 🔬 MVVM Command/ChangeNotifier dispatch (compass_app — no setState) + Navigator.push(MaterialPageRoute(builder:)) nav routes |
| Lua / Luau | Neovim / Roblox | module dispatch (require→mod, mod.fn); event/callback | — | ✅ already covered for the dominant flow (measure-first, no code change) — Neovim is module-heavy (require('x') + x.fn()), and the general import + name resolution already handles it: telescope.nvim 220 imports + 335 cross-file mod.fn calls, traces end-to-end (map_entries ← init.lua → get_current_picker (state.lua)). Luau instance-path require(game:GetService(...)) handled by the extractor. 🔬 event-callback registration (vim.keymap.set(…, fn), autocmd callback=, Roblox signal:Connect(fn)) is predominantly INLINE anonymous closures (corpus ~12 inline vs ~2 named) — the anonymous-handler frontier; named handlers too rare to justify a synthesizer |
| Erlang | OTP behaviours | request → behaviour dispatch (Var:callback(...) folds) → implementer callback |
S | ✅ behaviour-callback dispatch synthesizer (erlangBehaviourDispatchEdges) — a behaviour declares -callback fn/N, implementers declare -behaviour(B), and the framework dispatches through a VARIABLE module (Handler:init, Middleware:execute folds), a hop extraction deliberately leaves silent. Bridge: each Var:fn(args) site → every implementer of the ONE in-repo behaviour declaring (fn, site-arity) that defines+exports fn; a name+arity collision across behaviours bails (cowboy's init/2 is declared by FIVE handler-flavored behaviours → correctly silent), and above the fan-out cap (24) the site is skipped entirely (ejabberd's gen_mod, ~230 mod_* implementers, stays a visibly dynamic boundary rather than 24 arbitrary edges). Behaviour discovery scans -callback decls in every module (not just implements targets) so implementer-less behaviours still gate ambiguity. Validated: cowboy S — 38 edges, all real contracts (middleware chain cowboy_stream_h::execute → cowboy_router/cowboy_handler::execute, stream-handler init/data/early_error folds → all 5 core + 2 test handlers, sub-protocol upgrade, websocket_init); ejabberd M — 598 edges (listener/auth/pubsub/MIX backends, max per-site fan-out 9); emqx L — 843 edges (gateway codec/channel families, max fan-out 20); precision spot-check 36/36 (every sampled target declares the via-behaviour + exports the callback); node counts unchanged; erl-sample 0-control clean (dispatch with no valid implementer → no edge); index cost +~1.4s on emqx's 2,273 files. The cowboy request flow now connects END-TO-END in one explore: cowboy_stream:init → [erlang behaviour] cowboy_stream_h:init → request_process → execute → [erlang behaviour] cowboy_handler:execute. 🔬 gen_server registered-name cross-module targets (atom == module-name convention); the terminal Handler:init hop where multiple sub-protocol behaviours share the contract (genuinely ambiguous — the dispatch site's body is the answer) |
| Scala | Play / Akka | request → conf/routes → controller action | R + X | ✅ Play conf/routes → controller — the extensionless conf/routes wasn't indexed; added narrow file-walk opt-in (isPlayRoutesFile) + a Play resolver parsing METHOD /path Controller.action(args) → the action method (computer-database 0→8, 7/8; starter 0→4, 3/4 — the unresolved are Play's framework Assets controller, external). Scala general controller→DAO dispatch already resolves. No-regression: the file-walk change only ADDS Play routes files (excalidraw 9,290 / suite 800 unchanged). 🔬 SIRD programmatic router (-> /v1 Router include + case GET(p"/x") in code) + Akka actor receive/Behaviors.receiveMessage message→handler |
| Swift × Objective-C | mixed iOS apps | Swift obj.foo(bar:) → ObjC -fooWithBar:; ObjC [obj fooWithBar:] → Swift @objc func foo(bar:) |
R | ✅ Swift↔ObjC cross-language bridge — frameworks/swift-objc.ts implements Apple's @objc auto-bridging name math (incl. init forms initWith<First>:, property getter+setter pairs, @objc(custom:) override) and the reverse direction strips Cocoa preposition prefixes (With/For/By/In/On/At/From/To/Of/As) to derive Swift base-name candidates. Validated on Charts S 28/1 obj→swift / swift→objc, realm-swift M 36/1185, wikipedia-ios L 52/983. Genericname blocklist (init, description, count, …) keeps precision. Confidence 0.6 (name-match's 1.0 wins ties) — bridge only fires when name-match has no result. 🔬 Swift generics over ObjC protocols, Swift extensions on ObjC classes (silently miss; matches Java/Kotlin generics frontier) |
| JS × native | React Native legacy bridge | JS NativeModules.X.fn(...) → ObjC RCT_EXPORT_METHOD / Java/Kotlin @ReactMethod |
R | ✅ RN legacy bridge — frameworks/react-native.ts parses RCT_EXPORT_MODULE (default-name from RCT-prefix-stripped class name) + RCT_EXPORT_METHOD(selector:(...)) + RCT_REMAP_METHOD(jsName, selector) on the ObjC side and @ReactMethod + getName() literal on Java/Kotlin. AsyncStorage S 8/8 precise (setItem→legacy_multiSet, etc.), react-native-firebase L 18 precise after RCTEventEmitter built-in blocklist (initial 78 included 60 addListener:/remove: false positives — every emitter subclass declares those via RCT_EXPORT_METHOD, JS callers route through the NativeEventEmitter abstraction not the native method directly). 🔬 dynamic bridge keys (NativeModules[someVar]) — literal-key only |
| JS × native | React Native TurboModules | JS spec interface ↔ native impl | R (spec as ground truth) | ✅ partial — parses TurboModuleRegistry.get*<Spec>('Name') + the Spec interface methods. Each spec method matches to a native impl by selector first-keyword (ObjC) / identifier (JVM). react-native-svg S 9 precise (getTotalLength, getPointAtLength, getCTM, isPointInFill, …) bridging to Java impls (the iOS side is Codegen-auto-generated without RCT_EXPORT_METHOD declarations). 🔬 TurboModule native impl classes that don't use legacy macros (RNSvg iOS — would need inheritance-aware bridging via the Codegen-generated NativeFooSpec superclass) |
| ObjC/Java/Kotlin → JS | React Native event emitters | native sendEventWithName:/emit(...) → JS addListener('e', handler) |
S (cross-lang channel) | ✅ rn-event-channel synthesizer — matches ObjC sendEventWithName:@"X", Swift sendEvent(withName: "X", ...), and JVM .emit("X", ...) to JS addListener('X', handler) keyed by literal event name. Same fan-out cap (EVENT_FANOUT_CAP=6) as in-language channel. Subscribe-wrapper fallback for RN-library APIs (const Foo = { watchX(listener) { addListener('e', listener) } }) — when the handler arg is a parameter, falls back to the enclosing function and then the enclosing constant/variable (reachability-correct attribution to the JS API surface). RNFirebase L 3 push-notification flow edges (UIApplicationDelegate → JS onMessage/onNotificationOpenedApp), RNGeolocation S 2 location-event edges (Swift onLocationChange/onLocationError → JS Geolocation). 🔬 inline arrow handlers addListener('e', d => …) (anonymous frontier) |
| JS × Swift/Kotlin | Expo Modules | JS requireNativeModule('X').fn(...) → Swift/Kotlin Function("fn") { ... } |
R (extract → synthetic method nodes) | ✅ expo-modules framework extractor — parses Swift/Kotlin Module { Name("X"); Function("y") { ... }; AsyncFunction("z") { ... }; Property("w") { ... } } literals and synthesizes method nodes named after each declaration. JS callsites resolve via existing name-matcher (no separate resolve() needed). expo-haptics S 6 method nodes (notificationAsync, impactAsync, selectionAsync × Swift + Kotlin), expo-camera M 41 (full SDK surface incl. takePictureAsync, record, scanFromURLAsync, view props width/height), expo SDK sweep L 134 (7 packages, 72 Swift + 62 Kotlin). Same-name JS wrappers in the package itself shadow the native names (CameraView.tsx's pausePreview wraps native pausePreview); external consumer apps bridge through to native directly. 🔬 closure body extraction (the Function trailing closure isn't a body-range node yet) |
| JS × native | React Native Fabric / Codegen + legacy Paper view components | JSX <MyView prop={v}/> → Codegen spec → native class (or Paper RCT_EXPORT_VIEW_PROPERTY / @ReactProp) |
R (extract) + S (native-impl) + JSX | ✅ fabric-view extractor + fabric-native-impl synthesizer — extractor parses both modern Codegen TS specs (codegenNativeComponent<NativeProps>('Name', ...)) and legacy Paper view-manager macros (RCT_EXPORT_VIEW_PROPERTY on ObjC, @ReactProp on Java/Kotlin). Emits a component node per declaration + a property node per declared prop. Synthesizer links the component to its native impl class by RN's convention-based name+suffix (exact/View/ComponentView/Manager/ViewManager). Combined with reactJsxChildEdges, full consumer flow: JSX <MyView/> → fabric component → native class. Validated on RNSegmentedControl S (legacy Paper) 1 component + 11 props + 4 bridges, RNScreens M (pure Codegen) 27 components + 272 props + 68 bridges (was 0 before Phase 6), RNSkia L (hybrid + monorepo) 5 + 14 + 15 across Codegen TS + Android Java + iOS ObjC. Monorepo detect added: probes packages/<sub>/package.json etc. via listDirectories when the root manifest is a workspace declaration (was the gating bug on RNSkia). 🔬 Fabric event-handler props (onTap={cb}) — JSX attribute extraction needed |
(Verify the exact supported set against src/extraction/languages/ andsrc/resolution/frameworks/ before starting — this table is a starting point.)
| TypeScript/JS | Next.js (App Router + Pages Router) | page → <Link> / router.push / redirect → page; page load → data; client component → 'use server' action → DB → redirect; route.ts handler → DB → response | R + S | ✅ 2026-08-28 (frameworks/nextjs.ts, next-router-synthesizer.ts, Steps load trigger + server-action crossing): fixture __tests__/nextjs.test.ts end to end (Screens routed, the push attributed back under its condition, the action's redirect); leerob/next-saas-starter indexes its pages and links. 🔬 agent A/B (--model sonnet, ≥2 runs/arm) not run yet |
| TypeScript/JS | Express + React (MERN monorepo) | client axios / fetch literal path → own route → handler → Mongoose → response rows | S + R | ✅ 2026-08-28 (tier-synthesizer.ts http-client + Express mounts / chained router.route() / wrapped const h = asyncHandler(…) handlers / nested package.json detection): bradtraversy/proshop_mern 49 routes (19 pages + 30 endpoints), 23 client→route edges, every one spot-checked correct; login → ⇢ POST /api/users/login → User.findOne → 401 rows → jwt.sign. Node count stable across re-index. Agent A/B (2026-08-28, run-all.sh, Sonnet/high, 2 runs/arm, warm daemon, CLI shim on — 0 CLI leaks): "How does submitting the login form reach the database, and what does the API respond with when the password is wrong?" — with: 14s / 14s, 2 tool calls each, 0 Read, 0 Grep, 1 and 2 explores (explore sufficiency: answered after 1 call in run 1, after 2 in run 2), answer names submitHandler → login → POST /api/users/login → authUser → User.findOne → matchPassword → 401 end to end; without: 18s / 37s, 14 and 8 tool calls, 7 and 1 Read (+7 Bash cats in run 2, a subagent in run 1). Tokens processed 153k/165k vs 237k/369k. Pass bar met on the small repo (0 Read/Grep, faster, within 1–2 explores against a budget of 1). 🔬 medium/large (Ghost) and ≥3 prompts still to run |
| TypeScript/JS | NestJS queues / events / sockets | queue.add('job') → @Process('job'); emit('x') → @OnEvent('x'); socket.emit → @SubscribeMessage and server.emit → socket.on | S | ✅ 2026-08-28 (tier-synthesizer.ts queue-job, event-bus): nestjs/nest sample/26-queues (transcode → handleTranscode) and sample/30-event-emitter (order.created → its listener) exact, 0 wrong edges after the e2e/ and generic-event guards; immich-app/immich 0 edges — a generated SDK client and a wrapped queue API carry no literal, so silence (correct). 🔬 A/B not run |
| Python | FastAPI (prefixed routers) | request → Depends → handler → session → HTTPException rows | R | ✅ 2026-08-28 python.ts postExtract composes APIRouter(prefix=) + literal include_router(prefix=): fastapi/full-stack-fastapi-template 23 routes named GET /items/{id}, POST /login/access-token (were GET /); a computed mount prefix is skipped. 🔬 A/B not run |
| C# | ASP.NET Minimal API endpoint groups | request → group handler → ISender.Send → TypedResults rows | R | ✅ 2026-08-28 csharp.ts handler-first MapPost(Handler[, path]) under the class + $"/api/{groupName}" head (jasontaylordev/CleanArchitecture shape); fixture end to end in ui-steps-api-servers.test.ts (PUT /api/TodoItems/{id} → 204 · 400 rows); jasontaylordev/CleanArchitecture 10 routes, all the app's endpoints (were 0). 🔬 A/B not run |
Retrieval A/Bs that are not coverage work
Coverage decides whether a flow exists in the graph. A second class of change decides
whether the answer explore returns is sufficient — how the byte envelope is divided across
the files it found. Same pass bar (Read → 0, no wall-clock regression), same --model sonnet --effort high rule, but the harness is ab-new-vs-baseline.sh (new build vs baseline build,
both codegraph-on) rather than run-all.sh's with-vs-without, because the question is
whether a change to codegraph helped, not whether codegraph helps.
| Change | Repos | Result |
|---|---|---|
Score-proportional byte allocation (#1500, epic CG-1) — 2026-08-04, feature/CG-1 vs main, 3 runs/arm |
client-go (Go, 2,454 f, 2,001 generated — the reporter's shape), excalidraw (TS, 672 f), express (JS, 147 f, control) | Gate FAILED. Read 0/0/0 both arms on client-go and excalidraw; excalidraw 34s → 24s median with one fewer explore call; generated clientsets/informers drop from 10.5% of a baseline envelope to 0% in every new run. But express regressed in 1 of 3 runs (4 Reads, 52s) from a reproducible non-agent cause: a file whose proportional reservation lands below its own size no longer renders whole and its cluster render leaves the reservation unspent (lib/utils.js 6,380 B whole → 583 B stub, envelope 13.8K → 9.2K). Full record: docs/benchmarks/explore-allocation-ab-1500.md |
↳ re-run after CG-21 — 2026-08-04, feature/CG-1 @ fca7d87 vs the same main, 6 runs/arm on express + excalidraw, 3 on client-go |
same three repos | Gate PASSES, all four bars. Read = 0 in all 15 new-arm runs — the express regression does not reproduce in 6 attempts, and the baseline now reads in 4 of 6 while the new arm reads in none; express median 24.5s → 21.5s. client-go answer share 92.7–96.2% vs a baseline run at 53.8%. Excalidraw's new arm is ~8s slower at the median, not attributed to the build: explore's own latency is 374 ms vs 372 ms (n=5), deterministic responses differ by +2% with one byte-identical, and the unchanged main build's own median moved 34s → 26.5s between the two sessions. Deterministic core: lib/utils.js 583 B stub → 6,268 B whole, envelope 9.2K → 14.5K on an unchanged budget |
↳ the gate (CG-22) — 2026-08-04, feature/CG-1 @ abee46c vs main @ 49c11fc pinned by SHA, CG-15's exact setup: RUNS=3, fresh clones, 3 runs/arm (6 on client-go, two pooled batches) |
same three repos (excalidraw now 677 f; same tier) | Gate PASSES, all four bars — re-measured independently of the task that wrote the fix. Read = 0 in all 12 new-arm runs, while the baseline reads in 3 of 3 express runs and 1 of 6 client-go runs. Express median 26s → 24s, lib/utils.js delivered whole in every new run (6,455–7,673 B) and never opened. client-go answer share 89.8–97.8% (baseline 85.6–100% — this session's baseline sampled well, so the gap is smaller than CG-21's); generated clientsets/informers take 0% in all 12 runs. One honest counter-point: excalidraw's new arm runs below its baseline on answer share (66.6–81.9 vs 75.5–92.7), still far above the 50% bar. client-go's +1.5s median at n=6 is not the build — explore's own latency is 669 vs 668 ms and the new build's deterministic response is smaller (15.8K vs 18.9K) and more concentrated (50.2% vs 35.7% on the top file). Deterministic core, both builds re-measured in-session: lib/utils.js 6,380 B whole on both, envelope 13,849 → 14,913 on an unchanged 13,000 budget |
Two harness lessons from that run, both now baked into ab-new-vs-baseline.sh:
- Name codegraph in the prompt for this class of A/B. Whether the agent picks the tool at
all is an adoption axis a retrieval change does not touch; a run that never calls explore
measures nothing about how explore divides its bytes (one pre-run: 0 codegraph calls, 3
Reads). It is not a forced-Read-0 — the agent stays free to fall back, which is the bar. CODEGRAPH_NO_PROMPT_HOOK=1on both arms. The machine's ambient front-load hook resolves
to whatever is indist/, which the script itself rewrites between arms.
7. Known limits & gotchas (from the excalidraw/django work)
- Coverage enables, doesn't force, the no-read path. Agents still read to confirm
source sometimes; cost stays ~flat (codegraph calls trade for reads). The reliable
win is completeness + making Read-0 possible. Don't expect a guaranteed cost drop. - Vue (validated 2026-05-23, vitepress S / vben M / element-plus L). SFC
<template>
is unparsed by the extractor, so template usage needs synthesis (vueTemplateEdges):@click="fn"→ handler, kebab<el-button>→ElButton. PascalCase<Child/>is
already covered by the JSX channel (the SFC component node spans the template). Result:
agent reads drop in every size (vben login 1–3 vs 4–11), strongest where handlers are
local functions (vbenhandleLogin/handleSubmit).
Composable-destructure handlers RESOLVED:@click="closeSidebar"whereconst { close: closeSidebar } = useSidebarControl()now follows alias → composable →
the returnedclosefn (when it's defined in the composable's file). vitepress sidebar
flow dropped 6 → 0 reads (best case). Precise-only — no fallback to the composable
itself (the staticuseX()call edge already covers that), so it adds nothing where the
returned fn can't be located (e.g. re-exported / external composable). Remaining limits:
prefix-convention kebab — element-plusel-button→button.vue(component namedbutton, notElButton), so kebab stays unresolved there; and reactive→render
(vue-core Proxy runtime) — the deep framework-internal frontier, deferred. - Svelte / SvelteKit (validated 2026-05-23, realworld S / skeleton M / shadcn L) — already well-covered.
Unlike Vue, the.svelteextractor already parses the template:extractTemplateCalls({fn()}),extractTemplateComponents(`` composition — skeleton 956 / shadcn 1610 reference edges),
plusimport * as apinamespace +load→api resolution all work. Agent A/B (realworld login): with
codegraph 1 read vs without 4 — codegraph already wins out of the box. The one extraction gap
was object-of-functions (export const actions = { default: async () => {} }; the walker
deliberately skips object-literal functions to avoid inline-object noise). Fixed for EXPORTED consts
(general — Redux/Express handler maps too);extractFunctionnameOverridekeeps inline-object arrows
skipped. Residual: a$lib-alias namespace call (api.post) from an extracted action node doesn't
resolve even though the same alias resolves forload— a deeper resolver interaction, deferred
(local/relative calls from actions connect). Lesson: measure before assuming a hole — modern Svelte
barely useson:click={fn}(form actions / callback props instead), so the assumed event-handler hole
wasn't the real one; Svelte needed far less than Vue. - Express / Koa (validated 2026-05-23, realworld S / parse M / ghost L) — high-value inline-handler fix.
The resolver already handled named handlers, middleware, andXController.method/XService.method.
The real hole was inline arrow route handlers (router.post('/x', async (req,res) => {...})— the
dominant modern pattern): the handler regex[^)]+broke on the arrow's), so the route connected to
NOTHING and the anonymous handler's body (the request→service flow) was lost. The entire inline-handler
API was unreachable (realworldPOST /users/login→ 0 edges). Fixed (frameworks/express.ts): span the
call with a string-aware balanced scan; for inline arrows, extract the body's calls (RESERVED-filtered to
drop res/req/builtins) and attribute them to the route node → realworld 19 / ghost 65 precise
route→service edges (POST /users/login→login, POST /articles→createArticle, …), no node explosion,
framework-scoped (zero blast radius off Express). Deterministic win is clear; the agent A/B is muddied
by repo characteristics — realworld (39 files) is below the size where codegraph beats reading, and
Ghost's layered custom-API architecture makes both arms thrash. Residual: custom routers — payload's
6.4k-file codebase had 0 routes (its router abstraction isn'tapp.get-style, so undetected). Lesson
inverse of Svelte: Express's dominant pattern WAS the uncovered one, so it needed real work like Vue. - NestJS (validated 2026-05-23, realworld S / immich M-L / amplication L) — already well-covered. The
nestjsresolver handles @decorator routes (HTTP/GraphQL/microservice/WS). DI controller→service
(this.svc.method()) resolves correctly even at scale — every immich controller→service edge hit the
right same-module service (addUsersToAlbum→addUsers,getMyApiKey→getMine,copyAsset→copy) via
name + co-location, no type_of edge needed. Agent A/B (immich album flow): codegraph eliminated Grep
(0 vs 3) tracing route→controller→service. No dynamic-dispatch hole. One GENERAL hygiene gap surfaced
(not NestJS-specific): the realworld example commits itsdist/build output, which codegraph indexes
(246 dup nodes) because the file walk only respects.gitignorewith no default build-dir ignore. Real
apps (immich/amplication) gitignoredist/(0 dup nodes), so it's narrow — a default ignore fordist/build/out/.next/coverageis a clean follow-up, deferred (core-indexer change, the user's call). - Rails (validated 2026-05-23, realworld S / spree M / forem L) — high-value RESTful-routing fix. The
railsresolver only saw explicitget '/x' => 'c#a'routes, so resource-routed apps (the dominant
pattern) had ZERO route nodes (realworld + spree). Fixed (frameworks/ruby.ts): expandresources :x/resource :xinto their RESTful actions (only/except filters + pluralization for the singularresource),
reference a precisecontroller#action, and resolve that to the action method in<ctrl>_controller.rb
(explicit routes fixed too — they referenced a bare ambiguousaction). realworld 0→16, forem
0→635 precise route→action edges. Agent A/B (forem comment-creation, large): codegraph 1–4 reads /
0 grep / 47–53s vs without 4–5 reads / 2–3 grep / 66–85s — fewer reads, no grep, faster. TheclaimsReferencepre-filter was the gotcha:articles#indexnames no declared symbol, soresolveOne
dropped it beforeresolve()ran — needed the same claim hook as the django ORM work. Residuals: Rails
Engine routing (spree still 0 — it mounts an engine, notconfig/routes.rbresources); ActiveRecord
dynamic finders (Article.find_by_slug— metaprogramming frontier). - Spring/MyBatis enterprise flow (validated 2026-05-26, mall-tiny S — closes #389). Three holes that left
the canonical enterprise-Java chain (HTTP route → Controller → BO/Service → ServiceImpl → DAO/Mapper → MyBatis XML SQL) broken at multiple hops on real Spring projects.- Field-injected concrete-bean trace. Java's
this.userbo.toLogin2()parsed asmethod_invocation( object=field_access(this, userbo)). The extractor surfacedthis.userbo.toLogin2verbatim and the
name-matcher's single-dot regex couldn't unwrap it; even if it had,userbodoesn't capitalize cleanly
toUserBO(the JVM naming heuristic inmatchMethodCall.Strategy2) so the receiver-typed lookup also
missed. Fix is in the language layer, not Spring-specific: (a) extractor unwrapsfield_access(this, X)
to useXas the receiver (src/extraction/tree-sitter.ts); (b)matchMethodCalllearns to look up
the receiver name as a field declaration in the enclosing class and use the field'ssignature-stored
declared type (inferJavaFieldReceiverTypeinsrc/resolution/name-matcher.ts). Repro confirmed on the
issue's exact example:UserAction.toLogin2 → UserBO.toLogin2edge appeared (was 0 outgoing edges). - MyBatis XML mapper indexing + Java↔XML bridge.
*.xmlis now a language (xml), with a custom
extractor (src/extraction/mybatis-extractor.ts) that emits one method-shaped node per<select|insert| update|delete|sql id="X">qualified as<namespace>::, plus `` →<sql>
fragment refs. Non-mapper XML (pom, log4j, web.xml) emits only a file node — no symbol noise. A new
synthesizer (mybatisJavaXmlEdgesincallback-synthesizer.ts) indexes Java methods by<ClassName>::<methodName>and joins them to the XML qualified names by suffix-match. Ambiguous
simple-name collisions are dropped (precision over recall). mall-tiny: 6/6 custom-SQL mapper methods
bridge to their<select>statements; full chaintrace(UmsRoleController.listResource → UmsResource Mapper::getResourceListByRoleId(xml))connects in 4 hops across controller/service/impl/mapper/XML. - Spring config-key linkage.
application.{yml,yaml,properties}+ profile variants
(application-dev.yml,bootstrap.yml, etc.) parse on the framework path. Leaf YAML keys + every.propertiesline becomeconstantnodes qualified by their dotted path.@Value("${k}")/@Value("${k:default}")and@ConfigurationProperties(prefix="X")emit binding nodes that resolve to
the matching key (or, for prefix, the closest key under it). Relaxed binding (kebabcache-list
↔ camelcacheList↔ snakecache_list↔CACHE_LIST) handled via canonical-form match. mall-tiny:
11/11@Valueannotations resolved (incl.secure.ignored@ConfigurationPropertiesprefix).
Coverage frontier: cross-module XML statement references (`` to a fragment in
another mapper file — works when the include uses the dotted namespace form);@PropertySourceexternal
property files; Spring Cloud Config (remote properties); ambiguous mapper-name collisions across packages
(Java mappercom.a.Xandcom.b.Xboth withselectOne— currently dropped to avoid mis-resolving).
- Field-injected concrete-bean trace. Java's
- Spring (validated 2026-05-23, realworld S / mall M / halo L) — bare-mapping + class-prefix routing fix.
The resolver required a string path in the mapping regex, so BARE method mappings (@PostMappingwith the
path on the class@RequestMapping) — the dominant multi-method-controller pattern — were missed (halo
had 28 routes for 2444 files; realworld's 2-action favorite controller linked only one). Fix
(frameworks/java.ts): treat class@RequestMappingas a PREFIX (joined, not a bogus route); match
verb-specific mappings BARE-or-with-path; also handle method-level@RequestMapping(method=...)(older
style). realworld 13→19, mall →246 precise route→method (class prefix joined); DI controller→service
resolves (article→findBySlug). Agent A/B (mall cart flow): with codegraph 0 reads/0 grep vs without 2/2.
A first cut regressed mall 292→1 by dropping@RequestMapping-on-method — caught by the cross-repo
route-count check; the playbook's regression guard earns its keep. Residuals: halo's custom patterns
(9/29 resolve); Spring Data JPA derived queries (metaprogramming frontier). - Django / DRF (validated 2026-05-23, realworld S / wagtail M / saleor L) — mostly covered + a DRF-router
fix. The ORM (_iterable_class→ModelIterable, the original investigation) and URL routing
(path/url/as_view→view) were already done. The one hole: DRFrouter.register(r'articles', ArticleViewSet)(the core CRUD endpoints) wasn't extracted — onlypath()/url()were. Fix
(frameworks/python.ts): matchrouter.register(the STRING first arg separates it fromadmin.register(Model, Admin), whose first arg is a model class) → route→ViewSet class. Narrow in this
corpus (realworld has 1 router; wagtail usespath(), saleor is GraphQL) but real for DRF-router APIs.
Agent A/B (wagtail Page flow, medium): codegraph 4–7 reads / 1–4 grep / 58–81s vs without 7–9 reads
/ 6 grep / 82–86s — fewer reads, fewer greps, faster. No regression (wagtail/saleor route counts
unchanged — purely additive). Residuals: signals (post_save→receiver), DRF viewset CRUD actions
(inherited from the base class, not in the user's ViewSet), saleor's GraphQL resolvers. - Laravel (validated 2026-05-23, realworld S / firefly M / bookstack L) — route precision fix. The
resolver discarded the controller from the handler:Route::get([UserController::class,'index'])/'UserController@index'emitted a BAREindexref, which name-matching mis-resolved to the WRONG
controller (everyindex/show→ whichever it found first; realworld GET user → ArticleController.index,
should be UserController). Fix (frameworks/laravel.ts): emit preciseController@method(array + string
syntax, namespace-stripped) +claimsReferenceit past the pre-filter → existing Pattern-4resolveControllerMethod. realworld all routes correct; bookstack 267/332 precise (GET pages →
PageApiController.list). Agent A/B (bookstack page-view, large): codegraph 2–3 reads / 1–2 grep /
51–60s vs without 4–6 / 3–5 / 60–74s. No node explosion. Residuals: firefly resolves only 3/568
(its fluent->uses()/['uses'=>...]handler format isn't parsed); Eloquent dynamic finders
(metaprogramming frontier). - Gin / chi (validated 2026-05-23, realworld S / gin-vue-admin M / gitness L) — group-var routing fix.
The route regex matched only(router|r|mux|app|e).METHOD(...), but real apps route on GROUP vars
(v1.GET,PublicGroup.GET,userRouter.POST), so group-routed apps connected almost nothing
(gin-vue-admin: 4 routes for 625 files). Fix (frameworks/go.ts): broaden the receiver to ANY
identifier — the verb + string-path + handler-arg gates keep it route-specific (http.Get(url)has no
handler arg → excluded). gin-vue-admin 4→259 routes (257 resolve precisely:POST createInfo → CreateInfo); realworld stable (no regression); no garbage. Agent A/B (create-user flow): codegraph
0 reads / 0 grep / 26–30s vs without 3 / 3 / 52–53s — cleanest backend win yet (0/0, 2× faster).
Residuals: inlinefunc(c *gin.Context){}handlers (anonymous, body lost — like Express before its fix);
gitness's chi custom handlers (26/321). - ASP.NET Core (validated 2026-05-23, realworld S / eShopOnWeb M / jellyfin L) — detection + bare-attribute
fix. Two holes: (1)detect()only fired on a/Controllers/dir or rootProgram.cs/.csproj(which
often isn't in the indexed source set), so feature-folder apps (realworld:Features/*/FooController.cs,
subdirProgram.cs) were NEVER detected → 0 routes despite a full controller set. Broaden: scan
Controller/Program/Startup.csfor ASP.NET signatures. (2) the attribute regex required a string path →
bare[HttpGet](route on the class[Route("[controller]")]) missed (eShopOnWeb was 24 bare / 2
string). Match bare-or-path + join the class[Route]prefix (like Spring). NoclaimsReference
needed — ASP.NET attribute routes are co-located IN the controller with the action, so the bare method
ref resolves same-file (unlike Rails/Laravel, whose routes live in a separate file). realworld 0→19,
eShopOnWeb 9→33, jellyfin 362→399, all precise (GET /articles → Get, class prefix joined), no explosion.
Agent A/B (eShop catalog listing): codegraph 1–2 reads / 0 grep / 63–75s vs without 6–7 / 1–6 /
77–79s. Residual: EF Core LINQ/DbSet (metaprogramming frontier). - Flask / FastAPI (validated 2026-05-23, fastapi-realworld S / flask-microblog S / Netflix dispatch L /
redash L) — decorator-extraction + builtin-name fixes. Routes were extracted but the request→route→handler
flow broke at two regex assumptions and one resolver filter. (1) Flask requireddefimmediately after@x.route(...), so any intervening decorator (@login_required,@cache.cached) or stacked@x.route
lines (one view bound to several URLs) dropped the route — microblog extracted 6 of 27 real routes.
Switched Flask to FastAPI'sfindHandlerscan (match the decorator, then find the nextdef), skipping
intervening decorators: 6→27, all resolved. (2) FastAPI's path regex[^'"]+rejected the empty path@router.get("")(router/prefix-root routes, frequently multi-line) → realworld lost 8 endpoints (list/create
article, comments, login/register).[^'"]+→[^'"]*+ empty-path name guard: realworld 12→20, Netflix
dispatch 290/290 (100%). (3) Bare-name builtin guard (src/resolution/index.ts): a handler named
after a Python builtin method (index,get,update,count…) was filtered byisBuiltInOrExternal
and lost its route→handler edge — microblog'sindexview (its/+/indexstacked routes) resolved to
nothing. The dotted-method branch already had aknownNamesguard; mirrored it onto the bare branch (a name
a declared symbol owns is not a builtin call). +2 legit edges on realworld, 0 change on the django control
(302/373 identical — precision held). Flows trace end-to-end (login → get_user_by_email2 hops;create_user → from_dict). Agent A/B (realworld login-auth flow, n=2/arm): codegraph 0–1 read / 0 grep /
3–4 codegraph / 30–39s (context→[search]→trace→node) vs without 3 read / 2 grep / 33–36s — eliminates
grep, cuts reads to 0–1 (small repo, so wall-clock ties; the tool-count drop is the win). Residuals: Flask-RESTful class-basedapi.add_resource(Resource,'/x')(redash's actual API shape — a separate class-method-as-verb mechanism, NOT
the README's documented decorator/blueprint Flask) and a pre-existing JS file-route false-positive in
redash's React frontend (32 bogus.js"routes" from a JS resolver — unrelated to Python). Lesson: the
builtin-name filter is a silent precision tax across Python — any view/function namedget/index/update
loses edges; the fix is general (helps Django/DRF handlers too), not Flask-specific. - Drupal (validated 2026-05-23, admin_toolbar S / webform M / drupal-core L) — pre-filter + detection fixes.
The*.routing.ymlextractor and the_controller/_formresolver already existed but two gaps kept most
routes unlinked. (1) TheclaimsReferencepre-filter gotcha (again): Drupal handler refs are FQCNs
(\Drupal\…\Class::method), bare form classes (\…\SettingsForm), or single-colon controller-services
(\…\Controller:method). Only the::methodshape survivedresolveOne's pre-filter (itsmemberis a
known method name); the bare-FQCN forms and single-colon controllers named no declared symbol and were
dropped beforeresolve()ran. AddedclaimsReference(FQCN /Class:method/hook_*) + a single-colon
branch in the controller regex → core 536→731 of 836 routes (87%); all three previously-broken shapes now
resolve (/admin/content/comment→CommentAdminOverview form,/big_pipe/no-js→setNoJsCookie controller).
(2) Detection missed standalone contrib modules:detect()only checked composerrequirefor adrupal/*dep, but a contrib module often has an EMPTYrequireand is identified only by"name":"drupal/<m>"+"type":"drupal-module"(admin_toolbar → 0 routes). Broadened to composer name/type- a
*.info.ymlfallback → admin_toolbar 0→14 (14/14). Canonical flow traverses (getAnnouncements←/admin/announcements_feed); node count unchanged (resolution-only). Agent A/B (dblog route→controller,
n=2/arm): codegraph 0 read / 1 grep / 20–22s vs without 1 read / 2 grep + glob / 28–32s — fewer
tools and faster on the ~10k-file core. Residuals (frontier):
entity-annotation handlers (_entity_form: comment.default→ handler classes declared in the entity's#[ContentEntityType]annotation, not a direct ref — ~78 of core's ~105 remaining unresolved) and OOP#[Hook]attributes — Drupal 11 converted nearly all procedural hooks to#[Hook('event')]methods (core:
418 attribute files vs 3 procedural*.modulehooks), so the resolver's procedural-hook detection (docblock@Implements/module_hooknaming) finds essentially nothing in modern core (0 hook edges). Both are real
follow-ups, not regressions.
- a
- Rust / Axum + Rocket + actix (validated 2026-05-23, realworld-axum S / actix-examples + Rocket M / crates.io L) — Axum chained-method + namespaced-handler fix.
The attribute-macro path (#[get("/x")] fn h, actix/Rocket) and single Axum.route("/x", get(h))already
worked, but the Axum extractor used a flat regex that captured only the FIRSTmethod(handler)of a route
and only a bare\w+handler. Two dominant Axum idioms broke it: (1) method chains.route("/user", get(get_current_user).put(update_user))— the.putarm produced NO route node, so half
the API was missing (realworld-axum had only the GET of each chain); (2) namespaced handlersget(listing::feed_articles)—\w+capturedlisting(the module), so the route resolved to nothing.
Rewrote with a balanced-paren scan of each.route(...)call, a per-method node, and last-::-segment
handler names → realworld-axum 12→19 routes, 19/19 resolved (every chained PUT/DELETE/POST now present;feed_articlesresolves). Rocket needed nothing (550/556, 99% — attribute macros). crates.io confirms
namespaced axum handlers resolve (router.rs 6/6) but defines most of its API via theutoipa_axumroutes!
macro (frontier) and has a SvelteKit frontend (42 of its 50 "routes" are+page.svelte, correctly
attributed to SvelteKit). Agent A/B (update-user flow,
n=2/arm): codegraph 0–2 read / 0 grep / 32–40s vs without 3 read / 0–1 grep + glob / 33–41s — modest
(realworld-axum is in the small-repo tie zone) but consistent, with one fully-clean 0-read/0-grep run. Node
count stable; the Axum fix is Axum-scoped (the attribute/actix/Rocket path is untouched). - Actix runtime routing (validated 2026-05-23, actix-examples) — the builder API was the dominant style and fully missed.
Actix's attribute macros (#[get("/x")] fn h) were covered, but real actix apps route via the builder API:web::resource("/path").route(web::get().to(handler)),web::resource("/").to(handler)(all methods), and
App-level.route("/path", web::get().to(handler)). The handler lives in.to(handler), notget(handler),
so the Axum.routescan extracted nothing for them — actix-examples had 80web::resourcecalls all
unlinked. Added an actix block: scan eachweb::resource("/path")(bounding its method chain at the next
resource to avoid bleed) forweb::METHOD().to(h)pairs, fall back to a direct.to(h)(methodANY), plus
the App-level.route("/x", web::METHOD().to(h))form. actix-examples 51→128 routes, 35→112 resolved
(87.5%) (GET /user/{name}→with_param,POST /user→add_user). No regression on Axum (realworld-axum still
19/19) — the actix patterns (web::resource/web::method().to()) don't appear in Axum code. Residuals
(frontier):web::scope("/api")prefixes aren't prepended to nested resource paths, and anonymous.to(|req| …)closure handlers have no named target (the ~16 still-unresolved). - Swift / Vapor (validated 2026-05-23, vapor-template S / SteamPress M / SwiftPackageIndex-Server L) — the resolver was effectively dead on real apps.
The Vapor extractor only matched(app|router|routes).METHOD("path", use: handler), but modern Vapor routes
on a grouped builder insideRouteCollection.boot(routes:):let todos = routes.grouped("todos"); todos.get(use: index)— any var receiver, NO path arg (the path is the group prefix). Every real app tested
extracted 0 routes (template, penny-bot, Feather, SteamPress, SPI). Rewrote the extractor: (1) any
receiver\w+(not just app/router/routes); (2) optional path segments that may be non-string
(User.parameter,:id, a path constant) — theuse:keyword is the discriminator separating a route fromEnvironment.get("X")/req.parameters.get("X"); (3) a group-prefix map fromlet X = Y.grouped("a")andY.group("a") { X in }so a route on a grouped/nested var gets the full path (todo.delete(use: delete)→DELETE /todos/:todoID). Result: vapor-template 0→3 (3/3, nested path exact), SteamPress 0→27
(27/27, incl.BlogPost.parameterroutes), SPI 0→14 (14/14 handler resolution). Canonical flow
traverses (createPostHandler←GET /createPost, →createPostView). Residuals (frontier):
typed-route enums (SPI registers viaapp.get(SiteURL.x.pathComponents, use:)— handler resolves but the
path label is/, no string literal) and closure handlers (app.get("hello") { req in }— anonymous, no
named target). penny-bot (Discord bot) and Feather (custom module router) have no standard Vapor routing at
all — the Vapor ecosystem's routing styles vary widely. Agent A/B (create-post flow, n=2/arm): codegraph
0 read / 0 grep / 4 codegraph / 26–30s (both runs fully clean) vs without 1–4 read / 0–2 grep +
glob/bash, one run spawned a sub-agent / 34–48s. Node count stable; fix is Vapor-scoped (SwiftUI/UIKit
untouched). - React Router routing (validated 2026-05-23, react-realworld S) — the routing half of the React row.
React rendering (state→render, jsx-child) was already covered; route→component was NOT —react.tsextracted
components/hooks and Next.js file routes but returnedreferences: [], so<Route>declarations produced
nothing. Added<Route>JSX extraction: scan a window after each<Route\b(so the nested>inelement={<Comp/>}doesn't truncate it), pullpath="…"+component={C}(v5) orelement={<C/>}(v6) in
any attribute order, emit a route node + component reference (resolves via the existing PascalCaseresolveComponent). react-realworld 0→10, 10/10 (/login→Login,/editor/:slug→Editor,/@:username→Profile);<Routes>container excluded via the\bboundary. No regression on excalidraw
(9,290 nodes, 46 react-render synth edges intact, 0 false routes). 🔬 the object data-router APIcreateBrowserRouter([{ path, element }])(modern v6, used by bulletproof-react) is object-based not JSX — a
separate frontier; plus a pre-existing Next.js false-positive (*.config.mjsin apages/app dir treated
as a route). - Dart / Flutter (validated 2026-05-23, flutter/samples: counter S / books S / compass_app M) — synthesizer + a foundational extractor fix.
Flutter's reactive hop issetState(() {…})re-runningbuild(context)— framework-internal, no static edge,
so "tap → handler → setState → rebuilt UI" dead-ends at setState (the Dart analog of React's setState→render).
Added aflutter-buildsynthesizer channel (Phase 4b): for each Dart class with abuildmethod, link every
sibling method whose body callssetState(→build(gated to.dart). But it was blocked by a
foundational gap: Dart models a method body as a sibling of themethod_signaturenode, so every Dart
method node hadendLine == startLine(signature only) —sliceLines(start,end)saw onlyvoid f() {, never
the body. Fixed in the sharedcreateNode: when a function/method's resolved body sits beyond the node,
extendendLineto it (guarded — child-body grammars are a no-op; controls excalidraw 9,290 / django 302
unchanged). This fix is foundational, not Flutter-specific — every Dart callee/context/body scan was
previously truncated. Result: counterinitState→build, booksinitState→build+build→BookDetail/BookForm.
Widget composition needs no synthesis — unlike JSX, Dart widgets are explicit constructor calls
(BookDetail(...)), already static (compass_appbuild→ErrorIndicator/HomeButton/_Card). Residuals
(frontier): MVVM state management (compass_app uses Command/ChangeNotifier + ListenableBuilder, 0 setState —
a different dispatch shape) andNavigator.push(MaterialPageRoute(builder: (_) => DetailPage()))navigation
(route-as-widget, uncovered). - Kotlin / Spring Boot + Jetpack Compose (validated 2026-05-23, spring-petclinic-kotlin S / compose-samples) — extend Spring to Kotlin; Compose is free.
Kotlin had ZERO framework coverage — no resolver listedkotlin, and the Spring resolver waslanguages: ['java']with a.java-only extract gate and a Java-syntax handler regex (public X name()). So Spring Boot
Kotlin apps (identical@GetMapping/@RestControllerannotations,.ktfiles) extracted 0 routes. Extended
the Spring resolver:['java','kotlin'], accept.kt, and add a Kotlinfun name(alternative to the
handler-method regex (Kotlin has no access modifier and the return type follows the name). petclinic-kotlin
0→18, 18/18; class@RequestMappingprefixes join, stacked annotations (@ResponseBody) are skipped, DI
controller→repo resolves (showOwner ← GET /owners/{ownerId}→OwnerRepository.findById/VisitRepository.findByPetId). Java Spring unchanged (realworld 19/19 — the Kotlinfunand Javapublic X
alternatives are disjoint per language). Jetpack Compose composition needs no work —@Composable
functions calling child@Composables are plain Kotlin function calls, already static (JetcasterPodcastInformation→HtmlTextContainer,FollowedPodcastCarouselItem→PodcastImage), like Dart widget
constructors. Agent A/B (view-owner flow, n=2/arm): codegraph 0–1 read / 0 grep / 1 codegraph / 11–18s (a
singlecontextcall answers it) vs without 2 read / 0–1 grep + glob / 20–28s. Residuals (frontier):
Ktorrouting { get("/x") { … } }inline-lambda handlers (anonymous,
no named target), Compose recomposition (implicit — readingmutableStateOftriggers recompose, nosetState-style gate to anchor a synthesizer), and coroutines/Flow dispatch. - Lua / Luau (validated 2026-05-23, telescope.nvim / lualine.nvim / Knit — measure-first, already covered).
The matrix guessed "event/callback dispatch (synthesizer)", but measurement says otherwise: real Neovim
plugins are MODULE-dispatch-heavy (local m = require('telescope.actions'); m.fn()), and codegraph's generalrequire-import + cross-file name resolution already handles it — telescope.nvim has 220 resolved imports
and 335 cross-filemodule.fncall edges, and a flow traces end-to-end (map_entries ← init.lua → get_current_pickerin actions/state.lua). The Luau extractor already handles Roblox instance-path requires
(require(game:GetService("ReplicatedStorage").Packages.Knit)). The assumed hole isn't real — like
Svelte/NestJS. The genuine frontier is event-callback registration (vim.keymap.set(mode, lhs, fn), autocmd{callback=fn}, Robloxsignal:Connect(fn)), but it's predominantly INLINE anonymous closures (corpus: ~12
inline:Connect(function…)vs ~2 named), and telescope's keymaps are inline functions or vim-command
STRINGS, not named refs. A named-only callback synthesizer would cover a tiny fraction, so per "measure before
building / partial coverage is worse than none", none was built — no code change; recorded as validated.
Agent A/B (actions.utils map flow, n=2/arm): codegraph 0 read / 0 grep / 18–24s vs without 1 read
(+glob) / 24–25s — small flow so modest, but the 0-read confirms the module dispatch is navigable. - Scala / Play (validated 2026-05-23, play-samples: computer-database / starter / rest-api) — Play conf/routes → controller.
Scala's general dispatch (controller→DAO) already resolves, but Play declares routes in an EXTENSIONLESSconf/routesfile (GET /computers controllers.Application.list(p: Int ?= 0)) the file walk never indexed
(isSourceFilerequires an extension). Added a narrow opt-in (isPlayRoutesFile:conf/routes/*.routes)
routed through the no-grammar (yaml-style) path, plus a Play resolver that parses eachMETHOD /path Controller.action(args)line (dropping package prefix + args) and resolvesController.action
to the action method in that controller class. computer-database 0→8 routes, 7/8 (the 1 unresolved iscontrollers.Assets.versioned— Play's framework Assets controller, external), starter 0→4 (3/4). The flow
connects request→route→controller→DAO. A/B (list-computers, n=2/arm): codegraph 0 read / 0 grep / 3
codegraph / 17–22s vs without 2–3 read / 1–2 grep + glob / 16–17s. No-regression: the file-walk
change only ADDS Play routes files (narrow match) — excalidraw 9,290 and the full suite (800) unchanged.
Residuals (frontier): Play SIRD programmatic routers (-> /v1 v1.PostRouterinclude +case GET(p"/x")
in a Router class — rest-api-example) and Akka actor message→handler (receive { case Msg => … }/Behaviors.receiveMessage— untyped, a synthesizer shape). - C / C++ (validated 2026-05-23, redis C / leveldb C++) — general dispatch works; a C++ inheritance fix + override bridge.
Measure-first: C/C++ DIRECT dispatch is excellent out of the box (redis 29,464 cross-file call edges,
leveldb 1,462) — the bulk of the value. The dynamic-dispatch frontier is two shapes: (1) C callback
structs (struct {.proc=fn}+cmd->proc()) — but in redis theprocfield fans out to 422 command
functions, far too noisy to synthesize precisely, so deliberately skipped (per "partial coverage worse than
none"). (2) C++ vtables (iter->Next()→ the subclass override). The override link was blocked upstream:extractInheritancehandledbase_clause(PHP) but not C++'sbase_class_clause, so C++extendsedges
were missing/partial (leveldb 219→298 after the fix). Added acpp-overridesynthesizer channel (the C++
analog of react-render): for eachextendsedge, link each base method → the subclass method of the same
name, so trace/callees from the interface method reach the implementation. leveldb 12 precise edges
(Iterator::Next/Seek/Prev → MergingIterator), 0 on C (redis) and TS (excalidraw — gated to C++); the C++
override integration test passes. Residual (frontier): pure-virtual base methods (virtual void Next() = 0;) are declarations the extractor doesn't emit as nodes, so overrides of a purely-abstract interface can't
be bridged (only bases with a real method node — an inline default or non-pure virtual); plus the C
callback-struct fan-out. Relied on deterministic validation (no A/B): the cross-file-call counts + precise
override spot-check are conclusive. - Frontier pass (2026-05-23) — tractable partials closed, noise/hard ones deliberately left. After the main
sweep, swept the documented frontiers and triaged by precision/value. DONE: React Router object
data-router (literalcreateBrowserRouter([{path, element}])); Next.js route false-positives (config files +nextjs-pages/substring → require a real page ext + path-segment match; bulletproof 4→0); Flask-RESTfuladd_resource→Resource class (redash 6→77); Flask tuplemethods=(…); Flask detection broadened to
subdir/app-factory entrypoints (flask-realworld 0→19); gorilla/mux confirmed already covered (any-receiver
HandleFunc) + a test. LEFT (with rationale, not punts): C callback-struct dispatch (cmd->proc()→
422-way field fan-out = noise); metaprogramming finders (ActiveRecord/Eloquent/Spring-Data-JPA/EF — dynamic
naming, no static target); reactive runtimes (Vue Proxy / Compose recomposition — deep internals, no
setState-style gate); Akka actor message dispatch (untyped); pure anonymous inline closures (the def-use
frontier — no named target); React lazy data-router (variable paths + lazy imports); C++ pure-virtual base
methods (extracting bodyless decls risks duplicate decl/def nodes for modest gain). Forcing these would add
noise, violating "partial coverage worse than none." - Difficulty gradient is real: named-ref dispatch (resolver) is cheap; anonymous
callback dispatch (synthesizer) is medium; anonymous-arrow handlers are the hard
remaining gap (no identity → need synthesizer link-through-body, not yet built). - Extraction changes are high blast radius. The Phase-3 named-inline-callback
extraction is in the sharedtree-sitter.tswalker — re-check node counts across
several languages after any extraction change (it held at +3 on excalidraw because
anonymous arrows are skipped). - Synthesizer precision guards: registrar-name uniqueness, named-only handlers, and
an event fan-out cap (skip generic events likeerror/change). Receiver-type
matching (viatype_ofedges) is the planned precision upgrade — deferred. - As-built shortcuts (callback synthesizer): pairs registrar/dispatcher by file+field
(class proxy), regex arg-recovery (named refs only),provenance:'heuristic'+metadata.synthesizedBy(the enum has no'callback-synthesis'). See the design doc. - Synthesizer runs only in
resolveAndPersistBatched(full index) — wire intoresolveAndPersistfor incremental sync before shipping. - Symbol ambiguity in
trace: common names (render,execute_sql) match many
nodes; trace picks among them and may start from the wrong one. Trace from the specific
method, not a class name.
8. Definition of done (the whole mission)
For each language × framework: the canonical flow traces end-to-end, an agent can
answer the flow question with Read 0 in at least some runs with the glue present, no node
explosion, no regression — recorded in the matrix (§6) with the validating repo + numbers.
Then ship-prep: tests per mechanism, CHANGELOG, wire incremental, commit.