From e7d9f8c6fa2c245572ecf5b35c7222f32d701d02 Mon Sep 17 00:00:00 2001 From: Colby McHenry Date: Sat, 20 Jun 2026 12:32:05 -0500 Subject: [PATCH] feat(explore): surface interface/registry dispatch boundaries and window oversize spine methods MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two gaps closed in `codegraph_explore` output quality: **Interface/registry dispatch (#687 extension).** When a named token resolves to a large same-name family (≥8 members) that doesn't land on the connected flow, the static path truly ends there — the target is chosen at runtime from N implementations (plugin/strategy/handler interface). `buildPolymorphicBoundaries` detects this via `implements`/`extends` edges, ranks candidate supertypes by their TRUE graph-wide implementer count (not FTS sample frequency, which is biased), and emits a "## Interface dispatch" section naming the supertype, implementer count, and a few concrete targets. Fires only for uncovered named tokens; a connected flow stays silent. **Oversize spine method windowing.** A flow entry that is a god-method (e.g. n8n's 962-line `processRunExecutionData`) previously lost the per-file budget to denser peripheral blocks and was dropped, forcing the agent to `Read` it back. The spine call site (edge line to the next hop) is now tracked via `spineCallSites` and used to window the method to its signature head + a ±28-line band around the call, keeping it under the OVERSIZE_SPINE_LINES threshold. Spine clusters also rank first in the budget sort and may exceed the per-file cap up to a 2.5× ceiling so they can never be starved by co-flow files. Test suite gains an `interface dispatch` describe block (announce, silent-on-connected, silent-below-threshold) and uses `beforeAll`/`afterAll` to pin `CODEGRAPH_OFFLOAD_DISABLE=1` so structural assertions are hermetic regardless of machine config. --- __tests__/dynamic-boundaries.test.ts | 109 +++++++++++++- src/mcp/tools.ts | 204 +++++++++++++++++++++++++-- 2 files changed, 298 insertions(+), 15 deletions(-) diff --git a/__tests__/dynamic-boundaries.test.ts b/__tests__/dynamic-boundaries.test.ts index a0b1b0e..d6a2206 100644 --- a/__tests__/dynamic-boundaries.test.ts +++ b/__tests__/dynamic-boundaries.test.ts @@ -8,7 +8,7 @@ * showing nothing. Deterministic, query-time only, no graph mutation, and a * fully connected flow must never produce the section. */ -import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { describe, it, expect, beforeAll, afterAll, afterEach } from 'vitest'; import * as fs from 'fs'; import * as path from 'path'; import * as os from 'os'; @@ -16,6 +16,19 @@ import CodeGraph from '../src/index'; import { ToolHandler } from '../src/mcp/tools'; import { scanDynamicDispatch } from '../src/mcp/dynamic-boundaries'; +// These suites assert on the RAW codegraph_explore output (the Flow / boundary +// sections). The managed reasoning-offload, when configured on the dev machine +// (~/.codegraph/config.json `{"offload":{"managed":true}}`), REPLACES that output +// with a remote Cerebras synthesis — so the structural assertions only hold with +// the offload off. Disable it for this file so the suite is hermetic regardless +// of machine config, then restore. +let _prevOffloadDisable: string | undefined; +beforeAll(() => { _prevOffloadDisable = process.env.CODEGRAPH_OFFLOAD_DISABLE; process.env.CODEGRAPH_OFFLOAD_DISABLE = '1'; }); +afterAll(() => { + if (_prevOffloadDisable === undefined) delete process.env.CODEGRAPH_OFFLOAD_DISABLE; + else process.env.CODEGRAPH_OFFLOAD_DISABLE = _prevOffloadDisable; +}); + // --------------------------------------------------------------------------- // Unit: the scanner // --------------------------------------------------------------------------- @@ -297,3 +310,97 @@ describe('codegraph_explore — dynamic boundaries', () => { expect(text).toContain('handle_save'); }); }); + +// --------------------------------------------------------------------------- +// Integration: interface/registry dispatch (a named method has many impls) +// --------------------------------------------------------------------------- + +describe('codegraph_explore — interface dispatch', () => { + let testDir: string; + let cg: CodeGraph; + let handler: ToolHandler; + + const setup = async (files: Record, include: string[]) => { + testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-iface-')); + const src = path.join(testDir, 'src'); + fs.mkdirSync(src, { recursive: true }); + for (const [name, content] of Object.entries(files)) { + fs.writeFileSync(path.join(src, name), content); + } + cg = CodeGraph.initSync(testDir, { config: { include, exclude: [] } }); + await cg.indexAll(); + handler = new ToolHandler(cg); + }; + + afterEach(() => { + if (cg) cg.destroy(); + if (testDir && fs.existsSync(testDir)) fs.rmSync(testDir, { recursive: true, force: true }); + }); + + // 9 classes implement INodeType, each with execute(); a runtime registry lookup + // dispatches to one. The agent names the static entry + `execute`, which can't + // resolve to a single impl — the boundary IS the answer. + const nodeFamily = (n: number) => { + const names = ['Http', 'Set', 'If', 'Merge', 'Code', 'Webhook', 'Cron', 'Func', 'NoOp', 'Switch', 'Wait', 'Filter']; + return [ + 'export interface INodeType { execute(): unknown; }', + ...names.slice(0, n).map((nm, i) => `export class ${nm}Node implements INodeType { execute() { return ${i}; } }`), + ].join('\n'); + }; + const engine = [ + "import { registry } from './registry';", + 'export class WorkflowExecute {', + ' processRunExecutionData() { return this.runNode(); }', + ' runNode() { return this.executeNode(); }', + ' executeNode() {', + " const nodeType = registry.get('http');", + ' return nodeType.execute();', + ' }', + '}', + ].join('\n'); + const registry = [ + "import type { INodeType } from './nodes';", + 'class Registry {', + ' private m: Record = {};', + ' get(k: string): INodeType { return this.m[k]!; }', + '}', + 'export const registry = new Registry();', + ].join('\n'); + + it('announces the interface, the TRUE implementer count, and sample targets', async () => { + await setup({ 'nodes.ts': nodeFamily(9), 'registry.ts': registry, 'engine.ts': engine }, ['**/*.ts']); + + const res = await handler.execute('codegraph_explore', { query: 'processRunExecutionData executeNode execute' }); + const text = res.content[0].text as string; + + expect(text).toContain('## Interface dispatch (a named method has many implementations)'); + expect(text).toMatch(/`execute` → runtime dispatch to \*\*9\*\* types implementing `INodeType`/); + // a couple of concrete targets, with file:line + expect(text).toMatch(/\b\w+Node\.execute` \(/); + // never steer to Read + expect(text).not.toMatch(/\buse Read\b/i); + }); + + it('stays SILENT on a fully connected flow with no polymorphic family', async () => { + await setup({ + 'pipeline.ts': [ + 'export function stepOne() { return stepTwo(); }', + 'export function stepTwo() { return stepThree(); }', + 'export function stepThree() { return 3; }', + ].join('\n'), + }, ['**/*.ts']); + + const res = await handler.execute('codegraph_explore', { query: 'stepOne stepThree' }); + const text = res.content[0].text as string; + expect(text).toContain('## Flow'); + expect(text).not.toContain('## Interface dispatch'); + }); + + it('stays SILENT when the interface family is below the polymorphism threshold (3 impls)', async () => { + await setup({ 'nodes.ts': nodeFamily(3), 'registry.ts': registry, 'engine.ts': engine }, ['**/*.ts']); + + const res = await handler.execute('codegraph_explore', { query: 'processRunExecutionData executeNode execute' }); + const text = res.content[0].text as string; + expect(text).not.toContain('## Interface dispatch'); + }); +}); diff --git a/src/mcp/tools.ts b/src/mcp/tools.ts index 6bbcd14..3dff006 100644 --- a/src/mcp/tools.ts +++ b/src/mcp/tools.ts @@ -1547,8 +1547,11 @@ export class ToolHandler { * whose qualifiedName contains another named token (`PmsProductServiceImpl::list`), * dropping unrelated `OmsOrderService::list`. */ - private buildFlowFromNamedSymbols(cg: CodeGraph, query: string): { text: string; pathNodeIds: Set; namedNodeIds: Set; uniqueNamedNodeIds: Set } { - const EMPTY = { text: '', pathNodeIds: new Set(), namedNodeIds: new Set(), uniqueNamedNodeIds: new Set() }; + private buildFlowFromNamedSymbols(cg: CodeGraph, query: string): { text: string; pathNodeIds: Set; namedNodeIds: Set; uniqueNamedNodeIds: Set; spineCallSites: Map } { + // spineCallSites: for each spine node, the line where it CALLS the next hop — + // lets the source assembler window an oversize spine method (e.g. n8n's 962-line + // processRunExecutionData) to the call site instead of dumping the whole body. + const EMPTY = { text: '', pathNodeIds: new Set(), namedNodeIds: new Set(), uniqueNamedNodeIds: new Set(), spineCallSites: new Map() }; try { const CALLABLE = new Set(['method', 'function', 'component', 'constructor']); // Strip only a REAL file extension (Create.cs → Create); KEEP qualified @@ -1578,8 +1581,13 @@ export class ToolHandler { // the dynamic-boundary scan (a token is covered when ANY of its nodes // lands on the main chain — overloads off the chain don't count against). const tokenNodes = new Map(); + // token → its full same-name callable family (before the container filter). + // A LARGE family that fails to connect on the chain is a polymorphic + // interface/registry dispatch — surfaced by buildPolymorphicBoundaries below. + const tokenFamily = new Map(); for (const t of tokens) { const cands = this.findAllSymbols(cg, t).nodes.filter((n) => CALLABLE.has(n.kind)); + tokenFamily.set(t, cands); // A qualified or otherwise-specific name (<=3 hits) keeps all; an // ambiguous simple name keeps only candidates whose container is named. const specific = cands.length <= 3; @@ -1607,7 +1615,7 @@ export class ToolHandler { const boundaries = this.buildDynamicBoundaries(cg, [...named.values()], named); if (!boundaries) return EMPTY; const text = boundaries + '> Full source for these symbols is below.\n'; - return { text, pathNodeIds: new Set(), namedNodeIds: new Set(named.keys()), uniqueNamedNodeIds }; + return { text, pathNodeIds: new Set(), namedNodeIds: new Set(named.keys()), uniqueNamedNodeIds, spineCallSites: new Map() }; } const MAX_HOPS = 7; let best: Array<{ node: Node; edge: Edge | null }> | null = null; @@ -1642,6 +1650,14 @@ export class ToolHandler { } const hasMain = !!best && best.length >= 3; const pathIds = new Set((best ?? []).map((s) => s.node.id)); + // Where each spine node calls the NEXT hop (best[i+1].edge is the edge from + // best[i] → best[i+1]; its line is the call site inside best[i]'s body). Lets + // the assembler window an oversize spine method to the call instead of dumping it. + const spineCallSites = new Map(); + if (best) for (let i = 0; i < best.length - 1; i++) { + const ln = best[i + 1]?.edge?.line; + if (ln && ln > 0 && !spineCallSites.has(best[i]!.node.id)) spineCallSites.set(best[i]!.node.id, ln); + } // Dynamic-boundary scan (#687) — fires ONLY when the flow the agent // asked about did not fully connect: some token resolved to nodes but @@ -1673,6 +1689,28 @@ export class ToolHandler { } } + // Interface/registry-dispatch announcement (extends #687 to GRAPH-visible + // polymorphism). A method the agent NAMED that resolves to a large same-name + // family AND did not land on the main chain is almost always a runtime + // dispatch (plugin/strategy/handler interface): the concrete target is chosen + // at runtime from N implementations, so no single static edge is the answer. + // The body-scan above can't see this — `nodeType.execute()` is textually an + // ordinary call; the polymorphism lives in the graph (implements edges), so + // detect it there. Fires ONLY for an uncovered named token; a connected flow + // stays silent. + let polyText = ''; + { + const POLY_MIN_FAMILY = 8; // smaller families are overload sets, not dispatch + const polyCands: Array<{ token: string; family: Node[] }> = []; + for (const [t, fam] of tokenFamily) { + if (fam.length < POLY_MIN_FAMILY) continue; + const ids = tokenNodes.get(t) || []; + if (ids.some((id) => pathIds.has(id))) continue; // covered by the flow — silent + polyCands.push({ token: t, family: fam }); + } + if (polyCands.length) polyText = this.buildPolymorphicBoundaries(cg, polyCands, named); + } + // Supplementary: dynamic-dispatch (synthesized) edges incident to a NAMED // symbol — the indirect hops an agent would otherwise grep/Read to // reconstruct ("where do the appended `validators` actually run?"). The @@ -1704,7 +1742,7 @@ export class ToolHandler { } } - if (!hasMain && synthLines.length === 0 && !boundaryText) return EMPTY; + if (!hasMain && synthLines.length === 0 && !boundaryText && !polyText) return EMPTY; const out: string[] = []; if (hasMain) { out.push('## Flow (call path among the symbols you queried)', ''); @@ -1725,13 +1763,14 @@ export class ToolHandler { ); } if (boundaryText) out.push(boundaryText); + if (polyText) out.push(polyText); out.push('> Full source for these symbols is below — the call flow among them, followed by their bodies.', ''); // namedNodeIds = every callable the agent explicitly named (a superset of // the spine). A file holding one is something the agent asked to SEE, so it // must keep full source even if it's an off-spine polymorphic sibling — the // agent named `getResponseWithInterceptorChain` / `SQLCompiler.execute_sql` // as the mechanism, not as an interchangeable leaf. See the skeleton gate. - return { text: out.join('\n'), pathNodeIds: pathIds, namedNodeIds: new Set(named.keys()), uniqueNamedNodeIds }; + return { text: out.join('\n'), pathNodeIds: pathIds, namedNodeIds: new Set(named.keys()), uniqueNamedNodeIds, spineCallSites }; } catch { return EMPTY; } @@ -1793,6 +1832,93 @@ export class ToolHandler { ].join('\n'); } + /** + * Interface/registry-dispatch announcement — #687 extended to GRAPH-visible + * polymorphism (the body-scan can't see it: `nodeType.execute()` is textually + * an ordinary call; the polymorphism lives in the `implements`/`extends` edges). + * + * A method the agent named that resolves to a large same-name family whose + * definers overwhelmingly implement/extend ONE supertype is a runtime dispatch: + * the concrete target is chosen at runtime from N implementations, so no single + * static edge is "the answer" — the implementations ARE the continuations. We + * announce the supertype, its TRUE implementer count, and a few concrete targets, + * then steer to codegraph_explore. Graph-only, query-time, zero mutation; the + * caller fires it ONLY for an UNCOVERED named token, so a connected flow is silent. + * + * Robust to FTS sampling bias: the same-name family is a capped FTS sample that + * over-represents whatever FTS ranks first (n8n: DB `TableOperation.execute` + * outnumbered `INodeType.execute` in the sample 7:6 even though INodeType has + * 611 implementers vs a handful). So candidate supertypes are ranked by their + * TRUE graph-wide implementer count, NOT their frequency in the sample. + */ + private buildPolymorphicBoundaries(cg: CodeGraph, candidates: Array<{ token: string; family: Node[] }>, named: Map): string { + const CLASSY = new Set(['class', 'struct', 'interface', 'trait', 'protocol', 'abstract']); + const MIN_IMPL = 8; // a supertype needs >= this many implementers to count as "polymorphic" + const MIN_SUPPORT = 2; // >= this many sampled definers must share the supertype (ties it to the token) + const SAMPLE = 40; // family members inspected per token + const MAX_NOTES = 3; + const rel = (p: string) => p.replace(/\\/g, '/'); + const containerOf = (m: Node): Node | null => { + try { const ce = cg.getIncomingEdges(m.id).find((e) => e.kind === 'contains'); return ce ? cg.getNode(ce.source) : null; } + catch { return null; } + }; + const notes: string[] = []; + const seenSuper = new Set(); + for (const { token, family } of candidates) { + if (notes.length >= MAX_NOTES) break; + // supertype id → how many sampled definers share it + a few example definers + const supers = new Map(); + for (const m of family.slice(0, SAMPLE)) { + const container = containerOf(m); + if (!container || !CLASSY.has(container.kind)) continue; + let sups: Node[] = []; + try { + sups = cg.getOutgoingEdges(container.id) + .filter((e) => e.kind === 'implements' || e.kind === 'extends') + .map((e) => { try { return cg.getNode(e.target); } catch { return null; } }) + .filter((n): n is Node => !!n && CLASSY.has(n.kind) && (n.name?.length || 0) >= 3); + } catch { /* no supertypes — free function or unresolved */ } + for (const s of sups) { + const e = supers.get(s.id) || { node: s, count: 0, targets: [] }; + e.count++; + if (e.targets.length < 6) e.targets.push(m); + supers.set(s.id, e); + } + } + // Pick the supertype with the most TRUE implementers (graph-wide), among + // those genuinely shared by the token's definers. + let best: { node: Node; impl: number; targets: Node[] } | null = null; + for (const { node, count, targets } of supers.values()) { + if (count < MIN_SUPPORT) continue; + let impl = 0; + try { impl = cg.getIncomingEdges(node.id).filter((e) => e.kind === 'implements' || e.kind === 'extends').length; } + catch { /* leave 0 — gated out below */ } + if (impl < MIN_IMPL) continue; + if (!best || impl > best.impl) best = { node, impl, targets }; + } + if (!best || seenSuper.has(best.node.id)) continue; + seenSuper.add(best.node.id); + const namedNames = new Set([...named.values()].map((n) => n.name)); + const eg = best.targets.slice(0, 4).map((m) => { + const cont = containerOf(m); + const disp = cont ? `${cont.name}.${m.name}` : (m.qualifiedName || m.name); + const mark = cont && namedNames.has(cont.name) ? ' ← you named this' : ''; + return `\`${disp}\` (${rel(m.filePath)}:${m.startLine})${mark}`; + }); + const more = best.impl > eg.length ? ` +${best.impl - eg.length} more` : ''; + notes.push(`- \`${token}\` → runtime dispatch to **${best.impl}** types implementing \`${best.node.name}\` — the static path ends here, the target is chosen at runtime. e.g. ${eg.join(', ')}${more}`); + } + if (notes.length === 0) return ''; + return [ + '## Interface dispatch (a named method has many implementations)', + '', + ...notes, + '', + '> The method above is dispatched at runtime to one of the listed implementations (a registry / plugin / strategy interface) — there is no single static caller→callee edge; the implementations ARE the continuations. To follow one, run codegraph_explore on a listed target.', + '', + ].join('\n'); + } + /** * Shortlist candidate runtime targets for a dispatch key surfaced by * {@link buildDynamicBoundaries}. Exact conventional names first (`save` → @@ -2721,7 +2847,7 @@ export class ToolHandler { const n = cg.getNode(id); if (n && n.filePath === filePath && n.startLine > 0 && n.endLine > 0) rangeNodes.set(id, n); } - const ranges: Array<{ start: number; end: number; name: string; kind: string; importance: number }> = [...rangeNodes.values()] + const ranges: Array<{ start: number; end: number; name: string; kind: string; importance: number; spine: boolean; spineCallLine?: number }> = [...rangeNodes.values()] // Drop whole-file envelope nodes (containers covering >50% of the file). .filter(n => !(ENVELOPE_KINDS.has(n.kind) && (n.endLine - n.startLine + 1) > fileLines.length * 0.5)) .map(n => { @@ -2730,7 +2856,12 @@ export class ToolHandler { else if (flow.namedNodeIds.has(n.id)) importance = 9; // agent named it → keep its cluster else if (glueNodeIds.has(n.id)) importance = 6; // bridging caller/callee of an entry else if (connectedToEntry.has(n.id)) importance = 3; - return { start: n.startLine, end: n.endLine, name: n.name, kind: n.kind, importance }; + // On the rendered call-path spine? That IS the flow answer — its cluster + // must never be dropped by the per-file budget (n8n's huge workflow-execute.ts: + // processRunExecutionData, the named flow ENTRY at L1562, is a large + // low-density method that lost the budget to denser blocks and got cut, so + // the agent Read it back — the very thing explore exists to prevent). + return { start: n.startLine, end: n.endLine, name: n.name, kind: n.kind, importance, spine: flow.pathNodeIds.has(n.id), spineCallLine: flow.spineCallSites.get(n.id) }; }); // Add edge source locations in this file — captures template references @@ -2748,7 +2879,7 @@ export class ToolHandler { // Look up target name from subgraph first, fall back to edge kind const targetNode = subgraph.nodes.get(edge.target); const targetName = targetNode?.name ?? edge.kind; - ranges.push({ start: edge.line, end: edge.line, name: targetName, kind: edge.kind, importance: 2 }); + ranges.push({ start: edge.line, end: edge.line, name: targetName, kind: edge.kind, importance: 2, spine: false }); } } @@ -2757,13 +2888,15 @@ export class ToolHandler { if (ranges.length === 0) continue; const gapThreshold = budget.gapThreshold; - const clusters: Array<{ start: number; end: number; symbols: string[]; score: number; maxImportance: number }> = []; + const clusters: Array<{ start: number; end: number; symbols: string[]; score: number; maxImportance: number; hasSpine: boolean; spineCallLine?: number }> = []; let current = { start: ranges[0]!.start, end: ranges[0]!.end, symbols: [`${ranges[0]!.name}(${ranges[0]!.kind})`], score: ranges[0]!.importance, maxImportance: ranges[0]!.importance, + hasSpine: ranges[0]!.spine, + spineCallLine: ranges[0]!.spineCallLine, }; for (let i = 1; i < ranges.length; i++) { @@ -2773,6 +2906,8 @@ export class ToolHandler { current.symbols.push(`${r.name}(${r.kind})`); current.score += r.importance; current.maxImportance = Math.max(current.maxImportance, r.importance); + current.hasSpine = current.hasSpine || r.spine; + current.spineCallLine = current.spineCallLine ?? r.spineCallLine; } else { clusters.push(current); current = { @@ -2781,6 +2916,8 @@ export class ToolHandler { symbols: [`${r.name}(${r.kind})`], score: r.importance, maxImportance: r.importance, + hasSpine: r.spine, + spineCallLine: r.spineCallLine, }; } } @@ -2795,16 +2932,40 @@ export class ToolHandler { // get tail-trimmed with a marker. const contextPadding = 3; const withLineNumbers = exploreLineNumbersEnabled(); - const buildSection = (c: { start: number; end: number }): string => { + // Language-neutral separator (no `//` — not a comment in Python, Ruby, + // etc.). With line numbers on, the line-number jump also signals the gap. + const GAP_MARKER = '\n\n... (gap) ...\n\n'; + // An oversize spine method (the call path runs THROUGH a god-method — n8n's + // processRunExecutionData is 962 lines) is windowed to its next-hop CALL site + // plus the signature head, NOT dumped whole. Without this the cluster is too big + // for any per-file cap and gets dropped, so the agent Reads the method back — + // the exact gap this closes. Bounded, so a god-method can't blow the budget yet + // the spine's call still appears in context. + const OVERSIZE_SPINE_LINES = 200; + const SPINE_WINDOW = 28; // lines each side of the next-hop call site + const buildSection = (c: { start: number; end: number; hasSpine?: boolean; spineCallLine?: number }): string => { + if (c.hasSpine && c.spineCallLine && (c.end - c.start + 1) > OVERSIZE_SPINE_LINES) { + const call = c.spineCallLine; + const winStart = Math.max(c.start, call - SPINE_WINDOW); + const winEnd = Math.min(c.end, call + SPINE_WINDOW); + const parts: string[] = []; + // Signature head, only when it sits clearly above the window (else the + // window already covers the method opening). + const headEnd = Math.min(c.start + 4, winStart - 2); + if (headEnd >= c.start) { + const head = fileLines.slice(c.start - 1, headEnd).join('\n'); + parts.push(withLineNumbers ? numberSourceLines(head, c.start) : head); + } + const win = fileLines.slice(winStart - 1, winEnd).join('\n'); + parts.push(withLineNumbers ? numberSourceLines(win, winStart) : win); + return parts.join(GAP_MARKER); + } const startIdx = Math.max(0, c.start - 1 - contextPadding); const endIdx = Math.min(fileLines.length, c.end + contextPadding); const slice = fileLines.slice(startIdx, endIdx).join('\n'); // startIdx is 0-based, so the slice's first line is line startIdx + 1. return withLineNumbers ? numberSourceLines(slice, startIdx + 1) : slice; }; - // Language-neutral separator (no `//` — not a comment in Python, Ruby, - // etc.). With line numbers on, the line-number jump also signals the gap. - const GAP_MARKER = '\n\n... (gap) ...\n\n'; // Rank clusters for inclusion under the per-file cap. Entry-point // clusters come first: a cluster containing a query entry point @@ -2819,6 +2980,11 @@ export class ToolHandler { const rankedClusters = clusters .map((c, i) => ({ idx: i, span: c.end - c.start + 1, c })) .sort((a, b) => { + // Spine clusters first — the rendered call path IS the flow answer, so it + // outranks any denser block of peripheral declarations (a low-density entry + // method must not lose the budget to them). Within spine / within non-spine, + // the existing importance → density → score → span order holds. + if (a.c.hasSpine !== b.c.hasSpine) return (b.c.hasSpine ? 1 : 0) - (a.c.hasSpine ? 1 : 0); if (b.c.maxImportance !== a.c.maxImportance) return b.c.maxImportance - a.c.maxImportance; const densityA = a.c.score / a.span; const densityB = b.c.score / b.span; @@ -2834,6 +3000,11 @@ export class ToolHandler { // That source-order slice is what cut Django's `_fetch_all` (L2237, importance // 9 — agent-named) when query.py was the last of four big files to be emitted. const fileBudget = Math.min(budget.maxCharsPerFile, Math.max(0, budget.maxOutputChars - totalChars - 200)); + // Spine ceiling: a flow-path cluster may exceed the per-file cap (the call + // path is the answer), but bounded — at most ~2.5× the per-file cap and never + // past what's left of the total output cap — so a pathological long in-file + // spine can't run away or starve co-flow files entirely. + const SPINE_CEILING = Math.min(budget.maxCharsPerFile * 2.5, Math.max(0, budget.maxOutputChars - totalChars - 200)); const chosenIndices = new Set(); let projectedChars = 0; for (const rc of rankedClusters) { @@ -2846,7 +3017,12 @@ export class ToolHandler { projectedChars += sectionLen; continue; } - if (projectedChars + sectionLen > fileBudget) continue; + // A spine cluster (the rendered call path) is the flow answer — include it + // past the per-file budget up to the spine ceiling; non-spine clusters obey + // the normal per-file budget. + const fits = projectedChars + sectionLen <= fileBudget; + const spineFits = rc.c.hasSpine && projectedChars + sectionLen <= SPINE_CEILING; + if (!fits && !spineFits) continue; chosenIndices.add(rc.idx); projectedChars += sectionLen; }