feat: Improve Java extraction, multi-symbol aggregation, and impact traversal
Java extraction: - Handle Java method_invocation AST (receiver.method pattern) - Support Java extends_interfaces and super_interfaces with type_list - Create unresolved references for Java imports for cross-file resolution - Extract interface inheritance via extractInheritance MCP tools: - Aggregate callers/callees/impact across ALL matching symbols (e.g. multiple overloads or same-named methods in different classes) - New findAllSymbols() helper for multi-symbol lookup Graph traversal: - Impact analysis now traverses into container children (class → methods) so that callers of methods appear in the impact radius of their class Other: - Add deleteSpecificResolvedReferences() for precise cleanup after resolution - Add 'instance-method' to resolvedBy union type - Version bump to 0.6.8 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
8b541be894
commit
d04a911309
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@colbymchenry/codegraph",
|
||||
"version": "0.6.6",
|
||||
"version": "0.6.8",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@colbymchenry/codegraph",
|
||||
"version": "0.6.6",
|
||||
"version": "0.6.8",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@colbymchenry/codegraph",
|
||||
"version": "0.6.6",
|
||||
"version": "0.6.8",
|
||||
"description": "Supercharge Claude Code with semantic code intelligence. 30% fewer tokens, 25% fewer tool calls, 100% local.",
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
|
||||
@@ -926,6 +926,23 @@ export class QueryBuilder {
|
||||
this.db.prepare(`DELETE FROM unresolved_refs WHERE from_node_id IN (${placeholders})`).run(...fromNodeIds);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete specific resolved references by (fromNodeId, referenceName, referenceKind) tuples.
|
||||
* More precise than deleteResolvedReferences — only removes refs that were actually resolved.
|
||||
*/
|
||||
deleteSpecificResolvedReferences(refs: Array<{ fromNodeId: string; referenceName: string; referenceKind: string }>): void {
|
||||
if (refs.length === 0) return;
|
||||
const stmt = this.db.prepare(
|
||||
'DELETE FROM unresolved_refs WHERE from_node_id = ? AND reference_name = ? AND reference_kind = ?'
|
||||
);
|
||||
const deleteMany = this.db.transaction((items: typeof refs) => {
|
||||
for (const ref of items) {
|
||||
stmt.run(ref.fromNodeId, ref.referenceName, ref.referenceKind);
|
||||
}
|
||||
});
|
||||
deleteMany(refs);
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Statistics
|
||||
// ===========================================================================
|
||||
|
||||
@@ -1325,10 +1325,14 @@ export class TreeSitterExtractor {
|
||||
let kind: NodeKind = 'interface';
|
||||
if (this.language === 'rust') kind = 'trait';
|
||||
|
||||
this.createNode(kind, name, node, {
|
||||
const interfaceNode = this.createNode(kind, name, node, {
|
||||
docstring,
|
||||
isExported,
|
||||
});
|
||||
if (!interfaceNode) return;
|
||||
|
||||
// Extract extends (interface inheritance)
|
||||
this.extractInheritance(node, interfaceNode.id);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1754,6 +1758,19 @@ export class TreeSitterExtractor {
|
||||
signature: importText,
|
||||
});
|
||||
}
|
||||
// Create unresolved reference for import resolution before returning
|
||||
if (moduleName && this.nodeStack.length > 0) {
|
||||
const parentId = this.nodeStack[this.nodeStack.length - 1];
|
||||
if (parentId) {
|
||||
this.unresolvedReferences.push({
|
||||
fromNodeId: parentId,
|
||||
referenceName: moduleName,
|
||||
referenceKind: 'imports',
|
||||
line: node.startPosition.row + 1,
|
||||
column: node.startPosition.column,
|
||||
});
|
||||
}
|
||||
}
|
||||
return; // Java handled completely above
|
||||
} else if (this.language === 'csharp') {
|
||||
// C# using directives: using System, using System.Collections.Generic, using static X, using Alias = X
|
||||
@@ -1960,6 +1977,21 @@ export class TreeSitterExtractor {
|
||||
|
||||
// Get the function/method being called
|
||||
let calleeName = '';
|
||||
|
||||
// Java/Kotlin method_invocation has 'object' + 'name' fields instead of 'function'
|
||||
const nameField = getChildByField(node, 'name');
|
||||
const objectField = getChildByField(node, 'object');
|
||||
|
||||
if (nameField && objectField && node.type === 'method_invocation') {
|
||||
// Java-style method call: receiver.method()
|
||||
const methodName = getNodeText(nameField, this.source);
|
||||
const receiverName = getNodeText(objectField, this.source);
|
||||
|
||||
if (methodName) {
|
||||
// Emit receiver.method form for qualified resolution
|
||||
calleeName = `${receiverName}.${methodName}`;
|
||||
}
|
||||
} else {
|
||||
const func = getChildByField(node, 'function') || node.namedChild(0);
|
||||
|
||||
if (func) {
|
||||
@@ -1976,6 +2008,7 @@ export class TreeSitterExtractor {
|
||||
calleeName = getNodeText(func, this.source);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (calleeName) {
|
||||
this.unresolvedReferences.push({
|
||||
@@ -2023,30 +2056,38 @@ export class TreeSitterExtractor {
|
||||
if (
|
||||
child.type === 'extends_clause' ||
|
||||
child.type === 'class_heritage' ||
|
||||
child.type === 'superclass'
|
||||
child.type === 'superclass' ||
|
||||
child.type === 'extends_interfaces' // Java interface extends
|
||||
) {
|
||||
// Extract parent class name
|
||||
const superclass = child.namedChild(0);
|
||||
if (superclass) {
|
||||
const name = getNodeText(superclass, this.source);
|
||||
// Extract parent class/interface names
|
||||
// Java uses type_list wrapper: superclass -> type_identifier, extends_interfaces -> type_list -> type_identifier
|
||||
const typeList = child.namedChildren.find((c: SyntaxNode) => c.type === 'type_list');
|
||||
const targets = typeList ? typeList.namedChildren : [child.namedChild(0)];
|
||||
for (const target of targets) {
|
||||
if (target) {
|
||||
const name = getNodeText(target, this.source);
|
||||
this.unresolvedReferences.push({
|
||||
fromNodeId: classId,
|
||||
referenceName: name,
|
||||
referenceKind: 'extends',
|
||||
line: child.startPosition.row + 1,
|
||||
column: child.startPosition.column,
|
||||
line: target.startPosition.row + 1,
|
||||
column: target.startPosition.column,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
child.type === 'implements_clause' ||
|
||||
child.type === 'class_interface_clause' ||
|
||||
child.type === 'super_interfaces' || // Java class implements
|
||||
child.type === 'interfaces' // Dart
|
||||
) {
|
||||
// Extract implemented interfaces
|
||||
for (let j = 0; j < child.namedChildCount; j++) {
|
||||
const iface = child.namedChild(j);
|
||||
// Java uses type_list wrapper: super_interfaces -> type_list -> type_identifier
|
||||
const typeList = child.namedChildren.find((c: SyntaxNode) => c.type === 'type_list');
|
||||
const targets = typeList ? typeList.namedChildren : child.namedChildren;
|
||||
for (const iface of targets) {
|
||||
if (iface) {
|
||||
const name = getNodeText(iface, this.source);
|
||||
this.unresolvedReferences.push({
|
||||
|
||||
@@ -483,6 +483,25 @@ export class GraphTraverser {
|
||||
}
|
||||
visited.add(nodeId);
|
||||
|
||||
// For container nodes (classes, interfaces, structs, etc.), also traverse
|
||||
// into their children so that callers of contained methods appear in impact
|
||||
const focalNode = this.queries.getNodeById(nodeId);
|
||||
if (focalNode) {
|
||||
const containerKinds = new Set(['class', 'interface', 'struct', 'trait', 'protocol', 'module', 'enum']);
|
||||
if (containerKinds.has(focalNode.kind)) {
|
||||
const containsEdges = this.queries.getOutgoingEdges(nodeId, ['contains']);
|
||||
for (const edge of containsEdges) {
|
||||
const childNode = this.queries.getNodeById(edge.target);
|
||||
if (childNode && !visited.has(childNode.id)) {
|
||||
nodes.set(childNode.id, childNode);
|
||||
edges.push(edge);
|
||||
// Recurse into children at the same depth (they're part of the same symbol)
|
||||
this.getImpactRecursive(childNode.id, maxDepth, currentDepth, nodes, edges, visited);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Get all incoming edges (things that depend on this node)
|
||||
const incomingEdges = this.queries.getIncomingEdges(nodeId);
|
||||
|
||||
|
||||
+87
-21
@@ -5,7 +5,7 @@
|
||||
*/
|
||||
|
||||
import CodeGraph, { findNearestCodeGraphRoot } from '../index';
|
||||
import type { Node, SearchResult, Subgraph, TaskContext, NodeKind } from '../types';
|
||||
import type { Node, Edge, SearchResult, Subgraph, TaskContext, NodeKind } from '../types';
|
||||
import { createHash } from 'crypto';
|
||||
import { writeFileSync } from 'fs';
|
||||
import { clamp } from '../utils';
|
||||
@@ -475,19 +475,28 @@ export class ToolHandler {
|
||||
const cg = this.getCodeGraph(args.projectPath as string | undefined);
|
||||
const limit = clamp((args.limit as number) || 20, 1, 100);
|
||||
|
||||
const match = this.findSymbol(cg, symbol);
|
||||
if (!match) {
|
||||
const allMatches = this.findAllSymbols(cg, symbol);
|
||||
if (allMatches.nodes.length === 0) {
|
||||
return this.textResult(`Symbol "${symbol}" not found in the codebase`);
|
||||
}
|
||||
|
||||
const callers = cg.getCallers(match.node.id);
|
||||
|
||||
if (callers.length === 0) {
|
||||
return this.textResult(`No callers found for "${symbol}"${match.note}`);
|
||||
// Aggregate callers across all matching symbols
|
||||
const seen = new Set<string>();
|
||||
const allCallers: Node[] = [];
|
||||
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 callerNodes = callers.slice(0, limit).map(c => c.node);
|
||||
const formatted = this.formatNodeList(callerNodes, `Callers of ${symbol}`) + match.note;
|
||||
if (allCallers.length === 0) {
|
||||
return this.textResult(`No callers found for "${symbol}"${allMatches.note}`);
|
||||
}
|
||||
|
||||
const formatted = this.formatNodeList(allCallers.slice(0, limit), `Callers of ${symbol}`) + allMatches.note;
|
||||
return this.textResult(this.truncateOutput(formatted));
|
||||
}
|
||||
|
||||
@@ -501,19 +510,28 @@ export class ToolHandler {
|
||||
const cg = this.getCodeGraph(args.projectPath as string | undefined);
|
||||
const limit = clamp((args.limit as number) || 20, 1, 100);
|
||||
|
||||
const match = this.findSymbol(cg, symbol);
|
||||
if (!match) {
|
||||
const allMatches = this.findAllSymbols(cg, symbol);
|
||||
if (allMatches.nodes.length === 0) {
|
||||
return this.textResult(`Symbol "${symbol}" not found in the codebase`);
|
||||
}
|
||||
|
||||
const callees = cg.getCallees(match.node.id);
|
||||
|
||||
if (callees.length === 0) {
|
||||
return this.textResult(`No callees found for "${symbol}"${match.note}`);
|
||||
// Aggregate callees across all matching symbols
|
||||
const seen = new Set<string>();
|
||||
const allCallees: Node[] = [];
|
||||
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 calleeNodes = callees.slice(0, limit).map(c => c.node);
|
||||
const formatted = this.formatNodeList(calleeNodes, `Callees of ${symbol}`) + match.note;
|
||||
if (allCallees.length === 0) {
|
||||
return this.textResult(`No callees found for "${symbol}"${allMatches.note}`);
|
||||
}
|
||||
|
||||
const formatted = this.formatNodeList(allCallees.slice(0, limit), `Callees of ${symbol}`) + allMatches.note;
|
||||
return this.textResult(this.truncateOutput(formatted));
|
||||
}
|
||||
|
||||
@@ -527,14 +545,37 @@ export class ToolHandler {
|
||||
const cg = this.getCodeGraph(args.projectPath as string | undefined);
|
||||
const depth = clamp((args.depth as number) || 2, 1, 10);
|
||||
|
||||
const match = this.findSymbol(cg, symbol);
|
||||
if (!match) {
|
||||
const allMatches = this.findAllSymbols(cg, symbol);
|
||||
if (allMatches.nodes.length === 0) {
|
||||
return this.textResult(`Symbol "${symbol}" not found in the codebase`);
|
||||
}
|
||||
|
||||
const impact = cg.getImpactRadius(match.node.id, depth);
|
||||
// Aggregate impact across all matching symbols
|
||||
const mergedNodes = new Map<string, Node>();
|
||||
const mergedEdges: Edge[] = [];
|
||||
const seenEdges = new Set<string>();
|
||||
|
||||
const formatted = this.formatImpact(symbol, impact) + match.note;
|
||||
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 mergedImpact = {
|
||||
nodes: mergedNodes,
|
||||
edges: mergedEdges,
|
||||
roots: allMatches.nodes.map(n => n.id),
|
||||
};
|
||||
|
||||
const formatted = this.formatImpact(symbol, mergedImpact) + allMatches.note;
|
||||
return this.textResult(this.truncateOutput(formatted));
|
||||
}
|
||||
|
||||
@@ -822,6 +863,31 @@ export class ToolHandler {
|
||||
return { node: results[0]!.node, note: '' };
|
||||
}
|
||||
|
||||
/**
|
||||
* Find ALL symbols matching a name. Used by callers/callees/impact to aggregate
|
||||
* results across all matching symbols (e.g., multiple classes with an `execute` method).
|
||||
*/
|
||||
private findAllSymbols(cg: CodeGraph, symbol: string): { nodes: Node[]; note: string } {
|
||||
const results = cg.searchNodes(symbol, { limit: 50 });
|
||||
|
||||
if (results.length === 0) {
|
||||
return { nodes: [], note: '' };
|
||||
}
|
||||
|
||||
const exactMatches = results.filter(r => r.node.name === symbol);
|
||||
|
||||
if (exactMatches.length <= 1) {
|
||||
const node = exactMatches[0]?.node ?? results[0]!.node;
|
||||
return { nodes: [node], note: '' };
|
||||
}
|
||||
|
||||
const locations = exactMatches.map(r =>
|
||||
`${r.node.kind} at ${r.node.filePath}:${r.node.startLine}`
|
||||
);
|
||||
const note = `\n\n> **Note:** Aggregated results across ${exactMatches.length} symbols named "${symbol}": ${locations.join(', ')}`;
|
||||
return { nodes: exactMatches.map(r => r.node), note };
|
||||
}
|
||||
|
||||
/**
|
||||
* Truncate output if it exceeds the maximum length
|
||||
*/
|
||||
|
||||
@@ -39,7 +39,7 @@ export interface ResolvedRef {
|
||||
/** Confidence score (0-1) */
|
||||
confidence: number;
|
||||
/** How it was resolved */
|
||||
resolvedBy: 'exact-match' | 'import' | 'qualified-name' | 'framework' | 'fuzzy';
|
||||
resolvedBy: 'exact-match' | 'import' | 'qualified-name' | 'framework' | 'fuzzy' | 'instance-method';
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user