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:
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user