explore-cluster-starvation.test.ts 7.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170
  1. /**
  2. * Regression gate for CLUSTER-LEVEL STARVATION inside one file (task CG-36).
  3. *
  4. * A file's ranked clusters used to be all-or-nothing past the first one: the
  5. * top-ranked cluster was taken (shrunk to fit if it had to be), and every
  6. * cluster below it was rendered whole and then either fit the remainder or was
  7. * dropped entirely. On a file whose top-ranked cluster is TRIVIAL that discards
  8. * the answer — django's `db/models/sql/query.py` kept a 22-line glue cluster and
  9. * dropped the 624-line `Query` body beneath it, spending 1,923 of a 7,947
  10. * reservation, and okhttp's `RealInterceptorChain.kt` did the same behind its
  11. * import header.
  12. *
  13. * What makes it hard to see is that the response stays FULL: the unspent
  14. * reservation carries forward exactly as designed, so a lower-scoring file takes
  15. * the bytes and every envelope-share measure still looks healthy. The gate is
  16. * therefore per-file spend, not share.
  17. *
  18. * Two fixtures, pulling in opposite directions — read them together:
  19. *
  20. * - `starved-cluster-ts` is the defect. Its answer-bearing cluster must be
  21. * SHRUNK into whatever the trivial cluster left, not dropped.
  22. * - `dense-header-ts` is the Session.swift shape that cluster ranking puts
  23. * importance ahead of density FOR. Its query's methods sit ~200 lines under
  24. * a dense property list, and they must keep winning the budget. Any future
  25. * rework of selection or shrinking has to satisfy both.
  26. */
  27. import { describe, it, expect, beforeAll, afterAll } from 'vitest';
  28. import * as fs from 'fs';
  29. import * as path from 'path';
  30. import * as os from 'os';
  31. import CodeGraph from '../src/index';
  32. import { ToolHandler } from '../src/mcp/tools';
  33. import type { ExploreDiagnosticReport } from '../src/mcp/explore-diagnostics';
  34. interface Run {
  35. dir: string;
  36. cg: CodeGraph;
  37. response: string;
  38. report: ExploreDiagnosticReport;
  39. }
  40. /** Copy a fixture tree to a temp dir, index it, and run one explore call. */
  41. async function runFixture(fixture: string, query: string): Promise<Run> {
  42. const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-cg36-'));
  43. fs.cpSync(path.join(__dirname, 'fixtures', fixture), dir, { recursive: true });
  44. fs.rmSync(path.join(dir, '.codegraph'), { recursive: true, force: true });
  45. const cg = CodeGraph.initSync(dir);
  46. await cg.indexAll();
  47. const sidecar = path.join(dir, 'explore-diag.jsonl');
  48. const previous = process.env.CODEGRAPH_EXPLORE_DEBUG;
  49. process.env.CODEGRAPH_EXPLORE_DEBUG = sidecar;
  50. let response: string;
  51. try {
  52. response = (await new ToolHandler(cg).execute('codegraph_explore', { query }))
  53. .content?.[0]?.text ?? '';
  54. } finally {
  55. if (previous === undefined) delete process.env.CODEGRAPH_EXPLORE_DEBUG;
  56. else process.env.CODEGRAPH_EXPLORE_DEBUG = previous;
  57. }
  58. const written = fs.readFileSync(sidecar, 'utf-8').trim().split('\n').filter(Boolean);
  59. return { dir, cg, response, report: JSON.parse(written[written.length - 1]!) };
  60. }
  61. function teardown(run: Run | undefined): void {
  62. if (!run) return;
  63. run.cg.destroy();
  64. if (fs.existsSync(run.dir)) fs.rmSync(run.dir, { recursive: true, force: true });
  65. }
  66. describe('CG-36 — a trivial cluster must not starve the answer-bearing one', () => {
  67. const TARGET = 'src/pipeline/chain.ts';
  68. const QUERY = 'how does a request travel from sendRequest to the socket';
  69. let run: Run;
  70. let target: ExploreDiagnosticReport['files'][number];
  71. beforeAll(async () => {
  72. run = await runFixture('starved-cluster-ts', QUERY);
  73. target = run.report.files.find((f) => f.path === TARGET)!;
  74. }, 120_000);
  75. afterAll(() => teardown(run));
  76. describe('fixture shape — if this rots, the gate below means nothing', () => {
  77. it('renders through the cluster path, with the answer past the trivial helper', () => {
  78. expect(target, `${TARGET} is not among the ranked candidates`).toBeDefined();
  79. expect(target.render).toBe('clusters');
  80. // The helper the entry point calls directly, and the class it does not.
  81. const nodes = run.cg.getNodesInFile(TARGET);
  82. const helper = nodes.find((n) => n.name === 'describeChain')!;
  83. const proceed = nodes.find((n) => n.name === 'proceed')!;
  84. expect(helper).toBeDefined();
  85. expect(proceed).toBeDefined();
  86. // Far enough apart to cluster separately at any gap threshold we ship.
  87. expect(proceed.startLine - helper.endLine).toBeGreaterThan(20);
  88. });
  89. it('reserves the file the largest share, so an unspent share is a defect', () => {
  90. expect(target.allowance ?? 0).toBeGreaterThan(4000);
  91. const others = run.report.files.filter((f) => f.path !== TARGET);
  92. for (const f of others) expect(f.allowance ?? 0).toBeLessThan(target.allowance!);
  93. });
  94. });
  95. describe('the gate', () => {
  96. it('spends most of the reservation it was given', () => {
  97. // 28.8% on the CG-24 epic tip, 131% (its reservation plus carry-forward
  98. // slack it can now actually use) with the fix. The bar is deliberately
  99. // well below both so ordinary budget movement does not fail the suite.
  100. expect(target.finalChars / target.allowance!).toBeGreaterThan(0.6);
  101. });
  102. it('delivers the flow the query asked about, not just the helper beside it', () => {
  103. // Both ends of the in-file flow, in the cluster that used to be dropped.
  104. expect(run.response).toContain('async proceed(request: PipelineRequest)');
  105. expect(run.response).toContain('private async writeAndRead(request: PipelineRequest)');
  106. });
  107. it('keeps the response inside the hard ceiling', () => {
  108. expect(run.report.envelope.chars).toBeLessThanOrEqual(run.report.budget.hardCeiling);
  109. });
  110. });
  111. });
  112. describe('CG-36 — a dense declaration block must not bury the query\'s methods', () => {
  113. const TARGET = 'src/net/session.ts';
  114. const QUERY = 'how does perform create a URLRequest and start the task';
  115. let run: Run;
  116. let target: ExploreDiagnosticReport['files'][number];
  117. beforeAll(async () => {
  118. run = await runFixture('dense-header-ts', QUERY);
  119. target = run.report.files.find((f) => f.path === TARGET)!;
  120. }, 120_000);
  121. afterAll(() => teardown(run));
  122. describe('fixture shape — if this rots, the gate below means nothing', () => {
  123. it('has a dense low-importance header and the named methods far below it', () => {
  124. expect(target, `${TARGET} is not among the ranked candidates`).toBeDefined();
  125. expect(target.render).toBe('clusters');
  126. const nodes = run.cg.getNodesInFile(TARGET);
  127. const perform = nodes.find((n) => n.name === 'perform')!;
  128. expect(perform).toBeDefined();
  129. // The header block: many adjacent declarations above the first named
  130. // method, which is what makes it the densest region of the file.
  131. const above = nodes.filter((n) => n.endLine < perform.startLine
  132. && (n.kind === 'property' || n.kind === 'field' || n.kind === 'method'));
  133. expect(above.length).toBeGreaterThan(20);
  134. expect(perform.startLine).toBeGreaterThan(150);
  135. });
  136. });
  137. describe('the gate', () => {
  138. it('delivers all three methods the query named', () => {
  139. expect(run.response).toContain('async perform(url: string, method: string');
  140. expect(run.response).toContain('didCreateURLRequest(request: URLRequest)');
  141. expect(run.response).toContain('task(request: URLRequest, identifier: number)');
  142. });
  143. it('spends the file\'s reservation on them', () => {
  144. expect(target.finalChars / target.allowance!).toBeGreaterThan(0.6);
  145. });
  146. it('keeps the response inside the hard ceiling', () => {
  147. expect(run.report.envelope.chars).toBeLessThanOrEqual(run.report.budget.hardCeiling);
  148. });
  149. });
  150. });