probe-factory-closure.mjs 6.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144
  1. #!/usr/bin/env node
  2. /**
  3. * CG-27 measurement probe — what a factory-closure file actually delivers.
  4. *
  5. * `probe-allocation.mjs` measures how the envelope is split BETWEEN files. This
  6. * one measures what comes back from WITHIN one file whose top-level symbol spans
  7. * almost all of it: a `createFoo()` factory returning an object of closures
  8. * (Svelte 5 rune stores, React hook modules, Zustand `create((set,get)=>({…}))`,
  9. * IIFE module-pattern JS). The claim under test is a ranking one, not a byte one
  10. * — CG-30 already bounds the bytes — so the number that matters is WHICH inner
  11. * symbols reach the agent, not how many chars did.
  12. *
  13. * Prints, for the factory file: every line range the response delivered, and for
  14. * each inner function whether its DEFINITION LINE is inside one of them.
  15. *
  16. * Usage (needs a current `npm run build`):
  17. * node scripts/agent-eval/probe-factory-closure.mjs
  18. * node scripts/agent-eval/probe-factory-closure.mjs --json
  19. * node scripts/agent-eval/probe-factory-closure.mjs --query "..."
  20. */
  21. import { cpSync, mkdtempSync, readFileSync, rmSync, existsSync } from 'node:fs';
  22. import { tmpdir } from 'node:os';
  23. import { dirname, join, resolve } from 'node:path';
  24. import { fileURLToPath, pathToFileURL } from 'node:url';
  25. const HERE = dirname(fileURLToPath(import.meta.url));
  26. const REPO_ROOT = resolve(HERE, '../..');
  27. const FIXTURE = join(REPO_ROOT, '__tests__/fixtures/factory-closure-ts');
  28. const TARGET = 'src/stores/dashboard-store.ts';
  29. const argv = process.argv.slice(2);
  30. const asJson = argv.includes('--json');
  31. const queryAt = argv.indexOf('--query');
  32. const QUERY = queryAt >= 0
  33. ? argv[queryAt + 1]
  34. : 'how does the dashboard store refresh its metrics and apply a filter';
  35. const say = (s = '') => { if (!asJson) console.log(s); };
  36. const num = (n) => Math.round(n).toLocaleString('en-US');
  37. const load = (rel) => import(pathToFileURL(resolve(REPO_ROOT, rel)).href);
  38. if (!existsSync(join(REPO_ROOT, 'dist/index.js'))) {
  39. console.error('dist/ not built — run `npm run build` first.');
  40. process.exit(2);
  41. }
  42. const idxMod = await load('dist/index.js');
  43. const toolsMod = await load('dist/mcp/tools.js');
  44. const CodeGraph = idxMod.default?.default ?? idxMod.default ?? idxMod.CodeGraph;
  45. const ToolHandler = toolsMod.ToolHandler ?? toolsMod.default?.ToolHandler;
  46. const dir = mkdtempSync(join(tmpdir(), 'cg-factory-'));
  47. cpSync(FIXTURE, dir, { recursive: true });
  48. rmSync(join(dir, '.codegraph'), { recursive: true, force: true });
  49. let out;
  50. try {
  51. let cg = CodeGraph.initSync(dir);
  52. await cg.indexAll();
  53. // Inner function definitions, straight from the index — the symbols the file's
  54. // enclosing factory range would otherwise swallow.
  55. const nodes = cg.getNodesInFile(TARGET);
  56. const factory = nodes.find((n) => n.name === 'createDashboardStore');
  57. const inner = nodes
  58. .filter((n) => (n.kind === 'function' || n.kind === 'method')
  59. && n.name !== 'createDashboardStore'
  60. && factory && n.startLine > factory.startLine && n.endLine <= factory.endLine)
  61. .sort((a, b) => a.startLine - b.startLine);
  62. cg.close?.();
  63. const sidecar = join(dir, 'diag.jsonl');
  64. process.env.CODEGRAPH_EXPLORE_DEBUG = sidecar;
  65. cg = CodeGraph.openSync(dir);
  66. const res = await new ToolHandler(cg).execute('codegraph_explore', { query: QUERY });
  67. const text = res.content?.[0]?.text ?? '';
  68. cg.close?.();
  69. delete process.env.CODEGRAPH_EXPLORE_DEBUG;
  70. const report = JSON.parse(readFileSync(sidecar, 'utf8').trim().split('\n').pop());
  71. // Which source lines of the target file the response actually carries. The
  72. // response numbers every delivered line `<n>\t<text>`; match them back against
  73. // the file so a line number that merely appears in prose can't count.
  74. const source = readFileSync(join(dir, TARGET), 'utf8').split('\n');
  75. const delivered = new Set();
  76. for (const line of text.split('\n')) {
  77. const m = /^(\d+)\t(.*)$/.exec(line);
  78. if (!m) continue;
  79. const n = Number(m[1]);
  80. if (n >= 1 && n <= source.length && source[n - 1] === m[2]) delivered.add(n);
  81. }
  82. // Collapse to ranges for display.
  83. const ranges = [];
  84. for (const n of [...delivered].sort((a, b) => a - b)) {
  85. const last = ranges[ranges.length - 1];
  86. if (last && n === last.end + 1) last.end = n;
  87. else ranges.push({ start: n, end: n });
  88. }
  89. const covered = (n) => delivered.has(n.startLine);
  90. const rec = report.files.find((f) => f.path === TARGET) ?? null;
  91. out = {
  92. query: QUERY,
  93. target: TARGET,
  94. fileLines: source.length,
  95. factory: factory ? { name: factory.name, start: factory.startLine, end: factory.endLine } : null,
  96. file: rec && {
  97. rank: rec.rank, render: rec.render, clipped: rec.clipped,
  98. emittedChars: rec.emittedChars, finalChars: rec.finalChars,
  99. allowance: rec.allowance, spendable: rec.spendable, skipped: rec.skipped,
  100. },
  101. deliveredRanges: ranges,
  102. deliveredLines: delivered.size,
  103. inner: inner.map((n) => ({ name: n.name, start: n.startLine, end: n.endLine, delivered: covered(n) })),
  104. innerDelivered: inner.filter(covered).length,
  105. innerTotal: inner.length,
  106. envelope: report.envelope,
  107. allFiles: report.files
  108. .filter((f) => f.emittedChars > 0 || f.finalChars > 0)
  109. .map((f) => ({ rank: f.rank, path: f.path, render: f.render, emitted: f.emittedChars, final: f.finalChars })),
  110. };
  111. } finally {
  112. rmSync(dir, { recursive: true, force: true });
  113. }
  114. if (asJson) {
  115. console.log(JSON.stringify(out, null, 2));
  116. } else {
  117. say(`query "${out.query}"`);
  118. say(`target ${out.target} — ${out.fileLines} lines, factory ${out.factory?.name} spans ${out.factory?.start}–${out.factory?.end}`);
  119. say('');
  120. say(' # render emitted final file');
  121. for (const f of out.allFiles) {
  122. say(` ${String(f.rank).padStart(2)} ${(f.render ?? '-').padEnd(10)} ${num(f.emitted).padStart(7)} ${num(f.final).padStart(7)} ${f.path}`);
  123. }
  124. say('');
  125. say(`delivered lines of ${out.target}: ${out.deliveredLines}`);
  126. say(` ranges: ${out.deliveredRanges.map((r) => `${r.start}-${r.end}`).join(', ') || '(none)'}`);
  127. say('');
  128. say(`inner symbols whose definition reached the agent: ${out.innerDelivered}/${out.innerTotal}`);
  129. for (const n of out.inner) {
  130. say(` ${n.delivered ? '✓' : '·'} ${n.name} (${n.start}–${n.end})`);
  131. }
  132. }