explore-oversize-member.test.ts 8.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179
  1. /**
  2. * Regression fixture for CG-30 — a cluster's top member may not overshoot the
  3. * file's budget without bound.
  4. *
  5. * `shrinkCluster` keeps the highest-importance member of an oversize cluster
  6. * WHOLE, deliberately: an empty file section sends the agent to Read, which is
  7. * the outcome explore exists to prevent. What it lacked was a bound. On the
  8. * originating repo one file emitted 22,376 chars against a 9,181-char
  9. * reservation — 2.44x — past both the per-file budget and the spine ceiling,
  10. * because its top member alone was that big. The overshoot is what collapses
  11. * `headroom` for every file ranked below it (CG-31), and it has a second face:
  12. * a member too big for the whole response ceiling makes the file drop out
  13. * entirely rather than render short.
  14. *
  15. * `__tests__/fixtures/oversize-member-ts/` reproduces both permanently. Three
  16. * report builders compete for one envelope, each a single long function far
  17. * bigger than any reservation it can earn beside its siblings. Measured against
  18. * the pre-fix build, this fixture produced:
  19. *
  20. * monthly.ts 12,391 chars emitted on a 3,334 budget (3.7x)
  21. * quarterly.ts dropped entirely — no headroom left (the CG-31 half)
  22. *
  23. * The gate below is that both are now bounded AND delivered: the bound cuts the
  24. * overshoot, and cutting the overshoot is what buys back the starved file.
  25. *
  26. * Measured against `spendable`, not `reserved`: the render paths bound
  27. * themselves by the reservation PLUS whatever slack the files above left on the
  28. * table, so a file legitimately spending inherited slack is not an overshoot.
  29. */
  30. import { describe, it, expect, beforeAll, afterAll } from 'vitest';
  31. import * as fs from 'fs';
  32. import * as path from 'path';
  33. import * as os from 'os';
  34. import CodeGraph from '../src/index';
  35. import { ToolHandler } from '../src/mcp/tools';
  36. import { attributeSourceBytes } from '../src/mcp/explore-diagnostics';
  37. import type { ExploreDiagnosticReport, ExploreDiagnosticFile } from '../src/mcp/explore-diagnostics';
  38. const FIXTURE_SRC = path.join(__dirname, 'fixtures', 'oversize-member-ts');
  39. /** A symbol bag spanning the three builders — the sibling files compete. */
  40. const QUERY = 'buildMonthlyReport buildWeeklyReport buildQuarterlyReport formatReportRow persistReport';
  41. /** The giant: one ~24K function, far past the whole-response ceiling. */
  42. const GIANT = 'src/report/monthly.ts';
  43. /** Mid-size: one ~11K function — the file the giant's overshoot used to starve. */
  44. const STARVED = 'src/report/quarterly.ts';
  45. /** The bound: 1.5x, the same multiple the spine ceiling already draws. */
  46. const OVERSHOOT_FACTOR = 1.5;
  47. describe('CG-30 — an oversize cluster member is bounded, not unbounded', () => {
  48. let testDir: string;
  49. let cg: CodeGraph;
  50. let response: string;
  51. let report: ExploreDiagnosticReport;
  52. let bytes: Map<string, number>;
  53. const fileOf = (p: string): ExploreDiagnosticFile => {
  54. const rec = report.files.find((f) => f.path === p);
  55. if (!rec) throw new Error(`${p} absent from the diagnostic report`);
  56. return rec;
  57. };
  58. /** What the render paths actually bound themselves by. */
  59. const budgetOf = (rec: ExploreDiagnosticFile): number => rec.spendable ?? rec.allowance ?? 0;
  60. beforeAll(async () => {
  61. testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-cg30-'));
  62. fs.cpSync(FIXTURE_SRC, testDir, { recursive: true });
  63. fs.rmSync(path.join(testDir, '.codegraph'), { recursive: true, force: true });
  64. cg = CodeGraph.initSync(testDir);
  65. await cg.indexAll();
  66. // The per-file budget is only observable through the diagnostic sidecar, and
  67. // the whole gate is "emitted vs what the file was allowed to spend".
  68. const sidecar = path.join(testDir, 'explore-diag.jsonl');
  69. const previous = process.env.CODEGRAPH_EXPLORE_DEBUG;
  70. process.env.CODEGRAPH_EXPLORE_DEBUG = sidecar;
  71. try {
  72. const handler = new ToolHandler(cg);
  73. const result = await handler.execute('codegraph_explore', { query: QUERY });
  74. response = result.content?.[0]?.text ?? '';
  75. } finally {
  76. if (previous === undefined) delete process.env.CODEGRAPH_EXPLORE_DEBUG;
  77. else process.env.CODEGRAPH_EXPLORE_DEBUG = previous;
  78. }
  79. const written = fs.readFileSync(sidecar, 'utf-8').trim().split('\n').filter(Boolean);
  80. report = JSON.parse(written[written.length - 1]!) as ExploreDiagnosticReport;
  81. bytes = attributeSourceBytes(response);
  82. }, 120_000);
  83. afterAll(() => {
  84. if (cg) cg.destroy();
  85. if (testDir && fs.existsSync(testDir)) fs.rmSync(testDir, { recursive: true, force: true });
  86. });
  87. // ── Fixture shape — if these rot, the gate below means nothing ─────────────
  88. describe('fixture shape', () => {
  89. it('holds single members far bigger than any budget they can earn', () => {
  90. for (const file of [GIANT, STARVED]) {
  91. const source = fs.readFileSync(path.join(testDir, file), 'utf-8');
  92. const top = cg.getNodesInFile(file)
  93. .filter((n) => n.kind === 'function')
  94. .sort((a, b) => (b.endLine - b.startLine) - (a.endLine - a.startLine))[0];
  95. expect(top, `${file} has no function node`).toBeDefined();
  96. // One symbol, most of the file — the "top member alone is oversize" shape.
  97. expect(top!.endLine - top!.startLine).toBeGreaterThan(180);
  98. expect(source.length).toBeGreaterThan(budgetOf(fileOf(file)) * 2);
  99. }
  100. });
  101. it('is too long to ship whole, so both render through the cluster path', () => {
  102. for (const file of [GIANT, STARVED]) {
  103. const lineCount = fs.readFileSync(path.join(testDir, file), 'utf-8').split('\n').length;
  104. // Past WHOLE_FILE_MAX_LINES (220 for a non-central file), so the
  105. // whole-file paths — grace and buy — cannot claim it.
  106. expect(lineCount, file).toBeGreaterThan(220);
  107. expect(fileOf(file).render, file).toBe('clusters');
  108. }
  109. });
  110. });
  111. // ── The gate ──────────────────────────────────────────────────────────────
  112. describe('bounded overshoot', () => {
  113. it('CG-30 GATE: the giant no longer emits a multiple of its budget', () => {
  114. const rec = fileOf(GIANT);
  115. // Pre-fix this file emitted 12,391 on a 3,334 budget (3.7x).
  116. expect(rec.emittedChars).toBeLessThanOrEqual(
  117. Math.round(budgetOf(rec) * OVERSHOOT_FACTOR) + 1);
  118. });
  119. it('CG-30 GATE: no clustered file emits past 1.5x what it may spend', () => {
  120. const over = report.files
  121. .filter((f) => f.render === 'clusters' && budgetOf(f) > 0)
  122. .filter((f) => f.emittedChars > Math.round(budgetOf(f) * OVERSHOOT_FACTOR) + 1)
  123. .map((f) => `${f.path}: ${f.emittedChars} of ${budgetOf(f)}`);
  124. expect(over).toEqual([]);
  125. });
  126. it('CG-31: the file the overshoot used to starve is delivered', () => {
  127. // Pre-fix: dropped with skip reason `budget-clusters` — the giant above it
  128. // had already spent the headroom this file needed.
  129. expect(fileOf(STARVED).skipped).toBeNull();
  130. expect(bytes.get(STARVED) ?? 0).toBeGreaterThan(0);
  131. });
  132. it('never emits an empty section — the invariant the old rule protected', () => {
  133. for (const rec of report.files) {
  134. if (rec.render !== 'clusters') continue;
  135. expect(rec.emittedChars, rec.path).toBeGreaterThan(0);
  136. }
  137. // And the windowed file still leads with the symbol the query named.
  138. expect(response).toContain('export function buildMonthlyReport');
  139. });
  140. it('cuts on whole lines — a body is never sliced mid-line', () => {
  141. const source = fs.readFileSync(path.join(testDir, GIANT), 'utf-8').split('\n');
  142. const numbered = response
  143. .split('\n')
  144. .map((l) => /^(\d+)\t(.*)$/.exec(l))
  145. .filter((m): m is RegExpExecArray => m !== null)
  146. .filter((m) => Number(m[1]) >= 1 && Number(m[1]) <= source.length);
  147. const matching = numbered.filter((m) => source[Number(m[1]) - 1] === m[2]);
  148. // Every line the response numbers for this file is that whole source line.
  149. expect(matching.length).toBeGreaterThan(20);
  150. });
  151. it('reports the cut rather than presenting a window as the whole file', () => {
  152. expect(fileOf(GIANT).clipped).toBe(true);
  153. });
  154. it('keeps the response inside the hard ceiling', () => {
  155. expect(report.envelope.chars).toBeLessThanOrEqual(report.budget.hardCeiling);
  156. });
  157. });
  158. });