explore-nl-stopword-collision.test.ts 5.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125
  1. /**
  2. * codegraph_explore — NL-stopword collision guard (named-symbol seeding).
  3. *
  4. * handleExplore's named-symbol seeding treats every identifier-shaped query
  5. * token as "a symbol the agent named" and grants its definition the
  6. * named-FIRST sort tier. But explore also takes natural-language questions,
  7. * whose ordinary English words collide with real callables: on this repo the
  8. * query "…check the latest version…" exact-matched the lone `check()` method
  9. * of an unrelated WAL-valve class, which then outranked (and, within the
  10. * per-repo file budget, fully displaced) the upgrade module the corroboration
  11. * ranking had correctly scored — so the agent fell back to Read/Grep.
  12. *
  13. * The guard: a shape-precise token (camelCase, PascalCase, snake_case,
  14. * qualified) seeds unconditionally — it is an unambiguous symbol reference.
  15. * A BARE lowercase word seeds only definitions whose file another query token
  16. * co-names (that other token is itself a symbol defined in the same file, the
  17. * "check drain fire" sibling-bag shape) — which an incidental English-word
  18. * collision never is.
  19. */
  20. import { describe, it, expect, beforeEach, afterEach } from 'vitest';
  21. import * as fs from 'fs';
  22. import * as path from 'path';
  23. import * as os from 'os';
  24. import CodeGraph from '../src/index';
  25. import { ToolHandler } from '../src/mcp/tools';
  26. /** Paths explore rendered as full-body ``**`<path>`** —`` source sections, in order. */
  27. function sourcedFiles(text: string): string[] {
  28. const out: string[] = [];
  29. for (const line of text.split('\n')) {
  30. const m = line.match(/^\*\*`(.+?)`\*\* —/);
  31. if (m) out.push(m[1].trim());
  32. }
  33. return out;
  34. }
  35. describe('codegraph_explore — NL-stopword collision guard', () => {
  36. let testDir: string;
  37. let cg: CodeGraph;
  38. let handler: ToolHandler;
  39. beforeEach(async () => {
  40. testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-stopword-'));
  41. // --- The collision file: an unrelated class whose methods are ordinary
  42. // English words ("check", "drain", "fire" — the only defs of those names).
  43. // Substantive bodies + an internal call mesh so it isn't skipped as a stub.
  44. const dbDir = path.join(testDir, 'src', 'db');
  45. fs.mkdirSync(dbDir, { recursive: true });
  46. fs.writeFileSync(path.join(dbDir, 'valve.ts'),
  47. `export class Valve {\n` +
  48. ` private open = false;\n` +
  49. ` check(): void {\n` +
  50. ` if (this.open) {\n` +
  51. ` this.fire();\n` +
  52. ` }\n` +
  53. ` }\n` +
  54. ` fire(): void {\n` +
  55. ` this.drain();\n` +
  56. ` this.open = false;\n` +
  57. ` }\n` +
  58. ` drain(): void {\n` +
  59. ` this.open = true;\n` +
  60. ` }\n` +
  61. `}\n`);
  62. // --- The answer file: the upgrade module the query is actually about.
  63. // Its path and symbol names match the query's topic terms (upgrade,
  64. // latest, version), so the corroboration ranking scores it — the bug was
  65. // the collision file's named tier sorting ABOVE it anyway.
  66. const upDir = path.join(testDir, 'src', 'upgrade');
  67. fs.mkdirSync(upDir, { recursive: true });
  68. fs.writeFileSync(path.join(upDir, 'updater.ts'),
  69. `export function normalizeVersion(v: string): string {\n` +
  70. ` return v.startsWith('v') ? v : 'v' + v;\n` +
  71. `}\n` +
  72. `export function resolveLatestVersion(): string {\n` +
  73. ` return normalizeVersion('9.9.9');\n` +
  74. `}\n` +
  75. `export function runUpgrade(): string {\n` +
  76. ` const latest = resolveLatestVersion();\n` +
  77. ` return latest;\n` +
  78. `}\n`);
  79. cg = CodeGraph.initSync(testDir, { config: { include: ['**/*.ts'], exclude: [] } });
  80. await cg.indexAll();
  81. handler = new ToolHandler(cg);
  82. });
  83. afterEach(() => {
  84. if (cg) cg.destroy();
  85. if (fs.existsSync(testDir)) fs.rmSync(testDir, { recursive: true, force: true });
  86. });
  87. async function explore(query: string): Promise<string> {
  88. const res = await handler.execute('codegraph_explore', { query });
  89. expect(res.isError).toBeFalsy();
  90. return res.content[0]!.text;
  91. }
  92. it('a bare English word ("check") does not tier its namesake above the corroborated answer file', async () => {
  93. const text = await explore('how does the upgrade flow check the latest version');
  94. const files = sourcedFiles(text);
  95. const updater = files.findIndex((f) => f.endsWith('updater.ts'));
  96. const valve = files.findIndex((f) => f.endsWith('valve.ts'));
  97. // The upgrade module must render, and must rank above the collision file
  98. // (pre-guard, valve.ts held the named-FIRST tier and sorted on top).
  99. expect(updater).toBeGreaterThanOrEqual(0);
  100. if (valve !== -1) expect(updater).toBeLessThan(valve);
  101. });
  102. it('a sibling bag of bare words ("check drain fire") still tiers their shared file first', async () => {
  103. const text = await explore('check drain fire');
  104. const files = sourcedFiles(text);
  105. // Every token is co-named by the others in valve.ts — genuine bare-name
  106. // symbol bags must keep the named tier.
  107. expect(files[0]).toMatch(/valve\.ts$/);
  108. });
  109. it('a shape-precise token (camelCase) seeds unconditionally', async () => {
  110. const text = await explore('runUpgrade');
  111. const files = sourcedFiles(text);
  112. expect(files[0]).toMatch(/updater\.ts$/);
  113. });
  114. });