feat(resolution): gin middleware-chain synthesizer + Opus 4.8 benchmark refresh (#547)

* fix(agent-eval): detect idle by content-stability, not spinner absence

Opus 4.8's extended-thinking TUI shows no spinner / interrupt hint / timer while it streams its final answer — those appear only during the thinking and tool-use phases. The old detector treated ~5s of not-busy + prompt-present as done, so it killed interactive runs mid-answer, silently truncating both arms of the tmux A/B (low tool counts; the final assistant message left as a mid-investigation preamble). Now a run is done only when the captured pane stops changing for ~8s; while streaming, the pane changes every poll so stability never accrues. BUSY_RE stays as the immediate busy-reset for the thinking/tool/live-timer phase. Content-stability is model-agnostic — it survives future spinner re-wordings.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs(readme): refresh VS Code benchmark on v0.9.7 + Opus 4.8

Re-ran the VS Code A/B (headless median-of-4) on the current build and model. Cost savings held at 26% ($0.66->$0.89), but token/time/tool-call savings narrowed (78->63%, 52->20%, 85->69%) because Opus 4.8's without-CodeGraph arm explores far more efficiently than 4.7's did (16 tool calls vs 55, no Explore-subagent fan-out); the WITH arm is unchanged at 5 calls / 0 reads. Recomputed the average row and noted that the VS Code row is now a different model/version epoch than the other six.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(resolution): synthesize gin middleware-chain edges (Next -> registered handlers)

Gin runs its entire handler chain through one dynamic line in (*Context).Next -- c.handlers[c.index](c), a slice-index dispatch tree-sitter can't resolve. So callees(Next) dead-ended at the len() helper and the flow ServeHTTP -> handleHTTPRequest -> Next stopped at the exact symbol a 'how does the middleware chain work' question is about, sending the agent to re-query and Read/grep (a measured gin WITH-arm rabbit-hole: 2/4 headless runs spiraled to ~5min, one mis-firing the opt-in Workflow orchestration tool). Find the chain dispatcher (a Go method invoking a handlers slice by index) and link it -> every HandlerFunc registered via .Use/.GET/.../.Handle, so callees(Next) and trace(ServeHTTP, handler) connect end-to-end. Gated on the dispatcher existing (inert on non-gin Go repos), named handlers only (inline closures skipped), capped; provenance heuristic / synthesizedBy gin-middleware-chain, registeredAt = the registration site. Validated: gin callees(Next) now surfaces Logger/Recovery/ErrorLogger + handlers (node count stable at 2,544; 5 precise edges); agent A/B (headless median-of-4, Opus 4.8) flipped gin from -58% cost / -129% time to +7% cost / +35% tokens / +8% time / 38% tool calls, all 4 WITH runs clean (0 Read/Grep/Bash). 167/167 unit tests pass incl. the new gin-middleware-chain test.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs(readme): publish uniform Opus 4.8 benchmark + per-repo breakdown accordion

Refresh all 7 benchmark rows to the v0.9.7 / Opus 4.8 headless median-of-4 (was a mix of the 4.8 VS Code row + six 4.7 rows). New average 18% cheaper / 51% fewer tokens / 16% faster / 57% fewer tool calls; headline + methodology note updated 4.7->4.8. The gap is smaller than the prior 4.7 numbers because Opus 4.8's native grep/read is more efficient (the without-arm no longer fans out into large Explore-subagent sweeps) -- not a codegraph regression; CodeGraph still cuts tool calls and tokens on all 7 repos, with cost marginal/negative only on django + okhttp. Adds a top-level 'Per-repo breakdown' accordion (per-metric Time/Reads/Grep-Bash/Tool calls/Tokens/Cost, WITH vs WITHOUT, per repo) directly below the condensed summary; methodology/queries/why-wins move to a second accordion.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs(changelog): note Gin middleware-chain synthesizer under [Unreleased]

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Colby Mchenry
2026-05-28 23:41:11 -05:00
committed by GitHub
co-authored by Claude Opus 4.8
parent 7a75c82dd9
commit f58de8a391
6 changed files with 350 additions and 38 deletions
+1
View File
@@ -12,6 +12,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
### New Features
- `codegraph init` now builds the initial index by default — you no longer need the `-i`/`--index` flag (it's still accepted, so existing commands and scripts keep working). (#483)
- Go: Gin middleware chains now connect end-to-end in `codegraph_trace` and `codegraph_explore` — following a request reaches the middleware and route handlers registered via `.Use()` / `.GET()` instead of dead-ending where the framework dispatches the chain dynamically.
### Fixes
+88 -24
View File
@@ -4,7 +4,7 @@
### Supercharge Claude Code, Cursor, Codex, OpenCode, Hermes Agent, Gemini, Antigravity, and Kiro with Semantic Code Intelligence
**~35% cheaper · ~70% fewer tool calls · 100% local**
**~18% cheaper · ~57% fewer tool calls · 100% local**
### [Documentation & Website →](https://colbymchenry.github.io/codegraph/)
@@ -83,26 +83,101 @@ When Claude Code explores a codebase, it spawns **Explore agents** that scan fil
### 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. Each cell is the savings at the **median of 4 runs per arm**. _Re-validated on **v0.9.4** (2026-05-24)._
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. Each cell is the savings at the **median of 4 runs per arm**. _Re-validated on **v0.9.7** + Opus 4.8 (2026-05-28)._
> **Average: 35% cheaper · 57% fewer tokens · 46% faster · 71% fewer tool calls**
> **Average: 18% cheaper · 51% fewer tokens · 16% faster · 57% fewer tool calls**
| Codebase | Language | Cost | Tokens | Time | Tool calls |
|----------|----------|------|--------|------|------------|
| **VS Code** | TypeScript · ~10k files | 26% cheaper | 78% fewer | 52% faster | 85% fewer |
| **Excalidraw** | TypeScript · ~640 | 52% cheaper | 90% fewer | 73% faster | 96% fewer |
| **Django** | Python · ~3k | 12% cheaper | 36% fewer | 19% faster | 53% fewer |
| **Tokio** | Rust · ~790 | 82% cheaper | 86% fewer | 71% faster | 92% fewer |
| **OkHttp** | Java · ~645 | 2% cheaper | 13% fewer | 31% faster | 45% fewer |
| **Gin** | Go · ~110 | 21% cheaper | 34% fewer | 27% faster | 40% fewer |
| **Alamofire** | Swift · ~110 | 47% cheaper | 64% fewer | 48% faster | 83% fewer |
| **VS Code** | TypeScript · ~10k files | 26% cheaper | 63% fewer | 20% faster | 69% fewer |
| **Excalidraw** | TypeScript · ~640 | 40% cheaper | 71% fewer | 41% faster | 82% fewer |
| **Django** | Python · ~3k | 10% costlier | 45% fewer | 3% slower | 64% fewer |
| **Tokio** | Rust · ~790 | 30% cheaper | 69% fewer | 22% faster | 71% fewer |
| **OkHttp** | Java · ~645 | 3% costlier | 32% fewer | 15% faster | 60% fewer |
| **Gin** | Go · ~110 | 7% cheaper | 35% fewer | 8% faster | 38% fewer |
| **Alamofire** | Swift · ~110 | 38% cheaper | 45% fewer | 6% faster | 8% fewer |
The gains scale with codebase size: on large repos the agent answers from the index in a handful of calls with **zero file reads**, while the no-CodeGraph agent fans out across grep/find/Read (and the sub-agents it spawns). On a small repo like Gin (~150 files) native search is already cheap, so the margin narrows.
CodeGraph cuts **tool calls and total tokens on every repo** and answers large repos with **zero file reads**, while the no-CodeGraph agent spends its budget on grep/find/Read discovery. The **cost** margin is narrower — and occasionally negative on smaller repos (Django, OkHttp) — because a modern model's native search is already cheap and CodeGraph's richer responses cost real input tokens; the consistent wins are fewer tool calls and faster answers.
<details>
<summary><strong>Per-repo breakdown — WITH vs WITHOUT (median of 4)</strong></summary>
**VS Code** · ~10k files
| Metric | WITH cg | WITHOUT cg | Δ |
|---|---|---|---|
| Time | 1m 49s | 2m 16s | 20% faster |
| File Reads | 0 | 7 | 7 |
| Grep/Bash | 0 | 9 | 9 |
| Tool calls | 5 | 16 | 71% fewer |
| Total tokens | 672k | 1.81M | 63% fewer |
| Cost | $0.66 | $0.89 | 26% cheaper |
**Excalidraw** · ~640 files
| Metric | WITH cg | WITHOUT cg | Δ |
|---|---|---|---|
| Time | 1m 41s | 2m 51s | 41% faster |
| File Reads | 0 | 11 | 11 |
| Grep/Bash | 0 | 11 | 11 |
| Tool calls | 4 | 22 | 82% fewer |
| Total tokens | 692k | 2.39M | 71% fewer |
| Cost | $0.63 | $1.04 | 40% cheaper |
**Django** · ~3k files
| Metric | WITH cg | WITHOUT cg | Δ |
|---|---|---|---|
| Time | 2m 2s | 1m 58s | 4% slower |
| File Reads | 2 | 10 | 8 |
| Grep/Bash | 0 | 5 | 5 |
| Tool calls | 5 | 14 | 64% fewer |
| Total tokens | 720k | 1.30M | 45% fewer |
| Cost | $0.70 | $0.64 | 10% costlier |
**Tokio** · ~790 files
| Metric | WITH cg | WITHOUT cg | Δ |
|---|---|---|---|
| Time | 1m 54s | 2m 26s | 22% faster |
| File Reads | 0 | 11 | 11 |
| Grep/Bash | 0 | 6 | 6 |
| Tool calls | 5 | 17 | 73% fewer |
| Total tokens | 657k | 2.10M | 69% fewer |
| Cost | $0.61 | $0.86 | 30% cheaper |
**OkHttp** · ~645 files
| Metric | WITH cg | WITHOUT cg | Δ |
|---|---|---|---|
| Time | 1m 18s | 1m 32s | 15% faster |
| File Reads | 1 | 5 | 4 |
| Grep/Bash | 0 | 6 | 6 |
| Tool calls | 4 | 10 | 58% fewer |
| Total tokens | 713k | 1.05M | 32% fewer |
| Cost | $0.59 | $0.57 | 3% costlier |
**Gin** · ~110 files
| Metric | WITH cg | WITHOUT cg | Δ |
|---|---|---|---|
| Time | 1m 12s | 1m 18s | 9% faster |
| File Reads | 0 | 4 | 4 |
| Grep/Bash | 0 | 4 | 4 |
| Tool calls | 5 | 8 | 40% fewer |
| Total tokens | 533k | 815k | 35% fewer |
| Cost | $0.44 | $0.47 | 7% cheaper |
**Alamofire** · ~110 files
| Metric | WITH cg | WITHOUT cg | Δ |
|---|---|---|---|
| Time | 2m 0s | 2m 7s | 6% faster |
| File Reads | 6 | 8 | 2 |
| Grep/Bash | 2 | 4 | 2 |
| Tool calls | 11 | 12 | 9% fewer |
| Total tokens | 1.09M | 1.98M | 45% fewer |
| Cost | $0.63 | $1.01 | 38% cheaper |
</details>
<details>
<summary><strong>Full benchmark details</strong></summary>
**Methodology.** Each arm is `claude -p` (Claude Opus 4.7) 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 (input incl. cached + 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-validated on codegraph **v0.9.4** (2026-05-24); per-repo numbers move run-to-run with how hard the without-arm thrashes (the median-of-4 smooths it, but tails remain — e.g. Tokio's without-arm hit $2.41/3m one batch).
**Methodology.** Each arm is `claude -p` (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 (input incl. cached + 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-validated on codegraph **v0.9.7** (2026-05-28). These numbers are lower than the prior Opus 4.7 validation — not a CodeGraph regression but a stronger native baseline: Opus 4.8 greps/reads efficiently on the main thread instead of fanning out into large Explore-subagent sweeps, so the no-CodeGraph arm is leaner than it used to be. Per-repo numbers move run-to-run with how hard the without-arm thrashes (the median-of-4 smooths it, but tails remain — e.g. Django's without-arm hit $2.71/14m one batch).
**Queries:**
| Codebase | Query |
@@ -115,18 +190,7 @@ The gains scale with codebase size: on large repos the agent answers from the in
| Gin | "How does gin route requests through its middleware chain?" |
| Alamofire | "How does Alamofire build, send, and validate a request?" |
**Raw medians — WITH → WITHOUT:**
| Codebase | Cost | Tokens | Time | Tool calls |
|----------|------|--------|------|------------|
| VS Code | $0.60 → $0.80 | 601k → 2.8M | 1m 10s → 2m 26s | 8 → 55 |
| Excalidraw | $0.43 → $0.90 | 344k → 3.5M | 48s → 2m 58s | 3 → 79 |
| Django | $0.59 → $0.67 | 739k → 1.2M | 1m 19s → 1m 38s | 9 → 19 |
| Tokio | $0.42 → $2.41 | 379k → 2.6M | 53s → 3m 2s | 4 → 53 |
| OkHttp | $0.47 → $0.47 | 636k → 730k | 42s → 1m 1s | 6 → 11 |
| Gin | $0.37 → $0.47 | 444k → 675k | 44s → 1m 0s | 6 → 10 |
| Alamofire | $0.61 → $1.14 | 1.0M → 2.8M | 1m 17s → 2m 27s | 12 → 69 |
**Why CodeGraph wins:** with the index available, the agent answers directly — `codegraph_context` to map the area, then one `codegraph_explore` for the relevant source — and stops, usually with zero file reads. Without it, the agent (and the Explore sub-agents it spawns) 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.
**Why CodeGraph wins:** with the index available, the agent answers directly — `codegraph_context` to map the area, then one `codegraph_explore` for the relevant source — and stops, usually with zero file reads. 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.
</details>
+113
View File
@@ -0,0 +1,113 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import * as fs from 'node:fs';
import * as path from 'node:path';
import * as os from 'node:os';
import { CodeGraph } from '../src';
/**
* End-to-end synthesizer test for the gin middleware chain.
*
* `(*Context).Next` runs the handler chain by slice index
* (`c.handlers[c.index](c)`) — a computed dispatch tree-sitter can't resolve, so
* `callees(Next)` would otherwise dead-end at the `len()` helper. Handlers are
* registered via `.Use(...)` / `.GET("/path", h)`. Verify the synthesizer links
* `Next` → each registered NAMED HandlerFunc, captures the wiring site, and
* skips inline (anonymous) closures.
*/
describe('gin middleware-chain synthesizer', () => {
let dir: string;
beforeEach(() => {
dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gin-chain-fixture-'));
});
afterEach(() => {
fs.rmSync(dir, { recursive: true, force: true });
});
it('links Context.Next to handlers registered via Use/GET and skips inline closures', async () => {
fs.writeFileSync(path.join(dir, 'go.mod'), 'module ginapp\n\ngo 1.21\n');
// gin-core shape: the dynamic-dispatch chain driver + registration surface.
fs.writeFileSync(
path.join(dir, 'gin.go'),
`package gin
type HandlerFunc func(*Context)
type HandlersChain []HandlerFunc
type Context struct {
handlers HandlersChain
index int8
}
func (c *Context) Next() {
c.index++
for c.index < int8(len(c.handlers)) {
c.handlers[c.index](c)
c.index++
}
}
type Engine struct {
Handlers HandlersChain
}
func (e *Engine) Use(middleware ...HandlerFunc) {
e.Handlers = append(e.Handlers, middleware...)
}
func (e *Engine) GET(path string, handlers ...HandlerFunc) {}
`
);
// registration site: named middleware + named route handler + an inline closure.
fs.writeFileSync(
path.join(dir, 'app.go'),
`package gin
func Logger(c *Context) {}
func Recovery(c *Context) {}
func getUser(c *Context) {}
func setup() {
e := &Engine{}
e.Use(Logger, Recovery)
e.GET("/users", getUser)
e.GET("/inline", func(c *Context) {})
}
`
);
const cg = await CodeGraph.init(dir, { silent: true });
await cg.indexAll();
const db = (cg as any).db.db;
const rows = db
.prepare(
`SELECT s.name source_name, s.kind source_kind, t.name target_name,
json_extract(e.metadata,'$.via') via,
json_extract(e.metadata,'$.registeredAt') registeredAt
FROM edges e
JOIN nodes s ON s.id = e.source
JOIN nodes t ON t.id = e.target
WHERE json_extract(e.metadata,'$.synthesizedBy') = 'gin-middleware-chain'`
)
.all();
cg.close?.();
// Every edge originates from the chain dispatcher Context.Next.
expect(rows.length).toBeGreaterThan(0);
expect(rows.every((r: any) => r.source_name === 'Next' && r.source_kind === 'method')).toBe(true);
// Exactly the three NAMED handlers are linked — the inline closure (4th
// registration) is anonymous and must be skipped.
const targets = new Set(rows.map((r: any) => r.target_name));
expect(targets).toEqual(new Set(['Logger', 'Recovery', 'getUser']));
// The wiring site (`.Use`/`.GET` call) is surfaced for the agent.
const logger = rows.find((r: any) => r.target_name === 'Logger');
expect(logger.via).toBe('Logger');
expect(logger.registeredAt).toMatch(/app\.go:\d+/);
});
});
@@ -182,7 +182,7 @@ Status legend: ✅ done+validated · 🔬 hole identified · ⬜ not started.
| 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 | 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). 🔬 inline `func(c){}` handlers (anonymous, body lost); subrouter/`PathPrefix` path-prefix not prepended (label only); gitness chi custom (26/321) |
| 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) |
| 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>::<id>` + `<sql id="X">` fragments + `<include refid>` 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 `<include>` 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) |
+24 -11
View File
@@ -81,25 +81,38 @@ for _ in $(seq 1 120); do
done
[ "$started" = 1 ] || { echo "agent never started working"; cap; tmux kill-session -t "$SESSION" 2>/dev/null; exit 1; }
# Poll for idle: not busy AND present, for 10 consecutive polls (~5s) to ride
# out mid-conversation thinking gaps that briefly drop the spinner. Up to ~15min.
consec=0
for _ in $(seq 1 1800); do
pane=$(cap)
if echo "$pane" | grep -qE "$BUSY_RE"; then
consec=0
elif echo "$pane" | grep -q ""; then
consec=$((consec+1)); [ "$consec" -ge 10 ] && break
# Poll for idle. CRITICAL: Opus 4.8 (extended thinking) renders NO spinner /
# "esc to interrupt" / timer while it STREAMS its final answer — those appear
# only during the thinking + tool-use phases ("✻ Marinating… (32s · ↓ 1.3k
# tokens · thinking with max effort)"). So BUSY_RE reads "not busy" for the whole
# 10-30s answer stream, and any short not-busy threshold kills the run mid-answer
# (the truncation bug). We therefore detect "done" by CONTENT STABILITY, not by a
# spinner string: while the agent streams, the captured pane changes every poll,
# so stability never accrues; it accrues only once the agent has finished and the
# static "✻ Brewed for 1m 9s" summary is all that is left. BUSY_RE still hard-
# resets stability (covers thinking/tool-use/live-timer, where text can briefly
# sit still). Need STABLE_NEEDED polls (~8s) of zero pane change + present.
# Content-stability is model-agnostic — it survives future spinner re-wordings.
STABLE_NEEDED=16
prev=""; stable=0
for _ in $(seq 1 2400); do # up to ~20 min
pane="$(cap)"
sig="$(printf '%s' "$pane" | tr -s '[:space:]' ' ')"
if printf '%s' "$pane" | grep -qE "$BUSY_RE"; then
stable=0 # thinking / tool use / live timer → busy
elif [ -n "$sig" ] && [ "$sig" = "$prev" ] && printf '%s' "$pane" | grep -q ""; then
stable=$((stable+1)); [ "$stable" -ge "$STABLE_NEEDED" ] && break
else
consec=0
stable=0 # answer still streaming → pane changing
fi
prev="$sig"
sleep 0.5
done
sleep 1
tmux capture-pane -p -t "$SESSION" -S - > "$OUT"
echo "captured $(wc -l < "$OUT") lines -> $OUT"
grep -oE "Done \([^)]*\)" "$OUT" | tail -1
grep -oE "Done \([^)]*\)|[A-Z][a-z]+ for ([0-9]+m )?[0-9]+s" "$OUT" | tail -1
grep -oE "[0-9.]+k?/[0-9.]+M" "$OUT" | tail -1 | sed 's/^/Context /'
tmux kill-session -t "$SESSION" 2>/dev/null
+123 -2
View File
@@ -25,6 +25,7 @@ import type { Edge, Node } from '../types';
import type { QueryBuilder } from '../db/queries';
import type { ResolutionContext } from './types';
import { isGeneratedFile } from '../extraction/generated-detection';
import { stripCommentsForRegex } from './strip-comments';
const REGISTRAR_NAME = /^(on[A-Z]\w*|subscribe|addListener|addEventListener|register|watch|listen|addCallback)$/;
const DISPATCHER_NAME = /(emit|trigger|notify|dispatch|fire|publish|flush)/i;
@@ -966,11 +967,129 @@ function mybatisJavaXmlEdges(queries: QueryBuilder): Edge[] {
return edges;
}
/**
* Gin middleware chain. Gin runs its entire handler chain through one dynamic
* line in `(*Context).Next`:
* for c.index < len(c.handlers) { c.handlers[c.index](c); c.index++ }
* `c.handlers` is a `HandlersChain` (`[]HandlerFunc`) assembled at registration
* time by `combineHandlers` from the funcs passed to `r.Use(...)` /
* `r.GET("/path", h...)` / `r.Handle(...)`. Because the call is a computed index
* into a runtime-built slice, tree-sitter resolves `c.handlers[c.index](c)` to
* NOTHING — so `callees(Next)` is just the `len()` helper and the flow
* `ServeHTTP → handleHTTPRequest → Next` dead-ends at the exact symbol the
* "how do requests flow through the middleware chain" question is about. The
* agent then re-queries Next and falls back to Read/grep (validated: the gin
* WITH-arm rabbit-holed on precisely this dead-end).
*
* Bridge it: find the chain DISPATCHER (a Go method whose body invokes a
* `handlers` slice by index) and link it → every HandlerFunc registered via a
* gin registration call, so `callees(Next)` and `trace(ServeHTTP, <handler>)`
* connect end-to-end. Named handlers only (`gin.Logger()` → `Logger`,
* `authMiddleware`); inline closures are anonymous and skipped. Like
* react-render / interface-impl this is a deliberate over-approximation —
* reachability-correct (any registered handler CAN run for some route), capped,
* and gated on the dispatcher existing so it never runs on non-gin Go repos.
* Provenance `heuristic`, `synthesizedBy:'gin-middleware-chain'`; `registeredAt`
* is the `.Use`/`.GET` site an agent would otherwise grep for.
*/
const GIN_DISPATCH_RE = /\.handlers\s*\[[^\]]*\]\s*\(/; // c.handlers[c.index](c)
const GIN_REG_RE = /\.(?:Use|GET|POST|PUT|PATCH|DELETE|OPTIONS|HEAD|Any|Handle)\s*\(/g;
/** Balanced `(...)` body starting at the '(' index; null if unbalanced. */
function goBalancedArgs(s: string, openIdx: number): string | null {
let depth = 0;
for (let i = openIdx; i < s.length; i++) {
const c = s[i];
if (c === '(') depth++;
else if (c === ')') { depth--; if (depth === 0) return s.slice(openIdx + 1, i); }
}
return null;
}
/** Split a top-level comma list, respecting nested () [] {}. */
function goSplitArgs(args: string): string[] {
const out: string[] = [];
let depth = 0, cur = '';
for (const c of args) {
if (c === '(' || c === '[' || c === '{') { depth++; cur += c; }
else if (c === ')' || c === ']' || c === '}') { depth--; cur += c; }
else if (c === ',' && depth === 0) { out.push(cur); cur = ''; }
else cur += c;
}
if (cur.trim()) out.push(cur);
return out;
}
/** Tail ident of a handler arg: `gin.Logger()`→`Logger`, `mw`→`mw`; null for string paths / closures. */
function goHandlerIdent(expr: string): string | null {
const cleaned = expr.trim().replace(/\(\s*\)$/, ''); // drop a trailing call ()
if (!cleaned || cleaned.startsWith('"') || cleaned.startsWith('`') || cleaned.startsWith('func')) return null;
const m = cleaned.match(/(?:\.|^)([A-Za-z_]\w*)$/);
return m ? m[1]! : null;
}
function ginMiddlewareChainEdges(queries: QueryBuilder, ctx: ResolutionContext): Edge[] {
// 1. Find the chain dispatcher(s): a Go method that invokes a `handlers` slice by index.
const dispatchers = queries.getNodesByKind('method').filter((n) => {
if (n.language !== 'go') return false;
const content = ctx.readFile(n.filePath);
const src = content && sliceLines(content, n.startLine, n.endLine);
return !!src && GIN_DISPATCH_RE.test(src);
});
if (dispatchers.length === 0) return []; // not a gin repo — bail
// 2. Collect handler identifiers registered via gin registration calls
// (.Use / .GET / … / .Handle). String args (paths/methods) and inline
// closures are dropped by goHandlerIdent; the rest are HandlerFuncs.
const registered = new Map<string, string>(); // name → registeredAt (file:line)
for (const file of ctx.getAllFiles()) {
if (!file.endsWith('.go')) continue;
const content = ctx.readFile(file);
if (!content || (!content.includes('.Use(') && !/\.(?:GET|POST|PUT|PATCH|DELETE|OPTIONS|HEAD|Any|Handle)\(/.test(content))) continue;
const safe = stripCommentsForRegex(content, 'go');
GIN_REG_RE.lastIndex = 0;
let m: RegExpExecArray | null;
while ((m = GIN_REG_RE.exec(safe))) {
const parenIdx = m.index + m[0].length - 1;
const argStr = goBalancedArgs(safe, parenIdx);
if (!argStr) continue;
const line = safe.slice(0, m.index).split('\n').length;
for (const arg of goSplitArgs(argStr)) {
const name = goHandlerIdent(arg);
if (name && !registered.has(name)) registered.set(name, `${file}:${line}`);
}
}
}
if (registered.size === 0) return [];
// 3. Link each dispatcher → each registered handler node (dedup, capped).
const edges: Edge[] = [];
const seen = new Set<string>();
for (const disp of dispatchers) {
let added = 0;
for (const [name, registeredAt] of registered) {
if (added >= MAX_CALLBACKS_PER_CHANNEL) break;
const handler = ctx.getNodesByName(name).find(
(n) => (n.kind === 'function' || n.kind === 'method') && n.language === 'go'
);
if (!handler || handler.id === disp.id) continue;
const key = `${disp.id}>${handler.id}`;
if (seen.has(key)) continue;
seen.add(key);
edges.push({
source: disp.id, target: handler.id, kind: 'calls', line: disp.startLine,
provenance: 'heuristic',
metadata: { synthesizedBy: 'gin-middleware-chain', via: name, registeredAt },
});
added++;
}
}
return edges;
}
/**
* Synthesize dispatcher→callback edges (field observers + EventEmitters +
* React re-render + JSX children + Vue templates + RN event channel +
* Fabric native-impl + MyBatis Java↔XML). Returns the count added. Never
* throws into indexing — callers wrap in try/catch.
* Fabric native-impl + MyBatis Java↔XML + Gin middleware chain). Returns the
* count added. Never throws into indexing — callers wrap in try/catch.
*/
export function synthesizeCallbackEdges(queries: QueryBuilder, ctx: ResolutionContext): number {
const fieldEdges = fieldChannelEdges(queries, ctx);
@@ -985,6 +1104,7 @@ export function synthesizeCallbackEdges(queries: QueryBuilder, ctx: ResolutionCo
const rnEventEdgesList = rnEventEdges(ctx);
const fabricNativeEdges = fabricNativeImplEdges(ctx);
const mybatisEdges = mybatisJavaXmlEdges(queries);
const ginEdges = ginMiddlewareChainEdges(queries, ctx);
const merged: Edge[] = [];
const seen = new Set<string>();
@@ -1001,6 +1121,7 @@ export function synthesizeCallbackEdges(queries: QueryBuilder, ctx: ResolutionCo
...rnEventEdgesList,
...fabricNativeEdges,
...mybatisEdges,
...ginEdges,
]) {
const key = `${e.source}>${e.target}`;
if (seen.has(key)) continue;