codegraph Documentation

Pre-indexed code knowledge graph, auto syncs on code changes, for Claude Code, Codex, Gemini, Cursor, OpenCode, AntiGravity, Kiro, and Hermes Agent — fewer...

65,590 stars C C 1 file · ~57,060 tokens
RAW Doc

File: README.md

Contents

Get Started

1. Install the CLI

No Node.js required — one command grabs the right build for your OS:

bash
# 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 | iex

Already have Node? Use npm instead (works on any version)

bash
npm i @colbymchenry/codegraph

CodeGraph 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:

bash
codegraph install

Detects 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

bash
cd your-project
codegraph init

codegraph 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:

bash
codegraph uninstall

Pass --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:

  1. 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 via CODEGRAPH_WATCH_DEBOUNCE_MS, clamped to [100ms, 60s]). Bursts of edits collapse into a single sync.

  2. 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 to Read it 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.

  3. 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 (a git pull from the terminal, edits from another editor, a previous agent session that exited) get absorbed on the next session's first tool call.

text
agent writes src/Widget.ts
  → watcher fires (<100ms)
  → debounce (default 2s)
  → sync; Widget.ts is in the index
  → next agent query sees it

Verify 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

bash
npx @colbymchenry/codegraph

The 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 codegraph on 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 the codegraph explore command, since the MCP server's own guidance only reaches the main agent. Removed cleanly by codegraph 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):

bash
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

bash
cd your-project
codegraph init

Builds 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:

bash
npm install @colbymchenry/codegraph

Add to ~/.claude.json:

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

