Просмотр исходного кода

fix: report callers/callees/query truncation (#1674) (#1772)

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>
Colby Mchenry 5 часов назад
Родитель
Сommit
85550eb2ce
5 измененных файлов с 238 добавлено и 8 удалено
  1. 1 0
      CHANGELOG.md
  2. 101 0
      __tests__/cli-truncation.test.ts
  3. 97 0
      __tests__/mcp-callers-truncation.test.ts
  4. 21 6
      src/bin/codegraph.ts
  5. 18 2
      src/mcp/tools.ts

+ 1 - 0
CHANGELOG.md

@@ -213,6 +213,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; the `codegraph_callers` and `codegraph_callees` MCP answers carry the same "showing N of M" note. (#1639, #1674)
 - CommonJS controllers written as `exports.getItems = async (req, res) => {…}` or `module.exports.x = function () {…}` are now indexed as exported functions, so `node`, `callers` and impact find every Express handler in that style and the calls inside them belong to the handler instead of the file. Re-index JavaScript projects after upgrading. (#1675)
 - Python parameters annotated with a quoted forward reference — `def f(o: "Alpha")`, or anything under `from __future__ import annotations` — now resolve the methods called on them, the same as the unquoted annotation. Re-index Python projects after upgrading. (#1684)
 - **A C macro call written with designated initializers no longer swallows every function after it.** Betaflight resets each config struct with `RESET_CONFIG(type, dst, .field = value, …)`, a shape the C grammar cannot parse; past a hundred or so fields its error recovery ran the enclosing function to the end of the file, the next function vanished from the index and every later one was filed under the first, where name matching then treated it as an unreachable closure. The argument list of such a call is now blanked before parsing, offsets kept, so the file's functions come out with their real extents. On that tree 45 functions in `pid.c` alone moved back to top level and their 117 callers resolve at exact-match confidence. Re-index after upgrading. (#1729)

+ 101 - 0
__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');
+  });
+});

+ 97 - 0
__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<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');
+  });
+});

+ 21 - 6
src/bin/codegraph.ts

@@ -1164,7 +1164,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,
       });
 
@@ -1172,14 +1174,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}"`);
@@ -1205,6 +1211,7 @@ program
             }
             console.log();
           }
+          if (truncated) console.log(chalk.dim(truncationMessage));
         }
       }
 
@@ -2183,13 +2190,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(
@@ -2199,6 +2209,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();
@@ -2261,13 +2272,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(
@@ -2277,6 +2291,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();

+ 18 - 2
src/mcp/tools.ts

@@ -2419,7 +2419,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));
     }
 
@@ -2441,6 +2446,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));
   }
@@ -2491,7 +2499,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));
     }
 
@@ -2511,6 +2524,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));
   }