From a3e7dfa8a6c717c72a45edf526aaa0879f7f0e2c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gerg=C5=91=20Magyar?= Date: Fri, 22 May 2026 10:24:02 +0100 Subject: [PATCH] fix(lang-kotlin): interface default method dispatch via implements-split MRO (#1763) (#1779) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #1763. `user.validate()` on `class User : Validator` resolved to no edge under REGISTRY_PRIMARY_KOTLIN=1 when validate() was a default method declared on the Validator interface: class User(val name: String) : Validator interface Validator { fun validate(): Boolean = true } fun run() { val user = User("alice"); user.validate() } The generic `buildMro` walks EXTENDS edges only. Kotlin classes implement interfaces via IMPLEMENTS edges (per the parsing-processor), so the implementor's MRO never picked up the interface's default methods — `findOwnedMember(User, validate)` returned undefined and no fallback walked to Validator. Fix: replace `defaultLinearize` with a Kotlin-specific MRO builder modeled after PHP's `buildPhpMro` (trait composition): 1. Run the generic `buildMro` (EXTENDS-only). 2. Collect direct IMPLEMENTS edges as class -> interface[] map. 3. For each class, walk its EXTENDS-MRO ancestors AND its own IMPLEMENTS edges to seed interface candidates, then BFS-close to pick up transitive interface inheritance (interface A : B). 4. Append the interface closure to the class's MRO (after the EXTENDS chain — Kotlin requires explicit override on conflict, so this ordering is a safe approximation for method lookup). 5. Classes with no EXTENDS but with IMPLEMENTS edges (the #1763 fixture shape) get their MRO seeded directly from their interfaces. Verification (REGISTRY_PRIMARY_KOTLIN=1): - Forced-mode: 21 -> 20 failing of 175 (1 fewer; test 2062 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, #1762). Does NOT add Kotlin to MIGRATED_LANGUAGES per parent #1746 flip criteria. Closes #1763. Refs #1746. Co-authored-by: Test --- .../languages/kotlin/scope-resolver.ts | 98 ++++++++++++++++++- 1 file changed, 96 insertions(+), 2 deletions(-) diff --git a/gitnexus/src/core/ingestion/languages/kotlin/scope-resolver.ts b/gitnexus/src/core/ingestion/languages/kotlin/scope-resolver.ts index 2b20808ad..5f69a2713 100644 --- a/gitnexus/src/core/ingestion/languages/kotlin/scope-resolver.ts +++ b/gitnexus/src/core/ingestion/languages/kotlin/scope-resolver.ts @@ -1,6 +1,10 @@ import { SupportedLanguages, type ParsedFile } from 'gitnexus-shared'; +import type { KnowledgeGraph } from '../../../graph/types.js'; import { buildMro, defaultLinearize } from '../../scope-resolution/passes/mro.js'; import type { ScopeResolver } from '../../scope-resolution/contract/scope-resolver.js'; +import { resolveDefGraphId } from '../../scope-resolution/graph-bridge/ids.js'; +import type { GraphNodeLookup } from '../../scope-resolution/graph-bridge/node-lookup.js'; +import { isClassLike } from '../../scope-resolution/scope/walkers.js'; import { kotlinProvider } from '../kotlin.js'; import { kotlinArityCompatibility, @@ -42,8 +46,7 @@ export const kotlinScopeResolver: ScopeResolver = { arityCompatibility: (callsite, def) => kotlinArityCompatibility(def, callsite), - buildMro: (graph, parsedFiles, nodeLookup) => - buildMro(graph, parsedFiles, nodeLookup, defaultLinearize), + buildMro: (graph, parsedFiles, nodeLookup) => buildKotlinMro(graph, parsedFiles, nodeLookup), populateOwners: (parsed: ParsedFile) => populateKotlinOwners(parsed), @@ -54,3 +57,94 @@ export const kotlinScopeResolver: ScopeResolver = { collapseMemberCallsByCallerTarget: false, hoistTypeBindingsToModule: true, }; + +/** + * Kotlin MRO builder — extends `defaultLinearize` (EXTENDS-only) with + * interface ancestors discovered via `IMPLEMENTS` edges. Interface + * default methods (`interface Validator { fun validate(): Boolean = true }`) + * are inherited by implementing classes without an explicit override; + * the generic MRO would not surface them because the implementor has + * no `EXTENDS` link to the interface (#1763). + * + * Interfaces are appended after the EXTENDS chain (Kotlin resolves + * conflicts by requiring an explicit override, so first-seen-in-MRO + * ordering is a reasonable approximation for method lookup). Transitive + * interface inheritance (`interface A : B`) is closed via BFS. + */ +function buildKotlinMro( + graph: KnowledgeGraph, + parsedFiles: readonly ParsedFile[], + nodeLookup: GraphNodeLookup, +): Map { + const mro = buildMro(graph, parsedFiles, nodeLookup, defaultLinearize); + + const defIdByGraphId = new Map(); + for (const parsed of parsedFiles) { + for (const def of parsed.localDefs) { + if (!isClassLike(def.type)) continue; + const graphId = resolveDefGraphId(parsed.filePath, def, nodeLookup); + if (graphId !== undefined) defIdByGraphId.set(graphId, def.nodeId); + } + } + + // Direct IMPLEMENTS targets per class-like def. + const directImpls = new Map(); + for (const rel of graph.iterRelationshipsByType('IMPLEMENTS')) { + const source = defIdByGraphId.get(rel.sourceId); + const target = defIdByGraphId.get(rel.targetId); + if (source === undefined || target === undefined) continue; + let list = directImpls.get(source); + if (list === undefined) { + list = []; + directImpls.set(source, list); + } + if (!list.includes(target)) list.push(target); + } + + // For each class, append the transitive closure of interfaces reachable + // through its own + ancestor classes' IMPLEMENTS edges. Walking + // ancestors picks up interfaces inherited via the EXTENDS chain + // (e.g. `class C : B; class B : A; interface A` — C inherits A's + // interface methods through B). + for (const [classDefId, extendsMro] of mro) { + const ancestorChain = [classDefId, ...extendsMro]; + const seeds: string[] = []; + for (const ancestorId of ancestorChain) { + for (const ifaceId of directImpls.get(ancestorId) ?? []) { + seeds.push(ifaceId); + } + } + if (seeds.length === 0) continue; + const interfaces = closeInterfaces(seeds, directImpls); + mro.set(classDefId, [...extendsMro, ...interfaces.filter((i) => !extendsMro.includes(i))]); + } + + // Classes with no EXTENDS still need an MRO entry when they implement + // interfaces (e.g. `class User : Validator` — no `mro` entry from the + // EXTENDS-only pass because no EXTENDS edges exist). + for (const [classDefId, ifaces] of directImpls) { + if (mro.has(classDefId)) continue; + mro.set(classDefId, closeInterfaces([...ifaces], directImpls)); + } + + return mro; +} + +function closeInterfaces( + seeds: readonly string[], + directImpls: ReadonlyMap, +): string[] { + const out: string[] = []; + const seen = new Set(); + const queue: string[] = [...seeds]; + while (queue.length > 0) { + const cur = queue.shift()!; + if (seen.has(cur)) continue; + seen.add(cur); + out.push(cur); + for (const next of directImpls.get(cur) ?? []) { + if (!seen.has(next)) queue.push(next); + } + } + return out; +}