Skip to content

fix(node): interleave same-named definitions before truncating - #1657

Open
ferrine wants to merge 2 commits into
colbymchenry:mainfrom
ferrine:fix/multi-definition-diversity
Open

fix(node): interleave same-named definitions before truncating#1657
ferrine wants to merge 2 commits into
colbymchenry:mainfrom
ferrine:fix/multi-definition-diversity

Conversation

@ferrine

@ferrine ferrine commented Aug 31, 2026

Copy link
Copy Markdown

Branch: ferrine:fix/multi-definition-diversitymain
Stacked on: fix/symbol-lookup-consistency (PR #1) — its commit shows in
this diff until that one merges.
Size: +132 over #1

Why

Looking up a name that is defined many times could hide whole languages and
whole directories, and never say that it had.

Results were ordered by file path and then truncated. So a name defined 40 times
under an early-sorting directory pushed the single definition under a
late-sorting directory past every cap. The answer looked complete, covered one
language, and never mentioned the other existed — which is the worst shape for
an agent, because there is nothing in the response to suggest a second look.

What changed

  • Definitions are interleaved across language and file before anything is
    trimmed, so each source gets representation within the cap instead of the
    first-sorting one consuming it.
  • The "and N more" line now names what was dropped, including which languages,
    so a truncated answer is visibly truncated.

Notes for review

Ordering only — no change to which definitions are found, just which survive the
cap and what the caller is told about the rest.

ferrine and others added 2 commits August 30, 2026 21:15
`callers`, `callees` and `impact` each carried their own symbol filter,
comparing the user's query against the BARE node name only:

    node.name === symbol || node.name.endsWith('.' + symbol)

That fails in two opposite directions in the same repository.

A bare name OVER-reports. Every same-named definition passes the filter
and their results are unioned under one heading — "Callers of group" can
list callers that belong to an entirely different `group`, with nothing
saying the name was ambiguous. Collisions cluster on short generic names
(`group`, `num`, `parse`), so this bites hardest exactly where the verbs
would otherwise be most useful.

A qualified name UNDER-reports. `Foo.Bar.baz` can never equal a bare
`baz`, so every candidate fails the filter, and the guarded fallback
takes whichever node full-text search ranked first — or reports "not
found" for a symbol that plainly exists. It only ever appeared to work
when FTS happened to return exactly one hit.

All three now resolve through graph/symbol-lookup, which the MCP tools
share, so a verb cannot drift from the matcher again:

- the exact-name index is consulted first and is authoritative. It is
  complete and uncapped, whereas FTS ranks, truncates, and tokenises
  `::` away — so resolution no longer depends on search ranking. FTS
  stays as the fallback for the fuzzy cases it is good at.
- `matchesSymbol` gains a boundary-aligned suffix match under a
  canonical separator. Splitting on every separator assumes no scope
  component contains one, which is false for any language whose module
  names are themselves dotted: the stored `A.B::c` can never equal the
  split-and-rejoined `A::B::c`, so a precise query resolved to nothing.
- an ambiguous bare name still aggregates (an interface method and its
  overrides are usually all wanted) but the union is now disclosed, with
  the matched definitions named and a qualified spelling that narrows
  it. `--json` gains a `targets` array and an `ambiguous` flag.

`matchesSymbol` moves out of mcp/tools.ts unchanged apart from the new
stage; the tool path delegates to it, so its existing coverage applies.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013Bsi9EH64kMisnik4E1gf7
`codegraph_node` on an ambiguous bare name renders as many full bodies
as fit a char budget, lists up to 20 more by file:line, then summarises
the rest as "+N more". The definitions arrive in index order —
`(file_path, start_line)` — and nothing reorders them, so a name defined
many times under an early-sorting directory consumes the body budget AND
the overflow list before a definition under a late-sorting one is
reached.

That is not a ranking nicety when one name exists in several languages.
Measured on a fixture with 40 definitions under `aaa_client/` and one
under `zzz_server/`: the lone definition landed past both caps, so the
answer named a single language and gave no sign the other existed. The
caller cannot tell a truncated answer from a complete one, and "+N more"
reads as "nothing you care about" precisely when the tail is the only
definition in some language.

Round-robin across languages, and within a language across files,
keeping original order inside each bucket, before anything truncates.
Every source that defines the name is then represented in the first few
entries however the paths sort. Inert when there is nothing to
interleave, so ordinary single-file overload sets are unaffected.

The final "+N more" now names the languages it dropped, so a truncation
that does happen is legible rather than silent.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013Bsi9EH64kMisnik4E1gf7
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant