fix(kotlin): a DSL lambda runs on its own receiver (#2206)

#2172 made the members of every type a project function type takes as a
receiver reachable from any bare call, since it could not tell which
lambda a call is in. With several such types in reach, proximity chose:
koin's `single { C51(get(), get()) }` and `Scope.new(...)`'s `get()` went to
`Koin.get` (koin also has `Koin.() -> ...` lambdas), 2,190 times.

- A lambda's receiver is read from the function it is passed to: the type
  before `.(` in its last parameter's function type, directly or through a
  typealias (`definition: Definition<T>`, `typealias Definition<T> =
  Scope.(ParametersHolder) -> T`), when every project function of that name
  agrees. The brace frames put it in the lambda's hierarchy.
- Of the members a bare call can reach, those its lexical hierarchy reaches -
  its class, an extension's receiver, the lambda it is in - come before those
  only the project-wide receiver set reaches.

koin: `get()` -> Scope (2,184), `viewModel` in `scope { }` -> ScopeDSL,
`modules(...)` in `startKoin { }` -> KoinApplication. Exposed: `varchar(...)`,
`integer`, `reference`, `datetime` in a table object -> Table's (were a
test table's same-named property). kotlinx.coroutines: `launch { }` ->
CoroutineScope.launch (was a test DSL's). Edge counts ~unchanged (targets
move): koin, Exposed, okhttp, coroutines, okio; nowinandroid, moshi
unchanged.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Colby Mchenry
2026-09-30 21:25:06 +00:00
committed by GitHub
co-authored by Claude Opus 5.5
parent a329df883a
commit fc73204f12
3 changed files with 184 additions and 2 deletions
+1
View File
@@ -14,6 +14,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
### Fixes
- Kotlin calls inside a DSL lambda now reach the lambda's own receiver. That receiver comes from the function the lambda is passed to, even through a typealias like koin's `Definition<T> = Scope.(…) -> T`. It also wins over other types the project uses as lambda receivers elsewhere. On koin, over 2,000 `get()` calls in `single { … }` now reach `Scope.get` instead of `Koin.get`; on Exposed, `varchar(…)` inside a table object reaches `Table.varchar`; on kotlinx.coroutines, `launch { }` reaches `CoroutineScope.launch`.
- A Swift call on a property declared with a type, like Kingfisher's `var cache: ImageCache!` or `self.storage.write(…)`, now reaches that type's method or one it inherits. It no longer lands on a subclass override in a test, or on another type's method of the same name.
- Calls in your own JavaScript no longer link to a same-named function inside a vendored minified script, like a `*.min.js` or a bundle made of a few enormous lines. Those names are mangled. In healthchecks, every jQuery `$(…)` call had gone to a one-letter helper inside `bootstrap-native.min.js`.
- A Swift call on a value built on the spot, like `JSONDecoder().decode(…)` or `Realm().create(…)`, now resolves to that type's method, or to one it inherits. It no longer lands on some other type's method of the same name: Vapor's `JSONDecoder().decode` calls had gone to a request's private container.
+95
View File
@@ -0,0 +1,95 @@
/**
* A Kotlin lambda runs on the receiver its function's parameter type names —
* directly (`Scope.() -> T`) or through a typealias (koin's `definition:
* Definition<T>` with `typealias Definition<T> = Scope.(ParametersHolder) ->
* T`) — and the members the code around a bare call reaches come before
* those only some other lambda type in the project could: koin's
* `single { C51(get(), get()) }` and `Scope.new(…)`'s `get()` are Scope's,
* not Koin's (2,190 of them).
*/
import { describe, it, expect, afterAll, beforeAll } from 'vitest';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { CodeGraph } from '../src';
let root = '';
let cg: CodeGraph;
beforeAll(async () => {
root = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-kotlin-lambda-'));
const files: Record<string, string> = {
'src/main/kotlin/org/koin/core/Koin.kt': `package org.koin.core
class Koin {
fun <T> get(): T = TODO()
}
fun koinTest(block: Koin.() -> Unit) { }
`,
'src/main/kotlin/org/koin/core/scope/Scope.kt': `package org.koin.core.scope
class Scope {
fun <T> get(): T = TODO()
}
`,
'src/main/kotlin/org/koin/core/definition/Definition.kt': `package org.koin.core.definition
import org.koin.core.scope.Scope
typealias Definition<T> = Scope.(Int) -> T
`,
'src/main/kotlin/org/koin/core/module/Module.kt': `package org.koin.core.module
import org.koin.core.definition.Definition
class Module {
fun <T> single(definition: Definition<T>) { }
}
`,
'src/main/kotlin/org/koin/dsl/New.kt': `package org.koin.dsl
import org.koin.core.scope.Scope
inline fun <reified R, reified T1, reified T2> Scope.new(
constructor: (T1, T2) -> R,
): R = constructor(get(), get())
`,
'src/test/kotlin/app/Perfs.kt': `package app
import org.koin.core.module.Module
class C51(a: Int, b: Int)
fun perfModule(m: Module) {
m.single { C51(get(), get()) }
}
`,
};
for (const [rel, content] of Object.entries(files)) {
fs.mkdirSync(path.dirname(path.join(root, rel)), { recursive: true });
fs.writeFileSync(path.join(root, rel), content);
}
cg = await CodeGraph.init(root, { index: true });
});
afterAll(() => {
cg?.close();
if (root) fs.rmSync(root, { recursive: true, force: true });
});
const getTargets = (file: string) => {
const ids = cg.getNodesInFile(file).map((n) => n.id);
return [...new Set(cg.getOutgoingEdgesFrom(ids).filter((e) => e.kind === 'calls')
.map((e) => cg.getNode(e.target)!).filter((t) => t.name === 'get').map((t) => t.qualifiedName))];
};
describe('Kotlin lambda receivers', () => {
it('a lambda passed through a typealiased function type runs on its receiver', () => {
expect(getTargets('src/test/kotlin/app/Perfs.kt')).toEqual(['org.koin.core.scope::Scope::get']);
});
it('an extension function’s receiver beats a lambda type elsewhere', () => {
expect(getTargets('src/main/kotlin/org/koin/dsl/New.kt')).toEqual(['org.koin.core.scope::Scope::get']);
});
});
+88 -2
View File
@@ -2303,7 +2303,14 @@ function kotlinBraceFrames(file: string, context: ResolutionContext): Array<{ st
if (ch === '{') {
// `with(x) {`, `x.apply {`, `x.run {`: a receiver of whatever type x is.
const scoped = /(?:\bwith\s*\([^{}]*\)|\.\s*(?:apply|run)(?:\s*<[^<>]*>)?)\s*$/.test(pending);
stack.push({ start: line, names: scoped ? ['*'] : kotlinHeadNames(pending) });
let names = scoped ? ['*'] : kotlinHeadNames(pending);
// `single { get() }`: a lambda runs on the receiver its function's parameter type names.
if (!scoped && (!names || names.length === 0)) {
const call = /(?:^|[^\w$])([a-z_]\w*)\s*(?:<[^<>{}]*>)?\s*(?:\([^(){}]*\))?\s*$/.exec(pending)?.[1];
const receiver = call && !KOTLIN_BLOCK_WORDS.has(call) ? kotlinLambdaReceiver(call, context) : null;
if (receiver) names = [receiver];
}
stack.push({ start: line, names });
pending = '';
} else if (ch === '}') {
const open = stack.pop();
@@ -2317,6 +2324,63 @@ function kotlinBraceFrames(file: string, context: ResolutionContext): Array<{ st
return frames;
}
/** Words before a `{` that open a block, not a lambda argument. */
const KOTLIN_BLOCK_WORDS: ReadonlySet<string> = new Set([
'if', 'else', 'for', 'while', 'do', 'when', 'try', 'catch', 'finally', 'init', 'get', 'set', 'constructor',
'fun', 'class', 'object', 'interface', 'return', 'by', 'lazy', 'apply', 'run', 'also', 'let', 'with', 'use',
]);
const KOTLIN_LAMBDA_RECEIVERS = new WeakMap<ResolutionContext, Map<string, string | null>>();
/**
* The receiver a lambda passed to the project's `name` runs with: the type
* before `.(` in its last parameter's function type, directly or through a
* typealias — koin's `single(…, definition: Definition<T>)` with `typealias
* Definition<T> = Scope.(ParametersHolder) -> T` runs its lambda on a Scope.
* Null unless every `name` agrees.
*/
function kotlinLambdaReceiver(name: string, context: ResolutionContext): string | null {
let memo = KOTLIN_LAMBDA_RECEIVERS.get(context);
if (!memo) KOTLIN_LAMBDA_RECEIVERS.set(context, (memo = new Map()));
if (memo.has(name)) return memo.get(name)!;
const receiverOf = (type: string, depth: number): string | null => {
const direct = /^\s*(?:suspend\s+)?([A-Z]\w*)(?:<[^<>]*(?:<[^<>]*>[^<>]*)*>)?\s*\.\s*\(/.exec(type);
if (direct) return direct[1]!;
const alias = /^\s*([A-Z]\w*)\b/.exec(type)?.[1];
if (!alias || depth > 2) return null;
for (const decl of context.getNodesByName(alias)) {
if (decl.language !== 'kotlin' || decl.kind !== 'type_alias') continue;
const text = (context.getFileLines?.(decl.filePath) ?? context.readFile(decl.filePath)?.split(/\r?\n/) ?? [])[decl.startLine - 1] ?? '';
const rhs = /=\s*(.+)$/.exec(text)?.[1];
if (rhs) return receiverOf(rhs, depth + 1);
}
return null;
};
const found = new Set<string>();
for (const fn of context.getNodesByName(name)) {
if (fn.language !== 'kotlin' || (fn.kind !== 'function' && fn.kind !== 'method')) continue;
const lines = context.getFileLines?.(fn.filePath) ?? context.readFile(fn.filePath)?.split(/\r?\n/) ?? [];
const head = lines.slice(fn.startLine - 1, fn.startLine + 11).join(' ');
const open = head.search(new RegExp(`\\b${name}\\s*\\(`));
if (open < 0) continue;
let depth = 0;
let end = -1;
for (let i = head.indexOf('(', open); i < head.length; i++) {
if (head[i] === '(') depth++;
else if (head[i] === ')' && --depth === 0) { end = i; break; }
}
if (end < 0) continue;
const params = splitCppTopLevel(head.slice(head.indexOf('(', open) + 1, end));
const last = params[params.length - 1];
const type = last ? /:\s*([\s\S]+?)(?:\s*=\s*[^=>][\s\S]*)?$/.exec(last.replace(/^\s*(?:noinline|crossinline)\s+/, ''))?.[1] : undefined;
const receiver = type ? receiverOf(type, 0) : null;
if (receiver) found.add(receiver);
}
const result = found.size === 1 ? [...found][0]! : null;
memo.set(name, result);
return result;
}
/** The type names a Kotlin block head introduces: a type declaration's name and supertypes, or an extension function's receiver. */
function kotlinHeadNames(head: string): string[] | null {
let depth = 0;
@@ -2368,6 +2432,26 @@ function isKotlinMemberReachable(n: Node, ref: UnresolvedRef, context: Resolutio
return here.imports.has(`${objectPath}.${n.name}`) || here.stars.has(objectPath);
}
/**
* Of the members a bare Kotlin call can reach, the ones the code around it
* reaches — its class, an extension's receiver, the lambda it is in — before
* those only a lambda type somewhere in the project could: koin's `get()` in
* `Scope.new(…)` is Scope's, not Koin's.
*/
function lexicalKotlinMembers(candidates: Node[], ref: UnresolvedRef, context: ResolutionContext): Node[] {
if (candidates.length < 2) return candidates;
const hierarchy = kotlinHierarchyAt(ref, context);
if (hierarchy.has('*')) return candidates;
const lexical = candidates.filter((n) => {
if (n.kind !== 'method') return false;
const path = n.qualifiedName.slice(0, Math.max(0, n.qualifiedName.lastIndexOf('::'))).split(/::|\./);
let owner = path.pop() ?? '';
if (owner === 'Companion') owner = path.pop() ?? '';
return hierarchy.has(owner);
});
return lexical.length > 0 ? lexical : candidates;
}
/** Whether a standard-named Kotlin chain link can mean `n`: only through a receiver named after its owner. */
function isKotlinStdChainTarget(n: Node, receiver: string): boolean {
if (n.kind !== 'method' && n.kind !== 'function') return true;
@@ -4014,7 +4098,8 @@ export function matchByExactName(
!(bareJs && n.filePath !== ref.filePath && isLocallyBoundJsName(ref.referenceName, ref.filePath, context))
);
const candidates = dartBare ? nearestDartMembers(filtered, ref, context)
: swiftShape && swiftShape.shape !== 'chained' ? nearestSwiftMembers(filtered, ref, context) : filtered;
: swiftShape && swiftShape.shape !== 'chained' ? nearestSwiftMembers(filtered, ref, context)
: kotlinBare ? lexicalKotlinMembers(filtered, ref, context) : filtered;
if (candidates.length === 0) {
return null;
@@ -5083,6 +5168,7 @@ export function clearNameMatcherMemos(context: ResolutionContext): void {
RUST_TRAIT_IMPL_MEMO.delete(context);
RUST_USES.delete(context);
LEXICAL_SCOPE_MEMO.delete(context);
KOTLIN_LAMBDA_RECEIVERS.delete(context);
SCALA_IMPORTED_SUPERS.delete(context);
SCALA_PACKAGE_OBJECTS.delete(context);
LOCAL_DECL_MEMO.delete(context);