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
+397
View File
@@ -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
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,