The "no covering tests found" flag only inspected a symbol's direct callers, so helpers exercised transitively by tests (logDebug runs 1,471x under npm test) were reported untested — wrong for ~40% of flagged symbols per the issue's measurement. The check now BFSes up the caller graph (3 hops, 64-lookup budget per entry) and reports indirect coverage as "tested via callers: <files>". When nothing is found it claims only what was measured — "no tests found within 3 caller hops", or the weaker "no test calls this directly" if the budget ran out — and drops the warning glyph. Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
116 lines
4.7 KiB
TypeScript
116 lines
4.7 KiB
TypeScript
/**
|
|
* codegraph_explore blast-radius section.
|
|
*
|
|
* explore now appends a compact, always-on "Blast radius" for the entry
|
|
* symbols: who depends on each (locations only — no source) and which test
|
|
* files cover it, so the agent knows what to update/verify before editing
|
|
* without a separate impact call. Symbols with no dependents are skipped, and
|
|
* the section is omitted entirely when nothing qualifies.
|
|
*/
|
|
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
import * as fs from 'fs';
|
|
import * as path from 'path';
|
|
import * as os from 'os';
|
|
import CodeGraph from '../src/index';
|
|
import { ToolHandler } from '../src/mcp/tools';
|
|
|
|
describe('codegraph_explore — blast radius', () => {
|
|
let testDir: string;
|
|
let cg: CodeGraph;
|
|
let handler: ToolHandler;
|
|
|
|
beforeEach(async () => {
|
|
testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-blast-'));
|
|
const src = path.join(testDir, 'src');
|
|
fs.mkdirSync(src, { recursive: true });
|
|
|
|
// `target` is depended on by a sibling (caller) and a test file.
|
|
fs.writeFileSync(
|
|
path.join(src, 'feature.ts'),
|
|
`export function target() { return 1; }\n` +
|
|
`export function caller() { return target(); }\n`,
|
|
);
|
|
fs.writeFileSync(
|
|
path.join(src, 'feature.test.ts'),
|
|
`import { target } from './feature';\n` +
|
|
`export function checkTarget() { return target(); }\n`,
|
|
);
|
|
// A leaf with no dependents — must NOT show up in the blast radius.
|
|
fs.writeFileSync(
|
|
path.join(src, 'leaf.ts'),
|
|
`export function lonelyLeaf() { return 42; }\n`,
|
|
);
|
|
// `deepHelper` is only called by production code (`midCaller`), but the
|
|
// test file exercises it transitively — 2 caller hops up (#1475).
|
|
fs.writeFileSync(
|
|
path.join(src, 'util.ts'),
|
|
`export function deepHelper() { return 1; }\n`,
|
|
);
|
|
fs.writeFileSync(
|
|
path.join(src, 'mid.ts'),
|
|
`import { deepHelper } from './util';\n` +
|
|
`export function midCaller() { return deepHelper(); }\n`,
|
|
);
|
|
fs.writeFileSync(
|
|
path.join(src, 'mid.test.ts'),
|
|
`import { midCaller } from './mid';\n` +
|
|
`export function checkMid() { return midCaller(); }\n`,
|
|
);
|
|
// `untestedHelper` has a caller but no test anywhere up its caller chain.
|
|
fs.writeFileSync(
|
|
path.join(src, 'untested.ts'),
|
|
`export function untestedHelper() { return 3; }\n` +
|
|
`export function untestedCaller() { return untestedHelper(); }\n`,
|
|
);
|
|
|
|
cg = CodeGraph.initSync(testDir, { config: { include: ['**/*.ts'], exclude: [] } });
|
|
await cg.indexAll();
|
|
handler = new ToolHandler(cg);
|
|
});
|
|
|
|
afterEach(() => {
|
|
if (cg) cg.destroy();
|
|
if (fs.existsSync(testDir)) fs.rmSync(testDir, { recursive: true, force: true });
|
|
});
|
|
|
|
it('lists dependents (locations only) and covering tests for an entry symbol', async () => {
|
|
const res = await handler.execute('codegraph_explore', { query: 'target' });
|
|
const text = res.content[0].text;
|
|
|
|
expect(text).toContain('**Blast radius');
|
|
expect(text).toContain('`target`');
|
|
expect(text).toMatch(/caller/); // a caller count is reported
|
|
// It names WHERE (the caller file) — not the caller's source body.
|
|
expect(text).toContain('feature.ts');
|
|
// The direct covering test file is surfaced.
|
|
expect(text).toMatch(/tests:.*feature\.test\.ts/);
|
|
});
|
|
|
|
it('surfaces tests that cover a symbol transitively through its callers (#1475)', async () => {
|
|
const res = await handler.execute('codegraph_explore', { query: 'deepHelper' });
|
|
const text = res.content[0].text;
|
|
|
|
// deepHelper's only direct caller is production code, but mid.test.ts sits
|
|
// one more hop up — that must NOT read as "no tests".
|
|
expect(text).toMatch(/`deepHelper`[^\n]*tested via callers:[^\n]*mid\.test\.ts/);
|
|
const line = text.split('\n').find((l: string) => l.startsWith('- `deepHelper`'));
|
|
expect(line).not.toMatch(/no tests found|no covering tests/);
|
|
});
|
|
|
|
it('states only what was measured when no test exists up the caller chain', async () => {
|
|
const res = await handler.execute('codegraph_explore', { query: 'untestedHelper' });
|
|
const text = res.content[0].text;
|
|
|
|
// Bounded claim, no warning glyph — the tool verified nothing beyond 3 hops.
|
|
expect(text).toMatch(/`untestedHelper`[^\n]*no tests found within 3 caller hops/);
|
|
expect(text).not.toContain('⚠️ no covering tests found');
|
|
});
|
|
|
|
it('omits symbols that have no dependents from the blast radius', async () => {
|
|
const res = await handler.execute('codegraph_explore', { query: 'lonelyLeaf' });
|
|
const text = res.content[0].text;
|
|
// lonelyLeaf has zero callers — it must never appear under a blast-radius bullet.
|
|
expect(text).not.toMatch(/Blast radius[\s\S]*`lonelyLeaf`/);
|
|
});
|
|
});
|