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
+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;