Files
codegraph/scripts/agent-eval/probe-factory-closure.mjs
T
Colby McHenry 91cb5b4317 measure(explore): the factory-closure envelope premise does not hold (CG-27)
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.
2026-08-06 14:07:22 -05:00

148 lines
6.3 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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})`);
}
}