fix(kotlin): read Kotlin imports; an outside import owns its name (#2196)

The JVM import reader required a `;`, so a Kotlin file had no import
mappings at all. It now reads `import a.b.C` and `import a.b.C as D`.

- The outside-import rule from #2195 applies to Kotlin: `import
  java.net.Proxy`, `import org.mockito.Mockito.mock`, `import okio.buffer`
  own their names. A declaration the file itself makes comes first, in its
  own namespace (a callable for a call, a type otherwise) - Exposed's
  `toLocalDateTime(value)` beside `import kotlinx.datetime.toLocalDateTime`
  is the column type's member; gson's `new URI(...)` stays `java.net.URI`
  beside `TypeAdapters`' field `URI`.
- The mappings pick among same-named project classes: okhttp's
  `MockResponse.Builder()` (642) and `Request.Builder()` reach their classes,
  Exposed's `TestDB`/`SchemaUtils` the module the file imports, moshi's
  `Moshi.Builder()` (102).
- A renaming import binds its alias by FQN: kotlinx.coroutines' `import
  ...internal.unsafeFlow as flow` makes its 28 `flow { }` builders
  `unsafeFlow`, not the public `flow`.

okhttp -486 +1303, Exposed -1685 +1351 (duplicate-module reroutes; losses
are kotlin.test/kotlinx/okio/clikt/Gradle functions), moshi -21 +121, okio
-128 +51, koin -22 +11, coroutines -68 +47, nowinandroid -44 (Gradle DSL,
Compose). Java repos (gson, commons-lang, retrofit, jsoup, halo)
unchanged.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Colby Mchenry
2026-09-30 19:45:40 +00:00
committed by GitHub
parent 81ce7e5695
commit da1312e83b
4 changed files with 71 additions and 7 deletions
+1
View File
@@ -14,6 +14,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
### Fixes
- Kotlin imports now decide what a name means, as Java imports already do. `import java.net.Proxy` or `import org.mockito.Mockito.mock` no longer links to a same-named project symbol, and a member the file declares itself still comes first. An `import x.Foo` picks the right one of several same-named classes: on okhttp, `MockResponse.Builder()` and `Request.Builder()` now reach their classes. A renaming import like `import …internal.unsafeFlow as flow` means the function it names.
- In Java, a class you import from outside your project now owns its name. After `import java.lang.reflect.Field;` the file's `Field` no longer links to a project class that happens to share the name. In gson that was a test's nested `Field` class; in jsoup's W3C converter it was jsoup's own `Document` and `Element`. A static import like `import static org.mockito.Mockito.verify;` is covered the same way.
- Java enum constants and Kotlin enum entries with a body of their own now have their methods in the graph. Examples are `PLUS { int apply(…) { … } }` and `NewBuffer { override fun pipe() … }`. Before, those methods were missing and every call inside them was dropped. jsoup's HTML tokenizer and tree builder are written this way, so over 2,000 of their calls were invisible. Re-index to pick this up.
- Scala calls no longer land on another test's local value. A `val f` written inside one `test("…") { }` block was being picked for a lambda parameter `f` in another suite, and even for `f(…)` in cats' core library. A package import like `import cats.syntax.all._` no longer puts every class's members in scope. Members that really are in scope now resolve: those of an imported package object's parent types (`import sttp.client4._` gives `SttpApi`'s `multipart`), and of the file's own package object.
+25 -1
View File
@@ -5,7 +5,8 @@
* jsoup's W3C converter (`import org.w3c.dom.Document;`) to jsoup's own
* `Document`. A static import (`import static org.junit.Assert.assertEquals;`)
* likewise owns its name. Imports of the project's own classes — nested ones
* included — resolve as before.
* included — resolve as before. Kotlin imports (no `;`, optional `as`) are read
* the same way; a member the file itself declares still comes first.
*/
import { describe, it, expect, afterAll, beforeAll } from 'vitest';
import * as fs from 'fs';
@@ -52,6 +53,22 @@ public class Reflect {
return Inner.create();
}
}
`,
'src/main/kotlin/app/Dates.kt': `package app
import java.lang.reflect.Field
import kotlinx.datetime.toLocalDateTime
import app.model.Outer.Inner as Made
class Dates {
private fun toLocalDateTime(value: Long): Long = value
fun parse(value: Long, field: Field): Long {
field.getName()
Made.create()
return toLocalDateTime(value)
}
}
`,
'src/test/java/app/ReflectTest.java': `package app;
@@ -93,6 +110,13 @@ describe('Java single-type and static imports from outside the project', () => {
expect(edges).toContain('calls app.model::Outer::Inner::create');
});
it('Kotlin: an outside import owns its name, but a member the file declares comes first; `as` aliases bind', () => {
const edges = edgesFrom('src/main/kotlin/app/Dates.kt');
expect(edges).not.toContain('references app.functional::TypesTest::Field');
expect(edges).toContain('calls app::Dates::toLocalDateTime');
expect(edges).toContain('calls app.model::Outer::Inner::create');
});
it('a static import’s method is not a project method of that name', () => {
expect(edgesFrom('src/test/java/app/ReflectTest.java')).not.toContain('calls app.functional::TypesTest::assertEquals');
});
+31 -4
View File
@@ -1177,8 +1177,8 @@ function extractJavaImports(content: string): ImportMapping[] {
const stripped = content
.replace(/\/\*[\s\S]*?\*\//g, '')
.replace(/\/\/[^\n]*/g, '');
// `import [static] <fqn>[.*];`
const re = /^\s*import\s+(static\s+)?([\w.]+(?:\.\*)?)\s*;/gm;
// `import [static] <fqn>[.*];` — and Kotlin's `import <fqn> [as Alias]`, with no `;`.
const re = /^\s*import\s+(static\s+)?([\w.]+(?:\.\*)?)(?:\s+as\s+([\w]+))?\s*(?:;|$)/gm;
let match: RegExpExecArray | null;
while ((match = re.exec(stripped)) !== null) {
const fqn = match[2]!;
@@ -1187,11 +1187,11 @@ function extractJavaImports(content: string): ImportMapping[] {
// through the wildcard. (Future enhancement: enumerate package files.)
if (fqn.endsWith('.*')) continue;
const parts = fqn.split('.');
const localName = parts[parts.length - 1];
const localName = match[3] ?? parts[parts.length - 1];
if (!localName) continue;
mappings.push({
localName,
exportedName: localName,
exportedName: parts[parts.length - 1]!,
source: fqn,
isDefault: false,
isNamespace: false,
@@ -1436,6 +1436,26 @@ export function resolveJvmImport(
};
}
/** `Alias` / `Alias.member` through a renaming Kotlin import, by the imported FQN. */
function resolveJvmAlias(ref: UnresolvedRef, imports: ImportMapping[], context: ResolutionContext): ResolvedRef | null {
const dot = ref.referenceName.indexOf('.');
const root = dot < 0 ? ref.referenceName : ref.referenceName.slice(0, dot);
const imp = imports.find((m) => m.localName === root && m.localName !== m.exportedName);
if (!imp) return null;
const parts = imp.source.split('.');
// The package is some prefix of the FQN; the rest is the type path (`Outer::Inner`).
for (let i = parts.length - 1; i > 0; i--) {
const target = context.getNodesByQualifiedName(`${parts.slice(0, i).join('.')}::${parts.slice(i).join('::')}`)[0];
if (!target) continue;
const member = dot < 0 ? null : ref.referenceName.slice(dot + 1);
if (member === null) return { original: ref, targetNodeId: target.id, confidence: 0.9, resolvedBy: 'import' };
if (member.includes('.')) return null;
const found = context.getNodesByQualifiedName(`${target.qualifiedName}::${member}`)[0];
return found ? { original: ref, targetNodeId: found.id, confidence: 0.9, resolvedBy: 'import' } : null;
}
return null;
}
/**
* Pick the same-FQN candidate closest to `fromPath` by shared directory
* prefix, preferring an `expect` declaration on a tie. Used to keep a Kotlin
@@ -1706,6 +1726,13 @@ export function resolveViaImport(
if (moduleFile) return moduleFile;
}
// Kotlin's `import app.model.Outer.Inner as Made`: `Made.create()` is the
// aliased class's member, found by its FQN — nothing else binds the alias.
if (ref.language === 'kotlin' && ref.referenceKind !== 'imports') {
const aliased = resolveJvmAlias(ref, imports, context);
if (aliased) return aliased;
}
// Check if the reference name matches any import
for (const imp of imports) {
if (imp.localName === ref.referenceName || ref.referenceName.startsWith(imp.localName + '.')) {
+14 -2
View File
@@ -929,6 +929,9 @@ function isLuaLocal(candidate: Node, context: ResolutionContext): boolean {
return local;
}
const JVM_CALLABLE_KINDS: ReadonlySet<string> = new Set(['method', 'function']);
const JVM_TYPE_KINDS: ReadonlySet<string> = new Set(['class', 'interface', 'enum', 'struct', 'trait', 'type_alias', 'annotation']);
/** Per context: every package the project's JVM sources declare. */
const JVM_PACKAGES = new WeakMap<ResolutionContext, Set<string>>();
@@ -939,8 +942,16 @@ const JVM_PACKAGES = new WeakMap<ResolutionContext, Set<string>>();
* type (`import com.acme.Outer.Inner;`) is under a project package, so it is not.
*/
function isJavaOutsideImport(name: string, ref: UnresolvedRef, context: ResolutionContext): boolean {
const binding = context.getImportMappings(ref.filePath, 'java').find((m) => m.localName === name);
const binding = context.getImportMappings(ref.filePath, ref.language).find((m) => m.localName === name);
if (!binding) return false;
// A member the file declares itself is in scope before any import —
// Exposed's `toLocalDateTime(value)` inside the column type that defines it —
// in the same namespace only: gson's `new URI(…)` is `java.net.URI` beside
// `TypeAdapters`' field `URI`, as a type always is beside a value.
const call = ref.referenceKind === 'calls' && name === ref.referenceName;
const kinds = call ? JVM_CALLABLE_KINDS : JVM_TYPE_KINDS;
if ((context.getNodesInFileNamed?.(ref.filePath, name) ?? context.getNodesInFile(ref.filePath).filter((n) => n.name === name))
.some((n) => kinds.has(n.kind))) return false;
let packages = JVM_PACKAGES.get(context);
if (!packages) {
packages = new Set<string>();
@@ -7702,7 +7713,8 @@ function matchReferenceInner(
// `import java.lang.reflect.Field;` — the file's `Field` is the JDK's, never
// a project class of that name (gson's production code bound it to a test's
// nested `ParameterizedTypesTest.Field`).
if (ref.language === 'java' && ref.referenceKind !== 'imports' && isJavaOutsideImport(ref.referenceName.split('.')[0]!, ref, context)) {
if ((ref.language === 'java' || ref.language === 'kotlin') && ref.referenceKind !== 'imports' &&
isJavaOutsideImport(ref.referenceName.split('.')[0]!, ref, context)) {
return null;
}