From 56cdb3e6a6a8170d459dfa3c7c0e8de598b0e755 Mon Sep 17 00:00:00 2001 From: danusha2345 Date: Sun, 30 Aug 2026 12:16:59 +0300 Subject: [PATCH 1/2] fix: report CLI result truncation (#1639) --- CHANGELOG.md | 4 ++ __tests__/cli-truncation.test.ts | 101 +++++++++++++++++++++++++++++++ src/bin/codegraph.ts | 27 +++++++-- 3 files changed, 126 insertions(+), 6 deletions(-) create mode 100644 __tests__/cli-truncation.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 3ca5b9ff1..4be2669ec 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,10 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] +### Fixes + +- `codegraph callers`, `callees`, and `query` now clearly report when their result limit hides additional matches, including exact totals in callers/callees JSON output. (#1639) + ## [1.6.0] - 2026-08-26 diff --git a/__tests__/cli-truncation.test.ts b/__tests__/cli-truncation.test.ts new file mode 100644 index 000000000..d8fbc3672 --- /dev/null +++ b/__tests__/cli-truncation.test.ts @@ -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'); + }); +}); diff --git a/src/bin/codegraph.ts b/src/bin/codegraph.ts index 19038df1b..946d1014a 100644 --- a/src/bin/codegraph.ts +++ b/src/bin/codegraph.ts @@ -1158,7 +1158,9 @@ program const limit = parseInt(options.limit || '10', 10); const rawResults = cg.searchNodes(search, { - limit, + // Fetch one extra row so the CLI can report a cut without changing the + // long-standing bare-array contract of `query --json` (#1639). + limit: limit + 1, kinds: options.kind ? [options.kind as any] : undefined, }); @@ -1166,14 +1168,18 @@ program // hand-written implementation before protobuf/gRPC scaffolding // when both share a name. See extraction/generated-detection.ts. const isGen = cg.generatedFilePredicate(rawResults.map((r) => r.node.filePath)); - const results = [...rawResults].sort((a, b) => { + const rankedResults = [...rawResults].sort((a, b) => { const aGen = isGen(a.node.filePath) ? 1 : 0; const bGen = isGen(b.node.filePath) ? 1 : 0; return aGen - bGen; }); + const truncated = rankedResults.length > limit; + const results = rankedResults.slice(0, limit); + const truncationMessage = `Results truncated at ${limit}; pass --limit to widen.`; if (options.json) { console.log(JSON.stringify(results, null, 2)); + if (truncated) console.error(truncationMessage); } else { if (results.length === 0) { info(`No results found for "${search}"`); @@ -1199,6 +1205,7 @@ program } console.log(); } + if (truncated) console.log(chalk.dim(truncationMessage)); } } @@ -1989,13 +1996,16 @@ program } const limited = allCallers.slice(0, limit); + const total = allCallers.length; + const truncated = total > limit; if (options.json) { - console.log(JSON.stringify({ symbol, callers: limited }, null, 2)); + console.log(JSON.stringify({ symbol, callers: limited, total, limit, truncated }, null, 2)); } else if (limited.length === 0) { info(`No callers found for "${symbol}"`); } else { - console.log(chalk.bold(`\nCallers of "${symbol}" (${limited.length}):\n`)); + const count = truncated ? `${limited.length} of ${total}` : String(total); + console.log(chalk.bold(`\nCallers of "${symbol}" (${count}):\n`)); for (const node of limited) { const loc = node.startLine ? `:${node.startLine}` : ''; console.log( @@ -2005,6 +2015,7 @@ program console.log(chalk.dim(` ${node.filePath}${loc}`)); console.log(); } + if (truncated) console.log(chalk.dim(`Showing ${limited.length} of ${total}; pass --limit to widen.`)); } cg.destroy(); @@ -2067,13 +2078,16 @@ program } const limited = allCallees.slice(0, limit); + const total = allCallees.length; + const truncated = total > limit; if (options.json) { - console.log(JSON.stringify({ symbol, callees: limited }, null, 2)); + console.log(JSON.stringify({ symbol, callees: limited, total, limit, truncated }, null, 2)); } else if (limited.length === 0) { info(`No callees found for "${symbol}"`); } else { - console.log(chalk.bold(`\nCallees of "${symbol}" (${limited.length}):\n`)); + const count = truncated ? `${limited.length} of ${total}` : String(total); + console.log(chalk.bold(`\nCallees of "${symbol}" (${count}):\n`)); for (const node of limited) { const loc = node.startLine ? `:${node.startLine}` : ''; console.log( @@ -2083,6 +2097,7 @@ program console.log(chalk.dim(` ${node.filePath}${loc}`)); console.log(); } + if (truncated) console.log(chalk.dim(`Showing ${limited.length} of ${total}; pass --limit to widen.`)); } cg.destroy(); From 5a31139cd0f38050fc848da770f5eb6758f30ed4 Mon Sep 17 00:00:00 2001 From: danusha2345 Date: Thu, 3 Sep 2026 15:26:09 +0300 Subject: [PATCH 2/2] fix(mcp): say when callers/callees answers are cut at the limit (#1674) The codegraph_callers and codegraph_callees answers sliced the list at `limit` (default 20) with no marker, so a capped answer read as the complete set and an agent under-counted "who calls this". Both the flat list and the per-definition sections now say "showing N of M". --- CHANGELOG.md | 2 +- __tests__/mcp-callers-truncation.test.ts | 97 ++++++++++++++++++++++++ src/mcp/tools.ts | 20 ++++- 3 files changed, 116 insertions(+), 3 deletions(-) create mode 100644 __tests__/mcp-callers-truncation.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index b0f14c730..854533bae 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -201,7 +201,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). #### Symbols, tests and the viewer -- `codegraph callers`, `callees`, and `query` now clearly report when their result limit hides additional matches, including exact totals in callers/callees JSON output. (#1639) +- `codegraph callers`, `callees`, and `query` now clearly report when their result limit hides additional matches, including exact totals in callers/callees JSON output; the `codegraph_callers` and `codegraph_callees` MCP answers carry the same "showing N of M" note. (#1639, #1674) - **Files under an `e2e/` directory count as tests.** Their calls no longer appear as production callers in Steps, dead-code and test badges. diff --git a/__tests__/mcp-callers-truncation.test.ts b/__tests__/mcp-callers-truncation.test.ts new file mode 100644 index 000000000..ccb608369 --- /dev/null +++ b/__tests__/mcp-callers-truncation.test.ts @@ -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): Promise => { + 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'); + }); +}); diff --git a/src/mcp/tools.ts b/src/mcp/tools.ts index b0585745e..431df719f 100644 --- a/src/mcp/tools.ts +++ b/src/mcp/tools.ts @@ -2275,7 +2275,12 @@ export class ToolHandler { // A successful `file` narrowing makes the multi-symbol aggregation note // stale — suppress it. const note = fileFilter && !filteredOut ? '' : allMatches.note; - const formatted = this.formatNodeList(callers.slice(0, limit), `Callers of ${symbol}`, labels) + note + filterNote; + // Say when the cap cut the list (#1639, #1674): a truncated answer with + // no marker reads as the complete set, and an agent under-counts from it. + const cut = callers.length > limit + ? `\n\n> Showing ${limit} of ${callers.length} callers; pass \`limit\` (up to 100) to widen.` + : ''; + const formatted = this.formatNodeList(callers.slice(0, limit), `Callers of ${symbol}`, labels) + cut + note + filterNote; return this.textResult(this.truncateOutput(formatted)); } @@ -2297,6 +2302,9 @@ export class ToolHandler { const label = labels.get(node.id); lines.push(`- ${node.name} (${node.kind}) - ${node.filePath}${location}${label ? ` — via ${label}` : ''}`); } + if (callers.length > limit) { + lines.push(`- … +${callers.length - limit} more (pass \`limit\` to widen)`); + } } return this.textResult(this.truncateOutput(lines.join('\n') + filterNote)); } @@ -2347,7 +2355,12 @@ export class ToolHandler { // A successful `file` narrowing makes the multi-symbol aggregation note // stale — suppress it. const note = fileFilter && !filteredOut ? '' : allMatches.note; - const formatted = this.formatNodeList(callees.slice(0, limit), `Callees of ${symbol}`, labels) + note + filterNote; + // Say when the cap cut the list (#1639, #1674): a truncated answer with + // no marker reads as the complete set, and an agent under-counts from it. + const cut = callees.length > limit + ? `\n\n> Showing ${limit} of ${callees.length} callees; pass \`limit\` (up to 100) to widen.` + : ''; + const formatted = this.formatNodeList(callees.slice(0, limit), `Callees of ${symbol}`, labels) + cut + note + filterNote; return this.textResult(this.truncateOutput(formatted)); } @@ -2367,6 +2380,9 @@ export class ToolHandler { const label = labels.get(node.id); lines.push(`- ${node.name} (${node.kind}) - ${node.filePath}${location}${label ? ` — via ${label}` : ''}`); } + if (callees.length > limit) { + lines.push(`- … +${callees.length - limit} more (pass \`limit\` to widen)`); + } } return this.textResult(this.truncateOutput(lines.join('\n') + filterNote)); }