diff --git a/CHANGELOG.md b/CHANGELOG.md index 1480ea4..cd89e72 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/README.md b/README.md index 6b55f2d..96a3f42 100644 --- a/README.md +++ b/README.md @@ -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. + +
+Per-repo breakdown — WITH vs WITHOUT (median of 4) + +**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 | + +
Full benchmark details -**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.
diff --git a/__tests__/gin-middleware-chain.test.ts b/__tests__/gin-middleware-chain.test.ts new file mode 100644 index 0000000..b3f0ae0 --- /dev/null +++ b/__tests__/gin-middleware-chain.test.ts @@ -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+/); + }); +}); diff --git a/docs/design/dynamic-dispatch-coverage-playbook.md b/docs/design/dynamic-dispatch-coverage-playbook.md index cb7fdc8..1124e9f 100644 --- a/docs/design/dynamic-dispatch-coverage-playbook.md +++ b/docs/design/dynamic-dispatch-coverage-playbook.md @@ -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..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 → `` SQL | R (XML extract) + S (Java↔XML synthesizer) | ✅ **XML mapper as first-class language** (#389) — `src/extraction/mybatis-extractor.ts` parses files containing ``; emits one method-shaped node per statement qualified `::` + `` fragments + `` references. Non-mapper XML (pom, log4j) → file node only. `mybatisJavaXmlEdges` synthesizer indexes Java methods by `::` 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` CRUD inherited from framework, not in project); annotation-driven mappers (`@Select("SELECT ...")` on Java methods — the SQL lives in the annotation, not XML) | diff --git a/scripts/agent-eval/itrun.sh b/scripts/agent-eval/itrun.sh index f73d465..f9becee 100755 --- a/scripts/agent-eval/itrun.sh +++ b/scripts/agent-eval/itrun.sh @@ -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 diff --git a/src/resolution/callback-synthesizer.ts b/src/resolution/callback-synthesizer.ts index def7ff6..43e7bf0 100644 --- a/src/resolution/callback-synthesizer.ts +++ b/src/resolution/callback-synthesizer.ts @@ -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, )` + * 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(); // 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(); + 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(); @@ -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;