explore-blast-radius.test.ts 4.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115
  1. /**
  2. * codegraph_explore blast-radius section.
  3. *
  4. * explore now appends a compact, always-on "Blast radius" for the entry
  5. * symbols: who depends on each (locations only — no source) and which test
  6. * files cover it, so the agent knows what to update/verify before editing
  7. * without a separate impact call. Symbols with no dependents are skipped, and
  8. * the section is omitted entirely when nothing qualifies.
  9. */
  10. import { describe, it, expect, beforeEach, afterEach } from 'vitest';
  11. import * as fs from 'fs';
  12. import * as path from 'path';
  13. import * as os from 'os';
  14. import CodeGraph from '../src/index';
  15. import { ToolHandler } from '../src/mcp/tools';
  16. describe('codegraph_explore — blast radius', () => {
  17. let testDir: string;
  18. let cg: CodeGraph;
  19. let handler: ToolHandler;
  20. beforeEach(async () => {
  21. testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-blast-'));
  22. const src = path.join(testDir, 'src');
  23. fs.mkdirSync(src, { recursive: true });
  24. // `target` is depended on by a sibling (caller) and a test file.
  25. fs.writeFileSync(
  26. path.join(src, 'feature.ts'),
  27. `export function target() { return 1; }\n` +
  28. `export function caller() { return target(); }\n`,
  29. );
  30. fs.writeFileSync(
  31. path.join(src, 'feature.test.ts'),
  32. `import { target } from './feature';\n` +
  33. `export function checkTarget() { return target(); }\n`,
  34. );
  35. // A leaf with no dependents — must NOT show up in the blast radius.
  36. fs.writeFileSync(
  37. path.join(src, 'leaf.ts'),
  38. `export function lonelyLeaf() { return 42; }\n`,
  39. );
  40. // `deepHelper` is only called by production code (`midCaller`), but the
  41. // test file exercises it transitively — 2 caller hops up (#1475).
  42. fs.writeFileSync(
  43. path.join(src, 'util.ts'),
  44. `export function deepHelper() { return 1; }\n`,
  45. );
  46. fs.writeFileSync(
  47. path.join(src, 'mid.ts'),
  48. `import { deepHelper } from './util';\n` +
  49. `export function midCaller() { return deepHelper(); }\n`,
  50. );
  51. fs.writeFileSync(
  52. path.join(src, 'mid.test.ts'),
  53. `import { midCaller } from './mid';\n` +
  54. `export function checkMid() { return midCaller(); }\n`,
  55. );
  56. // `untestedHelper` has a caller but no test anywhere up its caller chain.
  57. fs.writeFileSync(
  58. path.join(src, 'untested.ts'),
  59. `export function untestedHelper() { return 3; }\n` +
  60. `export function untestedCaller() { return untestedHelper(); }\n`,
  61. );
  62. cg = CodeGraph.initSync(testDir, { config: { include: ['**/*.ts'], exclude: [] } });
  63. await cg.indexAll();
  64. handler = new ToolHandler(cg);
  65. });
  66. afterEach(() => {
  67. if (cg) cg.destroy();
  68. if (fs.existsSync(testDir)) fs.rmSync(testDir, { recursive: true, force: true });
  69. });
  70. it('lists dependents (locations only) and covering tests for an entry symbol', async () => {
  71. const res = await handler.execute('codegraph_explore', { query: 'target' });
  72. const text = res.content[0].text;
  73. expect(text).toContain('**Blast radius');
  74. expect(text).toContain('`target`');
  75. expect(text).toMatch(/caller/); // a caller count is reported
  76. // It names WHERE (the caller file) — not the caller's source body.
  77. expect(text).toContain('feature.ts');
  78. // The direct covering test file is surfaced.
  79. expect(text).toMatch(/tests:.*feature\.test\.ts/);
  80. });
  81. it('surfaces tests that cover a symbol transitively through its callers (#1475)', async () => {
  82. const res = await handler.execute('codegraph_explore', { query: 'deepHelper' });
  83. const text = res.content[0].text;
  84. // deepHelper's only direct caller is production code, but mid.test.ts sits
  85. // one more hop up — that must NOT read as "no tests".
  86. expect(text).toMatch(/`deepHelper`[^\n]*tested via callers:[^\n]*mid\.test\.ts/);
  87. const line = text.split('\n').find((l: string) => l.startsWith('- `deepHelper`'));
  88. expect(line).not.toMatch(/no tests found|no covering tests/);
  89. });
  90. it('states only what was measured when no test exists up the caller chain', async () => {
  91. const res = await handler.execute('codegraph_explore', { query: 'untestedHelper' });
  92. const text = res.content[0].text;
  93. // Bounded claim, no warning glyph — the tool verified nothing beyond 3 hops.
  94. expect(text).toMatch(/`untestedHelper`[^\n]*no tests found within 3 caller hops/);
  95. expect(text).not.toContain('⚠️ no covering tests found');
  96. });
  97. it('omits symbols that have no dependents from the blast radius', async () => {
  98. const res = await handler.execute('codegraph_explore', { query: 'lonelyLeaf' });
  99. const text = res.content[0].text;
  100. // lonelyLeaf has zero callers — it must never appear under a blast-radius bullet.
  101. expect(text).not.toMatch(/Blast radius[\s\S]*`lonelyLeaf`/);
  102. });
  103. });