fix(lang-kotlin): method-chain fixpoint receiver types (#1760) (#1776)

Closes #1760. Multi-step intra-file chains like

    val user = getUser()
    val addr = user.address
    val city = addr.getCity()
    city.save()

produced no `CALLS` edge for `city.save()` because the Kotlin extractor
only inferred property types for `simple_identifier` values (`val x = y`)
and call expressions with simple-identifier callees (`val x = fn()`).
Navigation expressions (`val addr = user.address`) and call expressions
with navigation-expression callees (`val city = addr.getCity()`)
returned null, leaving `addr` and `city` unbound — the chain broke
two hops before `city.save()`.

Implementation:
- `collectKotlinClassMembers(rootNode)` indexes per-file class fields
  (primary-constructor `val`/`var` params + body property declarations)
  and method return types. Per-file scope matches the existing
  extractor design.
- `inferKotlinPropertyType` gains two new cases:
  1. `navigation_expression` value — receiver type via `localTypes`,
     field type via `classMembers.fields`.
  2. `call_expression` with `navigation_expression` callee — receiver
     type via `localTypes`, method return type via `classMembers.methods`.
  Both return null when any link is unknown (safe / over-conservative).

Verification (REGISTRY_PRIMARY_KOTLIN=1):
- Forced-mode: 21 -> 20 failing of 175 (1 fewer; test 1491 in
  `test/integration/resolvers/kotlin.test.ts` now green).
- Default-mode Kotlin: 175/175 unchanged.
- Full resolver suite: 2216/2216 unchanged.
- Remaining 20 failures are tracked by sibling sub-issues
  (#1758, #1759, #1761, #1762, #1763).

Does NOT add Kotlin to MIGRATED_LANGUAGES per parent #1746 flip criteria.

Closes #1760. Refs #1746.

Co-authored-by: Test <test@example.com>
This commit is contained in:
Gergő Magyar
2026-05-22 10:40:46 +01:00
committed by GitHub
co-authored by Test
parent 67cc4c6d94
commit 64efc202f6
@@ -290,10 +290,11 @@ function synthesizeKotlinLocalAssignmentBindings(
returnTypes: ReadonlyMap<string, string>,
): CaptureMatch[] {
const out: CaptureMatch[] = [];
const classMembers = collectKotlinClassMembers(rootNode);
for (const fnNode of descendantsOfType(rootNode, 'function_declaration')) {
const localTypes = new Map<string, string>();
for (const prop of descendantsOfType(fnNode, 'property_declaration')) {
const inferred = inferKotlinPropertyType(prop, localTypes, returnTypes);
const inferred = inferKotlinPropertyType(prop, localTypes, returnTypes, classMembers);
if (inferred === null) continue;
localTypes.set(inferred.name.text, inferred.rawType);
if (inferred.synthetic) {
@@ -316,6 +317,77 @@ function synthesizeKotlinLocalAssignmentBindings(
return out;
}
interface KotlinClassMembers {
/** className → fieldName → raw type text */
readonly fields: ReadonlyMap<string, ReadonlyMap<string, string>>;
/** className → methodName → raw return type text */
readonly methods: ReadonlyMap<string, ReadonlyMap<string, string>>;
}
/**
* Per-file class-member index — primary-constructor `val`/`var` params,
* body property declarations, and method return types. Used by
* `inferKotlinPropertyType` to walk single-level field and method chains
* like `val addr = user.address` and `val city = addr.getCity()` (#1760).
*
* Indexes by simple class name only. Multi-class collisions inside a
* single file will pick whichever class was visited last for that name
* — acceptable because Kotlin forbids same-name top-level classes in
* one file and per-file resolution is the design boundary here.
*/
function collectKotlinClassMembers(rootNode: SyntaxNode): KotlinClassMembers {
const fields = new Map<string, Map<string, string>>();
const methods = new Map<string, Map<string, string>>();
for (const cls of descendantsOfType(rootNode, 'class_declaration')) {
const className = cls.namedChildren.find((child) => child.type === 'type_identifier')?.text;
if (className === undefined) continue;
const fmap = fields.get(className) ?? new Map<string, string>();
const mmap = methods.get(className) ?? new Map<string, string>();
const primary = cls.namedChildren.find((child) => child.type === 'primary_constructor');
if (primary !== undefined) {
for (const param of primary.namedChildren) {
if (param.type !== 'class_parameter') continue;
// Constructor params are class fields ONLY when prefixed with
// `val`/`var` (binding_pattern_kind). Plain `fn(x: Int)`-style
// params remain locals to the constructor.
if (param.namedChildren.find((c) => c.type === 'binding_pattern_kind') === undefined) {
continue;
}
const fname = param.namedChildren.find((c) => c.type === 'simple_identifier')?.text;
const ftype = param.namedChildren.find((c) => isKotlinTypeNode(c))?.text;
if (fname !== undefined && ftype !== undefined) fmap.set(fname, ftype);
}
}
const body = cls.namedChildren.find((child) => child.type === 'class_body');
if (body !== undefined) {
for (const member of body.namedChildren) {
if (member.type === 'property_declaration') {
const v = member.namedChildren.find((c) => c.type === 'variable_declaration');
const fname = v?.namedChildren.find((c) => c.type === 'simple_identifier')?.text;
const ftype = v?.namedChildren.find((c) => isKotlinTypeNode(c))?.text;
if (fname !== undefined && ftype !== undefined) fmap.set(fname, ftype);
} else if (member.type === 'function_declaration') {
const mname = member.namedChildren.find((c) => c.type === 'simple_identifier')?.text;
const paramsIdx = member.namedChildren.findIndex(
(c) => c.type === 'function_value_parameters',
);
const rtype =
paramsIdx < 0
? undefined
: member.namedChildren.slice(paramsIdx + 1).find((c) => isKotlinTypeNode(c))?.text;
if (mname !== undefined && rtype !== undefined) mmap.set(mname, rtype);
}
}
}
fields.set(className, fmap);
methods.set(className, mmap);
}
return { fields, methods };
}
function collectKotlinLocalTypeTexts(
fnNode: SyntaxNode,
returnTypes: ReadonlyMap<string, string>,
@@ -357,6 +429,7 @@ function inferKotlinPropertyType(
prop: SyntaxNode,
localTypes: ReadonlyMap<string, string>,
returnTypes: ReadonlyMap<string, string>,
classMembers?: KotlinClassMembers,
): { name: SyntaxNode; rawType: string; source: SyntaxNode; synthetic: boolean } | null {
const variable = prop.namedChildren.find((child) => child.type === 'variable_declaration');
const name = variable?.namedChildren.find((child) => child.type === 'simple_identifier');
@@ -375,18 +448,74 @@ function inferKotlinPropertyType(
return rawType === undefined ? null : { name, rawType, source: value, synthetic: true };
}
if (value?.type === 'navigation_expression') {
// `val addr = user.address` — receiver type → field on that class (#1760).
const chained = inferKotlinNavigationFieldType(value, localTypes, classMembers);
if (chained === null) return null;
return { name, rawType: chained, source: value, synthetic: true };
}
if (value?.type === 'call_expression') {
const callee = value.namedChildren.find((child) => child.type === 'simple_identifier');
const callee = value.namedChildren.find(
(child) => child.type === 'simple_identifier' || child.type === 'navigation_expression',
);
if (callee === undefined) return null;
const rawType =
returnTypes.get(callee.text) ?? (isUppercaseName(callee.text) ? callee.text : null);
if (rawType === null) return null;
return { name, rawType, source: callee, synthetic: true };
if (callee.type === 'simple_identifier') {
const rawType =
returnTypes.get(callee.text) ?? (isUppercaseName(callee.text) ? callee.text : null);
if (rawType === null) return null;
return { name, rawType, source: callee, synthetic: true };
}
// `val city = addr.getCity()` — receiver type → method return on that class (#1760).
const chained = inferKotlinNavigationCallReturnType(callee, localTypes, classMembers);
if (chained === null) return null;
return { name, rawType: chained, source: callee, synthetic: true };
}
return null;
}
/** Resolve `receiver.field` → field's declared type, where `receiver`
* is a simple identifier whose type is in `localTypes` and `field`
* is declared on that type in `classMembers.fields`. Returns null
* when any link in the chain is unknown — safe over-conservative. */
function inferKotlinNavigationFieldType(
nav: SyntaxNode,
localTypes: ReadonlyMap<string, string>,
classMembers: KotlinClassMembers | undefined,
): string | null {
if (classMembers === undefined) return null;
const receiver = nav.namedChild(0);
if (receiver === null || receiver.type !== 'simple_identifier') return null;
const member = nav.namedChildren
.find((c) => c.type === 'navigation_suffix')
?.namedChildren.find((c) => c.type === 'simple_identifier')?.text;
if (member === undefined) return null;
const recvType = localTypes.get(receiver.text);
if (recvType === undefined) return null;
return classMembers.fields.get(normalizeKotlinType(recvType))?.get(member) ?? null;
}
/** Resolve `receiver.method()` → method's declared return type, where
* `receiver` is a simple identifier whose type is in `localTypes` and
* `method` is declared on that type in `classMembers.methods`. */
function inferKotlinNavigationCallReturnType(
navCallee: SyntaxNode,
localTypes: ReadonlyMap<string, string>,
classMembers: KotlinClassMembers | undefined,
): string | null {
if (classMembers === undefined) return null;
const receiver = navCallee.namedChild(0);
if (receiver === null || receiver.type !== 'simple_identifier') return null;
const methodName = navCallee.namedChildren
.find((c) => c.type === 'navigation_suffix')
?.namedChildren.find((c) => c.type === 'simple_identifier')?.text;
if (methodName === undefined) return null;
const recvType = localTypes.get(receiver.text);
if (recvType === undefined) return null;
return classMembers.methods.get(normalizeKotlinType(recvType))?.get(methodName) ?? null;
}
function inferKotlinIterableElementType(
iterable: SyntaxNode,
localTypes: ReadonlyMap<string, string>,