Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
101 changes: 101 additions & 0 deletions __tests__/cli-truncation.test.ts
Original file line number Diff line number Diff line change
@@ -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');
});
});
27 changes: 21 additions & 6 deletions src/bin/codegraph.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1158,22 +1158,28 @@ 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,
});

// Mirror the MCP search down-rank so the CLI also surfaces the
// 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}"`);
Expand All @@ -1199,6 +1205,7 @@ program
}
console.log();
}
if (truncated) console.log(chalk.dim(truncationMessage));
}
}

Expand Down Expand Up @@ -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(
Expand All @@ -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();
Expand Down Expand Up @@ -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(
Expand All @@ -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();
Expand Down