Просмотр исходного кода

feat(ui): where the graph stops — the Flow strip's dynamic-dispatch end cap (CG-51)

A flow that does not reach what it was asked about now ends in a cap instead
of in silence: the dispatch form that ended it, the line, the static key when
the source spells one out, the candidate runtime targets as clickable rows,
and the name-only matches under 0.6 the search refused to follow. A flow that
does reach its destination never shows one.

The verdict is lifted out of `ToolHandler` into
`src/graph/dynamic-boundary-report.ts` and both callers render it —
`codegraph_explore`'s prose and `/api/flow`'s `WireFlowBoundary` — the same
move `named-symbol-flow.ts` made for the path finder, and for the same reason:
a reader holding the strip and the MCP answer must not be told two different
things. The explore prose is unchanged, byte for byte.

When nothing connects at all and a dispatch site explains why, the strip is
that site: one card opened at the line where the static path ends, plus the
cap. When nothing explains it, no stopping point is invented.
Colby McHenry 1 неделя назад
Родитель
Сommit
dc7f1e590e

+ 5 - 1
CHANGELOG.md

@@ -28,10 +28,14 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
 
 - **Ask how one symbol reaches another, in `codegraph ui`.** Type "how does execute reach getFile" into the search box — or `execute -> getFile` — and the Flow strip draws the call path between them, left to right, one card per hop. Each card is opened at the exact line that makes the next call rather than at the top of the function, so reading the strip is reading the handful of lines that actually carry the work; the identifier being called is a link, and clicking a card opens it in the symbol screen with the trail already set to the path you've read so far.
 
-  A dashed link is a hop nobody can see in the source — a callback, an interface dispatch, a React re-render, a JSX child — and it names the mechanism and, where CodeGraph knows it, the exact line the handler was wired at. When a name means several definitions, the strip says so and names the one the path runs through, offers the alternatives in a picker, and can draw them together as one branching diagram. "Not connected" is an answer rather than a failure: a flow that runs through a dispatch no static edge records genuinely has no path, and the screen says so instead of inventing one.
+  A dashed link is a hop nobody can see in the source — a callback, an interface dispatch, a React re-render, a JSX child — and it names the mechanism and, where CodeGraph knows it, the exact line the handler was wired at. When a name means several definitions, the strip says so and names the one the path runs through, offers the alternatives in a picker, and can draw them together as one branching diagram.
 
   The **"Read as flow"** button on the trail turns a walk you did by hand into the same strip. It is the same path finder `codegraph_explore` leads its answers with, so the picture and what your agent tells you can't disagree.
 
+- **When a path runs out, the Flow strip says where — and why.** A flow that doesn't reach what you asked about now ends in a small block: *"Where the graph stops."* It names the kind of dispatch that ended it — a computed member call, a `getattr`, a reflective invoke, a typed message bus — and the line it's on, and the card beside it opens at that exact line so you can read the code the block is talking about. Where the key is written in the source (`handlers['save']`) it shows the key and shortlists the symbols that could be on the other side, marking any you already named; where the key is a runtime value it says so rather than guessing.
+
+  It also lists what CodeGraph chose not to follow: name-only matches it wasn't confident enough about, with their confidence, and a count of the other calls the symbol makes that this path didn't need. Nothing is invented — no edge is guessed and none is added to your graph — and a flow that does reach what you asked for never shows the block at all. It's the same finding `codegraph_explore` announces to your agent when a flow breaks, so the screen and the answer agree.
+
 - **Read a whole file, with its call graph in the margin, in `codegraph ui`.** The file screen gained a **Source** tab: the file itself, top to bottom, with the same gutter markers as the symbol view and the same right-hand list of what each line calls, positioned level with the line that calls it. A 6,800-line file scrolls at full speed — only the lines on screen are ever drawn, and the text pages in behind you while the markers are there from the first frame.
 
   In the left margin is an arc for every call that stays inside the file, drawn from the calling line to the line the callee is defined on. Nothing is laid out by an algorithm — the author already put the symbols in order, so source order does the work, and this is the one place a file's internal call structure is legible at a glance. Hover a line to light the arcs the function under your cursor takes part in, and click an arc to jump to the other end. On a file with more than forty of them the picture narrows to the symbol you're reading instead of drawing a wash of overlapping sweeps, with the total in the header. A rail on the far left lists the file's symbols and follows you as you scroll, when the window is wide enough for it.

+ 1 - 0
README.md

@@ -347,6 +347,7 @@ What you get on that screen:
 - **Search** (`/` or ⌘K) over every symbol and file, **entry points** to start from (routes, hubs, files that run code at import time), and a **trail** of the path you walked that lives in the URL, so you can send someone the exact route you took.
 - Click any file path to open the **file view**: everything that file depends on, its outline in source order, and everything that depends on it. Its **Source** tab shows the whole file with the same gutter markers, plus an arc in the left margin for every call that stays inside the file — the one place a file's internal call structure is legible, because source order does the layout. A 6,800-line file scrolls at full speed.
 - **Ask for a path.** Type "how does execute reach getFile" (or `execute -> getFile`) and you get the **flow**: one card per hop, each opened at the line that makes the next call. Hops that no static edge records — a callback, an interface dispatch, a React re-render — are drawn dashed and name where the handler was wired. "Read as flow" turns a walk you did by hand into the same strip.
+- **And when the path runs out, it says where.** A flow that doesn't get there ends in "Where the graph stops": the kind of dispatch that ended it (a computed member call, a `getattr`, a reflective invoke, a message bus), its line, the key when the source spells one out, and a shortlist of what could be on the other side — plus the name-only matches CodeGraph refused to follow, with their confidence. Nothing is guessed, and a flow that does connect never shows it.
 - **The map**: the whole project at module granularity, laid out from the graph with dependencies pointing down — never drawn by hand, and the same picture every time. Cycles are listed rather than straightened away.
 - **It keeps up.** Save a file and a banner appears within about a third of a second saying the index hasn't caught up yet — and the screen switches to the file's current source rather than a body sliced at lines it no longer has. When something re-indexes, whatever is on screen refetches itself and says "Index updated · reloaded". A symbol that moved because you added a line above it is followed, not lost. Nothing polls: the viewer watches, and if it loses touch with the server it retries a few times and then says so instead of hammering it.
 

+ 166 - 0
__tests__/ui-flow-api.test.ts

@@ -30,6 +30,8 @@ import CodeGraph from '../src/index';
 import { createGraphApi, startUiServer, type GraphApi, type UiServerHandle } from '../src/ui-server';
 import { flowEdgeLabel, parseFlowQuery } from '../src/ui-server/api/flow';
 import { resolveNamedSymbolFlow } from '../src/graph/named-symbol-flow';
+import { ToolHandler } from '../src/mcp/tools';
+import { continuationsFrom } from '../src/graph/dynamic-boundary-report';
 import type { Edge } from '../src/types';
 
 let server: UiServerHandle;
