explore-allocation-1500.test.ts 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301
  1. /**
  2. * Regression fixture for GitHub issue #1500 / epic CG-1 — relevance-proportional
  3. * explore budget allocation.
  4. *
  5. * The reporter's repo is a Go service whose GENERATED FKIT CRUD layer sits beside
  6. * the hand-written use-case that does the real work. Asking an architecture
  7. * question that doesn't name the exact use-case ("how does payroll cycle create
  8. * and calculate payslips?") spends the explore envelope on the generated CRUD,
  9. * because the generated layer name-collides on every term in the question while
  10. * the hand-written workflow is one big file that gets clipped.
  11. *
  12. * `__tests__/fixtures/payroll-go/` reproduces that shape permanently. This suite
  13. * is in two halves:
  14. *
  15. * 1. **Fixture shape** — green today. These pin the properties the fixture must
  16. * keep for the gate below to mean anything: the generated/hand-written split
  17. * (including the ordinary-named generated files only a CONTENT header betrays,
  18. * which is the #1500 case), the deliberate name collisions, and the
  19. * runPayrollCycleAll → BuildPayslip → Upsert chain resolving end-to-end. If
  20. * the fixture rots, these fail first and say so.
  21. *
  22. * 2. **Budget allocation** — the gate. CG-10 (relevance scoring) closed most of
  23. * it: the generated CRUD now ranks and delivers BELOW the hand-written
  24. * workflow, and those assertions are live regressions. What remains is
  25. * `it.fails`, which DOCUMENTS THE PART STILL OPEN — vitest passes an
  26. * `it.fails` test only while its body throws, so it goes RED the moment
  27. * CG-12's proportional byte allocation lands.
  28. * **When it goes red, delete the `.fails` — do not delete the test.**
  29. *
  30. * The same assertions run outside vitest, against the built dist and with the
  31. * full CG-4 per-file diagnostic, via `node scripts/agent-eval/probe-allocation.mjs`
  32. * (declared in `scripts/agent-eval/allocation-fixtures.json`).
  33. */
  34. import { describe, it, expect, beforeAll, afterAll } from 'vitest';
  35. import * as fs from 'fs';
  36. import * as path from 'path';
  37. import * as os from 'os';
  38. import CodeGraph from '../src/index';
  39. import { ToolHandler, getExploreOutputBudget } from '../src/mcp/tools';
  40. import { attributeSourceBytes } from '../src/mcp/explore-diagnostics';
  41. import { isGeneratedFile, hasGeneratedHeader } from '../src/extraction/generated-detection';
  42. const FIXTURE_SRC = path.join(__dirname, 'fixtures', 'payroll-go');
  43. /** The question a newcomer asks — names none of the symbols that answer it. */
  44. const QUERY = 'how does payroll cycle create and calculate payslips?';
  45. /** The hand-written workflow: what the query is actually about. */
  46. const ANSWER_PREFIXES = [
  47. 'internal/usecase/',
  48. 'internal/store/',
  49. 'internal/transport/',
  50. 'internal/domain/',
  51. 'cmd/',
  52. ];
  53. /** The generated CRUD/DTO layer: what wins the envelope today. */
  54. const GENERATED_PREFIX = 'internal/gen/';
  55. const startsWithAny = (p: string, prefixes: string[]) => prefixes.some((x) => p.startsWith(x));
  56. describe('#1500 — generated Go CRUD beside a hand-written payroll workflow', () => {
  57. let testDir: string;
  58. let cg: CodeGraph;
  59. let handler: ToolHandler;
  60. let response: string;
  61. /** Delivered source bytes per file, attributed from the final response. */
  62. let bytes: Map<string, number>;
  63. beforeAll(async () => {
  64. testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-1500-'));
  65. fs.cpSync(FIXTURE_SRC, testDir, { recursive: true });
  66. // A stray index in the checked-in tree would be copied in and reused.
  67. fs.rmSync(path.join(testDir, '.codegraph'), { recursive: true, force: true });
  68. cg = CodeGraph.initSync(testDir);
  69. await cg.indexAll();
  70. handler = new ToolHandler(cg);
  71. const result = await handler.execute('codegraph_explore', { query: QUERY });
  72. response = result.content?.[0]?.text ?? '';
  73. bytes = attributeSourceBytes(response);
  74. }, 120_000);
  75. afterAll(() => {
  76. if (cg) cg.destroy();
  77. if (testDir && fs.existsSync(testDir)) fs.rmSync(testDir, { recursive: true, force: true });
  78. });
  79. // ── 1. Fixture shape ──────────────────────────────────────────────────────
  80. describe('fixture shape', () => {
  81. it('indexes as a Go project with both layers present', () => {
  82. const files = cg.getFiles().map((f) => f.path);
  83. expect(files.filter((p) => p.endsWith('.go')).length).toBeGreaterThanOrEqual(15);
  84. expect(files.some((p) => p.startsWith(GENERATED_PREFIX))).toBe(true);
  85. expect(files.some((p) => p.startsWith('internal/usecase/'))).toBe(true);
  86. });
  87. it('flags every generated file and no hand-written one', () => {
  88. for (const file of cg.getFiles()) {
  89. expect(file.generated, `${file.path} generated flag`).toBe(
  90. file.path.startsWith(GENERATED_PREFIX),
  91. );
  92. }
  93. });
  94. it('carries generated files that ONLY a content header betrays — the #1500 case', () => {
  95. // Half the generated tree has ordinary names (`payslip.go`, `store.go`).
  96. // Path-only detection misses them; the CG-5 content check is what catches
  97. // them. Without these the fixture would be a .pb.go fixture, not a #1500 one.
  98. const contentOnly = [
  99. 'internal/gen/fkit/payroll/payslip.go',
  100. 'internal/gen/fkit/payroll/payroll_cycle.go',
  101. 'internal/gen/fkit/payroll/store.go',
  102. 'internal/gen/fkit/payroll/calculate.go',
  103. 'internal/gen/fkit/payroll/dto.go',
  104. 'internal/gen/fkit/employee/employee.go',
  105. 'internal/gen/fkit/timesheet/timesheet.go',
  106. ];
  107. for (const rel of contentOnly) {
  108. const source = fs.readFileSync(path.join(testDir, rel), 'utf-8');
  109. expect(isGeneratedFile(rel), `${rel} must NOT be detectable by path`).toBe(false);
  110. expect(hasGeneratedHeader(source), `${rel} must be detectable by header`).toBe(true);
  111. expect(cg.getFile(rel)?.generated, `${rel} indexed flag`).toBe(true);
  112. }
  113. // …beside the conventional path-detectable ones, so both channels are covered.
  114. expect(isGeneratedFile('internal/gen/payrollpb/payroll.pb.go')).toBe(true);
  115. });
  116. it('collides the generated layer with the hand-written one by name', () => {
  117. // A naive scorer sees two BuildPayslips and two Upserts and has no reason
  118. // to prefer the one that implements the business rule.
  119. for (const name of ['BuildPayslip', 'Upsert', 'Store']) {
  120. const files = new Set(cg.getNodesByName(name).map((n) => n.filePath));
  121. expect([...files].some((p) => p.startsWith(GENERATED_PREFIX)), `${name} generated`).toBe(true);
  122. expect([...files].some((p) => !p.startsWith(GENERATED_PREFIX)), `${name} hand-written`).toBe(true);
  123. }
  124. });
  125. it('resolves the hand-written workflow chain end-to-end in the graph', () => {
  126. const calleesOf = (name: string, file: string) => {
  127. const node = cg.getNodesByName(name).find((n) => n.filePath === file);
  128. expect(node, `${name} in ${file}`).toBeTruthy();
  129. return cg
  130. .getOutgoingEdges(node!.id)
  131. .filter((e) => e.kind === 'calls')
  132. .map((e) => cg.getNode(e.target))
  133. .filter((n): n is NonNullable<typeof n> => !!n);
  134. };
  135. // handler → use-case
  136. expect(
  137. calleesOf('RunCycle', 'internal/transport/httpapi/payroll_handler.go')
  138. .some((n) => n.name === 'RunCycle' && n.filePath === 'internal/usecase/payroll/cycle.go'),
  139. ).toBe(true);
  140. // use-case → the workflow
  141. expect(
  142. calleesOf('RunCycle', 'internal/usecase/payroll/cycle.go')
  143. .some((n) => n.name === 'runPayrollCycleAll'),
  144. ).toBe(true);
  145. // the workflow → build + persist
  146. const workflow = calleesOf('runPayrollCycleAll', 'internal/usecase/payroll/cycle.go');
  147. expect(
  148. workflow.some((n) => n.name === 'BuildPayslip' && n.filePath === 'internal/usecase/payroll/payslip_builder.go'),
  149. 'runPayrollCycleAll must reach the hand-written BuildPayslip',
  150. ).toBe(true);
  151. expect(workflow.some((n) => n.name === 'Upsert'), 'runPayrollCycleAll must reach an Upsert').toBe(true);
  152. });
  153. it('routes an HTTP entry point into the workflow', () => {
  154. const router = cg.getNodesInFile('internal/transport/httpapi/router.go');
  155. expect(router.some((n) => n.kind === 'route' || n.name === 'NewRouter')).toBe(true);
  156. });
  157. it('sizes the two layers so the size-driven render split actually bites', () => {
  158. // The mechanism the epic is about: a small file ships WHOLE, a large one
  159. // falls through to clipped clusters. The workflow file must stay above the
  160. // whole-file window and the generated files below it, or the fixture stops
  161. // reproducing anything.
  162. const lines = (rel: string) => fs.readFileSync(path.join(testDir, rel), 'utf-8').split('\n').length;
  163. expect(lines('internal/usecase/payroll/cycle.go')).toBeGreaterThan(220);
  164. for (const rel of ['internal/gen/fkit/payroll/payslip.go', 'internal/gen/fkit/payroll/payroll_cycle.go']) {
  165. expect(lines(rel)).toBeLessThan(220);
  166. }
  167. });
  168. it('answers the query at all', () => {
  169. expect(response.length).toBeGreaterThan(1000);
  170. expect(bytes.size).toBeGreaterThan(0);
  171. });
  172. });
  173. // ── 2. Budget allocation — the open bug ───────────────────────────────────
  174. describe('budget allocation', () => {
  175. const share = (predicate: (p: string) => boolean) => {
  176. let total = 0;
  177. for (const [file, n] of bytes) if (predicate(file)) total += n;
  178. return total / response.length;
  179. };
  180. const answerShare = () => share((p) => startsWithAny(p, ANSWER_PREFIXES));
  181. const generatedShare = () => share((p) => p.startsWith(GENERATED_PREFIX));
  182. /**
  183. * BASELINE 2026-08-03, BEFORE CG-10 (very-tiny tier, 13,000-char budget):
  184. * 23,020 chars allocated, cut to 16,011 by the 19,500 hard ceiling. The
  185. * generated CRUD delivered 57.4%; the hand-written layer 25.6%, all of it
  186. * domain types. `cycle.go` was allocated the single largest slice (7,052
  187. * chars, 30.6%) and delivered ZERO — the ceiling dropped its whole section —
  188. * so runPayrollCycleAll, the hand-written BuildPayslip and the real Upsert
  189. * never reached the agent.
  190. *
  191. * AFTER CG-10 (relevance scoring): the generated files rank #3/#4 instead of
  192. * #1/#2 — kind-weighted scoring plus a generated rank PENALTY on both the
  193. * score and the graph mass, rather than the old tiebreak-at-equal-score.
  194. * `cycle.go` now delivers 38.9% and the generated layer 23.5%. Four of the
  195. * five gates below are green and are now live regressions.
  196. *
  197. * AFTER CG-12 (score-proportional allocation): every file's share of the
  198. * envelope is reserved before anything renders, and a file under 15% of the
  199. * top weight gets no source at all — so the two generated files cliff to
  200. * pointers, hand their `maxFiles` slots to the hand-written store and
  201. * builder, and the answer group takes ~79% with the generated layer at 0%.
  202. * `func (s *Service) BuildPayslip` — the "calculate" half of the question —
  203. * finally reaches the agent. All gates below are live regressions now.
  204. */
  205. it('CG-10 GATE: concentrates the envelope on the hand-written workflow', () => {
  206. expect(answerShare()).toBeGreaterThanOrEqual(0.55);
  207. });
  208. it('CG-10 GATE: does not spend the envelope on the generated CRUD', () => {
  209. expect(generatedShare()).toBeLessThanOrEqual(0.25);
  210. });
  211. it('CG-10 GATE: ranks the generated CRUD below the hand-written workflow', () => {
  212. // The #1500 report in one assertion: before CG-10 the generated layer both
  213. // outscored AND out-delivered the use-case that implements the business rule.
  214. expect(answerShare()).toBeGreaterThan(generatedShare());
  215. });
  216. it('CG-10 GATE: delivers the workflow file it allocated the most bytes to', () => {
  217. expect(bytes.get('internal/usecase/payroll/cycle.go') ?? 0).toBeGreaterThan(0);
  218. });
  219. it('CG-10 GATE: puts the hand-written chain in the response, not its generated twin', () => {
  220. // Bare `Upsert` also matches the generated collision — these needles are
  221. // unique to the hand-written chain.
  222. expect(response).toContain('runPayrollCycleAll');
  223. expect(response).toContain('s.store.Upsert(ctx, slip)');
  224. });
  225. it('CG-12 GATE: delivers the calculation the question asks about', () => {
  226. // `payslip_builder.go` ranks #6 and the tier's maxFiles is 4 — it reaches
  227. // the response only because the two generated files cliff to pointers
  228. // WITHOUT consuming a slot. That slot hand-off is the CG-12 mechanism.
  229. expect(bytes.get('internal/usecase/payroll/payslip_builder.go') ?? 0).toBeGreaterThan(0);
  230. expect(response).toContain('func (s *Service) BuildPayslip');
  231. });
  232. it('CG-12 GATE: withholds the generated CRUD bytes but still names it', () => {
  233. // A cliffed file costs ~100 chars instead of ~4,500, and stays one
  234. // follow-up explore away — withholding is only cheap if it stays nameable.
  235. expect(bytes.get('internal/gen/fkit/payroll/payslip.go') ?? 0).toBe(0);
  236. expect(response).toContain('**Not shown above — explore these names for their source**');
  237. expect(response).toMatch(/internal\/gen\/fkit\/payroll\/payslip\.go: \w+:\d+/);
  238. });
  239. it('CG-14 GATE: holds the response inside the hard ceiling under real pressure', () => {
  240. // This fixture is the stress case for the ceiling, not just for the split:
  241. // 19 files put it in the very-tiny tier (13,000-char envelope) while the
  242. // answer genuinely needs more, so the render loop spends its full allowed
  243. // overshoot — ~19.3K against a 19.5K ceiling. That leaves ~1% of headroom,
  244. // which is exactly why this is worth pinning: the bound that matters is the
  245. // host's ~25K inline cap, and above it the response is written to a file
  246. // the agent Reads back, undoing the point of the tool.
  247. const budget = getExploreOutputBudget(cg.getFiles().length);
  248. const hardCeiling = Math.min(Math.round(budget.maxOutputChars * 1.5), 25000);
  249. expect(response.length).toBeGreaterThan(budget.maxOutputChars);
  250. expect(response.length).toBeLessThanOrEqual(hardCeiling);
  251. expect(response.length).toBeLessThan(25000);
  252. });
  253. it('records the shape of the allocation so a regression is legible', () => {
  254. // Not a gate — a snapshot of the split, so a future change that shifts the
  255. // numbers shows up in the diff rather than silently flipping a gate.
  256. const generated = generatedShare();
  257. const answer = answerShare();
  258. expect({
  259. generatedWinsEnvelope: generated > answer,
  260. workflowFileDelivers: (bytes.get('internal/usecase/payroll/cycle.go') ?? 0) > 0,
  261. builderFileDelivers: (bytes.get('internal/usecase/payroll/payslip_builder.go') ?? 0) > 0,
  262. }).toEqual({
  263. generatedWinsEnvelope: false,
  264. workflowFileDelivers: true,
  265. builderFileDelivers: true,
  266. });
  267. });
  268. });
  269. });