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:
co-authored by
Claude Opus 4.8
parent
848fde9f59
commit
df6f4bec43
@@ -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
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user