Browse Source

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>
Colby McHenry 2 tuần trước cách đây
mục cha
commit
d8a2e91

+ 1 - 0
CHANGELOG.md

@@ -21,6 +21,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
 - Rust impact and `codegraph affected` now connect far more of the module graph. Struct literals (`Widget { n: 1 }`) are recorded as instantiations; a `use` / `pub use` brings its item into the dependency graph — so a `pub use` re-export hub (a `mod.rs` re-exporting its submodules) depends on the modules it re-exports — resolved by Rust module path (`crate::`/`self::`/`super::`), so a re-export of a common name like `read` links to the right module instead of a same-named symbol elsewhere; and trait dispatch reaches implementations — a struct whose methods cover a trait's is treated as implementing it, and a call through `&dyn Trait` resolves to the concrete method. Previously a Rust type linked only when called or used in a type position, so structs built by literal, modules surfaced only through `pub use`, and trait-only implementations looked like they had no dependents. (#584 for Rust traits)
 - Swift property wrappers and attributes are now connected. A `@Argument` / `@Published` / `@State` / custom `@propertyWrapper` on a property — and attributes on types, methods, and functions (`@objc`, `@MainActor`, …) — now record a dependency on the wrapper/attribute type. Previously these were dropped entirely (Swift attributes parse differently from other languages, and stored properties weren't being inspected), so the wrapper type looked unused and the file using it depended on nothing — a big gap for SwiftUI and argument-parser-style code.
 - Java annotations are now connected. Annotation definitions (`@interface Foo`) are indexed as types, and every `@Foo` usage on a class, method, or field is recorded as a dependency on it. Previously neither side was captured — annotation usages were dropped (they live inside the declaration's modifiers) and `@interface` types weren't indexed at all — so annotation-driven code (Spring `@GetMapping`, JPA `@Entity`, Gson `@SerializedName`, …) showed the annotation as having no users and the annotated class as not depending on it.
+- Kotlin Multiplatform `expect`/`actual` declarations are now connected. A platform implementation — `actual fun`, `actual class`, or an `actual typealias` in a `jvm` / `native` / `js` / `wasm` source set — is linked to the common `expect` declaration it fulfills (including the common case of an `expect class` fulfilled by an `actual typealias`). Previously a caller in common code resolved to the `expect` declaration, so every platform `actual` looked like it had no dependents and editing one showed an empty blast radius; now changing a platform implementation surfaces the common API and everything that uses it. (Kotlin)
 - C# `record` types are now indexed. `record`, `record class`, and `record struct` declarations (everywhere in modern C# — DTOs, value objects, CQRS messages, MediatR notifications) were previously skipped entirely, so every reference, generic type argument (`IEnumerable<MyRecord>`), and `new MyRecord(...)` pointed at nothing and the file defining a record looked like it had no callers or dependents. (#237)
 - Go interfaces now connect to their implementations. Go has no `implements` keyword — a type satisfies an interface just by having the right methods — so CodeGraph now infers that link: a struct whose methods cover an interface's method set is treated as implementing it, and a call through the interface (`API.Marshal(...)`) reaches every concrete implementation. This means a type used only via an interface (the common plugin/strategy pattern — e.g. JSON-codec or renderer implementations selected at runtime) is no longer reported as having no callers or no dependents, and impact now flows from an interface method to the implementations behind it. (#584)
 - Go now records cross-package struct creation. A composite literal like `render.XML{...}` or `pkga.Widget{...}` — including ones registered in a package-level `var registry = map[string]R{...}` — now links to the package that defines the type. Cross-package function calls and type references already resolved; this closes struct instantiation, so a package whose types are only *constructed* elsewhere (a common pattern for interface implementations) is no longer reported as having no dependents. Go type conversions such as `(*Wrapped)(x)` now link to the converted-to type as well.

+ 137 - 0
__tests__/extraction.test.ts

@@ -3141,6 +3141,143 @@ end`;
   });
 });
 
+describe('Kotlin Multiplatform expect/actual', () => {
+  let tempDir: string;
+  let cg: CodeGraph;
+
+  beforeEach(() => {
+    tempDir = createTempDir();
+  });
+
+  afterEach(() => {
+    if (cg) cg.close();
+    if (fs.existsSync(tempDir)) fs.rmSync(tempDir, { recursive: true, force: true });
+  });
+
+  it('links expect declarations to platform actual implementations and surfaces them in impact', async () => {
+    const common = path.join(tempDir, 'src', 'commonMain');
+    const jvm = path.join(tempDir, 'src', 'jvmMain');
+    fs.mkdirSync(common, { recursive: true });
+    fs.mkdirSync(jvm, { recursive: true });
+
+    // common source set: expect declarations + a caller that uses them
+    fs.writeFileSync(
+      path.join(common, 'SystemProps.kt'),
+      `package demo.internal
+
+expect fun systemProp(name: String): String?
+
+expect class Platform {
+    fun describe(): String
+}
+`
+    );
+    fs.writeFileSync(
+      path.join(common, 'Caller.kt'),
+      `package demo
+
+import demo.internal.systemProp
+import demo.internal.Platform
+
+fun useIt(): String {
+    val v = systemProp("os.name")
+    return Platform().describe() + v
+}
+`
+    );
+    // jvm source set: actual implementations
+    fs.writeFileSync(
+      path.join(jvm, 'SystemProps.kt'),
+      `package demo.internal
+
+actual fun systemProp(name: String): String? = System.getProperty(name)
+
+actual class Platform {
+    actual fun describe(): String = "JVM"
+}
+`
+    );
+
+    cg = CodeGraph.initSync(tempDir);
+    await cg.indexAll();
+    cg.resolveReferences();
+
+    // The expect/actual markers are captured onto the node's decorators.
+    const fns = cg.getNodesByKind('function');
+    const actualFn = fns.find(
+      (n) => n.name === 'systemProp' && n.decorators?.includes('actual')
+    );
+    const expectFn = fns.find(
+      (n) => n.name === 'systemProp' && n.decorators?.includes('expect')
+    );
+    expect(actualFn).toBeDefined();
+    expect(expectFn).toBeDefined();
+    expect(actualFn!.filePath).not.toBe(expectFn!.filePath);
+
+    // Editing the JVM actual must surface the common expect AND its caller —
+    // before the expect/actual bridge the actual had zero dependents.
+    const impact = cg.getImpactRadius(actualFn!.id, 3);
+    const impacted = [...impact.nodes.values()].map((n) => n.name);
+    expect(impacted).toContain('systemProp'); // the common expect
+    expect(impacted).toContain('useIt'); // the caller, reached transitively
+
+    // The bridging edge is a heuristic `calls` edge tagged by the synthesizer.
+    const bridge = impact.edges.find(
+      (e) =>
+        e.target === actualFn!.id &&
+        e.provenance === 'heuristic' &&
+        (e.metadata as { synthesizedBy?: string } | undefined)?.synthesizedBy ===
+          'kotlin-expect-actual'
+    );
+    expect(bridge).toBeDefined();
+    expect(bridge!.source).toBe(expectFn!.id);
+  });
+
+  it('links an expect class to an actual typealias (different node kinds)', async () => {
+    const common = path.join(tempDir, 'src', 'commonMain');
+    const jvm = path.join(tempDir, 'src', 'jvmMain');
+    fs.mkdirSync(common, { recursive: true });
+    fs.mkdirSync(jvm, { recursive: true });
+
+    fs.writeFileSync(
+      path.join(common, 'Lock.kt'),
+      `package demo
+
+expect class Lock {
+    fun acquire()
+}
+`
+    );
+    fs.writeFileSync(
+      path.join(jvm, 'Lock.kt'),
+      `package demo
+
+actual typealias Lock = java.util.concurrent.locks.ReentrantLock
+`
+    );
+
+    cg = CodeGraph.initSync(tempDir);
+    await cg.indexAll();
+    cg.resolveReferences();
+
+    const aliasNode = cg
+      .getNodesByKind('type_alias')
+      .find((n) => n.name === 'Lock' && n.decorators?.includes('actual'));
+    expect(aliasNode).toBeDefined();
+
+    // The actual typealias is now a cross-file dependency target (linked from
+    // the expect class), so it participates in impact rather than being orphaned.
+    const impact = cg.getImpactRadius(aliasNode!.id, 3);
+    const bridge = impact.edges.find(
+      (e) =>
+        e.target === aliasNode!.id &&
+        (e.metadata as { synthesizedBy?: string } | undefined)?.synthesizedBy ===
+          'kotlin-expect-actual'
+    );
+    expect(bridge).toBeDefined();
+  });
+});
+
 describe('Full Indexing', () => {
   let tempDir: string;
 

+ 23 - 0
src/extraction/languages/kotlin.ts

@@ -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 - 0
src/extraction/tree-sitter-types.ts

@@ -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 - 0
src/extraction/tree-sitter.ts

@@ -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 - 0
src/resolution/callback-synthesizer.ts

@@ -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,