dynamic-boundaries.test.ts 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406
  1. /**
  2. * Dynamic-boundary surfacing (#687).
  3. *
  4. * When the flow an agent asked codegraph_explore about does NOT fully connect,
  5. * the Flow section announces WHERE the static path ends — the dynamic-dispatch
  6. * site (computed member call, getattr, typed bus, runtime-keyed emit), with
  7. * candidate targets when a key is statically visible — instead of silently
  8. * showing nothing. Deterministic, query-time only, no graph mutation, and a
  9. * fully connected flow must never produce the section.
  10. */
  11. import { describe, it, expect, beforeAll, afterAll, afterEach } from 'vitest';
  12. import * as fs from 'fs';
  13. import * as path from 'path';
  14. import * as os from 'os';
  15. import CodeGraph from '../src/index';
  16. import { ToolHandler } from '../src/mcp/tools';
  17. import { scanDynamicDispatch } from '../src/mcp/dynamic-boundaries';
  18. // These suites assert on the RAW codegraph_explore output (the Flow / boundary
  19. // sections). The managed reasoning-offload, when configured on the dev machine
  20. // (~/.codegraph/config.json `{"offload":{"managed":true}}`), REPLACES that output
  21. // with a remote Cerebras synthesis — so the structural assertions only hold with
  22. // the offload off. Disable it for this file so the suite is hermetic regardless
  23. // of machine config, then restore.
  24. let _prevOffloadDisable: string | undefined;
  25. beforeAll(() => { _prevOffloadDisable = process.env.CODEGRAPH_OFFLOAD_DISABLE; process.env.CODEGRAPH_OFFLOAD_DISABLE = '1'; });
  26. afterAll(() => {
  27. if (_prevOffloadDisable === undefined) delete process.env.CODEGRAPH_OFFLOAD_DISABLE;
  28. else process.env.CODEGRAPH_OFFLOAD_DISABLE = _prevOffloadDisable;
  29. });
  30. // ---------------------------------------------------------------------------
  31. // Unit: the scanner
  32. // ---------------------------------------------------------------------------
  33. describe('scanDynamicDispatch', () => {
  34. it('detects a computed member call with a literal key', () => {
  35. const body = `function go(p) {\n table['save'](p);\n}`;
  36. const m = scanDynamicDispatch(body, 'typescript', 10);
  37. expect(m).toHaveLength(1);
  38. expect(m[0]!.form).toBe('computed-call');
  39. expect(m[0]!.key).toBe('save');
  40. expect(m[0]!.line).toBe(11); // absolute: body starts at file line 10
  41. expect(m[0]!.snippet).toContain("table['save'](p)");
  42. });
  43. it('detects a computed member call with a runtime key (no key extracted)', () => {
  44. const body = `dispatch(action) {\n this.handlers[action.type](action.payload);\n}`;
  45. const m = scanDynamicDispatch(body, 'typescript', 1);
  46. expect(m).toHaveLength(1);
  47. expect(m[0]!.form).toBe('computed-call');
  48. expect(m[0]!.key).toBeUndefined();
  49. });
  50. it('does not fire on dispatch shapes inside comments or strings', () => {
  51. const body = [
  52. 'function safe() {',
  53. " // this.handlers[action.type](payload) — commented out",
  54. ' const doc = "call handlers[key](p) to dispatch";',
  55. ' return 1;',
  56. '}',
  57. ].join('\n');
  58. expect(scanDynamicDispatch(body, 'typescript', 1)).toHaveLength(0);
  59. });
  60. it('does not treat plain indexing or array literals as dispatch', () => {
  61. const body = `function f(xs) {\n const a = xs[0];\n const b = [1, 2, 3];\n return a + b[1];\n}`;
  62. expect(scanDynamicDispatch(body, 'typescript', 1)).toHaveLength(0);
  63. });
  64. it('detects python getattr immediate-call', () => {
  65. const body = `def run(self, name):\n return getattr(self, name)(1)`;
  66. const m = scanDynamicDispatch(body, 'python', 5);
  67. expect(m).toHaveLength(1);
  68. expect(m[0]!.form).toBe('getattr-call');
  69. });
  70. it('detects two-step getattr only when the assigned name is called later', () => {
  71. const called = `def process(self, kind, p):\n handler = getattr(self, 'handle_' + kind)\n return handler(p)`;
  72. const m = scanDynamicDispatch(called, 'python', 1);
  73. expect(m).toHaveLength(1);
  74. expect(m[0]!.form).toBe('getattr-assign');
  75. expect(m[0]!.key).toBe('handle_'); // the literal prefix — enough to shortlist
  76. const notCalled = `def peek(self, kind):\n handler = getattr(self, 'handle_' + kind)\n return handler`;
  77. expect(scanDynamicDispatch(notCalled, 'python', 1)).toHaveLength(0);
  78. });
  79. it('detects ruby send with a symbol key', () => {
  80. const body = `def run(name)\n target.send(:handle_save, 1)\nend`;
  81. const m = scanDynamicDispatch(body, 'ruby', 1);
  82. expect(m).toHaveLength(1);
  83. expect(m[0]!.form).toBe('ruby-send');
  84. expect(m[0]!.key).toBe('handle_save');
  85. });
  86. it('detects typed message dispatch and marks the key as a type', () => {
  87. const body = `public async Task<int> Create(CreateCmd c) {\n return await _mediator.Send(new CreateTodoItemCommand(c));\n}`;
  88. const m = scanDynamicDispatch(body, 'csharp', 1);
  89. expect(m).toHaveLength(1);
  90. expect(m[0]!.form).toBe('typed-bus');
  91. expect(m[0]!.key).toBe('CreateTodoItemCommand');
  92. expect(m[0]!.keyIsType).toBe(true);
  93. });
  94. it('detects runtime-keyed emit but not literal-keyed emit', () => {
  95. const runtime = `notify(name, data) {\n this.emitter.emit(name, data);\n}`;
  96. const m = scanDynamicDispatch(runtime, 'typescript', 1);
  97. expect(m).toHaveLength(1);
  98. expect(m[0]!.form).toBe('var-key-dispatch');
  99. // Literal keys are the edge synthesizer's territory — not a boundary.
  100. const literal = `notify(data) {\n this.emitter.emit('saved', data);\n}`;
  101. expect(scanDynamicDispatch(literal, 'typescript', 1)).toHaveLength(0);
  102. });
  103. it('dedupes repeated same-form/same-key sites and counts the extras', () => {
  104. const body = [
  105. 'route(a) {',
  106. ' this.table[a.type](a.p);',
  107. ' this.table[a.kind](a.p);',
  108. ' this.table[a.name](a.p);',
  109. '}',
  110. ].join('\n');
  111. const m = scanDynamicDispatch(body, 'typescript', 1);
  112. expect(m).toHaveLength(1);
  113. expect(m[0]!.moreSites).toBe(2);
  114. });
  115. it('detects reflective dispatch with a literal method name as key', () => {
  116. const body = `public void run(Object o) {\n o.getClass().getMethod("handlePing").invoke(o);\n}`;
  117. const m = scanDynamicDispatch(body, 'java', 1);
  118. expect(m.length).toBeGreaterThanOrEqual(1);
  119. expect(m[0]!.form).toBe('reflection');
  120. expect(m[0]!.key).toBe('handlePing');
  121. });
  122. });
  123. // ---------------------------------------------------------------------------
  124. // Integration: codegraph_explore output
  125. // ---------------------------------------------------------------------------
  126. describe('codegraph_explore — dynamic boundaries', () => {
  127. let testDir: string;
  128. let cg: CodeGraph;
  129. let handler: ToolHandler;
  130. const setup = async (files: Record<string, string>, include: string[]) => {
  131. testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-boundary-'));
  132. const src = path.join(testDir, 'src');
  133. fs.mkdirSync(src, { recursive: true });
  134. for (const [name, content] of Object.entries(files)) {
  135. fs.writeFileSync(path.join(src, name), content);
  136. }
  137. cg = CodeGraph.initSync(testDir, { config: { include, exclude: [] } });
  138. await cg.indexAll();
  139. handler = new ToolHandler(cg);
  140. };
  141. afterEach(() => {
  142. if (cg) cg.destroy();
  143. if (testDir && fs.existsSync(testDir)) fs.rmSync(testDir, { recursive: true, force: true });
  144. });
  145. it('announces the boundary site and shortlists the keyed candidate', async () => {
  146. await setup({
  147. 'router.ts': [
  148. 'type Handler = (p: unknown) => void;',
  149. 'export class Router {',
  150. ' private table: Record<string, Handler> = {};',
  151. ' add(key: string, fn: Handler) { this.table[key] = fn; }',
  152. ' routeSave(payload: unknown) {',
  153. " this.table['save'](payload);",
  154. ' }',
  155. '}',
  156. ].join('\n'),
  157. 'handlers.ts': [
  158. "import { Router } from './router';",
  159. 'export function onSave(payload: unknown) { return payload; }',
  160. 'export function wire(r: Router) { r.add("save", onSave); }',
  161. ].join('\n'),
  162. }, ['**/*.ts']);
  163. const res = await handler.execute('codegraph_explore', { query: 'routeSave onSave' });
  164. const text = res.content[0].text as string;
  165. expect(text).toContain('## Dynamic boundaries');
  166. expect(text).toContain('computed member call');
  167. expect(text).toMatch(/router\.ts:6/); // the exact dispatch site
  168. expect(text).toContain('candidates for key `save`');
  169. expect(text).toContain('onSave');
  170. expect(text).toContain('← you named this');
  171. // Honesty constraint: never steer the agent to Read.
  172. expect(text).not.toMatch(/\buse Read\b/i);
  173. });
  174. it('announces a runtime-keyed boundary with no candidate list', async () => {
  175. await setup({
  176. 'bus.ts': [
  177. 'type Action = { type: string; payload?: unknown };',
  178. 'type Handler = (p: unknown) => void;',
  179. 'export class Bus {',
  180. ' private table: Record<string, Handler> = {};',
  181. ' route(action: Action) {',
  182. ' this.table[action.type](action.payload);',
  183. ' }',
  184. '}',
  185. ].join('\n'),
  186. 'handlers.ts': 'export function onSave(payload: unknown) { return payload; }',
  187. }, ['**/*.ts']);
  188. const res = await handler.execute('codegraph_explore', { query: 'route onSave' });
  189. const text = res.content[0].text as string;
  190. expect(text).toContain('## Dynamic boundaries');
  191. expect(text).toContain('computed member call');
  192. expect(text).not.toContain('candidates for key'); // runtime key → no shortlist to claim
  193. });
  194. it('surfaces the boundary even when the other symbol is not in the graph', async () => {
  195. await setup({
  196. 'bus.ts': [
  197. 'type Action = { type: string; payload?: unknown };',
  198. 'type Handler = (p: unknown) => void;',
  199. 'export class Bus {',
  200. ' private table: Record<string, Handler> = {};',
  201. ' route(action: Action) {',
  202. ' this.table[action.type](action.payload);',
  203. ' }',
  204. '}',
  205. ].join('\n'),
  206. }, ['**/*.ts']);
  207. // `processPayment` does not exist anywhere — only `route` resolves.
  208. const res = await handler.execute('codegraph_explore', { query: 'route processPayment' });
  209. const text = res.content[0].text as string;
  210. expect(text).toContain('## Dynamic boundaries');
  211. });
  212. it('renders a direct synthesized emit→handler hop as a dynamic-dispatch link (#687 criterion 1)', async () => {
  213. // Custom EventBus with a LITERAL key: the event-emitter synthesizer
  214. // bridges emit→handler, but the 2-node chain was invisible — too short
  215. // for the Flow section and skipped by the links section as "in-chain".
  216. await setup({
  217. 'bus.ts': [
  218. 'type Handler = (p: unknown) => void;',
  219. 'export class EventBus {',
  220. ' private listeners: Record<string, Handler[]> = {};',
  221. ' on(event: string, fn: Handler) { (this.listeners[event] ??= []).push(fn); }',
  222. ' emit(event: string, payload: unknown) { for (const fn of this.listeners[event] ?? []) fn(payload); }',
  223. '}',
  224. 'export const bus = new EventBus();',
  225. ].join('\n'),
  226. 'billing.ts': [
  227. "import { bus } from './bus';",
  228. 'export function settleInvoice(payload: unknown) { return payload; }',
  229. "bus.on('invoice.settled', settleInvoice);",
  230. ].join('\n'),
  231. 'checkout.ts': [
  232. "import { bus } from './bus';",
  233. 'export function completeCheckout(order: unknown) {',
  234. " bus.emit('invoice.settled', order);",
  235. '}',
  236. ].join('\n'),
  237. }, ['**/*.ts']);
  238. const res = await handler.execute('codegraph_explore', { query: 'completeCheckout settleInvoice' });
  239. const text = res.content[0].text as string;
  240. expect(text).toContain('## Dynamic-dispatch links among your symbols');
  241. expect(text).toMatch(/completeCheckout → settleInvoice/);
  242. expect(text).toContain('invoice.settled');
  243. // Connected via the synthesized edge — no boundary to announce.
  244. expect(text).not.toContain('## Dynamic boundaries');
  245. });
  246. it('never adds the section to a fully connected flow', async () => {
  247. await setup({
  248. 'pipeline.ts': [
  249. 'export function stepOne() { return stepTwo(); }',
  250. 'export function stepTwo() { return stepThree(); }',
  251. 'export function stepThree() { return 3; }',
  252. ].join('\n'),
  253. }, ['**/*.ts']);
  254. const res = await handler.execute('codegraph_explore', { query: 'stepOne stepThree' });
  255. const text = res.content[0].text as string;
  256. expect(text).toContain('## Flow');
  257. expect(text).not.toContain('## Dynamic boundaries');
  258. });
  259. it('python getattr dispatch surfaces with a prefix-key candidate', async () => {
  260. await setup({
  261. 'service.py': [
  262. 'class Service:',
  263. ' def handle_save(self, payload):',
  264. ' return payload',
  265. '',
  266. ' def process(self, kind, payload):',
  267. " handler = getattr(self, 'handle_' + kind)",
  268. ' return handler(payload)',
  269. ].join('\n'),
  270. }, ['**/*.py']);
  271. const res = await handler.execute('codegraph_explore', { query: 'process handle_save' });
  272. const text = res.content[0].text as string;
  273. expect(text).toContain('## Dynamic boundaries');
  274. expect(text).toContain('getattr');
  275. expect(text).toContain('handle_save');
  276. });
  277. });
  278. // ---------------------------------------------------------------------------
  279. // Integration: interface/registry dispatch (a named method has many impls)
  280. // ---------------------------------------------------------------------------
  281. describe('codegraph_explore — interface dispatch', () => {
  282. let testDir: string;
  283. let cg: CodeGraph;
  284. let handler: ToolHandler;
  285. const setup = async (files: Record<string, string>, include: string[]) => {
  286. testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-iface-'));
  287. const src = path.join(testDir, 'src');
  288. fs.mkdirSync(src, { recursive: true });
  289. for (const [name, content] of Object.entries(files)) {
  290. fs.writeFileSync(path.join(src, name), content);
  291. }
  292. cg = CodeGraph.initSync(testDir, { config: { include, exclude: [] } });
  293. await cg.indexAll();
  294. handler = new ToolHandler(cg);
  295. };
  296. afterEach(() => {
  297. if (cg) cg.destroy();
  298. if (testDir && fs.existsSync(testDir)) fs.rmSync(testDir, { recursive: true, force: true });
  299. });
  300. // 9 classes implement INodeType, each with execute(); a runtime registry lookup
  301. // dispatches to one. The agent names the static entry + `execute`, which can't
  302. // resolve to a single impl — the boundary IS the answer.
  303. const nodeFamily = (n: number) => {
  304. const names = ['Http', 'Set', 'If', 'Merge', 'Code', 'Webhook', 'Cron', 'Func', 'NoOp', 'Switch', 'Wait', 'Filter'];
  305. return [
  306. 'export interface INodeType { execute(): unknown; }',
  307. ...names.slice(0, n).map((nm, i) => `export class ${nm}Node implements INodeType { execute() { return ${i}; } }`),
  308. ].join('\n');
  309. };
  310. const engine = [
  311. "import { registry } from './registry';",
  312. 'export class WorkflowExecute {',
  313. ' processRunExecutionData() { return this.runNode(); }',
  314. ' runNode() { return this.executeNode(); }',
  315. ' executeNode() {',
  316. " const nodeType = registry.get('http');",
  317. ' return nodeType.execute();',
  318. ' }',
  319. '}',
  320. ].join('\n');
  321. const registry = [
  322. "import type { INodeType } from './nodes';",
  323. 'class Registry {',
  324. ' private m: Record<string, INodeType> = {};',
  325. ' get(k: string): INodeType { return this.m[k]!; }',
  326. '}',
  327. 'export const registry = new Registry();',
  328. ].join('\n');
  329. it('announces the interface, the TRUE implementer count, and sample targets', async () => {
  330. await setup({ 'nodes.ts': nodeFamily(9), 'registry.ts': registry, 'engine.ts': engine }, ['**/*.ts']);
  331. const res = await handler.execute('codegraph_explore', { query: 'processRunExecutionData executeNode execute' });
  332. const text = res.content[0].text as string;
  333. expect(text).toContain('## Interface dispatch (a named method has many implementations)');
  334. expect(text).toMatch(/`execute` → runtime dispatch to \*\*9\*\* types implementing `INodeType`/);
  335. // a couple of concrete targets, with file:line
  336. expect(text).toMatch(/\b\w+Node\.execute` \(/);
  337. // never steer to Read
  338. expect(text).not.toMatch(/\buse Read\b/i);
  339. });
  340. it('stays SILENT on a fully connected flow with no polymorphic family', async () => {
  341. await setup({
  342. 'pipeline.ts': [
  343. 'export function stepOne() { return stepTwo(); }',
  344. 'export function stepTwo() { return stepThree(); }',
  345. 'export function stepThree() { return 3; }',
  346. ].join('\n'),
  347. }, ['**/*.ts']);
  348. const res = await handler.execute('codegraph_explore', { query: 'stepOne stepThree' });
  349. const text = res.content[0].text as string;
  350. expect(text).toContain('## Flow');
  351. expect(text).not.toContain('## Interface dispatch');
  352. });
  353. it('stays SILENT when the interface family is below the polymorphism threshold (3 impls)', async () => {
  354. await setup({ 'nodes.ts': nodeFamily(3), 'registry.ts': registry, 'engine.ts': engine }, ['**/*.ts']);
  355. const res = await handler.execute('codegraph_explore', { query: 'processRunExecutionData executeNode execute' });
  356. const text = res.content[0].text as string;
  357. expect(text).not.toContain('## Interface dispatch');
  358. });
  359. });