瀏覽代碼

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).
Artem Bambalov 3 周之前
父節點
當前提交
34240eb297

+ 29 - 0
CHANGELOG.md

@@ -9,6 +9,35 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
 
 ## [Unreleased]
 
+### Added
+- **Java / Kotlin imports now resolve by fully-qualified name.** Extraction
+  wraps every top-level declaration of a `.kt` / `.java` file in a `namespace`
+  node carrying the file's `package` (so a class `Bar` in
+  `package com.example.foo` is indexed with qualifiedName
+  `com.example.foo::Bar`), and `import com.example.foo.Bar` looks the target
+  up through that index — regardless of whether the class lives in `Bar.kt`,
+  `Models.kt`, or a top-level function. Disambiguates same-name classes
+  across packages (the central failure mode of the previous name-matcher
+  fallback in multi-module Spring / Android codebases), works across the
+  Java↔Kotlin interop boundary, and lays groundwork for binding-precise
+  Dagger2 / Hilt resolution. Wildcard imports (`com.example.*`) still go
+  through name-matcher.
+- **Java / C# anonymous classes (`new T() { ... }`) are now extracted as
+  first-class class nodes with their overrides.** Previously, an anonymous
+  subclass returned from a factory or lambda — `return new BaseIter() {
+  @Override int separatorStart(int s) { ... } };` — produced only an
+  `instantiates` edge: the override methods were invisible to the graph and
+  Phase 5.5 interface-impl synthesis had no class to bridge. The anon class
+  now lands as `<TypeName$anon@line>` with an `extends` reference to the
+  named base/interface, scoped under the enclosing method, and its
+  `method_declaration` members become normal method nodes. The interface→impl
+  synthesizer then bridges the base's abstract methods to the anonymous
+  overrides automatically. Concrete effect on `google/guava` (3,227 .java
+  files): 3,608 anonymous classes extracted, +2,534 interface-impl edges
+  reach overrides hidden in `new T() { ... }` blocks (including lambda
+  bodies). An agent investigating `Splitter.SplittingIterator.separatorStart`
+  now sees the four anonymous overrides in its trail without a Read.
+
 ### Fixed
 - **`codegraph index` / `init -i` summary now reports the true edge count.**
   The per-file counter in the orchestrator only saw extraction-phase edges,

+ 172 - 0
__tests__/extraction.test.ts

@@ -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', () => {

+ 199 - 0
__tests__/frameworks-integration.test.ts

@@ -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();
+  });
+});

+ 113 - 2
__tests__/resolution.test.ts

@@ -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);

+ 8 - 0
src/extraction/languages/java.ts

@@ -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;
+  },
 };

+ 6 - 0
src/extraction/languages/kotlin.ts

@@ -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;
+  },
 };

+ 12 - 0
src/extraction/tree-sitter-types.ts

@@ -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;
 }

+ 128 - 0
src/extraction/tree-sitter.ts

@@ -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) {

+ 35 - 0
src/resolution/import-resolver.ts

@@ -948,6 +948,41 @@ export function extractReExports(content: string, language: Language): ReExport[
 /**
  * Resolve a reference using import mappings
  */
+/**
+ * JVM (Java / Kotlin) imports use fully-qualified names (`import
+ * com.example.foo.Bar`) decoupled from filenames, so the JS/Python
+ * style filesystem path lookup misses them whenever the file isn't
+ * named after its primary symbol (Kotlin `Utils.kt` exporting `Bar`,
+ * top-level fns, extension fns). Resolve them through the
+ * `qualifiedName` index instead — populated by the package_header /
+ * package_declaration namespace wrappers in the extractor.
+ */
+export function resolveJvmImport(
+  ref: UnresolvedRef,
+  context: ResolutionContext
+): ResolvedRef | null {
+  if (ref.referenceKind !== 'imports') return null;
+  if (ref.language !== 'java' && ref.language !== 'kotlin') return null;
+
+  const fqn = ref.referenceName;
+  const lastDot = fqn.lastIndexOf('.');
+  if (lastDot <= 0) return null;
+  const pkg = fqn.substring(0, lastDot);
+  const sym = fqn.substring(lastDot + 1);
+  // Wildcard imports (`com.example.*`) deliberately punt to name-matcher.
+  if (sym === '*') return null;
+
+  const candidates = context.getNodesByQualifiedName(`${pkg}::${sym}`);
+  if (candidates.length === 0) return null;
+
+  return {
+    original: ref,
+    targetNodeId: candidates[0]!.id,
+    confidence: 0.95,
+    resolvedBy: 'import',
+  };
+}
+
 export function resolveViaImport(
   ref: UnresolvedRef,
   context: ResolutionContext

+ 15 - 1
src/resolution/index.ts

@@ -17,7 +17,7 @@ import {
   ImportMapping,
 } from './types';
 import { matchReference } from './name-matcher';
-import { resolveViaImport, extractImportMappings, extractReExports, loadCppIncludeDirs } from './import-resolver';
+import { resolveViaImport, resolveJvmImport, extractImportMappings, extractReExports, loadCppIncludeDirs } from './import-resolver';
 import { detectFrameworks } from './frameworks';
 import { synthesizeCallbackEdges } from './callback-synthesizer';
 import { loadProjectAliases, type AliasMap } from './path-aliases';
@@ -528,6 +528,14 @@ export class ReferenceResolver {
       // Also check capitalized receiver (instance-method resolution)
       const capitalized = receiver.charAt(0).toUpperCase() + receiver.slice(1);
       if (this.knownNames.has(capitalized)) return true;
+      // JVM FQN: `com.example.foo.Bar` — the only useful segment is the
+      // last one (`Bar`); the earlier check finds `example.foo.Bar` which
+      // never matches a node name.
+      const lastDot = name.lastIndexOf('.');
+      if (lastDot > dotIdx) {
+        const tail = name.substring(lastDot + 1);
+        if (tail && this.knownNames.has(tail)) return true;
+      }
     }
     const colonIdx = name.indexOf('::');
     if (colonIdx > 0) {
@@ -588,6 +596,12 @@ export class ReferenceResolver {
       return null;
     }
 
+    // JVM FQN imports skip framework/name-matcher: `import com.example.Bar`
+    // resolves directly through the qualifiedName index, which is unambiguous
+    // even when several `Bar` classes exist in different packages.
+    const jvmImport = resolveJvmImport(ref, this.context);
+    if (jvmImport) return jvmImport;
+
     const candidates: ResolvedRef[] = [];
 
     // Strategy 1: Try framework-specific resolution