feat(explore): surface interface/registry dispatch boundaries and window oversize spine methods

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.
This commit is contained in:
Colby McHenry
2026-06-20 12:32:05 -05:00
parent 4f8782cbe5
commit e7d9f8c6fa
2 changed files with 298 additions and 15 deletions
+108 -1
View File
@@ -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<string, string>, 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<string, INodeType> = {};',
' 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');
});
});
+190 -14
View File
@@ -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<string>; namedNodeIds: Set<string>; uniqueNamedNodeIds: Set<string> } {
const EMPTY = { text: '', pathNodeIds: new Set<string>(), namedNodeIds: new Set<string>(), uniqueNamedNodeIds: new Set<string>() };
private buildFlowFromNamedSymbols(cg: CodeGraph, query: string): { text: string; pathNodeIds: Set<string>; namedNodeIds: Set<string>; uniqueNamedNodeIds: Set<string>; spineCallSites: Map<string, number> } {
// 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<string>(), namedNodeIds: new Set<string>(), uniqueNamedNodeIds: new Set<string>(), spineCallSites: new Map<string, number>() };
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<string, string[]>();
// 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<string, Node[]>();
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<string, number>() };
}
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<string, number>();
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, Node>): 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<string>();
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<string, { node: Node; count: number; targets: Node[] }>();
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<number>();
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;
}