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:
@@ -814,6 +814,130 @@ public class Calculator {
|
||||
expect(methodNode).toBeDefined();
|
||||
expect(methodNode?.isStatic).toBe(true);
|
||||
});
|
||||
|
||||
it('wraps top-level declarations in a namespace from package_declaration', () => {
|
||||
const code = `
|
||||
package com.example.foo;
|
||||
|
||||
public class Bar {
|
||||
public String greet() { return "hi"; }
|
||||
}
|
||||
`;
|
||||
const result = extractFromSource('Bar.java', code);
|
||||
|
||||
const ns = result.nodes.find((n) => n.kind === 'namespace');
|
||||
expect(ns?.name).toBe('com.example.foo');
|
||||
|
||||
const cls = result.nodes.find((n) => n.kind === 'class' && n.name === 'Bar');
|
||||
expect(cls?.qualifiedName).toBe('com.example.foo::Bar');
|
||||
|
||||
const greet = result.nodes.find((n) => n.kind === 'method' && n.name === 'greet');
|
||||
expect(greet?.qualifiedName).toBe('com.example.foo::Bar::greet');
|
||||
});
|
||||
|
||||
it('does not wrap when no package is declared', () => {
|
||||
const code = `
|
||||
public class Bar {
|
||||
public String greet() { return "hi"; }
|
||||
}
|
||||
`;
|
||||
const result = extractFromSource('Bar.java', code);
|
||||
expect(result.nodes.find((n) => n.kind === 'namespace')).toBeUndefined();
|
||||
const cls = result.nodes.find((n) => n.kind === 'class' && n.name === 'Bar');
|
||||
expect(cls?.qualifiedName).toBe('Bar');
|
||||
});
|
||||
|
||||
it('extracts anonymous-class overrides from `new T() { ... }`', () => {
|
||||
// The pattern that breaks the trace through `strategy.foo()` in
|
||||
// libraries like guava's Splitter: the lambda-returned anonymous
|
||||
// class overrides abstract methods on the base, but without
|
||||
// extracting those overrides the interface→impl synthesizer has
|
||||
// nothing to bridge.
|
||||
const code = `
|
||||
package com.example;
|
||||
|
||||
abstract class Base {
|
||||
abstract int compute(int x);
|
||||
}
|
||||
|
||||
public class Factory {
|
||||
public Base make() {
|
||||
return new Base() {
|
||||
@Override
|
||||
int compute(int x) { return x + 1; }
|
||||
};
|
||||
}
|
||||
}
|
||||
`;
|
||||
const result = extractFromSource('Factory.java', code);
|
||||
|
||||
const anon = result.nodes.find((n) => n.kind === 'class' && /Base\$anon@/.test(n.name));
|
||||
expect(anon, 'anonymous Base subclass should be extracted as a class').toBeDefined();
|
||||
|
||||
const compute = result.nodes.find(
|
||||
(n) => n.kind === 'method' && n.name === 'compute' && n.qualifiedName.includes('$anon@')
|
||||
);
|
||||
expect(compute, 'override method should be a method on the anon class').toBeDefined();
|
||||
expect(compute!.qualifiedName).toContain('Factory::make::<Base$anon@');
|
||||
expect(compute!.qualifiedName.endsWith('::compute')).toBe(true);
|
||||
|
||||
// Anon class must extend Base so Phase 5.5 (interface-impl) can bridge.
|
||||
const extendsRef = result.unresolvedReferences.find(
|
||||
(r) => r.referenceKind === 'extends' && r.referenceName === 'Base' && r.fromNodeId === anon!.id
|
||||
);
|
||||
expect(extendsRef, 'anon class should carry an `extends Base` reference').toBeDefined();
|
||||
|
||||
// The enclosing `make` method still emits an instantiates edge to Base —
|
||||
// anon extraction must not swallow that signal.
|
||||
const instantiatesRef = result.unresolvedReferences.find(
|
||||
(r) => r.referenceKind === 'instantiates' && r.referenceName === 'Base'
|
||||
);
|
||||
expect(instantiatesRef, 'enclosing method should still instantiate Base').toBeDefined();
|
||||
});
|
||||
|
||||
it('extracts anonymous-class overrides inside a lambda body', () => {
|
||||
// The exact guava pattern: a lambda is passed to a constructor, and the
|
||||
// lambda body returns `new T() { @Override ... }`. The anon class must
|
||||
// still surface even though it sits inside a lambda_expression node.
|
||||
const code = `
|
||||
package com.example;
|
||||
|
||||
interface Strategy {
|
||||
java.util.Iterator<String> iterator(String s);
|
||||
}
|
||||
|
||||
abstract class BaseIter implements java.util.Iterator<String> {
|
||||
abstract int separatorStart(int start);
|
||||
}
|
||||
|
||||
public class Splitter {
|
||||
private final Strategy strategy;
|
||||
public Splitter(Strategy s) { this.strategy = s; }
|
||||
|
||||
public static Splitter on(char c) {
|
||||
return new Splitter((seq) ->
|
||||
new BaseIter() {
|
||||
@Override
|
||||
int separatorStart(int start) { return start + 1; }
|
||||
@Override public boolean hasNext() { return false; }
|
||||
@Override public String next() { return null; }
|
||||
});
|
||||
}
|
||||
}
|
||||
`;
|
||||
const result = extractFromSource('Splitter.java', code);
|
||||
|
||||
const anon = result.nodes.find((n) => n.kind === 'class' && /BaseIter\$anon@/.test(n.name));
|
||||
expect(anon, 'anon BaseIter inside the lambda body should be extracted').toBeDefined();
|
||||
|
||||
const sepStart = result.nodes.find(
|
||||
(n) =>
|
||||
n.kind === 'method' &&
|
||||
n.name === 'separatorStart' &&
|
||||
n.qualifiedName.includes('$anon@')
|
||||
);
|
||||
expect(sepStart, 'override inside the lambda-returned anon class should be a method node').toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('C# Extraction', () => {
|
||||
@@ -1173,6 +1297,54 @@ interface WebSocket {
|
||||
expect(methodNames).toContain('send');
|
||||
expect(methodNames).toContain('cancel');
|
||||
});
|
||||
|
||||
it('wraps top-level declarations in a namespace from package_header', () => {
|
||||
const code = `
|
||||
package com.example.foo
|
||||
|
||||
class Bar {
|
||||
fun greet(): String = "hi"
|
||||
}
|
||||
|
||||
fun util(): Int = 42
|
||||
`;
|
||||
const result = extractFromSource('Bar.kt', code);
|
||||
|
||||
const ns = result.nodes.find((n) => n.kind === 'namespace');
|
||||
expect(ns?.name).toBe('com.example.foo');
|
||||
|
||||
const cls = result.nodes.find((n) => n.kind === 'class' && n.name === 'Bar');
|
||||
expect(cls?.qualifiedName).toBe('com.example.foo::Bar');
|
||||
|
||||
const greet = result.nodes.find((n) => n.kind === 'method' && n.name === 'greet');
|
||||
expect(greet?.qualifiedName).toBe('com.example.foo::Bar::greet');
|
||||
|
||||
const util = result.nodes.find((n) => n.kind === 'function' && n.name === 'util');
|
||||
expect(util?.qualifiedName).toBe('com.example.foo::util');
|
||||
});
|
||||
|
||||
it('handles a single-segment package', () => {
|
||||
const code = `
|
||||
package foo
|
||||
|
||||
class Bar
|
||||
`;
|
||||
const result = extractFromSource('Bar.kt', code);
|
||||
const cls = result.nodes.find((n) => n.kind === 'class' && n.name === 'Bar');
|
||||
expect(cls?.qualifiedName).toBe('foo::Bar');
|
||||
});
|
||||
|
||||
it('does not wrap when no package is declared', () => {
|
||||
const code = `
|
||||
class Bar {
|
||||
fun greet() = "hi"
|
||||
}
|
||||
`;
|
||||
const result = extractFromSource('Bar.kt', code);
|
||||
expect(result.nodes.find((n) => n.kind === 'namespace')).toBeUndefined();
|
||||
const cls = result.nodes.find((n) => n.kind === 'class' && n.name === 'Bar');
|
||||
expect(cls?.qualifiedName).toBe('Bar');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Dart Extraction', () => {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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