1
0

explore-diagnostics.test.ts 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303
  1. /**
  2. * Per-file allocation diagnostic for codegraph_explore (CG-4).
  3. *
  4. * The instrument ships in the product binary, so the load-bearing property is
  5. * NOT what it reports — it's that it reports NOTHING unless asked. An explore
  6. * response is the agent's context; a diagnostic that perturbs it by one byte
  7. * invalidates every A/B measurement taken with it on, which is the exact thing
  8. * the rest of the budget-allocation work depends on.
  9. *
  10. * So the first block pins byte-identical output across on/off, and only then
  11. * do we assert the report's shape and internal consistency.
  12. */
  13. import { describe, it, expect, beforeAll, afterAll, afterEach, vi } from 'vitest';
  14. import * as fs from 'fs';
  15. import * as path from 'path';
  16. import * as os from 'os';
  17. import { ToolHandler } from '../src/mcp/tools';
  18. import { attributeSourceBytes } from '../src/mcp/explore-diagnostics';
  19. import CodeGraph from '../src/index';
  20. const DEBUG_ENV = 'CODEGRAPH_EXPLORE_DEBUG';
  21. /** Restore the env var to "unset" — `delete` matters; '' is a distinct case. */
  22. function clearDebugEnv(): void {
  23. delete process.env[DEBUG_ENV];
  24. }
  25. describe('attributeSourceBytes', () => {
  26. it('attributes a fenced block to the file section header above it', () => {
  27. const text = [
  28. '**Exploration: x**',
  29. '',
  30. '**`src/a.ts`** — foo(function)',
  31. '',
  32. '```typescript',
  33. '1\tconst a = 1;',
  34. '2\tconst b = 2;',
  35. '```',
  36. '',
  37. '**`src/b.ts`** — bar(function)',
  38. '',
  39. '```typescript',
  40. '1\tconst c = 3;',
  41. '```',
  42. '',
  43. ].join('\n');
  44. const bytes = attributeSourceBytes(text);
  45. expect(bytes.get('src/a.ts')).toBe('1\tconst a = 1;\n2\tconst b = 2;'.length);
  46. expect(bytes.get('src/b.ts')).toBe('1\tconst c = 3;'.length);
  47. });
  48. it('sums multiple fenced blocks under one file header', () => {
  49. const text = [
  50. '**`src/a.ts`** — foo(function)',
  51. '',
  52. '```ts',
  53. 'aa',
  54. '```',
  55. '',
  56. '```ts',
  57. 'bbb',
  58. '```',
  59. ].join('\n');
  60. expect(attributeSourceBytes(text).get('src/a.ts')).toBe('aa'.length + 'bbb'.length);
  61. });
  62. it('counts an unterminated block — the ceiling can cut mid-fence', () => {
  63. const text = ['**`src/a.ts`** — foo(function)', '', '```ts', 'x'.repeat(40)].join('\n');
  64. expect(attributeSourceBytes(text).get('src/a.ts')).toBe(40);
  65. });
  66. it('returns nothing for text with no file sections', () => {
  67. expect(attributeSourceBytes('No relevant code found for "zzz"').size).toBe(0);
  68. expect(attributeSourceBytes('').size).toBe(0);
  69. });
  70. });
  71. describe('codegraph_explore allocation diagnostic', () => {
  72. let testDir: string;
  73. let sidecarDir: string;
  74. let cg: CodeGraph;
  75. let handler: ToolHandler;
  76. const QUERY = 'Session method helper callSession';
  77. beforeAll(async () => {
  78. testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-explore-diag-'));
  79. sidecarDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-explore-diag-out-'));
  80. const srcDir = path.join(testDir, 'src');
  81. fs.mkdirSync(srcDir);
  82. // One fat file plus several small callers, so the render loop exercises
  83. // more than one allocation branch (clusters for the fat file, whole-file
  84. // for the small ones) and there is a real per-file split to report.
  85. const fatLines: string[] = ['export class Session {'];
  86. for (let i = 0; i < 30; i++) {
  87. fatLines.push(` method${i}(arg: string): string {`);
  88. fatLines.push(` return this.helper${i}(arg) + "${i}";`);
  89. fatLines.push(` }`);
  90. fatLines.push(` private helper${i}(arg: string): string {`);
  91. fatLines.push(` return arg.repeat(${i + 1});`);
  92. fatLines.push(` }`);
  93. }
  94. fatLines.push('}');
  95. fs.writeFileSync(path.join(srcDir, 'session.ts'), fatLines.join('\n'));
  96. for (let i = 0; i < 6; i++) {
  97. fs.writeFileSync(
  98. path.join(srcDir, `support${i}.ts`),
  99. `import { Session } from './session';\n` +
  100. `export function callSession${i}(s: Session) {\n` +
  101. ` return s.method${i}('hi');\n` +
  102. `}\n`,
  103. );
  104. }
  105. clearDebugEnv();
  106. cg = CodeGraph.initSync(testDir, { config: { include: ['**/*.ts'], exclude: [] } });
  107. await cg.indexAll();
  108. handler = new ToolHandler(cg);
  109. });
  110. afterEach(() => {
  111. clearDebugEnv();
  112. vi.restoreAllMocks();
  113. });
  114. afterAll(() => {
  115. clearDebugEnv();
  116. if (cg) cg.destroy();
  117. for (const dir of [testDir, sidecarDir]) {
  118. if (dir && fs.existsSync(dir)) fs.rmSync(dir, { recursive: true, force: true });
  119. }
  120. });
  121. const explore = async (): Promise<string> => {
  122. const result = await handler.execute('codegraph_explore', { query: QUERY });
  123. return result.content?.[0]?.text ?? '';
  124. };
  125. it('produces byte-identical output whether the diagnostic is on or off', async () => {
  126. clearDebugEnv();
  127. const off = await explore();
  128. expect(off.length).toBeGreaterThan(0);
  129. // Sanity: the tool itself is deterministic, so a difference below is
  130. // attributable to the diagnostic and not to explore's own variance.
  131. expect(await explore()).toBe(off);
  132. vi.spyOn(process.stderr, 'write').mockImplementation((() => true) as typeof process.stderr.write);
  133. const sidecar = path.join(sidecarDir, 'identical.jsonl');
  134. for (const value of ['1', 'json', sidecar]) {
  135. process.env[DEBUG_ENV] = value;
  136. const on = await explore();
  137. clearDebugEnv();
  138. expect(on).toBe(off);
  139. }
  140. });
  141. it('writes nothing to stderr when the env var is unset', async () => {
  142. clearDebugEnv();
  143. const writes: string[] = [];
  144. vi.spyOn(process.stderr, 'write').mockImplementation(((chunk: unknown) => {
  145. writes.push(String(chunk));
  146. return true;
  147. }) as typeof process.stderr.write);
  148. await explore();
  149. expect(writes.join('')).toBe('');
  150. });
  151. it('stays off for every falsy env value', async () => {
  152. const writes: string[] = [];
  153. vi.spyOn(process.stderr, 'write').mockImplementation(((chunk: unknown) => {
  154. writes.push(String(chunk));
  155. return true;
  156. }) as typeof process.stderr.write);
  157. for (const value of ['', '0', 'false', 'off', 'no', 'OFF', ' 0 ']) {
  158. process.env[DEBUG_ENV] = value;
  159. await explore();
  160. }
  161. expect(writes.join('')).toBe('');
  162. });
  163. it('prints a per-file table to stderr when enabled', async () => {
  164. const writes: string[] = [];
  165. vi.spyOn(process.stderr, 'write').mockImplementation(((chunk: unknown) => {
  166. writes.push(String(chunk));
  167. return true;
  168. }) as typeof process.stderr.write);
  169. process.env[DEBUG_ENV] = '1';
  170. await explore();
  171. const out = writes.join('');
  172. expect(out).toContain('codegraph explore diagnostic');
  173. // Totals: envelope vs budget, and the file-selection funnel with its floor.
  174. expect(out).toMatch(/envelope [\d,]+ chars delivered · [\d,]+ allocated of [\d,]+ budget/);
  175. expect(out).toMatch(/hard ceiling [\d,]+/);
  176. // The funnel runs low-value filter → score floor; the floor is fractional
  177. // now that scoring is kind-weighted (CG-10).
  178. expect(out).toMatch(
  179. /files [\d,]+ grouped .*past low-value filter .*past score floor \(>=[\d.]+\).*in output \(maxFiles \d+\)/,
  180. );
  181. // Per-file columns.
  182. expect(out).toMatch(/#\s+alloc%\s+deliv%\s+bytes\s+reserved\s+score\s+graph\s+hits\s+pen\s+flags\s+render\s+file/);
  183. // The proportional split (CG-12): what was reserved, and where the cliff fell.
  184. expect(out).toMatch(/allocation [\d,]+ reserved of [\d,]+ pool · cliff at weight [\d.]+/);
  185. expect(out).toContain('src/session.ts');
  186. expect(out).toMatch(/\d+\.\d%/);
  187. // Kind mix — what each file's score was bought with.
  188. expect(out).toMatch(/kinds: (?:\w+:\d+ ?)+/);
  189. });
  190. it('appends one JSON report per call to a sidecar path', async () => {
  191. const sidecar = path.join(sidecarDir, 'reports.jsonl');
  192. process.env[DEBUG_ENV] = sidecar;
  193. await explore();
  194. await explore();
  195. clearDebugEnv();
  196. const rows = fs.readFileSync(sidecar, 'utf-8').trim().split('\n');
  197. expect(rows).toHaveLength(2);
  198. const report = JSON.parse(rows[0]!);
  199. expect(report.tool).toBe('codegraph_explore');
  200. expect(report.query).toBe(QUERY);
  201. // Totals the task asks for: envelope vs maxOutputChars, files considered
  202. // vs included, and the score floor that was applied.
  203. expect(report.budget.maxOutputChars).toBeGreaterThan(0);
  204. expect(report.envelope.chars).toBeGreaterThan(0);
  205. expect(report.selection.scoreFloor).toBeGreaterThan(0);
  206. expect(report.selection.filesGrouped).toBeGreaterThanOrEqual(report.selection.filesPastLowValueFilter);
  207. expect(report.selection.filesPastLowValueFilter).toBeGreaterThanOrEqual(report.selection.filesPastScoreFloor);
  208. expect(report.selection.filesPastScoreFloor).toBeGreaterThanOrEqual(report.selection.filesRanked);
  209. expect(report.selection.filesRanked).toBeGreaterThanOrEqual(report.selection.filesInFinalOutput);
  210. expect(report.selection.filesInFinalOutput).toBeGreaterThan(0);
  211. expect(report.selection.filesInFinalOutput).toBeLessThanOrEqual(report.budget.maxFiles);
  212. // Per-file: score, bytes, share, clipped, spine.
  213. const shown = report.files.filter((f: { finalChars: number }) => f.finalChars > 0);
  214. expect(shown.length).toBeGreaterThan(0);
  215. for (const f of shown) {
  216. expect(typeof f.path).toBe('string');
  217. expect(typeof f.score).toBe('number');
  218. expect(typeof f.graphScore).toBe('number');
  219. expect(typeof f.clipped).toBe('boolean');
  220. expect(typeof f.spine).toBe('boolean');
  221. expect(f.finalChars).toBeGreaterThan(0);
  222. expect(f.share).toBeGreaterThan(0);
  223. expect(f.share).toBeLessThanOrEqual(1);
  224. expect(f.render).toBeTruthy();
  225. }
  226. expect(shown.some((f: { path: string }) => f.path === 'src/session.ts')).toBe(true);
  227. });
  228. it('attributes the envelope consistently — per-file bytes sum to the reported source total', async () => {
  229. const sidecar = path.join(sidecarDir, 'consistency.jsonl');
  230. process.env[DEBUG_ENV] = sidecar;
  231. const text = await explore();
  232. clearDebugEnv();
  233. const report = JSON.parse(fs.readFileSync(sidecar, 'utf-8').trim());
  234. expect(report.envelope.chars).toBe(text.length);
  235. const summed = report.files.reduce(
  236. (s: number, f: { finalChars: number }) => s + f.finalChars, 0,
  237. );
  238. expect(summed).toBe(report.envelope.sourceChars);
  239. expect(report.envelope.sourceChars + report.envelope.metaChars).toBe(report.envelope.chars);
  240. // Shares are fractions of the delivered envelope, so they can't exceed it.
  241. const shareSum = report.files.reduce((s: number, f: { share: number }) => s + f.share, 0);
  242. expect(shareSum).toBeLessThanOrEqual(1.0001);
  243. expect(shareSum).toBeCloseTo(report.envelope.sourceShare, 3);
  244. });
  245. it('survives an unwritable sink without failing the explore call', async () => {
  246. clearDebugEnv();
  247. const expected = await explore();
  248. // A directory is never a valid append target.
  249. process.env[DEBUG_ENV] = sidecarDir;
  250. const result = await handler.execute('codegraph_explore', { query: QUERY });
  251. clearDebugEnv();
  252. expect(result.isError).toBeFalsy();
  253. expect(result.content?.[0]?.text).toBe(expected);
  254. });
  255. it('records a report even when explore finds nothing', async () => {
  256. const sidecar = path.join(sidecarDir, 'empty.jsonl');
  257. process.env[DEBUG_ENV] = sidecar;
  258. const result = await handler.execute('codegraph_explore', {
  259. query: 'zzzznonexistentsymbolzzzz',
  260. });
  261. clearDebugEnv();
  262. expect(result.content?.[0]?.text).toContain('No relevant code found');
  263. const report = JSON.parse(fs.readFileSync(sidecar, 'utf-8').trim());
  264. expect(report.note).toContain('no relevant code found');
  265. expect(report.files).toEqual([]);
  266. });
  267. });