/** * 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, beforeAll, afterAll, 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'; // These suites assert on the RAW codegraph_explore output (the Flow / boundary // sections). The managed reasoning-offload, when configured on the dev machine // (~/.codegraph/config.json `{"offload":{"managed":true}}`), REPLACES that output // with a remote Cerebras synthesis — so the structural assertions only hold with // the offload off. Disable it for this file so the suite is hermetic regardless // of machine config, then restore. let _prevOffloadDisable: string | undefined; beforeAll(() => { _prevOffloadDisable = process.env.CODEGRAPH_OFFLOAD_DISABLE; process.env.CODEGRAPH_OFFLOAD_DISABLE = '1'; }); afterAll(() => { if (_prevOffloadDisable === undefined) delete process.env.CODEGRAPH_OFFLOAD_DISABLE; else process.env.CODEGRAPH_OFFLOAD_DISABLE = _prevOffloadDisable; }); // --------------------------------------------------------------------------- // 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 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, 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 = {};', ' 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 = {};', ' 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 = {};', ' 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 = {};', ' 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'); }); }); // --------------------------------------------------------------------------- // Integration: interface/registry dispatch (a named method has many impls) // --------------------------------------------------------------------------- describe('codegraph_explore — interface dispatch', () => { let testDir: string; let cg: CodeGraph; let handler: ToolHandler; const setup = async (files: Record, include: string[]) => { testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-iface-')); 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 }); }); // 9 classes implement INodeType, each with execute(); a runtime registry lookup // dispatches to one. The agent names the static entry + `execute`, which can't // resolve to a single impl — the boundary IS the answer. const nodeFamily = (n: number) => { const names = ['Http', 'Set', 'If', 'Merge', 'Code', 'Webhook', 'Cron', 'Func', 'NoOp', 'Switch', 'Wait', 'Filter']; return [ 'export interface INodeType { execute(): unknown; }', ...names.slice(0, n).map((nm, i) => `export class ${nm}Node implements INodeType { execute() { return ${i}; } }`), ].join('\n'); }; const engine = [ "import { registry } from './registry';", 'export class WorkflowExecute {', ' processRunExecutionData() { return this.runNode(); }', ' runNode() { return this.executeNode(); }', ' executeNode() {', " const nodeType = registry.get('http');", ' return nodeType.execute();', ' }', '}', ].join('\n'); const registry = [ "import type { INodeType } from './nodes';", 'class Registry {', ' private m: Record = {};', ' get(k: string): INodeType { return this.m[k]!; }', '}', 'export const registry = new Registry();', ].join('\n'); it('announces the interface, the TRUE implementer count, and sample targets', async () => { await setup({ 'nodes.ts': nodeFamily(9), 'registry.ts': registry, 'engine.ts': engine }, ['**/*.ts']); const res = await handler.execute('codegraph_explore', { query: 'processRunExecutionData executeNode execute' }); const text = res.content[0].text as string; expect(text).toContain('## Interface dispatch (a named method has many implementations)'); expect(text).toMatch(/`execute` → runtime dispatch to \*\*9\*\* types implementing `INodeType`/); // a couple of concrete targets, with file:line expect(text).toMatch(/\b\w+Node\.execute` \(/); // never steer to Read expect(text).not.toMatch(/\buse Read\b/i); }); it('stays SILENT on a fully connected flow with no polymorphic family', 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('## Interface dispatch'); }); it('stays SILENT when the interface family is below the polymorphism threshold (3 impls)', async () => { await setup({ 'nodes.ts': nodeFamily(3), 'registry.ts': registry, 'engine.ts': engine }, ['**/*.ts']); const res = await handler.execute('codegraph_explore', { query: 'processRunExecutionData executeNode execute' }); const text = res.content[0].text as string; expect(text).not.toContain('## Interface dispatch'); }); });