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:
Artem Bambalov
2026-05-26 22:06:53 -05:00
committed by GitHub
parent 3808b4d0a8
commit 34240eb297
10 changed files with 717 additions and 3 deletions
+113 -2
View File
@@ -12,7 +12,8 @@ import { CodeGraph } from '../src';
import { Node, UnresolvedReference } from '../src/types';
import { ReferenceResolver, createResolver, ResolutionContext } from '../src/resolution';
import { matchReference } from '../src/resolution/name-matcher';
import { resolveImportPath, extractImportMappings, loadCppIncludeDirs, clearCppIncludeDirCache } from '../src/resolution/import-resolver';
import { resolveImportPath, extractImportMappings, resolveJvmImport, loadCppIncludeDirs, clearCppIncludeDirCache } from '../src/resolution/import-resolver';
import type { UnresolvedRef } from '../src/resolution/types';
import { detectFrameworks, getAllFrameworkResolvers } from '../src/resolution/frameworks';
import { QueryBuilder } from '../src/db/queries';
import { DatabaseConnection } from '../src/db';
@@ -353,6 +354,116 @@ from ..services import auth_service
});
});
describe('JVM FQN Import Resolution', () => {
// Build a ResolutionContext stub whose getNodesByQualifiedName answers
// from a fixed table — the only context method resolveJvmImport touches.
const makeContext = (byQName: Record<string, Node[]>): ResolutionContext => ({
getNodesInFile: () => [],
getNodesByName: () => [],
getNodesByQualifiedName: (q) => byQName[q] ?? [],
getNodesByKind: () => [],
fileExists: () => false,
readFile: () => null,
getProjectRoot: () => '',
getAllFiles: () => [],
});
const node = (id: string, name: string, qualifiedName: string, kind: Node['kind'] = 'class', language: Node['language'] = 'kotlin'): Node => ({
id, kind, name, qualifiedName,
filePath: 'Models.kt', language,
startLine: 1, endLine: 1, startColumn: 0, endColumn: 0,
updatedAt: 0,
});
const importRef = (referenceName: string, language: Node['language'] = 'kotlin'): UnresolvedRef => ({
fromNodeId: 'caller',
referenceName,
referenceKind: 'imports',
line: 1, column: 0,
filePath: 'Caller.kt',
language,
});
it('resolves a Kotlin class import by FQN regardless of filename', () => {
const target = node('n1', 'Bar', 'com.example.foo::Bar');
const ctx = makeContext({ 'com.example.foo::Bar': [target] });
const result = resolveJvmImport(importRef('com.example.foo.Bar'), ctx);
expect(result?.targetNodeId).toBe('n1');
expect(result?.resolvedBy).toBe('import');
});
it('resolves a Kotlin top-level function import by FQN', () => {
const util = node('n2', 'util', 'com.example.foo::util', 'function');
const ctx = makeContext({ 'com.example.foo::util': [util] });
const result = resolveJvmImport(importRef('com.example.foo.util'), ctx);
expect(result?.targetNodeId).toBe('n2');
});
it('resolves a Java import by FQN', () => {
const target = node('n3', 'Bar', 'com.example.foo::Bar', 'class', 'java');
const ctx = makeContext({ 'com.example.foo::Bar': [target] });
const result = resolveJvmImport(importRef('com.example.foo.Bar', 'java'), ctx);
expect(result?.targetNodeId).toBe('n3');
});
it('resolves cross-language: Kotlin importing a Java class', () => {
// The Kotlin file declares `import com.example.JavaBar` — the target is
// a Java class node. JVM interop means the resolver doesn't care about
// the source language of the target, only that the FQN matches.
const target = node('n4', 'JavaBar', 'com.example::JavaBar', 'class', 'java');
const ctx = makeContext({ 'com.example::JavaBar': [target] });
const result = resolveJvmImport(importRef('com.example.JavaBar'), ctx);
expect(result?.targetNodeId).toBe('n4');
});
it('disambiguates a name collision across packages', () => {
// Two classes named `Bar` in different packages. Each import resolves
// to the one whose FQN matches — not to "whichever was found first".
const barA = node('n5a', 'Bar', 'com.example.alpha::Bar');
const barB = node('n5b', 'Bar', 'com.example.beta::Bar');
const ctx = makeContext({
'com.example.alpha::Bar': [barA],
'com.example.beta::Bar': [barB],
});
expect(resolveJvmImport(importRef('com.example.alpha.Bar'), ctx)?.targetNodeId).toBe('n5a');
expect(resolveJvmImport(importRef('com.example.beta.Bar'), ctx)?.targetNodeId).toBe('n5b');
});
it('returns null for wildcard imports', () => {
const ctx = makeContext({});
expect(resolveJvmImport(importRef('com.example.foo.*'), ctx)).toBeNull();
});
it('returns null for unqualified names', () => {
// A single-segment name has no package; nothing to look up by FQN.
const ctx = makeContext({ 'Bar': [node('n6', 'Bar', 'Bar')] });
expect(resolveJvmImport(importRef('Bar'), ctx)).toBeNull();
});
it('returns null for non-JVM languages', () => {
const target = node('n7', 'Bar', 'com.example::Bar');
const ctx = makeContext({ 'com.example::Bar': [target] });
expect(resolveJvmImport(importRef('com.example.Bar', 'typescript'), ctx)).toBeNull();
});
it('returns null for non-imports reference kinds', () => {
// The resolver intentionally only acts on `imports` refs; ordinary
// `calls`/`extends` refs fall through to the framework + name-matcher
// strategies.
const target = node('n8', 'Bar', 'com.example::Bar');
const ctx = makeContext({ 'com.example::Bar': [target] });
const ref: UnresolvedRef = {
fromNodeId: 'caller', referenceName: 'com.example.Bar',
referenceKind: 'calls', line: 1, column: 0,
filePath: 'Caller.kt', language: 'kotlin',
};
expect(resolveJvmImport(ref, ctx)).toBeNull();
});
it('returns null when the FQN is not in the index', () => {
const ctx = makeContext({});
expect(resolveJvmImport(importRef('com.example.Unknown'), ctx)).toBeNull();
});
});
describe('Framework Detection', () => {
it('should detect React framework', () => {
const context: ResolutionContext = {
@@ -848,7 +959,7 @@ public class Handler {
const use = cg
.getNodesByKind('method')
.find((n) => n.qualifiedName === 'Handler::use');
.find((n) => n.qualifiedName === 'com.example.web::Handler::use');
expect(use).toBeDefined();
const calls = cg.getOutgoingEdges(use!.id).filter((e) => e.kind === 'calls');
expect(calls.length).toBeGreaterThanOrEqual(1);