| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406 |
- /**
- * 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<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');
- });
- });
- // ---------------------------------------------------------------------------
- // 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<string, string>, 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<string, INodeType> = {};',
- ' 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');
- });
- });
|