## File: README.md

## Contents

- [Get Started](#get-started)
- [Language Support](#language-support)
- [Why CodeGraph?](#why-codegraph)
- [Key Features](#key-features)
- [Framework-aware Routes](#framework-aware-routes)
- [Mixed iOS / React Native / Expo bridging](#mixed-ios--react-native--expo-bridging)
- [Quick Start](#quick-start)
- [How It Works](#how-it-works)
- [CLI Reference](#cli-reference)
- [MCP Tools](#mcp-tools)
- [Library Usage](#library-usage)
- [Configuration](#configuration)
- [Telemetry](#telemetry)
- [Verified releases](#verified-releases)
- [Supported Platforms](#supported-platforms)
- [Supported Agents](#supported-agents)
- [Supported Languages](#supported-languages)
- [Measured cross-file coverage](#measured-cross-file-coverage)
- [Troubleshooting](#troubleshooting)
- [License](#license)

## 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
```

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

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



### 2. Wire up your agent(s)

In a **new terminal**, run the installer to connect CodeGraph to the agents you use:

```bash
codegraph install
```

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

### 3. Initialize each project

```bash
cd your-project
codegraph init
```

<sub>`codegraph init` creates the local `.codegraph/` directory and builds the full graph in the same step — one command, done.</sub>



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

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

---

## Language Support

Every language below gets the same treatment — full structural extraction and cross-file resolution into one graph, no per-language setup:



<sub>Per-language details — extensions, frameworks, and what exactly gets extracted — in [Supported Languages](#supported-languages).</sub>

---

## 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`](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 |

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


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

```
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](https://colbymchenry.github.io/codegraph/guides/indexing/#stay-fresh-automatically).



---

## 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](https://github.com/danielgindi/Charts) | [realm-swift](https://github.com/realm/realm-swift) | [Wikipedia-iOS](https://github.com/wikimedia/wikipedia-ios) |
| RN legacy bridge | [AsyncStorage](https://github.com/react-native-async-storage/async-storage) | [react-native-svg](https://github.com/software-mansion/react-native-svg) | [react-native-firebase](https://github.com/invertase/react-native-firebase) |
| RN native → JS events | [RNGeolocation](https://github.com/Agontuk/react-native-geolocation-service) | — | react-native-firebase |
| Expo Modules | expo-haptics | expo-camera | expo SDK sweep (7 packages) |
| Fabric / Paper views | [react-native-segmented-control](https://github.com/react-native-segmented-control/segmented-control) | [react-native-screens](https://github.com/software-mansion/react-native-screens) | [react-native-skia](https://github.com/Shopify/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__*"
    ]
  }
}
```

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




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

```
┌───────────────────────────────────────────────────────────────────┐
│                            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](https://tree-sitter.github.io/) 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

```
/* 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](#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](#supported-languages) — 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 `.gitignore`s 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](#supported-languages) — 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](telemetry-worker/)
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`](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](.github/workflows/release.yml) — never from a laptop — and
carries cryptographic proof of it:

- **npm packages** are published via [trusted publishing](https://docs.npmjs.com/trusted-publishers)
  (OIDC — no long-lived npm tokens exist that could be stolen) with
  [provenance attestations](https://docs.npmjs.com/generating-provenance-statements)
  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](https://docs.github.com/en/actions/security-for-github-actions/using-artifact-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](#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 `.gitignore`d 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`:

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

- [ ] **Step 2: Run tests to verify they fail**

Run: `npx vitest run __tests__/frameworks.test.ts -t djangoResolver`
Expected: FAIL — `djangoResolver.extract` is undefined.

- [ ] **Step 3: Rewrite djangoResolver**

Replace the `djangoResolver` object in `src/resolution/frameworks/python.ts` (approximately lines 7-100) with:

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

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

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

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

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

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

- [ ] **Step 4: Run tests to verify they pass**

Run: `npx vitest run __tests__/frameworks.test.ts -t "flaskResolver|fastapiResolver"`
Expected: PASS (4 tests).

- [ ] **Step 5: Commit**

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

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

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

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

```
14 classes ──implements──▶ Interceptor      (BridgeInterceptor, CacheInterceptor,
                                              CallServerInterceptor, … )
```

Excalidraw's `renderStaticScene`, `Scene`, `Collab` share **no** common
supertype — the ≥3-implementer query returns nothing for them. So the signal
cleanly separates the two repos, and (validated below) leaves every non-sibling
flow untouched.

The `≥ 3` threshold matters: 1:1 "service interface → single impl" pairs (the
common Spring/Java shape) are **not** siblings and stay full. Only genuine
many-impl families (interceptor chains, strategy/visitor families, codec
registries) trip the gate.

## Skeleton rendering

For a skeletonized file we emit the class + member **signature lines** (not
bodies). Because a symbol node's `startLine` can point at a decorator/annotation
(`@Throws`, `@Override`, `@objc`), we scan forward up to 4 lines for the line
that actually *names* the symbol, so the skeleton shows the real signature:

```
#### …/CallServerInterceptor.kt — CallServerInterceptor, intercept, … · skeleton (signatures only; Read for a full body)
```kotlin
30  object CallServerInterceptor : Interceptor {
32  override fun intercept(chain: Interceptor.Chain): Response {
194 private fun shouldIgnoreAndWaitForRealResponse(code: Int): Boolean =
```
```

The header still lists the file's symbols and says `Read for a full body`, so the
agent can pull one specific implementation if it truly needs it.

## Validation (refined gate)

Headless `claude -p`, Opus 4.8, **WITH vs WITHOUT** CodeGraph (the real benchmark
arm, not the on/off probe the first cut used). Cost = median `total_cost_usd`.

| Repo | WITH→WITHOUT cost | WITH reads | WITHOUT reads | RealCall/compiler read-back |
|---|---|---|---|---|
| **OkHttp** (n=4) | **$0.45 → $0.50** (~10% cheaper) | 2 | 3.5 | **0 / —** (RealCall full) |
| **Django** (n=6) | **$0.56 → $0.63** (~10% cheaper) | 2 | 8.5 | half the runs read 0 |

Both were the README's **cost outliers** (OkHttp 3% costlier, Django 10%
costlier) and both flipped to clear wins. OkHttp WITH was cheaper in all 4 runs;
Django in 5 of 6 (n=6 to see through its high variance). WITHOUT baselines match
the README ($0.50/$0.63 vs $0.57/$0.64), so the gain is the WITH-arm improving.

The **decisive check now passes for the right reason**: with the named-callable
spare, OkHttp's `RealCall` stays full and is **never** Read back (it was Read
back in 3/4 runs before the fix). The inert repos (Excalidraw / Tokio / VS Code /
Gin) stay at **0 skeletons** — verified by probe — because the refined gate
skeletonizes a strict subset of the original. (The first cut's "on vs off, reads
flat 1 vs 3" claim came from a deterministic probe query and did **not** hold for
the agent's real query — that mismatch is what this refinement corrects.)

## Dead ends (don't re-attempt these)

1. **Demote/rank low-value files** (e.g. broaden `isLowValuePath` to drop
   `*-testing-support/` fixtures). Improves *content quality* but **not size** —
   explore refills the freed budget with other full bodies (28,478 → 28,424).
   Ranking ≠ shrinking; you must *skeletonize* to shrink.
2. **Gate on entry-node membership.** A precise symbol-bag explore query *names*
   every chain participant, so they're all "entry nodes" — no separation, nothing
   skeletonizes.
3. **Rely on interface-impl synthesizer edges** (`synthesizedBy:'interface-impl'`)
   for the sibling signal. They were **not** created for OkHttp's `Interceptor`
   (a Kotlin `fun interface`), so the signal must come from the real
   `implements`/`extends` edges, not synth edges.
4. **A plain "core-floor" gate** (keep first N full, skeletonize the rest) —
   skeletonized Excalidraw's *distinct* steps → **+17% cost regression**. The
   sibling condition is what makes it safe.
5. **Sparing a file because it DEFINES the supertype** (the first refinement
   attempt). Backwards: a base+subclasses *family* file (Django's `compiler.py`,
   2,266 lines) is huge and Read-anyway, so keeping it full just **eats the 28 KB
   explore budget and starves the sibling files** the agent then Reads — it
   regressed Django to **9% costlier** ($0.71). Defining a supertype is instead
   an **override** that lets a named family file skeletonize anyway.
6. **Validating skeletonization with the deterministic probe query only.** The
   probe (`probe-explore.mjs "<symbol bag>"`) and the *agent's* real explore
   query name symbols differently, so they form different spines and skeletonize
   different files. The probe said "Django: 0 skeletons / reads flat"; the real
   agent query skeletonized `compiler.py` and Read it back. **Always confirm with
   a real-agent A/B (`run-all.sh`), not just the probe.**

## Code

- `src/mcp/tools.ts`
  - `adaptiveExploreEnabled()` — the flag (default on).
  - `buildFlowFromNamedSymbols()` — returns `{ text, pathNodeIds, namedNodeIds }`.
    `namedNodeIds` is every callable the agent named (a superset of the spine) —
    the named-callable spare reads it.
  - `handleExplore()` — two cached helpers: `isPolymorphicSibling()` (a node has
    an outgoing `implements`/`extends` to a ≥3-impl supertype) and
    `definesPolymorphicSupertype()` (a node HAS ≥3 incoming `implements`/`extends`
    — i.e. the file is the family base). The skeleton branch:
    `off-spine && isPolymorphicSibling && !(namedInFile && !definesSupertype)`.
    `exactNodeIds` / `anchorSpans` (the exact targets) and `bodyFocusLines()`
    (what a window of an oversize body must reach) are shared by the focused
    view and the cluster path.
- `src/search/query-paths.ts` — `lineAnchors`: `file:line` / range parsing.
- `__tests__/adaptive-explore-sizing.test.ts` — 7 cases incl. the named-callable
  spare (RealCall) and the supertype-family override (compiler.py).
- `__tests__/explore-exact-target.test.ts` — the django shape in miniature:
  qualified body whole, subclass overrides still signatures, blast-radius lead,
  `file:line` and `lines a-b` anchors, and a windowed body keeping its tail call.

## Frontier / future work

- **Per-symbol skeletonization within a family file.** `compiler.py` is
  skeletonized whole, so `SQLCompiler.execute_sql` (the base mechanism) becomes a
  signature too and *is* Read back in ~half the Django runs. The ideal is to keep
  the base class's methods full and elide only the redundant subclass bodies —
  shrinking the payload without eliding the answer. Whole-file skeletonization
  can't express that yet.
- **Big non-sibling files dominate Django's residual reads.** `query.py` (3,040
  lines) and `sql/query.py` are not polymorphic families, so skeletonization
  can't touch them; the agent Reads them when the 28 KB clustered view is
  insufficient. That's the explore-budget / big-file-clustering frontier, not
  skeletonization.
- **Non-interface sibling families** (Go `HandlerFunc` slices, function-pointer
  registries) aren't caught — they have no `implements`/`extends` edge. Gin's
  middleware chain, for instance, doesn't trip the gate (its handlers are funcs,
  not interface impls).
- **Exemplar selection** when *no* interceptor is on the spine: today all siblings
  skeletonize and the agent leans on the interface contract; showing one as a
  forced exemplar might read slightly better (untested).

---

## File: docs/design/agent-codegraph-adoption.md

# Getting agents to actually use codegraph (not Read) — design notes & handoff

> Working doc for a fresh session. Two problems to crack:
> **(P1)** agents still reach for `Read`/`grep` during implementation instead of codegraph;
> **(P2)** on startup the codegraph MCP server can be `pending` when the agent's first turn fires, so the agent runs with *no* codegraph at all.
>
> Read `codegraph/CLAUDE.md` → "Retrieval performance & dynamic-dispatch coverage" first — it's the doctrine these ideas must respect.

---

## Context — what already shipped (so you don't repeat it)

- **#733 (`7175dc4`)** — reframed the agent-facing steering (`src/mcp/server-instructions.ts` + the `codegraph_node`/`codegraph_explore` descriptions in `src/mcp/tools.ts`) to cover *implementation*, not just Q&A; and added **file-view mode**: `codegraph_node` now accepts a bare `file` (no `symbol`) → returns that file's symbol map + its dependents (blast radius) + verbatim bodies (`includeCode`). `handleFileView` in `src/mcp/tools.ts`.
- **Clean A/B result** (new build vs baseline build, both codegraph-connected, same fully-implemented task — `kindExclude` added to `codegraph_search`):
  - **baseline:** 0 codegraph calls, 8 Reads (agent *ignored* available codegraph).
  - **new:** 2 `codegraph_explore` calls, 5 Reads.
  - So the reframe *did* move tool-choice — but the agent used `codegraph_explore`, **never the file-view**, and still Read 5×. n=1/arm.
- **Eval harness fix** (`#735`): nested attach is a *startup-latency* problem, not a hard block. `scripts/agent-eval/ab-new-vs-baseline.sh` now pre-warms a daemon + skips the re-exec; use it (run non-nested for cleanest results).

**Doctrine constraints (from CLAUDE.md — do not relitigate):**
- *Adapt the tool to the agent.* Changing tool descriptions / `server-instructions.ts` is **low-salience** and has *regressed* wall-clock before. Wording alone won't reliably move tool-choice.
- *New tools fare worse than extending an existing one* (the agent under-picks even `trace`; `codegraph_context` was removed).
- The real levers that landed historically: **coverage** (more flows connect statically → `explore` surfaces them) and **sufficiency** (output complete enough that the agent *stops* reading).
- The optimization target is **wall-clock + tool-call count + Read=0**, not token cost (cost is lower as a side effect).

---

## P1 — Agents under-use codegraph during implementation

### STATUS — 2026-06-08 (RESOLVED via Read-parity, not a hook)

**The fix: make `codegraph_node` read a file *exactly like the Read tool*, only
faster — so the agent reaches for it naturally. No forcing.** The owner's steer
settled the direction: *"codegraph should be able to Read just like the Read
tool… make it as good as Read. Read is slow and old; querying the index is fast.
You keep diverging away from using codegraph rather than pursuing the fix."*

**DONE — `handleFileView` (`src/mcp/tools.ts`) is now full Read parity:**
- A `file` with no `symbol` returns the file's current source numbered
  **byte-for-byte the way Read does — `<n>\t<line>`, no padding, trailing empty
  line kept** (verified by reading the same file with both and diffing). The only
  addition is a **one-line blast-radius header** (`used by N files: …`).
- **`offset` / `limit` mean exactly what they do on Read** (1-based start; max
  lines; default whole file capped at 2000 lines like Read). Large files paginate
  honestly (`(lines X–Y of N — pass offset/limit…)`), never the 15k `truncateOutput` chop.
- Content is the **default** (no `includeCode` needed); `symbolsOnly: true` returns
  the cheap structural map instead. Security preserved: `yaml`/`properties`
  summarized by key, never dumped (#383); reads via `validatePathWithinRoot` (#527).
- Tests: `__tests__/node-file-view.test.ts` (9, incl. strict format parity
  `^1000\t  const v998 = 998;` and unpadded `^1\timport …`). Full suite green
  (1270). Descriptions / `server-instructions.ts` / CHANGELOG reframed: "read a
  source file with codegraph_node instead of Read — same bytes, faster."

**The hook (idea 1) — A/B'd and REJECTED. Do not ship.** Kept only as an eval
artifact (`scripts/agent-eval/redirect-read-hook.sh` + `ab-hook.sh`).
- Clean A/B (2 runs/arm, devpit "add `dp ping`, build it"; both arms codegraph-attached):
  - **nohook:** 0 codegraph calls, 1 Read, **5–7 tool calls, 6–8 turns, 55–77s.** (Reproduces P1: agent ignores codegraph — but read-once-and-edit is *efficient* here.)
  - **hook (deny-redirect):** 0 *successful* Reads + 1 file-view call (parity worked, edit compiled), but **8–9 tool calls, 9–10 turns, 200–239s**, and the agent **fought the deny** — `ToolSearch` to find the tool, reflexive re-Read (denied), then **`Bash python3` to read the file around the block.**
  - Verdict: a blanket Read-deny **regresses the target metrics (~2× tool calls, more turns) on a simple edit** and the agent routes around it. Forcing is the wrong lever; making the tool genuinely better than Read is the right one.
- If routing is ever revisited: not a blanket hook. Either a narrow trigger (large
  files only / after-N-reads) **with a clean A/B on a Read-heavy multi-file task**
  (the hook's best case, untested), or just keep widening coverage + sufficiency.

---

**Symptom:** even with codegraph attached + the new steering, the agent reflexively `Read`s/`grep`s mid-implementation, and never reaches for the file-view. Descriptions can't fix this (low-salience wall).

### Ideas, ranked by expected leverage

1. **PreToolUse(Read/Grep) hook that redirects to codegraph** — *highest leverage; the only channel that actually changes behavior.*
   - Claude Code **hooks** can intercept a tool call and inject context or block it — unlike descriptions, this is *not* low-salience. We already have `scripts/agent-eval/block-read-hook.sh` + `hook-settings.json` (used to force Read=0 in evals).
   - Ship a **recommended (opt-in) hook**: on `Read` (or `Grep`) of a path that's *indexed*, inject "this file is indexed — `codegraph_node {file}` returns it + its blast radius for fewer tokens; treat its output as already-Read." Soft nudge (don't hard-block, or it'll frustrate users on configs/docs codegraph doesn't index).
   - The installer (`src/installer/targets/claude.ts`) could offer to add this hook (opt-in, like the auto-allow permissions).
   - **Validate** with `ab-new-vs-baseline.sh` (Read count, with vs without the hook). This is the experiment most likely to move the needle.
   - Open Qs: how to know a path is indexed from inside a hook (query `codegraph files`/`status`, or a fast local check against `.codegraph`); avoiding noise on non-indexed files; per-language false positives.

2. **Sufficiency: make the file-view the obvious Read replacement so the agent *wants* it.**
   - The A/B showed the agent never passed a `file` to `codegraph_node`. Why? It doesn't think "Read this file" → "codegraph_node file=X". Investigate: is the file-view's value (symbols + dependents + bodies) actually *better than Read* for the agent's next step (an `Edit`)? It returns bodies — but does it return enough surrounding context to `Edit` confidently? If not, the agent Reads anyway.
   - Consider: when the agent *does* Read an indexed file, is there a way to make codegraph's prior `explore`/`node` output have *already* given it what it needed? (i.e. fix the upstream sufficiency, not the Read itself.)

3. **Coverage — the durable lever.** Every flow that connects statically is one the agent doesn't Read to reconstruct. Keep closing dynamic-dispatch gaps (`src/resolution/`). Less about "stop Reading," more about "never need to."

4. **Naming / affordance experiments (low confidence, cheap).** The file-view is buried inside `codegraph_node`. A dedicated, obviously-named affordance might get picked more — *but* "new tools fare worse," so this likely loses. If tried, A/B it; don't assume.

**Recommendation:** prototype **idea 1 (the Read-redirect hook)** and A/B it. It's the one lever with a real chance of moving behavior. Everything else is incremental.

---

## P2 — Agent runs without codegraph because the server is `pending` at startup

**Symptom:** `serve --mcp` isn't ready when the agent's first turn fires (the host marks the MCP server `status:"pending"` / 0 tools), so the agent starts Read/grep and never uses codegraph. We saw this hard in nested evals (~2-3s startup vs the agent's turn-1); **real users hit a milder version** — the first query of a session may not have codegraph.

### Root cause
`serve --mcp` does a `--liftoff-only` **re-exec** (for a node memory flag) **and** spawns/binds a detached **daemon** before tools are usable. Under load that exceeds the host's MCP-startup window. (`CODEGRAPH_WASM_RELAUNCHED=1` skips the re-exec; pre-warming a daemon removes the bind latency — both proven in `ab-new-vs-baseline.sh`. But a real user can't pre-warm.)

### Ideas, ranked

1. **CODEGRAPH-SIDE — expose the static tool list INSTANTLY, decoupled from the daemon. *Biggest shippable win; helps every user.***
   - Hypothesis: the host marks codegraph `pending` because `tools/list` (tool exposure) waits on the daemon connect. The local handshake already answers `initialize` fast (~107ms; `runLocalHandshakeProxy` in `src/mcp/proxy.ts`, `getStaticTools` is imported there). **Investigate: does `serve --mcp` answer `tools/list` *locally and instantly* from `getStaticTools`, or does it forward it to the still-connecting daemon?** If the latter, decouple it: advertise the static tools the moment the client asks, mark connected, and resolve the daemon in the background for actual tool *calls*.
   - Verify with: `printf '\n\n<tools/list>\n' | node dist/bin/codegraph.js serve --mcp --path <repo>` and time the `tools/list` response, daemon-mode vs in-process. In-process answered in ~165ms; daemon-mode is the suspect.
   - If this lands, `pending`-at-startup largely disappears without any host change.

2. **CODEGRAPH-SIDE — speed/skip the re-exec on the MCP serve path.** The re-exec exists for a V8 memory flag (`src/extraction/wasm-runtime-flags.ts`, `RELAUNCH_GUARD_ENV = CODEGRAPH_WASM_RELAUNCHED`). For MCP serving on a normal repo the flag may be unnecessary, or settable without a full process re-exec. Removing one process spawn from the cold path shaves the startup window.

3. **CODEGRAPH-SIDE — a SessionStart hook that pre-warms the daemon.** Ship an opt-in Claude Code `SessionStart` hook (installer-added) that spawns/warms the daemon for the project at session start, so it's bound before the first query. Mitigation if (1) is hard.

4. **HOST-SIDE — "wait/retry on pending" — this is what you asked about, but it's a Claude Code (MCP client) behavior, not codegraph's to fix.** codegraph can't make the agent retry. Options: (a) raise it with Anthropic as an MCP-client improvement (don't let the agent's first turn proceed until configured MCP servers finish connecting, or retry `pending` servers); (b) note `MCP_TIMEOUT` exists but did **not** help here, because the problem is *tool exposure timing*, not a connection timeout. Frame this as a request, and lean on (1)–(3) for what we control.

**Recommendation:** chase **idea 1** (decouple `tools/list` from the daemon). It's the fix that makes codegraph "connected" instantly for everyone. Ship **idea 3** (pre-warm SessionStart hook) as a cheap mitigation in parallel. File the host-side request (4) but don't depend on it.

---

## Key files / pointers

- **Steering / tools:** `src/mcp/server-instructions.ts` (the `initialize` instructions — single source of truth), `src/mcp/tools.ts` (tool descriptions + handlers; `handleNode`/`handleFileView`/`handleSearch`, `getStaticTools`).
- **Startup / daemon / proxy:** `src/mcp/proxy.ts` (`runProxy`, `connectWithHello`, `runLocalHandshakeProxy`, PPID watchdog), `src/mcp/index.ts` (`runProxyWithLocalHandshake`, `spawnDetachedDaemon`), `src/mcp/daemon.ts`.
- **Runtime flags:** `src/extraction/wasm-runtime-flags.ts` (`RELAUNCH_GUARD_ENV=CODEGRAPH_WASM_RELAUNCHED`, `HOST_PPID_ENV=CODEGRAPH_HOST_PPID`).
- **Hooks (existing):** `scripts/agent-eval/block-read-hook.sh`, `scripts/agent-eval/hook-settings.json` (the eval's force-Read-0 hook — basis for the P1 redirect hook).
- **Installer (where to add a recommended hook):** `src/installer/targets/claude.ts`.
- **Eval harness:** `scripts/agent-eval/ab-new-vs-baseline.sh` (new-vs-baseline, pre-warm baked in), `run-all.sh` (with-vs-without), `parse-run.mjs` (tool-by-type counts; `codegraph tools exposed: 0` + 0 codegraph calls = ran without).
- **Doctrine:** `CLAUDE.md` → "Retrieval performance & dynamic-dispatch coverage" + the agent-eval note under "Validation methodology".

## How to validate anything here
- **P1 (Read displacement):** `bash scripts/agent-eval/ab-new-vs-baseline.sh  "" [baseline-ref]` — compare `Read` vs `mcp__codegraph__*` counts. ≥2 runs/arm (n=1 is noisy). Run non-nested for cleanest results. Use a *genuinely new* feature task (verify it doesn't already exist — the first A/B attempt wasted a run on an already-implemented `--quiet`).
- **P2 (startup):** time `tools/list` from `serve --mcp` (above); and count cold-start runs where `init` shows `connected` + tools > 0. Don't trust a single `pending` init snapshot — confirm by whether the agent actually called codegraph.

## Constraints / gotchas to remember
- Descriptions/instructions are low-salience — **A/B every behavioral claim**, don't ship wording on faith.
- New tools < extending existing ones.
- The host's `init` snapshot can say `pending` even when the server then connects — judge by actual usage.
- Don't run evals nested for "clean" numbers unless pre-warmed; even then, a real terminal is better.

## Suggested start order for the fresh session
1. **P2 idea 1** — verify whether `serve --mcp` answers `tools/list` locally/instantly; if not, decouple it from the daemon. (Highest-value, shippable, helps all users, no behavioral guesswork.)
2. **P1 idea 1** — prototype the PreToolUse(Read) redirect hook; A/B it. (Highest-value behavioral lever.)
3. Ship the P2 SessionStart pre-warm hook as a mitigation; file the host-side wait/retry request.

---

## File: docs/design/callback-edge-synthesis.md

# Design + status: general callback / observer edge synthesis

**Status:** SHIPPED (the synthesizer in `callback-synthesizer.ts` is merged and on
`main`). This doc records the original design.
**Motivation:** close the dynamic-dispatch hole that static extraction leaves for
observer / event-emitter / signal patterns, where a *dispatcher* invokes callbacks
registered elsewhere through a shared store — so flows like "how does an update
reach the screen" actually exist in the graph.

> **Update (2026-06-01):** the `codegraph_trace` and `codegraph_context` MCP tools
> were since **removed** — `codegraph_explore` is the single surfacing tool now. Its
> "Flow" section (`buildFlowFromNamedSymbols`) and the `codegraph_node` trail surface
> these synthesized edges; the `trace(a, b)` notation below means "the a→b flow,"
> which you now verify with `codegraph_explore` / `probe-explore.mjs` (the
> `probe-trace.mjs` / `probe-context.mjs` dev probes went away with the tools).

---

## TL;DR for a new session

We synthesize `dispatcher → callback` edges that static parsing misses. It works:

- **Field observer** (excalidraw `Scene.onUpdate`/`triggerUpdate`): synthesizes
  `triggerUpdate → triggerRender`. `trace(mutateElement, triggerRender)` now = 3 hops.
- **EventEmitter** (express `on('mount', …)`/`emit('mount')`): synthesizes `use → onmount`.
- Precision is high: excalidraw got **1** synthesized edge out of 27k (the correct one);
  node count moved +3 after Phase 3 (no explosion).

**Files touched (all uncommitted on `main`):**
- `src/resolution/callback-synthesizer.ts` — the whole-graph synthesis pass (Phase 1 + 2).
- `src/resolution/index.ts` — calls `synthesizeCallbackEdges()` at the end of
  `resolveAndPersistBatched()` (after base edges are persisted) + the import.
- `src/extraction/tree-sitter.ts` — `visitFunctionBody` now extracts **named** nested
  functions (Phase 3), so inline named handlers become linkable nodes.

**How to reproduce / test:**
```bash
npm run build
rm -rf /tmp/codegraph-corpus/excalidraw/.codegraph
( cd /tmp/codegraph-corpus/excalidraw && codegraph init -i )
# synthesized edges (provenance='heuristic', metadata.synthesizedBy in {callback,event-emitter,…,http-client,queue-job,event-bus}):
sqlite3 /tmp/codegraph-corpus/excalidraw/.codegraph/codegraph.db \
  "select s.name||' → '||t.name||'  '||coalesce(e.metadata,'') from edges e \
   join nodes s on e.source=s.id join nodes t on e.target=t.id where e.provenance='heuristic';"
# end-to-end flow (the synthesized edge shows up in explore's Flow section + node trail):
node scripts/agent-eval/probe-explore.mjs /tmp/codegraph-corpus/excalidraw "triggerUpdate triggerRender"
```
Probe scripts (dev-only, in `scripts/agent-eval/`): `probe-node.mjs` (symbol + trail),
`probe-explore.mjs` (relevant source + the flow among named symbols). EventEmitter
fixture lives at `/tmp/cb-fixture/bus.js` (ephemeral — recreate or move into `__tests__/`).

---

## Cross-tier channels (`src/resolution/tier-synthesizer.ts`, 2026-08-28)

The web's RN bridge: one pass, registered before the in-process emitter pass (the more specific edge wins a 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`.

## Next.js links (`src/resolution/next-router-synthesizer.ts`, 2026-08-28)

`<Link href="/x">`, `<Link href={`/users/${id}`}>`, `<Link href={{ pathname }}>` and an internal `<a href>` are JSX
attributes — no reference is ever extracted for them — so this pass reads them from the source, attributes each to the
component it is written in, matches the href against the Next page table (`frameworks/nextjs.ts`) and synthesizes 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`.

## Related work (same coverage effort)
This is one half of closing dynamic-dispatch coverage. The other artifacts on `main`:
- **Named attribute/descriptor resolver**: `claimsReference` (`resolution/types.ts`,
  pre-filter 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`](./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`](./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 `request`s, 8 `task`s), 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 `cat`s 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`](../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 `@Composable`s 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 `trace`s 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.