Skip to content

CLI callers/callees/query truncate at a default --limit with no total and no marker — and the human header prints the truncated count as the total (Callers of "target" (20) when there are 50), while codegraph_explore already marks its own elisions — verified end-to-end #1639

Description

@kogriv

Summary

codegraph callers|callees (default --limit 20) and codegraph query (default --limit 10) cut their results and emit nothing that says a cut happened:

  • --json returns { symbol, callers: [...] } — no total, no truncated, no echo of the applied limit. query --json returns a bare array, so there is not even an envelope a total could live in.
  • The human path prints the truncated count as if it were the total: Callers of "target" (20): when there are 50.

The true total is in scope at the line that discards it (allCallers.length), so nothing has to be recomputed to report it.

This matters more than a missing convenience field, because getCallers merges imports, calls, references and instantiates into one array in edge-insertion order. Which rows survive the cut is therefore arbitrary with respect to kind, and a consumer cannot tell a complete answer from a slice of one. In the minimal repro below the survivors happen to be all function; on a real repository (below) they happened to be all file, so the default answer for two symbols contained zero of the symbol-level callers the command exists to return.

codegraph_explore — the one tool the MCP server exposes by default — already does this right, marking its own elisions inline (+5 more, +27 more). This report is only asking for that existing pattern on the callers/callees/query path.

Adjacent but distinct: #1512 concerns same-named definitions being merged in these same functions. This one is about the cut, and is orthogonal to that fix.

Root cause

src/bin/codegraph.ts, callers (identical shape in callees at :2069):

const limit = parseInt(options.limit || '20', 10);                 // :1957
...
const limited = allCallers.slice(0, limit);                        // :1991

if (options.json) {
  console.log(JSON.stringify({ symbol, callers: limited }, null, 2));   // :1994  <- no total
} else if (limited.length === 0) {
  info(`No callers found for "${symbol}"`);
} else {
  console.log(chalk.bold(`\nCallers of "${symbol}" (${limited.length}):\n`));  // :1998  <- truncated count printed as the total
}

allCallers.length is live at :1991 and dropped.

Repro A — minimal, executed end-to-end

26 files: one definition, 25 modules that each import it and call it from a function.

mkdir repro && cd repro
printf 'def target():\n    return 1\n' > lib.py
for i in $(seq -w 0 24); do
  printf 'from lib import target\n\n\ndef caller_%s():\n    return target()\n' "$i" > "c$i.py"
done
codegraph init . -y          # 26 files, 77 nodes, 101 edges
$ codegraph callers target --json | jq '.callers | length, (group_by(.kind)[] | {(.[0].kind): length})'
20
{"function": 20}

$ codegraph callers target --limit 500 --json | jq '.callers | length, (group_by(.kind)[] | {(.[0].kind): length})'
50
{"file": 25}
{"function": 25}

$ codegraph callers target | head -2

Callers of "target" (20):

There are 50. The command says 20, and the JSON gives a consumer no way to learn otherwise.

query in the same project:

$ codegraph query target --json | jq length              # 10
$ codegraph query target --limit 500 --json | jq length  # 26

Repro B — a real repository, executed end-to-end

A 207-file Python package (5 113 nodes, 15 247 edges), five well-connected symbols, default limit vs --limit 500:

symbol default full symbol-level callers hidden by the default
MACDZoneAnalyzer 20 (0 symbols, 20 files) 79 (58 symbols, 21 files) all 58
ZoneInfo 20 (0 symbols) 62 (33 symbols) all 33
ZoneFeaturesAnalyzer 20 (11 symbols) 51 (42 symbols) 31
analyze_zones 20 (20 symbols) 72 (49 symbols) 29
get_logger 20 (20 symbols) 104 (40 symbols) 20

On the first two, the file rows from imports occupied the first 21 positions of allCallers, so the default returned only files. A reader with no reason to suspect a limit would reasonably conclude the tool models callers as file-level import fan-in rather than call sites. It does not — that is the truncation talking.

Suggested fix

Minimal, and consistent with what explore already does:

  1. JSON — carry the counts, e.g. { symbol, callers, total: allCallers.length, limit, truncated: allCallers.length > limit }. Emitting the block unconditionally (with truncated: false when everything fit) is worth a thought: an absent field is itself ambiguous to a machine consumer, which cannot distinguish "not truncated" from "this version does not report truncation".
  2. Human — print the total in the header and a footer when cut, e.g. Callers of "target" (20 of 50 — pass --limit to widen).
  3. Same for callees (:2069) and query. query returns a bare array today, so it needs either an envelope or a stderr note.

Environment

CodeGraph 1.6.0 (npm @colbymchenry/codegraph), Linux x64, Node v24.12.0, Python targets, telemetry off via DO_NOT_TRACK=1. Source read at 6a056ec.

Not claimed here

  • I am not asking for a particular ordering of allCallers. The order is an implementation detail and may well be fine; the request is only that the cut be visible.
  • The MCP callers/callees handlers were not tested — this is the CLI path.

Found while benchmarking CodeGraph against my own Python code-graph tool on a shared, hash-pinned scope; the full measurement, including where CodeGraph came out ahead, is public in that project's tool card. The first run of this query is what produced the wrong conclusion described above, which is why it seemed worth reporting rather than working around.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions