probe-factory-closure.mjs 6.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147
  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 targetAt = process.argv.indexOf('--target');
  29. const TARGET = targetAt >= 0 ? process.argv[targetAt + 1] : 'src/stores/dashboard-store.ts';
  30. const factoryAt = process.argv.indexOf('--factory');
  31. const FACTORY = factoryAt >= 0 ? process.argv[factoryAt + 1] : 'createDashboardStore';
  32. const argv = process.argv.slice(2);
  33. const asJson = argv.includes('--json');
  34. const queryAt = argv.indexOf('--query');
  35. const QUERY = queryAt >= 0
  36. ? argv[queryAt + 1]
  37. : 'how does the dashboard store refresh its metrics and apply a filter';
  38. const say = (s = '') => { if (!asJson) console.log(s); };
  39. const num = (n) => Math.round(n).toLocaleString('en-US');
  40. const load = (rel) => import(pathToFileURL(resolve(REPO_ROOT, rel)).href);
  41. if (!existsSync(join(REPO_ROOT, 'dist/index.js'))) {
  42. console.error('dist/ not built — run `npm run build` first.');
  43. process.exit(2);
  44. }
  45. const idxMod = await load('dist/index.js');
  46. const toolsMod = await load('dist/mcp/tools.js');
  47. const CodeGraph = idxMod.default?.default ?? idxMod.default ?? idxMod.CodeGraph;
  48. const ToolHandler = toolsMod.ToolHandler ?? toolsMod.default?.ToolHandler;
  49. const dir = mkdtempSync(join(tmpdir(), 'cg-factory-'));
  50. cpSync(FIXTURE, dir, { recursive: true });
  51. rmSync(join(dir, '.codegraph'), { recursive: true, force: true });
  52. let out;
  53. try {
  54. let cg = CodeGraph.initSync(dir);
  55. await cg.indexAll();
  56. // Inner function definitions, straight from the index — the symbols the file's
  57. // enclosing factory range would otherwise swallow.
  58. const nodes = cg.getNodesInFile(TARGET);
  59. const factory = nodes.find((n) => n.name === FACTORY);
  60. const inner = nodes
  61. .filter((n) => (n.kind === 'function' || n.kind === 'method')
  62. && n.name !== FACTORY
  63. && factory && n.startLine > factory.startLine && n.endLine <= factory.endLine)
  64. .sort((a, b) => a.startLine - b.startLine);
  65. cg.close?.();
  66. const sidecar = join(dir, 'diag.jsonl');
  67. process.env.CODEGRAPH_EXPLORE_DEBUG = sidecar;
  68. cg = CodeGraph.openSync(dir);
  69. const res = await new ToolHandler(cg).execute('codegraph_explore', { query: QUERY });
  70. const text = res.content?.[0]?.text ?? '';
  71. cg.close?.();
  72. delete process.env.CODEGRAPH_EXPLORE_DEBUG;
  73. const report = JSON.parse(readFileSync(sidecar, 'utf8').trim().split('\n').pop());
  74. // Which source lines of the target file the response actually carries. The
  75. // response numbers every delivered line `<n>\t<text>`; match them back against
  76. // the file so a line number that merely appears in prose can't count.
  77. const source = readFileSync(join(dir, TARGET), 'utf8').split('\n');
  78. const delivered = new Set();
  79. for (const line of text.split('\n')) {
  80. const m = /^(\d+)\t(.*)$/.exec(line);
  81. if (!m) continue;
  82. const n = Number(m[1]);
  83. if (n >= 1 && n <= source.length && source[n - 1] === m[2]) delivered.add(n);
  84. }
  85. // Collapse to ranges for display.
  86. const ranges = [];
  87. for (const n of [...delivered].sort((a, b) => a - b)) {
  88. const last = ranges[ranges.length - 1];
  89. if (last && n === last.end + 1) last.end = n;
  90. else ranges.push({ start: n, end: n });
  91. }
  92. const covered = (n) => delivered.has(n.startLine);
  93. const rec = report.files.find((f) => f.path === TARGET) ?? null;
  94. out = {
  95. query: QUERY,
  96. target: TARGET,
  97. fileLines: source.length,
  98. factory: factory ? { name: factory.name, start: factory.startLine, end: factory.endLine } : null,
  99. file: rec && {
  100. rank: rec.rank, render: rec.render, clipped: rec.clipped,
  101. emittedChars: rec.emittedChars, finalChars: rec.finalChars,
  102. allowance: rec.allowance, spendable: rec.spendable, skipped: rec.skipped,
  103. },
  104. deliveredRanges: ranges,
  105. deliveredLines: delivered.size,
  106. inner: inner.map((n) => ({ name: n.name, start: n.startLine, end: n.endLine, delivered: covered(n) })),
  107. innerDelivered: inner.filter(covered).length,
  108. innerTotal: inner.length,
  109. envelope: report.envelope,
  110. allFiles: report.files
  111. .filter((f) => f.emittedChars > 0 || f.finalChars > 0)
  112. .map((f) => ({ rank: f.rank, path: f.path, render: f.render, emitted: f.emittedChars, final: f.finalChars })),
  113. };
  114. } finally {
  115. rmSync(dir, { recursive: true, force: true });
  116. }
  117. if (asJson) {
  118. console.log(JSON.stringify(out, null, 2));
  119. } else {
  120. say(`query "${out.query}"`);
  121. say(`target ${out.target} — ${out.fileLines} lines, factory ${out.factory?.name} spans ${out.factory?.start}–${out.factory?.end}`);
  122. say('');
  123. say(' # render emitted final file');
  124. for (const f of out.allFiles) {
  125. say(` ${String(f.rank).padStart(2)} ${(f.render ?? '-').padEnd(10)} ${num(f.emitted).padStart(7)} ${num(f.final).padStart(7)} ${f.path}`);
  126. }
  127. say('');
  128. say(`delivered lines of ${out.target}: ${out.deliveredLines}`);
  129. say(` ranges: ${out.deliveredRanges.map((r) => `${r.start}-${r.end}`).join(', ') || '(none)'}`);
  130. say('');
  131. say(`inner symbols whose definition reached the agent: ${out.innerDelivered}/${out.innerTotal}`);
  132. for (const n of out.inner) {
  133. say(` ${n.delivered ? '✓' : '·'} ${n.name} (${n.start}–${n.end})`);
  134. }
  135. }