fix(lang-kotlin): interface default method dispatch via implements-split MRO (#1763) (#1779)

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 <test@example.com>
This commit is contained in:
Gergő Magyar 2026-05-22 10:24:02 +01:00 • committed by GitHub
parent ccf0b8b73c
commit a3e7dfa8a6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194

View file

@ -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<string, string[]> {
const mro = buildMro(graph, parsedFiles, nodeLookup, defaultLinearize);
const defIdByGraphId = new Map<string, string>();
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<string, string[]>();
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, readonly string[]>,
): string[] {
const out: string[] = [];
const seen = new Set<string>();
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;
}