no-silent-fuzzy-symbol.test.ts 8.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217
  1. /**
  2. * #1473 — callers/callees/impact must not silently answer for a different
  3. * symbol when the requested name has no exact match (or has an exact match
  4. * with zero callers). Fuzzy FTS hits may appear only as did-you-mean hints.
  5. */
  6. import { describe, it, expect, beforeAll, beforeEach, afterEach } from 'vitest';
  7. import { execFileSync } from 'child_process';
  8. import * as fs from 'fs';
  9. import * as path from 'path';
  10. import * as os from 'os';
  11. import { initGrammars, loadAllGrammars } from '../src/extraction/grammars';
  12. const BIN = path.resolve(__dirname, '../dist/bin/codegraph.js');
  13. beforeAll(async () => {
  14. await initGrammars();
  15. await loadAllGrammars();
  16. });
  17. function hasSqliteBindings(): boolean {
  18. try {
  19. const { DatabaseSync } = require('node:sqlite');
  20. const db = new DatabaseSync(':memory:');
  21. db.close();
  22. return true;
  23. } catch {
  24. return false;
  25. }
  26. }
  27. const HAS_SQLITE = hasSqliteBindings();
  28. function tmpRoot(prefix: string): string {
  29. return fs.mkdtempSync(path.join(os.tmpdir(), prefix));
  30. }
  31. function rmTree(dir: string): void {
  32. if (fs.existsSync(dir)) fs.rmSync(dir, { recursive: true, force: true });
  33. }
  34. function runCli(args: string[], cwd: string): { stdout: string; status: number } {
  35. try {
  36. const stdout = execFileSync(process.execPath, [BIN, ...args], {
  37. cwd,
  38. encoding: 'utf-8',
  39. env: {
  40. ...process.env,
  41. CODEGRAPH_NO_DAEMON: '1',
  42. CODEGRAPH_TELEMETRY: '0',
  43. },
  44. stdio: ['ignore', 'pipe', 'pipe'],
  45. });
  46. return { stdout, status: 0 };
  47. } catch (err: unknown) {
  48. const e = err as { stdout?: string; status?: number };
  49. return { stdout: e.stdout ?? '', status: typeof e.status === 'number' ? e.status : 1 };
  50. }
  51. }
  52. describe.skipIf(!HAS_SQLITE)('no silent fuzzy substitution (#1473) — MCP', () => {
  53. let projectRoot: string;
  54. let cg: any;
  55. let handler: any;
  56. beforeEach(async () => {
  57. projectRoot = tmpRoot('codegraph-1473-mcp-');
  58. const src = path.join(projectRoot, 'src', 'a', 'b', 'c');
  59. fs.mkdirSync(src, { recursive: true });
  60. fs.writeFileSync(
  61. path.join(src, 'D.java'),
  62. `package a.b.c;\n\npublic class D {\n public void e() { System.out.println("e"); }\n public void ef() { System.out.println("ef"); }\n}\n`
  63. );
  64. fs.writeFileSync(
  65. path.join(src, 'Caller.java'),
  66. `package a.b.c;\n\npublic class Caller {\n public void callsEfOnly() { D d = new D(); d.ef(); }\n public void alsoCallsEf() { D d = new D(); d.ef(); }\n}\n`
  67. );
  68. // Case-differing pair: exact Fetch has 0 callers; lowercase fetch has callers.
  69. fs.writeFileSync(
  70. path.join(projectRoot, 'src', 'Fetch.cs'),
  71. `public class Torture {\n public void Fetch() {}\n}\n`
  72. );
  73. fs.writeFileSync(
  74. path.join(projectRoot, 'src', 'fetch.py'),
  75. `def fetch():\n return 1\n\ndef load():\n return fetch()\n`
  76. );
  77. const CodeGraph = (await import('../src/index')).default;
  78. const { ToolHandler } = await import('../src/mcp/tools');
  79. cg = CodeGraph.initSync(projectRoot);
  80. await cg.indexAll();
  81. handler = new ToolHandler(cg);
  82. });
  83. afterEach(() => {
  84. handler?.closeAll();
  85. cg?.destroy();
  86. rmTree(projectRoot);
  87. });
  88. async function text(tool: string, args: Record<string, unknown>): Promise<string> {
  89. const res = await handler.execute(tool, args);
  90. return res.content?.[0]?.text ?? '';
  91. }
  92. it('callers: missing name is not found (with did-you-mean), not a fuzzy hit labelled as the typed name', async () => {
  93. const out = await text('codegraph_callers', { symbol: 'Calls' });
  94. expect(out).toMatch(/Symbol "Calls" not found/);
  95. expect(out).toMatch(/Did you mean:/);
  96. expect(out).not.toMatch(/Callees of Calls|Callers of Calls/);
  97. expect(out).not.toMatch(/\bef\b/);
  98. });
  99. it('callees: missing name does not return another method\'s callees', async () => {
  100. const out = await text('codegraph_callees', { symbol: 'Calls' });
  101. expect(out).toMatch(/Symbol "Calls" not found/);
  102. expect(out).not.toContain('Callees of Calls');
  103. });
  104. it('impact: missing prefix does not substitute a longer name', async () => {
  105. const out = await text('codegraph_impact', { symbol: 'callsEf' });
  106. expect(out).toMatch(/Symbol "callsEf" not found/);
  107. expect(out).toMatch(/Did you mean:.*callsEfOnly/);
  108. // Suggestion only — must not claim impact results for the mistyped name.
  109. expect(out).not.toMatch(/Impact:|"callsEf" affects|affected/);
  110. });
  111. it('callers: exact name with zero callers stays empty (no case-sibling substitution)', async () => {
  112. const out = await text('codegraph_callers', { symbol: 'Fetch' });
  113. expect(out).toMatch(/No callers found for "Fetch"/);
  114. expect(out).not.toContain('load');
  115. });
  116. it('callers: real exact name still resolves', async () => {
  117. const out = await text('codegraph_callers', { symbol: 'ef' });
  118. expect(out).toContain('Callers of ef');
  119. expect(out).toContain('callsEfOnly');
  120. expect(out).toContain('alsoCallsEf');
  121. });
  122. it('findAllSymbols returns no nodes for a fuzzy-only hit', async () => {
  123. const findAllSymbols = (handler as any).findAllSymbols.bind(handler);
  124. const all = findAllSymbols(cg, 'Calls');
  125. expect(all.nodes).toEqual([]);
  126. expect(all.note).toMatch(/Did you mean:/);
  127. });
  128. });
  129. describe.skipIf(!HAS_SQLITE || !fs.existsSync(BIN))('no silent fuzzy substitution (#1473) — CLI', () => {
  130. let projectRoot: string;
  131. beforeEach(async () => {
  132. projectRoot = tmpRoot('codegraph-1473-cli-');
  133. const src = path.join(projectRoot, 'src', 'a', 'b', 'c');
  134. fs.mkdirSync(src, { recursive: true });
  135. fs.writeFileSync(
  136. path.join(src, 'D.java'),
  137. `package a.b.c;\n\npublic class D {\n public void e() { System.out.println("e"); }\n public void ef() { System.out.println("ef"); }\n}\n`
  138. );
  139. fs.writeFileSync(
  140. path.join(src, 'Caller.java'),
  141. `package a.b.c;\n\npublic class Caller {\n public void callsEfOnly() { D d = new D(); d.ef(); }\n public void alsoCallsEf() { D d = new D(); d.ef(); }\n}\n`
  142. );
  143. fs.writeFileSync(
  144. path.join(projectRoot, 'src', 'Fetch.cs'),
  145. `public class Torture {\n public void Fetch() {}\n}\n`
  146. );
  147. fs.writeFileSync(
  148. path.join(projectRoot, 'src', 'fetch.py'),
  149. `def fetch():\n return 1\n\ndef load():\n return fetch()\n`
  150. );
  151. const CodeGraph = (await import('../src/index')).default;
  152. const cg = CodeGraph.initSync(projectRoot);
  153. await cg.indexAll();
  154. cg.close();
  155. });
  156. afterEach(() => {
  157. rmTree(projectRoot);
  158. });
  159. it('callers: fuzzy-only name → not found with did-you-mean (JSON stays empty)', () => {
  160. const { stdout } = runCli(['callers', 'Calls', '--json'], projectRoot);
  161. // JSON path is only taken on a successful resolve; not-found prints info text.
  162. expect(stdout).toMatch(/Symbol "Calls" not found/);
  163. expect(stdout).toMatch(/did you mean/i);
  164. expect(stdout).not.toMatch(/"name":\s*"ef"/);
  165. });
  166. it('callees: fuzzy-only name → not found', () => {
  167. const { stdout } = runCli(['callees', 'Calls', '--json'], projectRoot);
  168. expect(stdout).toMatch(/Symbol "Calls" not found/);
  169. expect(stdout).not.toMatch(/"name":\s*"ef"/);
  170. });
  171. it('impact: prefix of a real name → not found', () => {
  172. const { stdout } = runCli(['impact', 'callsEf', '--json'], projectRoot);
  173. expect(stdout).toMatch(/Symbol "callsEf" not found/);
  174. expect(stdout).toMatch(/did you mean:.*callsEfOnly/i);
  175. // Not a successful JSON impact payload for the fuzzy hit.
  176. expect(stdout).not.toMatch(/"affected"\s*:/);
  177. expect(stdout).not.toMatch(/"symbol":\s*"callsEf"/);
  178. });
  179. it('callers: exact Fetch with zero callers → empty list, not fetch\'s callers', () => {
  180. const { stdout } = runCli(['callers', 'Fetch', '--json'], projectRoot);
  181. expect(stdout).toContain('"symbol": "Fetch"');
  182. expect(stdout).toMatch(/"callers":\s*\[\s*\]/);
  183. expect(stdout).not.toContain('load');
  184. });
  185. it('callers: exact ef still lists real callers', () => {
  186. const { stdout } = runCli(['callers', 'ef', '--json'], projectRoot);
  187. expect(stdout).toContain('callsEfOnly');
  188. expect(stdout).toContain('alsoCallsEf');
  189. });
  190. });