From 2dd1065f0e673c627ca8a66eb5331f8202465177 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gerg=C5=91=20Magyar?= Date: Fri, 22 May 2026 10:40:59 +0100 Subject: [PATCH] fix(lang-kotlin): virtual dispatch via constructor type override (#1762) (#1778) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../ingestion/languages/kotlin/captures.ts | 41 ++++++++++++++++++- 1 file changed, 40 insertions(+), 1 deletion(-) diff --git a/gitnexus/src/core/ingestion/languages/kotlin/captures.ts b/gitnexus/src/core/ingestion/languages/kotlin/captures.ts index a0f376da7f..4864d7d38c 100644 --- a/gitnexus/src/core/ingestion/languages/kotlin/captures.ts +++ b/gitnexus/src/core/ingestion/languages/kotlin/captures.ts @@ -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 [];