@@ -152,6 +154,46 @@ export function describeRow(id: string): string {
 `
   );
 
+  // A registry whose call target is a string key (CG-51): one site whose key is
+  // a literal — so a candidate shortlist is possible — and one whose key is a
+  // runtime value, where claiming a candidate would be a guess.
+  write(
+    projectRoot,
+    'src/router/table.ts',
+    `type Handler = (payload: string) => string;
+
+const routerTable: Record<string, Handler> = {};
+
+export function register(key: string, fn: Handler): void {
+  routerTable[key] = fn;
+}
+
+export function routeSave(payload: string): string {
+  return routerTable['save'](payload);
+}
+
+export function routeAny(name: string, payload: string): string {
+  return routerTable[name](payload);
+}
+
+export function beginWork(name: string, payload: string): string {
+  return routeAny(name, payload);
+}
+`
+  );
+  write(
+    projectRoot,
+    'src/router/handlers.ts',
+    `import { register } from './table';
+
+export function onSave(payload: string): string {
+  return payload;
+}
+
+register('save', onSave);
+`
+  );
+
   // A Go interface with one implementation: the resolver synthesizes an
   // interface-impl `calls` edge across it, which is what the strip draws dashed.
   write(
@@ -343,6 +385,130 @@ describe('GET /api/flow — a directed question', () => {
   });
 });
 
+describe('GET /api/flow — where the graph stops', () => {
+  it('caps a keyed dispatch with its form, its key and a candidate target', async () => {
+    const payload = await getFlow('?from=routeSave&to=onSave');
+    // No static edge crosses `routerTable['save']`, so this is not a path — it
+    // is the one card where the looking stopped, plus the cap.
+    expect(payload.reason).toMatch(/No chain of calls reaches onSave/);
+    const flow = payload.flows[0];
+    expect(flow.partial).toBe(true);
+    expect(names(flow)).toEqual(['routeSave']);
+
+    const boundary = flow.boundary;
+    expect(boundary.node.name).toBe('routeSave');
+    const site = boundary.sites[0];
+    expect(site.form).toBe('computed-call');
+    expect(site.label).toBe('computed member call');
+    expect(site.key).toBe('save');
+    expect(site.line).toBeGreaterThan(boundary.node.line);
+    expect(site.candidates.map((c: any) => c.display)).toContain('onSave');
+    // The reader named it, so the cap says so rather than presenting it as new.
+    expect(site.candidates.find((c: any) => c.display === 'onSave').named).toBe(true);
+    expect(boundary.missed.map((m: any) => m.name)).toContain('onSave');
+  });
+
+  it('opens the card at the dispatch line, with real source around it', async () => {
+    const payload = await getFlow('?from=routeSave&to=onSave');
+    const flow = payload.flows[0];
+    const site = flow.boundary.sites[0];
+    const source = flow.hops[0].source;
+    expect(source.drift).toBe(false);
+    expect(source.from).toBeLessThanOrEqual(site.line);
+    expect(source.to).toBeGreaterThanOrEqual(site.line);
+    expect(source.lines.join('\n')).toContain("routerTable['save']");
+  });
+
+  it('claims no candidates when the key is a runtime value', async () => {
+    const payload = await getFlow('?from=routeAny&to=onSave');
+    const site = payload.flows[0].boundary.sites[0];
+    expect(site.form).toBe('computed-call');
+    expect(site.key).toBeNull();
+    expect(site.candidates).toEqual([]);
+    expect(site.candidateNote).toBeNull();
+  });
+
+  it('caps a chain that connects but never reaches everything it was asked about', async () => {
+    const payload = await getFlow('?symbols=beginWork,routeAny,onSave');
+    const flow = payload.flows[0];
+    expect(flow.partial).toBe(false);
+    expect(names(flow)).toEqual(['beginWork', 'routeAny']);
+    // The cap hangs off the dead end, not off the symbol that was named last.
+    expect(flow.boundary.node.name).toBe('routeAny');
+    expect(flow.boundary.sites[0].form).toBe('computed-call');
+    expect(flow.boundary.missed.map((m: any) => m.name)).toEqual(['onSave']);
+    // The last card opens at the dispatch line the cap beside it describes.
+    const last = flow.hops[flow.hops.length - 1].source;
+    const stop = flow.boundary.sites[0].line;
+    expect(last.from).toBeLessThanOrEqual(stop);
+    expect(last.to).toBeGreaterThanOrEqual(stop);
+  });
+
+  it('never caps a flow that reaches what it was asked for', async () => {
+    const payload = await getFlow('?from=bootstrap&to=toRow');
+    expect(payload.flows[0].boundary).toBeNull();
+    expect(payload.flows[0].partial).toBe(false);
+  });
+
+  it('stays silent when nothing connects and no dispatch site explains it', async () => {
+    // `bootstrap` and `orphanHandler` are both ordinary code. Inventing a
+    // stopping point here would be a claim, not a finding.
+    const payload = await getFlow('?from=bootstrap&to=orphanHandler');
+    expect(payload.flows).toEqual([]);
+  });
+
+  it('counts the calls the path did not need and lists them', async () => {
+    const payload = await getFlow('?symbols=beginWork,routeAny,onSave');
+    const { further, uncertain } = payload.flows[0].boundary;
+    // The count and the list are the same fact — the rule every payload keeps.
+    expect(further.shown).toBe(further.items.length);
+    expect(further.total).toBeGreaterThanOrEqual(further.shown);
+    expect(uncertain.shown).toBe(uncertain.items.length);
+  });
+});
+
+describe('the end cap and codegraph_explore agree', () => {
+  it('names the same site, the same key and the same candidate', async () => {
+    const payload = await getFlow('?from=routeSave&to=onSave');
+    const site = payload.flows[0].boundary.sites[0];
+
+    const cg = CodeGraph.openSync(projectRoot);
+    try {
+      const res = await new ToolHandler(cg).execute('codegraph_explore', {
+        query: 'routeSave onSave',
+      });
+      const text = res.content[0].text as string;
+      // Both renderings come from `findDynamicBoundaries`; if they ever drift
+      // apart, a reader with the strip and the MCP answer side by side has no
+      // way to tell which one is lying.
+      expect(text).toContain('**Dynamic boundaries');
+      expect(text).toContain(site.label);
+      expect(text).toContain(`src/router/table.ts:${site.line}`);
+      expect(text).toContain(`candidates for key \`${site.key}\``);
+      for (const candidate of site.candidates) expect(text).toContain(candidate.display);
+    } finally {
+      cg.close();
+    }
+  });
+
+  it('splits a symbol\'s outgoing calls into the sure and the unfollowed', () => {
+    const cg = CodeGraph.openSync(projectRoot);
+    try {
+      const node = cg.getNodesByName('handleRequest')[0]!;
+      const all = continuationsFrom(cg, node);
+      expect(all.resolved.map((c) => c.node.name)).toContain('loadRow');
+      expect(all.uncertain.every((c) => (c.confidence ?? 1) < 0.6)).toBe(true);
+      // Excluding what is already on the path is what keeps the cap from
+      // listing the hop the reader just walked as an unexplored exit.
+      const target = all.resolved[0]!.node.id;
+      const rest = continuationsFrom(cg, node, new Set([target]));
+      expect(rest.resolved.map((c) => c.node.id)).not.toContain(target);
+    } finally {
+      cg.close();
+    }
+  });
+});
+
 describe('GET /api/flow — a synthesized hop', () => {
   it('draws the interface bridge as a dashed hop that names its mechanism', async () => {
     const payload = await getFlow('?from=Tick&to=stamp');

+ 198 - 2
__tests__/ui-flow-model.test.ts

@@ -28,8 +28,19 @@ import {
   NO_SOURCE_HEIGHT,
   PADDING,
   ROW_GAP,
+  capId,
+  endCapHeight,
+  endCapText,
+  END_CAP_DASH,
+  END_CAP_WIDTH,
 } from '../ui/src/lib/flow-model';
-import type { WireFlow, WireFlowEdge, WireFlowHop } from '../ui/src/lib/api';
+import type {
+  WireFlow,
+  WireFlowBoundary,
+  WireFlowEdge,
+  WireFlowHop,
+  WireNodeRef,
+} from '../ui/src/lib/api';
 
 /* ------------------------------------------------------------- builders -- */
 
@@ -74,11 +85,54 @@ function hop(name: string, opts: { lines?: number; edge?: WireFlowEdge | null }
   };
 }
 
-function flow(id: string, names: string[]): WireFlow {
+function flow(
+  id: string,
+  names: string[],
+  extra: { boundary?: WireFlowBoundary | null; partial?: boolean } = {}
+): WireFlow {
   return {
     id,
     label: `${names[0]} → ${names[names.length - 1]}`,
     hops: names.map((name, i) => hop(name, { edge: i === 0 ? null : edge() })),
+    boundary: extra.boundary ?? null,
+    partial: extra.partial === true,
+  };
+}
+
+function ref(name: string): WireNodeRef {
+  return {
+    id: `method:${name}`,
+    kind: 'method',
+    name,
+    qualifiedName: name,
+    file: `src/${name}.ts`,
+    line: 10,
+    endLine: 40,
+    language: 'typescript',
+    test: false,
+  };
+}
+
+function boundary(over: Partial<WireFlowBoundary> = {}): WireFlowBoundary {
+  return {
+    node: ref('routeAny'),
+    sites: [
+      {
+        form: 'computed-call',
+        label: 'computed member call',
+        snippet: "return table[name](payload);",
+        line: 61,
+        key: 'save',
+        keyIsType: false,
+        moreSites: 0,
+        candidates: [{ node: ref('onSave'), display: 'onSave', named: true }],
+        candidateNote: null,
+      },
+    ],
+    uncertain: { total: 0, shown: 0, truncated: false, items: [] },
+    further: { total: 0, shown: 0, truncated: false, items: [] },
+    missed: [ref('onSave')],
+    ...over,
   };
 }
 
@@ -200,6 +254,7 @@ describe('buildFlowLayout — one path', () => {
   it('answers an empty picture for no flows at all', () => {
     expect(buildFlowLayout([], null)).toEqual({
       cards: [],
+      endCaps: [],
       links: [],
       width: 0,
       height: 0,
@@ -251,6 +306,147 @@ describe('buildFlowLayout — two paths that merge', () => {
   });
 });
 
+describe('endCapText', () => {
+  it('names the form, keeps the key and counts the candidates', () => {
+    const text = endCapText(boundary());
+    expect(text.intro).toContain('routeAny');
+    expect(text.sites[0].headline).toBe('computed member call at line 61');
+    expect(text.sites[0].key).toBe('save');
+    expect(text.sites[0].candidateHeading).toBe('1 candidate target \u203a');
+    expect(text.quiet).toBeNull();
+    expect(text.missed).toContain('onSave');
+  });
+
+  it('says the key is a runtime value rather than leaving the line blank', () => {
+    const b = boundary();
+    b.sites[0]!.key = null;
+    b.sites[0]!.candidates = [];
+    const text = endCapText(b);
+    expect(text.sites[0].key).toBeNull();
+    expect(text.sites[0].notes).toContain('the key is a runtime value');
+    expect(text.sites[0].candidateHeading).toBeNull();
+  });
+
+  it('admits when the detector found nothing rather than implying a cause', () => {
+    const text = endCapText(boundary({ sites: [] }));
+    expect(text.quiet).toMatch(/No dynamic-dispatch site/);
+    expect(text.sites).toEqual([]);
+  });
+
+  it('leads with the unfollowed name-only matches and their confidence', () => {
+    const text = endCapText(
+      boundary({
+        uncertain: {
+          total: 3,
+          shown: 2,
+          truncated: true,
+          items: [
+            { node: ref('save'), line: 61, confidence: 0.4 },
+            { node: ref('store'), line: 62, confidence: 0.35 },
+          ],
+        },
+      })
+    );
+    // The count is the TRUE total, not the length of the visible list.
+    expect(text.uncertainHeading).toBe('3 name-only matches not followed (confidence < 0.6)');
+    expect(text.uncertain).toHaveLength(2);
+  });
+
+  it('counts further resolved calls in the plural the number actually needs', () => {
+    const one = endCapText(
+      boundary({ further: { total: 1, shown: 1, truncated: false, items: [] } })
+    );
+    expect(one.further).toContain('1 further resolved call ');
+    const many = endCapText(
+      boundary({ further: { total: 4, shown: 0, truncated: true, items: [] } })
+    );
+    expect(many.further).toContain('4 further resolved calls ');
+  });
+});
+
+describe('endCapHeight', () => {
+  it('grows with what the cap has to say', () => {
+    const bare = endCapHeight(boundary({ sites: [], missed: [] }));
+    const full = endCapHeight(
+      boundary({
+        uncertain: {
+          total: 2,
+          shown: 2,
+          truncated: false,
+          items: [
+            { node: ref('save'), line: 61, confidence: 0.4 },
+            { node: ref('store'), line: 62, confidence: 0.3 },
+          ],
+        },
+        further: { total: 5, shown: 0, truncated: true, items: [] },
+      })
+    );
+    expect(full).toBeGreaterThan(bare);
+  });
+
+  it('is a whole number, because it is a pixel', () => {
+    expect(Number.isInteger(endCapHeight(boundary()))).toBe(true);
+  });
+});
+
+describe('buildFlowLayout — the end cap', () => {
+  it('places the cap one column past the symbol the path stopped at', () => {
+    const f = flow('f1', ['alpha', 'routeAny'], { boundary: boundary() });
+    const layout = buildFlowLayout([f], 'f1');
+    expect(layout.endCaps).toHaveLength(1);
+    const cap = layout.endCaps[0]!;
+    expect(cap.id).toBe(capId('method:routeAny'));
+    expect(cap.anchorId).toBe('method:routeAny');
+    expect(cap.column).toBe(1 + 1);
+    expect(cap.width).toBe(END_CAP_WIDTH);
+    expect(layout.columns).toBe(3);
+    // The card the cap hangs off is tinted at the dispatch line.
+    expect(layout.cards.find((c) => c.id === 'method:routeAny')!.stopLine).toBe(61);
+    expect(layout.cards.find((c) => c.id === 'method:alpha')!.stopLine).toBeNull();
+  });
+
+  it('joins it with a dotted link that carries no arrow and no edge', () => {
+    const layout = buildFlowLayout([flow('f1', ['alpha', 'routeAny'], { boundary: boundary() })], 'f1');
+    const link = layout.links.find((l) => l.cap);
+    expect(link).toBeDefined();
+    expect(link!.edge).toBeNull();
+    expect(link!.dash).toBe(END_CAP_DASH);
+    expect(link!.label).toBe('end of static path');
+    expect(link!.labelLines.join(' ')).toBe('end of static path');
+    expect(link!.lineLabel).toBeNull();
+  });
+
+  it('draws no cap for a flow that reached what it was asked for', () => {
+    const layout = buildFlowLayout([flow('f1', ['alpha', 'beta'])], 'f1');
+    expect(layout.endCaps).toEqual([]);
+    expect(layout.links.every((l) => !l.cap)).toBe(true);
+  });
+
+  it('draws ONE cap when two paths run out at the same symbol', () => {
+    const a = flow('a', ['alpha', 'routeAny'], { boundary: boundary() });
+    const b = flow('b', ['gamma', 'routeAny'], { boundary: boundary() });
+    const layout = buildFlowLayout([a, b], 'a');
+    expect(layout.endCaps).toHaveLength(1);
+    expect(layout.endCaps[0]!.flows.sort()).toEqual(['a', 'b']);
+  });
+
+  it('leaves room for a cap wider or narrower than a card', () => {
+    const layout = buildFlowLayout([flow('f1', ['alpha', 'routeAny'], { boundary: boundary() })], 'f1');
+    const cap = layout.endCaps[0]!;
+    // The canvas is wide enough to hold the cap, not just the cards.
+    expect(layout.width).toBe(cap.x + cap.width + PADDING);
+    // And the cap starts one gap past the card it hangs off.
+    const anchor = layout.cards.find((c) => c.id === 'method:routeAny')!;
+    expect(cap.x).toBe(anchor.x + CARD_WIDTH + LINK_WIDTH);
+  });
+
+  it('ignores a boundary whose symbol is not on screen', () => {
+    const orphan = boundary({ node: ref('nowhere') });
+    const layout = buildFlowLayout([flow('f1', ['alpha', 'beta'], { boundary: orphan })], 'f1');
+    expect(layout.endCaps).toEqual([]);
+  });
+});
+
 describe('buildFlowLayout — awkward shapes', () => {
   it('never draws a card left of something that calls it, on a long merge', () => {
     // a → b → c → d and a → d: `d`'s column must come from the LONGEST route,

+ 13 - 0
docs/design/codegraph-ui-design-spec.md

@@ -190,6 +190,19 @@ heuristic dasharray `5 3`. End cap: **240px**, dashed `--rule-soft` border, 12px
 (form, key, line) + uncertain continuations. In the real build the strip is a Svelte Flow canvas laid out left→right with the
 same card/link visuals.
 
+**End cap, as built (phase 2, CG-51).** Shown only when a flow does not reach everything the question named —
+a connected answer has no boundary to announce. 240px, 1px dashed `--rule-soft`, padding 12px, 12px/1.45 `--ink-2`,
+joined to the card it hangs off by an 86px `2 4` dotted link labelled "end of static path" with **no arrowhead**
+(an arrow would point at a continuation). Content: "**Where the graph stops.**" then, per dispatch site, the form and
+its line ("computed member call at line 61"), the static key in 11.5px mono when one is visible, "the key is a runtime
+value" when not, "N candidate targets ›" over clickable mono rows (`display` + `basename:line`, an already-named symbol
+first), then the name-only continuations under 0.6 as mono rows with their confidence and a dotted `--ink-4` underline,
+then the count of further resolved calls and the symbols never reached. Its height is arithmetic like a card's
+(`endCapText` builds the strings, `endCapHeight` measures them, the component renders exactly those), and the card it
+hangs off opens at the dispatch line and tints it `--accent-soft`. One cap per stopping symbol, not per flow.
+The verdict comes from `src/graph/dynamic-boundary-report.ts` — the detector `codegraph_explore` announces boundaries
+with — so the strip and the MCP answer cannot disagree.
+
 ### 3.6 Map (`#/map`)
 Grid: canvas `minmax(600px,1fr)` | side panel **320px** (`--rule-soft` left border, 14px 16px padding).
 Nodes: rect `width = max(110, label.length × 7.3 + 28)`, **height 40**, `--paper` fill, 1px `--ink` stroke (2px + `--press` fill

+ 10 - 0
site/src/content/docs/guides/viewer.md

@@ -71,6 +71,16 @@ Each card is opened at the line that makes the next call, not at the top of the
 - **When a name means several definitions**, the strip says so under the picture and names the one this path runs through — and offers the other paths in the picker at the top. Choosing "All paths" draws them as one diagram, branching where they differ and rejoining where they agree.
 - **"Not connected" is an answer**, not a failure: a flow that runs through a dispatch no static edge records genuinely has no path, and the screen says that rather than inventing one.
 
+### Where the graph stops
+
+A path that does not reach what you asked about ends in a dashed block headed **"Where the graph stops."** It is the honest end of the search rather than an error, and it carries what the resolver actually knows:
+
+- **The dispatch form** that ended the path — a computed member call, a `getattr`, a reflective invoke, a `#selector`, a typed message bus — and the line it sits on. The card beside it is opened at that line, so the source the block is describing is on screen.
+- **The key, when the source writes one down.** `handlers['save']` gives `save`, and the block shortlists the symbols that could be on the other side of it — `onSave`, `handleSave`, `SaveHandler` — marking any you already named. When the key is a runtime value it says so instead of shortlisting anything.
+- **What was not followed.** Name-only matches under 0.6 confidence are listed with their confidence, and the other calls the symbol makes are counted. A refused guess left invisible would read as "there is nothing here", which is the one thing it does not mean.
+
+Nothing on the block is invented: no edge is guessed, and none is written to your graph. A flow that reaches what it was asked for never shows one. It is the same finding `codegraph_explore` announces to an agent when a flow breaks, drawn from the same detector, so the screen and the agent's answer cannot disagree.
+
 The **"Read as flow"** button on the trail turns a walk you did by hand into the same strip. It is the same path finder `codegraph_explore` leads its answers with, so the picture and what your agent tells you cannot disagree.
 
 ## The map

+ 359 - 0
src/graph/dynamic-boundary-report.ts

@@ -0,0 +1,359 @@
+/**
+ * Where the graph stops — the boundary report, as data.
+ *
+ * When a flow does not connect, the honest answer is not "no path": it is the
+ * dispatch site where the static path ends. `src/mcp/dynamic-boundaries.ts`
+ * finds those sites in a body with deterministic regex; this module is the
+ * graph-aware layer on top of it — it reads the bodies off disk, shortlists the
+ * candidate runtime targets for a statically-visible dispatch key, and collects
+ * the continuations out of the stopping symbol that the search did not follow.
+ *
+ * It exists for the same reason `named-symbol-flow.ts` does. `codegraph_explore`
+ * announces boundaries in prose ("**Dynamic boundaries** … candidates for key
+ * `save`: …") and the viewer's Flow strip draws the same verdict as an end cap
+ * (design spec §3.5). Two derivations of "where does this stop" would eventually
+ * disagree, and a reader who had both on screen would have no way to tell which
+ * one was lying. So the *verdict* lives here once, and each caller renders it:
+ * `ToolHandler.buildDynamicBoundaries` turns it into markdown, `/api/flow` turns
+ * it into `WireFlowBoundary`.
+ *
+ * Everything here is query-time and read-only. The graph is never mutated, no
+ * edge is ever guessed, and a fully connected flow never reaches this module —
+ * silence beats a wrong edge (#687).
+ */
+
+import type CodeGraph from '../index';
+import type { Edge, Node } from '../types';
+import { scanDynamicDispatch, type BoundaryMatch } from '../mcp/dynamic-boundaries';
+import { validatePathWithinRoot } from '../utils';
+import { existsSync, readFileSync } from 'fs';
+
+/** Below this resolution confidence an edge is a name-only guess, not a call. */
+export const UNCERTAIN_BELOW = 0.6;
+
+/** Dispatch sites reported across one scan. Matches explore's bullet budget. */
+export const MAX_BOUNDARY_SITES = 4;
+
+/** Bodies read off disk per scan, however many symbols were handed in. */
+const MAX_SCAN = 8;
+
+/** Total body characters read per scan — a god-function tail must not stall a request. */
+const MAX_TOTAL_CHARS = 200_000;
+
+/** Candidate runtime targets shortlisted for one dispatch key. */
+const MAX_CANDIDATES = 4;
+
+/** FTS rows inspected while shortlisting; also the "too generic" threshold. */
+const CANDIDATE_SEARCH_LIMIT = 12;
+
+/** Kinds that can be the runtime target of a dispatch. */
+const CALLABLE_KINDS = new Set(['method', 'function', 'component', 'constructor', 'class']);
+
+/**
+ * A conventional handler method on a typed-bus target class — MediatR's
+ * `Handle`, a consumer's `Consume`, PHP's `__invoke`.
+ */
+const HANDLER_METHODS = /^(handle|handleAsync|execute|executeAsync|consume|consumeAsync|run|__invoke)$/i;
+
+// =============================================================================
+// Shapes
+// =============================================================================
+
+/** One plausible runtime target of a keyed dispatch. */
+export interface BoundaryCandidate {
+  node: Node;
+  /**
+   * How the candidate should be named. Usually `qualifiedName`, but a typed-bus
+   * key resolves to a CLASS whose real target is its handler method, so the
+   * display names that method (`CreateTodoCommandHandler.Handle`) and `node` is
+   * the method too — a row the reader clicks must open what it claims.
+   */
+  display: string;
+  /** The reader already named this symbol: "you were right, here's the wiring". */
+  named: boolean;
+}
+
+/** A dispatch site: the detector's verdict plus what the graph knows about it. */
+export interface BoundarySite extends BoundaryMatch {
+  /** Runtime targets for {@link BoundaryMatch.key}. Empty when the key is a runtime value. */
+  candidates: BoundaryCandidate[];
+  /**
+   * Why there is no shortlist, when a key was visible but nothing could be
+   * narrowed down: "key `id` is too generic to shortlist (12+ matches)".
+   */
+  candidateNote: string | null;
+}
+
+/** Every dispatch site found in one symbol's body. */
+export interface NodeBoundary {
+  node: Node;
+  sites: BoundarySite[];
+}
+
+/** One call out of the stopping symbol, and how sure the resolver was of it. */
+export interface BoundaryContinuation {
+  node: Node;
+  line: number | null;
+  confidence: number | null;
+}
+
+/**
+ * The calls recorded out of a symbol, split by whether the resolver believed
+ * them. `uncertain` is the part a flow search deliberately does not follow.
+ */
+export interface BoundaryContinuations {
+  resolved: BoundaryContinuation[];
+  uncertain: BoundaryContinuation[];
+}
+
+export interface BoundaryScanOptions {
+  /** Dispatch sites returned in total. Default {@link MAX_BOUNDARY_SITES}. */
+  maxSites?: number;
+  /** Symbols the reader named — candidates matching one are marked and sort first. */
+  named?: ReadonlyMap<string, Node>;
+}
+
+// =============================================================================
+// The scan
+// =============================================================================
+
+/**
+ * Scan the given symbols' bodies for dynamic-dispatch sites, in order.
+ *
+ * `scanList` is a priority order, not a set: the caller puts the place the flow
+ * actually stopped first (the chain's dead end), then the symbols that were
+ * asked for and never reached. Scanning stops at the first of three budgets —
+ * sites found, bodies read, characters read — so a question about a god
+ * function costs the same as any other.
+ *
+ * Returns one entry per symbol that yielded at least one site; a symbol with a
+ * clean body is simply absent, because "nothing dynamic here" is not a finding.
+ */
+export function findDynamicBoundaries(
+  cg: CodeGraph,
+  scanList: readonly Node[],
+  opts: BoundaryScanOptions = {}
+): NodeBoundary[] {
+  const maxSites = opts.maxSites ?? MAX_BOUNDARY_SITES;
+  const named = opts.named ?? new Map<string, Node>();
+  let projectRoot: string;
+  try {
+    projectRoot = cg.getProjectRoot();
+  } catch {
+    return [];
+  }
+
+  const out: NodeBoundary[] = [];
+  const seenNode = new Set<string>();
+  const seenSite = new Set<string>();
+  let sites = 0;
+  let scanned = 0;
+  let charsScanned = 0;
+
+  for (const node of scanList) {
+    if (sites >= maxSites || scanned >= MAX_SCAN || charsScanned > MAX_TOTAL_CHARS) break;
+    if (seenNode.has(node.id) || !node.startLine || !node.endLine) continue;
+    seenNode.add(node.id);
+    const absPath = validatePathWithinRoot(projectRoot, node.filePath);
+    if (!absPath || !existsSync(absPath)) continue;
+    let content: string;
+    try {
+      content = readFileSync(absPath, 'utf-8');
+    } catch {
+      continue;
+    }
+    const body = content.split('\n').slice(node.startLine - 1, node.endLine).join('\n');
+    scanned++;
+    charsScanned += body.length;
+
+    const found: BoundarySite[] = [];
+    for (const match of scanDynamicDispatch(body, node.language || '', node.startLine)) {
+      if (sites >= maxSites) break;
+      const siteKey = `${node.filePath}:${match.line}:${match.form}`;
+      if (seenSite.has(siteKey)) continue;
+      seenSite.add(siteKey);
+      const shortlist = match.key
+        ? shortlistBoundaryCandidates(cg, match.key, !!match.keyIsType, named, node.id)
+        : { candidates: [], note: null };
+      found.push({ ...match, candidates: shortlist.candidates, candidateNote: shortlist.note });
+      sites++;
+    }
+    if (found.length > 0) out.push({ node, sites: found });
+  }
+  return out;
+}
+
+// =============================================================================
+// Candidates
+// =============================================================================
+
+const normalizeName = (s: string): string => s.toLowerCase().replace(/[^a-z0-9]/g, '');
+
+/**
+ * Shortlist the runtime targets a dispatch key could reach.
+ *
+ * Exact conventional names first (`save` → `onSave` / `handleSave`;
+ * `CreateCmd` → `CreateCmdHandler`), then FTS, with a normalized-containment
+ * post-filter — FTS camel-splitting is fuzzier than a candidate list should be,
+ * and a shortlist that is mostly wrong is worse than none. Symbols the caller
+ * already named sort first and are marked.
+ *
+ * A key too short or too common to narrow down returns no candidates and a
+ * `note` saying so, rather than four arbitrary rows.
+ */
+export function shortlistBoundaryCandidates(
+  cg: CodeGraph,
+  key: string,
+  keyIsType: boolean,
+  named: ReadonlyMap<string, Node>,
+  selfId: string
+): { candidates: BoundaryCandidate[]; note: string | null } {
+  const keyNorm = normalizeName(key);
+  if (keyNorm.length < 3) return { candidates: [], note: null };
+
+  const cands = new Map<string, Node>();
+  const consider = (n: Node | undefined | null): void => {
+    if (!n || n.id === selfId || !CALLABLE_KINDS.has(n.kind) || cands.has(n.id)) return;
+    const nameNorm = normalizeName(n.name || '');
+    if (nameNorm.length < 3) return;
+    if (!nameNorm.includes(keyNorm) && !keyNorm.includes(nameNorm)) return;
+    cands.set(n.id, n);
+  };
+
+  const cap = key.charAt(0).toUpperCase() + key.slice(1);
+  const probes = keyIsType
+    ? [`${key}Handler`, key]
+    : [key, `on${cap}`, `handle${cap}`, `${key}Handler`, `handle_${key}`];
+  for (const probe of probes) {
+    try {
+      for (const n of cg.getNodesByName(probe)) consider(n);
+    } catch {
+      /* an exact probe that misses is the normal case */
+    }
+  }
+
+  let raw = 0;
+  try {
+    const results = cg.searchNodes(key, { limit: CANDIDATE_SEARCH_LIMIT });
+    raw = results.length;
+    for (const r of results) consider(r.node);
+  } catch {
+    /* FTS syntax edge — the exact probes already ran */
+  }
+
+  if (cands.size === 0) {
+    const generic = raw >= CANDIDATE_SEARCH_LIMIT && key.length < 5;
+    return {
+      candidates: [],
+      note: generic ? `key \`${key}\` is too generic to shortlist (${raw}+ matches)` : null,
+    };
+  }
+
+  // A constructor candidate duplicates its class: extractors emit constructors
+  // as METHOD nodes named like the class (C#/Java `Foo::Foo`) — keep the class.
+  const all = [...cands.values()];
+  const classKey = new Set(
+    all.filter((n) => n.kind === 'class').map((n) => `${n.name}|${n.filePath}`)
+  );
+  // The flow's named set holds callables only, so a class whose METHOD the
+  // reader named still counts as named — transfer the mark by name.
+  const namedNames = new Set([...named.values()].map((n) => n.name));
+  const isNamed = (n: Node): boolean => named.has(n.id) || namedNames.has(n.name);
+
+  const candidates = all
+    .filter((n) => !(n.kind !== 'class' && classKey.has(`${n.name}|${n.filePath}`)))
+    .sort((a, b) => (isNamed(b) ? 1 : 0) - (isNamed(a) ? 1 : 0))
+    .slice(0, MAX_CANDIDATES)
+    .map((n): BoundaryCandidate => {
+      // Typed-bus convention: the runtime target is the candidate class's
+      // Handle/Execute/Consume method — name the exact node, not just the class.
+      if (keyIsType && n.kind === 'class') {
+        const method = handlerMethodOf(cg, n);
+        if (method) {
+          return { node: method, display: `${n.name}.${method.name}`, named: isNamed(n) };
+        }
+      }
+      return { node: n, display: n.qualifiedName || n.name, named: isNamed(n) };
+    });
+
+  return { candidates, note: null };
+}
+
+function handlerMethodOf(cg: CodeGraph, cls: Node): Node | null {
+  try {
+    return (
+      cg
+        .getOutgoingEdges(cls.id)
+        .filter((e) => e.kind === 'contains')
+        .map((e) => {
+          try {
+            return cg.getNode(e.target);
+          } catch {
+            return null;
+          }
+        })
+        .find((c): c is Node => !!c && c.kind === 'method' && HANDLER_METHODS.test(c.name)) ?? null
+    );
+  } catch {
+    return null; // a class whose members do not resolve — show the class itself
+  }
+}
+
+// =============================================================================
+// Continuations
+// =============================================================================
+
+const CONTINUATION_KINDS = new Set(['calls', 'instantiates']);
+
+/**
+ * The calls recorded out of a symbol, minus the ones already on the path.
+ *
+ * This is the other half of an honest end cap. A flow that stops somewhere has
+ * two kinds of unexplored exit: calls the resolver was sure of and the path
+ * simply did not need, and name-only matches under {@link UNCERTAIN_BELOW} that
+ * the search deliberately refused to follow. Listing the second kind is the
+ * point — an unfollowed guess that stays invisible reads as "there is nothing
+ * here", which is the one thing it does not mean.
+ *
+ * Deduped by target, keeping the first line each was recorded at.
+ */
+export function continuationsFrom(
+  cg: CodeGraph,
+  node: Node,
+  exclude: ReadonlySet<string> = new Set()
+): BoundaryContinuations {
+  const resolved = new Map<string, BoundaryContinuation>();
+  const uncertain = new Map<string, BoundaryContinuation>();
+  let edges: Edge[];
+  try {
+    edges = cg.getOutgoingEdges(node.id);
+  } catch {
+    return { resolved: [], uncertain: [] };
+  }
+  for (const edge of edges) {
+    if (!CONTINUATION_KINDS.has(edge.kind)) continue;
+    if (edge.target === node.id || exclude.has(edge.target)) continue;
+    const meta = (edge.metadata ?? {}) as Record<string, unknown>;
+    const confidence = typeof meta.confidence === 'number' ? meta.confidence : null;
+    const bucket = confidence !== null && confidence < UNCERTAIN_BELOW ? uncertain : resolved;
+    if (bucket.has(edge.target)) continue;
+    let target: Node | null;
+    try {
+      target = cg.getNode(edge.target);
+    } catch {
+      continue;
+    }
+    if (!target) continue;
+    bucket.set(edge.target, {
+      node: target,
+      line: typeof edge.line === 'number' ? edge.line : null,
+      confidence,
+    });
+  }
+  const byLine = (a: BoundaryContinuation, b: BoundaryContinuation): number =>
+    (a.line ?? 0) - (b.line ?? 0);
+  return {
+    resolved: [...resolved.values()].sort(byLine),
+    uncertain: [...uncertain.values()].sort(byLine),
+  };
+}

+ 28 - 93
src/mcp/tools.ts

@@ -40,7 +40,7 @@ import {
 } from 'fs';
 import { createHash } from 'crypto';
 import { clamp, validatePathWithinRoot, validateProjectPath, isConfigLeafNode, CONFIG_LEAF_LANGUAGES } from '../utils';
-import { scanDynamicDispatch } from './dynamic-boundaries';
+import { findDynamicBoundaries, type BoundarySite } from '../graph/dynamic-boundary-report';
 import {
   lastQualifierPart,
   matchesSymbol,
@@ -2743,37 +2743,22 @@ export class ToolHandler {
    * connected flow never reaches this method.
    */
   private buildDynamicBoundaries(cg: CodeGraph, scanList: Node[], named: Map<string, Node>): string {
-    const MAX_NOTES = 4;       // boundary bullets per explore
-    const MAX_SCAN = 8;        // bodies scanned
-    const MAX_TOTAL_CHARS = 200_000;
-    let projectRoot: string;
-    try { projectRoot = cg.getProjectRoot(); } catch { return ''; }
+    const MAX_NOTES = 4; // boundary bullets per explore
+    // The verdict is not derived here — `findDynamicBoundaries` produces it and
+    // the viewer's end cap renders the same object, so the two can never
+    // disagree about where a flow stops. What is left here is the prose.
+    const reports = findDynamicBoundaries(cg, scanList, { named, maxSites: MAX_NOTES });
     const notes: string[] = [];
-    const seenNode = new Set<string>();
-    const seenSite = new Set<string>();
-    let scanned = 0, charsScanned = 0;
-    for (const node of scanList) {
-      if (notes.length >= MAX_NOTES || scanned >= MAX_SCAN || charsScanned > MAX_TOTAL_CHARS) break;
-      if (seenNode.has(node.id) || !node.startLine || !node.endLine) continue;
-      seenNode.add(node.id);
-      const absPath = validatePathWithinRoot(projectRoot, node.filePath);
-      if (!absPath || !existsSync(absPath)) continue;
-      let content: string;
-      try { content = readFileSync(absPath, 'utf-8'); } catch { continue; }
-      const body = content.split('\n').slice(node.startLine - 1, node.endLine).join('\n');
-      scanned++;
-      charsScanned += body.length;
-      for (const m of scanDynamicDispatch(body, node.language || '', node.startLine)) {
+    for (const report of reports) {
+      if (notes.length >= MAX_NOTES) break;
+      for (const site of report.sites) {
         if (notes.length >= MAX_NOTES) break;
-        const siteKey = `${node.filePath}:${m.line}:${m.form}`;
-        if (seenSite.has(siteKey)) continue;
-        seenSite.add(siteKey);
-        const more = m.moreSites ? ` (+${m.moreSites} more such site${m.moreSites > 1 ? 's' : ''} in this body)` : '';
-        notes.push(`- \`${node.name}\` (${node.filePath}:${m.line}) — ${m.label}: \`${m.snippet}\`${more}`);
-        if (m.key) {
-          const cand = this.boundaryCandidates(cg, m.key, !!m.keyIsType, named, node.id);
-          if (cand) notes.push(`  ${cand}`);
-        }
+        const more = site.moreSites
+          ? ` (+${site.moreSites} more such site${site.moreSites > 1 ? 's' : ''} in this body)`
+          : '';
+        notes.push(`- \`${report.node.name}\` (${report.node.filePath}:${site.line}) — ${site.label}: \`${site.snippet}\`${more}`);
+        const cand = this.boundaryCandidates(site);
+        if (cand) notes.push(`  ${cand}`);
       }
     }
     if (notes.length === 0) return '';
@@ -2875,70 +2860,20 @@ export class ToolHandler {
   }
 
   /**
-   * Shortlist candidate runtime targets for a dispatch key surfaced by
-   * {@link buildDynamicBoundaries}. Exact conventional names first (`save` →
-   * `onSave`/`handleSave`; `CreateCmd` → `CreateCmdHandler`), then FTS, with a
-   * normalized-containment post-filter (FTS camel-splitting is fuzzier than a
-   * candidate list should be). Symbols the agent already named sort first and
-   * are marked — that's the "you were right, here's the wiring" case.
+   * Render the candidate shortlist for a dispatch site as one line.
+   *
+   * The shortlist itself is `shortlistBoundaryCandidates` in
+   * `../graph/dynamic-boundary-report` — shared with the viewer's end cap, so
+   * "candidates for key `save`" names the same symbols in both places. Symbols
+   * the agent already named are marked: that is the "you were right, here's the
+   * wiring" case.
    */
-  private boundaryCandidates(cg: CodeGraph, key: string, keyIsType: boolean, named: Map<string, Node>, selfId: string): string {
-    const CALLABLE = new Set(['method', 'function', 'component', 'constructor', 'class']);
-    const norm = (s: string) => s.toLowerCase().replace(/[^a-z0-9]/g, '');
-    const keyNorm = norm(key);
-    if (keyNorm.length < 3) return '';
-    const cands = new Map<string, Node>();
-    const consider = (n: Node | undefined | null) => {
-      if (!n || n.id === selfId || !CALLABLE.has(n.kind) || cands.has(n.id)) return;
-      const nameNorm = norm(n.name || '');
-      if (nameNorm.length < 3) return;
-      if (!nameNorm.includes(keyNorm) && !keyNorm.includes(nameNorm)) return;
-      cands.set(n.id, n);
-    };
-    const cap = key.charAt(0).toUpperCase() + key.slice(1);
-    const probes = keyIsType
-      ? [`${key}Handler`, key]
-      : [key, `on${cap}`, `handle${cap}`, `${key}Handler`, `handle_${key}`];
-    for (const p of probes) {
-      try { for (const n of cg.getNodesByName(p)) consider(n); } catch { /* exact probe miss is fine */ }
-    }
-    let raw = 0;
-    try {
-      const results = cg.searchNodes(key, { limit: 12 });
-      raw = results.length;
-      for (const r of results) consider(r.node);
-    } catch { /* FTS syntax edge — exact probes already ran */ }
-    if (cands.size === 0) {
-      return raw >= 12 && key.length < 5 ? `key \`${key}\` is too generic to shortlist (${raw}+ matches)` : '';
-    }
-    // A constructor candidate duplicates its class: extractors emit ctors as
-    // METHOD nodes named like the class (C#/Java `Foo::Foo`) — keep the class.
-    const all = [...cands.values()];
-    const classKey = new Set(all.filter((n) => n.kind === 'class').map((n) => `${n.name}|${n.filePath}`));
-    const namedNames = new Set([...named.values()].map((n) => n.name));
-    const isNamed = (n: Node) => named.has(n.id) || namedNames.has(n.name); // the flow's named set holds callables only — transfer the mark to the class
-    const list = all
-      .filter((n) => !(n.kind !== 'class' && classKey.has(`${n.name}|${n.filePath}`)))
-      .sort((a, b) => (isNamed(b) ? 1 : 0) - (isNamed(a) ? 1 : 0))
-      .slice(0, 4)
-      .map((n) => {
-        // Typed-bus convention: the runtime target is the candidate class's
-        // Handle/Execute/Consume method — name the exact node, not just the class.
-        let display = n.qualifiedName || n.name;
-        let at = `${n.filePath}:${n.startLine}`;
-        if (keyIsType && n.kind === 'class') {
-          try {
-            const HANDLER_METHODS = /^(handle|handleAsync|execute|executeAsync|consume|consumeAsync|run|__invoke)$/i;
-            const method = cg.getOutgoingEdges(n.id)
-              .filter((e) => e.kind === 'contains')
-              .map((e) => { try { return cg.getNode(e.target); } catch { return null; } })
-              .find((c): c is Node => !!c && c.kind === 'method' && HANDLER_METHODS.test(c.name));
-            if (method) { display = `${n.name}.${method.name}`; at = `${method.filePath}:${method.startLine}`; }
-          } catch { /* class without resolvable members — show the class itself */ }
-        }
-        return `\`${display}\` (${at})${isNamed(n) ? ' ← you named this' : ''}`;
-      });
-    return `candidates for key \`${key}\`: ${list.join(', ')}`;
+  private boundaryCandidates(site: BoundarySite): string {
+    if (site.candidates.length === 0) return site.candidateNote ?? '';
+    const list = site.candidates.map((c) =>
+      `\`${c.display}\` (${c.node.filePath}:${c.node.startLine})${c.named ? ' ← you named this' : ''}`
+    );
+    return `candidates for key \`${site.key}\`: ${list.join(', ')}`;
   }
 
   /**

+ 273 - 6
src/ui-server/api/flow.ts

@@ -40,11 +40,25 @@ import {
   normalizeToken,
   DIRECTED_MAX_HOPS,
 } from '../../graph/named-symbol-flow';
+import {
+  continuationsFrom,
+  findDynamicBoundaries,
+  type BoundaryContinuation,
+  type NodeBoundary,
+} from '../../graph/dynamic-boundary-report';
 import { highlightLines, type HighlightResult } from '../highlight';
 import { badRequest, intParam } from './respond';
 import { findIndexedFile, hasDriftedOnDisk, splitLines, toRequestPath } from './source';
 import { resolveProjectFile } from '../security';
-import { toNodeRef, toWireEdge, UNCERTAIN_BELOW, type WireEdge, type WireNodeRef } from './wire';
+import {
+  toNodeRef,
+  toWireEdge,
+  wireList,
+  UNCERTAIN_BELOW,
+  type WireEdge,
+  type WireList,
+  type WireNodeRef,
+} from './wire';
 import * as fs from 'fs';
 
 /** Lines shown either side of the call site on a card (design spec §3.5). */
@@ -127,12 +141,80 @@ export interface WireFlowHop {
   source: WireFlowSource | null;
 }
 
+/** One plausible runtime target of a keyed dispatch — a clickable cap row. */
+export interface WireBoundaryCandidate {
+  node: WireNodeRef;
+  /** How to name it: usually the qualified name, or `Class.handlerMethod`. */
+  display: string;
+  /** The question already named this symbol — "you were right, here's the wiring". */
+  named: boolean;
+}
+
+/** A dynamic-dispatch site: the form, the key when it is visible, the targets. */
+export interface WireBoundarySite {
+  /** Stable form id, e.g. `computed-call`. */
+  form: string;
+  /** What to call it on screen: "computed member call", "getattr dispatch". */
+  label: string;
+  /** The source line of the site, trimmed. */
+  snippet: string;
+  line: number;
+  /** The statically visible key (`handlers['save']` → `save`), or null. */
+  key: string | null;
+  /** The key is a TYPE name, so the target is `<Type>Handler` by convention. */
+  keyIsType: boolean;
+  /** Further sites of the same form and key in this body. */
+  moreSites: number;
+  candidates: WireBoundaryCandidate[];
+  /** Why there is no shortlist, when a key was visible but too generic. */
+  candidateNote: string | null;
+}
+
+/** A call out of the stopping symbol, and how sure the resolver was. */
+export interface WireFlowContinuation {
+  node: WireNodeRef;
+  line: number | null;
+  confidence: number | null;
+}
+
+/**
+ * Where the graph stops (design spec §3.5).
+ *
+ * Attached to a flow that does not reach everything the question named. It is
+ * the same verdict `codegraph_explore` announces in prose — both render
+ * `findDynamicBoundaries` — so the strip's end cap and the MCP answer can never
+ * disagree about where a path ends or what could continue it.
+ */
+export interface WireFlowBoundary {
+  /** The last symbol the static path reached. The cap hangs off this card. */
+  node: WireNodeRef;
+  /** Dispatch sites in that symbol's body. Empty when none was detected. */
+  sites: WireBoundarySite[];
+  /** Name-only matches under 0.6 the search did NOT follow. */
+  uncertain: WireList<WireFlowContinuation>;
+  /** Calls the resolver was sure of that this path does not need. */
+  further: WireList<WireFlowContinuation>;
+  /** Symbols the question named that this path never reaches. */
+  missed: WireNodeRef[];
+}
+
 export interface WireFlow {
   /** Stable within a payload: the hop ids joined. Used as the picker's value. */
   id: string;
   /** "execute → rowToFileRecord", for the header's flow picker. */
   label: string;
   hops: WireFlowHop[];
+  /**
+   * The end cap, when this path stops short of the question. Null on a flow
+   * that reaches everything it was asked about — a connected answer has no
+   * boundary to announce, and saying otherwise would be noise.
+   */
+  boundary: WireFlowBoundary | null;
+  /**
+   * This strip is not an answer to the question, it is where the answer ran
+   * out: one card at the dispatch site rather than a path.
+   */
+  partial: boolean;
 }
 
 /** An endpoint that named more than one definition, and which one was taken. */
@@ -371,13 +453,19 @@ interface RawHop {
   node: Node;
   edge: Edge | null;
   upward: boolean;
+  /**
+   * Open the card here instead of at the call site or the definition. Set on a
+   * boundary-only strip, whose single card exists to show the dispatch line.
+   */
+  anchor?: number;
 }
 
 async function toWireFlow(
   cg: CodeGraph,
   projectRoot: string,
   cache: Map<string, FileCache>,
-  raw: readonly RawHop[]
+  raw: readonly RawHop[],
+  extra: { boundary?: WireFlowBoundary | null; partial?: boolean } = {}
 ): Promise<WireFlow> {
   const hops: WireFlowHop[] = [];
   for (let i = 0; i < raw.length; i++) {
@@ -415,7 +503,7 @@ async function toWireFlow(
         projectRoot,
         cache,
         step.node,
-        callRef?.line ?? step.node.startLine
+        step.anchor ?? callRef?.line ?? step.node.startLine
       ),
     });
   }
@@ -423,8 +511,75 @@ async function toWireFlow(
   const last = raw[raw.length - 1]?.node.name ?? '?';
   return {
     id: raw.map((h) => h.node.id).join('>'),
-    label: `${first} → ${last}`,
+    label: extra.partial ? `${first} → stops here` : `${first} → ${last}`,
     hops,
+    boundary: extra.boundary ?? null,
+    partial: extra.partial === true,
+  };
+}
+
+// =============================================================================
+// Where the graph stops
+// =============================================================================
+
+/** Continuations listed in an end cap before it just counts the rest. */
+const MAX_CONTINUATIONS = 6;
+
+/** Symbols named and never reached, listed in an end cap. */
+const MAX_MISSED = 4;
+
+/** Dispatch sites reported per strip. One cap is a card, not a report. */
+const MAX_SITES_PER_FLOW = 3;
+
+function toContinuation(c: BoundaryContinuation): WireFlowContinuation {
+  return { node: toNodeRef(c.node), line: c.line, confidence: c.confidence };
+}
+
+/**
+ * Build the end cap for a path that stopped short.
+ *
+ * `reports` comes from the shared detector, so the form, the key and the
+ * candidate targets are the ones `codegraph_explore` would print. Everything
+ * else on the cap is graph state around the stopping symbol: the calls it makes
+ * that this path did not need, and the name-only matches under 0.6 that the
+ * search refused to follow. That last list is the honest half — an unfollowed
+ * guess left invisible reads as "there is nothing here".
+ */
+function buildBoundary(
+  cg: CodeGraph,
+  stop: Node,
+  reports: readonly NodeBoundary[],
+  missed: readonly Node[],
+  onPath: ReadonlySet<string>
+): WireFlowBoundary {
+  const sites: WireBoundarySite[] = [];
+  for (const report of reports) {
+    for (const site of report.sites) {
+      if (sites.length >= MAX_SITES_PER_FLOW) break;
+      sites.push({
+        form: site.form,
+        label: site.label,
+        snippet: site.snippet,
+        line: site.line,
+        key: site.key ?? null,
+        keyIsType: site.keyIsType === true,
+        moreSites: site.moreSites ?? 0,
+        candidates: site.candidates.map((c) => ({
+          node: toNodeRef(c.node),
+          display: c.display,
+          named: c.named,
+        })),
+        candidateNote: site.candidateNote,
+      });
+    }
+  }
+  const { resolved, uncertain } = continuationsFrom(cg, stop, onPath);
+  return {
+    node: toNodeRef(stop),
+    sites,
+    uncertain: wireList(uncertain.slice(0, MAX_CONTINUATIONS).map(toContinuation), uncertain.length),
+    further: wireList(resolved.slice(0, MAX_CONTINUATIONS).map(toContinuation), resolved.length),
+    missed: missed.slice(0, MAX_MISSED).map(toNodeRef),
   };
 }
 
@@ -543,16 +698,75 @@ export async function buildFlow(
   const chosen = new Set(flow.chains.flatMap((c) => c.steps.map((s) => s.node.id)));
   const flows: WireFlow[] = [];
   for (const chain of flow.chains) {
+    const onPath = new Set(chain.steps.map((s) => s.node.id));
+    // A path that reaches everything the question named is connected, and a
+    // connected answer gets no cap — this is the gate the whole feature turns
+    // on. In directed mode a chain ends at `to` by construction, so it is
+    // always connected and this is always empty.
+    const missed = uncoveredNamed(flow, onPath);
+    let boundary: WireFlowBoundary | null = null;
+    if (missed.length > 0) {
+      const stop = (chain.steps[chain.steps.length - 1] as { node: Node }).node;
+      // Scan order is explore's: the dead end first (that IS where the partial
+      // flow stopped), then the symbols it never reached.
+      const reports = findDynamicBoundaries(cg, [stop, ...missed], {
+        named: flow.named,
+        maxSites: MAX_SITES_PER_FLOW,
+      });
+      boundary = buildBoundary(cg, stop, reports, missed, onPath);
+    }
+    // The last card opens at the dispatch line rather than at its definition,
+    // so the window shows the site the cap beside it is describing. Without
+    // this a long body puts them hundreds of lines apart and the cap reads as a
+    // claim about code the reader cannot see.
+    const stopLine = boundary?.sites[0]?.line;
     flows.push(
       await toWireFlow(
         cg,
         projectRoot,
         cache,
-        chain.steps.map((s) => ({ node: s.node, edge: s.edge, upward: false }))
+        chain.steps.map((s, i) => ({
+          node: s.node,
+          edge: s.edge,
+          upward: false,
+          ...(stopLine !== undefined && i === chain.steps.length - 1 ? { anchor: stopLine } : {}),
+        })),
+        { boundary }
       )
     );
   }
 
+  // No path at all. If a dispatch site explains why, the strip is that site:
+  // one card opened at the line where the static path ends, and the cap. Saying
+  // "not connected" while the answer sits three lines into the body would be
+  // the same silence this whole feature exists to break. With nothing detected
+  // we do NOT invent a stopping point — the search covered a whole region, and
+  // pinning "the graph stops here" on the seed would be a claim, not a finding.
+  if (flows.length === 0 && flow.named.size > 0) {
+    const seeds = boundarySeeds(flow, directed ? parsed.from : null, directed ? parsed.to : null);
+    const reports = findDynamicBoundaries(cg, seeds, {
+      named: flow.named,
+      maxSites: MAX_SITES_PER_FLOW,
+    });
+    const first = reports[0];
+    if (first && first.sites[0]) {
+      const stop = first.node;
+      const missed = uncoveredNamed(flow, new Set([stop.id]));
+      flows.push(
+        await toWireFlow(
+          cg,
+          projectRoot,
+          cache,
+          [{ node: stop, edge: null, upward: false, anchor: first.sites[0].line }],
+          {
+            boundary: buildBoundary(cg, stop, reports, missed, new Set([stop.id])),
+            partial: true,
+          }
+        )
+      );
+    }
+  }
+
   return {
     ...base,
     query: {
@@ -564,11 +778,64 @@ export async function buildFlow(
     flows,
     ambiguous: ambiguitiesOf(flow.tokenNodes, flow.named, chosen, flow.tokens),
     unresolved,
-    reason: flows.length > 0 ? null : noFlowReason(parsed, flow.tokens.length, unresolved),
+    // A boundary strip is not a path, so the reason still stands: it says what
+    // was not found, and the cap says where the looking stopped.
+    reason: flow.chains.length > 0 ? null : noFlowReason(parsed, flow.tokens.length, unresolved),
     timing: { elapsedMs: Date.now() - started },
   };
 }
 
+/**
+ * The named symbols this path never reaches, deduped by name.
+ *
+ * Per TOKEN, not per node: a token whose overloads are all off the path is
+ * genuinely unreached, but a token with one overload on it is answered — which
+ * is exactly how `codegraph_explore` decides whether to announce a boundary.
+ * The reader's own vocabulary (`uniqueNamedNodeIds`) sorts first, because a
+ * symbol only they named is the one they are actually asking about.
+ */
+function uncoveredNamed(
+  flow: ReturnType<typeof resolveNamedSymbolFlow>,
+  onPath: ReadonlySet<string>
+): Node[] {
+  const out: Node[] = [];
+  const seenName = new Set<string>();
+  for (const ids of flow.tokenNodes.values()) {
+    if (ids.length === 0 || ids.some((id) => onPath.has(id))) continue;
+    for (const id of ids) {
+      const node = flow.named.get(id);
+      if (!node || seenName.has(node.name)) continue;
+      seenName.add(node.name);
+      out.push(node);
+    }
+  }
+  return out.sort(
+    (a, b) =>
+      (flow.uniqueNamedNodeIds.has(b.id) ? 1 : 0) - (flow.uniqueNamedNodeIds.has(a.id) ? 1 : 0)
+  );
+}
+
+/**
+ * Bodies to scan when nothing connected, in the order worth scanning them.
+ *
+ * The outward walk starts at `from`, so a dispatch in `from`'s body is the one
+ * that stopped it; `to`'s body is scanned after, because a flow can equally
+ * break on the far side (the handler is reached by a bus nobody calls
+ * directly). A `?symbols=` question has no direction and scans what it named.
+ */
+function boundarySeeds(
+  flow: ReturnType<typeof resolveNamedSymbolFlow>,
+  from: string | null,
+  to: string | null
+): Node[] {
+  if (from === null || to === null) return [...flow.named.values()];
+  const pick = (token: string): Node[] =>
+    (flow.tokenNodes.get(normalizeToken(token)) ?? [])
+      .map((id) => flow.named.get(id))
+      .filter((n): n is Node => !!n);
+  return [...pick(from), ...pick(to)];
+}
+
 /**
  * Why there is no strip, in the words that say what to do next.
  *

+ 23 - 1
ui/README.md

@@ -43,7 +43,7 @@ src/
   lib/trail.svelte.ts     the walked path; mirrored into the `t` query param
   lib/kinds.ts            kind glyph letters
   lib/map-model.ts        the Map's deterministic layered layout (pure)
-  lib/flow-model.ts       the Flow strip's card/link geometry — a DAG (pure)
+  lib/flow-model.ts       the Flow strip's card/link geometry + the end cap — a DAG (pure)
   lib/filecode-model.ts   the whole-file view: fixed line height, arcs, paging (pure)
   lib/live.svelte.ts      /api/events: two counters every screen refreshes from
   lib/toast.svelte.ts     the one transient note ("Index updated · reloaded")
@@ -68,6 +68,28 @@ announce the project to a font CDN.
 | `#/flow?symbols=a,b,c` | flow strip — `codegraph_explore`'s own question |
 | `#/flow?t=<trail>` | flow strip — the trail you walked, read as a flow |
 
+## Where the graph stops
+
+A flow that does not reach everything it was asked about carries a
+`boundary` on the wire, and `buildFlowLayout` turns it into an extra 240px node
+one column past the symbol the path stopped at, joined by a dotted `2 4` link
+labelled "end of static path" that deliberately has **no arrowhead** — an arrow
+would point at a continuation, and the absence of one is the finding.
+
+Two rules hold it together:
+
+- **The cap's height is arithmetic, like a card's.** `endCapText()` builds every
+  sentence the cap shows and `endCapHeight()` measures them; the component then
+  renders exactly what was measured. Change the wording in one and the other
+  moves with it — they are the same function read twice.
+- **One cap per stopping symbol, not per flow.** Two paths that run out at the
+  same place ran out for the same reason, and two caps side by side would read
+  as two different findings.
+
+The verdict itself is not computed here or in the server: it is
+`findDynamicBoundaries` in `src/graph/dynamic-boundary-report.ts`, the same
+detector `codegraph_explore` announces boundaries with.
+
 ## Live updates
 
 The viewer never polls. `lib/live.svelte.ts` holds one `EventSource` on

+ 1 - 1
ui/src/components/flow/FlowCard.svelte

@@ -73,7 +73,7 @@
       const claimed = assignRefs(lineTokens, refs.get(n) ?? []);
       return {
         n,
-        call: n === hop.callRef?.line,
+        call: n === hop.callRef?.line || n === card.stopLine,
         parts: lineTokens.map((token, index): Part => {
           const ref = claimed.get(index) ?? null;
           return { text: token.text, cls: ref ? null : tokenClass(token.cls), ref };

+ 179 - 0
ui/src/components/flow/FlowEndCap.svelte

@@ -0,0 +1,179 @@
+<!--
+  Where the graph stops (design spec §3.5).
+
+  The last thing on a strip that did not reach what it was asked about. It is
+  not an error state and not an apology: a flow running through a computed
+  member call, a string-keyed bus or a reflective invoke genuinely has no static
+  edge to follow, and the useful answer is the dispatch site itself — the form,
+  the key when the source makes it visible, the line, and the symbols that could
+  plausibly be on the other side.
+
+  Every claim on it comes from the server, which builds it with the same
+  detector `codegraph_explore` announces boundaries with. Nothing here guesses:
+  a candidate row is a shortlist, and it says so by being under a heading that
+  counts it rather than under an arrow that asserts it.
+
+  The last block is the one that matters most. A name-only match under 0.6
+  confidence is a continuation the search deliberately refused to follow, and
+  leaving it invisible would read as "there is nothing here" — which is the one
+  thing it does not mean.
+-->
+<script lang="ts">
+  import { Handle, Position } from '@xyflow/svelte';
+  import { endCapText, type FlowEndCapLayout } from '../../lib/flow-model';
+  import { basename } from '../../lib/symbol-model';
+
+  interface Props {
+    data: {
+      cap: FlowEndCapLayout;
+      dimmed: boolean;
+      onOpen: (nodeId: string) => void;
+    };
+  }
+
+  let { data }: Props = $props();
+  let cap = $derived(data.cap);
+  let text = $derived(endCapText(cap.boundary));
+</script>
+
+<div
+  class="endcap"
+  class:dim={data.dimmed}
+  style={`width:${cap.width}px;min-height:${cap.height}px`}
+>
+  <Handle type="target" position={Position.Left} id="in" isConnectable={false} />
+
+  <p class="lead"><b>Where the graph stops.</b> {text.intro}</p>
+
+  {#each text.sites as site, i (i)}
+    <div class="site">
+      <p class="form">{site.headline}</p>
+      {#if site.key !== null}
+        <p class="key">key <span class="mono">{site.key}</span></p>
+      {/if}
+      {#each site.notes as note (note)}
+        <p class="soft">{note}</p>
+      {/each}
+      {#if site.candidateHeading !== null}
+        <p class="soft">{site.candidateHeading}</p>
+        {#each site.candidates as candidate (candidate.node.id)}
+          <button type="button" class="row" onclick={() => data.onOpen(candidate.node.id)}>
+            <span class="nm">{candidate.display}</span>
+            <span class="at">{basename(candidate.node.file)}:{candidate.node.line}</span>
+          </button>
+        {/each}
+      {:else if site.candidateNote !== null}
+        <p class="soft">{site.candidateNote}</p>
+      {/if}
+    </div>
+  {/each}
+
+  {#if text.quiet !== null}
+    <p class="soft block">{text.quiet}</p>
+  {/if}
+
+  {#if text.uncertainHeading !== null}
+    <div class="block">
+      <p class="soft">{text.uncertainHeading}</p>
+      {#each text.uncertain as next (next.node.id)}
+        <button type="button" class="row" onclick={() => data.onOpen(next.node.id)}>
+          <span class="nm unsure">{next.node.name}</span>
+          <span class="at">{next.confidence === null ? '' : next.confidence.toFixed(2)}</span>
+        </button>
+      {/each}
+    </div>
+  {/if}
+
+  {#if text.further !== null}
+    <p class="block">{text.further}</p>
+  {/if}
+  {#if text.missed !== null}
+    <p class="block">{text.missed}</p>
+  {/if}
+</div>
+
+<style>
+  .endcap {
+    box-sizing: border-box;
+    padding: 12px;
+    background: var(--paper);
+    border: 1px dashed var(--rule-soft);
+    color: var(--ink-2);
+    font-size: 12px;
+    line-height: 1.45;
+    text-align: left;
+  }
+
+  .endcap.dim {
+    opacity: 0.4;
+  }
+
+  .lead {
+    margin: 0;
+  }
+
+  .lead b {
+    color: var(--ink);
+    font-weight: 600;
+  }
+
+  .site,
+  .block {
+    margin-top: 8px;
+  }
+
+  .endcap p {
+    margin: 0;
+  }
+
+  .form {
+    color: var(--ink);
+  }
+
+  .soft {
+    color: var(--ink-3);
+  }
+
+  .key .mono,
+  .mono {
+    font-family: var(--mono);
+  }
+
+  .row {
+    display: flex;
+    width: 100%;
+    align-items: baseline;
+    padding: 0;
+    background: none;
+    border: 0;
+    color: var(--ink-2);
+    cursor: pointer;
+    font: 11.5px / 18px var(--mono);
+    gap: 8px;
+    justify-content: space-between;
+    text-align: left;
+  }
+
+  .row:hover .nm {
+    color: var(--accent);
+  }
+
+  .nm {
+    overflow: hidden;
+    text-overflow: ellipsis;
+    white-space: nowrap;
+  }
+
+  /* A refused match reads as refused: the dotted rule under it is the same one
+     the code block draws under an uncertain call site. */
+  .unsure {
+    text-decoration: underline dotted var(--ink-4);
+    text-underline-offset: 3px;
+  }
+
+  .at {
+    color: var(--ink-4);
+    font-size: 11px;
+    white-space: nowrap;
+  }
+</style>

+ 7 - 1
ui/src/components/flow/FlowLink.svelte

@@ -11,6 +11,10 @@
   Straight, not curved: two cards on the same row sit at the same height, and a
   bezier between them would be a decorative wobble. The path bends only when a
   branch puts them on different rows.
+
+  The link into an end cap is the exception with no edge behind it: `2 4` dots,
+  no arrowhead, labelled "end of static path". An arrow would point at a
+  continuation, and the whole point of the cap is that there isn't one.
 -->
 <script lang="ts">
   import { BaseEdge, type EdgeProps } from '@xyflow/svelte';
@@ -39,7 +43,9 @@
 </script>
 
 <BaseEdge {path} class={`flink${d.dimmed ? ' dimmed' : ''}`} style={dashStyle} />
-<polygon class={`fhead${d.dimmed ? ' dimmed' : ''}`} points={head} />
+{#if !d.link.cap}
+  <polygon class={`fhead${d.dimmed ? ' dimmed' : ''}`} points={head} />
+{/if}
 <g class={`flabel${d.dimmed ? ' dimmed' : ''}`}>
   <title>{d.link.label}{d.link.lineLabel ? ` (${d.link.lineLabel})` : ''}</title>
   {#each above as line, i (i)}

+ 39 - 0
ui/src/lib/api.ts

@@ -459,11 +459,50 @@ export interface WireFlowHop {
   source: WireFlowSource | null;
 }
 
+/** One plausible runtime target of a keyed dispatch — a clickable cap row. */
+export interface WireBoundaryCandidate {
+  node: WireNodeRef;
+  display: string;
+  named: boolean;
+}
+
+/** A dynamic-dispatch site: the form, the key when it is visible, the targets. */
+export interface WireBoundarySite {
+  form: string;
+  label: string;
+  snippet: string;
+  line: number;
+  key: string | null;
+  keyIsType: boolean;
+  moreSites: number;
+  candidates: WireBoundaryCandidate[];
+  candidateNote: string | null;
+}
+
+export interface WireFlowContinuation {
+  node: WireNodeRef;
+  line: number | null;
+  confidence: number | null;
+}
+
+/** Where the graph stops — the strip's end cap (design spec §3.5). */
+export interface WireFlowBoundary {
+  node: WireNodeRef;
+  sites: WireBoundarySite[];
+  uncertain: WireList<WireFlowContinuation>;
+  further: WireList<WireFlowContinuation>;
+  missed: WireNodeRef[];
+}
+
 export interface WireFlow {
   id: string;
   /** "execute → rowToFileRecord", for the header's flow picker. */
   label: string;
   hops: WireFlowHop[];
+  /** Null on a flow that reaches everything it was asked about. */
+  boundary: WireFlowBoundary | null;
+  /** One card at the dispatch site, not a path: the answer ran out here. */
+  partial: boolean;
 }
 
 export interface WireFlowAmbiguity {

+ 310 - 40
ui/src/lib/flow-model.ts

@@ -23,7 +23,14 @@
  * Tested in `__tests__/ui-flow-model.test.ts`.
  */
 
-import type { WireFlow, WireFlowEdge, WireFlowHop } from './api';
+import type {
+  WireBoundaryCandidate,
+  WireFlow,
+  WireFlowBoundary,
+  WireFlowContinuation,
+  WireFlowEdge,
+  WireFlowHop,
+} from './api';
 
 /* ------------------------------------------------------------ dimensions -- */
 
@@ -67,6 +74,143 @@ export function cardHeight(hop: WireFlowHop): number {
   return HEADER_HEIGHT + body;
 }
 
+/* --------------------------------------------------------------- end cap -- */
+
+/** End-cap width (design spec §3.5). */
+export const END_CAP_WIDTH = 240;
+/** Padding inside the cap, all four sides. */
+export const END_CAP_PADDING = 12;
+/** 12px text at 1.45 — the cap's own line box. */
+export const END_CAP_LINE = 17.4;
+/** One mono row: a candidate target, an uncertain continuation. */
+export const END_CAP_ROW = 18;
+/** Space between two blocks inside the cap. */
+export const END_CAP_GAP = 8;
+
+/**
+ * Characters of 12px Archivo that fit across the cap's 216px of content.
+ *
+ * The cap's height has to be known before it renders, for the same reason a
+ * card's does — the layout packs columns with it. So the text is built here
+ * (see {@link endCapText}), measured with this constant, and the component
+ * renders exactly what was measured. Deliberately a little pessimistic: a cap
+ * estimated too tall leaves white space, a cap estimated too short would put
+ * its last row under the next one.
+ */
+const END_CAP_CHARS = 32;
+
+function wrappedLines(text: string): number {
+  return Math.max(1, Math.ceil(text.length / END_CAP_CHARS));
+}
+
+/** One dispatch site as the cap words it. */
+export interface EndCapSite {
+  /** "computed member call at line 61". */
+  headline: string;
+  /** The statically visible key, set in mono. Null when it is a runtime value. */
+  key: string | null;
+  /** "key is a runtime value", or the "+N more such sites" tail. */
+  notes: string[];
+  candidates: WireBoundaryCandidate[];
+  /** "N candidate targets", the heading over the rows. Null when there are none. */
+  candidateHeading: string | null;
+  /** Why there is no shortlist, when a key was visible but too generic. */
+  candidateNote: string | null;
+}
+
+/**
+ * Everything the end cap says, as strings.
+ *
+ * Built here rather than in the component so the layout can measure the cap
+ * before it exists — and so the wording is testable without a browser.
+ */
+export interface EndCapText {
+  /** The sentence after the bold "Where the graph stops." lead. */
+  intro: string;
+  sites: EndCapSite[];
+  /** "No dynamic-dispatch site …", when the detector found nothing. */
+  quiet: string | null;
+  uncertainHeading: string | null;
+  uncertain: WireFlowContinuation[];
+  further: string | null;
+  missed: string | null;
+}
+
+const plural = (n: number, one: string, many: string): string => (n === 1 ? one : many);
+
+export function endCapText(boundary: WireFlowBoundary): EndCapText {
+  const sites: EndCapSite[] = boundary.sites.map((site) => {
+    const notes: string[] = [];
+    if (site.key === null) notes.push('the key is a runtime value');
+    if (site.moreSites > 0) {
+      notes.push(`+${site.moreSites} more such ${plural(site.moreSites, 'site', 'sites')} here`);
+    }
+    return {
+      headline: `${site.label} at line ${site.line}`,
+      key: site.key,
+      notes,
+      candidates: site.candidates,
+      candidateHeading:
+        site.candidates.length > 0
+          ? `${site.candidates.length} candidate ${plural(site.candidates.length, 'target', 'targets')} \u203a`
+          : null,
+      candidateNote: site.candidateNote,
+    };
+  });
+
+  const missedNames = boundary.missed.map((m) => m.name);
+  return {
+    intro:
+      boundary.sites.length > 0
+        ? `${boundary.node.name} chooses its next call at runtime.`
+        : `${boundary.node.name} is the last symbol on this path.`,
+    sites,
+    quiet:
+      boundary.sites.length > 0
+        ? null
+        : 'No dynamic-dispatch site was detected in its body, so nothing here explains the break.',
+    uncertainHeading:
+      boundary.uncertain.total > 0
+        ? `${boundary.uncertain.total} name-only ${plural(boundary.uncertain.total, 'match', 'matches')} not followed (confidence < 0.6)`
+        : null,
+    uncertain: boundary.uncertain.items,
+    further:
+      boundary.further.total > 0
+        ? `It makes ${boundary.further.total} further resolved ${plural(boundary.further.total, 'call', 'calls')} this path does not need.`
+        : null,
+    missed:
+      missedNames.length > 0
+        ? `Never reaches ${missedNames.join(', ')}${boundary.missed.length < missedNames.length ? '…' : '.'}`
+        : null,
+  };
+}
+
+/** Exact rendered height of an end cap, which its CSS then pins as a minimum. */
+export function endCapHeight(boundary: WireFlowBoundary): number {
+  const text = endCapText(boundary);
+  let h = END_CAP_PADDING * 2;
+  h += wrappedLines(`Where the graph stops. ${text.intro}`) * END_CAP_LINE;
+  for (const site of text.sites) {
+    h += END_CAP_GAP;
+    h += wrappedLines(site.headline) * END_CAP_LINE;
+    if (site.key !== null) h += END_CAP_ROW;
+    for (const note of site.notes) h += wrappedLines(note) * END_CAP_LINE;
+    if (site.candidateHeading !== null) {
+      h += END_CAP_LINE + site.candidates.length * END_CAP_ROW;
+    } else if (site.candidateNote !== null) {
+      h += wrappedLines(site.candidateNote) * END_CAP_LINE;
+    }
+  }
+  if (text.quiet !== null) h += END_CAP_GAP + wrappedLines(text.quiet) * END_CAP_LINE;
+  if (text.uncertainHeading !== null) {
+    h += END_CAP_GAP + wrappedLines(text.uncertainHeading) * END_CAP_LINE;
+    h += text.uncertain.length * END_CAP_ROW;
+  }
+  if (text.further !== null) h += END_CAP_GAP + wrappedLines(text.further) * END_CAP_LINE;
+  if (text.missed !== null) h += END_CAP_GAP + wrappedLines(text.missed) * END_CAP_LINE;
+  return Math.round(h);
+}
+
 /* ----------------------------------------------------------------- model -- */
 
 export interface FlowCardLayout {
@@ -82,13 +226,22 @@ export interface FlowCardLayout {
   flows: string[];
   /** Position in the ACTIVE flow, or -1 when it is not on it. */
   step: number;
+  /**
+   * The dispatch line an end cap hangs off, when one does.
+   *
+   * The card is tinted there for the same reason a hop is tinted at its call
+   * site: it is the line the next thing on screen is about. A boundary card has
+   * no resolved call to link, so the tint is all the connection there is.
+   */
+  stopLine: number | null;
 }
 
 export interface FlowLinkLayout {
   id: string;
   source: string;
   target: string;
-  edge: WireFlowEdge;
+  /** Null on the dotted link into an end cap — no edge records a non-call. */
+  edge: WireFlowEdge | null;
   /** Flows this link belongs to. */
   flows: string[];
   /** The full label, for the connector's tooltip. */
@@ -104,10 +257,29 @@ export interface FlowLinkLayout {
   lineLabel: string | null;
   /** SVG dasharray, or null for a solid line. */
   dash: string | null;
+  /** This is the dotted link into an end cap, not a recorded edge. */
+  cap: boolean;
+}
+
+/** An end cap, placed one column past the symbol whose path stopped. */
+export interface FlowEndCapLayout {
+  /** `cap:<anchor node id>`. */
+  id: string;
+  /** The card the dotted link comes out of. */
+  anchorId: string;
+  boundary: WireFlowBoundary;
+  x: number;
+  y: number;
+  width: number;
+  height: number;
+  column: number;
+  /** Flows that stop here — what dims when one is picked. */
+  flows: string[];
 }
 
 export interface FlowLayout {
   cards: FlowCardLayout[];
+  endCaps: FlowEndCapLayout[];
   links: FlowLinkLayout[];
   width: number;
   height: number;
@@ -124,6 +296,14 @@ export function dashFor(edge: WireFlowEdge): string | null {
   return null;
 }
 
+/** The dotted link into an end cap (design spec §3.5). */
+export const END_CAP_DASH = '2 4';
+
+/** The layout id of the cap hanging off `anchorId`, and the way back. */
+export const capId = (anchorId: string): string => `cap:${anchorId}`;
+export const isCapId = (id: string): boolean => id.startsWith('cap:');
+export const anchorOf = (id: string): string => (isCapId(id) ? id.slice(4) : id);
+
 /** Longest a connector label line may be before it is cut. */
 export const LABEL_MAX_CHARS = 26;
 
@@ -209,7 +389,7 @@ export function buildFlowLayout(flows: readonly WireFlow[], activeId: string | n
   }
 
   if (cards.size === 0) {
-    return { cards: [], links: [], width: 0, height: 0, columns: 0, gaps: [] };
+    return { cards: [], endCaps: [], links: [], width: 0, height: 0, columns: 0, gaps: [] };
   }
 
   // ---- column = longest distance from a start -----------------------------
@@ -241,20 +421,69 @@ export function buildFlowLayout(flows: readonly WireFlow[], activeId: string | n
     column.set(id, best);
   }
 
+  // ---- the end caps ------------------------------------------------------
+  // One cap per stopping symbol, not per flow: two paths that run out at the
+  // same place ran out for the same reason, and two caps side by side saying so
+  // would read as two different findings.
+  const caps = new Map<string, { boundary: WireFlowBoundary; flows: string[] }>();
+  for (const flow of flows) {
+    const boundary = flow.boundary;
+    if (!boundary || !cards.has(boundary.node.id)) continue;
+    const hit = caps.get(boundary.node.id);
+    if (hit) {
+      if (!hit.flows.includes(flow.id)) hit.flows.push(flow.id);
+    } else {
+      caps.set(boundary.node.id, { boundary, flows: [flow.id] });
+    }
+  }
+
   // ---- pack each column, active flow first --------------------------------
+  interface Member {
+    id: string;
+    width: number;
+    height: number;
+    /** Cards before caps, then first-seen order. */
+    rank: number;
+    onActive: boolean;
+  }
+  const members = new Map<string, Member>();
+  for (const [id, card] of cards) {
+    members.set(id, {
+      id,
+      width: CARD_WIDTH,
+      height: cardHeight(card.hop),
+      rank: card.order,
+      onActive: activeSteps.has(id),
+    });
+  }
+  const capColumn = new Map<string, number>();
+  let capRank = cards.size;
+  for (const [anchorId, cap] of caps) {
+    const id = capId(anchorId);
+    capColumn.set(id, (column.get(anchorId) ?? 0) + 1);
+    members.set(id, {
+      id,
+      width: END_CAP_WIDTH,
+      height: endCapHeight(cap.boundary),
+      rank: capRank++,
+      onActive: activeSteps.has(anchorId),
+    });
+  }
+  const columnOf = (id: string): number => capColumn.get(id) ?? column.get(id) ?? 0;
+
   const byColumn = new Map<number, string[]>();
-  for (const id of cards.keys()) {
-    const c = column.get(id) ?? 0;
+  for (const id of members.keys()) {
+    const c = columnOf(id);
     const list = byColumn.get(c);
     if (list) list.push(id);
     else byColumn.set(c, [id]);
   }
   for (const list of byColumn.values()) {
     list.sort((a, b) => {
-      const onA = activeSteps.has(a) ? 0 : 1;
-      const onB = activeSteps.has(b) ? 0 : 1;
+      const onA = (members.get(a) as Member).onActive ? 0 : 1;
+      const onB = (members.get(b) as Member).onActive ? 0 : 1;
       if (onA !== onB) return onA - onB;
-      return (cards.get(a)?.order ?? 0) - (cards.get(b)?.order ?? 0);
+      return (members.get(a) as Member).rank - (members.get(b) as Member).rank;
     });
   }
 
@@ -262,7 +491,8 @@ export function buildFlowLayout(flows: readonly WireFlow[], activeId: string | n
 
   // Each gap is wide enough for the widest label that crosses it. Labels are
   // built here rather than in the render pass because the geometry depends on
-  // them — see LABEL_CHAR_WIDTH.
+  // them — see LABEL_CHAR_WIDTH. A cap's dotted link keeps the spec's 86px: its
+  // label is fixed and stacks into two short lines.
   const labelled = [...links.entries()].map(([key, link]) => ({
     key,
     link,
@@ -276,60 +506,100 @@ export function buildFlowLayout(flows: readonly WireFlow[], activeId: string | n
     const widest = Math.max(0, ...lines.map((l) => l.length), lineLabel?.length ?? 0);
     gaps[from] = Math.max(gaps[from] as number, Math.ceil(widest * LABEL_CHAR_WIDTH) + LABEL_PAD);
   }
+
+  // A column is as wide as its widest member, so a cap sharing a column with a
+  // card does not push the card's neighbours out of line.
+  const columnWidth = Array.from({ length: columns }, () => 0);
+  for (const [c, list] of byColumn) {
+    columnWidth[c] = Math.max(...list.map((id) => (members.get(id) as Member).width));
+  }
   const columnX: number[] = [PADDING];
   for (let c = 1; c < columns; c++) {
-    columnX[c] = (columnX[c - 1] as number) + CARD_WIDTH + (gaps[c - 1] as number);
+    columnX[c] = (columnX[c - 1] as number) + (columnWidth[c - 1] as number) + (gaps[c - 1] as number);
   }
 
-  const heights = new Map<string, number>();
-  for (const [id, card] of cards) heights.set(id, cardHeight(card.hop));
-
   // Rows are centred on the tallest column, so a one-card column sits opposite
   // the middle of a two-card one instead of hugging the top of the canvas.
   const columnHeights = new Map<number, number>();
   for (const [c, list] of byColumn) {
     columnHeights.set(
       c,
-      list.reduce((sum, id) => sum + (heights.get(id) ?? 0), 0) + ROW_GAP * (list.length - 1)
+      list.reduce((sum, id) => sum + (members.get(id) as Member).height, 0) +
+        ROW_GAP * (list.length - 1)
     );
   }
   const tallest = Math.max(...columnHeights.values());
 
   const laidOut = new Map<string, FlowCardLayout>();
+  const endCaps: FlowEndCapLayout[] = [];
   for (const [c, list] of byColumn) {
     let y = PADDING + (tallest - (columnHeights.get(c) ?? 0)) / 2;
     for (const id of list) {
-      const card = cards.get(id) as { hop: WireFlowHop; flows: string[]; order: number };
-      const height = heights.get(id) ?? 0;
-      laidOut.set(id, {
-        id,
-        hop: card.hop,
-        x: columnX[c] as number,
-        y,
-        width: CARD_WIDTH,
-        height,
-        column: c,
-        flows: card.flows,
-        step: activeSteps.get(id) ?? -1,
-      });
-      y += height + ROW_GAP;
+      const member = members.get(id) as Member;
+      const cap = caps.get(anchorOf(id));
+      if (cap && isCapId(id)) {
+        endCaps.push({
+          id,
+          anchorId: anchorOf(id),
+          boundary: cap.boundary,
+          x: columnX[c] as number,
+          y,
+          width: member.width,
+          height: member.height,
+          column: c,
+          flows: cap.flows,
+        });
+      } else {
+        const card = cards.get(id) as { hop: WireFlowHop; flows: string[]; order: number };
+        laidOut.set(id, {
+          id,
+          hop: card.hop,
+          x: columnX[c] as number,
+          y,
+          width: member.width,
+          height: member.height,
+          column: c,
+          flows: card.flows,
+          step: activeSteps.get(id) ?? -1,
+          stopLine: caps.get(id)?.boundary.sites[0]?.line ?? null,
+        });
+      }
+      y += member.height + ROW_GAP;
     }
   }
 
+  const linkLayouts: FlowLinkLayout[] = labelled.map(({ link, lines, lineLabel }) => ({
+    id: `${link.source}->${link.target}`,
+    source: link.source,
+    target: link.target,
+    edge: link.edge,
+    flows: link.flows,
+    label: link.edge.label,
+    labelLines: lines,
+    lineLabel,
+    dash: dashFor(link.edge),
+    cap: false,
+  }));
+  for (const cap of endCaps) {
+    linkLayouts.push({
+      id: `${cap.anchorId}->${cap.id}`,
+      source: cap.anchorId,
+      target: cap.id,
+      edge: null,
+      flows: cap.flows,
+      label: 'end of static path',
+      labelLines: ['end of', 'static path'],
+      lineLabel: null,
+      dash: END_CAP_DASH,
+      cap: true,
+    });
+  }
+
   return {
     cards: [...laidOut.values()].sort((a, b) => a.column - b.column || a.y - b.y),
-    links: labelled.map(({ link, lines, lineLabel }) => ({
-      id: `${link.source}->${link.target}`,
-      source: link.source,
-      target: link.target,
-      edge: link.edge,
-      flows: link.flows,
-      label: link.edge.label,
-      labelLines: lines,
-      lineLabel,
-      dash: dashFor(link.edge),
-    })),
-    width: (columnX[columns - 1] as number) + CARD_WIDTH + PADDING,
+    endCaps: endCaps.sort((a, b) => a.column - b.column || a.y - b.y),
+    links: linkLayouts,
+    width: (columnX[columns - 1] as number) + (columnWidth[columns - 1] as number) + PADDING,
     height: PADDING * 2 + tallest,
     columns,
     gaps,

+ 54 - 15
ui/src/views/FlowView.svelte

@@ -18,6 +18,7 @@
   import '@xyflow/svelte/dist/style.css';
   import FlowCard from '../components/flow/FlowCard.svelte';
   import FlowLink from '../components/flow/FlowLink.svelte';
+  import FlowEndCap from '../components/flow/FlowEndCap.svelte';
   import { fetchFlow, type WireFlow, type WireFlowPayload } from '../lib/api';
   import { live } from '../lib/live.svelte';
   import { navigate, symbolHref } from '../lib/router.svelte';
@@ -55,7 +56,7 @@
    * there for anyone who wants the shape rather than the code.
    */
   const START_VIEWPORT = { x: 0, y: 0, zoom: 1 };
-  const nodeTypes = { flow: FlowCard };
+  const nodeTypes = { flow: FlowCard, cap: FlowEndCap };
   const edgeTypes = { flow: FlowLink };
 
   /** The hops the trail form asks for, as `<dir><id>` — the wire's own spelling. */
@@ -107,24 +108,41 @@
 
   const nodes = $derived.by<Node[]>(() => {
     if (layout === null) return [];
-    return layout.cards.map((card) => ({
-      id: card.id,
-      type: 'flow',
-      position: { x: card.x, y: card.y },
+    const caps: Node[] = layout.endCaps.map((cap) => ({
+      id: cap.id,
+      type: 'cap',
+      position: { x: cap.x, y: cap.y },
       draggable: false,
       selectable: false,
       connectable: false,
       data: {
-        // The accent border marks the picked path, and only means something
-        // when there is more than one on screen. A single flow whose every
-        // card is accented has said nothing.
-        card,
-        current: showAll && card.step >= 0,
-        dimmed: showAll && card.step < 0,
-        onOpen: openCard,
-        onFollow: followCard,
+        cap,
+        dimmed: showAll && picked !== null && !cap.flows.includes(picked),
+        onOpen: openNode,
       },
     }));
+    // Caps first, so a card that overlaps one paints on top of it.
+    return [
+      ...caps,
+      ...layout.cards.map((card) => ({
+        id: card.id,
+        type: 'flow',
+        position: { x: card.x, y: card.y },
+        draggable: false,
+        selectable: false,
+        connectable: false,
+        data: {
+          // The accent border marks the picked path, and only means something
+          // when there is more than one on screen. A single flow whose every
+          // card is accented has said nothing.
+          card,
+          current: showAll && card.step >= 0,
+          dimmed: showAll && card.step < 0,
+          onOpen: openCard,
+          onFollow: followCard,
+        },
+      })),
+    ];
   });
 
   const edges = $derived.by<Edge[]>(() => {
@@ -172,6 +190,19 @@
     );
   }
 
+  /**
+   * A row on the end cap: a candidate runtime target, or a continuation the
+   * search refused to follow.
+   *
+   * It opens as a fresh start rather than as another hop, because neither is a
+   * call the graph recorded — pushing one onto the trail would draw a step
+   * nobody took. That is the whole reason the cap exists.
+   */
+  function openNode(nodeId: string): void {
+    trail.clear();
+    navigate(symbolHref(nodeId));
+  }
+
   /** The accent link inside a card: step to the symbol it names. */
   function followCard(card: FlowCardLayout): void {
     const target = card.hop.callRef?.targetId;
@@ -184,6 +215,9 @@
     if (p.query.kind === 'trail') {
       return 'Your trail, read as a flow: each card is opened at the line that carried you to the next one.';
     }
+    if (p.flows.some((f) => f.partial)) {
+      return 'No static path connects them. The card is where the looking stopped — a call whose target is chosen at runtime — and the cap names the form, the key and who could be on the other side.';
+    }
     if (p.query.kind === 'directed') {
       return 'Every card is a call the graph recorded. A dashed link is a hop no one can see in the source — a callback, an interface, a re-render — and it names where it was wired.';
     }
@@ -205,7 +239,9 @@
         }}
       >
         {#each flows as flow (flow.id)}
-          <option value={flow.id}>{flow.label} · {flow.hops.length} hops</option>
+          <option value={flow.id}
+            >{flow.label}{flow.hops.length > 1 ? ` · ${flow.hops.length} hops` : ''}</option
+          >
         {/each}
         {#if flows.length > 1}
           <option value={ALL}>All {flows.length} paths</option>
@@ -265,8 +301,11 @@
     {/if}
   </div>
 
-  {#if payload && (payload.ambiguous.length > 0 || payload.unresolved.length > 0)}
+  {#if payload && (payload.reason !== null || payload.ambiguous.length > 0 || payload.unresolved.length > 0) && layout !== null}
     <footer class="fnote">
+      {#if payload.reason !== null}
+        <p>{payload.reason}</p>
+      {/if}
       {#each payload.ambiguous as amb (amb.token)}
         <p>
           <span class="mono">{amb.token}</span> names {amb.others.length + 1} definitions.