CG-27 asked whether the >50%-of-file envelope drop should cover `function` / `method`, so a `createFoo()` factory returning an object of closures stops merging every closure inside it into one cluster. Measured on a hermetic fixture, it should not, and the issue is closed as obsolete with CG-30 credited. Two mechanisms already absorb the shape. shrinkCluster orders members by (importance desc, size ASC) and refuses any member that overruns the cap once something is kept, so a file-spanning member is only selected when it is the sole member of the top importance tier — eight of nine query shapes never selected it at all. When it IS selected, CG-30 windows it on whole lines, so the file still delivers bounded, readable source (6 of 9 closure definitions in that configuration). Dropping the range instead SPLITS the file, and only the first-chosen cluster may be shrunk: a trivial 7-line cluster won the density tiebreak and the answer-bearing cluster was dropped whole — rank-#1 file 7,539 chars and 7 of 11 closures to 397 and none. Reaching the same intent more carefully (defer the envelope MEMBER inside shrinkCluster, leaving clustering untouched) is noise: 69 vs 68 closure definitions across nine query shapes. Nothing shipped. Adds the fixture, the probe, a standing gate on the outcome, and the record — including a real defect the measurement exposed on the epic tip: django's query.py leaves 8,212 of 10,135 unspent and drops a score-290 cluster to keep a score-14 one. Filed separately. No behaviour change, so no CHANGELOG entry.
148 lines
6.3 KiB
JavaScript
148 lines
6.3 KiB
JavaScript
#!/usr/bin/env node
|
||
/**
|
||
* CG-27 measurement probe — what a factory-closure file actually delivers.
|
||
*
|
||
* `probe-allocation.mjs` measures how the envelope is split BETWEEN files. This
|
||
* one measures what comes back from WITHIN one file whose top-level symbol spans
|
||
* almost all of it: a `createFoo()` factory returning an object of closures
|
||
* (Svelte 5 rune stores, React hook modules, Zustand `create((set,get)=>({…}))`,
|
||
* IIFE module-pattern JS). The claim under test is a ranking one, not a byte one
|
||
* — CG-30 already bounds the bytes — so the number that matters is WHICH inner
|
||
* symbols reach the agent, not how many chars did.
|
||
*
|
||
* Prints, for the factory file: every line range the response delivered, and for
|
||
* each inner function whether its DEFINITION LINE is inside one of them.
|
||
*
|
||
* Usage (needs a current `npm run build`):
|
||
* node scripts/agent-eval/probe-factory-closure.mjs
|
||
* node scripts/agent-eval/probe-factory-closure.mjs --json
|
||
* node scripts/agent-eval/probe-factory-closure.mjs --query "..."
|
||
*/
|
||
import { cpSync, mkdtempSync, readFileSync, rmSync, existsSync } from 'node:fs';
|
||
import { tmpdir } from 'node:os';
|
||
import { dirname, join, resolve } from 'node:path';
|
||
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||
|
||
const HERE = dirname(fileURLToPath(import.meta.url));
|
||
const REPO_ROOT = resolve(HERE, '../..');
|
||
const FIXTURE = join(REPO_ROOT, '__tests__/fixtures/factory-closure-ts');
|
||
const targetAt = process.argv.indexOf('--target');
|
||
const TARGET = targetAt >= 0 ? process.argv[targetAt + 1] : 'src/stores/dashboard-store.ts';
|
||
const factoryAt = process.argv.indexOf('--factory');
|
||
const FACTORY = factoryAt >= 0 ? process.argv[factoryAt + 1] : 'createDashboardStore';
|
||
|
||
const argv = process.argv.slice(2);
|
||
const asJson = argv.includes('--json');
|
||
const queryAt = argv.indexOf('--query');
|
||
const QUERY = queryAt >= 0
|
||
? argv[queryAt + 1]
|
||
: 'how does the dashboard store refresh its metrics and apply a filter';
|
||
|
||
const say = (s = '') => { if (!asJson) console.log(s); };
|
||
const num = (n) => Math.round(n).toLocaleString('en-US');
|
||
|
||
const load = (rel) => import(pathToFileURL(resolve(REPO_ROOT, rel)).href);
|
||
if (!existsSync(join(REPO_ROOT, 'dist/index.js'))) {
|
||
console.error('dist/ not built — run `npm run build` first.');
|
||
process.exit(2);
|
||
}
|
||
const idxMod = await load('dist/index.js');
|
||
const toolsMod = await load('dist/mcp/tools.js');
|
||
const CodeGraph = idxMod.default?.default ?? idxMod.default ?? idxMod.CodeGraph;
|
||
const ToolHandler = toolsMod.ToolHandler ?? toolsMod.default?.ToolHandler;
|
||
|
||
const dir = mkdtempSync(join(tmpdir(), 'cg-factory-'));
|
||
cpSync(FIXTURE, dir, { recursive: true });
|
||
rmSync(join(dir, '.codegraph'), { recursive: true, force: true });
|
||
|
||
let out;
|
||
try {
|
||
let cg = CodeGraph.initSync(dir);
|
||
await cg.indexAll();
|
||
|
||
// Inner function definitions, straight from the index — the symbols the file's
|
||
// enclosing factory range would otherwise swallow.
|
||
const nodes = cg.getNodesInFile(TARGET);
|
||
const factory = nodes.find((n) => n.name === FACTORY);
|
||
const inner = nodes
|
||
.filter((n) => (n.kind === 'function' || n.kind === 'method')
|
||
&& n.name !== FACTORY
|
||
&& factory && n.startLine > factory.startLine && n.endLine <= factory.endLine)
|
||
.sort((a, b) => a.startLine - b.startLine);
|
||
cg.close?.();
|
||
|
||
const sidecar = join(dir, 'diag.jsonl');
|
||
process.env.CODEGRAPH_EXPLORE_DEBUG = sidecar;
|
||
cg = CodeGraph.openSync(dir);
|
||
const res = await new ToolHandler(cg).execute('codegraph_explore', { query: QUERY });
|
||
const text = res.content?.[0]?.text ?? '';
|
||
cg.close?.();
|
||
delete process.env.CODEGRAPH_EXPLORE_DEBUG;
|
||
const report = JSON.parse(readFileSync(sidecar, 'utf8').trim().split('\n').pop());
|
||
|
||
// Which source lines of the target file the response actually carries. The
|
||
// response numbers every delivered line `<n>\t<text>`; match them back against
|
||
// the file so a line number that merely appears in prose can't count.
|
||
const source = readFileSync(join(dir, TARGET), 'utf8').split('\n');
|
||
const delivered = new Set();
|
||
for (const line of text.split('\n')) {
|
||
const m = /^(\d+)\t(.*)$/.exec(line);
|
||
if (!m) continue;
|
||
const n = Number(m[1]);
|
||
if (n >= 1 && n <= source.length && source[n - 1] === m[2]) delivered.add(n);
|
||
}
|
||
// Collapse to ranges for display.
|
||
const ranges = [];
|
||
for (const n of [...delivered].sort((a, b) => a - b)) {
|
||
const last = ranges[ranges.length - 1];
|
||
if (last && n === last.end + 1) last.end = n;
|
||
else ranges.push({ start: n, end: n });
|
||
}
|
||
|
||
const covered = (n) => delivered.has(n.startLine);
|
||
const rec = report.files.find((f) => f.path === TARGET) ?? null;
|
||
|
||
out = {
|
||
query: QUERY,
|
||
target: TARGET,
|
||
fileLines: source.length,
|
||
factory: factory ? { name: factory.name, start: factory.startLine, end: factory.endLine } : null,
|
||
file: rec && {
|
||
rank: rec.rank, render: rec.render, clipped: rec.clipped,
|
||
emittedChars: rec.emittedChars, finalChars: rec.finalChars,
|
||
allowance: rec.allowance, spendable: rec.spendable, skipped: rec.skipped,
|
||
},
|
||
deliveredRanges: ranges,
|
||
deliveredLines: delivered.size,
|
||
inner: inner.map((n) => ({ name: n.name, start: n.startLine, end: n.endLine, delivered: covered(n) })),
|
||
innerDelivered: inner.filter(covered).length,
|
||
innerTotal: inner.length,
|
||
envelope: report.envelope,
|
||
allFiles: report.files
|
||
.filter((f) => f.emittedChars > 0 || f.finalChars > 0)
|
||
.map((f) => ({ rank: f.rank, path: f.path, render: f.render, emitted: f.emittedChars, final: f.finalChars })),
|
||
};
|
||
} finally {
|
||
rmSync(dir, { recursive: true, force: true });
|
||
}
|
||
|
||
if (asJson) {
|
||
console.log(JSON.stringify(out, null, 2));
|
||
} else {
|
||
say(`query "${out.query}"`);
|
||
say(`target ${out.target} — ${out.fileLines} lines, factory ${out.factory?.name} spans ${out.factory?.start}–${out.factory?.end}`);
|
||
say('');
|
||
say(' # render emitted final file');
|
||
for (const f of out.allFiles) {
|
||
say(` ${String(f.rank).padStart(2)} ${(f.render ?? '-').padEnd(10)} ${num(f.emitted).padStart(7)} ${num(f.final).padStart(7)} ${f.path}`);
|
||
}
|
||
say('');
|
||
say(`delivered lines of ${out.target}: ${out.deliveredLines}`);
|
||
say(` ranges: ${out.deliveredRanges.map((r) => `${r.start}-${r.end}`).join(', ') || '(none)'}`);
|
||
say('');
|
||
say(`inner symbols whose definition reached the agent: ${out.innerDelivered}/${out.innerTotal}`);
|
||
for (const n of out.inner) {
|
||
say(` ${n.delivered ? '✓' : '·'} ${n.name} (${n.start}–${n.end})`);
|
||
}
|
||
}
|