cli-definition-grouping.test.ts 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262
  1. /** CLI parity with MCP definition grouping and file narrowing (#1512, #1656). */
  2. import { afterAll, beforeAll, describe, expect, it } from 'vitest';
  3. import { spawnSync } from 'child_process';
  4. import * as fs from 'fs';
  5. import * as os from 'os';
  6. import * as path from 'path';
  7. import { CodeGraph } from '../src';
  8. import { ToolHandler } from '../src/mcp/tools';
  9. import { lookupSymbolNodes } from '../src/graph/symbol-lookup';
  10. const BIN = path.resolve(__dirname, '../dist/bin/codegraph.js');
  11. const COMMANDS = ['callers', 'callees', 'impact'] as const;
  12. type Command = typeof COMMANDS[number];
  13. let projectRoot: string;
  14. let cg: CodeGraph;
  15. let handler: ToolHandler;
  16. function runCli(command: Command, symbol = 'handle', args: string[] = []) {
  17. return spawnSync(process.execPath, [BIN, command, '-p', projectRoot, ...args, '--', symbol], {
  18. encoding: 'utf-8',
  19. env: { ...process.env, CODEGRAPH_NO_DAEMON: '1', CODEGRAPH_WASM_RELAUNCHED: '1', NO_COLOR: '1' },
  20. timeout: 30_000,
  21. });
  22. }
  23. function json(command: Command, symbol = 'handle', args: string[] = []) {
  24. const result = runCli(command, symbol, [...args, '--json']);
  25. expect(result.status, result.stderr).toBe(0);
  26. return JSON.parse(result.stdout);
  27. }
  28. function resultKey(command: Command) {
  29. return command === 'impact' ? 'affected' : command;
  30. }
  31. function write(file: string, source: string) {
  32. const absolute = path.join(projectRoot, file);
  33. fs.mkdirSync(path.dirname(absolute), { recursive: true });
  34. fs.writeFileSync(absolute, source);
  35. }
  36. beforeAll(async () => {
  37. projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-cli-1512-'));
  38. for (const [dir, helper] of [['a', 'alpha'], ['b', 'beta']]) {
  39. write(`${dir}/${helper}.js`, `export function ${helper}() { return 1; }\n`);
  40. write(`${dir}/svc.js`, `import { ${helper} } from './${helper}.js';\nexport function handle() { return ${helper}(); }\n`);
  41. write(`${dir}/main.js`, `import { handle } from './svc.js';\nexport function ${dir}Main() { return handle(); }\nexport function ${dir}Entry() { return ${dir}Main(); }\n`);
  42. write(`${dir}/work.js`, `import { shared } from '../shared.js';\nimport { ${helper} } from './${helper}.js';\nexport function work() { shared(); return ${helper}(); }\n`);
  43. write(`${dir}/work-caller.js`, `import { work } from './work.js';\nexport function ${dir}Worker() { work(); }\n`);
  44. }
  45. write('shared.js', 'export function shared() {}\n');
  46. write('both.js', "import { work as aWork } from './a/work.js';\nimport { work as bWork } from './b/work.js';\nexport function both() { aWork(); bWork(); }\n");
  47. write('quiet-a.js', 'export function quiet() {}\n');
  48. write('quiet-b.js', "import { alpha } from './a/alpha.js';\nexport function quiet() { alpha(); }\nexport function wake() { quiet(); }\n");
  49. write('scopes.ts', [
  50. 'function leftOnly() {}',
  51. 'function rightOnly() {}',
  52. 'export class Left { run() { leftOnly(); } }',
  53. 'export class Right { run() { rightOnly(); } }',
  54. ].join('\n'));
  55. // Java overloads have separate bodies/nodes; TS signature-only overloads are
  56. // intentionally skipped by extraction, so they cannot exercise grouping.
  57. write('Overloads.java', [
  58. 'public class Overloads {',
  59. ' static String stringIdentity(String value) { return value; }',
  60. ' static int intIdentity(int value) { return value; }',
  61. ' public static String convert(String value) { return stringIdentity(value); }',
  62. ' public static int convert(int value) { return intIdentity(value); }',
  63. ' public static void convertCaller() { convert(1); convert("value"); }',
  64. '}',
  65. ].join('\n'));
  66. for (let i = 0; i < 55; i++) {
  67. write(`crowd/def-${i}.js`, "import { shared } from '../shared.js';\nexport function crowded() { shared(); }\n");
  68. }
  69. cg = CodeGraph.initSync(projectRoot);
  70. await cg.indexAll();
  71. handler = new ToolHandler(cg);
  72. }, 30_000);
  73. afterAll(() => {
  74. handler?.closeAll();
  75. cg?.close();
  76. if (projectRoot) fs.rmSync(projectRoot, { recursive: true, force: true });
  77. });
  78. describe.each(COMMANDS)('%s definition grouping (#1512)', (command) => {
  79. it('attributes every result and graph edge to its definition in JSON', () => {
  80. const out = json(command);
  81. expect(out.ambiguous).toBe(true);
  82. expect(out.aggregation).toBe('union');
  83. expect(out.definitions).toHaveLength(2);
  84. const key = resultKey(command);
  85. for (const [dir, other] of [['a', 'b'], ['b', 'a']]) {
  86. const group = out.definitions.find((d: any) => d.definition.filePath === `${dir}/svc.js`);
  87. expect(group.definition).toMatchObject({ name: 'handle', kind: 'function', startLine: 2 });
  88. expect(group.roots).toHaveLength(1);
  89. expect(group[key].length).toBeGreaterThan(0);
  90. expect(group[key].every((n: any) => n.filePath.startsWith(`${dir}/`))).toBe(true);
  91. expect(JSON.stringify(group)).not.toContain(`"filePath":"${other}/`);
  92. const actual = cg.getNodesByName('handle').find(n => n.filePath === `${dir}/svc.js`)!;
  93. const expectedNodes = command === 'impact'
  94. ? [...cg.getImpactRadius(actual.id, 2).nodes.values()]
  95. : cg[command === 'callers' ? 'getCallers' : 'getCallees'](actual.id).map(c => c.node);
  96. expect(new Set(group[key].map((n: any) => n.id))).toEqual(new Set(expectedNodes.map(n => n.id)));
  97. const ids = new Set([...group.roots, ...group[key].map((n: any) => n.id)]);
  98. expect(group.edges.length).toBeGreaterThan(0);
  99. for (const edge of group.edges) {
  100. expect(ids.has(edge.source)).toBe(true);
  101. expect(ids.has(edge.target)).toBe(true);
  102. }
  103. }
  104. });
  105. it('prints each definition above only its own results', () => {
  106. const result = runCli(command);
  107. expect(result.status, result.stderr).toBe(0);
  108. expect(result.stdout).toContain('2 distinct definitions');
  109. expect(result.stdout).toContain('--file');
  110. const sections = result.stdout.split(/(?=function handle \(javascript\) — [ab]\/svc\.js:2)/).slice(1);
  111. expect(sections).toHaveLength(2);
  112. for (const section of sections) {
  113. const dir = section.includes('— a/svc.js:2') ? 'a' : 'b';
  114. expect(section).toContain(command === 'callees' ? `${dir}/${dir === 'a' ? 'alpha' : 'beta'}.js` : `${dir}/main.js`);
  115. expect(section).not.toContain(dir === 'a' ? 'b/' : 'a/');
  116. }
  117. });
  118. it.each(['a/svc.js', './a/svc.js'])('--file %s selects the same definition as MCP', async (file) => {
  119. const out = json(command, 'handle', ['--file', file]);
  120. expect(out.definitions).toHaveLength(1);
  121. expect(out.definitions[0].definition.filePath).toBe('a/svc.js');
  122. expect(out.targets.every((n: any) => n.filePath === 'a/svc.js')).toBe(true);
  123. expect(out.ambiguous).toBe(false);
  124. expect(out.filteredOut).toBe(false);
  125. expect(out[resultKey(command)].every((n: any) => n.filePath.startsWith('a/'))).toBe(true);
  126. const human = runCli(command, 'handle', ['--file', file]).stdout;
  127. const mcp = (await handler.execute(`codegraph_${command}`, { symbol: 'handle', file })).content[0]?.text ?? '';
  128. for (const text of [human, mcp]) {
  129. expect(text).not.toContain('b/');
  130. expect(text).not.toContain('distinct definitions');
  131. expect(text).toContain(command === 'callees' ? 'a/alpha.js' : 'a/main.js');
  132. }
  133. });
  134. it('a suffix matching both files keeps both definitions', () => {
  135. const out = json(command, 'handle', ['-f', 'svc.js']);
  136. expect(out.definitions).toHaveLength(2);
  137. expect(out.filteredOut).toBe(false);
  138. });
  139. it('a non-matching file discloses the fallback in JSON and text', async () => {
  140. const note = 'no definition of "handle" matches file "missing.js" — showing all definitions instead.';
  141. const out = json(command, 'handle', ['--file', 'missing.js']);
  142. expect(out.filteredOut).toBe(true);
  143. expect(out.note).toBe(note);
  144. expect(out.definitions).toHaveLength(2);
  145. expect(runCli(command, 'handle', ['--file', 'missing.js']).stdout).toContain(note);
  146. const mcp = await handler.execute(`codegraph_${command}`, { symbol: 'handle', file: 'missing.js' });
  147. expect(mcp.content[0]?.text).toContain(note);
  148. });
  149. it('keeps same-file overloads together as MCP does', () => {
  150. const out = json(command, 'convert');
  151. expect(cg.getNodesByName('convert').length).toBeGreaterThan(1);
  152. expect(out.definitions).toHaveLength(1);
  153. expect(out.definitions[0].roots.length).toBeGreaterThan(1);
  154. expect(out.ambiguous).toBe(false);
  155. expect(lookupSymbolNodes(cg, 'convert').ambiguous).toBe(false);
  156. expect(out.definitions[0][resultKey(command)].length).toBeGreaterThan(0);
  157. });
  158. it('does not substitute another definition for an unknown qualified name', () => {
  159. const out = runCli(command, 'Missing.run');
  160. expect(out.status, out.stderr).toBe(0);
  161. expect(out.stdout).toContain('Symbol "Missing.run" not found');
  162. expect(out.stdout).not.toContain('leftOnly');
  163. expect(out.stdout).not.toContain('rightOnly');
  164. });
  165. });
  166. describe('CLI definition boundaries and limits', () => {
  167. it('separates different qualified names within the same file', () => {
  168. const out = json('callees', 'run', ['--file', 'scopes.ts']);
  169. expect(out.definitions).toHaveLength(2);
  170. for (const name of ['Left', 'Right']) {
  171. const group = out.definitions.find((d: any) => d.definition.qualifiedName === `${name}::run`);
  172. expect(group.callees.map((n: any) => n.name)).toEqual([`${name.toLowerCase()}Only`]);
  173. }
  174. const qualified = json('callees', 'Left.run');
  175. expect(qualified.definitions).toHaveLength(1);
  176. expect(qualified.callees.map((n: any) => n.name)).toEqual(['leftOnly']);
  177. });
  178. it.each(['callers', 'callees'] as const)('%s includes definitions with no edges', (command) => {
  179. const out = json(command, 'quiet');
  180. expect(out.definitions).toHaveLength(2);
  181. const empty = out.definitions.find((d: any) => d.definition.filePath === 'quiet-a.js');
  182. expect(empty[command]).toEqual([]);
  183. expect(empty.edges).toEqual([]);
  184. expect(empty).toMatchObject({ total: 0, limit: 20, truncated: false });
  185. expect(runCli(command, 'quiet').stdout).toContain(`(no ${command})`);
  186. });
  187. it('keeps shared callers and callees in each definition instead of deduplicating across them', () => {
  188. for (const command of ['callers', 'callees'] as const) {
  189. const out = json(command, 'work');
  190. expect(out.definitions).toHaveLength(2);
  191. for (const group of out.definitions) {
  192. expect(group[command].map((n: any) => n.name)).toContain(command === 'callers' ? 'both' : 'shared');
  193. }
  194. expect(out[command].filter((n: any) => n.name === (command === 'callers' ? 'both' : 'shared'))).toHaveLength(1);
  195. }
  196. });
  197. it.each(['callers', 'callees'] as const)('%s preserves union metadata and limits each definition independently', (command) => {
  198. // Callers include the importing file nodes as well as the calling functions.
  199. const total = command === 'callers' ? 6 : 3;
  200. const perDefinition = command === 'callers' ? 4 : 2;
  201. const out = json(command, 'work', ['--limit', '1']);
  202. expect(out).toMatchObject({ total, limit: 1, truncated: true });
  203. expect(out[command]).toHaveLength(1);
  204. for (const group of out.definitions) {
  205. expect(group).toMatchObject({ total: perDefinition, limit: 1, truncated: true });
  206. expect(group[command]).toHaveLength(1);
  207. expect(group.edges).toHaveLength(1);
  208. expect(group.edges[0][command === 'callers' ? 'source' : 'target']).toBe(group[command][0].id);
  209. }
  210. const human = runCli(command, 'work', ['--limit', '1']).stdout;
  211. expect(human.split(`Showing 1 of ${perDefinition}; pass --limit to widen.`)).toHaveLength(3);
  212. const complete = json(command, 'work', ['--limit', '100']);
  213. expect(complete).toMatchObject({ total, limit: 100, truncated: false });
  214. for (const group of complete.definitions) {
  215. expect(group).toMatchObject({ total: perDefinition, limit: 100, truncated: false });
  216. expect(group[command]).toHaveLength(perDefinition);
  217. }
  218. });
  219. it('applies impact depth within each definition and reports its own graph counts', () => {
  220. for (const depth of [1, 2]) {
  221. const out = json('impact', 'handle', ['--depth', String(depth)]);
  222. expect(out.depth).toBe(depth);
  223. // Each root also has an importing file node at depth one.
  224. expect(out.nodeCount).toBe(2 * (depth + 2));
  225. expect(out.edgeCount).toBe(2 * (depth + 1));
  226. for (const group of out.definitions) {
  227. expect(group.nodeCount).toBe(depth + 2);
  228. expect(group.affected).toHaveLength(group.nodeCount);
  229. expect(group.edgeCount).toBe(depth + 1);
  230. expect(group.edges).toHaveLength(group.edgeCount);
  231. }
  232. }
  233. });
  234. it('enumerates definitions beyond the FTS cap and can narrow to any of them', () => {
  235. const out = json('callees', 'crowded');
  236. expect(out.definitions).toHaveLength(55);
  237. for (const group of out.definitions) expect(group.callees.map((n: any) => n.name)).toEqual(['shared']);
  238. const narrowed = json('callees', 'crowded', ['--file', 'crowd/def-54.js']);
  239. expect(narrowed.definitions).toHaveLength(1);
  240. expect(narrowed.definitions[0].definition.filePath).toBe('crowd/def-54.js');
  241. });
  242. });