Files
codegraph/__tests__/explore-allocation-1500.test.ts
T
Colby McHenryandClaude Opus 5 1d9206d2d0 test(explore): lock down proportional byte allocation (CG-14, #1500)
Coverage for the CG-12 allocator, built around "would this go red if the
lever were removed" rather than line coverage — every way this regresses
is silent, ending in an agent falling back to Read.

Unit (`explore-proportional-allocation.test.ts`, 18 -> 38): calibration
pins, envelope safety across every tier and 30 candidate shapes, the
cliff boundary, spine weighting/trim survival, the diffuse control, and
the degenerate inputs — identical scores, a lone file, a runaway top
scorer, zero results, maxFiles 0, a non-finite score.

End-to-end (`explore-allocation-e2e.test.ts`, new): CG-6's second
regression fixture as a deterministic synthetic mirror — a large relevant
file, a small helper that used to win by shipping whole, and an
incidental `explore`/`BUDGET` collision — asserting per-file budget
share, not file presence. Plus degenerate result sets and a survey-style
diffuse control through the real render loop. The live self-query arm
stays in probe-allocation.mjs, where drift is a number to re-baseline
rather than a red suite.

Reverting the render loop to the pre-CG-12 rules reproduces #1500 on the
mirror exactly and takes 5 e2e + 2 payroll gates red:

  file                     score  pre-CG-12       CG-12
  src/mcp/allocator.ts      77.5  4,843 (39.7%)   9,335 (80.1%)
  src/util/budget-math.ts   36.0  6,079 (49.8%)   1,037 ( 8.9%)

Two defects the invariants surfaced, both fixed in tools.ts:
- rounded shares could sum past `pool`, so "reservations fit the
  envelope" was approximate rather than exact; both terms now floor
- a non-finite score made every share Infinity/Infinity, handing the
  render loop a NaN allowance; `weightOf` now fails safe to 0

Also adds a hard-ceiling gate to the payroll fixture — at 19.3K against
a 19.5K ceiling it is the only fixture that stresses the ~25K inline cap
— and exports EXPLORE_ALLOCATION so invariant tests read the constants
while one test pins the literals.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 00:56:50 -05:00

302 lines
15 KiB
TypeScript

/**
* Regression fixture for GitHub issue #1500 / epic CG-1 — relevance-proportional
* explore budget allocation.
*
* The reporter's repo is a Go service whose GENERATED FKIT CRUD layer sits beside
* the hand-written use-case that does the real work. Asking an architecture
* question that doesn't name the exact use-case ("how does payroll cycle create
* and calculate payslips?") spends the explore envelope on the generated CRUD,
* because the generated layer name-collides on every term in the question while
* the hand-written workflow is one big file that gets clipped.
*
* `__tests__/fixtures/payroll-go/` reproduces that shape permanently. This suite
* is in two halves:
*
* 1. **Fixture shape** — green today. These pin the properties the fixture must
* keep for the gate below to mean anything: the generated/hand-written split
* (including the ordinary-named generated files only a CONTENT header betrays,
* which is the #1500 case), the deliberate name collisions, and the
* runPayrollCycleAll → BuildPayslip → Upsert chain resolving end-to-end. If
* the fixture rots, these fail first and say so.
*
* 2. **Budget allocation** — the gate. CG-10 (relevance scoring) closed most of
* it: the generated CRUD now ranks and delivers BELOW the hand-written
* workflow, and those assertions are live regressions. What remains is
* `it.fails`, which DOCUMENTS THE PART STILL OPEN — vitest passes an
* `it.fails` test only while its body throws, so it goes RED the moment
* CG-12's proportional byte allocation lands.
* **When it goes red, delete the `.fails` — do not delete the test.**
*
* The same assertions run outside vitest, against the built dist and with the
* full CG-4 per-file diagnostic, via `node scripts/agent-eval/probe-allocation.mjs`
* (declared in `scripts/agent-eval/allocation-fixtures.json`).
*/
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
import CodeGraph from '../src/index';
import { ToolHandler, getExploreOutputBudget } from '../src/mcp/tools';
import { attributeSourceBytes } from '../src/mcp/explore-diagnostics';
import { isGeneratedFile, hasGeneratedHeader } from '../src/extraction/generated-detection';
const FIXTURE_SRC = path.join(__dirname, 'fixtures', 'payroll-go');
/** The question a newcomer asks — names none of the symbols that answer it. */
const QUERY = 'how does payroll cycle create and calculate payslips?';
/** The hand-written workflow: what the query is actually about. */
const ANSWER_PREFIXES = [
'internal/usecase/',
'internal/store/',
'internal/transport/',
'internal/domain/',
'cmd/',
];
/** The generated CRUD/DTO layer: what wins the envelope today. */
const GENERATED_PREFIX = 'internal/gen/';
const startsWithAny = (p: string, prefixes: string[]) => prefixes.some((x) => p.startsWith(x));
describe('#1500 — generated Go CRUD beside a hand-written payroll workflow', () => {
let testDir: string;
let cg: CodeGraph;
let handler: ToolHandler;
let response: string;
/** Delivered source bytes per file, attributed from the final response. */
let bytes: Map<string, number>;
beforeAll(async () => {
testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-1500-'));
fs.cpSync(FIXTURE_SRC, testDir, { recursive: true });
// A stray index in the checked-in tree would be copied in and reused.
fs.rmSync(path.join(testDir, '.codegraph'), { recursive: true, force: true });
cg = CodeGraph.initSync(testDir);
await cg.indexAll();
handler = new ToolHandler(cg);
const result = await handler.execute('codegraph_explore', { query: QUERY });
response = result.content?.[0]?.text ?? '';
bytes = attributeSourceBytes(response);
}, 120_000);
afterAll(() => {
if (cg) cg.destroy();
if (testDir && fs.existsSync(testDir)) fs.rmSync(testDir, { recursive: true, force: true });
});
// ── 1. Fixture shape ──────────────────────────────────────────────────────
describe('fixture shape', () => {
it('indexes as a Go project with both layers present', () => {
const files = cg.getFiles().map((f) => f.path);
expect(files.filter((p) => p.endsWith('.go')).length).toBeGreaterThanOrEqual(15);
expect(files.some((p) => p.startsWith(GENERATED_PREFIX))).toBe(true);
expect(files.some((p) => p.startsWith('internal/usecase/'))).toBe(true);
});
it('flags every generated file and no hand-written one', () => {
for (const file of cg.getFiles()) {
expect(file.generated, `${file.path} generated flag`).toBe(
file.path.startsWith(GENERATED_PREFIX),
);
}
});
it('carries generated files that ONLY a content header betrays — the #1500 case', () => {
// Half the generated tree has ordinary names (`payslip.go`, `store.go`).
// Path-only detection misses them; the CG-5 content check is what catches
// them. Without these the fixture would be a .pb.go fixture, not a #1500 one.
const contentOnly = [
'internal/gen/fkit/payroll/payslip.go',
'internal/gen/fkit/payroll/payroll_cycle.go',
'internal/gen/fkit/payroll/store.go',
'internal/gen/fkit/payroll/calculate.go',
'internal/gen/fkit/payroll/dto.go',
'internal/gen/fkit/employee/employee.go',
'internal/gen/fkit/timesheet/timesheet.go',
];
for (const rel of contentOnly) {
const source = fs.readFileSync(path.join(testDir, rel), 'utf-8');
expect(isGeneratedFile(rel), `${rel} must NOT be detectable by path`).toBe(false);
expect(hasGeneratedHeader(source), `${rel} must be detectable by header`).toBe(true);
expect(cg.getFile(rel)?.generated, `${rel} indexed flag`).toBe(true);
}
// …beside the conventional path-detectable ones, so both channels are covered.
expect(isGeneratedFile('internal/gen/payrollpb/payroll.pb.go')).toBe(true);
});
it('collides the generated layer with the hand-written one by name', () => {
// A naive scorer sees two BuildPayslips and two Upserts and has no reason
// to prefer the one that implements the business rule.
for (const name of ['BuildPayslip', 'Upsert', 'Store']) {
const files = new Set(cg.getNodesByName(name).map((n) => n.filePath));
expect([...files].some((p) => p.startsWith(GENERATED_PREFIX)), `${name} generated`).toBe(true);
expect([...files].some((p) => !p.startsWith(GENERATED_PREFIX)), `${name} hand-written`).toBe(true);
}
});
it('resolves the hand-written workflow chain end-to-end in the graph', () => {
const calleesOf = (name: string, file: string) => {
const node = cg.getNodesByName(name).find((n) => n.filePath === file);
expect(node, `${name} in ${file}`).toBeTruthy();
return cg
.getOutgoingEdges(node!.id)
.filter((e) => e.kind === 'calls')
.map((e) => cg.getNode(e.target))
.filter((n): n is NonNullable<typeof n> => !!n);
};
// handler → use-case
expect(
calleesOf('RunCycle', 'internal/transport/httpapi/payroll_handler.go')
.some((n) => n.name === 'RunCycle' && n.filePath === 'internal/usecase/payroll/cycle.go'),
).toBe(true);
// use-case → the workflow
expect(
calleesOf('RunCycle', 'internal/usecase/payroll/cycle.go')
.some((n) => n.name === 'runPayrollCycleAll'),
).toBe(true);
// the workflow → build + persist
const workflow = calleesOf('runPayrollCycleAll', 'internal/usecase/payroll/cycle.go');
expect(
workflow.some((n) => n.name === 'BuildPayslip' && n.filePath === 'internal/usecase/payroll/payslip_builder.go'),
'runPayrollCycleAll must reach the hand-written BuildPayslip',
).toBe(true);
expect(workflow.some((n) => n.name === 'Upsert'), 'runPayrollCycleAll must reach an Upsert').toBe(true);
});
it('routes an HTTP entry point into the workflow', () => {
const router = cg.getNodesInFile('internal/transport/httpapi/router.go');
expect(router.some((n) => n.kind === 'route' || n.name === 'NewRouter')).toBe(true);
});
it('sizes the two layers so the size-driven render split actually bites', () => {
// The mechanism the epic is about: a small file ships WHOLE, a large one
// falls through to clipped clusters. The workflow file must stay above the
// whole-file window and the generated files below it, or the fixture stops
// reproducing anything.
const lines = (rel: string) => fs.readFileSync(path.join(testDir, rel), 'utf-8').split('\n').length;
expect(lines('internal/usecase/payroll/cycle.go')).toBeGreaterThan(220);
for (const rel of ['internal/gen/fkit/payroll/payslip.go', 'internal/gen/fkit/payroll/payroll_cycle.go']) {
expect(lines(rel)).toBeLessThan(220);
}
});
it('answers the query at all', () => {
expect(response.length).toBeGreaterThan(1000);
expect(bytes.size).toBeGreaterThan(0);
});
});
// ── 2. Budget allocation — the open bug ───────────────────────────────────
describe('budget allocation', () => {
const share = (predicate: (p: string) => boolean) => {
let total = 0;
for (const [file, n] of bytes) if (predicate(file)) total += n;
return total / response.length;
};
const answerShare = () => share((p) => startsWithAny(p, ANSWER_PREFIXES));
const generatedShare = () => share((p) => p.startsWith(GENERATED_PREFIX));
/**
* BASELINE 2026-08-03, BEFORE CG-10 (very-tiny tier, 13,000-char budget):
* 23,020 chars allocated, cut to 16,011 by the 19,500 hard ceiling. The
* generated CRUD delivered 57.4%; the hand-written layer 25.6%, all of it
* domain types. `cycle.go` was allocated the single largest slice (7,052
* chars, 30.6%) and delivered ZERO — the ceiling dropped its whole section —
* so runPayrollCycleAll, the hand-written BuildPayslip and the real Upsert
* never reached the agent.
*
* AFTER CG-10 (relevance scoring): the generated files rank #3/#4 instead of
* #1/#2 — kind-weighted scoring plus a generated rank PENALTY on both the
* score and the graph mass, rather than the old tiebreak-at-equal-score.
* `cycle.go` now delivers 38.9% and the generated layer 23.5%. Four of the
* five gates below are green and are now live regressions.
*
* AFTER CG-12 (score-proportional allocation): every file's share of the
* envelope is reserved before anything renders, and a file under 15% of the
* top weight gets no source at all — so the two generated files cliff to
* pointers, hand their `maxFiles` slots to the hand-written store and
* builder, and the answer group takes ~79% with the generated layer at 0%.
* `func (s *Service) BuildPayslip` — the "calculate" half of the question —
* finally reaches the agent. All gates below are live regressions now.
*/
it('CG-10 GATE: concentrates the envelope on the hand-written workflow', () => {
expect(answerShare()).toBeGreaterThanOrEqual(0.55);
});
it('CG-10 GATE: does not spend the envelope on the generated CRUD', () => {
expect(generatedShare()).toBeLessThanOrEqual(0.25);
});
it('CG-10 GATE: ranks the generated CRUD below the hand-written workflow', () => {
// The #1500 report in one assertion: before CG-10 the generated layer both
// outscored AND out-delivered the use-case that implements the business rule.
expect(answerShare()).toBeGreaterThan(generatedShare());
});
it('CG-10 GATE: delivers the workflow file it allocated the most bytes to', () => {
expect(bytes.get('internal/usecase/payroll/cycle.go') ?? 0).toBeGreaterThan(0);
});
it('CG-10 GATE: puts the hand-written chain in the response, not its generated twin', () => {
// Bare `Upsert` also matches the generated collision — these needles are
// unique to the hand-written chain.
expect(response).toContain('runPayrollCycleAll');
expect(response).toContain('s.store.Upsert(ctx, slip)');
});
it('CG-12 GATE: delivers the calculation the question asks about', () => {
// `payslip_builder.go` ranks #6 and the tier's maxFiles is 4 — it reaches
// the response only because the two generated files cliff to pointers
// WITHOUT consuming a slot. That slot hand-off is the CG-12 mechanism.
expect(bytes.get('internal/usecase/payroll/payslip_builder.go') ?? 0).toBeGreaterThan(0);
expect(response).toContain('func (s *Service) BuildPayslip');
});
it('CG-12 GATE: withholds the generated CRUD bytes but still names it', () => {
// A cliffed file costs ~100 chars instead of ~4,500, and stays one
// follow-up explore away — withholding is only cheap if it stays nameable.
expect(bytes.get('internal/gen/fkit/payroll/payslip.go') ?? 0).toBe(0);
expect(response).toContain('**Not shown above — explore these names for their source**');
expect(response).toMatch(/internal\/gen\/fkit\/payroll\/payslip\.go: \w+:\d+/);
});
it('CG-14 GATE: holds the response inside the hard ceiling under real pressure', () => {
// This fixture is the stress case for the ceiling, not just for the split:
// 19 files put it in the very-tiny tier (13,000-char envelope) while the
// answer genuinely needs more, so the render loop spends its full allowed
// overshoot — ~19.3K against a 19.5K ceiling. That leaves ~1% of headroom,
// which is exactly why this is worth pinning: the bound that matters is the
// host's ~25K inline cap, and above it the response is written to a file
// the agent Reads back, undoing the point of the tool.
const budget = getExploreOutputBudget(cg.getFiles().length);
const hardCeiling = Math.min(Math.round(budget.maxOutputChars * 1.5), 25000);
expect(response.length).toBeGreaterThan(budget.maxOutputChars);
expect(response.length).toBeLessThanOrEqual(hardCeiling);
expect(response.length).toBeLessThan(25000);
});
it('records the shape of the allocation so a regression is legible', () => {
// Not a gate — a snapshot of the split, so a future change that shifts the
// numbers shows up in the diff rather than silently flipping a gate.
const generated = generatedShare();
const answer = answerShare();
expect({
generatedWinsEnvelope: generated > answer,
workflowFileDelivers: (bytes.get('internal/usecase/payroll/cycle.go') ?? 0) > 0,
builderFileDelivers: (bytes.get('internal/usecase/payroll/payslip_builder.go') ?? 0) > 0,
}).toEqual({
generatedWinsEnvelope: false,
workflowFileDelivers: true,
builderFileDelivers: true,
});
});
});
});