probe-named-symbol.mjs 6.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163
  1. #!/usr/bin/env node
  2. /**
  3. * "Did the symbol the agent NAMED actually render?" (CG-38).
  4. *
  5. * This is the measurement the whole CG-24 epic was missing. Every other probe
  6. * here scores the response in AGGREGATE — `probe-suite-envelope.mjs` measures how
  7. * much source came back, `probe-file-spend.mjs` measures whether the bytes went
  8. * to the files that earned them, `probe-allocation.mjs` measures group shares.
  9. * All three are green on a response that returns 25K of source from the right
  10. * file and still omits the one function the agent asked for by name. That is
  11. * exactly what CG-38 was: `queueMessage` at L1087 of a 1,414-line file, whose
  12. * file won rank #1 with 67% of the envelope, never rendered — the agent got a
  13. * same-stem `QueuedMessage` INTERFACE at L70 instead and had to Read the file.
  14. *
  15. * So the assertion here is per-SYMBOL and binary: for each named symbol, does its
  16. * definition line appear in the rendered source? Nothing else can substitute —
  17. * not the file being present, not its share, not its byte count.
  18. *
  19. * Usage (needs a current `npm run build`):
  20. * node scripts/agent-eval/probe-named-symbol.mjs
  21. * node scripts/agent-eval/probe-named-symbol.mjs --verbose
  22. * # any indexed repo, ad hoc:
  23. * node scripts/agent-eval/probe-named-symbol.mjs <repo> "<query>" sym1 sym2
  24. *
  25. * Exit code is 1 when any expected symbol is missing, so this can gate.
  26. */
  27. import { cpSync, mkdtempSync, rmSync, readFileSync, existsSync } from 'node:fs';
  28. import { tmpdir } from 'node:os';
  29. import { join, resolve, dirname } from 'node:path';
  30. import { fileURLToPath, pathToFileURL } from 'node:url';
  31. const HERE = dirname(fileURLToPath(import.meta.url));
  32. const REPO = resolve(HERE, '..', '..');
  33. const load = async (rel) => import(pathToFileURL(resolve(REPO, rel)).href);
  34. const idxMod = await load('dist/index.js');
  35. const toolsMod = await load('dist/mcp/tools.js');
  36. const CodeGraph = idxMod.default?.default ?? idxMod.default ?? idxMod.CodeGraph;
  37. const { ToolHandler } = toolsMod;
  38. /**
  39. * The fixture cases. `symbols` are what the agent names; each must come back with
  40. * its DEFINITION rendered. The queries deliberately cover both shapes the bug was
  41. * reported on — a bare symbol bag and a prose question — because the failure had
  42. * a different cause on each and a fix for one does not imply the other.
  43. */
  44. const FIXTURE = '__tests__/fixtures/tail-render-ts';
  45. const CASES = [
  46. {
  47. id: 'tail-symbol-bag',
  48. why: 'two sibling closures past L1000, named directly; neither calls the other',
  49. query: 'queueMessage flushQueuedMessages',
  50. symbols: ['queueMessage', 'flushQueuedMessages'],
  51. },
  52. {
  53. id: 'tail-prose',
  54. why: 'same two symbols named inside a prose question',
  55. query: 'how does queueMessage hand its entries to flushQueuedMessages',
  56. symbols: ['queueMessage', 'flushQueuedMessages'],
  57. },
  58. {
  59. id: 'tail-with-decoy',
  60. why: 'the same-stem QueuedMessage interface at L70 must not stand in for the functions',
  61. query: 'explain queueMessage, removeQueuedMessage and flushQueuedMessages',
  62. symbols: ['queueMessage', 'removeQueuedMessage', 'flushQueuedMessages'],
  63. },
  64. ];
  65. /** Every `<n>\t<text>` line number present in the response's source blocks. */
  66. function renderedLines(response) {
  67. const out = new Set();
  68. for (const m of response.matchAll(/^(\d+)\t/gm)) out.add(Number(m[1]));
  69. return out;
  70. }
  71. /**
  72. * A symbol counts as rendered only when its DECLARATION line is among the lines
  73. * the response actually sent — not when its name merely appears somewhere (it
  74. * shows up in the section header symbol list and in call sites regardless, which
  75. * is precisely how this defect hid for a whole epic).
  76. */
  77. function check(cg, response, names) {
  78. const lines = renderedLines(response);
  79. return names.map((name) => {
  80. const node = (cg.getNodesByName?.(name) ?? []).find((n) => n.startLine > 0);
  81. return {
  82. name,
  83. file: node?.filePath ?? '(not indexed)',
  84. line: node?.startLine ?? 0,
  85. rendered: !!node && lines.has(node.startLine),
  86. };
  87. });
  88. }
  89. async function runCase(root, { query, symbols }) {
  90. const cg = CodeGraph.openSync(root);
  91. try {
  92. const res = await new ToolHandler(cg).execute('codegraph_explore', { query });
  93. const response = res.content?.[0]?.text ?? '';
  94. return { response, results: check(cg, response, symbols) };
  95. } finally {
  96. try { cg.close?.(); } catch { /* already closed */ }
  97. }
  98. }
  99. const argv = process.argv.slice(2);
  100. const verbose = argv.includes('--verbose');
  101. const positional = argv.filter((a) => !a.startsWith('--'));
  102. let failures = 0;
  103. let checked = 0;
  104. if (positional.length >= 3) {
  105. // Ad-hoc mode: <repo> "<query>" sym...
  106. const [repo, query, ...symbols] = positional;
  107. const { response, results } = await runCase(resolve(repo), { query, symbols });
  108. console.log(`\n${repo}\n query "${query}" · ${response.length} chars\n`);
  109. for (const r of results) {
  110. checked += 1;
  111. if (!r.rendered) failures += 1;
  112. console.log(` ${r.rendered ? 'PASS' : 'FAIL'} ${r.name} ${r.file}:${r.line}`);
  113. }
  114. } else {
  115. const src = join(REPO, FIXTURE);
  116. if (!existsSync(src)) {
  117. console.error(`fixture missing: ${src}`);
  118. process.exit(2);
  119. }
  120. const dir = mkdtempSync(join(tmpdir(), 'cg-named-'));
  121. try {
  122. cpSync(src, dir, { recursive: true });
  123. rmSync(join(dir, '.codegraph'), { recursive: true, force: true });
  124. const cg = CodeGraph.initSync(dir);
  125. await cg.indexAll();
  126. cg.close?.();
  127. console.log(`\ntail-render-ts · agent-named symbols must render\n`);
  128. for (const c of CASES) {
  129. const { response, results } = await runCase(dir, c);
  130. console.log(`── ${c.id} — ${c.why}`);
  131. console.log(` query "${c.query}"`);
  132. console.log(` response ${response.length.toLocaleString()} chars`);
  133. for (const r of results) {
  134. checked += 1;
  135. if (!r.rendered) failures += 1;
  136. console.log(` ${r.rendered ? 'PASS' : 'FAIL'} ${r.name} defined at ${r.file}:${r.line}`
  137. + (r.rendered ? '' : ' — DEFINITION NOT IN RESPONSE'));
  138. }
  139. if (verbose && failures) {
  140. const spans = [...renderedLines(response)].sort((a, b) => a - b);
  141. console.log(` rendered lines: ${spans[0]}..${spans[spans.length - 1]} (${spans.length} lines)`);
  142. }
  143. console.log();
  144. }
  145. } finally {
  146. rmSync(dir, { recursive: true, force: true });
  147. }
  148. }
  149. console.log(failures === 0
  150. ? `Every agent-named symbol rendered (${checked} checked).`
  151. : `${failures} of ${checked} agent-named symbols did NOT render.`);
  152. process.exit(failures === 0 ? 0 : 1);