mcp-callers-truncation.test.ts 4.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102
  1. /**
  2. * The MCP `codegraph_callers` / `codegraph_callees` answers say when their
  3. * `limit` cut the list (#1639, #1674). A capped list with no marker reads as
  4. * the complete set, and an agent under-counts "who calls this" from it.
  5. */
  6. import { describe, it, expect, beforeAll, afterAll } from 'vitest';
  7. import * as fs from 'fs';
  8. import * as path from 'path';
  9. import * as os from 'os';
  10. import { CodeGraph } from '../src';
  11. import { ToolHandler } from '../src/mcp/tools';
  12. import { initGrammars, loadAllGrammars } from '../src/extraction/grammars';
  13. let tmpDir: string;
  14. let cg: CodeGraph;
  15. let handler: ToolHandler;
  16. const text = async (tool: string, args: Record<string, unknown>): Promise<string> => {
  17. const res = await handler.execute(tool, args);
  18. return res.content?.[0]?.text ?? '';
  19. };
  20. const CALLERS = 25;
  21. beforeAll(async () => {
  22. await initGrammars();
  23. await loadAllGrammars();
  24. tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-1674-'));
  25. fs.mkdirSync(path.join(tmpDir, 'src'));
  26. // `warm` lives in a file of another name: one definition, the flat list.
  27. fs.writeFileSync(path.join(tmpDir, 'src', 'target.ts'), 'export function warm(n: number): number { return n; }\n');
  28. fs.writeFileSync(
  29. path.join(tmpDir, 'src', 'callers.ts'),
  30. "import { warm } from './target';\n" +
  31. Array.from({ length: CALLERS }, (_, i) => `export function caller${i}(): number { return warm(${i}); }`).join('\n') +
  32. '\n'
  33. );
  34. // Two real `hot` functions exercise per-definition truncation. A filename
  35. // is not an overload of its exact-named function (#1809).
  36. fs.writeFileSync(path.join(tmpDir, 'src', 'hot.ts'), 'export function hot(n: number): number { return n; }\n');
  37. fs.writeFileSync(
  38. path.join(tmpDir, 'src', 'hot-callers.ts'),
  39. "import { hot } from './hot';\n" +
  40. Array.from({ length: CALLERS }, (_, i) => `export function hotCaller${i}(): number { return hot(${i}); }`).join('\n') +
  41. '\n'
  42. );
  43. fs.writeFileSync(path.join(tmpDir, 'src', 'other-hot.ts'), 'export function hot(n: number): number { return n + 1; }\n');
  44. fs.writeFileSync(path.join(tmpDir, 'src', 'other-hot-callers.ts'),
  45. "import { hot } from './other-hot';\n" +
  46. Array.from({ length: CALLERS }, (_, i) => `export function otherHotCaller${i}(): number { return hot(${i}); }`).join('\n') + '\n');
  47. fs.writeFileSync(
  48. path.join(tmpDir, 'src', 'fan.ts'),
  49. Array.from({ length: CALLERS }, (_, i) => `export function helper${i}(): number { return ${i}; }`).join('\n') +
  50. `\nexport function fanout(): number { return ${Array.from({ length: CALLERS }, (_, i) => `helper${i}()`).join(' + ')}; }\n`
  51. );
  52. cg = CodeGraph.initSync(tmpDir);
  53. await cg.indexAll();
  54. handler = new ToolHandler(cg);
  55. });
  56. afterAll(() => {
  57. cg.destroy();
  58. fs.rmSync(tmpDir, { recursive: true, force: true });
  59. });
  60. describe('codegraph_callers truncation', () => {
  61. it('says how many callers the default limit hid', async () => {
  62. const out = await text('codegraph_callers', { symbol: 'warm' });
  63. // The importing file counts as a caller too, so the total is at least CALLERS.
  64. const m = out.match(/Showing 20 of (\d+) callers; pass `limit`/);
  65. expect(m).not.toBeNull();
  66. expect(Number(m![1])).toBeGreaterThanOrEqual(CALLERS);
  67. expect(out.match(/^- caller\d+ /gm)?.length).toBe(20);
  68. });
  69. it('is silent when the list is complete', async () => {
  70. const out = await text('codegraph_callers', { symbol: 'warm', limit: 100 });
  71. expect(out).not.toContain('Showing');
  72. expect(out.match(/^- caller\d+ /gm)?.length).toBe(CALLERS);
  73. });
  74. it('marks the cut inside each per-definition section too', async () => {
  75. const out = await text('codegraph_callers', { symbol: 'hot' });
  76. expect(out).toContain('2 distinct definitions');
  77. expect(out.match(/- … \+\d+ more \(pass `limit` to widen\)/g)).toHaveLength(2);
  78. expect(await text('codegraph_callers', { symbol: 'hot', limit: 100 })).not.toContain('more (pass');
  79. });
  80. });
  81. describe('codegraph_callees truncation', () => {
  82. it('says how many callees the default limit hid', async () => {
  83. const out = await text('codegraph_callees', { symbol: 'fanout' });
  84. const m = out.match(/Showing 20 of (\d+) callees; pass `limit`/);
  85. expect(m).not.toBeNull();
  86. expect(Number(m![1])).toBe(CALLERS);
  87. });
  88. it('is silent when the list is complete', async () => {
  89. const out = await text('codegraph_callees', { symbol: 'fanout', limit: 100 });
  90. expect(out).not.toContain('Showing');
  91. });
  92. });