dynamic-boundaries.test.ts 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299
  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, beforeEach, 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. // ---------------------------------------------------------------------------
  19. // Unit: the scanner
  20. // ---------------------------------------------------------------------------
  21. describe('scanDynamicDispatch', () => {
  22. it('detects a computed member call with a literal key', () => {
  23. const body = `function go(p) {\n table['save'](p);\n}`;
  24. const m = scanDynamicDispatch(body, 'typescript', 10);
  25. expect(m).toHaveLength(1);
  26. expect(m[0]!.form).toBe('computed-call');
  27. expect(m[0]!.key).toBe('save');
  28. expect(m[0]!.line).toBe(11); // absolute: body starts at file line 10
  29. expect(m[0]!.snippet).toContain("table['save'](p)");
  30. });
  31. it('detects a computed member call with a runtime key (no key extracted)', () => {
  32. const body = `dispatch(action) {\n this.handlers[action.type](action.payload);\n}`;
  33. const m = scanDynamicDispatch(body, 'typescript', 1);
  34. expect(m).toHaveLength(1);
  35. expect(m[0]!.form).toBe('computed-call');
  36. expect(m[0]!.key).toBeUndefined();
  37. });
  38. it('does not fire on dispatch shapes inside comments or strings', () => {
  39. const body = [
  40. 'function safe() {',
  41. " // this.handlers[action.type](payload) — commented out",
  42. ' const doc = "call handlers[key](p) to dispatch";',
  43. ' return 1;',
  44. '}',
  45. ].join('\n');
  46. expect(scanDynamicDispatch(body, 'typescript', 1)).toHaveLength(0);
  47. });
  48. it('does not treat plain indexing or array literals as dispatch', () => {
  49. const body = `function f(xs) {\n const a = xs[0];\n const b = [1, 2, 3];\n return a + b[1];\n}`;
  50. expect(scanDynamicDispatch(body, 'typescript', 1)).toHaveLength(0);
  51. });
  52. it('detects python getattr immediate-call', () => {
  53. const body = `def run(self, name):\n return getattr(self, name)(1)`;
  54. const m = scanDynamicDispatch(body, 'python', 5);
  55. expect(m).toHaveLength(1);
  56. expect(m[0]!.form).toBe('getattr-call');
  57. });
  58. it('detects two-step getattr only when the assigned name is called later', () => {
  59. const called = `def process(self, kind, p):\n handler = getattr(self, 'handle_' + kind)\n return handler(p)`;
  60. const m = scanDynamicDispatch(called, 'python', 1);
  61. expect(m).toHaveLength(1);
  62. expect(m[0]!.form).toBe('getattr-assign');
  63. expect(m[0]!.key).toBe('handle_'); // the literal prefix — enough to shortlist
  64. const notCalled = `def peek(self, kind):\n handler = getattr(self, 'handle_' + kind)\n return handler`;
  65. expect(scanDynamicDispatch(notCalled, 'python', 1)).toHaveLength(0);
  66. });
  67. it('detects ruby send with a symbol key', () => {
  68. const body = `def run(name)\n target.send(:handle_save, 1)\nend`;
  69. const m = scanDynamicDispatch(body, 'ruby', 1);
  70. expect(m).toHaveLength(1);
  71. expect(m[0]!.form).toBe('ruby-send');
  72. expect(m[0]!.key).toBe('handle_save');
  73. });
  74. it('detects typed message dispatch and marks the key as a type', () => {
  75. const body = `public async Task<int> Create(CreateCmd c) {\n return await _mediator.Send(new CreateTodoItemCommand(c));\n}`;
  76. const m = scanDynamicDispatch(body, 'csharp', 1);
  77. expect(m).toHaveLength(1);
  78. expect(m[0]!.form).toBe('typed-bus');
  79. expect(m[0]!.key).toBe('CreateTodoItemCommand');
  80. expect(m[0]!.keyIsType).toBe(true);
  81. });
  82. it('detects runtime-keyed emit but not literal-keyed emit', () => {
  83. const runtime = `notify(name, data) {\n this.emitter.emit(name, data);\n}`;
  84. const m = scanDynamicDispatch(runtime, 'typescript', 1);
  85. expect(m).toHaveLength(1);
  86. expect(m[0]!.form).toBe('var-key-dispatch');
  87. // Literal keys are the edge synthesizer's territory — not a boundary.
  88. const literal = `notify(data) {\n this.emitter.emit('saved', data);\n}`;
  89. expect(scanDynamicDispatch(literal, 'typescript', 1)).toHaveLength(0);
  90. });
  91. it('dedupes repeated same-form/same-key sites and counts the extras', () => {
  92. const body = [
  93. 'route(a) {',
  94. ' this.table[a.type](a.p);',
  95. ' this.table[a.kind](a.p);',
  96. ' this.table[a.name](a.p);',
  97. '}',
  98. ].join('\n');
  99. const m = scanDynamicDispatch(body, 'typescript', 1);
  100. expect(m).toHaveLength(1);
  101. expect(m[0]!.moreSites).toBe(2);
  102. });
  103. it('detects reflective dispatch with a literal method name as key', () => {
  104. const body = `public void run(Object o) {\n o.getClass().getMethod("handlePing").invoke(o);\n}`;
  105. const m = scanDynamicDispatch(body, 'java', 1);
  106. expect(m.length).toBeGreaterThanOrEqual(1);
  107. expect(m[0]!.form).toBe('reflection');
  108. expect(m[0]!.key).toBe('handlePing');
  109. });
  110. });
  111. // ---------------------------------------------------------------------------
  112. // Integration: codegraph_explore output
  113. // ---------------------------------------------------------------------------
  114. describe('codegraph_explore — dynamic boundaries', () => {
  115. let testDir: string;
  116. let cg: CodeGraph;
  117. let handler: ToolHandler;
  118. const setup = async (files: Record<string, string>, include: string[]) => {
  119. testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-boundary-'));
  120. const src = path.join(testDir, 'src');
  121. fs.mkdirSync(src, { recursive: true });
  122. for (const [name, content] of Object.entries(files)) {
  123. fs.writeFileSync(path.join(src, name), content);
  124. }
  125. cg = CodeGraph.initSync(testDir, { config: { include, exclude: [] } });
  126. await cg.indexAll();
  127. handler = new ToolHandler(cg);
  128. };
  129. afterEach(() => {
  130. if (cg) cg.destroy();
  131. if (testDir && fs.existsSync(testDir)) fs.rmSync(testDir, { recursive: true, force: true });
  132. });
  133. it('announces the boundary site and shortlists the keyed candidate', async () => {
  134. await setup({
  135. 'router.ts': [
  136. 'type Handler = (p: unknown) => void;',
  137. 'export class Router {',
  138. ' private table: Record<string, Handler> = {};',
  139. ' add(key: string, fn: Handler) { this.table[key] = fn; }',
  140. ' routeSave(payload: unknown) {',
  141. " this.table['save'](payload);",
  142. ' }',
  143. '}',
  144. ].join('\n'),
  145. 'handlers.ts': [
  146. "import { Router } from './router';",
  147. 'export function onSave(payload: unknown) { return payload; }',
  148. 'export function wire(r: Router) { r.add("save", onSave); }',
  149. ].join('\n'),
  150. }, ['**/*.ts']);
  151. const res = await handler.execute('codegraph_explore', { query: 'routeSave onSave' });
  152. const text = res.content[0].text as string;
  153. expect(text).toContain('## Dynamic boundaries');
  154. expect(text).toContain('computed member call');
  155. expect(text).toMatch(/router\.ts:6/); // the exact dispatch site
  156. expect(text).toContain('candidates for key `save`');
  157. expect(text).toContain('onSave');
  158. expect(text).toContain('← you named this');
  159. // Honesty constraint: never steer the agent to Read.
  160. expect(text).not.toMatch(/\buse Read\b/i);
  161. });
  162. it('announces a runtime-keyed boundary with no candidate list', async () => {
  163. await setup({
  164. 'bus.ts': [
  165. 'type Action = { type: string; payload?: unknown };',
  166. 'type Handler = (p: unknown) => void;',
  167. 'export class Bus {',
  168. ' private table: Record<string, Handler> = {};',
  169. ' route(action: Action) {',
  170. ' this.table[action.type](action.payload);',
  171. ' }',
  172. '}',
  173. ].join('\n'),
  174. 'handlers.ts': 'export function onSave(payload: unknown) { return payload; }',
  175. }, ['**/*.ts']);
  176. const res = await handler.execute('codegraph_explore', { query: 'route onSave' });
  177. const text = res.content[0].text as string;
  178. expect(text).toContain('## Dynamic boundaries');
  179. expect(text).toContain('computed member call');
  180. expect(text).not.toContain('candidates for key'); // runtime key → no shortlist to claim
  181. });
  182. it('surfaces the boundary even when the other symbol is not in the graph', async () => {
  183. await setup({
  184. 'bus.ts': [
  185. 'type Action = { type: string; payload?: unknown };',
  186. 'type Handler = (p: unknown) => void;',
  187. 'export class Bus {',
  188. ' private table: Record<string, Handler> = {};',
  189. ' route(action: Action) {',
  190. ' this.table[action.type](action.payload);',
  191. ' }',
  192. '}',
  193. ].join('\n'),
  194. }, ['**/*.ts']);
  195. // `processPayment` does not exist anywhere — only `route` resolves.
  196. const res = await handler.execute('codegraph_explore', { query: 'route processPayment' });
  197. const text = res.content[0].text as string;
  198. expect(text).toContain('## Dynamic boundaries');
  199. });
  200. it('renders a direct synthesized emit→handler hop as a dynamic-dispatch link (#687 criterion 1)', async () => {
  201. // Custom EventBus with a LITERAL key: the event-emitter synthesizer
  202. // bridges emit→handler, but the 2-node chain was invisible — too short
  203. // for the Flow section and skipped by the links section as "in-chain".
  204. await setup({
  205. 'bus.ts': [
  206. 'type Handler = (p: unknown) => void;',
  207. 'export class EventBus {',
  208. ' private listeners: Record<string, Handler[]> = {};',
  209. ' on(event: string, fn: Handler) { (this.listeners[event] ??= []).push(fn); }',
  210. ' emit(event: string, payload: unknown) { for (const fn of this.listeners[event] ?? []) fn(payload); }',
  211. '}',
  212. 'export const bus = new EventBus();',
  213. ].join('\n'),
  214. 'billing.ts': [
  215. "import { bus } from './bus';",
  216. 'export function settleInvoice(payload: unknown) { return payload; }',
  217. "bus.on('invoice.settled', settleInvoice);",
  218. ].join('\n'),
  219. 'checkout.ts': [
  220. "import { bus } from './bus';",
  221. 'export function completeCheckout(order: unknown) {',
  222. " bus.emit('invoice.settled', order);",
  223. '}',
  224. ].join('\n'),
  225. }, ['**/*.ts']);
  226. const res = await handler.execute('codegraph_explore', { query: 'completeCheckout settleInvoice' });
  227. const text = res.content[0].text as string;
  228. expect(text).toContain('## Dynamic-dispatch links among your symbols');
  229. expect(text).toMatch(/completeCheckout → settleInvoice/);
  230. expect(text).toContain('invoice.settled');
  231. // Connected via the synthesized edge — no boundary to announce.
  232. expect(text).not.toContain('## Dynamic boundaries');
  233. });
  234. it('never adds the section to a fully connected flow', async () => {
  235. await setup({
  236. 'pipeline.ts': [
  237. 'export function stepOne() { return stepTwo(); }',
  238. 'export function stepTwo() { return stepThree(); }',
  239. 'export function stepThree() { return 3; }',
  240. ].join('\n'),
  241. }, ['**/*.ts']);
  242. const res = await handler.execute('codegraph_explore', { query: 'stepOne stepThree' });
  243. const text = res.content[0].text as string;
  244. expect(text).toContain('## Flow');
  245. expect(text).not.toContain('## Dynamic boundaries');
  246. });
  247. it('python getattr dispatch surfaces with a prefix-key candidate', async () => {
  248. await setup({
  249. 'service.py': [
  250. 'class Service:',
  251. ' def handle_save(self, payload):',
  252. ' return payload',
  253. '',
  254. ' def process(self, kind, payload):',
  255. " handler = getattr(self, 'handle_' + kind)",
  256. ' return handler(payload)',
  257. ].join('\n'),
  258. }, ['**/*.py']);
  259. const res = await handler.execute('codegraph_explore', { query: 'process handle_save' });
  260. const text = res.content[0].text as string;
  261. expect(text).toContain('## Dynamic boundaries');
  262. expect(text).toContain('getattr');
  263. expect(text).toContain('handle_save');
  264. });
  265. });