Add Actualizer: cross-module expect/actual linking for Kotlin - #1
Merged
Conversation
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
This PR introduces Actualizer, a Kotlin compiler plugin system that enables
expect/actualdeclarations 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 theactualizer { }extension and wires two modes of operation:stubUnfulfilledExpects(): Scans for unfulfilledexpectdeclarations and generates throwing stubs so modules can compile standaloneactualizes(): Merges foreign project source into the current compilation alongside realactualimplementationsCompiler Plugin (
ActualizerCompilerPluginRegistrar): IR and FIR extensions that:actualdeclarations link back toexpectdeclarations from foreign modulesStub Generation
expectdeclarations and generates matchingactualstubs via KotlinPoetbuild/generated/actualizer-stubs/Type System Support
whereclausesout/in/*)Implementation Details
kotlin("jvm")modules never seeexpect/actualunless Actualizer adds them, so any pair is unambiguously Actualizer-managedexpectdeclarations and which modules consume them via IR generationSample Projects
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