explore-factory-closure.test.ts 7.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157
  1. /**
  2. * Regression gate for the FACTORY-CLOSURE file shape (task CG-27).
  3. *
  4. * A `createFoo()` that returns an object of closures spans almost all of its
  5. * file, so its indexed range is an ENVELOPE around every symbol the query
  6. * actually wants. Svelte 5 rune stores, React custom-hook modules, IIFE
  7. * module-pattern JS and Zustand's `create((set, get) => ({ … }))` are all
  8. * written this way, so it is a shape rather than a one-repo quirk.
  9. *
  10. * CG-27 asked whether the >50%-of-file envelope drop — which fires for `class`,
  11. * `struct`, `interface` and friends but not for `function`/`method` — should be
  12. * extended to cover it. **Measured, it should not**, and the issue was closed as
  13. * obsolete: `docs/benchmarks/explore-factory-closure-cg27.md` has the numbers.
  14. * Two independent mechanisms already absorb the shape:
  15. *
  16. * - `shrinkCluster` orders members by (importance desc, SIZE ASC) and refuses
  17. * any member that overruns the cap once something is kept, so a file-spanning
  18. * member is only ever selected when it is the sole member of the top
  19. * importance tier;
  20. * - when it IS selected, CG-30 windows it on whole lines rather than emitting
  21. * it whole, so the file still delivers bounded, readable source.
  22. *
  23. * Dropping the range instead SPLITS the file into several clusters, and only the
  24. * first-chosen cluster may be shrunk — measured, a trivial 7-line cluster won the
  25. * density tiebreak and the answer-bearing cluster was dropped whole, taking the
  26. * rank-#1 file from 7,539 chars and 7 of 11 inner definitions to 397 and none.
  27. *
  28. * So this file pins the OUTCOME, not the mechanism: whatever future work does to
  29. * clustering, a factory-closure file must keep delivering the closures inside it
  30. * — that is what stops the agent Reading the file back.
  31. */
  32. import { describe, it, expect, beforeAll, afterAll } from 'vitest';
  33. import * as fs from 'fs';
  34. import * as path from 'path';
  35. import * as os from 'os';
  36. import CodeGraph from '../src/index';
  37. import { ToolHandler } from '../src/mcp/tools';
  38. import type { ExploreDiagnosticReport } from '../src/mcp/explore-diagnostics';
  39. const FIXTURE_SRC = path.join(__dirname, 'fixtures', 'factory-closure-ts');
  40. /** The factory file, and the closure factory whose body is nearly all of it. */
  41. const TARGET = 'src/stores/dashboard-store.ts';
  42. const FACTORY = 'createDashboardStore';
  43. /** Prose the way a newcomer asks it, naming two of the closures inside. */
  44. const QUERY = 'how does the dashboard store refresh its metrics and apply a filter';
  45. describe('CG-27 — a factory-closure file delivers the closures inside it', () => {
  46. let testDir: string;
  47. let cg: CodeGraph;
  48. let response: string;
  49. let report: ExploreDiagnosticReport;
  50. /** Source lines of TARGET the response actually carried. */
  51. let delivered: Set<number>;
  52. let sourceLines: string[];
  53. beforeAll(async () => {
  54. testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-cg27-'));
  55. fs.cpSync(FIXTURE_SRC, testDir, { recursive: true });
  56. fs.rmSync(path.join(testDir, '.codegraph'), { recursive: true, force: true });
  57. cg = CodeGraph.initSync(testDir);
  58. await cg.indexAll();
  59. const sidecar = path.join(testDir, 'explore-diag.jsonl');
  60. const previous = process.env.CODEGRAPH_EXPLORE_DEBUG;
  61. process.env.CODEGRAPH_EXPLORE_DEBUG = sidecar;
  62. try {
  63. response = (await new ToolHandler(cg).execute('codegraph_explore', { query: QUERY }))
  64. .content?.[0]?.text ?? '';
  65. } finally {
  66. if (previous === undefined) delete process.env.CODEGRAPH_EXPLORE_DEBUG;
  67. else process.env.CODEGRAPH_EXPLORE_DEBUG = previous;
  68. }
  69. const written = fs.readFileSync(sidecar, 'utf-8').trim().split('\n').filter(Boolean);
  70. report = JSON.parse(written[written.length - 1]!) as ExploreDiagnosticReport;
  71. // A line counts as delivered only when the response numbers it AND the text
  72. // matches that source line — a line number quoted in prose must not count.
  73. sourceLines = fs.readFileSync(path.join(testDir, TARGET), 'utf-8').split('\n');
  74. delivered = new Set();
  75. for (const line of response.split('\n')) {
  76. const m = /^(\d+)\t(.*)$/.exec(line);
  77. if (!m) continue;
  78. const n = Number(m[1]);
  79. if (n >= 1 && n <= sourceLines.length && sourceLines[n - 1] === m[2]) delivered.add(n);
  80. }
  81. }, 120_000);
  82. afterAll(() => {
  83. if (cg) cg.destroy();
  84. if (testDir && fs.existsSync(testDir)) fs.rmSync(testDir, { recursive: true, force: true });
  85. });
  86. /** The closures defined inside the factory, straight from the index. */
  87. const innerClosures = () => {
  88. const nodes = cg.getNodesInFile(TARGET);
  89. const factory = nodes.find((n) => n.name === FACTORY)!;
  90. return nodes.filter((n) => (n.kind === 'function' || n.kind === 'method')
  91. && n.name !== FACTORY
  92. && n.startLine > factory.startLine && n.endLine <= factory.endLine);
  93. };
  94. describe('fixture shape — if this rots, the gate below means nothing', () => {
  95. it('holds one symbol spanning most of the file, with closures inside it', () => {
  96. const factory = cg.getNodesInFile(TARGET).find((n) => n.name === FACTORY);
  97. expect(factory, `${TARGET} has no ${FACTORY} node`).toBeDefined();
  98. // The envelope condition the >50% drop tests for — and `function`, the kind
  99. // that drop does not cover.
  100. expect(factory!.kind).toBe('function');
  101. expect(factory!.endLine - factory!.startLine + 1)
  102. .toBeGreaterThan(sourceLines.length * 0.5);
  103. expect(innerClosures().length).toBeGreaterThanOrEqual(8);
  104. });
  105. it('is too long to ship whole, so it renders through the cluster path', () => {
  106. // Past WHOLE_FILE_MAX_LINES (220 for a non-central file): the whole-file
  107. // grace and buy arms cannot claim it, so the envelope actually matters.
  108. expect(sourceLines.length).toBeGreaterThan(220);
  109. expect(report.files.find((f) => f.path === TARGET)?.render).toBe('clusters');
  110. });
  111. });
  112. describe('the gate', () => {
  113. it('delivers the closures the query named, not just the factory head', () => {
  114. const inner = innerClosures();
  115. for (const name of ['refreshMetrics', 'applyFilter']) {
  116. const node = inner.find((n) => n.name === name)!;
  117. expect(node, `${name} is not an inner closure any more`).toBeDefined();
  118. expect(delivered.has(node.startLine), `${name} definition line not delivered`).toBe(true);
  119. }
  120. });
  121. it('delivers most of the closures, spread across the file', () => {
  122. const inner = innerClosures();
  123. const hit = inner.filter((n) => delivered.has(n.startLine));
  124. // Measured on the `feature/CG-24` tip: 7 of 11. The bar is half, so ordinary
  125. // budget movement does not fail the suite, but losing the closures does.
  126. expect(hit.length).toBeGreaterThanOrEqual(Math.ceil(inner.length / 2));
  127. // Not one contiguous head window off the top of the factory: the whole
  128. // point is that selection reaches symbols deep in the body.
  129. const last = inner[inner.length - 1]!;
  130. const deepest = Math.max(...hit.map((n) => n.startLine));
  131. expect(deepest).toBeGreaterThan((last.startLine + inner[0]!.startLine) / 2);
  132. });
  133. it('never renders an empty section for the file', () => {
  134. const rec = report.files.find((f) => f.path === TARGET)!;
  135. expect(rec.emittedChars).toBeGreaterThan(0);
  136. expect(delivered.size).toBeGreaterThan(20);
  137. });
  138. it('keeps the response inside the hard ceiling', () => {
  139. expect(report.envelope.chars).toBeLessThanOrEqual(report.budget.hardCeiling);
  140. });
  141. });
  142. });