fix(lang-kotlin): virtual dispatch via constructor type override (#1762) (#1778)

Closes #1762. `val animal: Animal = Dog(); animal.speak()` resolved
to `Animal.speak` (or no edge to Dog at all) under
`REGISTRY_PRIMARY_KOTLIN=1` because the Kotlin scope query emits BOTH
an annotation type-binding (`animal -> Animal`) and a constructor-
inferred type-binding (`animal -> Dog`). The generic scope-extractor
ranks annotation sources higher than constructor-inferred sources (see
`typeBindingStrength` in scope-extractor.ts), so the annotation
always won and `animal.speak()` dispatched against the static type.

Kotlin's virtual dispatch semantics expect the dynamic type — the
overriding `Dog.speak` should win when the RHS is a constructor call,
because that's what runs at runtime.

Fix: in `emitKotlinScopeCaptures`, suppress the `@type-binding.
annotation` capture when the underlying `property_declaration` has a
`call_expression` value sibling. The constructor-inferred capture
remains, becomes the sole binding for the variable, and receiver-bound
resolution dispatches against the constructed class (and walks its
MRO).

This is intentionally Kotlin-specific — flipping precedence globally
would change behavior for other languages whose static-type
annotations are still the right binding when present. Kotlin is the
language where the constructor RHS is the dispatch target by design.

Verification (REGISTRY_PRIMARY_KOTLIN=1):
- Forced-mode: 21 -> 20 failing of 175 (1 fewer; test 1715 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, #1760, #1761, #1763).

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

Closes #1762. Refs #1746.

Co-authored-by: Test <test@example.com>
This commit is contained in:
Gergő Magyar 2026-05-22 10:40:59 +01:00 • committed by GitHub
parent 7596d8f9ce
commit 2dd1065f0e

View file

@ -1,4 +1,4 @@
import type { Capture, CaptureMatch } from 'gitnexus-shared';
import type { Capture, CaptureMatch, Range } from 'gitnexus-shared';
import {
findNodeAtRange,
nodeToCapture,
@ -73,6 +73,30 @@ export function emitKotlinScopeCaptures(
if (navNode === null || !shouldEmitReadMember(navNode)) continue;
}
// Virtual dispatch via constructor type (#1762). When a property
// declaration carries BOTH an explicit type annotation AND a
// constructor-style call value (e.g. `val animal: Animal = Dog()`),
// suppress the annotation capture so the constructor-inferred
// binding wins. This matches Kotlin's virtual dispatch semantics:
// `animal.speak()` should resolve to the overriding `Dog.speak`
// (the dynamic type), not `Animal.speak` (the static annotation).
//
// The annotation source has higher precedence than constructor-
// inferred in the generic scope-extractor (see
// `typeBindingStrength` in scope-extractor.ts), so the only way to
// make the constructor type prevail is to drop the annotation at
// emission time.
if (
grouped['@type-binding.annotation'] !== undefined &&
grouped['@type-binding.name'] !== undefined &&
grouped['@type-binding.type'] !== undefined
) {
const annotation = grouped['@type-binding.annotation']!;
if (propertyDeclHasConstructorValue(tree.rootNode, annotation.range)) {
continue;
}
}
if (grouped['@scope.function'] !== undefined) {
out.push(grouped);
const fnNode = findNodeAtRange(
@ -646,6 +670,21 @@ function shouldEmitReadMember(navNode: SyntaxNode): boolean {
return true;
}
/** True when the property_declaration anchored at `range` has a
* `call_expression` value sibling (i.e. `val x: T = Foo()`). Used to
* suppress the explicit-annotation type-binding capture so the
* constructor-inferred binding wins (#1762). */
function propertyDeclHasConstructorValue(rootNode: SyntaxNode, range: Range): boolean {
const propNode = findNodeAtRange(rootNode, range, 'property_declaration');
if (propNode === null) return false;
const variable = propNode.namedChildren.find((c) => c.type === 'variable_declaration');
if (variable === undefined) return false;
const value = propNode.namedChildren.find(
(c) => c.id !== variable.id && c.type !== 'binding_pattern_kind',
);
return value?.type === 'call_expression';
}
function callArguments(callNode: SyntaxNode): SyntaxNode[] {
const suffix = callNode.namedChildren.find((child) => child.type === 'call_suffix');
if (suffix === undefined) return [];