explore-named-symbol-render.test.ts 8.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199
  1. /**
  2. * Standing gate for THE GUARANTEE (task CG-38): if the agent names a symbol and
  3. * that symbol's file is admitted to the response, the symbol's DEFINITION renders.
  4. *
  5. * This is the measurement the CG-24 epic never had. Its probes all score the
  6. * response in aggregate — envelope share, per-file spend, source totals, file
  7. * counts — and every one of them is green on a response that returns 25K of
  8. * source from the right file and still omits the function the agent asked for by
  9. * name. That is what CG-38 was: on a 1,414-line Svelte store, `queueMessage`
  10. * (L1087) and `flushQueuedMessages` (L1102) never rendered even though their file
  11. * won rank #1 with 67% of the envelope; the agent got the same-stem
  12. * `QueuedMessage` INTERFACE at L70 and had to Read the file to find the
  13. * functions. Longstanding, not an epic regression — the controlled bisect (index
  14. * held fixed, engine varied across every epic merge point) found it at every
  15. * build including pre-epic.
  16. *
  17. * Two independent causes, and the fixture below fails on either:
  18. *
  19. * 1. `buildFlowFromNamedSymbols` returned EMPTY — throwing away the NAMED-SYMBOL
  20. * IDENTITY along with the narrative — whenever the named symbols happened not
  21. * to form a call chain. Two sibling closures in one factory produce no chain,
  22. * no synthesized hop and no dispatch boundary, so both defs lost the
  23. * importance-9 rank that the named-def injection exists to give them.
  24. * 2. The ceiling trim cut in SOURCE ORDER, so whatever survived the shrink at
  25. * the END of a large file was always the first thing dropped.
  26. *
  27. * The fixture mirrors the reported file's geometry deliberately: a decoy
  28. * same-stem interface at L70, a factory closure at L104 spanning ~92% of the file
  29. * (so every symbol merges into ONE cluster), the target functions past L1000, and
  30. * a 2,500-line generated `.d.ts` for the ranker to penalise.
  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. const FIXTURE = 'tail-render-ts';
  39. const TARGET = 'src/lib/session-store.ts';
  40. let dir: string;
  41. let cg: CodeGraph;
  42. /** Every `<n>\t<text>` line number the response actually sent. */
  43. function renderedLines(response: string): Set<number> {
  44. const out = new Set<number>();
  45. for (const m of response.matchAll(/^(\d+)\t/gm)) out.add(Number(m[1]));
  46. return out;
  47. }
  48. async function explore(query: string): Promise<string> {
  49. const res = await new ToolHandler(cg).execute('codegraph_explore', { query });
  50. return res.content?.[0]?.text ?? '';
  51. }
  52. function defLineOf(name: string): number {
  53. const node = cg.getNodesByName(name).find((n) => n.filePath === TARGET && n.startLine > 0);
  54. expect(node, `${name} is not indexed in ${TARGET}`).toBeDefined();
  55. return node!.startLine;
  56. }
  57. beforeAll(async () => {
  58. dir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-cg38-'));
  59. fs.cpSync(path.join(__dirname, 'fixtures', FIXTURE), dir, { recursive: true });
  60. fs.rmSync(path.join(dir, '.codegraph'), { recursive: true, force: true });
  61. cg = CodeGraph.initSync(dir);
  62. await cg.indexAll();
  63. }, 180_000);
  64. afterAll(() => {
  65. cg?.destroy();
  66. if (dir && fs.existsSync(dir)) fs.rmSync(dir, { recursive: true, force: true });
  67. });
  68. describe('CG-38 fixture shape — if this rots, the gate below means nothing', () => {
  69. it('puts the target functions past L1000 of a ~1,400-line file', () => {
  70. const lines = fs.readFileSync(path.join(dir, TARGET), 'utf-8').split('\n');
  71. expect(lines.length).toBeGreaterThan(1300);
  72. expect(defLineOf('queueMessage')).toBeGreaterThan(1000);
  73. expect(defLineOf('flushQueuedMessages')).toBeGreaterThan(1000);
  74. });
  75. it('wraps them in a closure spanning most of the file, so they all cluster as one', () => {
  76. const lines = fs.readFileSync(path.join(dir, TARGET), 'utf-8').split('\n');
  77. const factory = cg.getNodesByName('createSessionStore')
  78. .find((n) => n.filePath === TARGET)!;
  79. expect(factory).toBeDefined();
  80. expect(factory.endLine - factory.startLine + 1).toBeGreaterThan(lines.length * 0.5);
  81. });
  82. it('carries the same-stem decoy near the top', () => {
  83. const decoy = cg.getNodesByName('QueuedMessage').find((n) => n.filePath === TARGET)!;
  84. expect(decoy).toBeDefined();
  85. expect(decoy.kind).toBe('interface');
  86. expect(decoy.startLine).toBeLessThan(100);
  87. });
  88. it('carries a generated declaration file for the ranker to penalise', () => {
  89. const dts = path.join(dir, 'types/worker-configuration.d.ts');
  90. expect(fs.existsSync(dts)).toBe(true);
  91. expect(fs.readFileSync(dts, 'utf-8').split('\n').length).toBeGreaterThan(2000);
  92. });
  93. it('neither target calls the other — that absence is what produced no flow', () => {
  94. const queue = cg.getNodesByName('queueMessage').find((n) => n.filePath === TARGET)!;
  95. const flush = cg.getNodesByName('flushQueuedMessages').find((n) => n.filePath === TARGET)!;
  96. const between = [...cg.getCallees(queue.id), ...cg.getCallees(flush.id)]
  97. .filter(({ node }) => node.id === queue.id || node.id === flush.id);
  98. expect(between).toHaveLength(0);
  99. });
  100. });
  101. describe('CG-38 — an agent-named symbol renders its definition', () => {
  102. /**
  103. * Both reported query shapes. They fail for different reasons — the symbol bag
  104. * never built a flow at all, the prose question built one and then lost the
  105. * tail to the ceiling trim — so a fix for one does not imply the other.
  106. */
  107. const CASES: Array<{ shape: string; query: string; symbols: string[] }> = [
  108. {
  109. shape: 'symbol bag',
  110. query: 'queueMessage flushQueuedMessages',
  111. symbols: ['queueMessage', 'flushQueuedMessages'],
  112. },
  113. {
  114. shape: 'prose question',
  115. query: 'how does queueMessage hand its entries to flushQueuedMessages',
  116. symbols: ['queueMessage', 'flushQueuedMessages'],
  117. },
  118. {
  119. shape: 'three siblings, with the decoy interface competing',
  120. query: 'explain queueMessage, removeQueuedMessage and flushQueuedMessages',
  121. symbols: ['queueMessage', 'removeQueuedMessage', 'flushQueuedMessages'],
  122. },
  123. ];
  124. for (const { shape, query, symbols } of CASES) {
  125. it(`renders every named definition — ${shape}`, async () => {
  126. const response = await explore(query);
  127. const lines = renderedLines(response);
  128. for (const name of symbols) {
  129. const line = defLineOf(name);
  130. // The NAME alone proves nothing: it appears in the section header's
  131. // symbol list and at call sites whether or not the body was sent. Only
  132. // the definition LINE being among the rendered lines counts.
  133. expect(lines.has(line), `${name} (${TARGET}:${line}) did not render for "${query}"`)
  134. .toBe(true);
  135. }
  136. }, 120_000);
  137. }
  138. it('never steers the agent to Read', async () => {
  139. const response = await explore('queueMessage flushQueuedMessages');
  140. expect(response).not.toMatch(/\buse Read\b|\bRead the file\b/i);
  141. }, 120_000);
  142. });
  143. describe('CG-38 — a penalty on one file cannot shrink an unrelated file\'s render', () => {
  144. /**
  145. * The issue's sharpest lead: on an index where the generated `.d.ts` was NOT
  146. * flagged, the target file rendered ~581 lines including both symbols; on an
  147. * index where it WAS flagged, the same engine rendered 12. `rankPenalty` scales
  148. * `fileGraphScore`, which moves the relevance gate (6% of max) and so reshuffles
  149. * the admitted set — a demotion of one file must not cost an unrelated
  150. * top-ranked file its source.
  151. *
  152. * Flipping `files.generated` on that one row holds the INDEX constant and
  153. * attributes any delta to the ranker alone (the CG-25 method).
  154. */
  155. const DTS = 'types/worker-configuration.d.ts';
  156. const QUERY = 'queueMessage flushQueuedMessages';
  157. it('renders the same named definitions with the .d.ts flagged and unflagged', async () => {
  158. const setGenerated = (value: number) => {
  159. // eslint-disable-next-line @typescript-eslint/no-explicit-any
  160. const db = (cg as any).db?.getDatabase?.() ?? (cg as any).db?.db;
  161. db.prepare('UPDATE files SET generated = ? WHERE path = ?').run(value, DTS);
  162. };
  163. const linesFor = async () => renderedLines(await explore(QUERY));
  164. const flagged = await linesFor();
  165. setGenerated(0);
  166. try {
  167. const unflagged = await linesFor();
  168. for (const name of ['queueMessage', 'flushQueuedMessages']) {
  169. const line = defLineOf(name);
  170. expect(flagged.has(line), `${name} missing with the .d.ts FLAGGED`).toBe(true);
  171. expect(unflagged.has(line), `${name} missing with the .d.ts UNFLAGGED`).toBe(true);
  172. }
  173. // The guarantee is about the named defs, not byte equality — the penalty is
  174. // supposed to move bytes around. What it must never do is cost the
  175. // top-ranked file the source the agent asked for.
  176. expect(unflagged.size).toBeGreaterThan(0);
  177. } finally {
  178. setGenerated(1);
  179. }
  180. }, 180_000);
  181. });