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();