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
+199
View File
@@ -606,3 +606,202 @@ describe('Java end-to-end — field-injected bean trace (issue #389)', () => {
cg.close();
});
});
describe('JVM FQN imports — end-to-end', () => {
let tmpDir: string | undefined;
afterEach(() => {
if (tmpDir) fs.rmSync(tmpDir, { recursive: true, force: true });
tmpDir = undefined;
});
it('resolves a Kotlin import when the file name differs from the class name', async () => {
// Bar lives in Models.kt — the filesystem-based Java-style path lookup
// (com/example/Bar.kt) misses this; only FQN-via-qualifiedName finds it.
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-jvm-imp-'));
fs.writeFileSync(
path.join(tmpDir, 'Models.kt'),
'package com.example\n\nclass Bar {\n fun greet(): String = "hi"\n}\n'
);
fs.writeFileSync(
path.join(tmpDir, 'Caller.kt'),
'package com.example.app\n\nimport com.example.Bar\n\nclass App {\n fun run() { Bar().greet() }\n}\n'
);
const cg = CodeGraph.initSync(tmpDir);
await cg.indexAll();
const bar = cg.getNodesByKind('class').find((n) => n.qualifiedName === 'com.example::Bar');
expect(bar, 'Bar should be extracted with package-qualified name').toBeDefined();
const importNode = cg.getNodesByKind('import').find((n) => n.name === 'com.example.Bar');
expect(importNode, 'import statement node should exist').toBeDefined();
// The imports edge may originate from the import node OR from a parent
// scope (file / namespace) — accept either, but require that an
// imports-kind edge to Bar exists.
const reachesBar = cg
.getIncomingEdges(bar!.id)
.find((e) => e.kind === 'imports');
expect(reachesBar, 'an imports edge should resolve to Bar via FQN').toBeDefined();
cg.close();
});
it('resolves a Kotlin top-level function import', async () => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-jvm-imp-'));
fs.writeFileSync(
path.join(tmpDir, 'Utils.kt'),
'package com.example\n\nfun util(): Int = 42\n'
);
fs.writeFileSync(
path.join(tmpDir, 'Caller.kt'),
'package com.example.app\n\nimport com.example.util\n\nfun main() { util() }\n'
);
const cg = CodeGraph.initSync(tmpDir);
await cg.indexAll();
const util = cg.getNodesByKind('function').find((n) => n.qualifiedName === 'com.example::util');
expect(util, 'top-level util() should be extracted under com.example').toBeDefined();
const edge = cg.getIncomingEdges(util!.id).find((e) => e.kind === 'imports');
expect(edge, 'imports edge should reach the top-level function by FQN').toBeDefined();
});
it('resolves cross-language: Kotlin importing a Java class', async () => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-jvm-imp-'));
fs.writeFileSync(
path.join(tmpDir, 'JavaBar.java'),
'package com.example;\n\npublic class JavaBar {\n public String greet() { return "hi"; }\n}\n'
);
fs.writeFileSync(
path.join(tmpDir, 'Caller.kt'),
'package com.example.app\n\nimport com.example.JavaBar\n\nfun main() { JavaBar().greet() }\n'
);
const cg = CodeGraph.initSync(tmpDir);
await cg.indexAll();
const javaBar = cg.getNodesByKind('class').find((n) => n.qualifiedName === 'com.example::JavaBar');
expect(javaBar, 'JavaBar should be extracted under com.example regardless of language').toBeDefined();
const edge = cg.getIncomingEdges(javaBar!.id).find((e) => e.kind === 'imports');
expect(edge, 'Kotlin caller should resolve its import to the Java class').toBeDefined();
});
it('disambiguates a class-name collision across packages', async () => {
// Two `Bar` classes in different packages — each importer should reach
// ITS Bar, not the other one. This is the central failure mode that
// name-matcher alone cannot disambiguate.
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-jvm-imp-'));
fs.writeFileSync(
path.join(tmpDir, 'AlphaBar.kt'),
'package com.example.alpha\n\nclass Bar { fun who() = "alpha" }\n'
);
fs.writeFileSync(
path.join(tmpDir, 'BetaBar.kt'),
'package com.example.beta\n\nclass Bar { fun who() = "beta" }\n'
);
fs.writeFileSync(
path.join(tmpDir, 'CallerA.kt'),
'package app\n\nimport com.example.alpha.Bar\n\nfun a() { Bar().who() }\n'
);
fs.writeFileSync(
path.join(tmpDir, 'CallerB.kt'),
'package app\n\nimport com.example.beta.Bar\n\nfun b() { Bar().who() }\n'
);
const cg = CodeGraph.initSync(tmpDir);
await cg.indexAll();
const alphaBar = cg.getNodesByKind('class').find((n) => n.qualifiedName === 'com.example.alpha::Bar');
const betaBar = cg.getNodesByKind('class').find((n) => n.qualifiedName === 'com.example.beta::Bar');
expect(alphaBar).toBeDefined();
expect(betaBar).toBeDefined();
expect(alphaBar!.id).not.toBe(betaBar!.id);
// Each Bar receives exactly one imports edge — from its own caller.
const alphaIncoming = cg.getIncomingEdges(alphaBar!.id).filter((e) => e.kind === 'imports');
const betaIncoming = cg.getIncomingEdges(betaBar!.id).filter((e) => e.kind === 'imports');
expect(alphaIncoming.length).toBeGreaterThan(0);
expect(betaIncoming.length).toBeGreaterThan(0);
// Sanity: the edges don't cross — alpha's incoming sources don't include
// beta's filePath and vice versa.
const sourceFiles = (edges: typeof alphaIncoming) =>
edges.map((e) => cg.getNode(e.source)?.filePath).filter(Boolean);
expect(sourceFiles(alphaIncoming).some((p) => p?.includes('CallerA.kt'))).toBe(true);
expect(sourceFiles(betaIncoming).some((p) => p?.includes('CallerB.kt'))).toBe(true);
});
});
describe('Java anonymous-class override synthesis — end-to-end', () => {
let tmpDir: string | undefined;
afterEach(() => {
if (tmpDir) fs.rmSync(tmpDir, { recursive: true, force: true });
tmpDir = undefined;
});
it('bridges an abstract base method to overrides inside `new Base() { ... }`', async () => {
// Mirrors guava Splitter: a factory returns `new BaseIter() {
// @Override int separatorStart(...) { ... } }`. Without anon-class
// extraction the override is invisible — Phase 5.5 interface-impl
// has no class to bridge — and an agent investigating `BaseIter.separatorStart`
// can't see its real implementation without reading the file.
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-anon-java-'));
fs.writeFileSync(
path.join(tmpDir, 'Splitter.java'),
'package com.example;\n' +
'\n' +
'abstract class BaseIter {\n' +
' abstract int separatorStart(int start);\n' +
'}\n' +
'\n' +
'public class Splitter {\n' +
' public BaseIter make() {\n' +
' return new BaseIter() {\n' +
' @Override\n' +
' int separatorStart(int start) { return start + 1; }\n' +
' };\n' +
' }\n' +
'}\n'
);
const cg = CodeGraph.initSync(tmpDir);
await cg.indexAll();
// The anon class is extracted and contains the override.
const anonClass = cg
.getNodesByKind('class')
.find((n) => /BaseIter\$anon@/.test(n.name));
expect(anonClass, 'anonymous BaseIter subclass should be a class node').toBeDefined();
const baseAbstract = cg
.getNodesByKind('method')
.find((n) => n.qualifiedName === 'com.example::BaseIter::separatorStart');
const anonOverride = cg
.getNodesByKind('method')
.find(
(n) =>
n.name === 'separatorStart' &&
n.qualifiedName.includes('$anon@') &&
n.qualifiedName.startsWith('com.example::Splitter::make::')
);
expect(baseAbstract, 'base abstract method should be in the graph').toBeDefined();
expect(anonOverride, 'anon-class override should be in the graph').toBeDefined();
// Phase 5.5 interface-impl: the abstract method has a synthesized
// `calls` edge to the anon override. Without this hop the agent
// would have to Read the file to discover the implementation.
const synthEdge = cg
.getOutgoingEdges(baseAbstract!.id)
.find((e) => e.target === anonOverride!.id && e.kind === 'calls');
expect(synthEdge, 'BaseIter.separatorStart should bridge to anon.separatorStart').toBeDefined();
expect(synthEdge!.provenance).toBe('heuristic');
expect((synthEdge!.metadata as { synthesizedBy?: string } | undefined)?.synthesizedBy).toBe(
'interface-impl'
);
cg.close();
});
});