Procházet zdrojové kódy

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>
Colby Mchenry před 1 týdnem
rodič
revize
222af6b87c

+ 2 - 0
CHANGELOG.md

@@ -16,6 +16,8 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
 
 ### New Features
 
+- Same-named symbols across a monorepo's apps are no longer conflated. In a NestJS-style workspace with one `UserService` per app, `codegraph_callers`, `codegraph_callees`, and `codegraph_impact` now report **one section per distinct definition** — each app's callers and blast radius under its own file-labeled heading — instead of a single merged list, and accept a `file` argument to focus exactly the definition you mean (like `codegraph_node` already did). Impact in particular no longer overstates a change's blast radius by merging unrelated same-named classes. Thanks @Igorgro. (#764)
+- Fixed a related source of cross-package wrong edges: PascalCase **type references from plain `.ts` files were being resolved as React components**, which could link a file's own type alias to an arbitrary same-named class in another package (on one large monorepo this produced over a thousand wrong cross-package reference edges; 96% are now gone, and the remainder are genuine shared-model imports). Component resolution now applies only to references from JSX-capable files and never guesses between multiple candidates without a positional signal. Re-index a project to benefit. (#764) (TypeScript, React)
 - TypeScript and JavaScript **class fields are now reported as properties instead of methods**. A plain field like `public fonts: Fonts;` previously extracted as a method, misrepresenting class shape and letting calls to same-named functions resolve to data fields (a boolean field named `isArray` was soaking up `Array.isArray(...)` call edges). Fields holding arrow functions or function expressions (`onClick = () => {…}`, including wrapped ones like `onScroll = throttle(() => {…})`) correctly remain methods and their bodies are still analyzed. Field initializers are analyzed too, so `history = createHistory()` records its call — and JavaScript class fields, which previously produced no symbol at all, now appear in the graph. Re-index a project to benefit. (#808) (TypeScript, JavaScript)
 - Callback registration through `this` now resolves precisely in TypeScript and JavaScript: `window.addEventListener("online", this.onOfflineStatusToggle)` or an API object like `{ mutateElement: this.mutateElement }` produces a reference edge to the **enclosing class's own method** — never a same-named method on an unrelated class, and never a data field. Builds on the callback-registration support below. (#808) (TypeScript, JavaScript)
 - Callback-registration coverage deepened across four more shapes: a `this.<member>` registration whose method lives on a **base class** now resolves through the inheritance chain (`bus.on("submit", this.handleSubmit)` in a subclass links to the parent's `handleSubmit`); Java and Kotlin **method references to other classes** (`Handlers::onMessage`, `OtherClass::handle`) resolve across files, with `this::` and `super::` scoped to the defining class and references through a variable deliberately left out; and Swift bare callback names now match only the **enclosing type's** methods (implicit `self`), eliminating a class of wrong edges where a parameter like `request` linked to a same-named method on an unrelated type. (Java, Kotlin, Swift, TypeScript, JavaScript)

+ 12 - 1
__tests__/resolution.test.ts

@@ -581,12 +581,23 @@ from ..services import auth_service
         line: 10,
         column: 5,
         filePath: 'src/App.tsx',
-        language: 'typescript' as const,
+        // Refs extracted from .tsx files carry language 'tsx' — component
+        // resolution is gated to JSX-capable refs (#764: PascalCase TYPE refs
+        // from plain .ts files were resolving to arbitrary same-named classes).
+        language: 'tsx' as const,
       };
 
       const result = reactResolver!.resolve(ref, context);
       expect(result).not.toBeNull();
       expect(result?.targetNodeId).toBe('component:src/Button.tsx:Button:5');
+
+      // The same PascalCase name referenced from a plain .ts file is a TYPE
+      // reference, not a component usage — component resolution must decline
+      // and leave it to proximity-aware name matching (#764: a .ts GraphQL
+      // types file's own `Account` alias was losing to an arbitrary same-named
+      // class in another monorepo package).
+      const tsRef = { ...ref, filePath: 'src/models.ts', language: 'typescript' as const };
+      expect(reactResolver!.resolve(tsRef, context)).toBeNull();
     });
 
     it('should resolve custom hook references', () => {

+ 138 - 0
__tests__/same-name-disambiguation.test.ts

@@ -0,0 +1,138 @@
+/**
+ * Same-named symbols across monorepo apps (#764).
+ *
+ * A NestJS-style monorepo has one `UserService` (and friends) per app. The
+ * graph keeps them as distinct nodes (import + proximity resolution), but the
+ * MCP tools used to AGGREGATE them: callers/callees returned one merged list
+ * and impact merged both blast radii — the conflation agents warned about.
+ *
+ * Now: multiple DISTINCT definitions (different file/qualified-name) render
+ * one section per definition, and `file` narrows to a single definition.
+ * Same-file overloads still merge (that's the overload feature).
+ */
+
+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 ?? '';
+};
+
+beforeAll(async () => {
+  await initGrammars();
+  await loadAllGrammars();
+
+  tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-764-'));
+  const mk = (rel: string, content: string) => {
+    const p = path.join(tmpDir, rel);
+    fs.mkdirSync(path.dirname(p), { recursive: true });
+    fs.writeFileSync(p, content);
+  };
+
+  for (const app of ['billing', 'admin']) {
+    mk(
+      `apps/${app}/src/users/user.service.ts`,
+      [
+        "import { UserRepository } from './user.repository';",
+        'export class UserService {',
+        '  constructor(private readonly repo: UserRepository) {}',
+        '  findAll(): string[] {',
+        `    return this.repo.load_${app}();`,
+        '  }',
+        '}',
+      ].join('\n')
+    );
+    mk(
+      `apps/${app}/src/users/user.repository.ts`,
+      `export class UserRepository {\n  load_${app}(): string[] { return []; }\n}\n`
+    );
+    mk(
+      `apps/${app}/src/users/user.controller.ts`,
+      [
+        "import { UserService } from './user.service';",
+        'export class UserController {',
+        '  constructor(private readonly users: UserService) {}',
+        '  list(): string[] { return this.users.findAll(); }',
+        '}',
+      ].join('\n')
+    );
+  }
+
+  cg = CodeGraph.initSync(tmpDir);
+  await cg.indexAll();
+  handler = new ToolHandler(cg);
+}, 120_000);
+
+afterAll(() => {
+  cg?.destroy();
+  if (tmpDir) fs.rmSync(tmpDir, { recursive: true, force: true });
+});
+
+describe('same-named symbols across apps (#764)', () => {
+  it('graph keeps the apps apart: no cross-app edges at all', () => {
+    const billing = new Set(
+      cg.getNodesByName('findAll').filter((n) => n.filePath.includes('billing')).map((n) => n.id)
+    );
+    for (const id of billing) {
+      for (const e of cg.getIncomingEdges(id)) {
+        const src = cg.getNode(e.source);
+        expect(src?.filePath.includes('admin')).toBe(false);
+      }
+    }
+  });
+
+  it('callers: one section per distinct definition, each with only its own callers', async () => {
+    const out = await text('codegraph_callers', { symbol: 'findAll' });
+    expect(out).toContain('2 distinct definitions');
+    // Section per definition…
+    expect(out).toContain('apps/admin/src/users/user.service.ts');
+    expect(out).toContain('apps/billing/src/users/user.service.ts');
+    // …and the billing section must list the billing controller, not admin's.
+    const billingSection = out.slice(out.indexOf('apps/billing/src/users/user.service.ts'));
+    const billingBody = billingSection.slice(0, billingSection.indexOf('###', 3) > 0 ? billingSection.indexOf('###', 3) : undefined);
+    expect(billingBody).toContain('apps/billing/src/users/user.controller.ts');
+    expect(billingBody).not.toContain('apps/admin/src/users/user.controller.ts');
+  });
+
+  it('callers: `file` narrows to one definition (flat list, no stale aggregation note)', async () => {
+    const out = await text('codegraph_callers', {
+      symbol: 'findAll',
+      file: 'apps/billing/src/users/user.service.ts',
+    });
+    expect(out).not.toContain('distinct definitions');
+    expect(out).toContain('apps/billing/src/users/user.controller.ts');
+    expect(out).not.toContain('apps/admin/');
+    expect(out).not.toContain('Aggregated results');
+  });
+
+  it('callers: a non-matching `file` falls back to all definitions with a note', async () => {
+    const out = await text('codegraph_callers', { symbol: 'findAll', file: 'apps/nonexistent/x.ts' });
+    expect(out).toContain('no definition of "findAll" matches file');
+    expect(out).toContain('2 distinct definitions');
+  });
+
+  it('impact: separate blast radius per definition, never a merged one', async () => {
+    const out = await text('codegraph_impact', { symbol: 'UserService' });
+    expect(out).toContain('2 distinct definitions');
+    // Each section's count covers ONE app (service + ctor + findAll +
+    // controller side), not the union of both.
+    const counts = [...out.matchAll(/affects (\d+) symbols/g)].map((m) => Number(m[1]));
+    expect(counts).toHaveLength(2);
+    for (const c of counts) expect(c).toBeLessThanOrEqual(7);
+  });
+
+  it('callees: grouped the same way', async () => {
+    const out = await text('codegraph_callees', { symbol: 'list' });
+    expect(out).toContain('2 distinct definitions');
+  });
+});

+ 1 - 1
src/extraction/extraction-version.ts

@@ -21,4 +21,4 @@
  * turns the re-index hint into noise — keep it honest (see CLAUDE.md, "Honesty
  * in the product is load-bearing").
  */
-export const EXTRACTION_VERSION = 23;
+export const EXTRACTION_VERSION = 24;

+ 1 - 1
src/mcp/server-instructions.ts

@@ -47,7 +47,7 @@ typically one to a few calls; a grep/read exploration is dozens.
 - **Almost any question — "how does X work", architecture, a bug, "what/where is X", or surveying an area** → \`codegraph_explore\` (PRIMARY — call FIRST; ONE capped call returns the verbatim source of the relevant symbols grouped by file; most often the ONLY call you need)
 - **"How does X reach/become Y? / the flow / the path from X to Y"** → \`codegraph_explore\`, naming the symbols that span the flow (e.g. \`mutateElement renderScene\`) — it surfaces the call path among them, including dynamic-dispatch hops (callbacks, React re-render, JSX children) grep can't follow
 - **"What is the symbol named X?" (just its location)** → \`codegraph_search\`
-- **"What calls this?" / "What does this call?" / "What would changing this break?"** → \`codegraph_callers\` / \`codegraph_callees\` / \`codegraph_impact\`. Callers includes where a function is **registered as a callback** (passed as an argument, assigned to a function pointer/field, listed in a handler table) — labeled "via callback registration" — so a function with no direct calls is NOT dead if it's wired up somewhere
+- **"What calls this?" / "What does this call?" / "What would changing this break?"** → \`codegraph_callers\` / \`codegraph_callees\` / \`codegraph_impact\`. Callers includes where a function is **registered as a callback** (passed as an argument, assigned to a function pointer/field, listed in a handler table) — labeled "via callback registration" — so a function with no direct calls is NOT dead if it's wired up somewhere. When several UNRELATED symbols share a name (one \`UserService\` per monorepo app), these tools report **one section per definition** (never a merged list) — pass \`file\` to focus the definition you mean
 - **Reading a source FILE (any time you'd use the \`Read\` tool)** → \`codegraph_node\` with a \`file\` path and no \`symbol\`. It returns the file's **current source with line numbers — the same \`<n>\\t<line>\` shape \`Read\` gives you, safe to \`Edit\` from** — narrowable with \`offset\`/\`limit\` exactly like \`Read\`, PLUS a one-line note of which files depend on it. Same bytes as \`Read\`, faster (served from the index), with the blast radius attached. Use it **instead of \`Read\`** for indexed source files; fall back to \`Read\` only for what codegraph doesn't index (configs, docs). Pass \`symbolsOnly: true\` for just the file's structure.
 - **About to read or edit a symbol you can name** → \`codegraph_node\` with that \`symbol\` (SECONDARY — the after-explore depth tool): the verbatim source (\`includeCode: true\`) PLUS its caller/callee trail, so before changing it you see what calls it and what your edit would break. For an OVERLOADED name it returns EVERY matching definition's body in one call, so you never Read a file to find the right overload
 - **"What's in directory X?"** → \`codegraph_files\`

+ 194 - 54
src/mcp/tools.ts

@@ -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 };
+    };
 
-    if (allCallers.length === 0) {
-      return this.textResult(`No callers found for "${symbol}"${allMatches.note}`);
+    // 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));
     }
 
-    const formatted = this.formatNodeList(allCallers.slice(0, limit), `Callers of ${symbol}`, labels) + allMatches.note;
-    return this.textResult(this.truncateOutput(formatted));
+    // 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}` : ''}`);
+      }
+    }
+    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 (allCallees.length === 0) {
-      return this.textResult(`No callees found for "${symbol}"${allMatches.note}`);
+    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));
     }
 
-    const formatted = this.formatNodeList(allCallees.slice(0, limit), `Callees of ${symbol}`, labels) + allMatches.note;
-    return this.textResult(this.truncateOutput(formatted));
+    // 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}` : ''}`);
+      }
+    }
+    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));
   }
 
   /**

+ 17 - 3
src/resolution/frameworks/react.ts

@@ -32,8 +32,19 @@ export const reactResolver: FrameworkResolver = {
   },
 
   resolve(ref: UnresolvedRef, context: ResolutionContext): ResolvedRef | null {
-    // Pattern 1: Component references (PascalCase)
-    if (isPascalCase(ref.referenceName) && !isBuiltInType(ref.referenceName)) {
+    // Pattern 1: Component references (PascalCase). Only from JSX-capable
+    // files — a component is USED in markup, which only parses in .tsx/.jsx.
+    // Without this gate, every PascalCase TYPE reference in plain .ts files
+    // went through component resolution: in a monorepo with same-named
+    // classes per package (#764, amplication), a `.ts` GraphQL-types file's
+    // own `Account` type alias lost to an arbitrary `Account` CLASS in
+    // another package (the framework's 0.8 outranked the name-matcher's
+    // proximity-correct 0.7).
+    if (
+      (ref.language === 'tsx' || ref.language === 'jsx') &&
+      isPascalCase(ref.referenceName) &&
+      !isBuiltInType(ref.referenceName)
+    ) {
       const result = resolveComponent(ref.referenceName, ref.filePath, context);
       if (result) {
         return {
@@ -305,7 +316,10 @@ function resolveComponent(
   );
   if (preferred.length > 0) return preferred[0]!.id;
 
-  return components[0]!.id;
+  // No positional signal: only an UNAMBIGUOUS name may resolve. Returning
+  // components[0] here picked an arbitrary same-named class anywhere in the
+  // repo (#764) — let the name-matcher's proximity scoring decide instead.
+  return components.length === 1 ? components[0]!.id : null;
 }
 
 /**