How codegraph_explore divides its byte envelope among files was unobservable — you could read a response and guess, but not say "this file took 16% and that one took 20%." Nothing else in the budget- allocation epic is measurable without that. CODEGRAPH_EXPLORE_DEBUG now emits one report per explore call (stderr table, stderr JSON, or a JSONL sidecar path). Per file: relevance score, graph mass, term hits, ranking flags, render mode, bytes allocated vs delivered, both shares, and whether it was clipped — plus why a ranked candidate never rendered. Totals cover envelope vs maxOutputChars vs the hard ceiling, the source/meta split, the selection funnel, and the score floor and relevance-gate thresholds applied. Allocated and delivered are reported separately on purpose: they diverge exactly when the 25K ceiling truncates, and conflating them is how a dropped trailing file goes unnoticed. Off by default and byte-identical when off — it ships in the product binary, and a diagnostic that perturbs the response by one byte would invalidate every A/B taken with it on. ExploreDiagnostics.start() returns null unless the env var is set, so every call site is a `diag?.` no-op. Baseline recorded in docs/design/explore-budget-allocation.md: on this repo, src/mcp/tools.ts gets 15.8% of the envelope while three weakly- relevant agent-eval scripts take 61% between them — despite tools.ts carrying 5.4x the score and 2.6x the graph mass of any of them. Small files ship whole; the large answer file is clipped at maxCharsPerFile. Rank ordering is correct and buys nothing. The loop also allocated 23,193 chars against an 18,000 budget. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
295 lines
11 KiB
TypeScript
295 lines
11 KiB
TypeScript
/**
|
|
* Per-file allocation diagnostic for codegraph_explore (CG-4).
|
|
*
|
|
* The instrument ships in the product binary, so the load-bearing property is
|
|
* NOT what it reports — it's that it reports NOTHING unless asked. An explore
|
|
* response is the agent's context; a diagnostic that perturbs it by one byte
|
|
* invalidates every A/B measurement taken with it on, which is the exact thing
|
|
* the rest of the budget-allocation work depends on.
|
|
*
|
|
* So the first block pins byte-identical output across on/off, and only then
|
|
* do we assert the report's shape and internal consistency.
|
|
*/
|
|
import { describe, it, expect, beforeAll, afterAll, afterEach, vi } from 'vitest';
|
|
import * as fs from 'fs';
|
|
import * as path from 'path';
|
|
import * as os from 'os';
|
|
import { ToolHandler } from '../src/mcp/tools';
|
|
import { attributeSourceBytes } from '../src/mcp/explore-diagnostics';
|
|
import CodeGraph from '../src/index';
|
|
|
|
const DEBUG_ENV = 'CODEGRAPH_EXPLORE_DEBUG';
|
|
|
|
/** Restore the env var to "unset" — `delete` matters; '' is a distinct case. */
|
|
function clearDebugEnv(): void {
|
|
delete process.env[DEBUG_ENV];
|
|
}
|
|
|
|
describe('attributeSourceBytes', () => {
|
|
it('attributes a fenced block to the file section header above it', () => {
|
|
const text = [
|
|
'**Exploration: x**',
|
|
'',
|
|
'**`src/a.ts`** — foo(function)',
|
|
'',
|
|
'```typescript',
|
|
'1\tconst a = 1;',
|
|
'2\tconst b = 2;',
|
|
'```',
|
|
'',
|
|
'**`src/b.ts`** — bar(function)',
|
|
'',
|
|
'```typescript',
|
|
'1\tconst c = 3;',
|
|
'```',
|
|
'',
|
|
].join('\n');
|
|
const bytes = attributeSourceBytes(text);
|
|
expect(bytes.get('src/a.ts')).toBe('1\tconst a = 1;\n2\tconst b = 2;'.length);
|
|
expect(bytes.get('src/b.ts')).toBe('1\tconst c = 3;'.length);
|
|
});
|
|
|
|
it('sums multiple fenced blocks under one file header', () => {
|
|
const text = [
|
|
'**`src/a.ts`** — foo(function)',
|
|
'',
|
|
'```ts',
|
|
'aa',
|
|
'```',
|
|
'',
|
|
'```ts',
|
|
'bbb',
|
|
'```',
|
|
].join('\n');
|
|
expect(attributeSourceBytes(text).get('src/a.ts')).toBe('aa'.length + 'bbb'.length);
|
|
});
|
|
|
|
it('counts an unterminated block — the ceiling can cut mid-fence', () => {
|
|
const text = ['**`src/a.ts`** — foo(function)', '', '```ts', 'x'.repeat(40)].join('\n');
|
|
expect(attributeSourceBytes(text).get('src/a.ts')).toBe(40);
|
|
});
|
|
|
|
it('returns nothing for text with no file sections', () => {
|
|
expect(attributeSourceBytes('No relevant code found for "zzz"').size).toBe(0);
|
|
expect(attributeSourceBytes('').size).toBe(0);
|
|
});
|
|
});
|
|
|
|
describe('codegraph_explore allocation diagnostic', () => {
|
|
let testDir: string;
|
|
let sidecarDir: string;
|
|
let cg: CodeGraph;
|
|
let handler: ToolHandler;
|
|
|
|
const QUERY = 'Session method helper callSession';
|
|
|
|
beforeAll(async () => {
|
|
testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-explore-diag-'));
|
|
sidecarDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-explore-diag-out-'));
|
|
const srcDir = path.join(testDir, 'src');
|
|
fs.mkdirSync(srcDir);
|
|
|
|
// One fat file plus several small callers, so the render loop exercises
|
|
// more than one allocation branch (clusters for the fat file, whole-file
|
|
// for the small ones) and there is a real per-file split to report.
|
|
const fatLines: string[] = ['export class Session {'];
|
|
for (let i = 0; i < 30; i++) {
|
|
fatLines.push(` method${i}(arg: string): string {`);
|
|
fatLines.push(` return this.helper${i}(arg) + "${i}";`);
|
|
fatLines.push(` }`);
|
|
fatLines.push(` private helper${i}(arg: string): string {`);
|
|
fatLines.push(` return arg.repeat(${i + 1});`);
|
|
fatLines.push(` }`);
|
|
}
|
|
fatLines.push('}');
|
|
fs.writeFileSync(path.join(srcDir, 'session.ts'), fatLines.join('\n'));
|
|
|
|
for (let i = 0; i < 6; i++) {
|
|
fs.writeFileSync(
|
|
path.join(srcDir, `support${i}.ts`),
|
|
`import { Session } from './session';\n` +
|
|
`export function callSession${i}(s: Session) {\n` +
|
|
` return s.method${i}('hi');\n` +
|
|
`}\n`,
|
|
);
|
|
}
|
|
|
|
clearDebugEnv();
|
|
cg = CodeGraph.initSync(testDir, { config: { include: ['**/*.ts'], exclude: [] } });
|
|
await cg.indexAll();
|
|
handler = new ToolHandler(cg);
|
|
});
|
|
|
|
afterEach(() => {
|
|
clearDebugEnv();
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
afterAll(() => {
|
|
clearDebugEnv();
|
|
if (cg) cg.destroy();
|
|
for (const dir of [testDir, sidecarDir]) {
|
|
if (dir && fs.existsSync(dir)) fs.rmSync(dir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
const explore = async (): Promise<string> => {
|
|
const result = await handler.execute('codegraph_explore', { query: QUERY });
|
|
return result.content?.[0]?.text ?? '';
|
|
};
|
|
|
|
it('produces byte-identical output whether the diagnostic is on or off', async () => {
|
|
clearDebugEnv();
|
|
const off = await explore();
|
|
expect(off.length).toBeGreaterThan(0);
|
|
|
|
// Sanity: the tool itself is deterministic, so a difference below is
|
|
// attributable to the diagnostic and not to explore's own variance.
|
|
expect(await explore()).toBe(off);
|
|
|
|
vi.spyOn(process.stderr, 'write').mockImplementation((() => true) as typeof process.stderr.write);
|
|
const sidecar = path.join(sidecarDir, 'identical.jsonl');
|
|
for (const value of ['1', 'json', sidecar]) {
|
|
process.env[DEBUG_ENV] = value;
|
|
const on = await explore();
|
|
clearDebugEnv();
|
|
expect(on).toBe(off);
|
|
}
|
|
});
|
|
|
|
it('writes nothing to stderr when the env var is unset', async () => {
|
|
clearDebugEnv();
|
|
const writes: string[] = [];
|
|
vi.spyOn(process.stderr, 'write').mockImplementation(((chunk: unknown) => {
|
|
writes.push(String(chunk));
|
|
return true;
|
|
}) as typeof process.stderr.write);
|
|
await explore();
|
|
expect(writes.join('')).toBe('');
|
|
});
|
|
|
|
it('stays off for every falsy env value', async () => {
|
|
const writes: string[] = [];
|
|
vi.spyOn(process.stderr, 'write').mockImplementation(((chunk: unknown) => {
|
|
writes.push(String(chunk));
|
|
return true;
|
|
}) as typeof process.stderr.write);
|
|
for (const value of ['', '0', 'false', 'off', 'no', 'OFF', ' 0 ']) {
|
|
process.env[DEBUG_ENV] = value;
|
|
await explore();
|
|
}
|
|
expect(writes.join('')).toBe('');
|
|
});
|
|
|
|
it('prints a per-file table to stderr when enabled', async () => {
|
|
const writes: string[] = [];
|
|
vi.spyOn(process.stderr, 'write').mockImplementation(((chunk: unknown) => {
|
|
writes.push(String(chunk));
|
|
return true;
|
|
}) as typeof process.stderr.write);
|
|
|
|
process.env[DEBUG_ENV] = '1';
|
|
await explore();
|
|
const out = writes.join('');
|
|
|
|
expect(out).toContain('codegraph explore diagnostic');
|
|
// Totals: envelope vs budget, and the file-selection funnel with its floor.
|
|
expect(out).toMatch(/envelope [\d,]+ chars delivered · [\d,]+ allocated of [\d,]+ budget/);
|
|
expect(out).toMatch(/hard ceiling [\d,]+/);
|
|
expect(out).toMatch(/files [\d,]+ grouped .*past score floor \(>=\d+\).*in output \(maxFiles \d+\)/);
|
|
// Per-file columns.
|
|
expect(out).toMatch(/#\s+alloc%\s+deliv%\s+bytes\s+score\s+graph\s+hits\s+flags\s+render\s+file/);
|
|
expect(out).toContain('src/session.ts');
|
|
expect(out).toMatch(/\d+\.\d%/);
|
|
});
|
|
|
|
it('appends one JSON report per call to a sidecar path', async () => {
|
|
const sidecar = path.join(sidecarDir, 'reports.jsonl');
|
|
process.env[DEBUG_ENV] = sidecar;
|
|
await explore();
|
|
await explore();
|
|
clearDebugEnv();
|
|
|
|
const rows = fs.readFileSync(sidecar, 'utf-8').trim().split('\n');
|
|
expect(rows).toHaveLength(2);
|
|
|
|
const report = JSON.parse(rows[0]!);
|
|
expect(report.tool).toBe('codegraph_explore');
|
|
expect(report.query).toBe(QUERY);
|
|
|
|
// Totals the task asks for: envelope vs maxOutputChars, files considered
|
|
// vs included, and the score floor that was applied.
|
|
expect(report.budget.maxOutputChars).toBeGreaterThan(0);
|
|
expect(report.envelope.chars).toBeGreaterThan(0);
|
|
expect(report.selection.scoreFloor).toBeGreaterThan(0);
|
|
expect(report.selection.filesGrouped).toBeGreaterThanOrEqual(report.selection.filesPastScoreFloor);
|
|
expect(report.selection.filesPastScoreFloor).toBeGreaterThanOrEqual(report.selection.filesRanked);
|
|
expect(report.selection.filesRanked).toBeGreaterThanOrEqual(report.selection.filesInFinalOutput);
|
|
expect(report.selection.filesInFinalOutput).toBeGreaterThan(0);
|
|
expect(report.selection.filesInFinalOutput).toBeLessThanOrEqual(report.budget.maxFiles);
|
|
|
|
// Per-file: score, bytes, share, clipped, spine.
|
|
const shown = report.files.filter((f: { finalChars: number }) => f.finalChars > 0);
|
|
expect(shown.length).toBeGreaterThan(0);
|
|
for (const f of shown) {
|
|
expect(typeof f.path).toBe('string');
|
|
expect(typeof f.score).toBe('number');
|
|
expect(typeof f.graphScore).toBe('number');
|
|
expect(typeof f.clipped).toBe('boolean');
|
|
expect(typeof f.spine).toBe('boolean');
|
|
expect(f.finalChars).toBeGreaterThan(0);
|
|
expect(f.share).toBeGreaterThan(0);
|
|
expect(f.share).toBeLessThanOrEqual(1);
|
|
expect(f.render).toBeTruthy();
|
|
}
|
|
expect(shown.some((f: { path: string }) => f.path === 'src/session.ts')).toBe(true);
|
|
});
|
|
|
|
it('attributes the envelope consistently — per-file bytes sum to the reported source total', async () => {
|
|
const sidecar = path.join(sidecarDir, 'consistency.jsonl');
|
|
process.env[DEBUG_ENV] = sidecar;
|
|
const text = await explore();
|
|
clearDebugEnv();
|
|
|
|
const report = JSON.parse(fs.readFileSync(sidecar, 'utf-8').trim());
|
|
expect(report.envelope.chars).toBe(text.length);
|
|
|
|
const summed = report.files.reduce(
|
|
(s: number, f: { finalChars: number }) => s + f.finalChars, 0,
|
|
);
|
|
expect(summed).toBe(report.envelope.sourceChars);
|
|
expect(report.envelope.sourceChars + report.envelope.metaChars).toBe(report.envelope.chars);
|
|
// Shares are fractions of the delivered envelope, so they can't exceed it.
|
|
const shareSum = report.files.reduce((s: number, f: { share: number }) => s + f.share, 0);
|
|
expect(shareSum).toBeLessThanOrEqual(1.0001);
|
|
expect(shareSum).toBeCloseTo(report.envelope.sourceShare, 3);
|
|
});
|
|
|
|
it('survives an unwritable sink without failing the explore call', async () => {
|
|
clearDebugEnv();
|
|
const expected = await explore();
|
|
|
|
// A directory is never a valid append target.
|
|
process.env[DEBUG_ENV] = sidecarDir;
|
|
const result = await handler.execute('codegraph_explore', { query: QUERY });
|
|
clearDebugEnv();
|
|
|
|
expect(result.isError).toBeFalsy();
|
|
expect(result.content?.[0]?.text).toBe(expected);
|
|
});
|
|
|
|
it('records a report even when explore finds nothing', async () => {
|
|
const sidecar = path.join(sidecarDir, 'empty.jsonl');
|
|
process.env[DEBUG_ENV] = sidecar;
|
|
const result = await handler.execute('codegraph_explore', {
|
|
query: 'zzzznonexistentsymbolzzzz',
|
|
});
|
|
clearDebugEnv();
|
|
|
|
expect(result.content?.[0]?.text).toContain('No relevant code found');
|
|
const report = JSON.parse(fs.readFileSync(sidecar, 'utf-8').trim());
|
|
expect(report.note).toContain('no relevant code found');
|
|
expect(report.files).toEqual([]);
|
|
});
|
|
});
|