Skip to content

callers/callees/impact silently answer for a DIFFERENT symbol when the requested name does not exist (top fuzzy hit substituted, no note) — CLI + MCP, verified end-to-end #1473

Description

@inth3shadows

Edited after re-verification. The first revision quoted a JSON payload produced against a larger scratch fixture than the one it listed, and named findSymbolMatches as findSymbol. Every command and line number below has now been re-run/re-checked from a clean npm run build at 572d22b; the results matrix is new.

Summary

callers / callees / impact can answer for a different symbol than the one you asked for, labelling the output with the name you typed. No note, no "did you mean", nothing in --json that lets a consumer tell a real answer from a substituted one. Two distinct triggers, both verified:

  1. The name doesn't exist at all — the top fuzzy FTS hit is used instead of reporting "not found". Reproduces on the CLI and the MCP tools.
  2. The name exists but has zero callers — the CLI discards that (correct, empty) result and falls back to the top fuzzy hit, so "nothing calls this" silently becomes "here are six callers". CLI-only; the MCP handler gets this case right.

Symbol "…" not found is printed only when FTS returns literally zero rows, so a typo or partial name — where a wrong answer is most plausible and least noticeable — never reaches it.

This is the concrete defect underneath #1455 ("Prioritize precise matching over fuzzy matching or prefix matching"), which reports the symptom without a repro.

Verified results matrix

All against this repo indexed at 572d22b, plus a two-file Java fixture. ❌ = wrong answer.

Query Reality CLI MCP
buildFlow no such symbol; 1 fuzzy hit (buildFlowFromNamedSymbols) ❌ 1 caller ❌ 1 caller
handleExplo no such symbol; 2 fuzzy hits, neither exact ❌ 1 caller ❌ 1 caller
Fetch exists, C# method, 0 callers ❌ 6 callers ✅ "No callers found"
zzzzz no such symbol; 0 FTS hits ✅ not found ✅ not found

1. buildFlow — name doesn't exist, single fuzzy hit

select name from nodes where name='buildFlow'[]. The only near-name is buildFlowFromNamedSymbols (src/mcp/tools.ts:1882).

$ node dist/bin/codegraph.js callers buildFlow --json
{
  "symbol": "buildFlow",
  "callers": [
    { "name": "handleExplore", "kind": "method", "filePath": "src/mcp/tools.ts", "startLine": 2502 }
  ]
}

MCP (ToolHandler.execute('codegraph_callers', { symbol: 'buildFlow' })):

**Callers of buildFlow (1 found)**

- handleExplore (method) - src/mcp/tools.ts:2502

handleExplore is the real caller of buildFlowFromNamedSymbols (call site src/mcp/tools.ts:3086, inside handleExplore which spans from 2502).

2. handleExplo — name doesn't exist, multiple fuzzy hits, none exact

query handleExplo returns handleExplore (60.7) and FILE_SECTION_PREFIX (6.3); select name from nodes where name='handleExplo'[].

$ node dist/bin/codegraph.js callers handleExplo --json
{
  "symbol": "handleExplo",
  "callers": [
    { "name": "dispatchTool", "kind": "method", "filePath": "src/mcp/tools.ts", "startLine": 1477 }
  ]
}

MCP returns the same: **Callers of handleExplo (1 found)** - dispatchTool (method) - src/mcp/tools.ts:1477.

3. Fetch — the symbol exists, has no callers, and gets someone else's

Two nodes differ only in case:

Fetch  method    __tests__/fixtures/kernel-parity/Torture.cs:140   -> callers: 0
fetch  function  assets/generate-language-tiles.py:101             -> callers: 6

FTS is case-insensitive, so searchNodes('Fetch') ranks the Python fetch (103.2) above the C# Fetch (88.1). The exact-match filter does select the C# node — it just yields zero callers, which trips the fallback:

$ node dist/bin/codegraph.js callers Fetch --json
{
  "symbol": "Fetch",
  "callers": [
    { "name": "load",           "kind": "method",   "filePath": "__tests__/fixtures/kernel-parity/TortureMini.dart", "startLine": 50 },
    { "name": "runner",         "kind": "function", "filePath": "__tests__/fixtures/kernel-parity/torture.R",        "startLine": 189 },
    { "name": "load",           "kind": "method",   "filePath": "__tests__/fixtures/kernel-parity/torture.dart",     "startLine": 140 },
    { "name": "run",            "kind": "method",   "filePath": "__tests__/fixtures/kernel-parity/torture.py",       "startLine": 25 },
    { "name": "si_glyph",       "kind": "function", "filePath": "assets/generate-language-tiles.py",                 "startLine": 148 },
    { "name": "devicon_glyph",  "kind": "function", "filePath": "assets/generate-language-tiles.py",                 "startLine": 158 }
  ]
}

None of these call the C# Fetch. The MCP handler answers this one correctly (No callers found for "Fetch"), so CLI and MCP disagree on the same index.

4. callees / impact — same substitution, minimal Java fixture

// src/a/b/c/D.java
package a.b.c;

