Răsfoiți Sursa

feat(explore): dynamic-dispatch boundary surfacing — announce where a flow ends instead of guessing edges (#687) (#835)

* feat(explore): announce dynamic-dispatch boundaries when a flow can't connect statically (#687)

When buildFlowFromNamedSymbols can't connect the agent's named symbols, scan
the disconnected symbols' bodies (query-time, deterministic, zero graph
mutation) for dynamic-dispatch forms — computed member calls, getattr,
reflection, typed message buses, runtime-keyed emits, Proxy — and announce
the exact site where the static path ends, with candidate runtime targets
when a dispatch key is statically visible. The honest alternative to
guessing edges: surface the boundary, don't fabricate the bridge.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(agent-eval): ab-new-vs-baseline survives files added since the baseline ref

A single multi-file 'git checkout <ref> --' with one unknown pathspec checks
out nothing, so the baseline arm silently ran the NEW build. Check out
per-file and remove files that don't exist on the baseline.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* docs(playbook): boundary surfacing as the mechanism floor for non-gateable dispatch (#687)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(explore): render a direct synthesized hop between two named symbols (#687)

A 2-node chain populates pathIds but renders nothing (Flow needs >=3), and
the dynamic-links section skipped its edge as 'already in the main chain' —
so a custom EventBus emit→handler connection was invisible. Skip-as-in-chain
now applies only when a chain actually renders, and the boundary scan treats
short-chain endpoints as connected.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Colby Mchenry 1 săptămână în urmă
părinte
comite
df6f4bec43

+ 2 - 0
CHANGELOG.md

@@ -16,6 +16,8 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
 
 ### New Features
 
