Skip to content

Add Actualizer: cross-module expect/actual linking for Kotlin - #1

Merged
KP2048 merged 19 commits into
masterfrom
claude/kotlin-ir-kmp-expect-actual-32smjb
Aug 6, 2026
Merged

Add Actualizer: cross-module expect/actual linking for Kotlin#1
KP2048 merged 19 commits into
masterfrom
claude/kotlin-ir-kmp-expect-actual-32smjb

Conversation

@KP2048

@KP2048 KP2048 commented Jul 28, 2026

Copy link
Copy Markdown
Member

Summary

This PR introduces Actualizer, a Kotlin compiler plugin system that enables expect/actual declarations to be resolved across independent Gradle modules without using the Kotlin Multiplatform Plugin. This is particularly useful for multiloader projects (like Minecraft mods using Architectury) where modules need to be compiled against different classpaths independently.

Key Changes

Core Plugin Infrastructure

  • Gradle Plugin (ActualizerGradlePlugin): Registers the actualizer { } extension and wires two modes of operation:

    • stubUnfulfilledExpects(): Scans for unfulfilled expect declarations and generates throwing stubs so modules can compile standalone
    • actualizes(): Merges foreign project source into the current compilation alongside real actual implementations
  • Compiler Plugin (ActualizerCompilerPluginRegistrar): IR and FIR extensions that:

    • Report which actual declarations link back to expect declarations from foreign modules
    • Suppress multiplatform-related diagnostics that would otherwise fail in non-KMP modules
    • Attach module provenance information to linked declarations

Stub Generation

  • ExpectStubGenerator: Parses Kotlin source files using real PSI (not regex) to find expect declarations and generates matching actual stubs via KotlinPoet
    • Handles complex cases: nested function types, type parameters with bounds, variance modifiers, secondary constructors, nested classes, enum entries
    • Runs in an isolated worker classloader to avoid classpath conflicts
    • Generates stubs into build/generated/actualizer-stubs/