public class D {
    public void e()  { System.out.println("e"); }
    public void ef() { System.out.println("ef"); }
}

// src/a/b/c/Caller.java
package a.b.c;

public class Caller {
    public void callsEfOnly() { D d = new D(); d.ef(); }
    public void alsoCallsEf() { D d = new D(); d.ef(); }
}

codegraph init && codegraph index, then ask about names that no symbol has:

$ node dist/bin/codegraph.js callees Calls --json
{
  "symbol": "Calls",
  "callees": [
    { "name": "ef", "kind": "method", "filePath": "src/a/b/c/D.java", "startLine": 5 },
    { "name": "D",  "kind": "class",  "filePath": "src/a/b/c/D.java", "startLine": 3 }
  ]
}

$ node dist/bin/codegraph.js impact callsEf --json
{ "symbol": "callsEf", "depth": 2, "nodeCount": 1, "edgeCount": 0,
  "affected": [ { "name": "callsEfOnly", "kind": "method", "filePath": "src/a/b/c/Caller.java", "startLine": 4 } ] }

MCP agrees on the wrong answer here too: **Callees of Calls (2 found)** - ef (method) - src/a/b/c/D.java:5 - D (class) … — via instantiation.

Root cause

CLI — the same block is duplicated in all three commands (src/bin/codegraph.ts: callers 1863/1874, callees 1942/1952, impact 2022/2038). Two independent paths let a non-exact match through:

const matches = cg.searchNodes(symbol, { limit: 50 });      // fuzzy FTS
...
for (const match of matches) {
  const exactMatch = match.node.name === symbol
    || match.node.name.endsWith(`.${symbol}`)
    || match.node.name.endsWith(`::${symbol}`);
  if (!exactMatch && matches.length > 1) continue;          // (A) filter disabled when FTS returns exactly 1 row
  ...
}

// Fallback: if exact filter removed everything, use the top match
if (allCallers.length === 0 && matches[0]) {                // (B) fires whenever the result set is empty
  for (const c of cg.getCallers(matches[0].node.id)) { ... }
}
  • (A) produces case 1 (buildFlow → one FTS row → filter bypassed).
  • (B) produces case 2 (handleExplo → two rows, neither exact → filtered to nothing → substitute) and case 3 (Fetch → exact match found, but it contributes zero callers, so the set is empty and the substitution happens anyway). (B) cannot distinguish "no exact match" from "exact match with no callers" — that conflation is what makes case 3 possible.

MCPfindAllSymbols (src/mcp/tools.ts:4498) carries the equivalent of (B), which is why cases 1, 2 and 4 reproduce there:

const exactMatches = results.filter(r => this.matchesSymbol(r.node, symbol));
if (exactMatches.length <= 1) {
  const node = exactMatches[0]?.node ?? results[0]!.node;   // fuzzy substitution
  return { nodes: [node], note: '' };
}

note is '' on this path, so the substitution is invisible in the rendered output. It is not affected by case 3, because it resolves the symbol first and reports that node's (empty) callers rather than re-checking emptiness afterwards.

Side observation, not a verified defect: the sibling resolver findSymbolMatches (src/mcp/tools.ts:4447) carries the #173 guard — "a qualified lookup must not fall back to a fuzzy file hit" — and findAllSymbols has no equivalent. I could not get a wrong answer out of that specific gap in my fixtures (a.b.c.E.run, Nope.run, E.run all correctly returned no callers), so I'm flagging it as code asymmetry only.

Why it matters

  • These commands exist for scripting and CI (per the header comment on the CLI block). --json gives a consumer no way to distinguish a real answer from a substituted one.
  • Case 3 hits the exact question people ask before deleting or renaming code — does anything call this? — and turns the correct answer ("nothing") into a list of unrelated callers. Any repo with a case-differing name pair (Fetch/fetch, Run/run, New/new — routine once C#/Go/Java and Python/JS share a tree) is exposed.
  • A typo or partially-remembered name returns a confident, plausible, wrong answer rather than an error.

Suggested direction

  1. Drop the && matches.length > 1 condition — a single fuzzy hit is not more trustworthy than one of many.
  2. Split "no exact match" from "exact match, no results" in the CLI. Fallback (B) should only be considered in the former case; the latter should print No callers found for "X" as it already does elsewhere.
  3. When there is genuinely no exact match, prefer an explicit Symbol "X" not found — did you mean: <top hits>? (non-zero exit) over answering for a different node. Same for findAllSymbols: return { nodes: [] } when exactMatches is empty, or carry a loud note.
  4. If the fuzzy fallback is wanted for ergonomics, surface it — "resolvedSymbol" in the JSON and a > **Note:** no symbol named "X"; showing results for "Y" line in the MCP text — so the answer is never silently mislabelled.

No test currently covers the symbol-resolution behavior of these commands — the only suite that invokes callers is __tests__/cli-no-color.test.ts, and it asserts ANSI-free output, not which symbol was resolved.

Environment

main @ 572d22b (v1.5.0), Node 22.22.2, Linux (WSL2). Clean npm run build, real SQLite index, MCP results taken through ToolHandler.execute against the same index.

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