feat(impact): Kotlin Multiplatform expect/actual linking — bridge common decls to platform impls

A KMP `common` source set declares `expect fun foo()` / `expect class Bar`;
each platform source set (jvm, native, js, wasm) provides an `actual`
implementation with the identical fully-qualified name in a different file.
Callers in common code resolve to the `expect` declaration, so every platform
`actual` ended up with zero dependents — invisible to impact/`affected` and
showing an empty blast radius even though editing it can break every caller.

- Capture the `expect`/`actual` platform modifiers at extraction via a new
  generic `extractModifiers` hook (Kotlin reads `modifiers > platform_modifier`),
  persisted onto the node's decorators list.
- Synthesize a heuristic `calls` edge from the common declaration to each
  platform `actual` (mirroring the interface-impl bridge: abstract → concrete),
  matching by qualified name + the `actual` marker. The declaration side is the
  same-FQN, non-`actual` node, which also gates out plain cross-file overloads.
- Widen kind matching so an `expect class` fulfilled by an `actual typealias`
  (a core KMP idiom: CancellationException, ReentrantLock, …) links too.

Measured (fair cross-file dependent coverage, symbol-bearing source files):
kotlinx.coroutines 76.8% → 93.5%; OkHttp holds at 96.2% (barely uses KMP).
Node count stable (edges added, not nodes); residual is genuine frontiers
(expect-decl sides with no in-repo callers, ServiceLoader/agent SPI, test infra).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Colby McHenry
2026-06-04 01:09:38 -04:00
co-authored by Claude Opus 4.8
parent d111f26573
commit d8a2e91b2f
6 changed files with 246 additions and 0 deletions
+23
View File
@@ -227,6 +227,29 @@ export const kotlinExtractor: LanguageExtractor = {
}
return false;
},
extractModifiers: (node) => {
// Kotlin Multiplatform `expect`/`actual` markers live in
// modifiers > platform_modifier > (expect | actual)
// Capturing them lets the resolver link an `expect` declaration in a
// common source set to its `actual` implementations in platform source
// sets (those impls otherwise have zero dependents — the caller resolves
// to the `expect`). Match the AST node, not raw text, so an annotation
// argument or identifier named "actual" can't false-positive.
const mods: string[] = [];
for (let i = 0; i < node.childCount; i++) {
const child = node.child(i);
if (child?.type !== 'modifiers') continue;
for (let j = 0; j < child.childCount; j++) {
const pm = child.child(j);
if (pm?.type !== 'platform_modifier') continue;
for (let k = 0; k < pm.childCount; k++) {
const kw = pm.child(k);
if (kw && (kw.type === 'expect' || kw.type === 'actual')) mods.push(kw.type);
}
}
}
return mods.length > 0 ? mods : undefined;
},
extractImport: (node, source) => {
const importText = source.substring(node.startIndex, node.endIndex).trim();
const identifier = node.namedChildren.find((c: SyntaxNode) => c.type === 'identifier');
+8
View File
@@ -138,6 +138,14 @@ export interface LanguageExtractor {
isStatic?: (node: SyntaxNode) => boolean;
/** Check if variable declaration is a constant (const vs let/var) */
isConst?: (node: SyntaxNode) => boolean;
/**
* Extract extra symbol-level modifier keywords to persist on the node's
* `decorators` list (e.g. Kotlin `expect`/`actual` multiplatform markers).
* Called generically for every created node; return undefined/[] when none.
* Used by the resolver to link `expect` declarations to their `actual`
* implementations across source sets.
*/
extractModifiers?: (node: SyntaxNode) => string[] | undefined;
// --- New config properties ---
+9
View File
@@ -517,6 +517,15 @@ export class TreeSitterExtractor {
...extra,
};
// Persist extra symbol-level modifiers (e.g. Kotlin `expect`/`actual`) onto
// the node's decorators list so the resolver can pair multiplatform
// declarations with their implementations. Merged, not overwritten, so a
// language that also captures real annotations keeps both.
const mods = this.extractor?.extractModifiers?.(node);
if (mods && mods.length > 0) {
newNode.decorators = [...(newNode.decorators ?? []), ...mods];
}
this.nodes.push(newNode);
// Add containment edge from parent
+68
View File
@@ -506,6 +506,72 @@ function goImplementsEdges(queries: QueryBuilder): Edge[] {
return edges;
}
/**
* Kotlin Multiplatform `expect`/`actual` linking. A `common` source set declares
* `expect fun foo()` / `expect class Bar`; each platform source set (jvm, native,
* js, …) provides an `actual` implementation with the IDENTICAL fully-qualified
* name in a different file. Callers in common code resolve to the `expect`
* declaration, so every `actual` impl ends up with zero dependents — invisible to
* impact/affected even though editing it can break every caller of the API.
*
* Synthesize a `calls` edge from the common declaration to each platform `actual`
* (mirroring the interface-impl bridge: abstract → concrete), so editing a
* platform impl surfaces the common `expect` and its callers, and the impl file
* participates in the graph.
*
* `expect`/`actual` are captured onto the node's `decorators` list at extraction
* (kotlin.ts `extractModifiers`). Members of an `expect class` are NOT themselves
* keyword-marked, so the declaration side is matched as the same-FQN, same-kind
* node that is NOT marked `actual`. Requiring an `actual`-marked counterpart also
* gates out plain cross-file overloads (neither side is marked).
*/
// Kinds that an `expect`/`actual` pair may legitimately straddle. `expect class`
// is routinely fulfilled by an `actual typealias` (e.g. `actual typealias
// CancellationException = …`, `actual typealias SchedulerTask = Task`), so a
// strict kind match would miss those one-line alias files. Same-FQN + the
// `actual` marker already gates out unrelated symbols, so widening to the
// type-like kinds is safe.
const KMP_TYPE_KINDS = new Set(['class', 'interface', 'struct', 'enum', 'type_alias']);
function kmpKindsCompatible(a: string, b: string): boolean {
return a === b || (KMP_TYPE_KINDS.has(a) && KMP_TYPE_KINDS.has(b));
}
function kotlinExpectActualEdges(queries: QueryBuilder): Edge[] {
const edges: Edge[] = [];
const seen = new Set<string>();
const actuals = queries
.getAllNodes()
.filter((n) => n.language === 'kotlin' && !!n.decorators?.includes('actual'));
for (const act of actuals) {
let added = 0;
for (const cand of queries.getNodesByQualifiedNameExact(act.qualifiedName)) {
if (added >= MAX_CALLBACKS_PER_CHANNEL) break;
// The declaration side: same FQN + compatible kind, a different file, NOT
// itself an `actual` (that would be a sibling platform impl, not the decl).
if (cand.language !== 'kotlin' || cand.id === act.id) continue;
if (!kmpKindsCompatible(cand.kind, act.kind) || cand.filePath === act.filePath) continue;
if (cand.decorators?.includes('actual')) continue;
const key = `${cand.id}>${act.id}`;
if (seen.has(key)) continue;
seen.add(key);
edges.push({
source: cand.id,
target: act.id,
kind: 'calls',
line: cand.startLine,
provenance: 'heuristic',
metadata: {
synthesizedBy: 'kotlin-expect-actual',
via: act.name,
registeredAt: `${act.filePath}:${act.startLine}`,
},
});
added++;
}
}
return edges;
}
function interfaceOverrideEdges(queries: QueryBuilder): Edge[] {
const edges: Edge[] = [];
const seen = new Set<string>();
@@ -1266,6 +1332,7 @@ export function synthesizeCallbackEdges(queries: QueryBuilder, ctx: ResolutionCo
const flutterEdges = flutterBuildEdges(queries, ctx);
const cppEdges = cppOverrideEdges(queries);
const ifaceEdges = interfaceOverrideEdges(queries);
const kotlinExpectActual = kotlinExpectActualEdges(queries);
const goGrpcEdges = goGrpcStubImplEdges(queries);
const rnEventEdgesList = rnEventEdges(ctx);
const fabricNativeEdges = fabricNativeImplEdges(ctx);
@@ -1284,6 +1351,7 @@ export function synthesizeCallbackEdges(queries: QueryBuilder, ctx: ResolutionCo
...flutterEdges,
...cppEdges,
...ifaceEdges,
...kotlinExpectActual,
...goGrpcEdges,
...rnEventEdgesList,
...fabricNativeEdges,