Land #1647 onto current main: callers/callees/query (CLI + MCP) now say when --limit hid matches, with totals in JSON and a widening hint. Also covers #1639. Co-authored-by: Colby McHenry <colbymchenry@users.noreply.github.com>
This commit is contained in:
co-authored by
Colby McHenry
parent
43271f3cd3
commit
85550eb2ce
@@ -0,0 +1,101 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
import { spawnSync } from 'child_process';
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import { CodeGraph } from '../src';
|
||||
|
||||
const BIN = path.resolve(__dirname, '../dist/bin/codegraph.js');
|
||||
|
||||
function runCli(cwd: string, args: string[]) {
|
||||
return spawnSync(process.execPath, [BIN, ...args, '-p', cwd], {
|
||||
encoding: 'utf-8',
|
||||
env: { ...process.env, CODEGRAPH_NO_DAEMON: '1', CODEGRAPH_WASM_RELAUNCHED: '1', NO_COLOR: '1' },
|
||||
});
|
||||
}
|
||||
|
||||
describe('CLI truncation reporting (#1639)', () => {
|
||||
let tempDir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-cli-truncation-'));
|
||||
fs.writeFileSync(
|
||||
path.join(tempDir, 'lib.ts'),
|
||||
[
|
||||
'export function target() {}',
|
||||
'export function helperA() {}',
|
||||
'export function helperB() {}',
|
||||
'export function helperC() {}',
|
||||
'export function source() { helperA(); helperB(); helperC(); }',
|
||||
'export function TargetHitOne() {}',
|
||||
'export function TargetHitTwo() {}',
|
||||
'export function TargetHitThree() {}',
|
||||
].join('\n'),
|
||||
);
|
||||
for (let i = 0; i < 3; i++) {
|
||||
fs.writeFileSync(
|
||||
path.join(tempDir, `caller-${i}.ts`),
|
||||
`import { target } from './lib';\nexport function caller${i}() { target(); }\n`,
|
||||
);
|
||||
}
|
||||
const cg = CodeGraph.initSync(tempDir);
|
||||
await cg.indexAll();
|
||||
cg.close();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('reports exact callers metadata in JSON and human output', () => {
|
||||
const jsonRun = runCli(tempDir, ['callers', 'target', '--limit', '2', '--json']);
|
||||
expect(jsonRun.status).toBe(0);
|
||||
const parsed = JSON.parse(jsonRun.stdout);
|
||||
expect(parsed.callers).toHaveLength(2);
|
||||
expect(parsed.total).toBeGreaterThan(2);
|
||||
expect(parsed.limit).toBe(2);
|
||||
expect(parsed.truncated).toBe(true);
|
||||
|
||||
const humanRun = runCli(tempDir, ['callers', 'target', '--limit', '2']);
|
||||
expect(humanRun.stdout).toMatch(/Callers of "target" \(2 of \d+\):/);
|
||||
expect(humanRun.stdout).toMatch(/Showing 2 of \d+; pass --limit to widen\./);
|
||||
|
||||
const complete = JSON.parse(runCli(tempDir, ['callers', 'target', '--limit', '100', '--json']).stdout);
|
||||
expect(complete.total).toBe(complete.callers.length);
|
||||
expect(complete.limit).toBe(100);
|
||||
expect(complete.truncated).toBe(false);
|
||||
});
|
||||
|
||||
it('reports exact callees metadata in JSON and human output', () => {
|
||||
const jsonRun = runCli(tempDir, ['callees', 'source', '--limit', '2', '--json']);
|
||||
expect(jsonRun.status).toBe(0);
|
||||
const parsed = JSON.parse(jsonRun.stdout);
|
||||
expect(parsed.callees).toHaveLength(2);
|
||||
expect(parsed.total).toBeGreaterThan(2);
|
||||
expect(parsed.limit).toBe(2);
|
||||
expect(parsed.truncated).toBe(true);
|
||||
|
||||
const humanRun = runCli(tempDir, ['callees', 'source', '--limit', '2']);
|
||||
expect(humanRun.stdout).toMatch(/Callees of "source" \(2 of \d+\):/);
|
||||
expect(humanRun.stdout).toMatch(/Showing 2 of \d+; pass --limit to widen\./);
|
||||
|
||||
const complete = JSON.parse(runCli(tempDir, ['callees', 'source', '--limit', '100', '--json']).stdout);
|
||||
expect(complete.total).toBe(complete.callees.length);
|
||||
expect(complete.limit).toBe(100);
|
||||
expect(complete.truncated).toBe(false);
|
||||
});
|
||||
|
||||
it('keeps query --json as an array and reports truncation on stderr', () => {
|
||||
const jsonRun = runCli(tempDir, ['query', 'TargetHit', '--limit', '1', '--json']);
|
||||
expect(jsonRun.status).toBe(0);
|
||||
expect(JSON.parse(jsonRun.stdout)).toHaveLength(1);
|
||||
expect(jsonRun.stderr).toContain('Results truncated at 1; pass --limit to widen.');
|
||||
|
||||
const humanRun = runCli(tempDir, ['query', 'TargetHit', '--limit', '1']);
|
||||
expect(humanRun.stdout).toContain('Results truncated at 1; pass --limit to widen.');
|
||||
|
||||
const complete = runCli(tempDir, ['query', 'TargetHit', '--limit', '100', '--json']);
|
||||
expect(Array.isArray(JSON.parse(complete.stdout))).toBe(true);
|
||||
expect(complete.stderr).not.toContain('Results truncated');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,97 @@
|
||||
/**
|
||||
* The MCP `codegraph_callers` / `codegraph_callees` answers say when their
|
||||
* `limit` cut the list (#1639, #1674). A capped list with no marker reads as
|
||||
* the complete set, and an agent under-counts "who calls this" from it.
|
||||
*/
|
||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import * as os from 'os';
|
||||
import { CodeGraph } from '../src';
|
||||
import { ToolHandler } from '../src/mcp/tools';
|
||||
import { initGrammars, loadAllGrammars } from '../src/extraction/grammars';
|
||||
|
||||
let tmpDir: string;
|
||||
let cg: CodeGraph;
|
||||
let handler: ToolHandler;
|
||||
|
||||
const text = async (tool: string, args: Record<string, unknown>): Promise<string> => {
|
||||
const res = await handler.execute(tool, args);
|
||||
return res.content?.[0]?.text ?? '';
|
||||
};
|
||||
|
||||
const CALLERS = 25;
|
||||
|
||||
beforeAll(async () => {
|
||||
await initGrammars();
|
||||
await loadAllGrammars();
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-1674-'));
|
||||
fs.mkdirSync(path.join(tmpDir, 'src'));
|
||||
// `warm` lives in a file of another name: one definition, the flat list.
|
||||
fs.writeFileSync(path.join(tmpDir, 'src', 'target.ts'), 'export function warm(n: number): number { return n; }\n');
|
||||
fs.writeFileSync(
|
||||
path.join(tmpDir, 'src', 'callers.ts'),
|
||||
"import { warm } from './target';\n" +
|
||||
Array.from({ length: CALLERS }, (_, i) => `export function caller${i}(): number { return warm(${i}); }`).join('\n') +
|
||||
'\n'
|
||||
);
|
||||
// `hot` shares its name with its file, so the answer groups per definition.
|
||||
fs.writeFileSync(path.join(tmpDir, 'src', 'hot.ts'), 'export function hot(n: number): number { return n; }\n');
|
||||
fs.writeFileSync(
|
||||
path.join(tmpDir, 'src', 'hot-callers.ts'),
|
||||
"import { hot } from './hot';\n" +
|
||||
Array.from({ length: CALLERS }, (_, i) => `export function hotCaller${i}(): number { return hot(${i}); }`).join('\n') +
|
||||
'\n'
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(tmpDir, 'src', 'fan.ts'),
|
||||
Array.from({ length: CALLERS }, (_, i) => `export function helper${i}(): number { return ${i}; }`).join('\n') +
|
||||
`\nexport function fanout(): number { return ${Array.from({ length: CALLERS }, (_, i) => `helper${i}()`).join(' + ')}; }\n`
|
||||
);
|
||||
cg = CodeGraph.initSync(tmpDir);
|
||||
await cg.indexAll();
|
||||
handler = new ToolHandler(cg);
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
cg.destroy();
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe('codegraph_callers truncation', () => {
|
||||
it('says how many callers the default limit hid', async () => {
|
||||
const out = await text('codegraph_callers', { symbol: 'warm' });
|
||||
// The importing file counts as a caller too, so the total is at least CALLERS.
|
||||
const m = out.match(/Showing 20 of (\d+) callers; pass `limit`/);
|
||||
expect(m).not.toBeNull();
|
||||
expect(Number(m![1])).toBeGreaterThanOrEqual(CALLERS);
|
||||
expect(out.match(/^- caller\d+ /gm)?.length).toBe(20);
|
||||
});
|
||||
|
||||
it('is silent when the list is complete', async () => {
|
||||
const out = await text('codegraph_callers', { symbol: 'warm', limit: 100 });
|
||||
expect(out).not.toContain('Showing');
|
||||
expect(out.match(/^- caller\d+ /gm)?.length).toBe(CALLERS);
|
||||
});
|
||||
|
||||
it('marks the cut inside each per-definition section too', async () => {
|
||||
const out = await text('codegraph_callers', { symbol: 'hot' });
|
||||
expect(out).toContain('distinct definitions');
|
||||
expect(out).toMatch(/- … \+\d+ more \(pass `limit` to widen\)/);
|
||||
expect(await text('codegraph_callers', { symbol: 'hot', limit: 100 })).not.toContain('more (pass');
|
||||
});
|
||||
});
|
||||
|
||||
describe('codegraph_callees truncation', () => {
|
||||
it('says how many callees the default limit hid', async () => {
|
||||
const out = await text('codegraph_callees', { symbol: 'fanout' });
|
||||
const m = out.match(/Showing 20 of (\d+) callees; pass `limit`/);
|
||||
expect(m).not.toBeNull();
|
||||
expect(Number(m![1])).toBe(CALLERS);
|
||||
});
|
||||
|
||||
it('is silent when the list is complete', async () => {
|
||||
const out = await text('codegraph_callees', { symbol: 'fanout', limit: 100 });
|
||||
expect(out).not.toContain('Showing');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user