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:
co-authored by
Claude Opus 4.8
parent
dce61a5f4a
commit
222af6b87c
@@ -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;
|
||||
|
||||
@@ -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
@@ -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));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user