json
{
  "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_explore for 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 passing projectPath — 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

text
┌───────────────────────────────────────────────────────────────────┐
│                            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          │
└───────────────────────────────────────────────────────────────────┘
  1. 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.

  2. Storage — Everything goes into a local SQLite database (.codegraph/codegraph.db) with FTS5 full-text search.

  3. Resolution — After extraction, references are resolved: function calls → definitions, imports → source files, class inheritance, and framework-specific patterns.

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

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

bash
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:

bash
#!/usr/bin/env bash
AFFECTED=$(git diff --name-only HEAD | codegraph affected --stdin --quiet)
if [ -n "$AFFECTED" ]; then
  npx vitest run $AFFECTED
fi

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

typescript
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/node available and skipLibCheck: 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 .gitignore directly (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 under
static/), list it under exclude in codegraph.json — gitignore-style
patterns, matched against repo-root-relative paths, honored on index, sync, and
watch:

json
{
  "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):

json
{
  "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/ or
optional-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:

json
{
  "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:

json
{
  "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:

bash
codegraph telemetry off    # or: CODEGRAPH_TELEMETRY=0, or DO_NOT_TRACK=1

TELEMETRY.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:

    bash
    npm audit signatures
  • GitHub Release bundles (and SHA256SUMS) carry signed
    build attestations
    (SLSA v1.0 Build Level 2). Verify any downloaded bundle:

    bash
    gh 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.codegraph with codemode: false (keeps codegraph_explore on the native tool list; codegraph install migrates the older mcp.codegraph shape)
  • 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), or npm i @colbymchenry/codegraph@latest.
  • codegraph status shows Journal: other than wal — 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 — add extract?() to FrameworkResolver; remove extractNodes?().
  • src/resolution/frameworks/index.ts — keep detectFrameworks signature; add getApplicableFrameworks(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 into ExtractionOrchestrator.indexAll after 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-thread extractFromSource and worker-thread parse path both have to cover this).
  • __tests__/frameworks.test.ts — NEW. One describe per 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 a route -> class edge with kind references exists from urlpatterns entry to UserListView.

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-100

  • Step 1: Write the failing test

Create __tests__/frameworks.test.ts:

typescript
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:

typescript
/**
 * 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
bash
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.ts

  • Step 1: Write the failing test

Append to __tests__/frameworks.test.ts:

typescript
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:

typescript
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
bash
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:

text
/* 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:

text
/* Detailed source-code truncated for AI context efficiency. */

Also ensure the top of the file imports UnresolvedRef and Node:

typescript
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
bash
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:

typescript
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:

text
/* Detailed source-code truncated for AI context efficiency. */

And add this shared helper at the bottom of python.ts:

text
/* 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
bash
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:

typescript
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:

text
/* Detailed source-code truncated for AI context efficiency. */

And add near the top of the file:

typescript
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
bash
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:

  1. Add languages: [...] field.
  2. Replace extractNodes(filePath, content) with extract(filePath, content): { nodes, references }.
  3. Inside extract(), for each matched route regex: create a route node (reuse existing shape) AND emit a UnresolvedRef for the handler/controller with fromNodeId = routeNode.id.
  4. For each framework, add one unit test to __tests__/frameworks.test.ts that 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 by users); resources :users → one node per CRUD action, each referencing the corresponding method name on UsersController. 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 inside get(...). 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 after use:. languages: ['swift'].

Each of these gets its own commit in the form:

bash
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 via resolveLaravelHandler(): strip [/]/::class, take second element of comma-split array or Ctrl@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#method split 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 shared extractDecoratorRoutes helper (move it to a new src/resolution/frameworks/shared.ts if cleaner). Find the next public or private method 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) and router.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 first public|private|protected method 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) — add languages: ['javascript', 'typescript'], rename extractNodes to extract, make it return { nodes, references: [] } (the existing logic only emits nodes, no handler references needed yet — a follow-up can add <Route element={}/> → Page references).
  • 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:

text
/* 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:

typescript
// 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:

typescript
// 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
bash
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 dangling extractNodes remains

  • Modify: README.md — add a section on framework route extraction

  • Step 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:

markdown
### 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
bash
git add README.md
git commit -m "docs: document framework route extraction"

Task 9: Open the PR

  • Step 1: Push branch to fork
bash
git push -u origin feat/framework-extract-wiring
  • Step 2: Create PR
bash
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: FrameworkExtractionResult is defined once in Task 1 and used by every resolver's extract signature.
  • 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 add route -> Page references.
  • Spring Controller-class scoping. Method-scoped mappings work; class-level @RequestMapping base 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 branch
feat/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's RealCall (it implements the
9-impl Lockable mixin, so it tripped the sibling signal even though it's
the orchestrator) and Django's compiler.py (it defines SQLCompiler and
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 back compiler.py (collapsed → its execute_sql/as_sql
bodies elided) and query.py (a non-sibling god-file whose _fetch_all cluster
got trimmed). Four changes took both repos from ~9–10% to ~14–17% cheaper
with median 0 reads:

  1. Uniqueness-aware spare — only a (near-)UNIQUE named callable spares a
    file. as_sql has 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.
  2. 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. So SQLCompiler.execute_sql/as_sql survive
    while the 80 other symbols + redundant subclasses collapse → no Read-back.
  3. 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.)
  4. 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 at min(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
index SQLCompiler.as_sql pre_sql_setup get_select returned get_select and
pre_sql_setup in full and SQLCompiler.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 bridge
get_qualify_sql and get_select (both above as_sql in 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, returning SQLInsertCompiler.as_sql and friends instead; every run
Read compiler.py. The fix:

  1. 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 bare as_sql is NOT exact, so the family rules above
    still keep the 110 overrides as signatures.
  2. 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.
  3. 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``).
  4. Line anchors (query-paths.ts): file:line, file:a-b, #La-Lb, and
    prose lines a-b / L a-L b next to a path. A range renders as exactly that
    span (cluster path, ranked first).
  5. 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), the lines 900-1003 span 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_sql whole,
execute_sql/results_iter to signatures).

Agent A/B (ab-new-vs-baseline.sh, django, README question pair, Sonnet /
--effort high, RUNS=3, baseline e63fe2ec): Read 0 in 3/3 new runs vs a
Read of compiler.py in 2/3 baseline runs
— each right after the agent's own
SQLCompiler.as_sql … compiler.py:776 explore, 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_sql 1/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
    : Interceptor impls while keeping RealInterceptorChain (the dispatch
    mechanism) and RealCall (the orchestrator the agent named) full → ~10%
    cheaper than native, 0 RealCall read-backs
    (see Refinement for the corrected
    numbers; the original 28.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 to
maxOutputChars (the "whole-small-file rule" dumps any relevant file ≤220 lines
in full). The budget is a target, not a ceiling:

text
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 bodies

The 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):

  1. A spine exists. buildFlowFromNamedSymbols returns its path node set
    (pathNodeIds) and the full set of agent-named callables (namedNodeIds). If
    no spine forms, nothing skeletonizes.

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

  3. A polymorphic sibling. The file's class implements/extends a supertype
    with ≥ 3 implementers (MIN_SIBLINGS) — the signal that it's one of many
    interchangeable impls. From real implements/extends edges, cached.

  4. 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's
    compiler.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 — proceed is on the spine → kept full (cond. 2).
  • RealCall — off-spine, and it trips the sibling signal via the 9-impl
    Lockable mixin
    (not because it's an interchangeable interceptor). But the
    agent named getResponseWithInterceptorChain/execute/enqueue in 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 extend
    SQLCompiler), the agent named execute_sql in it — but it defines the
    SQLCompiler supertype
    , 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:

text
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:

text
#### …/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 =
text
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 duplicate
source>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', with
synthesizedBy, 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, and
registeredAt = 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 by axios.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 route METHOD path in 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's
    process when there is none), new Worker('email', handler) (an inline handler → the enclosing function, else the
    enclosing constant), Bull's queue.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-side socket.on('x') (client→server); from a file
    with one (@WebSocketGateway, io.on('connection'), new Server) → client-side socket.on('x', …), named or inline
    (→ the enclosing component) (server→client). Plain .on ↔ .emit stays 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.

<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 a
navigates 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

ts
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 SITE

The 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 in
src/resolution/callback-synthesizer.ts — not under frameworks/.

Sibling mechanism for the other dynamic-dispatch class — named attribute/
descriptor dispatch (e.g. django self._iterable_class(...)) — is the
claimsReference hook (resolution/types.ts + resolution/index.ts pre-filter)

  • a FrameworkResolver.resolve() (django ORM resolver in frameworks/python.ts).
    That one does fit resolve() 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)

  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).
  2. Confirm by body (read via ctx.readFile + slice node lines): registrar has
    this.<F>.add|push|set(; dispatcher has for (… of [Array.from(]this.<F>) + a call,
    or this.<F>.forEach(.
  3. Pairing — DIVERGENCE: the design said pair by class; the build pairs by
    same file + same field F (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.
  4. 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).
  5. Synthesize dispatcher → fn (getNodesByName(arg) → method|function). Capped at
    MAX_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 (enclosingFn finds 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 like error/change would 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 and
extractName 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)

  1. 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 link dispatcher → (calls inside the arrow). Highest
    remaining recall win; handles the most common modern callback shape.
  2. Wire into resolveAndPersist (incremental sync) — synthesis currently runs only
    in resolveAndPersistBatched (full index). Incremental re-index won't refresh
    synthesized edges.
  3. Receiver-type matching for EventEmitter precision (replace/augment the fan-out
    cap) — use type_of edges so x.emit('change') only links to y.on('change', fn)
    when x,y are the same type. Lets the fan-out cap relax.
  4. Tree-sitter arg recovery (replace the regex in field-channel Stage 4) — robust for
    arrows, multi-arg, line-wrapped calls.
  5. Single-callback fields (this.onChange = cb; … this.onChange()) — scalar-store
    variant of the field observer; not built.
  6. Broad precision/recall audit — run across the full corpus; tally synthesized edges
    per repo, spot-check, confirm no explosion on EventEmitter-heavy repos.
  7. 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/off ignored.
  • Synthesized edges are additive — never replace static edges; tooling can filter on
    provenance='heuristic' + metadata.synthesizedBy.

This is one half of closing dynamic-dispatch coverage. The other artifacts on main:

  • Named attribute/descriptor resolver: claimsReference (resolution/types.ts,
    pre-filter in resolution/index.ts) + django ORM resolver (frameworks/python.ts,
    _iterable_class → ModelIterable.__iter__).
  • Retrieval/UX changes (separate from coverage): explore whole-small-file + glue
    fixes, the explore Flow section (buildFlowFromNamedSymbols), and node-with-trail
    — all in src/mcp/tools.ts. (codegraph_trace / codegraph_context were 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 in callback-edge-synthesis.md.
The cross-cutting dispatch-shape queue (Redux/RTK Query/NgRx/MediatR/registries —
organized by indirection shape, not language×framework) is in
dispatch-synthesizer-backlog.md.
Full investigation context + findings: auto-memory project_codegraph_read_displacement.

Update (2026-06-01): the codegraph_trace and codegraph_context MCP tools were
removed — codegraph_explore is the single surfacing tool now. Its "Flow" section
(buildFlowFromNamedSymbols) surfaces the synthesized edges this playbook is about, and
you validate coverage with codegraph_explore / scripts/agent-eval/probe-explore.mjs.
Where the text below writes trace(a, b) or lists trace/context among 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_class is 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_all calls self._iterable_class(self) (a runtime-chosen
    iterable, default ModelIterable), 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: djangoResolver claims the unresolved _iterable_class ref through the
    name-exists pre-filter, then resolves it to ModelIterable.__iter__.
  • Files: src/resolution/types.ts (claimsReference? on FrameworkResolver),
    src/resolution/index.ts (pre-filter in resolveOne consults claimsReference),
    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.triggerUpdate does for (cb of this.callbacks) cb(); triggerRender
    is registered via scene.onUpdate(this.triggerRender). The triggerUpdate → triggerRender edge is dynamic → trace returned no path; the whole update flow broke.
  • Fix: a whole-graph pass that detects registrar/dispatcher channels, correlates
    registration sites, and synthesizes dispatcher → callback edges. Plus extraction of
    named inline callbacks so handlers like express's function onmount(){} are nodes.
  • Files: src/resolution/callback-synthesizer.ts (the pass — field observers +
    EventEmitter), src/resolution/index.ts (calls synthesizeCallbackEdges() at the end
    of resolveAndPersistBatched), src/extraction/tree-sitter.ts (visitFunctionBody
    extracts named nested functions).
  • Result: trace(mutateElement, triggerRender) → 3 hops; express use → onmount.

3c. Alamofire deferred validation — closure-collection dispatch (Swift)

  • Hole: DataRequest.validate(_:) builds a closure and validators.write { $0.append(validator) };
    the base Request.didCompleteTask runs them via validators.forEach { $0() }. Append and
    dispatch live in different files and classes (a subclass appends, the base iterates) and the
    field is a Swift Protected<[@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 grepped validators and 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 in trace, and as a "Dynamic-dispatch links among your
    symbols" section in codegraph_explore (buildFlowFromNamedSymbols) so the relationship shows even
    when the agent named only validate, not the didCompleteTask that drains the list.
  • Files: src/resolution/callback-synthesizer.ts (closureCollectionEdges),
    src/mcp/tools.ts (synthEdgeNote closure-collection case + the explore synth-links section).
  • Result: trace(didCompleteTask, validate) connects with the closure-collection hop + the
    validators.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 — every
Read'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)

bash
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 — a resolve() branch +
    claimsReference(name) if the ref name isn't a declared symbol. Copy djangoResolver.
  • 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)

  1. Deterministic: probe-trace(from,to) finds the path; probe-node shows the
    bridged hop. The previously-broken hop is closed.
  2. Precision: count + spot-check synthesized/resolved edges — no explosion, correct targets:
    bash
    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';"
    (Resolver edges aren't heuristic; verify via the trace + callees instead.)
  3. 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.
  4. End-to-end agent eval: run the flow question with codegraph and measure
    reads / answer-completeness / cost vs a pre-fix baseline:
    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>" all
    Then parse: Read count, 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)

  • trace finds 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/ and
src/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=1 on both arms. The machine's ambient front-load hook resolves
    to whatever is in dist/, 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
    (vben handleLogin/handleSubmit).
    Composable-destructure handlers RESOLVED: @click="closeSidebar" where
    const { close: closeSidebar } = useSidebarControl() now follows alias → composable →
    the returned close fn (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 static useX() 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-plus el-button → button.vue (component named
    button, not ElButton), 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 .svelte extractor already parses the template: extractTemplateCalls ({fn()}),
    extractTemplateComponents (`` composition — skeleton 956 / shadcn 1610 reference edges),
    plus import * as api namespace + 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); extractFunction nameOverride keeps 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 for load — a deeper resolver interaction, deferred
    (local/relative calls from actions connect). Lesson: measure before assuming a hole — modern Svelte
    barely uses on: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, and XController.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 (realworld POST /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't app.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
    nestjs resolver 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 its dist/ build output, which codegraph indexes
    (246 dup nodes) because the file walk only respects .gitignore with no default build-dir ignore. Real
    apps (immich/amplication) gitignore dist/ (0 dup nodes), so it's narrow — a default ignore for
    dist/build/out/.next/coverage is 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
    rails resolver only saw explicit get '/x' => 'c#a' routes, so resource-routed apps (the dominant
    pattern) had ZERO route nodes (realworld + spree). Fixed (frameworks/ruby.ts): expand resources :x /
    resource :x into their RESTful actions (only/except filters + pluralization for the singular resource),
    reference a precise controller#action, and resolve that to the action method in <ctrl>_controller.rb
    (explicit routes fixed too — they referenced a bare ambiguous action). 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. The
    claimsReference pre-filter was the gotcha:
    articles#index names no declared symbol, so resolveOne
    dropped it before resolve() ran — needed the same claim hook as the django ORM work. Residuals: Rails
    Engine routing
    (spree still 0 — it mounts an engine, not config/routes.rb resources); 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.
    1. Field-injected concrete-bean trace. Java's this.userbo.toLogin2() parsed as method_invocation( object=field_access(this, userbo)). The extractor surfaced this.userbo.toLogin2 verbatim and the
      name-matcher's single-dot regex couldn't unwrap it; even if it had, userbo doesn't capitalize cleanly
      to UserBO (the JVM naming heuristic in matchMethodCall.Strategy2) so the receiver-typed lookup also
      missed. Fix is in the language layer, not Spring-specific: (a) extractor unwraps field_access(this, X)
      to use X as the receiver (src/extraction/tree-sitter.ts); (b) matchMethodCall learns to look up
      the receiver name as a field declaration in the enclosing class and use the field's signature-stored
      declared type (inferJavaFieldReceiverType in src/resolution/name-matcher.ts). Repro confirmed on the
      issue's exact example: UserAction.toLogin2 → UserBO.toLogin2 edge appeared (was 0 outgoing edges).
    2. MyBatis XML mapper indexing + Java↔XML bridge. *.xml is 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 (mybatisJavaXmlEdges in callback-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 chain trace(UmsRoleController.listResource → UmsResource Mapper::getResourceListByRoleId(xml)) connects in 4 hops across controller/service/impl/mapper/XML.
    3. 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
      .properties line become constant nodes 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 (kebab cache-list
      ↔ camel cacheList ↔ snake cache_list ↔ CACHE_LIST) handled via canonical-form match. mall-tiny:
      11/11 @Value annotations resolved (incl. secure.ignored @ConfigurationProperties prefix).
      Coverage frontier: cross-module XML statement references (`` to a fragment in
      another mapper file — works when the include uses the dotted namespace form); @PropertySource external
      property files; Spring Cloud Config (remote properties); ambiguous mapper-name collisions across packages
      (Java mapper com.a.X and com.b.X both with selectOne — currently dropped to avoid mis-resolving).
  • 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 (@PostMapping with 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 @RequestMapping as 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: DRF router.register(r'articles', ArticleViewSet) (the core CRUD endpoints) wasn't extracted — only path()/url() were. Fix
    (frameworks/python.ts): match router.register (the STRING first arg separates it from
    admin.register(Model, Admin), whose first arg is a model class) → route→ViewSet class. Narrow in this
    corpus (realworld has 1 router; wagtail uses path(), 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 BARE index ref, which name-matching mis-resolved to the WRONG
    controller (every index/show → whichever it found first; realworld GET user → ArticleController.index,
    should be UserController). Fix (frameworks/laravel.ts): emit precise Controller@method (array + string
    syntax, namespace-stripped) + claimsReference it past the pre-filter → existing Pattern-4
    resolveControllerMethod. 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: inline func(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 root Program.cs/.csproj (which
    often isn't in the indexed source set), so feature-folder apps (realworld: Features/*/FooController.cs,
    subdir Program.cs) were NEVER detected → 0 routes despite a full controller set. Broaden: scan
    Controller/Program/Startup .cs for 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). No claimsReference
    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 required def immediately 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's findHandler scan (match the decorator, then find the next def), 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 by isBuiltInOrExternal
    and lost its route→handler edge — microblog's index view (its / + /index stacked routes) resolved to
    nothing. The dotted-method branch already had a knownNames guard; 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_email 2 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-based
    api.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 named get/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.yml extractor and the _controller/_form resolver already existed but two gaps kept most
    routes unlinked. (1) The claimsReference pre-filter gotcha (again): Drupal handler refs are FQCNs
    (\Drupal\…\Class::method), bare form classes (\…\SettingsForm), or single-colon controller-services
    (\…\Controller:method). Only the ::method shape survived resolveOne's pre-filter (its member is a
    known method name); the bare-FQCN forms and single-colon controllers named no declared symbol and were
    dropped before resolve() ran. Added claimsReference (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 composer require for a
    drupal/* dep, but a contrib module often has an EMPTY require and is identified only by
    "name":"drupal/<m>" + "type":"drupal-module" (admin_toolbar → 0 routes). Broadened to composer name/type
    • a *.info.yml fallback → 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 *.module hooks), so the resolver's procedural-hook detection (docblock
      @Implements / module_hook naming) finds essentially nothing in modern core (0 hook edges). Both are real
      follow-ups, not regressions.
  • 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 FIRST method(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 .put arm produced NO route node, so half
    the API was missing (realworld-axum had only the GET of each chain); (2) namespaced handlers
    get(listing::feed_articles) — \w+ captured listing (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_articles resolves). 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 the utoipa_axum routes!
    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), not get(handler),
    so the Axum .route scan extracted nothing for them — actix-examples had 80 web::resource calls all
    unlinked. Added an actix block: scan each web::resource("/path") (bounding its method chain at the next
    resource to avoid bleed) for web::METHOD().to(h) pairs, fall back to a direct .to(h) (method ANY), 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 inside RouteCollection.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) — the use: keyword is the discriminator separating a route from
    Environment.get("X") / req.parameters.get("X"); (3) a group-prefix map from let X = Y.grouped("a") and
    Y.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.parameter routes), SPI 0→14 (14/14 handler resolution). Canonical flow
    traverses (createPostHandler ← GET /createPost, → createPostView). Residuals (frontier):
    typed-route enums (SPI registers via app.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.ts extracted
    components/hooks and Next.js file routes but returned references: [], so <Route> declarations produced
    nothing. Added <Route> JSX extraction: scan a window after each <Route\b (so the nested > in
    element={<Comp/>} doesn't truncate it), pull path="…" + component={C} (v5) or element={<C/>} (v6) in
    any attribute order, emit a route node + component reference (resolves via the existing PascalCase
    resolveComponent). react-realworld 0→10, 10/10 (/login→Login, /editor/:slug→Editor,
    /@:username→Profile); <Routes> container excluded via the \b boundary. No regression on excalidraw
    (9,290 nodes, 46 react-render synth edges intact, 0 false routes). 🔬 the object data-router API
    createBrowserRouter([{ 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.mjs in a pages/ 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 is setState(() {…}) re-running build(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 a flutter-build synthesizer channel (Phase 4b): for each Dart class with a build method, link every
    sibling method whose body calls setState( → build (gated to .dart). But it was blocked by a
    foundational gap:
    Dart models a method body as a sibling of the method_signature node, so every Dart
    method node had endLine == startLine (signature only) — sliceLines(start,end) saw only void f() {, never
    the body. Fixed in the shared createNode: when a function/method's resolved body sits beyond the node,
    extend endLine to 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: counter initState→build, books initState→build + build→BookDetail/BookForm.
    Widget composition needs no synthesis — unlike JSX, Dart widgets are explicit constructor calls
    (BookDetail(...)), already static (compass_app build→ErrorIndicator/HomeButton/_Card). Residuals
    (frontier):
    MVVM state management (compass_app uses Command/ChangeNotifier + ListenableBuilder, 0 setState —
    a different dispatch shape) and Navigator.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 listed kotlin, and the Spring resolver was languages: ['java'] with a .java-only extract gate and a Java-syntax handler regex (public X name()). So Spring Boot
    Kotlin apps (identical @GetMapping/@RestController annotations, .kt files) extracted 0 routes. Extended
    the Spring resolver: ['java','kotlin'], accept .kt, and add a Kotlin fun 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 @RequestMapping prefixes 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 Kotlin fun and Java public X
    alternatives are disjoint per language). Jetpack Compose composition needs no work — @Composable
    functions calling child @Composables are plain Kotlin function calls, already static (Jetcaster
    PodcastInformation→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
    single context call answers it) vs without 2 read / 0–1 grep + glob / 20–28s. Residuals (frontier):
    Ktor routing { get("/x") { … } } inline-lambda handlers (anonymous,
    no named target), Compose recomposition (implicit — reading mutableStateOf triggers recompose, no
    setState-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 general
    require-import + cross-file name resolution already handles it — telescope.nvim has 220 resolved imports
    and 335 cross-file module.fn call edges
    , and a flow traces end-to-end (map_entries ← init.lua → get_current_picker in 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}, Roblox signal: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 EXTENSIONLESS
    conf/routes file (GET /computers controllers.Application.list(p: Int ?= 0)) the file walk never indexed
    (isSourceFile requires 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 each
    METHOD /path Controller.action(args) line (dropping package prefix + args) and resolves Controller.action
    to the action method in that controller class. computer-database 0→8 routes, 7/8 (the 1 unresolved is
    controllers.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.PostRouter include + 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 the proc field 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:
    extractInheritance handled base_clause (PHP) but not C++'s base_class_clause, so C++ extends edges
    were missing/partial (leveldb 219→298 after the fix). Added a cpp-override synthesizer channel (the C++
    analog of react-render): for each extends edge, 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 (literal createBrowserRouter([{path, element}])); Next.js route false-positives (config files +
    nextjs-pages/ substring → require a real page ext + path-segment match; bulletproof 4→0); Flask-RESTful
    add_resource→Resource class (redash 6→77); Flask tuple methods=(…); 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 shared tree-sitter.ts walker — 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 like error/change). Receiver-type
    matching (via type_of edges) 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 into
    resolveAndPersist for 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.