ソースを参照

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.
Colby McHenry 2 日 前
コミット
e7d9f8c6fa
2 ファイル変更298 行追加15 行削除
  1. 108 1
      __tests__/dynamic-boundaries.test.ts
  2. 190 14
      src/mcp/tools.ts

+ 108 - 1
__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<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
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<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;
       }