1
0

probe-decl-only.mjs 9.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198
  1. #!/usr/bin/env node
  2. /**
  3. * CG-28 measurement probe — what a DECLARATION-ONLY file takes from an explore
  4. * envelope, with and without a generated banner.
  5. *
  6. * The issue was filed because a Wrangler `worker-configuration.d.ts` scored 49
  7. * at `pen 1.00` and took 60.7% of an envelope on generic identifier overlap
  8. * (`ReadableStream`, `Body`, `ImageMetadata`, `Message`, …) with a prose query.
  9. * CG-25 has since taught `GENERATED_CONTENT_PATTERNS` the Wrangler banner, so
  10. * the first thing to measure is whether that alone settles it — it does, and
  11. * this probe quantifies it. What CG-25 does NOT cover is a declaration-only file
  12. * that carries no banner at all: a hand-maintained ambient `.d.ts`, vendored
  13. * typings, module augmentation. This probe puts both shapes in ONE fixture
  14. * against ONE envelope so the banner is the only difference between them.
  15. * (`.pyi` is not an indexed extension, so Python stubs never enter the graph.)
  16. *
  17. * Findings and the full regression evidence:
  18. * `docs/benchmarks/explore-declaration-only-cg28.md`.
  19. *
  20. * Fixture: `__tests__/fixtures/ambient-decls-ts/` — an upload path (route →
  21. * stream → metadata → queue) competing with:
  22. * types/worker-configuration.d.ts declaration-only, Wrangler banner (CG-25)
  23. * types/platform-shims.d.ts declaration-only, hand-written, NO banner
  24. * src/storage/types.ts declaration-only but IMPORTED — the control
  25. * that must never be damped
  26. *
  27. * Variants (`--variant`):
  28. * both as committed — the controlled comparison
  29. * strip-banner the banner is deleted from worker-configuration.d.ts, so the
  30. * two declaration files differ in NOTHING the ranker can see;
  31. * the delta against `both` is exactly what CG-25 buys
  32. *
  33. * Usage (needs a current `npm run build`):
  34. * node scripts/agent-eval/probe-decl-only.mjs
  35. * node scripts/agent-eval/probe-decl-only.mjs --variant strip-banner
  36. * node scripts/agent-eval/probe-decl-only.mjs --json
  37. * node scripts/agent-eval/probe-decl-only.mjs --query "..."
  38. */
  39. import { cpSync, mkdtempSync, readFileSync, writeFileSync, rmSync, existsSync } from 'node:fs';
  40. import { tmpdir } from 'node:os';
  41. import { dirname, join, resolve } from 'node:path';
  42. import { fileURLToPath, pathToFileURL } from 'node:url';
  43. const HERE = dirname(fileURLToPath(import.meta.url));
  44. const REPO_ROOT = resolve(HERE, '../..');
  45. const FIXTURE = join(REPO_ROOT, '__tests__/fixtures/ambient-decls-ts');
  46. const GENERATED_DECL = 'types/worker-configuration.d.ts';
  47. const HANDWRITTEN_DECL = 'types/platform-shims.d.ts';
  48. /**
  49. * The query shapes. The flow ones are prose and name no symbol — the shape that
  50. * let the original file in. The last one is the counter-case the issue requires:
  51. * a question genuinely ABOUT a declared type must still reach the declaration.
  52. */
  53. const QUERIES = [
  54. { id: 'flow-upload', kind: 'flow', text: 'how does an upload request stream the file body to storage and record image metadata' },
  55. { id: 'flow-pipe', kind: 'flow', text: 'where does the upload body get piped into the bucket and the metadata written' },
  56. { id: 'flow-generic', kind: 'flow', text: 'how are streams and messages and image metadata handled for uploads' },
  57. { id: 'flow-queue', kind: 'flow', text: 'what happens after an object is stored and the follow-up message is queued' },
  58. { id: 'type-shim', kind: 'type', text: 'UploadStorage StoredUploadObject ImageMetadataShim' },
  59. { id: 'type-prose', kind: 'type', text: 'what does the UploadStorage interface declare for putting an object' },
  60. ];
  61. const argv = process.argv.slice(2);
  62. const asJson = argv.includes('--json');
  63. const at = (flag) => { const i = argv.indexOf(flag); return i >= 0 ? argv[i + 1] : undefined; };
  64. const VARIANT = at('--variant') ?? 'both';
  65. const ONE_QUERY = at('--query');
  66. const ONE_ID = at('--only');
  67. const say = (s = '') => { if (!asJson) console.log(s); };
  68. const num = (n) => Math.round(n).toLocaleString('en-US');
  69. const pct = (f) => `${(f * 100).toFixed(1)}%`;
  70. if (!existsSync(join(REPO_ROOT, 'dist/index.js'))) {
  71. console.error('dist/ not built — run `npm run build` first.');
  72. process.exit(2);
  73. }
  74. const load = (rel) => import(pathToFileURL(resolve(REPO_ROOT, rel)).href);
  75. const idxMod = await load('dist/index.js');
  76. const toolsMod = await load('dist/mcp/tools.js');
  77. const CodeGraph = idxMod.default?.default ?? idxMod.default ?? idxMod.CodeGraph;
  78. const ToolHandler = toolsMod.ToolHandler ?? toolsMod.default?.ToolHandler;
  79. /** Copy the fixture, apply the variant, index it. Hermetic per run. */
  80. function materialize(variant) {
  81. const dir = mkdtempSync(join(tmpdir(), 'cg-decl-'));
  82. cpSync(FIXTURE, dir, { recursive: true });
  83. rmSync(join(dir, '.codegraph'), { recursive: true, force: true });
  84. if (variant === 'strip-banner') {
  85. const p = join(dir, GENERATED_DECL);
  86. // Drop only the banner comment lines; every declaration stays.
  87. const kept = readFileSync(p, 'utf8').split('\n').filter((l) => !/^\/\/ .*(Generated by Wrangler|Runtime types generated)/.test(l));
  88. writeFileSync(p, kept.join('\n'));
  89. } else if (variant !== 'both') {
  90. rmSync(dir, { recursive: true, force: true });
  91. throw new Error(`unknown --variant ${variant} (both | strip-banner)`);
  92. }
  93. return dir;
  94. }
  95. const queries = ONE_QUERY
  96. ? [{ id: 'custom', kind: 'flow', text: ONE_QUERY }]
  97. : QUERIES.filter((q) => !ONE_ID || q.id === ONE_ID);
  98. const dir = materialize(VARIANT);
  99. let rows;
  100. try {
  101. let cg = CodeGraph.initSync(dir);
  102. await cg.indexAll();
  103. cg.close?.();
  104. const sidecar = join(dir, 'diag.jsonl');
  105. rows = [];
  106. for (const q of queries) {
  107. rmSync(sidecar, { force: true });
  108. process.env.CODEGRAPH_EXPLORE_DEBUG = sidecar;
  109. cg = CodeGraph.openSync(dir);
  110. const res = await new ToolHandler(cg).execute('codegraph_explore', { query: q.text });
  111. const text = res.content?.[0]?.text ?? '';
  112. cg.close?.();
  113. delete process.env.CODEGRAPH_EXPLORE_DEBUG;
  114. const report = JSON.parse(readFileSync(sidecar, 'utf8').trim().split('\n').pop());
  115. const pick = (path) => {
  116. const f = report.files.find((x) => x.path === path);
  117. if (!f) return null;
  118. return {
  119. path, rank: f.rank, score: f.score, graph: f.graphScore, hits: f.termHits,
  120. penalty: f.penalty, generated: f.generated, render: f.render,
  121. named: f.named, entry: f.entry, central: f.central,
  122. allocatedShare: f.allocatedShare, share: f.share,
  123. emitted: f.emittedChars, final: f.finalChars, skipped: f.skipped,
  124. };
  125. };
  126. const declPaths = new Set([GENERATED_DECL, HANDWRITTEN_DECL]);
  127. const totalSource = report.files.reduce((a, f) => a + f.finalChars, 0);
  128. const declSource = report.files
  129. .filter((f) => declPaths.has(f.path))
  130. .reduce((a, f) => a + f.finalChars, 0);
  131. // "Named in the response but carrying no source" is the correct outcome for
  132. // a cliffed declaration file — the agent can still fetch it in one call.
  133. const namedInResponse = (p) => text.includes(p);
  134. rows.push({
  135. query: q.id, kind: q.kind, text: q.text,
  136. envelope: report.envelope,
  137. generatedDecl: pick(GENERATED_DECL),
  138. handwrittenDecl: pick(HANDWRITTEN_DECL),
  139. declSourceShare: totalSource > 0 ? declSource / totalSource : 0,
  140. implSourceShare: totalSource > 0 ? (totalSource - declSource) / totalSource : 0,
  141. topFile: report.files.filter((f) => f.finalChars > 0).sort((a, b) => b.finalChars - a.finalChars)[0]?.path ?? null,
  142. generatedNamed: namedInResponse(GENERATED_DECL),
  143. handwrittenNamed: namedInResponse(HANDWRITTEN_DECL),
  144. files: report.files
  145. .filter((f) => f.emittedChars > 0 || f.finalChars > 0)
  146. .map((f) => ({ rank: f.rank, path: f.path, score: f.score, graph: f.graphScore, hits: f.termHits, penalty: f.penalty, generated: f.generated, declOnly: f.ambientDeclaration, named: f.named, entry: f.entry, central: f.central, render: f.render, final: f.finalChars, share: f.share })),
  147. });
  148. }
  149. } finally {
  150. rmSync(dir, { recursive: true, force: true });
  151. }
  152. if (asJson) {
  153. console.log(JSON.stringify({ variant: VARIANT, rows }, null, 2));
  154. } else {
  155. say(`variant ${VARIANT}`);
  156. say('');
  157. for (const r of rows) {
  158. say(`── ${r.query} [${r.kind}] "${r.text}"`);
  159. say(` envelope ${num(r.envelope.chars)} chars · decl-only files hold ${pct(r.declSourceShare)} of delivered source`);
  160. say(' # deliv% bytes score graph hits pen gen flags render file');
  161. for (const f of r.files) {
  162. const flags = [f.named && "named", f.entry && "entry", f.central && "central", f.declOnly && "decl-only"].filter(Boolean).join(" ") || "-";
  163. say(
  164. ' ' + String(f.rank).padStart(2) + ' ' +
  165. pct(f.share).padStart(6) + ' ' +
  166. num(f.final).padStart(7) + ' ' +
  167. Number(f.score).toFixed(1).padStart(5) + ' ' +
  168. f.graph.toFixed(5).padStart(7) + ' ' +
  169. String(f.hits).padStart(4) + ' ' +
  170. f.penalty.toFixed(2).padStart(4) + ' ' +
  171. (f.generated ? ' ✓ ' : ' ') + ' ' +
  172. flags.padEnd(18) + ' ' +
  173. (f.render ?? '-').padEnd(9) + ' ' +
  174. f.path,
  175. );
  176. }
  177. for (const [label, d, named] of [
  178. ['generated ', r.generatedDecl, r.generatedNamed],
  179. ['handwritten', r.handwrittenDecl, r.handwrittenNamed],
  180. ]) {
  181. say(` ${label} ${d ? `rank #${d.rank}, score ${Number(d.score).toFixed(1)}, pen ${d.penalty.toFixed(2)}, ${num(d.final)} chars (${pct(d.share)})${d.final === 0 ? ` — ${d.skipped ?? d.render ?? 'not rendered'}` : ''}` : 'not a candidate'}${named ? ' · named in response' : ''}`);
  182. }
  183. say('');
  184. }
  185. }