+- **`codegraph_explore` now explains where a flow ends instead of going silent.** When the symbols you ask about don't connect statically — because the code dispatches through a runtime mechanism like a computed call (`handlers[action.type](...)`), Python's `getattr`, a command/mediator bus (`sender.Send(new DeleteCommand(...))`), reflection, or `new Proxy` — explore now announces the exact dispatch site (file and line) where the static path stops, and when the dispatch key is visible in the source it shortlists the likely runtime targets (for example pointing a MediatR command straight at its `Handler.Handle` method). Detection is deterministic and runs only when a flow fails to connect; fully connected flows are unchanged, and nothing about indexing or the graph itself changes. Relatedly, a custom event bus whose emit and handler connect through a single synthesized hop now shows that hop explicitly (with the registration site) — it previously rendered nothing because the connection was "too short" for the flow section. (#687)
+
 - **Anonymous usage telemetry, documented field-by-field and easy to turn off.** CodeGraph now collects a small set of anonymous usage statistics — which commands and MCP tools get used, which languages get indexed, which agents connect — so language and agent support work goes where real usage is. Never any code, file paths, file or symbol names, search queries, or IP addresses; usage aggregates locally into daily totals before anything is sent, and the ingest endpoint is public, auditable code in the repository that enforces the documented field list. The installer asks up front with a visible default-on toggle (and never re-asks); everywhere else a one-line notice prints before the first send. Disable any time with `codegraph telemetry off`, `CODEGRAPH_TELEMETRY=0`, or the cross-tool `DO_NOT_TRACK=1` standard — off means off: nothing is recorded, nothing is sent, and buffered data is deleted. `TELEMETRY.md` documents every field.
 - **Subagents and non-MCP agents can now reach CodeGraph.** Two new CLI commands — `codegraph explore "<symbols or question>"` and `codegraph node <symbol-or-file>` — print exactly what the matching MCP tools return (relevant symbols' source + call paths; one symbol's source + callers; file reads with line numbers), so any agent with a shell can use the graph. And `codegraph install` now writes a small marker-fenced CodeGraph section into each agent's instructions file (`CLAUDE.md` / `AGENTS.md` / `GEMINI.md`) pointing at both surfaces — that file is what Task-tool subagents actually see, where the MCP server's own guidance only reaches the main agent. Measured on a delegated code-exploration task: subagents went from almost never using CodeGraph (~1 in 9 runs) to using it in every run, including runs with zero grep/file-reading fallback. The section is small, survives your own content, upgrades cleanly from the old long block, and `codegraph uninstall` removes it. Thanks @liuyao37511. (#704)
 - **The MCP tool list is now a focused default of four** — `codegraph_explore`, `codegraph_node`, `codegraph_search`, and `codegraph_callers`. The other four (`codegraph_callees`, `codegraph_impact`, `codegraph_files`, `codegraph_status`) remain fully functional — the CLI and library API are unchanged, and `CODEGRAPH_MCP_TOOLS` re-enables any of them — but they're no longer listed to agents by default: measured agent behavior shows they're never or rarely picked, and the information they carry already arrives inline on the tools agents do use (explore's blast-radius section, node's dependents note, a symbol's own body as its callee list). A leaner list saves context tokens every session and steers agents to the right tool by presence alone.

+ 299 - 0
__tests__/dynamic-boundaries.test.ts

@@ -0,0 +1,299 @@
+/**
+ * Dynamic-boundary surfacing (#687).
+ *
+ * When the flow an agent asked codegraph_explore about does NOT fully connect,
+ * the Flow section announces WHERE the static path ends — the dynamic-dispatch
+ * site (computed member call, getattr, typed bus, runtime-keyed emit), with
+ * candidate targets when a key is statically visible — instead of silently
+ * showing nothing. Deterministic, query-time only, no graph mutation, and a
+ * fully connected flow must never produce the section.
+ */
+import { describe, it, expect, beforeEach, afterEach } from 'vitest';
+import * as fs from 'fs';
+import * as path from 'path';
+import * as os from 'os';
+import CodeGraph from '../src/index';
+import { ToolHandler } from '../src/mcp/tools';
+import { scanDynamicDispatch } from '../src/mcp/dynamic-boundaries';
+
+// ---------------------------------------------------------------------------
+// Unit: the scanner
+// ---------------------------------------------------------------------------
+
+describe('scanDynamicDispatch', () => {
+  it('detects a computed member call with a literal key', () => {
+    const body = `function go(p) {\n  table['save'](p);\n}`;
+    const m = scanDynamicDispatch(body, 'typescript', 10);
+    expect(m).toHaveLength(1);
+    expect(m[0]!.form).toBe('computed-call');
+    expect(m[0]!.key).toBe('save');
+    expect(m[0]!.line).toBe(11); // absolute: body starts at file line 10
+    expect(m[0]!.snippet).toContain("table['save'](p)");
+  });
+
+  it('detects a computed member call with a runtime key (no key extracted)', () => {
+    const body = `dispatch(action) {\n  this.handlers[action.type](action.payload);\n}`;
+    const m = scanDynamicDispatch(body, 'typescript', 1);
+    expect(m).toHaveLength(1);
+    expect(m[0]!.form).toBe('computed-call');
+    expect(m[0]!.key).toBeUndefined();
+  });
+
+  it('does not fire on dispatch shapes inside comments or strings', () => {
+    const body = [
+      'function safe() {',
+      "  // this.handlers[action.type](payload) — commented out",
+      '  const doc = "call handlers[key](p) to dispatch";',
+      '  return 1;',
+      '}',
+    ].join('\n');
+    expect(scanDynamicDispatch(body, 'typescript', 1)).toHaveLength(0);
+  });
+
+  it('does not treat plain indexing or array literals as dispatch', () => {
+    const body = `function f(xs) {\n  const a = xs[0];\n  const b = [1, 2, 3];\n  return a + b[1];\n}`;
+    expect(scanDynamicDispatch(body, 'typescript', 1)).toHaveLength(0);
+  });
+
+  it('detects python getattr immediate-call', () => {
+    const body = `def run(self, name):\n    return getattr(self, name)(1)`;
+    const m = scanDynamicDispatch(body, 'python', 5);
+    expect(m).toHaveLength(1);
+    expect(m[0]!.form).toBe('getattr-call');
+  });
+
+  it('detects two-step getattr only when the assigned name is called later', () => {
+    const called = `def process(self, kind, p):\n    handler = getattr(self, 'handle_' + kind)\n    return handler(p)`;
+    const m = scanDynamicDispatch(called, 'python', 1);
+    expect(m).toHaveLength(1);
+    expect(m[0]!.form).toBe('getattr-assign');
+    expect(m[0]!.key).toBe('handle_'); // the literal prefix — enough to shortlist
+
+    const notCalled = `def peek(self, kind):\n    handler = getattr(self, 'handle_' + kind)\n    return handler`;
+    expect(scanDynamicDispatch(notCalled, 'python', 1)).toHaveLength(0);
+  });
+
+  it('detects ruby send with a symbol key', () => {
+    const body = `def run(name)\n  target.send(:handle_save, 1)\nend`;
+    const m = scanDynamicDispatch(body, 'ruby', 1);
+    expect(m).toHaveLength(1);
+    expect(m[0]!.form).toBe('ruby-send');
+    expect(m[0]!.key).toBe('handle_save');
+  });
+
+  it('detects typed message dispatch and marks the key as a type', () => {
+    const body = `public async Task<int> Create(CreateCmd c) {\n  return await _mediator.Send(new CreateTodoItemCommand(c));\n}`;
+    const m = scanDynamicDispatch(body, 'csharp', 1);
+    expect(m).toHaveLength(1);
+    expect(m[0]!.form).toBe('typed-bus');
+    expect(m[0]!.key).toBe('CreateTodoItemCommand');
+    expect(m[0]!.keyIsType).toBe(true);
+  });
+
+  it('detects runtime-keyed emit but not literal-keyed emit', () => {
+    const runtime = `notify(name, data) {\n  this.emitter.emit(name, data);\n}`;
+    const m = scanDynamicDispatch(runtime, 'typescript', 1);
+    expect(m).toHaveLength(1);
+    expect(m[0]!.form).toBe('var-key-dispatch');
+
+    // Literal keys are the edge synthesizer's territory — not a boundary.
+    const literal = `notify(data) {\n  this.emitter.emit('saved', data);\n}`;
+    expect(scanDynamicDispatch(literal, 'typescript', 1)).toHaveLength(0);
+  });
+
+  it('dedupes repeated same-form/same-key sites and counts the extras', () => {
+    const body = [
+      'route(a) {',
+      '  this.table[a.type](a.p);',
+      '  this.table[a.kind](a.p);',
+      '  this.table[a.name](a.p);',
+      '}',
+    ].join('\n');
+    const m = scanDynamicDispatch(body, 'typescript', 1);
+    expect(m).toHaveLength(1);
+    expect(m[0]!.moreSites).toBe(2);
+  });
+
+  it('detects reflective dispatch with a literal method name as key', () => {
+    const body = `public void run(Object o) {\n  o.getClass().getMethod("handlePing").invoke(o);\n}`;
+    const m = scanDynamicDispatch(body, 'java', 1);
+    expect(m.length).toBeGreaterThanOrEqual(1);
+    expect(m[0]!.form).toBe('reflection');
+    expect(m[0]!.key).toBe('handlePing');
+  });
+});
+
+// ---------------------------------------------------------------------------
+// Integration: codegraph_explore output
+// ---------------------------------------------------------------------------
+
+describe('codegraph_explore — dynamic boundaries', () => {
+  let testDir: string;
+  let cg: CodeGraph;
+  let handler: ToolHandler;
+
+  const setup = async (files: Record<string, string>, include: string[]) => {
+    testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-boundary-'));
+    const src = path.join(testDir, 'src');
+    fs.mkdirSync(src, { recursive: true });
+    for (const [name, content] of Object.entries(files)) {
+      fs.writeFileSync(path.join(src, name), content);
+    }
+    cg = CodeGraph.initSync(testDir, { config: { include, exclude: [] } });
+    await cg.indexAll();
+    handler = new ToolHandler(cg);
+  };
+
+  afterEach(() => {
+    if (cg) cg.destroy();
+    if (testDir && fs.existsSync(testDir)) fs.rmSync(testDir, { recursive: true, force: true });
+  });
+
+  it('announces the boundary site and shortlists the keyed candidate', async () => {
+    await setup({
+      'router.ts': [
+        'type Handler = (p: unknown) => void;',
+        'export class Router {',
+        '  private table: Record<string, Handler> = {};',
+        '  add(key: string, fn: Handler) { this.table[key] = fn; }',
+        '  routeSave(payload: unknown) {',
+        "    this.table['save'](payload);",
+        '  }',
+        '}',
+      ].join('\n'),
+      'handlers.ts': [
+        "import { Router } from './router';",
+        'export function onSave(payload: unknown) { return payload; }',
+        'export function wire(r: Router) { r.add("save", onSave); }',
+      ].join('\n'),
+    }, ['**/*.ts']);
+
+    const res = await handler.execute('codegraph_explore', { query: 'routeSave onSave' });
+    const text = res.content[0].text as string;
+
+    expect(text).toContain('## Dynamic boundaries');
+    expect(text).toContain('computed member call');
+    expect(text).toMatch(/router\.ts:6/); // the exact dispatch site
+    expect(text).toContain('candidates for key `save`');
+    expect(text).toContain('onSave');
+    expect(text).toContain('← you named this');
+    // Honesty constraint: never steer the agent to Read.
+    expect(text).not.toMatch(/\buse Read\b/i);
+  });
+
+  it('announces a runtime-keyed boundary with no candidate list', async () => {
+    await setup({
+      'bus.ts': [
+        'type Action = { type: string; payload?: unknown };',
+        'type Handler = (p: unknown) => void;',
+        'export class Bus {',
+        '  private table: Record<string, Handler> = {};',
+        '  route(action: Action) {',
+        '    this.table[action.type](action.payload);',
+        '  }',
+        '}',
+      ].join('\n'),
+      'handlers.ts': 'export function onSave(payload: unknown) { return payload; }',
+    }, ['**/*.ts']);
+
+    const res = await handler.execute('codegraph_explore', { query: 'route onSave' });
+    const text = res.content[0].text as string;
+
+    expect(text).toContain('## Dynamic boundaries');
+    expect(text).toContain('computed member call');
+    expect(text).not.toContain('candidates for key'); // runtime key → no shortlist to claim
+  });
+
+  it('surfaces the boundary even when the other symbol is not in the graph', async () => {
+    await setup({
+      'bus.ts': [
+        'type Action = { type: string; payload?: unknown };',
+        'type Handler = (p: unknown) => void;',
+        'export class Bus {',
+        '  private table: Record<string, Handler> = {};',
+        '  route(action: Action) {',
+        '    this.table[action.type](action.payload);',
+        '  }',
+        '}',
+      ].join('\n'),
+    }, ['**/*.ts']);
+
+    // `processPayment` does not exist anywhere — only `route` resolves.
+    const res = await handler.execute('codegraph_explore', { query: 'route processPayment' });
+    const text = res.content[0].text as string;
+    expect(text).toContain('## Dynamic boundaries');
+  });
+
+  it('renders a direct synthesized emit→handler hop as a dynamic-dispatch link (#687 criterion 1)', async () => {
+    // Custom EventBus with a LITERAL key: the event-emitter synthesizer
+    // bridges emit→handler, but the 2-node chain was invisible — too short
+    // for the Flow section and skipped by the links section as "in-chain".
+    await setup({
+      'bus.ts': [
+        'type Handler = (p: unknown) => void;',
+        'export class EventBus {',
+        '  private listeners: Record<string, Handler[]> = {};',
+        '  on(event: string, fn: Handler) { (this.listeners[event] ??= []).push(fn); }',
+        '  emit(event: string, payload: unknown) { for (const fn of this.listeners[event] ?? []) fn(payload); }',
+        '}',
+        'export const bus = new EventBus();',
+      ].join('\n'),
+      'billing.ts': [
+        "import { bus } from './bus';",
+        'export function settleInvoice(payload: unknown) { return payload; }',
+        "bus.on('invoice.settled', settleInvoice);",
+      ].join('\n'),
+      'checkout.ts': [
+        "import { bus } from './bus';",
+        'export function completeCheckout(order: unknown) {',
+        "  bus.emit('invoice.settled', order);",
+        '}',
+      ].join('\n'),
+    }, ['**/*.ts']);
+
+    const res = await handler.execute('codegraph_explore', { query: 'completeCheckout settleInvoice' });
+    const text = res.content[0].text as string;
+
+    expect(text).toContain('## Dynamic-dispatch links among your symbols');
+    expect(text).toMatch(/completeCheckout → settleInvoice/);
+    expect(text).toContain('invoice.settled');
+    // Connected via the synthesized edge — no boundary to announce.
+    expect(text).not.toContain('## Dynamic boundaries');
+  });
+
+  it('never adds the section to a fully connected flow', async () => {
+    await setup({
+      'pipeline.ts': [
+        'export function stepOne() { return stepTwo(); }',
+        'export function stepTwo() { return stepThree(); }',
+        'export function stepThree() { return 3; }',
+      ].join('\n'),
+    }, ['**/*.ts']);
+
+    const res = await handler.execute('codegraph_explore', { query: 'stepOne stepThree' });
+    const text = res.content[0].text as string;
+    expect(text).toContain('## Flow');
+    expect(text).not.toContain('## Dynamic boundaries');
+  });
+
+  it('python getattr dispatch surfaces with a prefix-key candidate', async () => {
+    await setup({
+      'service.py': [
+        'class Service:',
+        '    def handle_save(self, payload):',
+        '        return payload',
+        '',
+        '    def process(self, kind, payload):',
+        "        handler = getattr(self, 'handle_' + kind)",
+        '        return handler(payload)',
+      ].join('\n'),
+    }, ['**/*.py']);
+
+    const res = await handler.execute('codegraph_explore', { query: 'process handle_save' });
+    const text = res.content[0].text as string;
+
+    expect(text).toContain('## Dynamic boundaries');
+    expect(text).toContain('getattr');
+    expect(text).toContain('handle_save');
+  });
+});

+ 12 - 0
docs/design/dynamic-dispatch-coverage-playbook.md

@@ -161,6 +161,18 @@ were found). Confirm it's dynamic by reading the break symbol's body.
 - `on('e',fn)` + `emit('e')` → **EventEmitter synthesizer** (§3b).
 - Inline handler not a node → **named:** extraction (already done generically in
   `tree-sitter.ts`); **anonymous:** synthesizer link-through-body (not yet built).
+- Dispatch that CAN'T be precision-gated as a class (runtime-keyed `table[key](...)`,
+  `getattr(self, expr)`, reflection, typed mediator buses, `new Proxy`) → **boundary
+  surfacing** (`src/mcp/dynamic-boundaries.ts`, #687): explore ANNOUNCES the dispatch
+  site where the static path ends — file:line, form, and candidate targets when the
+  key is statically visible — instead of synthesizing an edge. Query-time only, zero
+  graph mutation, fires only when the asked-about flow fails to connect. This is the
+  deliberate floor for the frontier: a wrong edge poisons the map (silent beats
+  wrong), but an honest "the flow continues at THIS site, likely into THESE
+  candidates" still saves the read-reconstruction spiral. When a boundary form later
+  proves precision-gateable on real repos (e.g. a same-repo literal-key command bus),
+  promote it to a synthesizer channel and the boundary note disappears on its own —
+  the flow then connects.
 
 ### Step 4 — Implement
 - **Resolver:** add to `src/resolution/frameworks/<lang>.ts` — a `resolve()` branch +

+ 6 - 1
scripts/agent-eval/ab-new-vs-baseline.sh

@@ -88,7 +88,12 @@ node "$BIN" init "$OUT/t-new" >/dev/null 2>&1 && echo "  indexed t-new"
 run_arm new "$OUT/t-new"
 
 echo "== BASELINE build ($BASE_REF) =="
-git -C "$ENGINE" checkout "$BASE_REF" -- $CHANGED
+# Per-file: a file ADDED since baseline has no pathspec on the ref — and a
+# single multi-file checkout with one bad pathspec checks out NOTHING, which
+# silently ran the NEW build in the baseline arm. Absent-on-baseline → remove.
+for f in $CHANGED; do
+  git -C "$ENGINE" checkout "$BASE_REF" -- "$f" 2>/dev/null || rm -f "$ENGINE/$f"
+done
 ( cd "$ENGINE" && npm run build >/dev/null 2>&1 ) && echo "  built"
 node "$BIN" init "$OUT/t-base" >/dev/null 2>&1 && echo "  indexed t-base"
 run_arm baseline "$OUT/t-base"

+ 397 - 0
src/mcp/dynamic-boundaries.ts

@@ -0,0 +1,397 @@
+/**
+ * Dynamic-dispatch boundary detection for codegraph_explore (#687).
+ *
+ * When the flow an agent asked about does NOT connect statically, the cause is
+ * almost always a dynamic-dispatch site: a computed member call, getattr,
+ * reflection, a string-keyed bus, a typed command/mediator dispatch. Guessing
+ * the missing edge was rejected (silent beats wrong — a wrong edge poisons the
+ * map and teaches abandonment). Instead, explore ANNOUNCES the boundary
+ * honestly: the exact site where the static path ends, the dispatch form, and
+ * — when a key is statically visible (string literal, `:symbol`, `new Type`)
+ * — that key, so the caller can shortlist candidate targets.
+ *
+ * Detection is deterministic regex over the comment/string-stripped bodies of
+ * the symbols the agent named, at QUERY TIME only. The graph is never mutated;
+ * an unbroken flow never triggers a scan. Matching runs on the stripped text
+ * (so commented-out / string-embedded code can't fire) but snippets and keys
+ * are sliced from the ORIGINAL source at the same offsets — both strippers
+ * blank contents in place, preserving offsets, precisely for this.
+ * (`stripCommentsForRegex` blanks comments but deliberately KEEPS string
+ * contents — framework extractors need route literals; here a dispatch shape
+ * inside a string is a false positive, so {@link blankStringContents} blanks
+ * them too, quotes preserved.)
+ */
+import { stripCommentsForRegex, type CommentLang } from '../resolution/strip-comments';
+
+export interface BoundaryMatch {
+  /** Stable form id, e.g. 'computed-call' — used for per-form dedupe. */
+  form: string;
+  /** Human label for the dispatch form, e.g. 'computed member call'. */
+  label: string;
+  /** One-line source snippet of the site (from the original, untrimmed text). */
+  snippet: string;
+  /** 1-based line within the scanned body's FILE (absolute, ready to print). */
+  line: number;
+  /**
+   * Statically-visible dispatch key, when one exists: the string literal in
+   * `handlers['save']`, the `:symbol` in ruby `send`, the type name in
+   * `Send(new CreateCmd(...))`. Drives candidate lookup. Undefined when the
+   * key is a runtime value (variable, computed expression).
+   */
+  key?: string;
+  /** For typed-bus matches the key is a TYPE name (candidates ~ `${key}Handler`). */
+  keyIsType?: boolean;
+  /** Additional sites of the same form+key in this body beyond the reported one. */
+  moreSites?: number;
+}
+
+interface FormSpec {
+  form: string;
+  label: string;
+  /** Languages this form applies to; undefined = all. Node.language values. */
+  langs?: Set<string>;
+  re: RegExp;
+  /**
+   * Derive the dispatch key from the ORIGINAL-source snippet around the match
+   * (match start .. match end + keyWindow). Return undefined when no static key.
+   */
+  keyFrom?: (orig: string) => { key: string; keyIsType?: boolean } | undefined;
+  /**
+   * Extra ORIGINAL chars after the match end handed to keyFrom, capped at the
+   * first newline — for forms whose key trails the matched prefix, e.g.
+   * `.getMethod(` → `"handlePing"`. Forms with $-anchored keyFrom regexes
+   * must leave this unset (the anchor relies on the slice ending at the match).
+   */
+  keyWindow?: number;
+}
+
+const JS_FAMILY = new Set(['typescript', 'javascript', 'tsx', 'jsx', 'vue', 'svelte', 'astro']);
+const PY = new Set(['python']);
+const RB = new Set(['ruby']);
+const PHP = new Set(['php']);
+const JVM_CS_GO = new Set(['java', 'kotlin', 'scala', 'csharp', 'go']);
+const SWIFT_OBJC = new Set(['swift', 'objc', 'objcpp', 'objective-c']);
+
+/** Exactly one quoted literal and no concatenation → that literal is the key. */
+function singleStringLiteral(text: string): string | undefined {
+  const m = text.match(/^[^'"`]*(['"`])([\w.:-]{2,64})\1[^'"`]*$/);
+  return m ? m[2] : undefined;
+}
+
+const FORMS: FormSpec[] = [
+  {
+    // handlers[action.type](payload) / registry[key](args) / table[k](...) —
+    // the `](` adjacency is the gate; a word/`)`/`]` char must precede `[` so
+    // array literals and markdown-ish text in prose can't fire.
+    form: 'computed-call',
+    label: 'computed member call',
+    re: /[\w$)\]]\s*\[([^[\]\n]{1,80})\]\s*\(/g,
+    keyFrom: (orig) => {
+      const inner = orig.match(/\[([^[\]\n]{1,80})\]\s*\($/);
+      const key = inner ? singleStringLiteral(inner[1]!) : undefined;
+      return key ? { key } : undefined;
+    },
+  },
+  {
+    // import(expr) / require(expr) with a NON-literal argument → runtime module
+    // choice. Literal imports are ordinary edges and never reach this scanner.
+    form: 'dynamic-import',
+    label: 'dynamic import',
+    langs: JS_FAMILY,
+    re: /\b(?:import|require)\s*\(\s*(?![\s'"`)])/g,
+  },
+  {
+    form: 'dynamic-import',
+    label: 'dynamic import',
+    langs: PY,
+    re: /\bimportlib\.import_module\s*\(|\b__import__\s*\(/g,
+  },
+  {
+    // obj.send(:method_name) / public_send / method(:name) — ruby metaprogramming.
+    form: 'ruby-send',
+    label: 'send dispatch',
+    langs: RB,
+    re: /\.(?:public_)?send\s*\(\s*:?\w+|\bmethod\s*\(\s*:\w+\s*\)/g,
+    keyFrom: (orig) => {
+      const m = orig.match(/:(\w+)/);
+      return m ? { key: m[1]! } : undefined;
+    },
+  },
+  {
+    // call_user_func([$this, 'method']) / $this->$method() / $callback() —
+    // PHP variable functions and callables.
+    form: 'php-dynamic',
+    label: 'dynamic call',
+    langs: PHP,
+    re: /\bcall_user_func(?:_array)?\s*\(|\$this\s*->\s*\$\w+\s*\(|\$\w+\s*\(/g,
+    keyWindow: 80,
+    keyFrom: (orig) => {
+      const key = singleStringLiteral(orig);
+      return key ? { key } : undefined;
+    },
+  },
+  {
+    // Reflection: Method.invoke / getMethod("x") / Class.forName / Go
+    // reflect MethodByName / C# Activator.CreateInstance, GetMethod.
+    form: 'reflection',
+    label: 'reflective dispatch',
+    langs: JVM_CS_GO,
+    re: /\.invoke\s*\(|\.get(?:Declared)?Method\s*\(|\.GetMethod\s*\(|MethodByName\s*\(|Activator\.CreateInstance|Class\.forName\s*\(/g,
+    keyWindow: 80,
+    keyFrom: (orig) => {
+      const key = singleStringLiteral(orig);
+      return key ? { key } : undefined;
+    },
+  },
+  {
+    // new Proxy(target, handler) / Reflect.get|apply — JS metaobject dispatch.
+    form: 'proxy-reflect',
+    label: 'Proxy/Reflect dispatch',
+    langs: JS_FAMILY,
+    re: /\bnew\s+Proxy\s*\(|\bReflect\.(?:get|apply|construct)\s*\(/g,
+  },
+  {
+    // mediator.Send(new CreateTodoItemCommand(...)) / bus.publish(new OrderEvent(...))
+    // — typed message dispatch (MediatR/CQRS/event-bus). The request TYPE is the
+    // key; the conventional target is `<Type>Handler`.
+    form: 'typed-bus',
+    label: 'typed message dispatch',
+    re: /\.(?:[Ss]end|[Pp]ublish|[Dd]ispatch|[Ee]xecute|[Pp]ost|[Ee]mit)(?:Async)?\s*(?:<[^<>\n]{0,80}>)?\s*\(\s*new\s+([A-Z]\w*)/g,
+    keyFrom: (orig) => {
+      const m = orig.match(/new\s+([A-Z]\w*)$/);
+      return m ? { key: m[1]!, keyIsType: true } : undefined;
+    },
+  },
+  {
+    // emitter.emit(eventVar, ...) / store.dispatch(action) — string-keyed
+    // dispatch where the key is a RUNTIME value. (Literal-keyed emits are the
+    // synthesizer's territory and connect statically when a handler matches.)
+    form: 'var-key-dispatch',
+    label: 'string-keyed dispatch (runtime key)',
+    re: /\.(?:emit|dispatch|trigger|fire|publish|broadcast)\s*\(\s*[A-Za-z_$][\w$]*(?:\.[\w$]+){0,3}\s*[,)]/g,
+  },
+  {
+    // Swift/ObjC: #selector(name) / NSClassFromString — runtime selector dispatch.
+    form: 'selector',
+    label: 'selector dispatch',
+    langs: SWIFT_OBJC,
+    re: /#selector\s*\(\s*([\w.]+)|NSClassFromString\s*\(/g,
+    keyFrom: (orig) => {
+      const m = orig.match(/#selector\s*\(\s*([\w.]+)/);
+      if (!m) return undefined;
+      const segs = m[1]!.split('.');
+      return { key: segs[segs.length - 1]! };
+    },
+  },
+];
+
+/** Map a Node.language to the comment-stripper's language set. */
+function commentLang(language: string): CommentLang | null {
+  switch (language) {
+    case 'python': return 'python';
+    case 'ruby': return 'ruby';
+    case 'rust': return 'rust';
+    case 'php': return 'php';
+    case 'go': return 'go';
+    case 'javascript':
+    case 'jsx':
+      return 'javascript';
+    case 'typescript':
+    case 'tsx':
+    case 'vue':
+    case 'svelte':
+    case 'astro':
+      return 'typescript';
+    case 'java':
+    case 'kotlin':
+    case 'scala':
+    case 'dart':
+      return 'java';
+    case 'csharp': return 'csharp';
+    case 'swift': return 'swift';
+    case 'c':
+    case 'cpp':
+    case 'objc':
+    case 'objcpp':
+      return 'java'; // C-style comments + double-quoted strings — close enough for blanking
+    default: return null;
+  }
+}
+
+const MAX_MATCHES_PER_BODY = 3;
+const MAX_BODY_CHARS = 60_000; // a god-function tail is still scannable; beyond this, truncate
+
+/**
+ * Blank the CONTENTS of string literals (quotes preserved, offsets preserved)
+ * so dispatch-shaped prose — docs, error messages, template text — can't fire
+ * a matcher. Run AFTER comment stripping (comments are already spaces).
+ * Backslash escapes are honored; `'`/`"` strings end at a newline (treated as
+ * unterminated, matching the comment stripper); backticks span lines, and
+ * `${...}` interpolations inside them are blanked too — missing a dispatch
+ * inside a template literal is acceptable, false-firing on prose is not.
+ */
+export function blankStringContents(text: string): string {
+  const out = text.split('');
+  let i = 0;
+  const n = text.length;
+  while (i < n) {
+    const c = text[i]!;
+    if (c === '"' || c === "'" || c === '`') {
+      const quote = c;
+      i++;
+      while (i < n && text[i] !== quote) {
+        if (text[i] === '\\' && i + 1 < n) {
+          out[i] = ' ';
+          out[i + 1] = ' ';
+          i += 2;
+          continue;
+        }
+        if (quote !== '`' && text[i] === '\n') break; // unterminated — stop blanking
+        if (text[i] !== '\n') out[i] = ' ';           // keep newlines for line math
+        i++;
+      }
+      if (i < n && text[i] === quote) i++;
+      continue;
+    }
+    i++;
+  }
+  return out.join('');
+}
+
+/**
+ * Scan one symbol's body for dynamic-dispatch sites.
+ *
+ * @param body       the symbol's source text (sliced from the file)
+ * @param language   Node.language of the symbol
+ * @param fileStartLine 1-based line where `body` starts in its file — returned
+ *                      line numbers are absolute file lines.
+ */
+export function scanDynamicDispatch(body: string, language: string, fileStartLine: number): BoundaryMatch[] {
+  const original = body.length > MAX_BODY_CHARS ? body.slice(0, MAX_BODY_CHARS) : body;
+  const lang = commentLang(language);
+  const stripped = blankStringContents(lang ? stripCommentsForRegex(original, lang) : original);
+
+  const out: BoundaryMatch[] = [];
+  const seen = new Map<string, BoundaryMatch>(); // form+key → first match (counts extras)
+
+  if (language === 'python') scanPythonGetattr(stripped, original, fileStartLine, out, seen);
+
+  for (const spec of FORMS) {
+    if (out.length >= MAX_MATCHES_PER_BODY) break;
+    if (spec.langs && !spec.langs.has(language)) continue;
+    spec.re.lastIndex = 0;
+    let m: RegExpExecArray | null;
+    while ((m = spec.re.exec(stripped)) !== null) {
+      let sliceEnd = m.index + m[0].length;
+      if (spec.keyWindow) {
+        const windowEnd = Math.min(original.length, sliceEnd + spec.keyWindow);
+        const nl = original.indexOf('\n', sliceEnd);
+        sliceEnd = nl !== -1 && nl < windowEnd ? nl : windowEnd;
+      }
+      const origSlice = original.slice(m.index, sliceEnd);
+      const derived = spec.keyFrom?.(origSlice);
+      const dedupeKey = `${spec.form}|${derived?.key ?? ''}`;
+      const prior = seen.get(dedupeKey);
+      if (prior) {
+        prior.moreSites = (prior.moreSites ?? 0) + 1;
+        continue;
+      }
+      const line = fileStartLine + countNewlines(original, m.index);
+      const match: BoundaryMatch = {
+        form: spec.form,
+        label: spec.label,
+        snippet: snippetAround(original, m.index),
+        line,
+        ...(derived ?? {}),
+      };
+      seen.set(dedupeKey, match);
+      out.push(match);
+      if (out.length >= MAX_MATCHES_PER_BODY) return out;
+    }
+  }
+  return out;
+}
+
+/**
+ * Python getattr dispatch — handled in code, not the FORMS table, because real
+ * getattr calls have nested-call arguments spanning lines
+ * (`getattr(self, request.method.lower(),\n  self.http_method_not_allowed)` —
+ * DRF's APIView.dispatch) that a regex argument class can't bound. Two shapes:
+ *   getattr(obj, name)(args)                      → immediate call
+ *   handler = getattr(obj, name) ... handler(...)  → assigned, called later
+ */
+const GETATTR_RE = /\bgetattr\s*\(/g;
+const MAX_GETATTR_ARGS = 300;
+
+function scanPythonGetattr(stripped: string, original: string, fileStartLine: number, out: BoundaryMatch[], seen: Map<string, BoundaryMatch>): void {
+  GETATTR_RE.lastIndex = 0;
+  let m: RegExpExecArray | null;
+  while ((m = GETATTR_RE.exec(stripped)) !== null && out.length < MAX_MATCHES_PER_BODY) {
+    const open = m.index + m[0].length - 1;
+    const close = matchBalancedParen(stripped, open);
+    if (close === -1) continue;
+
+    let form: string | undefined;
+    let label = '';
+    // Immediate call: getattr(...)(
+    const after = stripped.slice(close + 1, close + 8);
+    if (/^\s*\(/.test(after)) {
+      form = 'getattr-call';
+      label = 'getattr dispatch';
+    } else {
+      // Assigned form: look back for `name =` and forward for `name(`.
+      const lineStart = stripped.lastIndexOf('\n', m.index) + 1;
+      const before = stripped.slice(lineStart, m.index);
+      const assign = before.match(/(\w+)\s*=\s*$/);
+      if (assign && new RegExp(`\\b${assign[1]}\\s*\\(`).test(stripped.slice(close + 1))) {
+        form = 'getattr-assign';
+        label = 'getattr dispatch (assigned, called later)';
+      }
+    }
+    if (!form) continue;
+
+    const key = singleStringLiteral(original.slice(open + 1, close));
+    const dedupeKey = `${form}|${key ?? ''}`;
+    const prior = seen.get(dedupeKey);
+    if (prior) {
+      prior.moreSites = (prior.moreSites ?? 0) + 1;
+      continue;
+    }
+    const match: BoundaryMatch = {
+      form,
+      label,
+      snippet: snippetAround(original, m.index),
+      line: fileStartLine + countNewlines(original, m.index),
+      ...(key ? { key } : {}),
+    };
+    seen.set(dedupeKey, match);
+    out.push(match);
+  }
+}
+
+/** Index of the `)` balancing `text[open]`, or -1 (cap: MAX_GETATTR_ARGS chars). */
+function matchBalancedParen(text: string, open: number): number {
+  let depth = 0;
+  const end = Math.min(text.length, open + MAX_GETATTR_ARGS);
+  for (let i = open; i < end; i++) {
+    const c = text[i];
+    if (c === '(') depth++;
+    else if (c === ')' && --depth === 0) return i;
+  }
+  return -1;
+}
+
+function countNewlines(text: string, end: number): number {
+  let n = 0;
+  for (let i = 0; i < end; i++) if (text.charCodeAt(i) === 10) n++;
+  return n;
+}
+
+/** The full source line containing `index`, trimmed and capped for display. */
+function snippetAround(text: string, index: number): string {
+  const lineStart = text.lastIndexOf('\n', index) + 1;
+  let lineEnd = text.indexOf('\n', index);
+  if (lineEnd === -1) lineEnd = text.length;
+  const line = text.slice(lineStart, lineEnd).trim();
+  return line.length > 120 ? line.slice(0, 117) + '...' : line;
+}

+ 180 - 4
src/mcp/tools.ts

@@ -28,6 +28,7 @@ import {
 } from 'fs';
 import { clamp, validatePathWithinRoot, validateProjectPath, isConfigLeafNode, CONFIG_LEAF_LANGUAGES } from '../utils';
 import { isGeneratedFile } from '../extraction/generated-detection';
+import { scanDynamicDispatch } from './dynamic-boundaries';
 
 /**
  * An expected, recoverable "codegraph can't serve this" condition — most
@@ -1539,6 +1540,10 @@ export class ToolHandler {
       // (`as_sql`, 110 defs across every Expression/Compiler subclass) is NOT here,
       // so naming it doesn't keep every backend variant full and flood the budget.
       const uniqueNamedNodeIds = new Set<string>();
+      // token → resolved node ids: drives the token-coverage check that gates
+      // the dynamic-boundary scan (a token is covered when ANY of its nodes
+      // lands on the main chain — overloads off the chain don't count against).
+      const tokenNodes = new Map<string, string[]>();
       for (const t of tokens) {
         const cands = this.findAllSymbols(cg, t).nodes.filter((n) => CALLABLE.has(n.kind));
         // A qualified or otherwise-specific name (<=3 hits) keeps all; an
@@ -1551,13 +1556,25 @@ export class ToolHandler {
               const container = segs.length >= 2 ? segs[segs.length - 2] : '';
               return !!container && segPool.has(container);
             });
-        for (const n of pick.slice(0, 6)) {
+        const kept = pick.slice(0, 6);
+        tokenNodes.set(t, kept.map((n) => n.id));
+        for (const n of kept) {
           named.set(n.id, n);
           if (specific) uniqueNamedNodeIds.add(n.id);
         }
         if (named.size > 40) break;
       }
-      if (named.size < 2) return EMPTY;
+      if (named.size < 2) {
+        // The agent named a flow but only one side resolved (the other end is
+        // anonymous / runtime-registered / not extracted). The resolved side's
+        // body may still hold the dynamic-dispatch site that EXPLAINS the gap —
+        // surface that instead of silently returning nothing.
+        if (named.size === 0) return EMPTY;
+        const boundaries = this.buildDynamicBoundaries(cg, [...named.values()], named);
+        if (!boundaries) return EMPTY;
+        const text = boundaries + '> Full source for these symbols is below.\n';
+        return { text, pathNodeIds: new Set(), namedNodeIds: new Set(named.keys()), uniqueNamedNodeIds };
+      }
       const MAX_HOPS = 7;
       let best: Array<{ node: Node; edge: Edge | null }> | null = null;
       // BFS the full call graph (incl. synth edges) from each named seed, but
@@ -1592,6 +1609,36 @@ export class ToolHandler {
       const hasMain = !!best && best.length >= 3;
       const pathIds = new Set((best ?? []).map((s) => s.node.id));
 
+      // Dynamic-boundary scan (#687) — fires ONLY when the flow the agent
+      // asked about did not fully connect: some token resolved to nodes but
+      // none of them sit on the main chain (or there is no chain at all). A
+      // healthy flow skips this entirely. Scan order: the chain's dead end
+      // first (where the partial flow stops), then the disconnected symbols,
+      // agent-specific (unique-named) ones first.
+      let boundaryText = '';
+      {
+        const uncovered: Node[] = [];
+        if (!hasMain) {
+          // No rendered chain — but a 2-node chain still CONNECTS its two
+          // endpoints (e.g. via one synthesized hop, surfaced below as a
+          // dynamic-dispatch link). Only nodes off that short chain are
+          // unexplained breaks worth scanning.
+          for (const n of named.values()) if (!pathIds.has(n.id)) uncovered.push(n);
+        } else {
+          for (const ids of tokenNodes.values()) {
+            if (ids.length === 0 || ids.some((id) => pathIds.has(id))) continue;
+            for (const id of ids) { const n = named.get(id); if (n) uncovered.push(n); }
+          }
+        }
+        if (uncovered.length > 0) {
+          const scanList: Node[] = [];
+          if (hasMain) scanList.push(best![best!.length - 1]!.node);
+          scanList.push(...uncovered.sort((a, b) =>
+            (uniqueNamedNodeIds.has(b.id) ? 1 : 0) - (uniqueNamedNodeIds.has(a.id) ? 1 : 0)));
+          boundaryText = this.buildDynamicBoundaries(cg, scanList, named);
+        }
+      }
+
       // Supplementary: dynamic-dispatch (synthesized) edges incident to a NAMED
       // symbol — the indirect hops an agent would otherwise grep/Read to
       // reconstruct ("where do the appended `validators` actually run?"). The
@@ -1607,7 +1654,12 @@ export class ToolHandler {
         for (const { node: other, edge } of [...cg.getCallers(n.id), ...cg.getCallees(n.id)]) {
           if (synthLines.length >= 6) break;
           if (edge.provenance !== 'heuristic' || other.id === n.id) continue;
-          if (pathIds.has(edge.source) && pathIds.has(edge.target)) continue; // already in the main chain
+          // "Already in the main chain" only applies when a chain RENDERS
+          // (hasMain). A 2-node chain populates pathIds but renders nothing,
+          // so a direct synthesized hop between two named symbols (custom
+          // EventBus emit→handler, #687) was invisible — too short for Flow,
+          // skipped here as in-chain. Surface it.
+          if (hasMain && pathIds.has(edge.source) && pathIds.has(edge.target)) continue;
           const src = edge.source === n.id ? n : other;
           const tgt = edge.source === n.id ? other : n;
           const key = `${src.name}>${tgt.name}`;
@@ -1618,7 +1670,7 @@ export class ToolHandler {
         }
       }
 
-      if (!hasMain && synthLines.length === 0) return EMPTY;
+      if (!hasMain && synthLines.length === 0 && !boundaryText) return EMPTY;
       const out: string[] = [];
       if (hasMain) {
         out.push('## Flow (call path among the symbols you queried)', '');
@@ -1638,6 +1690,7 @@ export class ToolHandler {
           ''
         );
       }
+      if (boundaryText) out.push(boundaryText);
       out.push('> Full source for these symbols is below — the call flow among them, followed by their bodies.', '');
       // namedNodeIds = every callable the agent explicitly named (a superset of
       // the spine). A file holding one is something the agent asked to SEE, so it
@@ -1650,6 +1703,129 @@ export class ToolHandler {
     }
   }
 
+  /**
+   * Dynamic-boundary surfacing (#687): when the flow among the agent's named
+   * symbols does not fully connect, scan the disconnected symbols' bodies for
+   * dynamic-dispatch sites (computed member calls, getattr, reflection, typed
+   * message buses, runtime-keyed emits) and ANNOUNCE the boundary — the exact
+   * site, the form, and (when a key is statically visible) candidate targets —
+   * instead of guessing edges. The answer to "how does A reach B" when no
+   * static path exists IS the dispatch site: that's where the flow continues
+   * at runtime. Query-time, deterministic, zero graph mutation; a fully
+   * 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 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)) {
+        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}`);
+        }
+      }
+    }
+    if (notes.length === 0) return '';
+    return [
+      '## Dynamic boundaries (the static path ends at runtime dispatch)',
+      '',
+      ...notes,
+      '',
+      '> These sites choose their call target at runtime (registry / bus / reflection) — the site shown IS where the flow continues. To follow it, run codegraph_explore or codegraph_node on a candidate; source for the sites above is included below.',
+      '',
+    ].join('\n');
+  }
+
+  /**
+   * 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.
+   */
+  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(', ')}`;
+  }
+
   /**
    * Compact "blast radius" for the entry symbols of an explore result: who
    * depends on each (callers) and which test files cover it — LOCATIONS ONLY,