feat(jvm): resolve Java/Kotlin imports by fully-qualified name (#412)
Wrap top-level declarations of `.kt` / `.java` files in an implicit `namespace` node carrying the file's `package`, then resolve `import com.example.foo.Bar` through that qualifiedName index — so a Bar in Models.kt resolves correctly regardless of filename, a top-level function import binds to its declaration, Java↔Kotlin interop crosses cleanly, and same-name classes across packages no longer collide. Wildcard imports still go through name-matcher.
Also extracts Java/C# anonymous-class overrides (`new T() { ... }`) as first-class class nodes with their override methods. Phase 5.5 interface-impl then bridges T's abstract methods to the anonymous overrides automatically — including the lambda-returned `new T() { ... }` pattern common in guava (Splitter, CacheBuilder).
Concrete impact on macrozheng/mall (524 .java files, multi-module Spring + MyBatis): 524 namespace nodes, 862 imports edges newly resolve to Java symbols, 76 distinct `Criteria` classes preserved across packages with no merge. On google/guava (3,227 .java): 3,608 anonymous classes extracted, +2,534 interface-impl edges reach overrides hidden in `new T() { ... }` blocks.
Agent A/B playbook on small (spring-petclinic-kotlin, 38 .kt), medium (mall, 524 .java), large (guava, 3,227 .java) — 3 flow prompts × 2 runs/arm × 2 arms = 36 runs, claude-opus, headless. Spring repos: 0/0 Read/Grep with-arm, −27% wall-clock vs no-codegraph. Guava: 1.8 Read avg with-arm (vs 2.0 without) — improved by the anon-class extraction; residual is a lambda→SAM coverage gap orthogonal to FQN imports (filing follow-up).
This commit is contained in:
@@ -56,4 +56,12 @@ export const javaExtractor: LanguageExtractor = {
|
||||
}
|
||||
return null;
|
||||
},
|
||||
packageTypes: ['package_declaration'],
|
||||
extractPackage: (node, source) => {
|
||||
// package_declaration → scoped_identifier or identifier (single-segment)
|
||||
const id = node.namedChildren.find(
|
||||
(c: SyntaxNode) => c.type === 'scoped_identifier' || c.type === 'identifier'
|
||||
);
|
||||
return id ? source.substring(id.startIndex, id.endIndex).trim() : null;
|
||||
},
|
||||
};
|
||||
|
||||
@@ -235,4 +235,10 @@ export const kotlinExtractor: LanguageExtractor = {
|
||||
}
|
||||
return null;
|
||||
},
|
||||
packageTypes: ['package_header'],
|
||||
extractPackage: (node, source) => {
|
||||
// package_header → identifier (dotted: `com.example.foo`)
|
||||
const id = node.namedChildren.find((c: SyntaxNode) => c.type === 'identifier');
|
||||
return id ? source.substring(id.startIndex, id.endIndex).trim() : null;
|
||||
},
|
||||
};
|
||||
|
||||
@@ -212,4 +212,16 @@ export interface LanguageExtractor {
|
||||
* Returns the callee name if this node is a bare call, or undefined if not.
|
||||
*/
|
||||
extractBareCall?: (node: SyntaxNode, source: string) => string | undefined;
|
||||
|
||||
/**
|
||||
* Node types representing a file-level package/namespace declaration
|
||||
* (e.g. Kotlin `package_header`, Java `package_declaration`). When set,
|
||||
* the core wraps every top-level declaration in an implicit `namespace`
|
||||
* node carrying the FQN, so cross-file import resolution can match by
|
||||
* qualifiedName instead of filename (Kotlin filename ≠ class name).
|
||||
*/
|
||||
packageTypes?: string[];
|
||||
|
||||
/** Extract the dotted package name from a package declaration node. */
|
||||
extractPackage?: (node: SyntaxNode, source: string) => string | null;
|
||||
}
|
||||
|
||||
@@ -215,7 +215,17 @@ export class TreeSitterExtractor {
|
||||
|
||||
// Push file node onto stack so top-level declarations get contains edges
|
||||
this.nodeStack.push(fileNode.id);
|
||||
|
||||
// File-level package declaration (Kotlin/Java). Creates an implicit
|
||||
// `namespace` node wrapping every top-level declaration so their
|
||||
// qualifiedName carries the FQN — required for cross-file import
|
||||
// resolution on JVM languages where filename ≠ class name.
|
||||
const packageNodeId = this.extractFilePackage(this.tree.rootNode);
|
||||
if (packageNodeId) this.nodeStack.push(packageNodeId);
|
||||
|
||||
this.visitNode(this.tree.rootNode);
|
||||
|
||||
if (packageNodeId) this.nodeStack.pop();
|
||||
this.nodeStack.pop();
|
||||
} catch (error) {
|
||||
const msg = error instanceof Error ? error.message : String(error);
|
||||
@@ -378,6 +388,17 @@ export class TreeSitterExtractor {
|
||||
// their own `calls` refs.
|
||||
else if (INSTANTIATION_KINDS.has(nodeType)) {
|
||||
this.extractInstantiation(node);
|
||||
// Java/C# `new T(...) { ... }` — anonymous class with body. Without
|
||||
// extracting it as a class node + its methods, the interface→impl
|
||||
// synthesizer (Phase 5.5) can't bridge T's abstract methods to the
|
||||
// anonymous overrides, and an agent investigating a call through T
|
||||
// (`strategy.iterator(...)` where strategy is a Strategy lambda body)
|
||||
// has to Read the file to find the actual implementation.
|
||||
const anonBody = this.findAnonymousClassBody(node);
|
||||
if (anonBody) {
|
||||
this.extractAnonymousClass(node, anonBody);
|
||||
skipChildren = true;
|
||||
}
|
||||
}
|
||||
// (Decorator handling lives inside the symbol-creating extractors
|
||||
// — extractClass / extractFunction / extractProperty — because the
|
||||
@@ -490,6 +511,33 @@ export class TreeSitterExtractor {
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a `packageTypes` child under the root, create a `namespace` node
|
||||
* for it, and return its id so the caller can scope top-level
|
||||
* declarations underneath. Returns null when no package header is
|
||||
* present (script files, .kts without a package).
|
||||
*/
|
||||
private extractFilePackage(rootNode: SyntaxNode): string | null {
|
||||
const types = this.extractor?.packageTypes;
|
||||
if (!types || types.length === 0 || !this.extractor?.extractPackage) return null;
|
||||
|
||||
let pkgNode: SyntaxNode | null = null;
|
||||
for (let i = 0; i < rootNode.namedChildCount; i++) {
|
||||
const child = rootNode.namedChild(i);
|
||||
if (child && types.includes(child.type)) {
|
||||
pkgNode = child;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!pkgNode) return null;
|
||||
|
||||
const pkgName = this.extractor.extractPackage(pkgNode, this.source);
|
||||
if (!pkgName) return null;
|
||||
|
||||
const ns = this.createNode('namespace', pkgName, pkgNode);
|
||||
return ns?.id ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build qualified name from node stack
|
||||
*/
|
||||
@@ -1747,6 +1795,78 @@ export class TreeSitterExtractor {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a `class_body` child of an `object_creation_expression` — the
|
||||
* marker for an anonymous class (`new T() { ... }`). Returns the body
|
||||
* node so the caller can walk it as the anon class's members.
|
||||
*/
|
||||
private findAnonymousClassBody(node: SyntaxNode): SyntaxNode | null {
|
||||
for (let i = 0; i < node.namedChildCount; i++) {
|
||||
const child = node.namedChild(i);
|
||||
// Java: `class_body`. C# uses the same node kind.
|
||||
if (child && (child.type === 'class_body' || child.type === 'declaration_list')) {
|
||||
return child;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract a Java/C# anonymous class — `new T() { ...members }`. Emits a
|
||||
* `class` node named `<T$anon@line>`, an `extends` reference to T (so
|
||||
* Phase 5.5 interface-impl can bridge), and walks the body so its
|
||||
* `method_declaration` members become method nodes under the anon class.
|
||||
*
|
||||
* Why this matters: without anon-class extraction, the overrides inside
|
||||
* a lambda-returned `new T() { @Override int foo(){...} }` are not nodes,
|
||||
* so a call through T.foo (the abstract parent method) has no static
|
||||
* target — the agent has to Read the file to find the implementation.
|
||||
*/
|
||||
private extractAnonymousClass(node: SyntaxNode, body: SyntaxNode): void {
|
||||
if (!this.extractor) return;
|
||||
|
||||
// The instantiated type sits in the same field/position that
|
||||
// extractInstantiation reads from. Use the same lookup so the anon
|
||||
// class's `extends` target matches the `instantiates` edge.
|
||||
const typeNode =
|
||||
getChildByField(node, 'constructor') ||
|
||||
getChildByField(node, 'type') ||
|
||||
getChildByField(node, 'name') ||
|
||||
node.namedChild(0);
|
||||
let typeName = typeNode ? getNodeText(typeNode, this.source) : 'Object';
|
||||
const ltIdx = typeName.indexOf('<');
|
||||
if (ltIdx > 0) typeName = typeName.slice(0, ltIdx);
|
||||
const lastDot = Math.max(typeName.lastIndexOf('.'), typeName.lastIndexOf('::'));
|
||||
if (lastDot >= 0) typeName = typeName.slice(lastDot + 1).replace(/^[:.]/, '');
|
||||
typeName = typeName.trim() || 'Object';
|
||||
|
||||
const anonName = `<${typeName}$anon@${node.startPosition.row + 1}>`;
|
||||
const classNode = this.createNode('class', anonName, node, {});
|
||||
if (!classNode) return;
|
||||
|
||||
// The anonymous class implicitly extends/implements the named type.
|
||||
// We can't tell at extraction time whether T is a class or an interface,
|
||||
// so emit `extends`. Resolution will still bind T to whatever it is, and
|
||||
// Phase 5.5 (which already handles both `extends` and `implements`) will
|
||||
// bridge T's methods to the override names found in the anon body.
|
||||
this.unresolvedReferences.push({
|
||||
fromNodeId: classNode.id,
|
||||
referenceName: typeName,
|
||||
referenceKind: 'extends',
|
||||
line: typeNode?.startPosition.row ?? node.startPosition.row,
|
||||
column: typeNode?.startPosition.column ?? node.startPosition.column,
|
||||
});
|
||||
|
||||
// Walk the body's children so method_declaration nodes inside become
|
||||
// method nodes scoped to the anon class.
|
||||
this.nodeStack.push(classNode.id);
|
||||
for (let i = 0; i < body.namedChildCount; i++) {
|
||||
const child = body.namedChild(i);
|
||||
if (child) this.visitNode(child);
|
||||
}
|
||||
this.nodeStack.pop();
|
||||
}
|
||||
|
||||
/**
|
||||
* Scan `declNode` and its preceding siblings (within the parent's
|
||||
* named children) for decorator nodes, emitting a `decorates`
|
||||
@@ -1876,6 +1996,14 @@ export class TreeSitterExtractor {
|
||||
// about `call_expression`, so constructor invocations
|
||||
// produced no graph edges at all.
|
||||
this.extractInstantiation(node);
|
||||
// Anonymous class with body: `new T() { ... }` (Java/C#). Extract as
|
||||
// a class so interface-impl synthesis (Phase 5.5) can bridge T's
|
||||
// methods to the overrides — same rationale as in visitNode.
|
||||
const anonBody = this.findAnonymousClassBody(node);
|
||||
if (anonBody) {
|
||||
this.extractAnonymousClass(node, anonBody);
|
||||
return;
|
||||
}
|
||||
} else if (this.extractor!.extractBareCall) {
|
||||
const calleeName = this.extractor!.extractBareCall(node, this.source);
|
||||
if (calleeName && this.nodeStack.length > 0) {
|
||||
|
||||
Reference in New Issue
Block a user