fix(mcp+resolution): stop conflating same-named symbols across monorepo apps (#764) (#813)

A NestJS-style monorepo has one UserService/UserModule/UserRepository per
app; with no package concept for TS they share one global name scope and
agents visibly warned that CodeGraph was mixing unrelated classes.

Two distinct problems, two fixes:

1. TOOL AGGREGATION. callers/callees returned one merged list across every
   same-named match, and impact merged all their blast radii into a single
   overstated subgraph. Now: matches group into DISTINCT DEFINITIONS
   (filePath + qualifiedName — same-file overloads still merge, that's the
   overload feature) and render one file-labeled section per definition;
   a new `file` argument (path or suffix, like codegraph_node's) narrows
   to one definition, suppressing the stale aggregation note; a
   non-matching `file` falls back to all definitions with a note.
   server-instructions documents the behavior.

2. RESOLUTION WRONG EDGES. Auditing a real monorepo (amplication, 54k
   nodes) found 1,036 cross-package `references` edges into duplicated
   names. Root cause: the React framework resolver ran PascalCase
   component resolution on refs from PLAIN .ts FILES (a GraphQL types
   file's own `Account` type alias lost to an arbitrary same-named CLASS
   in another package — the resolver's blind `components[0]` fallback at
   confidence 0.8 outranked the name-matcher's proximity-correct 0.7).
   Component resolution is now gated to JSX-capable refs (tsx/jsx) and
   never guesses among multiple candidates without a positional signal
   (same-dir / component-dir / unique). Cross-package wrong edges:
   1,036 -> 40 (-96%; the remainder are genuine shared-model imports and
   codegen template scaffolds), with the freed refs re-resolving to the
   correct same-file/same-package targets. excalidraw (a real React repo)
   is a zero-delta control — legitimate component refs all carry
   same-dir/component-dir signals.

Graph-level separation was verified correct on a fixture before any
changes (import + proximity resolution keeps apps apart) — the conflation
was tool-level plus the react-resolver edge class.

Tests: 6-test e2e suite (grouped callers/callees, per-definition impact
radii, file narrowing, fallback note, cross-app edge isolation) + react
resolver unit tests updated to production reality (tsx refs resolve,
plain-ts refs decline). Full suite 1398 passed. EXTRACTION_VERSION
23 -> 24 (re-index to drop the wrong cross-package edges).

Closes #764

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Colby Mchenry
2026-06-11 16:24:22 -05:00
committed by GitHub
co-authored by Claude Opus 4.8
parent dce61a5f4a
commit 222af6b87c
7 changed files with 365 additions and 60 deletions
+194 -54
View File
@@ -411,6 +411,10 @@ export const tools: ToolDefinition[] = [
type: 'string',
description: 'Name of the function, method, or class to find callers for',
},
file: {
type: 'string',
description: 'Narrow to the definition in this file (path or suffix) when several same-named symbols exist (e.g. one UserService per app in a monorepo)',
},
limit: {
type: 'number',
description: 'Maximum number of callers to return (default: 20)',
@@ -431,6 +435,10 @@ export const tools: ToolDefinition[] = [
type: 'string',
description: 'Name of the function, method, or class to find callees for',
},
file: {
type: 'string',
description: 'Narrow to the definition in this file (path or suffix) when several same-named symbols exist',
},
limit: {
type: 'number',
description: 'Maximum number of callees to return (default: 20)',
@@ -451,6 +459,10 @@ export const tools: ToolDefinition[] = [
type: 'string',
description: 'Name of the symbol to analyze impact for',
},
file: {
type: 'string',
description: 'Narrow to the definition in this file (path or suffix) when several same-named symbols exist',
},
depth: {
type: 'number',
description: 'How many levels of dependencies to traverse (default: 2)',
@@ -1095,6 +1107,47 @@ export class ToolHandler {
return this.textResult(this.truncateOutput(formatted));
}
/**
* Group symbol matches into DISTINCT DEFINITIONS — one group per
* (filePath, qualifiedName), so same-file overloads stay together while
* unrelated same-named classes across a monorepo's apps (#764: one
* `UserService` per NestJS app) are kept apart. Optionally narrowed by a
* `file` path/suffix first.
*/
private groupDefinitions(
nodes: Node[],
fileFilter: string | undefined
): { groups: Node[][]; filteredOut: boolean } {
let pool = nodes;
let filteredOut = false;
if (fileFilter) {
const wanted = fileFilter.replace(/^\.\//, '');
const narrowed = pool.filter(
(n) => n.filePath === wanted || n.filePath.endsWith(wanted) || n.filePath.endsWith(`/${wanted}`)
);
if (narrowed.length > 0) {
pool = narrowed;
} else {
filteredOut = true;
}
}
const byDef = new Map<string, Node[]>();
for (const n of pool) {
const key = `${n.filePath}|${n.qualifiedName}`;
const group = byDef.get(key);
if (group) group.push(n);
else byDef.set(key, [n]);
}
return { groups: [...byDef.values()], filteredOut };
}
/** Section heading for one distinct definition in grouped output. */
private definitionHeading(group: Node[]): string {
const head = group[0]!;
const line = head.startLine ? `:${head.startLine}` : '';
return `### ${head.qualifiedName} (${head.kind}) — ${head.filePath}${line}`;
}
/**
* Handle codegraph_callers
*/
@@ -1104,33 +1157,68 @@ export class ToolHandler {
const cg = this.getCodeGraph(args.projectPath as string | undefined);
const limit = clamp((args.limit as number) || 20, 1, 100);
const fileFilter = typeof args.file === 'string' ? args.file : undefined;
const allMatches = this.findAllSymbols(cg, symbol);
if (allMatches.nodes.length === 0) {
return this.textResult(`Symbol "${symbol}" not found in the codebase`);
}
// Aggregate callers across all matching symbols
const seen = new Set<string>();
const allCallers: Node[] = [];
const labels = new Map<string, string>();
for (const node of allMatches.nodes) {
for (const c of cg.getCallers(node.id)) {
if (!seen.has(c.node.id)) {
seen.add(c.node.id);
allCallers.push(c.node);
const label = this.edgeLabel(c.edge);
if (label) labels.set(c.node.id, label);
const { groups, filteredOut } = this.groupDefinitions(allMatches.nodes, fileFilter);
const filterNote = filteredOut
? `\n\n> **Note:** no definition of "${symbol}" matches file "${fileFilter}" — showing all definitions instead.`
: '';
const collect = (defNodes: Node[]) => {
const seen = new Set<string>();
const callers: Node[] = [];
const labels = new Map<string, string>();
for (const node of defNodes) {
for (const c of cg.getCallers(node.id)) {
if (!seen.has(c.node.id)) {
seen.add(c.node.id);
callers.push(c.node);
const label = this.edgeLabel(c.edge);
if (label) labels.set(c.node.id, label);
}
}
}
return { callers, labels };
};
// Single definition (or same-file overloads): the familiar flat list.
if (groups.length === 1) {
const { callers, labels } = collect(groups[0]!);
if (callers.length === 0) {
return this.textResult(`No callers found for "${symbol}"${allMatches.note}${filterNote}`);
}
// 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;
return this.textResult(this.truncateOutput(formatted));
}
if (allCallers.length === 0) {
return this.textResult(`No callers found for "${symbol}"${allMatches.note}`);
// Multiple DISTINCT definitions (#764): one section per definition so an
// agent never mistakes one app's callers for another's. Narrow with
// `file` to focus a single definition.
const lines: string[] = [
`## Callers of ${symbol} — ${groups.length} distinct definitions (narrow with \`file\`)`,
];
for (const group of groups) {
const { callers, labels } = collect(group);
lines.push('', this.definitionHeading(group));
if (callers.length === 0) {
lines.push('- (no callers)');
continue;
}
for (const node of callers.slice(0, limit)) {
const location = node.startLine ? `:${node.startLine}` : '';
const label = labels.get(node.id);
lines.push(`- ${node.name} (${node.kind}) - ${node.filePath}${location}${label ? ` — via ${label}` : ''}`);
}
}
const formatted = this.formatNodeList(allCallers.slice(0, limit), `Callers of ${symbol}`, labels) + allMatches.note;
return this.textResult(this.truncateOutput(formatted));
return this.textResult(this.truncateOutput(lines.join('\n') + filterNote));
}
/**
@@ -1142,33 +1230,65 @@ export class ToolHandler {
const cg = this.getCodeGraph(args.projectPath as string | undefined);
const limit = clamp((args.limit as number) || 20, 1, 100);
const fileFilter = typeof args.file === 'string' ? args.file : undefined;
const allMatches = this.findAllSymbols(cg, symbol);
if (allMatches.nodes.length === 0) {
return this.textResult(`Symbol "${symbol}" not found in the codebase`);
}
// Aggregate callees across all matching symbols
const seen = new Set<string>();
const allCallees: Node[] = [];
const labels = new Map<string, string>();
for (const node of allMatches.nodes) {
for (const c of cg.getCallees(node.id)) {
if (!seen.has(c.node.id)) {
seen.add(c.node.id);
allCallees.push(c.node);
const label = this.edgeLabel(c.edge);
if (label) labels.set(c.node.id, label);
const { groups, filteredOut } = this.groupDefinitions(allMatches.nodes, fileFilter);
const filterNote = filteredOut
? `\n\n> **Note:** no definition of "${symbol}" matches file "${fileFilter}" — showing all definitions instead.`
: '';
const collect = (defNodes: Node[]) => {
const seen = new Set<string>();
const callees: Node[] = [];
const labels = new Map<string, string>();
for (const node of defNodes) {
for (const c of cg.getCallees(node.id)) {
if (!seen.has(c.node.id)) {
seen.add(c.node.id);
callees.push(c.node);
const label = this.edgeLabel(c.edge);
if (label) labels.set(c.node.id, label);
}
}
}
return { callees, labels };
};
if (groups.length === 1) {
const { callees, labels } = collect(groups[0]!);
if (callees.length === 0) {
return this.textResult(`No callees found for "${symbol}"${allMatches.note}${filterNote}`);
}
// 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;
return this.textResult(this.truncateOutput(formatted));
}
if (allCallees.length === 0) {
return this.textResult(`No callees found for "${symbol}"${allMatches.note}`);
// Multiple DISTINCT definitions (#764): per-definition sections.
const lines: string[] = [
`## Callees of ${symbol} — ${groups.length} distinct definitions (narrow with \`file\`)`,
];
for (const group of groups) {
const { callees, labels } = collect(group);
lines.push('', this.definitionHeading(group));
if (callees.length === 0) {
lines.push('- (no callees)');
continue;
}
for (const node of callees.slice(0, limit)) {
const location = node.startLine ? `:${node.startLine}` : '';
const label = labels.get(node.id);
lines.push(`- ${node.name} (${node.kind}) - ${node.filePath}${location}${label ? ` — via ${label}` : ''}`);
}
}
const formatted = this.formatNodeList(allCallees.slice(0, limit), `Callees of ${symbol}`, labels) + allMatches.note;
return this.textResult(this.truncateOutput(formatted));
return this.textResult(this.truncateOutput(lines.join('\n') + filterNote));
}
/**
@@ -1180,39 +1300,59 @@ export class ToolHandler {
const cg = this.getCodeGraph(args.projectPath as string | undefined);
const depth = clamp((args.depth as number) || 2, 1, 10);
const fileFilter = typeof args.file === 'string' ? args.file : undefined;
const allMatches = this.findAllSymbols(cg, symbol);
if (allMatches.nodes.length === 0) {
return this.textResult(`Symbol "${symbol}" not found in the codebase`);
}
// Aggregate impact across all matching symbols
const mergedNodes = new Map<string, Node>();
const mergedEdges: Edge[] = [];
const seenEdges = new Set<string>();
const { groups, filteredOut } = this.groupDefinitions(allMatches.nodes, fileFilter);
const filterNote = filteredOut
? `\n\n> **Note:** no definition of "${symbol}" matches file "${fileFilter}" — showing all definitions instead.`
: '';
for (const node of allMatches.nodes) {
const impact = cg.getImpactRadius(node.id, depth);
for (const [id, n] of impact.nodes) {
mergedNodes.set(id, n);
}
for (const e of impact.edges) {
const key = `${e.source}->${e.target}:${e.kind}`;
if (!seenEdges.has(key)) {
seenEdges.add(key);
mergedEdges.push(e);
const impactOf = (defNodes: Node[]) => {
const mergedNodes = new Map<string, Node>();
const mergedEdges: Edge[] = [];
const seenEdges = new Set<string>();
for (const node of defNodes) {
const impact = cg.getImpactRadius(node.id, depth);
for (const [id, n] of impact.nodes) {
mergedNodes.set(id, n);
}
for (const e of impact.edges) {
const key = `${e.source}->${e.target}:${e.kind}`;
if (!seenEdges.has(key)) {
seenEdges.add(key);
mergedEdges.push(e);
}
}
}
}
const mergedImpact = {
nodes: mergedNodes,
edges: mergedEdges,
roots: allMatches.nodes.map(n => n.id),
return { nodes: mergedNodes, edges: mergedEdges, roots: defNodes.map((n) => n.id) };
};
const formatted = this.formatImpact(symbol, mergedImpact) + allMatches.note;
return this.textResult(this.truncateOutput(formatted));
// Single definition (or same-file overloads): the familiar merged report.
if (groups.length === 1) {
const formatted = this.formatImpact(symbol, impactOf(groups[0]!)) + (fileFilter && !filteredOut ? "" : allMatches.note) + filterNote;
return this.textResult(this.truncateOutput(formatted));
}
// Multiple DISTINCT definitions (#764): a blast radius PER definition —
// merging unrelated same-named classes (one UserService per monorepo app)
// overstated impact and confused agents. Narrow with `file`.
const sections: string[] = [
`## Impact of ${symbol} — ${groups.length} distinct definitions (each with its own blast radius; narrow with \`file\`)`,
];
for (const group of groups) {
const head = group[0]!;
const line = head.startLine ? `:${head.startLine}` : '';
sections.push(
'',
this.formatImpact(`${head.qualifiedName} (${head.filePath}${line})`, impactOf(group))
);
}
return this.textResult(this.truncateOutput(sections.join('\n') + filterNote));
}
/**