From 84f8154ed4f5464dba45bdaa46f48ea87c320c1a Mon Sep 17 00:00:00 2001 From: mixxer Date: Fri, 25 Sep 2026 01:32:33 +0900 Subject: [PATCH 1/4] fix(kotlin): resolve imported type calls by qualified name --- __tests__/kotlin-imported-type-call.test.ts | 65 +++++++++++++++++++++ src/resolution/name-matcher.ts | 47 +++++++++++++++ 2 files changed, 112 insertions(+) create mode 100644 __tests__/kotlin-imported-type-call.test.ts diff --git a/__tests__/kotlin-imported-type-call.test.ts b/__tests__/kotlin-imported-type-call.test.ts new file mode 100644 index 0000000000..524bd0d214 --- /dev/null +++ b/__tests__/kotlin-imported-type-call.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it } from 'vitest'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import CodeGraph from '../src/index'; + +describe('Kotlin calls through explicit imports', () => { + it('uses the imported type, while retaining values, extensions and nested constructors', async () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-kotlin-import-')); + let cg: CodeGraph | undefined; + try { + for (const [file, source] of Object.entries({ + 'p/Outer.java': `package p; +public class Outer { public static class Inner { public Inner() {} } }`, + 'p/Factories.kt': `package p +object Factory { fun create(): Int = 2 } +object Modes +object Dimensions { val Height = 2 } +fun Int.toPx(): Int = this +class UnrelatedFactory { fun create(): Int = 1 }`, + 'q/Calls.kt': `package q +import android.app.TaskStackBuilder +import androidx.savedstate.SavedStateRegistryController +import p.Factory as LocalFactory +import p.Factory +import p.Modes +import p.Dimensions.Height +import p.toPx +import p.Outer +fun Modes.ext() {} +class Calls { + fun android() { TaskStackBuilder.create(null) } + fun androidx() { SavedStateRegistryController.create(this) } + fun aliased() { LocalFactory.create() } + fun direct() { Factory.create() } + fun extension() { Modes.ext() } + fun value() { Height.toPx() } + fun nested() { Outer.Inner() } +}`, + })) { + const target = path.join(dir, file); + fs.mkdirSync(path.dirname(target), { recursive: true }); + fs.writeFileSync(target, source); + } + cg = CodeGraph.initSync(dir, { config: { include: ['**/*.kt', '**/*.java'], exclude: [] } }); + await cg.indexAll(); + const callees = (name: string) => { + const caller = cg!.searchNodes(name).map(r => r.node) + .find(n => n.qualifiedName === `q::Calls::${name}`)!; + return cg!.getCallees(caller.id).filter(c => c.edge.kind === 'calls') + .map(c => c.node.qualifiedName); + }; + expect(callees('android')).toEqual([]); + expect(callees('androidx')).toEqual([]); + expect(callees('aliased')).toEqual(['p::Factory::create']); + expect(callees('direct')).toEqual(['p::Factory::create']); + expect(callees('extension')).toEqual(['Modes::ext']); + expect(callees('value')).toEqual(['Int::toPx']); + expect(callees('nested')).toEqual(['p::Outer::Inner::Inner']); + } finally { + cg?.destroy(); + fs.rmSync(dir, { recursive: true, force: true }); + } + }); +}); diff --git a/src/resolution/name-matcher.ts b/src/resolution/name-matcher.ts index 9debc1ec29..72e3813c96 100644 --- a/src/resolution/name-matcher.ts +++ b/src/resolution/name-matcher.ts @@ -18306,6 +18306,21 @@ export function tsImportedTypeFile(written: string, ref: UnresolvedRef, context: return file ?? undefined; } +function kotlinImportedType(name: string, ref: UnresolvedRef, context: ResolutionContext): string | undefined { + // ponytail: scan explicit imports here; cache per file only if indexing profiles justify it. + const source = context.readFile(ref.filePath); + if (!source) return undefined; + for (const match of source.matchAll(/^[ \t]*import[ \t]+([\w.]+)(?:[ \t]+as[ \t]+(\w+))?[ \t]*;?[ \t]*$/gm)) { + const fqn = match[1]!; + if ((match[2] || fqn.split('.').pop()) !== name) continue; + const importedNode = context.getNodesByName(fqn.split('.').pop()!).find(n => + n.qualifiedName.replace(/::/g, '.') === fqn); + if (importedNode && !['class', 'interface', 'enum', 'type_alias'].includes(importedNode.kind)) return undefined; + return fqn; + } + return undefined; +} + /** * Java/Kotlin: infer a receiver's declared type by walking field declarations * in the class enclosing the call site. The field's `signature` is already in @@ -21034,6 +21049,34 @@ export function matchMethodCall( if (!literal) return null; } + // An explicit Kotlin type import fixes the receiver's identity. Resolve + // only that type (including Java interop), never a same-named local method. + const importedType = ref.language === 'kotlin' && dotMatch && /^[A-Z]\w*$/.test(objectOrClass!) + ? kotlinImportedType(objectOrClass!, ref, context) : undefined; + if (importedType) { + const localExtension = context.getNodesInFile(ref.filePath).find(n => + n.kind === 'method' && n.qualifiedName === `${objectOrClass}::${methodName}`); + if (localExtension) return { original: ref, targetNodeId: localExtension.id, + confidence: 0.9, resolvedBy: 'qualified-name' }; + const simpleName = importedType.split('.').pop()!; + const owners = context.getNodesByName(simpleName).filter(n => + (n.kind === 'class' || n.kind === 'interface' || n.kind === 'enum') && + (n.language === 'kotlin' || n.language === 'java') && + n.qualifiedName.replace(/::/g, '.') === importedType); + const methods = owners.flatMap(owner => context.getNodesInFile(owner.filePath).filter(n => + n.kind === 'method' && (n.language === 'kotlin' || n.language === 'java') && + (n.qualifiedName === `${owner.qualifiedName}::${methodName}` || + n.qualifiedName === `${owner.qualifiedName}::Companion::${methodName}`))); + if (owners.length === 1 && methods.length === 0) { + const constructor = context.getNodesInFile(owners[0]!.filePath).find(n => + n.kind === 'method' && + n.qualifiedName === `${owners[0]!.qualifiedName}::${methodName}::${methodName}`); + if (constructor) return { original: ref, targetNodeId: constructor.id, + confidence: 0.9, resolvedBy: 'qualified-name' }; + } + return owners.length === 1 && methods.length > 0 ? { original: ref, targetNodeId: methods[0]!.id, + confidence: 0.9, resolvedBy: 'qualified-name' } : null; + } // A simple `receiver.method` / `receiver:method` / `receiver$method` shape whose // receiver type we can try to infer from its local declaration. // A python attribute receiver (`self.conn`, `cfg.client`) is not one: the @@ -29099,6 +29142,10 @@ function matchReferenceInner( // 2. Method call pattern result = nmTimed('methodCall', ref, () => matchMethodCall(ref, context)); if (result) return result; + if (ref.language === 'kotlin' && ref.referenceKind === 'calls') { + const importedReceiver = ref.referenceName.match(/^([A-Z]\w*)\.\w+$/)?.[1]; + if (importedReceiver && kotlinImportedType(importedReceiver, ref, context)) return null; + } // 3. Exact name match result = nmTimed('exactName', ref, () => matchByExactName(ref, context)); From 06aaa8cf54040ce1df5a4d51772d87fd0c48b4d9 Mon Sep 17 00:00:00 2001 From: mixxer Date: Fri, 25 Sep 2026 12:45:33 +0900 Subject: [PATCH 2/4] fix(kotlin): preserve visible extensions for imported receivers --- CHANGELOG.md | 1 + __tests__/kotlin-imported-type-call.test.ts | 27 +++++++++++++++- src/resolution/name-matcher.ts | 36 ++++++++++++++++++--- 3 files changed, 59 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f58218b4c7..8d110f504f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -647,6 +647,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - Zustand actions keep their callers when read through typed stores, destructured from store state, or selected by a hook. - Direct React Native bridge calls retain their native implementations and cross-platform relationships. - Dart extension-type getters remain searchable when using the WebAssembly parser. +- Kotlin calls through imported types now avoid unrelated same-named methods while retaining inherited members and project extension functions. (#1948) - Calling a built-in method on an awaited value no longer records a call into an unrelated class that happens to declare a method of the same name, and a variable bound to an awaited call now resolves methods on the type that call returns. Thanks @maxmilian. (#1840) - Spring mappings now include every declared path combination and resolve constants declared in the same file, while unresolved paths no longer appear as false root routes. (#1461) diff --git a/__tests__/kotlin-imported-type-call.test.ts b/__tests__/kotlin-imported-type-call.test.ts index 524bd0d214..793493e6f2 100644 --- a/__tests__/kotlin-imported-type-call.test.ts +++ b/__tests__/kotlin-imported-type-call.test.ts @@ -14,16 +14,31 @@ describe('Kotlin calls through explicit imports', () => { public class Outer { public static class Inner { public Inner() {} } }`, 'p/Factories.kt': `package p object Factory { fun create(): Int = 2 } +class ExtFactory { companion object } +open class Base { fun hello() {} } +object Obj : Base() object Modes object Dimensions { val Height = 2 } fun Int.toPx(): Int = this class UnrelatedFactory { fun create(): Int = 1 }`, + 'p/Ext.kt': `package p +import androidx.compose.ui.Modifier +fun ExtFactory.Companion.extMake() {} +fun Modes.extOther() {} +fun Modes.hidden() {} +fun Modifier.pad() {}`, 'q/Calls.kt': `package q -import android.app.TaskStackBuilder +import android.app.TaskStackBuilder // external type import androidx.savedstate.SavedStateRegistryController import p.Factory as LocalFactory import p.Factory +import p.ExtFactory // trailing comment +import p.Obj import p.Modes +import p.extMake +import p.extOther +import p.pad +import androidx.compose.ui.Modifier import p.Dimensions.Height import p.toPx import p.Outer @@ -36,6 +51,11 @@ class Calls { fun extension() { Modes.ext() } fun value() { Height.toPx() } fun nested() { Outer.Inner() } + fun companionExtension() { ExtFactory.extMake() } + fun inherited() { Obj.hello() } + fun externalExtension() { Modifier.pad() } + fun crossFileExtension() { Modes.extOther() } + fun unimportedExtension() { Modes.hidden() } }`, })) { const target = path.join(dir, file); @@ -57,6 +77,11 @@ class Calls { expect(callees('extension')).toEqual(['Modes::ext']); expect(callees('value')).toEqual(['Int::toPx']); expect(callees('nested')).toEqual(['p::Outer::Inner::Inner']); + expect(callees('companionExtension')).toEqual(['ExtFactory::extMake']); + expect(callees('inherited')).toEqual(['p::Base::hello']); + expect(callees('externalExtension')).toEqual(['Modifier::pad']); + expect(callees('crossFileExtension')).toEqual(['Modes::extOther']); + expect(callees('unimportedExtension')).toEqual([]); } finally { cg?.destroy(); fs.rmSync(dir, { recursive: true, force: true }); diff --git a/src/resolution/name-matcher.ts b/src/resolution/name-matcher.ts index 72e3813c96..06a4e9aa03 100644 --- a/src/resolution/name-matcher.ts +++ b/src/resolution/name-matcher.ts @@ -18310,7 +18310,7 @@ function kotlinImportedType(name: string, ref: UnresolvedRef, context: Resolutio // ponytail: scan explicit imports here; cache per file only if indexing profiles justify it. const source = context.readFile(ref.filePath); if (!source) return undefined; - for (const match of source.matchAll(/^[ \t]*import[ \t]+([\w.]+)(?:[ \t]+as[ \t]+(\w+))?[ \t]*;?[ \t]*$/gm)) { + for (const match of source.matchAll(/^[ \t]*import[ \t]+([\w.]+)(?:[ \t]+as[ \t]+(\w+))?[ \t]*;?[ \t]*(?:\/\/[^\n]*)?$/gm)) { const fqn = match[1]!; if ((match[2] || fqn.split('.').pop()) !== name) continue; const importedNode = context.getNodesByName(fqn.split('.').pop()!).find(n => @@ -18321,6 +18321,19 @@ function kotlinImportedType(name: string, ref: UnresolvedRef, context: Resolutio return undefined; } +function kotlinExtensionInScope(target: Node, ref: UnresolvedRef, context: ResolutionContext): boolean { + if (target.filePath === ref.filePath) return true; + const declaration = context.readFile(target.filePath) ?? ''; + const caller = context.readFile(ref.filePath) ?? ''; + const targetPackage = /^\s*package\s+([\w.]+)/m.exec(declaration)?.[1] ?? ''; + const callerPackage = /^\s*package\s+([\w.]+)/m.exec(caller)?.[1] ?? ''; + if (targetPackage === callerPackage) return true; + for (const match of caller.matchAll(/^[ \t]*import[ \t]+([\w.]+(?:\.\*)?)(?:[ \t]+as[ \t]+\w+)?[ \t]*(?:\/\/[^\n]*)?$/gm)) { + if (match[1] === `${targetPackage}.${target.name}` || match[1] === `${targetPackage}.*`) return true; + } + return false; +} + /** * Java/Kotlin: infer a receiver's declared type by walking field declarations * in the class enclosing the call site. The field's `signature` is already in @@ -21074,8 +21087,18 @@ export function matchMethodCall( if (constructor) return { original: ref, targetNodeId: constructor.id, confidence: 0.9, resolvedBy: 'qualified-name' }; } - return owners.length === 1 && methods.length > 0 ? { original: ref, targetNodeId: methods[0]!.id, - confidence: 0.9, resolvedBy: 'qualified-name' } : null; + if (owners.length === 1 && methods.length > 0) return { original: ref, targetNodeId: methods[0]!.id, + confidence: 0.9, resolvedBy: 'qualified-name' }; + const extensions = context.getNodesByName(methodName!).filter(n => + n.kind === 'method' && n.language === 'kotlin' && + n.qualifiedName === `${objectOrClass}::${methodName}`); + if (extensions.length > 0) { + const visible = extensions.filter(n => kotlinExtensionInScope(n, ref, context)); + if (visible.length === 1) return { original: ref, targetNodeId: visible[0]!.id, + confidence: 0.9, resolvedBy: 'qualified-name' }; + return null; + } + if (owners.length === 0) return null; } // A simple `receiver.method` / `receiver:method` / `receiver$method` shape whose // receiver type we can try to infer from its local declaration. @@ -29144,7 +29167,12 @@ function matchReferenceInner( if (result) return result; if (ref.language === 'kotlin' && ref.referenceKind === 'calls') { const importedReceiver = ref.referenceName.match(/^([A-Z]\w*)\.\w+$/)?.[1]; - if (importedReceiver && kotlinImportedType(importedReceiver, ref, context)) return null; + if (importedReceiver) { + const importedType = kotlinImportedType(importedReceiver, ref, context); + if (importedType && !context.getNodesByName(importedType.split('.').pop()!).some(n => + (n.kind === 'class' || n.kind === 'interface' || n.kind === 'enum') && + n.qualifiedName.replace(/::/g, '.') === importedType)) return null; + } } // 3. Exact name match From 4472c9e24a2b37249b297b8c4d80ac2842f3038c Mon Sep 17 00:00:00 2001 From: mixxer Date: Mon, 5 Oct 2026 00:16:31 +0900 Subject: [PATCH 3/4] fix: adapt PR to current upstream behavior --- CHANGELOG.md | 2 +- src/resolution/name-matcher.ts | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8d110f504f..b0eea9aacc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -389,6 +389,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - In JavaScript, TypeScript, Python, Go, Rust, Java and Kotlin, a function named in the value of a top-level constant or variable, or of a class field, like `const routes = { home }`, `HANDLERS = {"home": home}`, a Go `map[string]func()` table, a Rust `static` list of functions, `static handlers = { click: onClick }`, a Java `List.of(Registry::home)` field or a Kotlin `listOf(::home)` property, is now referenced once, by the constant, variable, field or property that holds it. The file or class around it no longer also shows up as a second reference from the same place, so a function's callers and impact list each table once. - `codegraph sync` and the file watcher now follow an edit that changes which declaration a module exports as its default, like `export default A` becoming `export default B` while `A` stays, or the module dropping its default. References through `import Y from './x'`, `{ default as Y }`, `require('./x').default`, a barrel that passes the default on or a route that renders it as a layout, and calls on a value typed by it, now link to the new default, or stay unlinked, as a fresh index has them. Before, they kept pointing at the old declaration until the importing file changed or the project was indexed again. - `codegraph sync` and the file watcher now relink a named import that a name match had linked to a declaration elsewhere while its module didn't export the name, once an edit makes the module export it, like `import { EmptyState } from './layouts/shared'` after the barrel gains `export { EmptyState } from './EmptyState'`. An import a name match had linked to a class the module exported only as its default comes unlinked once the module stops exporting it. Before, the import, the references through it and calls on values typed by it kept the old link until the importing file changed or the project was indexed again. +- Kotlin calls through imported types now avoid unrelated same-named methods while retaining inherited members and project extension functions. (#1948) ## [1.6.2] - 2026-10-03 @@ -647,7 +648,6 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - Zustand actions keep their callers when read through typed stores, destructured from store state, or selected by a hook. - Direct React Native bridge calls retain their native implementations and cross-platform relationships. - Dart extension-type getters remain searchable when using the WebAssembly parser. -- Kotlin calls through imported types now avoid unrelated same-named methods while retaining inherited members and project extension functions. (#1948) - Calling a built-in method on an awaited value no longer records a call into an unrelated class that happens to declare a method of the same name, and a variable bound to an awaited call now resolves methods on the type that call returns. Thanks @maxmilian. (#1840) - Spring mappings now include every declared path combination and resolve constants declared in the same file, while unresolved paths no longer appear as false root routes. (#1461) diff --git a/src/resolution/name-matcher.ts b/src/resolution/name-matcher.ts index 06a4e9aa03..ceb565a171 100644 --- a/src/resolution/name-matcher.ts +++ b/src/resolution/name-matcher.ts @@ -28916,7 +28916,9 @@ function matchReferenceInner( // nested `ParameterizedTypesTest.Field`). if ((ref.language === 'java' || ref.language === 'kotlin') && ref.referenceKind !== 'imports' && isJavaOutsideImport(ref.referenceName.split('.')[0]!, ref, context)) { - return null; + // Kotlin can call an in-project extension on an imported library type. + return ref.language === 'kotlin' && ref.referenceKind === 'calls' + ? matchMethodCall(ref, context) : null; } // A symbolic name in a Scala type is a type (`F ~> G`) or a kind-projector From 08c716f16b16ee0f55284264b8e0dacef4d018b3 Mon Sep 17 00:00:00 2001 From: mixxer Date: Mon, 5 Oct 2026 00:19:08 +0900 Subject: [PATCH 4/4] test: fix LSP diagnostics in PR fixtures --- __tests__/kotlin-imported-type-call.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/__tests__/kotlin-imported-type-call.test.ts b/__tests__/kotlin-imported-type-call.test.ts index 793493e6f2..fbdc44b70e 100644 --- a/__tests__/kotlin-imported-type-call.test.ts +++ b/__tests__/kotlin-imported-type-call.test.ts @@ -62,7 +62,7 @@ class Calls { fs.mkdirSync(path.dirname(target), { recursive: true }); fs.writeFileSync(target, source); } - cg = CodeGraph.initSync(dir, { config: { include: ['**/*.kt', '**/*.java'], exclude: [] } }); + cg = CodeGraph.initSync(dir); await cg.indexAll(); const callees = (name: string) => { const caller = cg!.searchNodes(name).map(r => r.node)