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>
This commit is contained in:
Colby Mchenry
2026-06-12 11:26:33 -05:00
committed by GitHub
co-authored by Claude Opus 4.8
parent 848fde9f59
commit df6f4bec43
6 changed files with 896 additions and 5 deletions
+180 -4
View File
@@ -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,