Type System Support

  • Full support for Kotlin's type system including:
    • Generic type parameters with bounds and where clauses
    • Variance modifiers (out/in/*)
    • Nullable types
    • Function types (including nested)
    • Supertypes and secondary constructors

Implementation Details

  • No annotations required: Plain kotlin("jvm") modules never see expect/actual unless Actualizer adds them, so any pair is unambiguously Actualizer-managed
  • Syntax-only parsing: Uses KtPsiFactory for correct handling of multi-line declarations and odd formatting without requiring semantic resolution
  • Isolated worker execution: Stub generation runs in a separate classloader to prevent dependency conflicts between the plugin and consuming projects
  • Module-aware linking: Tracks which modules own expect declarations and which modules consume them via IR generation

Sample Projects

  • sample/: Plain JVM demonstration showing expect/actual across three modules (api, feature-common, actual-jvm)
  • sample-architectury/: Real-world Fabric + NeoForge multiloader Minecraft mod example using Architectury Loom, demonstrating the original motivation for this plugin

Scope

Out of scope: annotations on declarations, contracts, and destructuring in parameters. These are not part of what the actual-checker compares between expect and actual.

https://claude.ai/code/session_01Ba6jHfo9TXnsFyUxkA3H1s

claude and others added 19 commits July 25, 2026 02:56
Lets an expect declaration in one Gradle module be actualized by a real
actual in a genuinely separate, independently built Gradle module - not
part of the same multiplatform source-set hierarchy - by merging the
foreign module's source files into the actual-providing leaf module's
compilation (-Xmulti-platform + -Xcommon-sources) so the real Kotlin
frontend performs the linking, then running an IrGenerationExtension on
that merged compilation to report cross-module provenance for every
@CrossModuleExpect-annotated link.

Includes a composite-build compiler plugin + Gradle plugin
(actualizer { actualizes(project(...)) } DSL) and a four-module sample
(api / feature-common / actual-jvm / app) that was built and run
end-to-end, including the negative path where a missing actual fails
the build at compile time.
…soft

Updates the Maven group (net.kernelpanicsoft), the Gradle/compiler plugin
id (net.kernelpanicsoft.actualizer), and all package declarations across
the plugin modules and sample project to match. Verified with a full
clean rebuild of the whole graph, including :sample:app:run.
The "commonMain gets its own standalone artifact" requirement no longer
relies on Kotlin Multiplatform's metadata-compilation trick (which
needed a second, unused js(IR) target purely to trigger it, pulling in
Node/npm/Yarn on any full build). Instead :sample:api and
actualizer-annotations are now plain kotlin("jvm") modules; the expect
declaration lives in a dedicated, non-default "crossModuleApi" source
set that :api itself never compiles (it isn't wired into
assemble/check/build), so its ordinary main source set produces a real,
completely normal JVM jar with real bytecode - no metadata/klib format.

ActualizerExtension.actualizes() now takes an optional source set name
(defaulting to "crossModuleApi") instead of assuming a multiplatform
commonMain source set, and the Gradle plugin merges that named source
set's directory the same way as before.

Verified with a full `./gradlew build` at the repo root: builds the
whole graph with zero JS/Node/npm/Yarn tasks, and :sample:app:run still
prints the cross-module-linked value. Negative path (missing actual)
still fails the build with the expected frontend error.
:api's crossModuleApi now also has a plain (non-expect) greet() that
delegates to greetingSuffix(). :sample:feature-common gets its own
deferred crossModuleApi source set with welcomeMessage(), which calls
:api's greet() - the same never-compiled-locally convention as :api.
:sample:actual-jvm's actualizer{} block now merges both modules'
crossModuleApi sources (actualizes() already supported multiple calls,
no plugin code changes needed), composing the whole chain into one jar
that :app consumes normally.

Along the way, fixed a same-package/same-filename collision: :api's
main and crossModuleApi source sets both had a Greeting.kt, producing
colliding GreetingKt facade classes that silently shadowed each other
on the runtime classpath (NoSuchMethodError). Renamed the crossModuleApi
file to ExpectApi.kt and documented the gotcha in the README.

Verified end-to-end: :sample:app:run prints "[feature-common] Hello,
world! (actualized independently by :sample:actual-jvm)", full clean
build stays JS/Node-free, and the missing-actual negative path still
fails at compile time.
Adds actualizer { stubUnfulfilledExpects() }: scans a module's main
source set for `expect fun` declarations (a deliberately scoped regex
scanner, see ExpectStubGenerator.kt) and generates a matching, throwing
`actual` stub for each, so a module with an unfulfilled expect compiles
into one completely normal, standalone jar instead of needing a
separate never-compiled source set. New actualizer-runtime module holds
ActualizerNotLinkedError, thrown if the stub is ever actually called.

:sample:api and :sample:feature-common go back to a single ordinary
"main" source set - no more crossModuleApi. :sample:actual-jvm's
actualizes() now merges each foreign project's "main" (the new
default), explicitly excluding anything under that project's own
build/ directory so its generated stub never gets merged in alongside
the real actual. actualizer plugin is no longer needed on
:feature-common at all - it's just a normal kotlin("jvm") module now.

This only simplifies module layout; the underlying constraint is
unchanged and documented: a stub baked into a standalone jar's bytecode
can never be "re-linked" to the real implementation later, so real,
working access still only exists wherever a leaf module performs the
actualizes() merge, same as before.

Verified: full clean build, :sample:app:run prints the correct chain,
a throwaway consumer pointed only at :api's jar gets
ActualizerNotLinkedError as designed, and the missing-actual negative
path still fails at compile time.
Adds a "Motivation" section explaining the real inspiration for this
plugin's design (Architectury-style multiloader Minecraft mods, where
each loader has its own Gradle plugin and independently-configured
classpath, so a single KMP module's "compile once" model doesn't fit),
and why Actualizer's approach - adding source/flags to an existing
module's own compileKotlin task rather than building a synthetic shared
compilation - is structurally suited to that. Also notes the inherent
limit: merged source still has to be valid against whatever classpath
the merging module provides, so environment mismatches (e.g. mismatched
remapping/mappings between a published common artifact and a consumer)
aren't something Actualizer can paper over.

No code changes; sample and mechanism stay generic as decided.
Adds a new actualizes(coordinate: String, dependsOnTasks: List<Any> =
emptyList()) overload: resolves that Maven coordinate's `sources`
classifier artifact (a plain -sources.jar, matching how most published
libraries - including Minecraft mods - actually publish sources) and
merges the extracted .kt files in exactly the same way as a project()
reference, so a leaf module can actualize expects declared in a library
it only has as a binary + sources dependency.

The resolution is deliberately lazy (a plain task with resolution
happening inside doLast, not a Sync task's from(), which Gradle would
otherwise try to eagerly resolve during task-graph construction long
before any publish task has run) - dependsOnTasks lets callers name
whatever needs to run first when the coordinate isn't guaranteed to
already be published (Gradle can't infer that automatically for an
arbitrary external coordinate the way it can for project() deps).

Sample: :sample:api now also applies maven-publish and defines a
hand-scoped sourcesJar task (from("src/main/kotlin") only - NOT the
default withSourcesJar(), which would also archive the generated stub
directory stubUnfulfilledExpects() added and break the merge with a
duplicate actual). New :sample:actual-jvm-published and
:sample:app-published demonstrate actualizing net.kernelpanicsoft.sample:api:0.1.0
as a published coordinate end-to-end.

Verified: full clean `gradle build` publishes, unpacks, and merges
correctly in one shot; :sample:app-published:run prints the real,
actualized value; report.json shows the Maven coordinate as owning
module.
ExpectStubGenerator.kt now scans for expect val/var (properties) and
expect class (via brace-depth-matched body scanning, flat member list)
in addition to the existing expect fun support, and generates the stub
output via KotlinPoet (FileSpec/FunSpec/PropertySpec/TypeSpec) instead
of hand-rolled string concatenation.

Fixed two real bugs found along the way:
- ClassName.bestGuess("String") produces an unqualified ClassName that
  KotlinPoet then tries to `import String` for - not valid Kotlin.
  Added a small lookup table mapping common kotlin/kotlin.collections
  names to their real qualified ClassName so they resolve correctly
  (and get correctly omitted from the import list); documented as a
  known gap for arbitrary unqualified custom types.
- Kotlin requires an actual class's primary constructor to be marked
  `actual` explicitly too, not just the class - both the generated
  stub and this repo's own hand-written sample actuals needed
  `actual constructor(...)`.

Sample: :sample:api gained expect val platformName and expect class
GreetingCounter(start: Int) { fun next(): Int } alongside the existing
expect fun, with matching actuals in both :sample:actual-jvm and
:sample:actual-jvm-published, exercised end-to-end via :app/:app-published.

Verified: full clean build succeeds across the whole graph; both apps
print correct output for all three expect kinds (fun/val/class); the
missing-actual negative path still fails at compile time; no JS/Node
tooling anywhere.
ExpectStubGenerator previously found expect declarations with a line-based
regex plus manual brace-depth counting to detect the end of an expect class
body. That's fragile: a `}` inside a comment or string literal, or a
declaration split across multiple lines, can throw the depth counter off
and corrupt what gets scanned into the generated stub.

Replace it with genuine Kotlin PSI parsing via KotlinCoreEnvironment /
KtPsiFactory (kotlin-compiler-embeddable, syntax-only - no semantic
resolution, so it doesn't choke on the very unfulfilled expects being
scanned for). Expect functions, properties, and classes are now found by
walking real KtNamedFunction/KtProperty/KtClass declarations, and class
members come from the real KtClassBody rather than a manual scan.

Verified against an adversarial case (an expect class containing a comment
with a stray `}`) that would have broken the old brace-counter; the
generated stub correctly captured the class's member and kept a subsequent
top-level expect fun separate. Full clean build (34/34 tasks) and both
sample apps (:sample:app:run, :sample:app-published:run) still pass, with
no JS/Node/npm/Yarn tasks anywhere in the build.

Updated README's "How it actually works" and "Known limitations" sections
to describe PSI-based scanning instead of the old regex/brace-counting
description.
…e positives

A plain kotlin("jvm") module never sees expect/actual at all unless Actualizer
put them there via -Xmulti-platform, so every pair the IR plugin sees is
already a genuine Actualizer-managed cross-module pair by construction - the
annotation added ceremony without disambiguating anything real. The IR
extension now correlates actuals to their owning foreign module by package
match instead of by annotation, and the stub generator no longer emits it.
Deletes the now-unused actualizer-annotations module entirely.

Also adds the real Architectury (Fabric + NeoForge) multiloader sample as a
separate Gradle build (sample-architectury/), the actual motivating use case
for this plugin, and mitigates the IDE-only false positives
(EXPECT_AND_ACTUAL_IN_THE_SAME_MODULE, ACTUAL_WITHOUT_EXPECT - confirmed via
FirErrors bytecode inspection) that show up because the IDE's live analysis
doesn't apply -Xmulti-platform: generated stubs carry @Suppress
automatically, hand-written expect/actual files get a documented
@file:Suppress convention. No stable FIR/K2 API exists to suppress a
built-in diagnostic from a compiler plugin, so @Suppress is the mechanism
that actually works in both the compiler and the IDE.

Fixes a real latent bug found along the way: stub generation ran eagerly at
Gradle configuration time, so `gradle clean build` in one invocation wiped
the generated stubs (via clean's execution) before compileKotlin ran.
Generation is now a proper Gradle task with real inputs/outputs.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ba6jHfo9TXnsFyUxkA3H1s
A third instance of the IDE-only expect/actual false positive: since the
IDE's live analysis doesn't collapse an expect/actual pair into one logical
declaration, any call site in the same module (e.g. Greeting.kt's greet()
calling its own expect fun greetingSuffix()) sees two same-signature
candidates and reports the call itself as ambiguous. Confirmed the exact
diagnostic name (OVERLOAD_RESOLUTION_AMBIGUITY) the same way as the other
two, via FirErrors bytecode inspection.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ba6jHfo9TXnsFyUxkA3H1s
Replaces the manual @file:Suppress convention with an automatic one: a
FirStatusTransformerExtension injects a synthetic @Suppress FIR annotation
(constructed by hand from FIR builders, not text) onto every top-level
declaration in any file containing an expect or actual, covering both the
declarations themselves and plain functions that merely call one (since
OVERLOAD_RESOLUTION_AMBIGUITY fires at the call site). No sample file needs
a literal @Suppress/@file:Suppress any more.

Verified empirically, not just by inspecting bytecode: temporarily injecting
"DEPRECATION" made an unrelated @deprecated call's warning disappear from an
actual fun's body and from a plain sibling function in the same file, with
zero @Suppress anywhere in source - confirming the mechanism actually works,
not just that it doesn't crash. Also confirmed (empirically, this time
correctly) that the K1 DiagnosticSuppressor SPI is never consulted by K2 -
a @deprecated warning survived it untouched even when registered to
unconditionally suppress everything.

This leans on internal, undocumented FIR builder APIs with no compatibility
guarantee across Kotlin versions, documented as such - the manual
@file:Suppress convention this replaces still works identically as a
fallback if a future Kotlin version breaks it.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ba6jHfo9TXnsFyUxkA3H1s
…tension

- Renamed the plugin-build group to net.kernelpanicsoft.actualizer (was
  net.kernelpanicsoft) and updated the compiler-plugin coordinate the Gradle
  plugin resolves for -Xplugin= to match.
- Added real maven-publish wiring for compiler-plugin and gradle-plugin,
  targeting a Reposilite instance (releases/snapshots split by version
  suffix), with credentials from a gitignored local.properties or
  REPOSILITE_USERNAME/REPOSILITE_PASSWORD env vars. This is what a real
  external consumer (not linked in via includeBuild) needs to resolve the
  plugin at all.
- Rewrote doc comments and both READMEs throughout for tone, and added
  KDoc to the plugin's public API surface (ActualizerExtension,
  ActualizerGradlePlugin, ActualizerCompilerPluginRegistrar,
  ActualizerIrExtension, ActualizerFirExtensions, ExpectStubGenerator).
- Hardened the FIR-based IDE-suppression extension against Kotlin version
  mismatches: a real consumer can easily end up running this plugin against
  a different compiler version than it was built against (the IDE's own
  bundled K2 compiler, or a Minecraft mod pinning a different Kotlin
  version). A binary-incompatible internal FIR API now degrades to "no
  automatic suppression" - caught as Throwable and latched off for the rest
  of the compiler process - instead of breaking a real compile.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ba6jHfo9TXnsFyUxkA3H1s
…hitectury

- Bump Kotlin from 2.0.21 to 2.4.10 across all builds (root, sample-architectury,
  plugin-build's two modules), fixing the resulting internal-API breakage:
  CompilerPluginRegistrar now requires an explicit pluginId, FirSimpleFunction was
  renamed to FirNamedFunction, constructClassLikeType/type were replaced with
  ClassId.createConeType/coneType, and KotlinCoreEnvironment/CompilerConfiguration
  construction now needs explicit opt-ins.
- Add ERROR_SUPPRESSION and NOT_A_MULTIPLATFORM_COMPILATION to the generated stubs'
  @Suppress list, matching the FIR extension's suppression set.
- Fix a real classloader split (IllegalAccessError on Disposer/ObjectTree/ObjectNode)
  between this plugin's own kotlin-compiler-embeddable dependency and the Kotlin
  Gradle Plugin's own build-tools-API machinery in the same daemon process, by
  running stub generation in an isolated worker classloader via WorkerExecutor.
- Add sample-architectury as a root-level includeBuild, reachable via explicit task
  paths for IDE navigation without being pulled into the default `gradle build`.
…llision

gradle-plugin declared kotlin-compiler-embeddable/kotlinpoet as implementation
dependencies, so java-gradle-plugin exposed them on the same plugin classpath a
consuming project's own kotlin("jvm") plugin loads into. kotlin-compiler-
embeddable bundles its own copy of classes like GradleBuildPerformanceMetric
that Kotlin Gradle Plugin's build-reporting code also uses, and the wrong one
won, producing a NoSuchMethodError on GradleBuildPerformanceMetric.values()
inside DefaultKotlinBasePlugin.apply - breaking kotlin("jvm") itself for any
project that also applies the actualizer plugin.

Both dependencies are only ever used at runtime inside GenerateStubsWorkAction,
which already resolves its own fresh copies onto an isolated worker classpath.
Switching them to compileOnly keeps them off the shared plugin classpath while
still letting ExpectStubGenerator.kt/GenerateStubsWorkAction.kt compile.
…enerics

The generated stub actuals had several real, confirmed-via-compile bugs:
variance (out/in/*) and star projections on type arguments were silently
dropped or flattened, qualified nested types like HolderLookup.Provider
resolved to nothing (missing import in the generated file), a generic
function's own <T> type parameter was dropped entirely, and secondary
constructors/supertypes/nested expect classes weren't handled at all.

Also fixes a real class-collision-adjacent bug in the FIR suppression
extension: ActualizerSuppressionStatusTransformer.inject wasn't idempotent,
so a declaration status-transformed more than once (which K2 does for
generic classes) got a second @Suppress annotation appended, and the real
compiler's "repeated annotation" checker crashes trying to report that
because the synthetic annotation has no PSI source. Now tracked by
declaration identity so each one is only injected once.

Adds full support for expect interfaces, fun interfaces, and enum classes
(with entries and constructor args), class modality (open/abstract/sealed),
where-clause bounds, and constructor-property parameters - each verified by
actually compiling the generated stub, not just inspecting it. expect data
classes and expect classes initializing a real superclass constructor are
both confirmed hard Kotlin-language restrictions (not gaps in this plugin),
so the constructor/superclass-call handling here is deliberately scoped to
match what's actually expressible in valid expect-class syntax.

Also wires the compiler plugin's FIR extension into stubUnfulfilledExpects()
(previously only actualizes() got it), so the manual @Suppress this file
used to add to every generated declaration is no longer needed - the FIR
extension now covers this file the same way it covers hand-written
expect/actual code.
…dy is

IrGenerationExtension.registerExtension was the one call in
ActualizerCompilerPluginRegistrar not wrapped in the Throwable-catching
fail-safe the FIR extension registration already has, even though it
reaches into the same kind of internal, version-specific compiler
machinery. A real user compiling through an out-of-process Kotlin daemon
on a different Kotlin version than 2.4.10 hit exactly that: a
NoClassDefFoundError on org.jetbrains.kotlin.extensions.ExtensionPointDescriptor
took down their entire build.

ActualizerIrExtension only observes expect/actual linking the frontend
already resolved, to write an optional JSON report and log lines - losing
it should never break a real compile, matching how the FIR extension's own
failure is already handled.
…@Suppress

Two real crashes from an external consumer's build, both class-loading /
annotation-repetition issues in the compiler-plugin's internal-API usage:

1. IrGenerationExtension.registerExtension threw NoClassDefFoundError on
   org.jetbrains.kotlin.extensions.ExtensionPointDescriptor on a Kotlin
   daemon running a different Kotlin version than this plugin compiles
   against. Decompiling confirmed FirExtensionRegistrarAdapter's own
   registration goes through the exact same ExtensionPointDescriptor
   superclass, so a different compiler-extension type wouldn't have been
   any more stable - but ActualizerIrExtension never actually generated or
   transformed IR, only read file paths/packages/names off it to write an
   optional JSON report. That information is available directly from
   source text, so there was never a real need to run inside the compiler
   for it at all. Removed the IR extension entirely; the same
   package-name-heuristic report is now computed in the Gradle plugin via
   plain PSI scanning (ActualizerLinkReport.kt), in its own isolated worker
   classloader like stub generation already uses, so it can't be broken by
   any Kotlin compiler/daemon version.

2. A declaration that already had its own hand-written @Suppress (e.g.
   `@Suppress("unused") actual object Foo`) crashed when the FIR extension
   added a second one alongside it: the real compiler's "repeated
   annotation" checker treats two @Suppress on one declaration as an error
   regardless of what either suppresses, and then crashes trying to report
   that diagnostic because our synthetic annotation has no PSI source.
   Fixed by merging into the existing annotation's argument list in place
   (mergeSuppressNames) instead of attaching a second one, so a declaration
   only ever ends up with the one @Suppress it already had, just covering
   more names when needed.

Also simplifies ActualizerCommandLineProcessor/ActualizerCompilerPluginRegistrar
now that moduleMap/selfModule/reportOutput are no longer needed by anything
in the compiler plugin.
The gradle-plugin's addSourceDir() was adding generated-stub and
unpacked-published-source directories to a source set without wiring
their producing tasks onto the resulting FileCollection. Consumers
that read straight off the source set (e.g. sourcesJar from
withSourcesJar()) rather than through compileKotlin's dependsOn could
run before those directories were populated. addSourceDir() now
accepts a builtBy list and wires it via project.files(dirs).builtBy(...).

actualizerUnpackXSources also needed task.inputs.files(configuration)
so Gradle infers build dependencies for composite-build coordinates
that dependencySubstitution maps onto a sibling included build's
project instead of a real published artifact.

Also removes two commented-out (dead) warning log calls left over
from local debugging, and checks in the Gradle wrapper (jar, scripts,
properties) so the project is buildable without a pre-existing local
Gradle install.
Copilot AI lite review requested due to automatic review settings August 6, 2026 18:04
@KP2048
KP2048 merged commit a6f7702 into master Aug 6, 2026
0 of 2 checks passed
@KP2048
KP2048 removed the request for review from Copilot August 6, 2026 18:38
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants