diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..9189ca8
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,10 @@
+.gradle/
+build/
+plugin-build/.gradle/
+plugin-build/*/build/
+.kotlin/
+*.iml
+.idea/
+out/
+kotlin-js-store/
+plugin-build/local.properties
diff --git a/.idea/misc.xml b/.idea/misc.xml
index a19c222..4fb4a07 100644
--- a/.idea/misc.xml
+++ b/.idea/misc.xml
@@ -14,7 +14,11 @@
-
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/modules.xml b/.idea/modules.xml
index 580eebd..4ee82df 100644
--- a/.idea/modules.xml
+++ b/.idea/modules.xml
@@ -2,7 +2,13 @@
-
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..271eb2a
--- /dev/null
+++ b/README.md
@@ -0,0 +1,448 @@
+# Actualizer
+
+A Kotlin IR compiler plugin (plus a Gradle plugin to drive it) that lets an `expect` declaration in
+one Gradle module be actualized by a real `actual` declaration in a separate, independently built
+Gradle module - one that isn't wired into the same multiplatform source-set hierarchy. Every module
+involved is a plain `kotlin("jvm")` project; nothing here uses the Kotlin Multiplatform plugin or
+any non-JVM target.
+
+```
+:sample:api expect fun/val/class (greetingSuffix, platformName, GreetingCounter), plus
+ ordinary code that calls them - one normal source set, one standalone jar
+:sample:feature-common depends on :api's plain jar; welcomeMessage() calls :api's greet()
+:sample:actual-jvm actual fun/val/class for all three - merges both :api's and
+ :feature-common's hand-written source in, unrelated Gradle module to either
+:sample:app ordinary dependency on :sample:actual-jvm (gets the real, linked chain)
+```
+
+`:sample:actual-jvm` never declares a dependency on `:sample:api` or `:sample:feature-common`.
+`:sample:app` never even knows either of them exists. Running `:sample:app:run` prints:
+
+```
+[feature-common] Hello, world! (actualized independently by :sample:actual-jvm)
+```
+
+All published modules (`compiler-plugin`, `gradle-plugin`) use the `net.kernelpanicsoft.actualizer`
+Maven group and package namespace.
+
+There's a second, real-world sample build in `sample-architectury/` - an Architectury (Fabric +
+NeoForge) Minecraft multiloader project, the actual motivating case for this mechanism (see
+"Motivation" below). It's a separate Gradle build with its own settings, proving this works across
+two independently configured, non-KMP mod-loader toolchains rather than just the plain-JVM sample
+above. See `sample-architectury/README.md`.
+
+No annotation is required on `expect`/`actual` declarations. A plain `kotlin("jvm")` module never
+sees `expect`/`actual` at all unless Actualizer put them there via `-Xmulti-platform`, so any pair
+Actualizer's IR plugin sees is already an Actualizer-managed cross-module pair by construction. An
+earlier version of this plugin used a `@CrossModuleExpect` marker annotation, but it only added
+ceremony - the ambiguity it was meant to resolve (accidental expect/actual usage) can't happen in
+a non-multiplatform module.
+
+## Motivation: multiloader-style projects
+
+The concrete inspiration is multiloader project layouts like Architectury-based Minecraft mods,
+where the same mod builds against several mod loaders (Fabric, NeoForge, ...) at once. Each loader
+brings its own Gradle plugin, its own loader API, and its own independently configured compile
+classpath - in Minecraft's case, the game jar remapped through that loader's own mapping pipeline.
+A single Kotlin Multiplatform module doesn't fit this shape: KMP's contract is "`commonMain`
+compiles once, shared everywhere," but here the common code often has to be recompiled per loader
+against a genuinely different classpath, not just a different platform API surface the way
+JVM/JS/Native differ.
+
+That's why this repo doesn't use the Kotlin Multiplatform plugin anywhere, and why Actualizer works
+the way it does: it never builds a synthetic shared compilation. It only adds source directories
+and compiler flags to a module's own, already-fully-configured `compileKotlin` task (see part 2
+below), so whatever loader-specific plugin runs first gets to finish configuring the module's
+classpath, and Actualizer's `afterEvaluate` wiring layers on top afterward. Common source ends up
+compiled against whatever classpath the leaf module already has, without Actualizer needing to
+know what's on it. The sample in this repo is plain JVM with no game jars or remapping, to keep
+the mechanism easy to follow, but it applies equally to a module whose classpath comes from Loom,
+NeoGradle, or any other plugin that shapes dependencies before compilation.
+
+One consequence worth calling out: this only works if the merged-in source is actually valid
+against whatever classpath the merging module provides. If a common artifact's source (hand-written,
+or unpacked from a sources jar - see part 5 below) was written against a different environment than
+the consumer's, merging the raw text doesn't reconcile that difference - it just fails to resolve,
+the same as if you'd hand-written mismatched code yourself.
+
+## Why this needs a plugin at all
+
+Stock Kotlin only resolves `expect`/`actual` within a single compiler invocation that's been told
+which of its input files are common and which are platform - wiring normally generated by the
+Kotlin Gradle Plugin from inside one `kotlin { }` block, with no notion of doing this across
+unrelated Gradle projects.
+
+There's no way to patch this after the fact: once `:sample:api` is compiled to bytecode, nothing
+running in another module's compilation can reach back and rewrite already-emitted `.class` files
+to redirect a call. So literal `expect`/`actual` across Gradle modules only works if the modules'
+source files end up merged into one compiler invocation - there's no alternative that keeps the
+real keyword.
+
+That also means a module compiled before any real `actual` exists can never call the real,
+eventually-linked implementation through its own already-compiled jar, only through source merged
+downstream. An auto-generated stub (below) lets an unfulfilled `expect` compile standalone, but
+calling it through that standalone jar always throws - the real chain only exists wherever a leaf
+module performs the merge.
+
+## How it actually works
+
+**1. The "common" module is an ordinary JVM jar, via an auto-generated stub, not runtime dispatch.**
+`:sample:api` is a plain `kotlin("jvm")` module with `id("net.kernelpanicsoft.actualizer")` and
+`actualizer { stubUnfulfilledExpects() }`. Its `expect fun greetingSuffix()`, `expect val
+platformName`, and `expect class GreetingCounter(start: Int) { fun next(): Int }` live directly in
+the ordinary `main` source set, next to regular code (`formatGreeting`, `greet()`,
+`describePlatform()`). Before compiling, the Actualizer Gradle plugin scans `main`'s `.kt` files for
+`expect` declarations using real Kotlin PSI parsing (see "Known limitations") and generates a
+matching, throwing `actual` stub for each into `build/generated/actualizer-stubs/...`
+(`ExpectStubGenerator.kt`, built on [KotlinPoet](https://square.github.io/kotlinpoet/) rather than
+hand-rolled string concatenation), e.g.:
+
+```kotlin
+actual fun greetingSuffix(): String = throw IllegalStateException("net.kernelpanicsoft.sample.api.greetingSuffix was never actualized - ...")
+
+actual val platformName: String
+ get() = throw IllegalStateException("net.kernelpanicsoft.sample.api.platformName was never actualized - ...")
+
+actual class GreetingCounter actual constructor(start: Int) {
+ init { throw IllegalStateException("net.kernelpanicsoft.sample.api.GreetingCounter was never actualized - ...") }
+ actual fun next(): Int = throw IllegalStateException("net.kernelpanicsoft.sample.api.GreetingCounter.next was never actualized - ...")
+}
+```
+
+Each generated declaration also carries `@Suppress("EXPECT_AND_ACTUAL_IN_THE_SAME_MODULE",
+"ACTUAL_WITHOUT_EXPECT")` - see "IDE false positives" below. The class stub throws immediately in
+its `init` block, so construction fails fast rather than relying on every member throwing
+individually, though the member stubs still exist so the actual class structurally matches the
+expect class. That generated file is added as an extra `main` source directory, and
+`-Xmulti-platform` + `-Xcommon-sources=` are set on
+`compileKotlin` - the same source-merging mechanism used everywhere else in this repo (part 2),
+just with a machine-written `actual` instead of a hand-written one. The real Kotlin frontend links
+the expects against the stub actuals and compiles successfully, so `./gradlew :sample:api:build`
+produces one normal jar - real bytecode, no metadata/klib format, no split source sets.
+`:sample:feature-common` depends on that jar with a plain `implementation(project(":sample:api"))`
+and calls `greet()` normally - it's calling an ordinary function that happens to call an expect, so
+it compiles and links fine. Calling it through this standalone chain throws
+`IllegalStateException` at runtime, since it's bound to the stub - part 2 covers how the real chain
+gets built instead.
+
+**2. The actual linking happens in the leaf module, via source merging of multiple foreign modules
+at once.**
+`:sample:actual-jvm` is also a plain `kotlin("jvm")` module with `id("net.kernelpanicsoft.actualizer")`
+applied and:
+
+```kotlin
+actualizer {
+ actualizes(project(":sample:api")) // greetingSuffix, formatGreeting, greet
+ actualizes(project(":sample:feature-common")) // welcomeMessage
+}
+```
+
+`actualizes(...)` can be called more than once - the Gradle plugin merges every foreign project's
+`main` source set (their hand-written source only, see below) into the same compiler invocation
+alongside `:actual-jvm`'s own real `actual`. That's how `:feature-common`'s `welcomeMessage()`
+(which calls `:api`'s `greet()`, which calls the expect) ends up compiled and packaged into one jar
+with the real chain intact - without `:feature-common` or `:api` ever depending on each other, and
+without `:actual-jvm` depending on either of their compiled jars (see "Known limitations" for why
+that has to be avoided). The Gradle plugin pulls each foreign project's hand-written source
+directory - excluding anything under that project's own build directory, so its generated stub
+never gets merged in alongside the real `actual` - into `:actual-jvm`'s own `main` source set, and
+adds two compiler flags to `compileKotlin`:
+
+```
+-Xmulti-platform
+-Xcommon-sources=
+```
+
+This is the same mechanism the Kotlin Gradle Plugin itself uses for JVM multiplatform targets -
+common and platform sources compiled together in one invocation, with `-Xcommon-sources` marking
+which are common. Since it's all one real compiler invocation, the actual Kotlin frontend does the
+expect/actual unification, not this plugin. It does lean on `-Xmulti-platform`/`-Xcommon-sources`,
+which are internal compiler flags not officially supported for this kind of use outside the Kotlin
+Gradle Plugin itself, and could change behavior between Kotlin versions. This repo pins Kotlin
+`2.0.21`, where the mechanism was verified to work as described (see "What was verified" below).
+
+**3. The IR plugin reports on what got linked.**
+`ActualizerIrExtension` (an `IrGenerationExtension`) runs inside that merged compilation. By the
+time it runs the frontend has already resolved (or already failed the build over) every
+expect/actual pair, since IR generation only happens after a successful frontend pass - there's no
+"unlinked" state left to fix. Its job is everything the raw mechanism doesn't give for free:
+
+It attaches module provenance, since the frontend only knows about files, not Gradle modules. The
+plugin walks the merged `IrModuleFragment`, groups the files pulled in from each foreign module by
+package (using a `moduleMap` compiler-plugin option the Gradle plugin builds, mapping each foreign
+source root to its owning Gradle module name), and reports every locally declared top-level
+declaration whose package matches one of those foreign packages as the `actual` linking it - no
+annotation needed, since every expect/actual pair reaching this extension is one by construction.
+It writes a JSON report to `build/actualizer/report.json`:
+
+```json
+{ "consumingModule": ":sample:actual-jvm",
+ "links": [ { "actual": "net.kernelpanicsoft.sample.api.greetingSuffix", "owningModules": [":sample:api"] } ] }
+```
+
+Once expect/actual are resolved, the `expect` declaration itself is elided from IR - only the
+`actual` survives as a real `IrFunction`. That's why the plugin correlates by package instead of
+pairing an "expect IR declaration" with an "actual IR declaration": the expect side doesn't exist
+to pair against by the time `IrGenerationExtension.generate()` runs.
+
+**4. `:sample:app` needs nothing special.** It has an ordinary
+`implementation(project(":sample:actual-jvm"))` dependency and calls `welcomeMessage()` normally.
+It never applies the Actualizer plugin and never references `:sample:api` or
+`:sample:feature-common` directly - it only sees `:actual-jvm`'s single, already-composed jar.
+
+**5. The same mechanism also works against a published library, not just a sibling project.**
+`:sample:actual-jvm-published` actualizes `net.kernelpanicsoft.sample:api:0.1.0` - the same
+`:sample:api` module, but consumed as a Maven coordinate instead of `project(":sample:api")`:
+
+```kotlin
+actualizer {
+ actualizes(
+ "net.kernelpanicsoft.sample:api:0.1.0",
+ dependsOnTasks = listOf(":sample:api:publishMavenPublicationToLocalRepository"),
+ )
+}
+```
+
+Instead of reading a project's source directory directly, this resolves that coordinate's
+`sources` classifier artifact (a plain `-sources.jar`, matching how most published libraries
+actually publish sources), unpacks its `.kt` files into a build-local directory, and merges that in
+the same way as a `project(...)` reference. `dependsOnTasks` exists because Gradle has no way to
+infer "this coordinate isn't published yet, run this task first" the way it does for `project(...)`
+dependencies - here it points at `:sample:api`'s own publish task, since this sample publishes and
+consumes the same library within one build for a reproducible demo. Real usage, where the library
+is published independently well before any consumer builds, wouldn't need `dependsOnTasks` at all.
+
+For the library side, `:sample:api` also applies `maven-publish` and defines its own `sourcesJar`
+task scoped to `from("src/main/kotlin")` - not the `java { withSourcesJar() }` convenience, which
+would also archive the generated stub directory `stubUnfulfilledExpects()` added to `main`,
+breaking the merge on the consumer side (see "Known limitations").
+
+`:sample:app-published` is the same idea as `:sample:app`, depending on
+`:sample:actual-jvm-published` instead - an ordinary binary dependency, no special wiring, printing
+the value produced by the published-and-actualized library.
+
+## What was verified
+
+Everything above was actually built and run in this environment with Gradle 8.14 / Kotlin 2.0.21 /
+JDK 21, not just designed on paper:
+
+- `./gradlew :sample:api:build` - a full, ordinary build succeeds and produces one plain JVM jar
+ (real bytecode, no metadata/klib format) with both the real `formatGreeting`/`greet`/
+ `describePlatform` and the auto-generated throwing stubs for `greetingSuffix`, `platformName`,
+ and `GreetingCounter` (function, property, and class stubs all covered).
+- Calling `greet()` via `:api`'s standalone jar directly (verified with a throwaway consumer
+ project pointed at just that jar) throws `IllegalStateException` with a clear message, as
+ designed.
+- `./gradlew :sample:feature-common:build` - succeeds depending only on `:sample:api`'s plain jar,
+ before any actual exists anywhere.
+- `./gradlew :sample:actual-jvm:build` - merges both `:sample:api`'s and `:sample:feature-common`'s
+ hand-written `main` sources and links them against the one real actual; `build/actualizer/report.json`
+ shows the link with correct module provenance.
+- `./gradlew :sample:app:run` - prints `[feature-common] Hello, world! (actualized independently by
+ :sample:actual-jvm)`, the full chain composed and produced by `:sample:actual-jvm`'s single jar,
+ with zero special wiring in `:app` itself.
+- `./gradlew :sample:actual-jvm-published:build` - publishes `:sample:api` to a local Maven
+ repository, resolves and unpacks its sources jar, and merges it against the real actual in
+ `:sample:actual-jvm-published`; `build/actualizer/report.json` shows the link with the Maven
+ coordinate (not a project path) as the owning module.
+- `./gradlew :sample:app-published:run` - prints `Hello, world! (actualized against the published
+ api:0.1.0 library)`, proving the merge worked against a published-and-resolved artifact, not just
+ files read off a sibling project.
+- `./gradlew build` at the repo root builds the entire graph, including publishing and consuming
+ the sample library, in one shot; the task log has zero JS/Node/npm/Yarn tasks anywhere.
+- Negative path: deleting the `actual` declaration from `:sample:actual-jvm` and re-running
+ `compileKotlin` fails the build with a real Kotlin frontend error pointing at the exact expect
+ declaration (`Expected greetingSuffix has no actual declaration in module -common for
+ JVM`) - a missing cross-module actual is a compile-time failure, not a silent runtime gap.
+- A full clean build of the whole graph reproduces all of the above.
+
+## Repo layout
+
+```
+plugin-build/ composite build (keeps the plugin's own Kotlin version pinned
+ independently of consumers, standard Kotlin-compiler-plugin layout)
+ compiler-plugin/ the IR compiler plugin itself (CommandLineProcessor,
+ CompilerPluginRegistrar, ActualizerIrExtension, FIR extensions)
+ gradle-plugin/ ActualizerGradlePlugin: the actualizer { actualizes(...) /
+ stubUnfulfilledExpects() } DSL, source-directory merging,
+ stub generation (ExpectStubGenerator.kt, using KotlinPoet),
+ -Xcommon-sources/-Xmulti-platform wiring
+sample/
+ api/ plain kotlin("jvm"), one "main" source set; expect fun/val/class +
+ ordinary code side by side; stubUnfulfilledExpects() keeps it
+ standalone-buildable; also publishes itself (maven-publish + a
+ hand-scoped sourcesJar) to a build-local repo for the
+ published-library demo below
+ feature-common/ depends on api's plain jar, nothing special - no actualizer plugin
+ needed on this module at all
+ actual-jvm/ applies the plugin; merges both api's and feature-common's
+ hand-written main sources (via project(...)); the real actual lives here
+ app/ plain binary consumer of actual-jvm, no special wiring
+ actual-jvm-published/ same idea as actual-jvm, but actualizes api's published Maven
+ coordinate instead of a project(...) reference
+ app-published/ plain binary consumer of actual-jvm-published, no special wiring
+sample-architectury/ a separate Gradle build - the real, motivating multiloader use case
+ (Fabric + NeoForge via Architectury Loom); see its own README.md
+```
+
+## IDE false positives
+
+The IDE's live analysis of a plain `kotlin("jvm")` module doesn't apply the `-Xmulti-platform`
+compiler flag that makes this mechanism compile in the first place, so it reports real Kotlin
+diagnostics as squiggles even though the Gradle build is fine. Two fire on the declarations
+themselves:
+
+```
+'public final actual fun greetingSuffix(): String' has no corresponding 'expect' declaration
+greetingSuffix: 'expect' and corresponding 'actual' are declared in the same module.
+```
+
+These are `ACTUAL_WITHOUT_EXPECT` and `EXPECT_AND_ACTUAL_IN_THE_SAME_MODULE`. A third fires at any
+call site within the same module that references the actualized declaration: since the IDE doesn't
+collapse an expect/actual pair into one logical declaration the way real compilation does, it sees
+two candidates with the same signature and reports the call itself as ambiguous:
+
+```
+Overload resolution ambiguity between candidates:
+expect fun greetingSuffix(): String
+actual fun greetingSuffix(): String
+```
+
+That's `OVERLOAD_RESOLUTION_AMBIGUITY`. All three names above were confirmed by inspecting
+`kotlin-compiler-embeddable`'s `FirErrors` directly - a bytecode search for the exact rendered
+message text, then the diagnostic-factory field referencing it. A fourth, `ERROR_SUPPRESSION`, is
+the compiler's own meta-warning about suppressing an error-severity diagnostic (below) - also
+suppressed, so the mechanism doesn't produce visible noise about itself.
+
+There's no stable, documented compiler-plugin API to suppress a built-in diagnostic:
+`FirAdditionalCheckersExtension` only lets a plugin add checkers, and the older, fully general
+`DiagnosticSuppressor` SPI is K1-only (it operates on the old `Diagnostic` type, which the K2/FIR
+pipeline this repo targets doesn't use) - confirmed by registering one and observing it never gets
+called. Suppression is instead read directly off each declaration's own `@Suppress` annotation by
+`AbstractDiagnosticCollector.getDiagnosticsSuppressedForContainer` while the FIR diagnostics
+collector walks the tree - a plain function with no extension point of its own.
+
+But that function is just reading `FirAnnotationContainer.getAnnotations()`, and every FIR
+declaration exposes a real, callable `replaceAnnotations(...)` mutator. So instead of requiring a
+literal `@Suppress`/`@file:Suppress` in your own source, `ActualizerFirExtensions.kt` constructs
+that same annotation by hand - a `FirAnnotation` wrapping `kotlin.Suppress` with the four names
+above as its `String` vararg argument, built from real FIR type/expression builders, not text - and
+attaches it via a `FirStatusTransformerExtension`, the one extension point that both runs early
+enough (during FIR status resolution, well before the diagnostics-collection pass) and hands back
+a live reference to the declaration being resolved. It's scoped per file, not per declaration: for
+every top-level declaration in a file, it looks up that file via
+`FirProvider.getFirCallableContainerFile`/`getFirClassifierContainerFileIfAny` and checks whether
+any declaration in it is `expect` or `isActual`; if so, every top-level declaration in that file
+gets the synthetic annotation. That covers not just the expect/actual declarations themselves but
+ordinary functions like `:sample:api`'s `greet()` that merely call one, since
+`OVERLOAD_RESOLUTION_AMBIGUITY` fires at the call site, which doesn't have to be expect/actual
+itself.
+
+This was verified empirically, not just reasoned about from bytecode: temporarily adding
+`"DEPRECATION"` to the injected name list and calling an unrelated `@Deprecated` function from both
+an `actual fun` and a plain sibling function in the same file - with zero `@Suppress` anywhere in
+source - made the deprecation warning disappear from the build log in both cases. Removing the
+injection (or the name) brought the warning straight back.
+
+A few consequences worth knowing:
+- No `@Suppress`/`@file:Suppress` is required anywhere in this repo's sample code any more - none
+ of `sample/api`, `sample/actual-jvm`, `sample/actual-jvm-published`, or the three
+ `sample-architectury` modules that declare or actualize an expect carry one. The Actualizer
+ compiler plugin does it for them.
+- The compiler still emits its own meta-warning about this (`This code uses error suppression for
+ '...'. [...] the compiler behavior is UNSPECIFIED and WON'T BE PRESERVED`) wherever the synthetic
+ annotation lands. That's expected and harmless - it doesn't fail the build - and it's itself
+ suppressed via `ERROR_SUPPRESSION`, though it would still show up with `--info`/verbose
+ diagnostics output.
+- This leans on internal, undocumented FIR builder APIs (`FirStatusTransformerExtension`,
+ `FirAnnotationBuilder`, `constructClassLikeType`, `FirProvider`) with no compatibility guarantee
+ across Kotlin versions, meaningfully more fragile than the compiler-flag reliance described
+ above. This repo targets Kotlin 2.0.21, but 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 analyzing a project it didn't build itself, or a Minecraft mod whose toolchain pins
+ a different Kotlin version than this sample does. A binary-incompatible internal API there
+ surfaces as `NoSuchMethodError`/`NoSuchFieldError`/`LinkageError`, none of which are `Exception`
+ subtypes. `ActualizerFirExtensions.kt` and its registration in
+ `ActualizerCompilerPluginRegistrar.kt` both catch `Throwable` around every use of these APIs and
+ latch a flag off for the rest of the compiler process on first failure, so a version mismatch
+ degrades to "no automatic suppression" rather than breaking a real compile. If that happens, the
+ fallback is the manual `@file:Suppress` convention this mechanism replaced - it still works
+ identically, just requires writing it out by hand again.
+- This is still purely cosmetic - it only affects what the IDE shows while editing, never what
+ actually compiles or runs. And it only covers declarations Actualizer's own extensions see, i.e.
+ modules with the compiler plugin wired in.
+- Not attempted: the IDE's "KMP gutter icon" (the margin icon for jumping between `expect` and
+ `actual`) comes from genuine Kotlin Multiplatform module structure - `dependsOn` source-set edges
+ the IDE's Gradle importer understands - which a plain `kotlin("jvm")` module doesn't have.
+ Getting it to appear here would need a real IntelliJ plugin, not just a compiler/Gradle plugin,
+ so it's out of scope for this repo.
+
+## Known limitations
+
+- **JVM only.** The `-Xcommon-sources` source-merge trick was only verified for the JVM target.
+ Native/JS multiplatform targets link expect/actual via klib "refines" edges instead, a different
+ and more internal/fragile mechanism this repo doesn't attempt.
+- **Source-only, not binary.** The Gradle plugin needs the foreign module's Kotlin source files,
+ either on disk (`project(...)`) or resolvable as a `sources`-classifier artifact (published
+ coordinates) - it can't actualize against something that only ships compiled klibs/jars with no
+ sources, since `-Xcommon-sources` takes source paths, not bytecode.
+- **A published library's sources jar must be scoped the same way `stubUnfulfilledExpects()`
+ requires for a `project(...)` reference.** It has to contain the library's real, hand-written
+ source only, not a generated stub. The default `java { withSourcesJar() }` convenience archives a
+ source set's full `allSource`, which on a module also using `stubUnfulfilledExpects()` includes
+ the generated stub directory - publishing that would merge the library's own stub `actual` in
+ alongside the real one the leaf module provides, breaking the build with a duplicate-`actual`
+ conflict. Configure a hand-scoped `sourcesJar` task instead (see `:sample:api`'s
+ `build.gradle.kts`).
+- **Resolving a published coordinate is lazy, and callers must order it explicitly.** Unlike a
+ `project(...)` reference, whose files already exist on disk, a published coordinate might not
+ exist yet when the build starts - most obviously when, as in this sample, the same build
+ publishes and consumes it. Gradle has no way to infer "publish this first" for an arbitrary
+ external coordinate, so `actualizes(coordinate, dependsOnTasks = listOf(...))` requires naming
+ the publishing task(s) yourself when that ordering isn't already guaranteed some other way.
+- **Relies on internal compiler flags.** `-Xmulti-platform` and `-Xcommon-sources` aren't a
+ supported public API for third-party use; a future Kotlin release could change or remove this
+ behavior without notice.
+- **The `expect` stub scanner is real PSI parsing, but syntax-only - a deliberately scoped subset,
+ not a full compiler frontend.** `ExpectStubGenerator.kt` parses each file with `KtPsiFactory`
+ (the same parser Kotlin tooling uses) rather than a regex/brace-counting scan, so multi-line
+ declarations and odd formatting are handled correctly - `expect class`/`expect object` members
+ come from the real `KtClassBody`, and `suspend fun` is recognized. Out of scope: generics on the
+ containing function/class, supertypes, secondary constructors, nested types, and
+ constructor-parameter auto-properties (`class Foo(val x: Int)`). It also runs no semantic
+ analysis or type resolution - only enough to find declarations and read their syntax, since
+ resolving types is exactly what would fail on the unfulfilled expects being scanned for. Generated
+ code goes through KotlinPoet (`FileSpec`/`FunSpec`/`PropertySpec`/`TypeSpec`), not string
+ concatenation.
+- **Type text scanned into a stub isn't a resolved type, just a structurally parsed name.**
+ `KtTypeReference.toTypeName` walks the real PSI type tree (`KtUserType`/`KtFunctionType`/
+ `KtNullableType`) rather than re-parsing text, so function types - including nested ones like
+ `() -> () -> Screen`, extension-receiver lambdas like `Int.(String) -> Boolean`, nullable
+ lambdas, and generic type arguments - are all handled correctly. A bare type name is resolved
+ against the file's own `import` directives (skipping star imports, which need semantic
+ resolution to expand), common `kotlin`/`kotlin.collections` names, and finally assumed to be a
+ sibling type in the same package. That last fallback is a guess: a star-imported type, or one
+ genuinely in the default package, resolves to the wrong package and the generated stub won't
+ compile. Write fully-qualified types if that matters.
+- **Classloader isolation between `plugin-build` and the consuming build.** `ActualizerGradlePlugin`
+ avoids importing Kotlin Gradle Plugin types (`KotlinJvmProjectExtension`, `KotlinCompile`, etc.)
+ and uses reflection by name instead. `plugin-build` resolves its own copy of `kotlin-gradle-plugin`
+ to compile against, which loads as a different `Class` instance than the one the consuming
+ build's `plugins { kotlin(...) }` loads, so `extensions.findByType(...)`/`tasks.withType(...)`
+ find nothing across that boundary directly. Documented in code comments in
+ `ActualizerGradlePlugin.kt`.
+- **A leaf module must not depend on the compiled jar of a project it also `actualizes(...)`.**
+ `:actual-jvm` merges `:api`'s and `:feature-common`'s source; if it also depended on their
+ compiled jars, the JVM would see two different definitions of the same package/class/method, and
+ whichever ends up first on the classpath would silently win. Keep leaf modules' dependencies
+ limited to things they don't also merge as source.
+- One actual per expect only - no support for choosing between multiple candidate actual-providing
+ modules (e.g. per build flavor). `actualizes(...)` merges in all of them unconditionally, and the
+ last declaration compiled for a given fully-qualified name wins or conflicts per ordinary Kotlin
+ rules.
+- **Merged source files must have collision-free file-facade names.** Kotlin compiles each
+ top-level-function file to a `Kt` JVM class. If two files merged into the same
+ compilation share both a package and a filename, they produce two same-named classes that
+ silently shadow each other on the runtime classpath, with a `NoSuchMethodError` for whatever
+ loaded second. Keep filenames distinct within a shared package across anything that might get
+ merged together.
diff --git a/build.gradle.kts b/build.gradle.kts
new file mode 100644
index 0000000..960a254
--- /dev/null
+++ b/build.gradle.kts
@@ -0,0 +1,3 @@
+plugins {
+ kotlin("jvm") version "2.4.10" apply false
+}
diff --git a/gradle.properties b/gradle.properties
new file mode 100644
index 0000000..d5fd5a7
--- /dev/null
+++ b/gradle.properties
@@ -0,0 +1,3 @@
+kotlin.code.style=official
+org.gradle.jvmargs=-Xmx2g
+kotlinVersion=2.0.21
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
new file mode 100644
index 0000000..6f97b2e
--- /dev/null
+++ b/gradle/libs.versions.toml
@@ -0,0 +1,6 @@
+[versions]
+sample-api = "0.1.0"
+
+[libraries]
+# :sample:actual-jvm-published's published-coordinate actualizes() target - see its build.gradle.kts.
+sample-api = { module = "net.kernelpanicsoft.sample:api", version.ref = "sample-api" }
diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar
new file mode 100644
index 0000000..b1b8ef5
Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ
diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties
new file mode 100644
index 0000000..8ff605c
--- /dev/null
+++ b/gradle/wrapper/gradle-wrapper.properties
@@ -0,0 +1,9 @@
+distributionBase=GRADLE_USER_HOME
+distributionPath=wrapper/dists
+distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.5-bin.zip
+networkTimeout=10000
+retries=0
+retryBackOffMs=500
+validateDistributionUrl=true
+zipStoreBase=GRADLE_USER_HOME
+zipStorePath=wrapper/dists
diff --git a/gradlew b/gradlew
new file mode 100755
index 0000000..249efbb
--- /dev/null
+++ b/gradlew
@@ -0,0 +1,248 @@
+#!/bin/sh
+
+#
+# Copyright © 2015 the original authors.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# https://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+# SPDX-License-Identifier: Apache-2.0
+#
+
+##############################################################################
+#
+# gradlew start up script for POSIX generated by Gradle.
+#
+# Important for running:
+#
+# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
+# noncompliant, but you have some other compliant shell such as ksh or
+# bash, then to run this script, type that shell name before the whole
+# command line, like:
+#
+# ksh gradlew
+#
+# Busybox and similar reduced shells will NOT work, because this script
+# requires all of these POSIX shell features:
+# * functions;
+# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
+# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
+# * compound commands having a testable exit status, especially «case»;
+# * various built-in commands including «command», «set», and «ulimit».
+#
+# Important for patching:
+#
+# (2) This script targets any POSIX shell, so it avoids extensions provided
+# by Bash, Ksh, etc; in particular arrays are avoided.
+#
+# The "traditional" practice of packing multiple parameters into a
+# space-separated string is a well documented source of bugs and security
+# problems, so this is (mostly) avoided, by progressively accumulating
+# options in "$@", and eventually passing that to Java.
+#
+# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
+# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
+# see the in-line comments for details.
+#
+# There are tweaks for specific operating systems such as AIX, CygWin,
+# Darwin, MinGW, and NonStop.
+#
+# (3) This script is generated from the Groovy template
+# https://github.com/gradle/gradle/blob/3d91ce3b8caaf77ad09f381f43615b715b53f72c/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
+# within the Gradle project.
+#
+# You can find Gradle at https://github.com/gradle/gradle/.
+#
+##############################################################################
+
+# Attempt to set APP_HOME
+
+# Resolve links: $0 may be a link
+app_path=$0
+
+# Need this for daisy-chained symlinks.
+while
+ APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
+ [ -h "$app_path" ]
+do
+ ls=$( ls -ld "$app_path" )
+ link=${ls#*' -> '}
+ case $link in #(
+ /*) app_path=$link ;; #(
+ *) app_path=$APP_HOME$link ;;
+ esac
+done
+
+# This is normally unused
+# shellcheck disable=SC2034
+APP_BASE_NAME=${0##*/}
+# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
+APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
+
+# Use the maximum available, or set MAX_FD != -1 to use that value.
+MAX_FD=maximum
+
+warn () {
+ echo "$*"
+} >&2
+
+die () {
+ echo
+ echo "$*"
+ echo
+ exit 1
+} >&2
+
+# OS specific support (must be 'true' or 'false').
+cygwin=false
+msys=false
+darwin=false
+nonstop=false
+case "$( uname )" in #(
+ CYGWIN* ) cygwin=true ;; #(
+ Darwin* ) darwin=true ;; #(
+ MSYS* | MINGW* ) msys=true ;; #(
+ NONSTOP* ) nonstop=true ;;
+esac
+
+
+
+# Determine the Java command to use to start the JVM.
+if [ -n "$JAVA_HOME" ] ; then
+ if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
+ # IBM's JDK on AIX uses strange locations for the executables
+ JAVACMD=$JAVA_HOME/jre/sh/java
+ else
+ JAVACMD=$JAVA_HOME/bin/java
+ fi
+ if [ ! -x "$JAVACMD" ] ; then
+ die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
+
+Please set the JAVA_HOME variable in your environment to match the
+location of your Java installation."
+ fi
+else
+ JAVACMD=java
+ if ! command -v java >/dev/null 2>&1
+ then
+ die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
+
+Please set the JAVA_HOME variable in your environment to match the
+location of your Java installation."
+ fi
+fi
+
+# Increase the maximum file descriptors if we can.
+if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
+ case $MAX_FD in #(
+ max*)
+ # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
+ # shellcheck disable=SC2039,SC3045
+ MAX_FD=$( ulimit -H -n ) ||
+ warn "Could not query maximum file descriptor limit"
+ esac
+ case $MAX_FD in #(
+ '' | soft) :;; #(
+ *)
+ # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
+ # shellcheck disable=SC2039,SC3045
+ ulimit -n "$MAX_FD" ||
+ warn "Could not set maximum file descriptor limit to $MAX_FD"
+ esac
+fi
+
+# Collect all arguments for the java command, stacking in reverse order:
+# * args from the command line
+# * the main class name
+# * -classpath
+# * -D...appname settings
+# * --module-path (only if needed)
+# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
+
+# For Cygwin or MSYS, switch paths to Windows format before running java
+if "$cygwin" || "$msys" ; then
+ APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
+
+ JAVACMD=$( cygpath --unix "$JAVACMD" )
+
+ # Now convert the arguments - kludge to limit ourselves to /bin/sh
+ for arg do
+ if
+ case $arg in #(
+ -*) false ;; # don't mess with options #(
+ /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
+ [ -e "$t" ] ;; #(
+ *) false ;;
+ esac
+ then
+ arg=$( cygpath --path --ignore --mixed "$arg" )
+ fi
+ # Roll the args list around exactly as many times as the number of
+ # args, so each arg winds up back in the position where it started, but
+ # possibly modified.
+ #
+ # NB: a `for` loop captures its iteration list before it begins, so
+ # changing the positional parameters here affects neither the number of
+ # iterations, nor the values presented in `arg`.
+ shift # remove old arg
+ set -- "$@" "$arg" # push replacement arg
+ done
+fi
+
+
+# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
+
+# Collect all arguments for the java command:
+# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
+# and any embedded shellness will be escaped.
+# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
+# treated as '${Hostname}' itself on the command line.
+
+set -- \
+ "-Dorg.gradle.appname=$APP_BASE_NAME" \
+ -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
+ "$@"
+
+# Stop when "xargs" is not available.
+if ! command -v xargs >/dev/null 2>&1
+then
+ die "xargs is not available"
+fi
+
+# Use "xargs" to parse quoted args.
+#
+# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
+#
+# In Bash we could simply go:
+#
+# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
+# set -- "${ARGS[@]}" "$@"
+#
+# but POSIX shell has neither arrays nor command substitution, so instead we
+# post-process each arg (as a line of input to sed) to backslash-escape any
+# character that might be a shell metacharacter, then use eval to reverse
+# that process (while maintaining the separation between arguments), and wrap
+# the whole thing up as a single "set" statement.
+#
+# This will of course break if any of these variables contains a newline or
+# an unmatched quote.
+#
+
+eval "set -- $(
+ printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
+ xargs -n1 |
+ sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
+ tr '\n' ' '
+ )" '"$@"'
+
+exec "$JAVACMD" "$@"
diff --git a/gradlew.bat b/gradlew.bat
new file mode 100644
index 0000000..a51ec4f
--- /dev/null
+++ b/gradlew.bat
@@ -0,0 +1,82 @@
+@rem
+@rem Copyright 2015 the original author or authors.
+@rem
+@rem Licensed under the Apache License, Version 2.0 (the "License");
+@rem you may not use this file except in compliance with the License.
+@rem You may obtain a copy of the License at
+@rem
+@rem https://www.apache.org/licenses/LICENSE-2.0
+@rem
+@rem Unless required by applicable law or agreed to in writing, software
+@rem distributed under the License is distributed on an "AS IS" BASIS,
+@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+@rem See the License for the specific language governing permissions and
+@rem limitations under the License.
+@rem
+@rem SPDX-License-Identifier: Apache-2.0
+@rem
+
+@if "%DEBUG%"=="" @echo off
+@rem ##########################################################################
+@rem
+@rem gradlew startup script for Windows
+@rem
+@rem ##########################################################################
+
+@rem Set local scope for the variables, and ensure extensions are enabled
+setlocal EnableExtensions
+
+set DIRNAME=%~dp0
+if "%DIRNAME%"=="" set DIRNAME=.
+@rem This is normally unused
+set APP_BASE_NAME=%~n0
+set APP_HOME=%DIRNAME%
+
+@rem Resolve any "." and ".." in APP_HOME to make it shorter.
+for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
+
+@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
+
+@rem Find java.exe
+if defined JAVA_HOME goto findJavaFromJavaHome
+
+set JAVA_EXE=java.exe
+%JAVA_EXE% -version >NUL 2>&1
+if %ERRORLEVEL% equ 0 goto execute
+
+echo. 1>&2
+echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
+echo. 1>&2
+echo Please set the JAVA_HOME variable in your environment to match the 1>&2
+echo location of your Java installation. 1>&2
+
+"%COMSPEC%" /c exit 1
+
+:findJavaFromJavaHome
+set JAVA_HOME=%JAVA_HOME:"=%
+set JAVA_EXE=%JAVA_HOME%/bin/java.exe
+
+if exist "%JAVA_EXE%" goto execute
+
+echo. 1>&2
+echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
+echo. 1>&2
+echo Please set the JAVA_HOME variable in your environment to match the 1>&2
+echo location of your Java installation. 1>&2
+
+"%COMSPEC%" /c exit 1
+
+:execute
+@rem Setup the command line
+
+
+
+@rem Execute gradlew
+@rem endlocal doesn't take effect until after the line is parsed and variables are expanded
+@rem which allows us to clear the local environment before executing the java command
+endlocal & "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* & call :exitWithErrorLevel
+
+:exitWithErrorLevel
+@rem Use "%COMSPEC%" /c exit to allow operators to work properly in scripts
+"%COMSPEC%" /c exit %ERRORLEVEL%
diff --git a/plugin-build/compiler-plugin/build.gradle.kts b/plugin-build/compiler-plugin/build.gradle.kts
new file mode 100644
index 0000000..0a8bad3
--- /dev/null
+++ b/plugin-build/compiler-plugin/build.gradle.kts
@@ -0,0 +1,50 @@
+import java.util.Properties
+
+plugins {
+ kotlin("jvm") version "2.4.10"
+ `maven-publish`
+}
+
+group = "net.kernelpanicsoft.actualizer"
+version = "0.1.0"
+
+repositories {
+ mavenCentral()
+}
+
+dependencies {
+ compileOnly("org.jetbrains.kotlin:kotlin-compiler-embeddable:2.4.10")
+}
+
+kotlin {
+ jvmToolchain(21)
+}
+
+publishing {
+ publications {
+ create("maven") {
+ from(components["java"])
+ }
+ }
+ repositories {
+ maven {
+ name = "Reposilite"
+ val releasesUrl = "https://maven.kernelpanicsoft.net/releases"
+ val snapshotsUrl = "https://maven.kernelpanicsoft.net/snapshots"
+
+ url = uri(if (version.toString().endsWith("SNAPSHOT")) snapshotsUrl else releasesUrl)
+
+ val localProperties = rootDir.resolve("local.properties")
+ .takeIf { it.exists() }
+ ?.inputStream()
+ ?.use { Properties().apply { load(it) } }
+
+ credentials {
+ username = localProperties?.getProperty("reposilite.username")
+ ?: System.getenv("REPOSILITE_USERNAME")
+ password = localProperties?.getProperty("reposilite.password")
+ ?: System.getenv("REPOSILITE_PASSWORD")
+ }
+ }
+ }
+}
diff --git a/plugin-build/compiler-plugin/src/main/kotlin/net/kernelpanicsoft/actualizer/compiler/ActualizerCommandLineProcessor.kt b/plugin-build/compiler-plugin/src/main/kotlin/net/kernelpanicsoft/actualizer/compiler/ActualizerCommandLineProcessor.kt
new file mode 100644
index 0000000..3b9d3ee
--- /dev/null
+++ b/plugin-build/compiler-plugin/src/main/kotlin/net/kernelpanicsoft/actualizer/compiler/ActualizerCommandLineProcessor.kt
@@ -0,0 +1,29 @@
+package net.kernelpanicsoft.actualizer.compiler
+
+import org.jetbrains.kotlin.compiler.plugin.AbstractCliOption
+import org.jetbrains.kotlin.compiler.plugin.CommandLineProcessor
+import org.jetbrains.kotlin.compiler.plugin.ExperimentalCompilerApi
+import org.jetbrains.kotlin.config.CompilerConfiguration
+
+/**
+ * Registers this plugin's ID with the compiler. There are currently no `-P` options to read: the
+ * cross-module actualization report this plugin used to accept `moduleMap`/`selfModule`/
+ * `reportOutput` options for is now generated entirely in the Gradle plugin instead (see
+ * `ActualizerLinkReport.kt`), so [ActualizerCompilerPluginRegistrar] no longer needs anything
+ * passed in here.
+ */
+@OptIn(ExperimentalCompilerApi::class)
+class ActualizerCommandLineProcessor : CommandLineProcessor {
+
+ override val pluginId: String = PLUGIN_ID
+
+ override val pluginOptions: Collection = emptyList()
+
+ override fun processOption(option: AbstractCliOption, value: String, configuration: CompilerConfiguration) {
+ error("Unexpected Actualizer plugin option: ${option.optionName}")
+ }
+
+ companion object {
+ const val PLUGIN_ID = "net.kernelpanicsoft.actualizer"
+ }
+}
diff --git a/plugin-build/compiler-plugin/src/main/kotlin/net/kernelpanicsoft/actualizer/compiler/ActualizerCompilerPluginRegistrar.kt b/plugin-build/compiler-plugin/src/main/kotlin/net/kernelpanicsoft/actualizer/compiler/ActualizerCompilerPluginRegistrar.kt
new file mode 100644
index 0000000..fa086a2
--- /dev/null
+++ b/plugin-build/compiler-plugin/src/main/kotlin/net/kernelpanicsoft/actualizer/compiler/ActualizerCompilerPluginRegistrar.kt
@@ -0,0 +1,34 @@
+package net.kernelpanicsoft.actualizer.compiler
+
+import net.kernelpanicsoft.actualizer.compiler.fir.ActualizerFirExtensionRegistrar
+import org.jetbrains.kotlin.compiler.plugin.CompilerPluginRegistrar
+import org.jetbrains.kotlin.compiler.plugin.ExperimentalCompilerApi
+import org.jetbrains.kotlin.config.CompilerConfiguration
+import org.jetbrains.kotlin.fir.extensions.FirExtensionRegistrarAdapter
+
+/**
+ * Registers [ActualizerFirExtensionRegistrar], which quiets IDE-only expect/actual false
+ * positives. This used to also register an `IrGenerationExtension` that reported on cross-module
+ * actualization links, but that only ever observed file paths/packages already available straight
+ * from source text - see `ActualizerLinkReport.kt` in the Gradle plugin, which does that via plain
+ * PSI scanning instead, so it can't be broken by a Kotlin version mismatch between this plugin and
+ * whatever actually compiles a consumer's code.
+ */
+@OptIn(ExperimentalCompilerApi::class)
+class ActualizerCompilerPluginRegistrar : CompilerPluginRegistrar() {
+
+ override val pluginId: String = ActualizerCommandLineProcessor.PLUGIN_ID
+ override val supportsK2: Boolean = true
+
+ override fun ExtensionStorage.registerExtensions(configuration: CompilerConfiguration) {
+ // ActualizerFirExtensionRegistrar builds on internal FIR APIs with no compatibility
+ // guarantee across Kotlin versions, so a version this plugin wasn't built against could
+ // fail here at class-load time. It's a cosmetic IDE-only feature (see the root README),
+ // so a failure here should never take down a real compile.
+ try {
+ FirExtensionRegistrarAdapter.registerExtension(ActualizerFirExtensionRegistrar())
+ } catch (_: Throwable) {
+ // No message collector available worth reporting through here - see class doc.
+ }
+ }
+}
diff --git a/plugin-build/compiler-plugin/src/main/kotlin/net/kernelpanicsoft/actualizer/compiler/fir/ActualizerFirExtensions.kt b/plugin-build/compiler-plugin/src/main/kotlin/net/kernelpanicsoft/actualizer/compiler/fir/ActualizerFirExtensions.kt
new file mode 100644
index 0000000..3fb0f1d
--- /dev/null
+++ b/plugin-build/compiler-plugin/src/main/kotlin/net/kernelpanicsoft/actualizer/compiler/fir/ActualizerFirExtensions.kt
@@ -0,0 +1,227 @@
+package net.kernelpanicsoft.actualizer.compiler.fir
+
+import org.jetbrains.kotlin.fir.FirSession
+import org.jetbrains.kotlin.fir.declarations.DirectDeclarationsAccess
+import org.jetbrains.kotlin.fir.declarations.FirCallableDeclaration
+import org.jetbrains.kotlin.fir.declarations.FirClassLikeDeclaration
+import org.jetbrains.kotlin.fir.declarations.FirConstructor
+import org.jetbrains.kotlin.fir.declarations.FirDeclaration
+import org.jetbrains.kotlin.fir.declarations.FirDeclarationStatus
+import org.jetbrains.kotlin.fir.declarations.FirFile
+import org.jetbrains.kotlin.fir.declarations.FirMemberDeclaration
+import org.jetbrains.kotlin.fir.declarations.FirNamedFunction
+import org.jetbrains.kotlin.fir.declarations.FirProperty
+import org.jetbrains.kotlin.fir.declarations.FirRegularClass
+import org.jetbrains.kotlin.fir.declarations.toAnnotationClassIdSafe
+import org.jetbrains.kotlin.fir.expressions.FirAnnotation
+import org.jetbrains.kotlin.fir.expressions.FirExpression
+import org.jetbrains.kotlin.fir.expressions.FirLiteralExpression
+import org.jetbrains.kotlin.fir.expressions.FirVarargArgumentsExpression
+import org.jetbrains.kotlin.fir.expressions.builder.buildAnnotation
+import org.jetbrains.kotlin.fir.expressions.builder.buildAnnotationArgumentMapping
+import org.jetbrains.kotlin.fir.expressions.builder.buildLiteralExpression
+import org.jetbrains.kotlin.fir.expressions.builder.buildVarargArgumentsExpression
+import org.jetbrains.kotlin.fir.extensions.FirExtensionRegistrar
+import org.jetbrains.kotlin.fir.extensions.FirStatusTransformerExtension
+import org.jetbrains.kotlin.fir.plugin.createConeType
+import org.jetbrains.kotlin.fir.resolve.providers.firProvider
+import org.jetbrains.kotlin.fir.symbols.impl.FirClassLikeSymbol
+import org.jetbrains.kotlin.fir.types.ConeKotlinTypeProjectionOut
+import org.jetbrains.kotlin.fir.types.builder.buildResolvedTypeRef
+import org.jetbrains.kotlin.name.StandardClassIds
+import org.jetbrains.kotlin.types.ConstantValueKind
+
+/** The diagnostics this file suppresses - see the root README's "IDE false positives" section. */
+private val SUPPRESSED_DIAGNOSTIC_NAMES = listOf(
+ "EXPECT_AND_ACTUAL_IN_THE_SAME_MODULE",
+ "ACTUAL_WITHOUT_EXPECT",
+ "OVERLOAD_RESOLUTION_AMBIGUITY",
+ "ERROR_SUPPRESSION",
+ "NOT_A_MULTIPLATFORM_COMPILATION",
+)
+
+// Everything below builds on internal, undocumented FIR APIs with no compatibility guarantee
+// across Kotlin versions - and a real consumer of this plugin can easily end up compiling with a
+// different Kotlin compiler than this plugin was built against (a different Minecraft version's
+// toolchain, or the IDE's own bundled K2 compiler analyzing a project it didn't compile itself).
+// A binary-incompatible internal API surfaces as NoSuchMethodError/NoSuchFieldError/LinkageError,
+// none of which are Exception subtypes, so this has to catch Throwable specifically. Since this
+// whole mechanism is purely cosmetic (see the root README), any failure latches
+// suppressionApiAvailable off for the rest of this compiler process rather than letting it break
+// a real build over an IDE-only nicety, or retrying the same failure on every declaration.
+@Volatile
+private var suppressionApiAvailable = true
+
+/** Builds a `@Suppress` [FirAnnotation] for [names] from FIR builders directly, not source text. */
+private fun buildSuppressAnnotation(session: FirSession, names: List): FirAnnotation {
+ val suppressConeType = StandardClassIds.Annotations.Suppress.createConeType(session)
+ val typeRef = buildResolvedTypeRef { coneType = suppressConeType }
+ val argumentMapping = buildAnnotationArgumentMapping {
+ mapping[StandardClassIds.Annotations.ParameterNames.suppressNames] = buildSuppressNamesVararg(session, names)
+ }
+ return buildAnnotation {
+ annotationTypeRef = typeRef
+ this.argumentMapping = argumentMapping
+ }
+}
+
+/** Builds the `vararg names: String` argument expression `@Suppress` takes, for [names]. */
+private fun buildSuppressNamesVararg(session: FirSession, names: List): FirVarargArgumentsExpression {
+ val stringConeType = StandardClassIds.String.createConeType(session)
+ val literalArgs = names.map { name ->
+ buildLiteralExpression(
+ source = null,
+ kind = ConstantValueKind.String,
+ value = name,
+ annotations = null,
+ setType = true,
+ prefix = null,
+ )
+ }
+ val varargArrayConeType = StandardClassIds.Array.createConeType(
+ session,
+ typeArguments = arrayOf(ConeKotlinTypeProjectionOut(stringConeType)),
+ )
+ return buildVarargArgumentsExpression {
+ arguments.addAll(literalArgs)
+ coneElementTypeOrNull = stringConeType
+ coneTypeOrNull = varargArrayConeType
+ }
+}
+
+/**
+ * If [existing] already suppresses everything in [names], does nothing. Otherwise mutates
+ * [existing]'s own argument list in place to add whatever's missing, rather than attaching a
+ * second `@Suppress` alongside it - the real compiler's "repeated annotation" checker treats two
+ * `@Suppress`es on one declaration as an error regardless of what either suppresses, and mutating
+ * in place keeps this a single annotation, with [existing]'s own real source untouched.
+ */
+private fun mergeSuppressNames(existing: FirAnnotation, session: FirSession, names: List) {
+ val existingArgument = existing.argumentMapping.mapping[StandardClassIds.Annotations.ParameterNames.suppressNames]
+ ?: return
+ val existingArgs: List = (existingArgument as? FirVarargArgumentsExpression)?.arguments
+ ?: listOf(existingArgument)
+ val existingNames = existingArgs.mapNotNull { (it as? FirLiteralExpression)?.value as? String }
+ val namesToAdd = names.filterNot { it in existingNames }
+ if (namesToAdd.isEmpty()) return
+
+ val mergedVararg = buildSuppressNamesVararg(session, existingNames + namesToAdd)
+ val mergedMapping = buildAnnotationArgumentMapping {
+ mapping[StandardClassIds.Annotations.ParameterNames.suppressNames] = mergedVararg
+ }
+ existing.replaceArgumentMapping(mergedMapping)
+}
+
+private fun isExpectOrActual(status: FirDeclarationStatus): Boolean = status.isExpect || status.isActual
+
+/** Registers [ActualizerSuppressionStatusTransformer] with the FIR extension pipeline. */
+class ActualizerFirExtensionRegistrar : FirExtensionRegistrar() {
+ override fun ExtensionRegistrarContext.configurePlugin() {
+ +::ActualizerSuppressionStatusTransformer
+ }
+}
+
+/**
+ * Attaches a synthetic `@Suppress` to every top-level declaration in a file containing an
+ * `expect` or `actual`, quieting the IDE-only false positives described at the top of this file.
+ */
+private class ActualizerSuppressionStatusTransformer(session: FirSession) : FirStatusTransformerExtension(session) {
+
+ private val fileNeedsSuppressionCache = mutableMapOf()
+
+ // K2's status resolution can invoke transformStatus more than once for the same declaration
+ // (e.g. across separate resolution passes for a generic class's members). Appending another
+ // @Suppress each time would give the declaration two, and the real compiler's own
+ // "repeated annotation" checker then crashes trying to report that - our synthetic annotation
+ // has no PSI source, and that checker unconditionally requires one to point the diagnostic at.
+ // Tracked by identity, not equality: FIR declarations are mutated in place across passes, so
+ // the same object recurring is exactly the case this guards against.
+ private val alreadyInjected: MutableSet =
+ java.util.Collections.newSetFromMap(java.util.IdentityHashMap())
+
+ private fun containingFile(declaration: FirMemberDeclaration): FirFile? {
+ val provider = session.firProvider
+ return when (declaration) {
+ is FirCallableDeclaration -> provider.getFirCallableContainerFile(declaration.symbol)
+ is FirClassLikeDeclaration -> provider.getFirClassifierContainerFileIfAny(declaration.symbol)
+ }
+ }
+
+ @OptIn(DirectDeclarationsAccess::class)
+ private fun fileNeedsSuppression(file: FirFile): Boolean = fileNeedsSuppressionCache.getOrPut(file) {
+ file.declarations.any { (it as? FirMemberDeclaration)?.status?.let(::isExpectOrActual) == true }
+ }
+
+ override fun needTransformStatus(declaration: FirDeclaration): Boolean {
+ if (!suppressionApiAvailable) return false
+ return try {
+ val member = declaration as? FirMemberDeclaration ?: return false
+ val file = containingFile(member) ?: return false
+ fileNeedsSuppression(file)
+ } catch (t: Throwable) {
+ suppressionApiAvailable = false
+ false
+ }
+ }
+
+ private fun inject(declaration: FirDeclaration) {
+ if (!suppressionApiAvailable) return
+ if (!alreadyInjected.add(declaration)) return
+ try {
+ // A declaration that already carries its own hand-written @Suppress (e.g.
+ // @Suppress("unused") on an actual object) merges our names into it in place instead
+ // of attaching a second @Suppress alongside it - see mergeSuppressNames's doc for why.
+ val existingSuppress = declaration.annotations.firstOrNull {
+ it.toAnnotationClassIdSafe(session) == StandardClassIds.Annotations.Suppress
+ }
+ if (existingSuppress != null) {
+ mergeSuppressNames(existingSuppress, session, SUPPRESSED_DIAGNOSTIC_NAMES)
+ } else {
+ val annotation = buildSuppressAnnotation(session, SUPPRESSED_DIAGNOSTIC_NAMES)
+ declaration.replaceAnnotations(declaration.annotations + annotation)
+ }
+ } catch (t: Throwable) {
+ suppressionApiAvailable = false
+ }
+ }
+
+ override fun transformStatus(
+ status: FirDeclarationStatus,
+ function: FirNamedFunction,
+ containingClass: FirClassLikeSymbol<*>?,
+ isLocal: Boolean,
+ ): FirDeclarationStatus {
+ inject(function)
+ return status
+ }
+
+ override fun transformStatus(
+ status: FirDeclarationStatus,
+ property: FirProperty,
+ containingClass: FirClassLikeSymbol<*>?,
+ isLocal: Boolean,
+ ): FirDeclarationStatus {
+ inject(property)
+ return status
+ }
+
+ override fun transformStatus(
+ status: FirDeclarationStatus,
+ regularClass: FirRegularClass,
+ containingClass: FirClassLikeSymbol<*>?,
+ isLocal: Boolean,
+ ): FirDeclarationStatus {
+ inject(regularClass)
+ return status
+ }
+
+ override fun transformStatus(
+ status: FirDeclarationStatus,
+ constructor: FirConstructor,
+ containingClass: FirClassLikeSymbol<*>?,
+ isLocal: Boolean,
+ ): FirDeclarationStatus {
+ inject(constructor)
+ return status
+ }
+}
diff --git a/plugin-build/compiler-plugin/src/main/resources/META-INF/services/org.jetbrains.kotlin.compiler.plugin.CommandLineProcessor b/plugin-build/compiler-plugin/src/main/resources/META-INF/services/org.jetbrains.kotlin.compiler.plugin.CommandLineProcessor
new file mode 100644
index 0000000..819a31f
--- /dev/null
+++ b/plugin-build/compiler-plugin/src/main/resources/META-INF/services/org.jetbrains.kotlin.compiler.plugin.CommandLineProcessor
@@ -0,0 +1 @@
+net.kernelpanicsoft.actualizer.compiler.ActualizerCommandLineProcessor
diff --git a/plugin-build/compiler-plugin/src/main/resources/META-INF/services/org.jetbrains.kotlin.compiler.plugin.CompilerPluginRegistrar b/plugin-build/compiler-plugin/src/main/resources/META-INF/services/org.jetbrains.kotlin.compiler.plugin.CompilerPluginRegistrar
new file mode 100644
index 0000000..1c55711
--- /dev/null
+++ b/plugin-build/compiler-plugin/src/main/resources/META-INF/services/org.jetbrains.kotlin.compiler.plugin.CompilerPluginRegistrar
@@ -0,0 +1 @@
+net.kernelpanicsoft.actualizer.compiler.ActualizerCompilerPluginRegistrar
diff --git a/plugin-build/gradle-plugin/build.gradle.kts b/plugin-build/gradle-plugin/build.gradle.kts
new file mode 100644
index 0000000..b1facee
--- /dev/null
+++ b/plugin-build/gradle-plugin/build.gradle.kts
@@ -0,0 +1,73 @@
+import java.util.Properties
+
+plugins {
+ kotlin("jvm") version "2.4.10"
+ `java-gradle-plugin`
+ `maven-publish`
+}
+
+group = "net.kernelpanicsoft.actualizer"
+version = "0.1.0"
+
+repositories {
+ mavenCentral()
+ gradlePluginPortal()
+ google()
+}
+
+kotlin {
+ jvmToolchain(21)
+}
+
+dependencies {
+ // Both compileOnly, not implementation: ExpectStubGenerator.kt/GenerateStubsWorkAction.kt only
+ // need these to compile. At runtime they only ever run inside GenerateStubsWorkAction's
+ // isolated worker classloader (see ActualizerGradlePlugin.wireStubGeneration), which resolves
+ // its own fresh copies of both via a detached configuration. If these were `implementation`,
+ // Gradle would merge them onto the same plugin classpath as a consuming project's own
+ // kotlin("jvm") plugin, and kotlin-compiler-embeddable's bundled classes collide with
+ // kotlin-gradle-plugin's own - e.g. a NoSuchMethodError on GradleBuildPerformanceMetric.values()
+ // while applying kotlin("jvm") itself, since both jars provide that class under the same name.
+ //
+ // Used to generate the actualizer { stubUnfulfilledExpects() } output (ExpectStubGenerator.kt)
+ // instead of hand-rolled string building - correct formatting, imports, and Kotlin syntax
+ // (including for `actual class` stubs) instead of ad-hoc string concatenation.
+ compileOnly("com.squareup:kotlinpoet:1.18.1")
+ // Used to find `expect` declarations to stub via real PSI parsing (ExpectStubGenerator.kt)
+ // instead of a naive regex + brace-counting scan. Pure syntax parsing only - no semantic
+ // resolution - so it doesn't choke on the very thing being scanned for (unfulfilled expects).
+ compileOnly("org.jetbrains.kotlin:kotlin-compiler-embeddable:2.4.10")
+}
+
+gradlePlugin {
+ plugins {
+ create("actualizer") {
+ id = "net.kernelpanicsoft.actualizer"
+ implementationClass = "net.kernelpanicsoft.actualizer.gradle.ActualizerGradlePlugin"
+ }
+ }
+}
+
+publishing {
+ repositories {
+ maven {
+ name = "Reposilite"
+ val releasesUrl = "https://maven.kernelpanicsoft.net/releases"
+ val snapshotsUrl = "https://maven.kernelpanicsoft.net/snapshots"
+
+ url = uri(if (version.toString().endsWith("SNAPSHOT")) snapshotsUrl else releasesUrl)
+
+ val localProperties = rootDir.resolve("local.properties")
+ .takeIf { it.exists() }
+ ?.inputStream()
+ ?.use { Properties().apply { load(it) } }
+
+ credentials {
+ username = localProperties?.getProperty("reposilite.username")
+ ?: System.getenv("REPOSILITE_USERNAME")
+ password = localProperties?.getProperty("reposilite.password")
+ ?: System.getenv("REPOSILITE_PASSWORD")
+ }
+ }
+ }
+}
diff --git a/plugin-build/gradle-plugin/src/main/kotlin/net/kernelpanicsoft/actualizer/gradle/ActualizerExtension.kt b/plugin-build/gradle-plugin/src/main/kotlin/net/kernelpanicsoft/actualizer/gradle/ActualizerExtension.kt
new file mode 100644
index 0000000..8d7bc6e
--- /dev/null
+++ b/plugin-build/gradle-plugin/src/main/kotlin/net/kernelpanicsoft/actualizer/gradle/ActualizerExtension.kt
@@ -0,0 +1,70 @@
+package net.kernelpanicsoft.actualizer.gradle
+
+import org.gradle.api.Project
+import org.gradle.api.artifacts.MinimalExternalModuleDependency
+import org.gradle.api.model.ObjectFactory
+import org.gradle.api.provider.Provider
+import javax.inject.Inject
+
+/** Name of the source set merged in from a foreign project when no other name is given. */
+const val DEFAULT_CROSS_MODULE_SOURCE_SET = "main"
+
+internal data class ActualizedProjectSource(val project: Project, val sourceSetName: String)
+internal data class ActualizedPublishedSource(val coordinate: String, val dependsOnTasks: List)
+
+/**
+ * The `actualizer { }` extension registered on every project the Actualizer plugin is applied to.
+ *
+ * A project uses this in one of two roles. An `expect`-declaring project calls
+ * [stubUnfulfilledExpects] so it still produces an ordinary standalone jar. A project providing
+ * the real `actual`s calls [actualizes] one or more times to pull in the foreign source and link
+ * against it.
+ */
+open class ActualizerExtension @Inject constructor(objects: ObjectFactory) {
+ internal val sources: MutableList = mutableListOf()
+ internal val publishedSources: MutableList = mutableListOf()
+ internal var stubUnfulfilledExpects: Boolean = false
+
+ /**
+ * Merges [sourceSet] of [project] into this project's compilation as source, alongside the
+ * real `actual` declarations written here.
+ *
+ * Can be called more than once to link against several foreign projects at once.
+ */
+ @JvmOverloads
+ fun actualizes(project: Project, sourceSet: String = DEFAULT_CROSS_MODULE_SOURCE_SET) {
+ sources += ActualizedProjectSource(project, sourceSet)
+ }
+
+ /**
+ * Same as [actualizes], but for a published library rather than a sibling project: resolves
+ * [publishedCoordinate]'s `sources` classifier artifact and merges the unpacked source in the
+ * same way a `project(...)` reference would be.
+ *
+ * @param dependsOnTasks Tasks to run before resolving [publishedCoordinate], needed only when
+ * the coordinate isn't published yet by the time this build runs - Gradle can't infer that
+ * ordering the way it does for `project(...)` dependencies.
+ * @see registerPublishedSourcesSync
+ */
+ @JvmOverloads
+ fun actualizes(publishedCoordinate: String, dependsOnTasks: List = emptyList()) {
+ publishedSources += ActualizedPublishedSource(publishedCoordinate, dependsOnTasks)
+ }
+
+ /** Version-catalog overload of [actualizes], e.g. `actualizes(libs.foo.bar)`. */
+ @JvmOverloads
+ fun actualizes(publishedCoordinate: Provider, dependsOnTasks: List = emptyList()) {
+ val dependency = publishedCoordinate.get()
+ actualizes("${dependency.group}:${dependency.name}:${dependency.version}", dependsOnTasks)
+ }
+
+ /**
+ * Generates a throwing `actual` for every unfulfilled `expect` in this project's `main`
+ * source set, so it compiles into an ordinary jar without needing a real `actual` anywhere
+ * yet. Calling one of those functions throws [IllegalStateException] until this jar is merged
+ * into a project that calls [actualizes] against it.
+ */
+ fun stubUnfulfilledExpects() {
+ stubUnfulfilledExpects = true
+ }
+}
diff --git a/plugin-build/gradle-plugin/src/main/kotlin/net/kernelpanicsoft/actualizer/gradle/ActualizerGradlePlugin.kt b/plugin-build/gradle-plugin/src/main/kotlin/net/kernelpanicsoft/actualizer/gradle/ActualizerGradlePlugin.kt
new file mode 100644
index 0000000..9471fcf
--- /dev/null
+++ b/plugin-build/gradle-plugin/src/main/kotlin/net/kernelpanicsoft/actualizer/gradle/ActualizerGradlePlugin.kt
@@ -0,0 +1,381 @@
+package net.kernelpanicsoft.actualizer.gradle
+
+import org.gradle.api.NamedDomainObjectContainer
+import org.gradle.api.Plugin
+import org.gradle.api.Project
+import org.gradle.api.artifacts.ExternalModuleDependency
+import org.gradle.api.file.SourceDirectorySet
+import org.gradle.api.provider.ListProperty
+import org.gradle.workers.WorkerExecutor
+import java.io.File
+import javax.inject.Inject
+
+private const val COMPILER_PLUGIN_COORDINATES = "net.kernelpanicsoft.actualizer:compiler-plugin:0.1.0"
+
+// Must match the versions gradle-plugin/build.gradle.kts itself declares - this is what
+// wireStubGeneration/wireCrossModuleActualization resolve onto their respective isolated worker
+// classpaths (GenerateStubsWorkAction/GenerateLinkReportWorkAction).
+private const val KOTLIN_COMPILER_EMBEDDABLE_COORDINATES = "org.jetbrains.kotlin:kotlin-compiler-embeddable:2.4.10"
+private const val KOTLINPOET_COORDINATES = "com.squareup:kotlinpoet:1.18.1"
+
+/**
+ * Registers the `actualizer { }` extension and wires its two modes of operation into the
+ * project's Kotlin compilation.
+ *
+ * [ActualizerExtension.stubUnfulfilledExpects] scans the `main` source set for `expect`
+ * declarations, generates a throwing `actual` stub for each ([ExpectStubGenerator]), and compiles
+ * `main` against that generated stub with `-Xmulti-platform` + `-Xcommon-sources`. That's what
+ * lets a module with an unfulfilled `expect` still produce an ordinary standalone jar.
+ *
+ * [ActualizerExtension.actualizes] merges a foreign project's (or published library's)
+ * hand-written source into this project's own compilation alongside a real `actual`, registers the
+ * Actualizer compiler plugin on that compilation (for its FIR extension's IDE false-positive
+ * suppression), and separately writes a JSON report of what got linked via plain PSI scanning in
+ * Gradle - see [writeActualizationLinkReport]'s doc for why that isn't done inside the compiler. A
+ * published coordinate is resolved lazily, since it may not be published yet when this build
+ * starts; a `project(...)` reference is resolved eagerly, since its files already exist on disk.
+ *
+ * This intentionally avoids referencing Kotlin Gradle Plugin types like `KotlinJvmProjectExtension`
+ * directly. `plugin-build` resolves its own copy of `kotlin-gradle-plugin` to compile this plugin,
+ * which loads into a different classloader than the copy a consuming build resolves via
+ * `plugins { kotlin("jvm") }`, so `extensions.findByType(...)`/`tasks.withType(...)` against those
+ * types find nothing across that boundary. Reflection by member name sidesteps it; only
+ * Gradle-core types loaded by Gradle's own shared classloader are referenced statically.
+ */
+class ActualizerGradlePlugin @Inject constructor(
+ private val workerExecutor: WorkerExecutor,
+) : Plugin {
+
+ override fun apply(project: Project) {
+ val extension = project.extensions.create("actualizer", ActualizerExtension::class.java)
+
+ project.afterEvaluate {
+ if (extension.stubUnfulfilledExpects) {
+ wireStubGeneration(project)
+ }
+ if (extension.sources.isNotEmpty() || extension.publishedSources.isNotEmpty()) {
+ wireCrossModuleActualization(project, extension)
+ }
+ }
+ }
+
+ private fun wireStubGeneration(project: Project) {
+ val mainDirs = namedSourceSetDirs(project, "main")
+ val mainFiles = mainDirs.filter { it.exists() }.flatMap { dir ->
+ dir.walkTopDown().filter { it.isFile && it.extension == "kt" }
+ }
+ if (mainFiles.isEmpty()) {
+ project.logger.warn(
+ "[actualizer] '${project.path}' called stubUnfulfilledExpects() but its 'main' " +
+ "Kotlin source set has no files to scan."
+ )
+ return
+ }
+
+ val outputDir = project.layout.buildDirectory.dir("generated/actualizer-stubs").get().asFile
+
+ // A real task rather than eager work in apply()/afterEvaluate, so it survives `clean` and
+ // `build` running in the same invocation and gets normal up-to-date checking.
+ val generateTask = project.tasks.register("generateActualizerStubs") { task ->
+ task.inputs.files(mainFiles).withPropertyName("actualizerExpectScanInputs")
+ task.outputs.dir(outputDir)
+ task.doLast {
+ // Runs in its own classloader, not this plugin's - see GenerateStubsWorkAction for
+ // why. The isolated classpath needs both this plugin's own classes (for
+ // ExpectStubGenerator.kt/GenerateStubsWorkAction itself) and its kotlin-compiler-
+ // embeddable/kotlinpoet dependencies.
+ val ownJar = File(ActualizerGradlePlugin::class.java.protectionDomain.codeSource.location.toURI())
+ val isolatedDeps = project.configurations.detachedConfiguration(
+ project.dependencies.create(KOTLIN_COMPILER_EMBEDDABLE_COORDINATES),
+ project.dependencies.create(KOTLINPOET_COORDINATES),
+ )
+ val workQueue = workerExecutor.classLoaderIsolation { spec ->
+ spec.classpath.from(ownJar, isolatedDeps)
+ }
+ workQueue.submit(GenerateStubsWorkAction::class.java) { params ->
+ params.mainFiles.from(mainFiles)
+ params.outputDir.set(outputDir)
+ params.projectPath.set(project.path)
+ }
+ workQueue.await()
+ }
+ }
+
+ addSourceDir(project, "main", listOf(outputDir), builtBy = listOf(generateTask))
+
+ // Registers the same compiler plugin wireCrossModuleActualization does, purely so its FIR
+ // extension is present here too and suppresses the IDE-only expect/actual false positives
+ // on this module's declarations - including the generated stub itself, which no longer
+ // carries its own manual @Suppress (see ExpectStubGenerator.kt).
+ val compilerPluginClasspath = project.configurations.detachedConfiguration(
+ project.dependencies.create(COMPILER_PLUGIN_COORDINATES)
+ )
+
+ // The whole source set counts as "common" here, not just the files with an expect in
+ // them - mirroring how a real commonMain source set is treated.
+ val commonSourcesValue = mainFiles.joinToString(",") { it.absolutePath }
+ val compileKotlinTask = project.tasks.named("compileKotlin")
+ compileKotlinTask.configure { task ->
+ task.dependsOn(generateTask, compilerPluginClasspath)
+ task.inputs.files(compilerPluginClasspath).withPropertyName("actualizerCompilerPluginClasspath")
+ val freeCompilerArgs = freeCompilerArgsProperty(task)
+ freeCompilerArgs.addAll(
+ project.provider {
+ val pluginJar = compilerPluginClasspath.files
+ .first { it.name.startsWith("compiler-plugin") }
+ .absolutePath
+ listOf(
+ "-Xmulti-platform",
+ "-Xcommon-sources=$commonSourcesValue",
+ "-Xplugin=$pluginJar",
+ )
+ }
+ )
+ }
+ }
+
+ private fun wireCrossModuleActualization(project: Project, extension: ActualizerExtension) {
+ // Captured before addSourceDir below merges the foreign dirs in, so this stays scoped to
+ // just this project's own hand-written source - see writeActualizationLinkReport's doc.
+ val ownMainDirs = namedSourceSetDirs(project, "main")
+ val ownMainFiles = ownMainDirs.filter { it.exists() }.flatMap { dir ->
+ dir.walkTopDown().filter { it.isFile && it.extension == "kt" }
+ }
+
+ val moduleMapEntries = mutableListOf()
+ // Project() references: files already exist on disk, safe to walk eagerly right now.
+ val eagerSourceFiles = mutableListOf()
+ val foreignSourceDirs = mutableListOf()
+ // Published coordinates: the sources jar might not exist yet, so these are only walked
+ // lazily below, after their Sync task has run.
+ val lazySourceDirs = mutableListOf()
+ val syncTasks = mutableListOf()
+
+ for (source in extension.sources) {
+ project.evaluationDependsOn(source.project.path)
+
+ val dirs = namedSourceSetDirs(source.project, source.sourceSetName)
+ if (dirs.isEmpty()) {
+ project.logger.warn(
+ "[actualizer] '${source.project.path}' has no '${source.sourceSetName}' Kotlin " +
+ "source set; nothing to merge into '${project.path}'."
+ )
+ continue
+ }
+
+ for (dir in dirs) {
+ foreignSourceDirs += dir
+ moduleMapEntries += "${dir.absolutePath}::${source.project.path}"
+ if (dir.exists()) {
+ eagerSourceFiles += dir.walkTopDown().filter { it.isFile && it.extension == "kt" }
+ }
+ }
+ }
+
+ for (published in extension.publishedSources) {
+ val (dir, syncTask) = registerPublishedSourcesSync(
+ project,
+ published.coordinate,
+ published.dependsOnTasks,
+ )
+ foreignSourceDirs += dir
+ lazySourceDirs += dir
+ moduleMapEntries += "${dir.absolutePath}::${published.coordinate}"
+ syncTasks += syncTask
+ }
+
+ if (eagerSourceFiles.isEmpty() && lazySourceDirs.isEmpty()) {
+ return
+ }
+
+ addSourceDir(project, "main", foreignSourceDirs, builtBy = syncTasks)
+
+ val compilerPluginClasspath = project.configurations.detachedConfiguration(
+ project.dependencies.create(COMPILER_PLUGIN_COORDINATES)
+ )
+
+ val reportOutput = project.layout.buildDirectory.file("actualizer/report.json").get().asFile
+
+ val compileKotlinTask = project.tasks.named("compileKotlin")
+ compileKotlinTask.configure { task ->
+ task.dependsOn(compilerPluginClasspath)
+ task.inputs.files(compilerPluginClasspath).withPropertyName("actualizerCompilerPluginClasspath")
+ syncTasks.forEach { task.dependsOn(it) }
+
+ val freeCompilerArgs = freeCompilerArgsProperty(task)
+
+ freeCompilerArgs.addAll(
+ project.provider {
+ // Walked lazily so this only runs after the Sync tasks have unpacked
+ // something into lazySourceDirs.
+ val allSourceFiles = eagerSourceFiles + lazySourceDirs.flatMap { dir ->
+ dir.walkTopDown().filter { it.isFile && it.extension == "kt" }
+ }
+ val commonSourcesValue = allSourceFiles.joinToString(",") { it.absolutePath }
+
+ // The resolved configuration also pulls in the plugin's own runtime deps
+ // (kotlin-stdlib etc) - only its own jar goes on -Xplugin=.
+ val pluginJar = compilerPluginClasspath.files
+ .first { it.name.startsWith("compiler-plugin") }
+ .absolutePath
+ listOf(
+ "-Xmulti-platform",
+ "-Xcommon-sources=$commonSourcesValue",
+ "-Xplugin=$pluginJar",
+ )
+ }
+ )
+
+ // Purely informational JSON report of which locally-declared top-level name is
+ // believed to actualize a foreign module's expect - see writeActualizationLinkReport's
+ // doc for why this runs entirely outside the compiler via plain PSI scanning, in its
+ // own worker classloader (see GenerateStubsWorkAction's doc for why that isolation
+ // matters). Only runs if compileKotlin itself succeeds.
+ task.doLast {
+ val ownJar = File(ActualizerGradlePlugin::class.java.protectionDomain.codeSource.location.toURI())
+ val isolatedDeps = project.configurations.detachedConfiguration(
+ project.dependencies.create(KOTLIN_COMPILER_EMBEDDABLE_COORDINATES)
+ )
+ val workQueue = workerExecutor.classLoaderIsolation { spec ->
+ spec.classpath.from(ownJar, isolatedDeps)
+ }
+ workQueue.submit(GenerateLinkReportWorkAction::class.java) { params ->
+ params.ownFiles.from(ownMainFiles)
+ params.moduleMapEntries.set(moduleMapEntries)
+ params.selfModule.set(project.path)
+ params.reportOutput.set(reportOutput)
+ }
+ workQueue.await()
+ }
+ }
+ }
+
+ /**
+ * Resolves [coordinate]'s `sources` classifier artifact and unpacks its `.kt` files into a
+ * build-local directory, mirroring [namedSourceSetDirs] for a `project(...)` reference.
+ *
+ * Returns the output directory and the task that populates it - the directory isn't populated
+ * until that task runs, so callers must depend on it rather than reading the directory
+ * directly.
+ *
+ * The library's sources jar must contain only its hand-written source, not a
+ * `stubUnfulfilledExpects()`-generated stub, or the merge ends up with two `actual`s for the
+ * same `expect`. A library using `stubUnfulfilledExpects()` itself needs its own `sourcesJar`
+ * task scoped to its real source directory rather than the default `withSourcesJar()`, which
+ * would also archive the generated stub. See `:sample:api`'s build script for an example.
+ */
+ private fun registerPublishedSourcesSync(
+ project: Project,
+ coordinate: String,
+ dependsOnTasks: List,
+ ): Pair {
+ val sourcesNotation = if (coordinate.endsWith(":sources")) coordinate else "$coordinate:sources"
+ val dependency = project.dependencies.create(sourcesNotation)
+ (dependency as? ExternalModuleDependency)?.isTransitive = false
+ val configuration = project.configurations.detachedConfiguration(dependency)
+
+ val safeName = coordinate.replace(Regex("[^A-Za-z0-9_.-]"), "_")
+ val outputDir = project.layout.buildDirectory.dir("generated/actualizer-published-sources/$safeName").get().asFile
+
+ // Not a Sync task with from(configuration) directly - that would resolve the detached
+ // configuration eagerly during task-graph construction, before dependsOnTasks below has
+ // had a chance to publish it. Resolving inside doLast defers it until this task runs.
+ //
+ // When the caller didn't pass dependsOnTasks, there's nothing else ordering this task
+ // against whatever actually produces the artifact - and dependsOnTasks is exactly what's
+ // omitted for a coordinate a composite build's dependencySubstitution maps onto a sibling
+ // included build's project instead of a real published artifact (the whole point of that
+ // substitution is that the consumer shouldn't need to know or care). There, "the sources
+ // jar" is really that project's own jar-producing task's output, which hasn't run yet on a
+ // clean build, and configuration.singleFile below could - and did - get resolved before
+ // that task ran, throwing NoSuchFileException on a jar that didn't exist yet.
+ //
+ // task.inputs.files(configuration) fixes that: it's still lazy (Gradle only snapshots it
+ // right before this task executes, well after any dependency it implies has already run),
+ // but it also makes Gradle infer configuration's own build dependencies against this task -
+ // the same implicit wiring project(...) dependencies get for free elsewhere in this plugin,
+ // which for a substituted coordinate resolves to the producing project's relevant task.
+ //
+ // This is deliberately skipped when dependsOnTasks is non-empty: that's the genuinely-not-
+ // published-anywhere-yet case (see :sample:actual-jvm-published), where the coordinate
+ // doesn't resolve via any substitution and asking Gradle to eagerly determine this
+ // configuration's build dependencies (which requires attempting real resolution) fails
+ // outright before dependsOnTasks's publish task ever runs. There, dependsOnTasks alone is
+ // both necessary and sufficient, exactly as before this fix.
+ val unpackTask = project.tasks.register("actualizerUnpack${safeName}Sources") { task ->
+ task.dependsOn(dependsOnTasks)
+ if (dependsOnTasks.isEmpty()) {
+ task.inputs.files(configuration).withPropertyName("actualizerPublishedSourcesArtifact")
+ }
+ task.outputs.dir(outputDir)
+ task.doLast {
+ outputDir.deleteRecursively()
+ outputDir.mkdirs()
+ project.sync { spec ->
+ spec.from(project.zipTree(configuration.singleFile))
+ spec.into(outputDir)
+ spec.include("**/*.kt")
+ }
+ }
+ }
+
+ return outputDir to unpackTask
+ }
+
+ /**
+ * The hand-written source directories of [sourceSetName] on [foreignProject], excluding
+ * anything under its build directory - so a foreign project's own generated stub never gets
+ * pulled into a merge alongside the real `actual` this leaf module provides.
+ */
+ private fun namedSourceSetDirs(foreignProject: Project, sourceSetName: String): List {
+ val sourceSets = kotlinSourceSets(foreignProject)
+ val sourceSet = sourceSets.findByName(sourceSetName) ?: return emptyList()
+ val kotlinDirSet = sourceSet.invokeGetter("getKotlin") as SourceDirectorySet
+ val buildDir = foreignProject.layout.buildDirectory.get().asFile.toPath()
+ return kotlinDirSet.srcDirs.filterNot { it.toPath().startsWith(buildDir) }
+ }
+
+ /**
+ * Registers [dirs] as extra source directories of [sourceSetName].
+ *
+ * [builtBy], when non-empty, is wired onto the [org.gradle.api.file.FileCollection] backing
+ * those directories - not just onto `compileKotlin` by whatever caller already knows to
+ * `dependsOn` the tasks that populate [dirs] (a generated-stub or unpacked-published-source
+ * directory, not a plain `project(...)` reference's already-on-disk source). Any *other*
+ * consumer of this source set - `sourcesJar` from a plain `withSourcesJar()`, for one real
+ * example - reads straight off the source set's `FileCollection`/`allSource`, not through
+ * `compileKotlin`, so it never sees a `dependsOn` added only there. Without `builtBy` here,
+ * such a task can run before [dirs] is actually populated, archiving whatever happened to be
+ * on disk at the time - Gradle's own task-validation flags exactly this as an "implicit
+ * dependency" hazard. Threading the build dependency through the FileCollection itself, the
+ * same way `sourceSet.kotlin.srcDir(someTask)` would, makes it visible to every consumer
+ * instead of just the one this plugin happened to think to wire manually.
+ */
+ private fun addSourceDir(project: Project, sourceSetName: String, dirs: List, builtBy: List = emptyList()) {
+ val sourceSets = kotlinSourceSets(project)
+ val sourceSet = sourceSets.getByName(sourceSetName)
+ val kotlinDirSet = sourceSet.invokeGetter("getKotlin") as SourceDirectorySet
+ if (builtBy.isEmpty()) {
+ kotlinDirSet.srcDirs(dirs)
+ } else {
+ kotlinDirSet.srcDirs(project.files(dirs).builtBy(*builtBy.toTypedArray()))
+ }
+ }
+
+ private fun kotlinSourceSets(project: Project): NamedDomainObjectContainer<*> {
+ val kotlinExtension = project.extensions.findByName("kotlin")
+ ?: error("[actualizer] '${project.path}' has no 'kotlin' extension; apply kotlin(\"jvm\") to it first.")
+ return kotlinExtension.invokeGetter("getSourceSets") as? NamedDomainObjectContainer<*>
+ ?: error("[actualizer] '${project.path}' has a 'kotlin' extension with no source set container.")
+ }
+
+ @Suppress("UNCHECKED_CAST")
+ private fun freeCompilerArgsProperty(task: Any): ListProperty {
+ val compilerOptions = task.invokeGetter("getCompilerOptions")
+ ?: error("[actualizer] a Kotlin compile task has no compilerOptions - is it really a KotlinCompile task?")
+ return compilerOptions.invokeGetter("getFreeCompilerArgs") as ListProperty
+ }
+
+ private fun Any.invokeGetter(methodName: String): Any? =
+ javaClass.methods.firstOrNull { it.name == methodName && it.parameterCount == 0 }?.invoke(this)
+}
diff --git a/plugin-build/gradle-plugin/src/main/kotlin/net/kernelpanicsoft/actualizer/gradle/ActualizerLinkReport.kt b/plugin-build/gradle-plugin/src/main/kotlin/net/kernelpanicsoft/actualizer/gradle/ActualizerLinkReport.kt
new file mode 100644
index 0000000..2fe13b3
--- /dev/null
+++ b/plugin-build/gradle-plugin/src/main/kotlin/net/kernelpanicsoft/actualizer/gradle/ActualizerLinkReport.kt
@@ -0,0 +1,100 @@
+package net.kernelpanicsoft.actualizer.gradle
+
+import org.jetbrains.kotlin.K1Deprecation
+import org.jetbrains.kotlin.cli.jvm.compiler.EnvironmentConfigFiles
+import org.jetbrains.kotlin.cli.jvm.compiler.KotlinCoreEnvironment
+import org.jetbrains.kotlin.com.intellij.openapi.util.Disposer
+import org.jetbrains.kotlin.config.CompilerConfiguration
+import org.jetbrains.kotlin.psi.KtNamedDeclaration
+import org.jetbrains.kotlin.psi.KtPsiFactory
+import java.io.File
+
+/** A foreign source root merged into this compilation, and the Gradle module (or coordinate) that owns it. */
+internal data class LinkReportModuleRoot(val root: File, val moduleName: String)
+
+private data class LinkEntry(val actualFqName: String, val owningModules: List)
+
+/**
+ * Reports which locally-declared top-level declaration in [ownFiles] shares a package with a
+ * foreign file merged in from one of [moduleMap]'s roots - the same "this actual links a foreign
+ * expect" heuristic `actualizes(...)` compilations have always used, just computed from source
+ * text via PSI instead of from inside the compiler.
+ *
+ * This used to run as an `IrGenerationExtension` in the compiler plugin, reading `IrFile.fileEntry`
+ * and `packageFqName` off the already-linked IR. That was never buying anything real: it only ever
+ * matched by package name, the same heuristic available straight from source text, and a real
+ * consumer's Kotlin daemon can be running a different Kotlin version than this plugin compiles
+ * against - which is exactly what surfaced a `NoClassDefFoundError` on
+ * `org.jetbrains.kotlin.extensions.ExtensionPointDescriptor` (internal compiler-extension plumbing,
+ * unrelated to anything this report actually needs) and took down a real build. Doing this in
+ * Gradle instead means it can't be broken by any Kotlin compiler/daemon version at all.
+ *
+ * Not a real linking guarantee, same as before: sharing a package is a heuristic, not confirmation
+ * that a specific `actual` really resolves a specific foreign `expect` at the symbol level.
+ */
+internal fun writeActualizationLinkReport(
+ ownFiles: List,
+ moduleMap: List,
+ selfModule: String,
+ reportOutput: File,
+) {
+ val disposable = Disposer.newDisposable("actualizer-link-report")
+ val links = try {
+ @OptIn(K1Deprecation::class, CompilerConfiguration.Internals::class)
+ val environment = KotlinCoreEnvironment.createForProduction(
+ disposable,
+ CompilerConfiguration(),
+ EnvironmentConfigFiles.JVM_CONFIG_FILES,
+ )
+ val psiFactory = KtPsiFactory(environment.project)
+
+ val foreignModulesByPackage = mutableMapOf>()
+ for (foreignRoot in moduleMap) {
+ if (!foreignRoot.root.exists()) continue
+ foreignRoot.root.walkTopDown().filter { it.isFile && it.extension == "kt" }.forEach { file ->
+ val packageName = psiFactory.createFile(file.name, file.readText()).packageFqName.asString()
+ foreignModulesByPackage.getOrPut(packageName) { mutableListOf() } += foreignRoot.moduleName
+ }
+ }
+
+ val links = mutableListOf()
+ for (file in ownFiles) {
+ if (!file.exists()) continue
+ val ktFile = psiFactory.createFile(file.name, file.readText())
+ val packageName = ktFile.packageFqName.asString()
+ val owningModules = foreignModulesByPackage[packageName] ?: continue
+ for (declaration in ktFile.declarations) {
+ val name = (declaration as? KtNamedDeclaration)?.name ?: continue
+ links += LinkEntry("$packageName.$name", owningModules)
+ }
+ }
+ links
+ } finally {
+ Disposer.dispose(disposable)
+ }
+
+ writeReportJson(reportOutput, selfModule, links)
+}
+
+/** Writes [links] to [reportOutput] as JSON, in the shape `{ consumingModule, links: [{ actual, owningModules }] }`. */
+private fun writeReportJson(reportOutput: File, selfModule: String, links: List) {
+ reportOutput.parentFile?.mkdirs()
+ val json = buildString {
+ append("{\n")
+ append(" \"consumingModule\": \"").append(selfModule).append("\",\n")
+ append(" \"links\": [\n")
+ links.forEachIndexed { index, link ->
+ append(" {")
+ append("\"actual\": \"").append(link.actualFqName).append("\", ")
+ append("\"owningModules\": [")
+ append(link.owningModules.joinToString(", ") { "\"$it\"" })
+ append("]")
+ append("}")
+ if (index != links.lastIndex) append(",")
+ append("\n")
+ }
+ append(" ]\n")
+ append("}\n")
+ }
+ reportOutput.writeText(json)
+}
diff --git a/plugin-build/gradle-plugin/src/main/kotlin/net/kernelpanicsoft/actualizer/gradle/ExpectStubGenerator.kt b/plugin-build/gradle-plugin/src/main/kotlin/net/kernelpanicsoft/actualizer/gradle/ExpectStubGenerator.kt
new file mode 100644
index 0000000..e435c47
--- /dev/null
+++ b/plugin-build/gradle-plugin/src/main/kotlin/net/kernelpanicsoft/actualizer/gradle/ExpectStubGenerator.kt
@@ -0,0 +1,645 @@
+package net.kernelpanicsoft.actualizer.gradle
+
+import com.squareup.kotlinpoet.ClassName
+import com.squareup.kotlinpoet.CodeBlock
+import com.squareup.kotlinpoet.FileSpec
+import com.squareup.kotlinpoet.FunSpec
+import com.squareup.kotlinpoet.KModifier
+import com.squareup.kotlinpoet.LambdaTypeName
+import com.squareup.kotlinpoet.ParameterizedTypeName.Companion.parameterizedBy
+import com.squareup.kotlinpoet.PropertySpec
+import com.squareup.kotlinpoet.STAR
+import com.squareup.kotlinpoet.TypeName
+import com.squareup.kotlinpoet.TypeSpec
+import com.squareup.kotlinpoet.TypeVariableName
+import com.squareup.kotlinpoet.WildcardTypeName
+import org.jetbrains.kotlin.K1Deprecation
+import org.jetbrains.kotlin.cli.jvm.compiler.EnvironmentConfigFiles
+import org.jetbrains.kotlin.cli.jvm.compiler.KotlinCoreEnvironment
+import org.jetbrains.kotlin.com.intellij.openapi.util.Disposer
+import org.jetbrains.kotlin.config.CompilerConfiguration
+import org.jetbrains.kotlin.lexer.KtTokens
+import org.jetbrains.kotlin.psi.KtClass
+import org.jetbrains.kotlin.psi.KtClassOrObject
+import org.jetbrains.kotlin.psi.KtDeclaration
+import org.jetbrains.kotlin.psi.KtEnumEntry
+import org.jetbrains.kotlin.psi.KtFile
+import org.jetbrains.kotlin.psi.KtFunctionType
+import org.jetbrains.kotlin.psi.KtNamedFunction
+import org.jetbrains.kotlin.psi.KtNullableType
+import org.jetbrains.kotlin.psi.KtObjectDeclaration
+import org.jetbrains.kotlin.psi.KtProjectionKind
+import org.jetbrains.kotlin.psi.KtProperty
+import org.jetbrains.kotlin.psi.KtPsiFactory
+import org.jetbrains.kotlin.psi.KtSecondaryConstructor
+import org.jetbrains.kotlin.psi.KtSuperTypeCallEntry
+import org.jetbrains.kotlin.psi.KtTypeAlias
+import org.jetbrains.kotlin.psi.KtTypeElement
+import org.jetbrains.kotlin.psi.KtTypeParameterListOwner
+import org.jetbrains.kotlin.psi.KtTypeReference
+import org.jetbrains.kotlin.psi.KtUserType
+import org.jetbrains.kotlin.psi.KtValueArgument
+import java.io.File
+
+// Scans and generates stub actuals for unfulfilled expect declarations, backing
+// ActualizerExtension.stubUnfulfilledExpects(). Finding expect declarations uses real PSI
+// parsing (KtPsiFactory) rather than a regex/brace-counting scan, so multi-line declarations and
+// odd formatting are handled correctly. Parsing is syntax-only - no semantic resolution, since
+// that's exactly what would fail on the unfulfilled expects being scanned for. Types are read by
+// walking the PSI type tree directly (see KtTypeReference.toTypeName below) rather than
+// re-parsing text, which is what makes nested function types resolve correctly. A function's or
+// class's own type parameters (``, plus `where` clauses) are captured too, including
+// `out`/`in`/`*` variance on type arguments, supertypes, secondary constructors, nested expect
+// classes, and constructor `val`/`var` parameters - all of that is part of what the real
+// actual-checker compares against the expect. Out of scope: annotations, contracts, and
+// destructuring in parameters. Generated code goes through KotlinPoet rather than string
+// concatenation.
+
+internal data class ParamText(val name: String, val type: TypeName)
+
+internal enum class ValOrVar { NONE, VAL, VAR }
+
+internal data class ConstructorParamText(val name: String, val type: TypeName, val valOrVar: ValOrVar = ValOrVar.NONE)
+
+internal enum class ClassKind { CLASS, INTERFACE, FUN_INTERFACE, OBJECT, ENUM }
+
+/** An enum entry's name, plus its constructor call's argument expressions as raw source text, if any. */
+internal data class EnumEntryText(val name: String, val argumentTexts: List = emptyList())
+
+internal sealed class ExpectMember {
+ abstract val name: String
+
+ data class Function(
+ override val name: String,
+ val params: List,
+ val returnType: TypeName,
+ val suspending: Boolean = false,
+ val typeVariables: List = emptyList(),
+ ) : ExpectMember()
+
+ data class Property(
+ override val name: String,
+ val type: TypeName,
+ val mutable: Boolean,
+ ) : ExpectMember()
+}
+
+internal data class ExpectClassInfo(
+ val name: String,
+ val kind: ClassKind,
+ val constructorParams: List,
+ val members: List,
+ val typeVariables: List = emptyList(),
+ // Every entry in the expect class's supertype list, treated uniformly as an interface to
+ // implement - see toClassInfo's doc for why a real superclass can't be told apart from an
+ // interface here, and can't have its constructor called from an expect class anyway.
+ val supertypes: List = emptyList(),
+ val secondaryConstructors: List> = emptyList(),
+ val nestedClasses: List = emptyList(),
+ val enumEntries: List = emptyList(),
+ // Only meaningful for CLASS - interfaces/fun interfaces are implicitly abstract/open already,
+ // enum classes and objects can't take a modality modifier at all. null means final (the
+ // default - Kotlin rarely writes `final` explicitly).
+ val modalityModifier: KModifier? = null,
+ // Kotlin doesn't synthesize an implicit no-arg primary constructor for a class that declares
+ // secondary constructors but no primary - those secondaries must delegate to `super(...)`
+ // instead of `this(...)`. True whenever there either is a primary constructor or there are no
+ // secondary constructors to worry about.
+ val hasPrimaryConstructor: Boolean = true,
+)
+
+internal data class ScannedExpectFile(
+ val sourceFile: File,
+ val packageName: String,
+ val topLevel: List,
+ val classes: List,
+)
+
+/** Parses [files] for top-level `expect` declarations, returning one entry per file that has any. */
+@OptIn(K1Deprecation::class, CompilerConfiguration.Internals::class)
+internal fun scanForExpectFunctions(files: Iterable): List {
+ val ktFiles = files.filter { it.isFile && it.extension == "kt" }
+ if (ktFiles.isEmpty()) return emptyList()
+
+ // A throwaway frontend environment just to get a KtPsiFactory, disposed in `finally` so it
+ // doesn't leak across builds in a long-lived Gradle daemon. KotlinCoreEnvironment is K1
+ // machinery, deprecated for real compilation but still the standard way to stand up a
+ // lightweight PSI factory for tooling like this.
+ val disposable = Disposer.newDisposable("actualizer-expect-scan")
+ try {
+ val environment = KotlinCoreEnvironment.createForProduction(
+ disposable,
+ CompilerConfiguration(),
+ EnvironmentConfigFiles.JVM_CONFIG_FILES,
+ )
+ val psiFactory = KtPsiFactory(environment.project)
+ val parsed = ktFiles.map { file -> file to psiFactory.createFile(file.name, file.readText()) }
+ // A hand-written `actual` - a class, a function, a property, or (since `expect class Foo`
+ // with no members is exactly how a real actualization aliases to an existing type, e.g.
+ // `actual typealias Foo = String`) a typealias - already fulfilling one of these expects
+ // elsewhere in this same scan means this isn't actually unfulfilled; skip it rather than
+ // generating a second, conflicting actual for the same name.
+ val alreadyActualized = collectAlreadyActualized(parsed.map { it.second })
+ return parsed.mapNotNull { (file, ktFile) -> scanKtFile(file, ktFile, alreadyActualized) }
+ } finally {
+ Disposer.dispose(disposable)
+ }
+}
+
+/** Top-level `actual` declarations' (package, simple name) pairs, across every file in this scan. */
+private fun collectAlreadyActualized(ktFiles: List): Set> {
+ val result = mutableSetOf>()
+ for (ktFile in ktFiles) {
+ val packageName = ktFile.packageFqName.asString()
+ for (declaration in ktFile.declarations) {
+ if (!declaration.hasModifier(KtTokens.ACTUAL_KEYWORD)) continue
+ val name = when (declaration) {
+ is KtTypeAlias -> declaration.name
+ is KtNamedFunction -> declaration.name
+ is KtProperty -> declaration.name
+ is KtClassOrObject -> declaration.name
+ else -> null
+ } ?: continue
+ result += packageName to name
+ }
+ }
+ return result
+}
+
+private fun scanKtFile(sourceFile: File, ktFile: KtFile, alreadyActualized: Set>): ScannedExpectFile? {
+ val topLevel = mutableListOf()
+ val classes = mutableListOf()
+ val context = TypeContext(importedSimpleNames(ktFile), ktFile.packageFqName.asString())
+ fun isFulfilled(name: String?) = name != null && (context.packageName to name) in alreadyActualized
+
+ for (declaration in ktFile.declarations) {
+ when {
+ declaration is KtNamedFunction && declaration.isExpect() && !isFulfilled(declaration.name) ->
+ topLevel += toFunctionMember(declaration, context)
+ declaration is KtProperty && declaration.isExpect() && !isFulfilled(declaration.name) ->
+ topLevel += toPropertyMember(declaration, context)
+ declaration is KtClassOrObject && declaration.isExpect() && !isFulfilled(declaration.name) ->
+ classes += toClassInfo(declaration, context)
+ }
+ }
+
+ if (topLevel.isEmpty() && classes.isEmpty()) return null
+ return ScannedExpectFile(sourceFile, context.packageName, topLevel, classes)
+}
+
+private fun KtDeclaration.isExpect(): Boolean = hasModifier(KtTokens.EXPECT_KEYWORD)
+
+/**
+ * Simple name to fully-qualified name, plus the file's own package - see [classNameFor] - and the
+ * names of any type parameters currently in scope (from an enclosing `expect class`/`expect fun`),
+ * so a bare `T` resolves to a [TypeVariableName] instead of being mistaken for a sibling class in
+ * the same package.
+ */
+private class TypeContext(
+ val imports: Map,
+ val packageName: String,
+ val typeParameterNames: Set = emptySet(),
+) {
+ fun withTypeParameters(names: Collection): TypeContext =
+ if (names.isEmpty()) this else TypeContext(imports, packageName, typeParameterNames + names)
+}
+
+/**
+ * Combines each type parameter's `` upper bound with any extra bounds from a trailing
+ * `where T : A, T : B` clause, since both are valid ways to add bounds to the same `T`.
+ */
+private fun KtTypeParameterListOwner.typeVariableNames(context: TypeContext): List {
+ val whereBounds = typeConstraints
+ .mapNotNull { constraint ->
+ val name = constraint.subjectTypeParameterName?.getReferencedName() ?: return@mapNotNull null
+ val bound = constraint.boundTypeReference ?: return@mapNotNull null
+ name to bound
+ }
+ .groupBy({ it.first }, { it.second })
+ return typeParameters.map { param ->
+ val name = param.name ?: "T"
+ val bounds = (listOfNotNull(param.extendsBound) + whereBounds[name].orEmpty()).map { it.toTypeName(context) }
+ if (bounds.isEmpty()) TypeVariableName(name) else TypeVariableName(name, *bounds.toTypedArray())
+ }
+}
+
+/** Simple name to fully-qualified name from [ktFile]'s `import` directives (star imports are skipped, since expanding them needs semantic resolution). */
+private fun importedSimpleNames(ktFile: KtFile): Map =
+ ktFile.importDirectives.mapNotNull { directive ->
+ if (directive.isAllUnder) return@mapNotNull null
+ val fqName = directive.importedFqName?.asString() ?: return@mapNotNull null
+ val simpleName = directive.aliasName ?: fqName.substringAfterLast('.')
+ simpleName to fqName
+ }.toMap()
+
+private fun toFunctionMember(function: KtNamedFunction, context: TypeContext): ExpectMember.Function {
+ val typeVariables = function.typeVariableNames(context)
+ val innerContext = context.withTypeParameters(typeVariables.map { it.name })
+ val params = function.valueParameters.map { param ->
+ ParamText(param.name ?: "arg", param.typeReference?.toTypeName(innerContext) ?: ANY)
+ }
+ return ExpectMember.Function(
+ name = function.name ?: error("Actualizer: found an unnamed 'expect fun' in ${function.containingFile.name}"),
+ params = params,
+ returnType = function.typeReference?.toTypeName(innerContext) ?: UNIT,
+ suspending = function.hasModifier(KtTokens.SUSPEND_KEYWORD),
+ typeVariables = typeVariables,
+ )
+}
+
+private fun toPropertyMember(property: KtProperty, context: TypeContext): ExpectMember.Property {
+ val typeReference = property.typeReference
+ ?: error(
+ "Actualizer: 'expect ${if (property.isVar) "var" else "val"} ${property.name}' in " +
+ "${property.containingFile.name} needs an explicit type for stubUnfulfilledExpects() to stub it."
+ )
+ return ExpectMember.Property(
+ name = property.name ?: error("Actualizer: found an unnamed 'expect val/var' in ${property.containingFile.name}"),
+ type = typeReference.toTypeName(context),
+ mutable = property.isVar,
+ )
+}
+
+private fun classKindOf(cls: KtClassOrObject): ClassKind = when {
+ cls is KtObjectDeclaration -> ClassKind.OBJECT
+ cls is KtClass && cls.isInterface() -> if (cls.hasModifier(KtTokens.FUN_KEYWORD)) ClassKind.FUN_INTERFACE else ClassKind.INTERFACE
+ cls is KtClass && cls.isEnum() -> ClassKind.ENUM
+ else -> ClassKind.CLASS
+}
+
+/** Only meaningful for [ClassKind.CLASS] - interfaces are implicitly open, enums/objects can't take one at all. */
+private fun modalityModifierOf(cls: KtClassOrObject, kind: ClassKind): KModifier? {
+ if (kind != ClassKind.CLASS) return null
+ return when {
+ cls.hasModifier(KtTokens.SEALED_KEYWORD) -> KModifier.SEALED
+ cls.hasModifier(KtTokens.ABSTRACT_KEYWORD) -> KModifier.ABSTRACT
+ cls.hasModifier(KtTokens.OPEN_KEYWORD) -> KModifier.OPEN
+ else -> null
+ }
+}
+
+private fun toClassInfo(cls: KtClassOrObject, context: TypeContext): ExpectClassInfo {
+ val kind = classKindOf(cls)
+ val typeVariables = cls.typeVariableNames(context)
+ val innerContext = context.withTypeParameters(typeVariables.map { it.name })
+
+ // KtObjectDeclaration/an interface has no primary/secondary constructors - only a class or enum
+ // class does, but both accessors live on KtClassOrObject itself and just return null/empty
+ // otherwise.
+ val primaryConstructor = cls.primaryConstructor
+ val constructorParams = primaryConstructor?.valueParameters.orEmpty().map { param ->
+ ConstructorParamText(
+ name = param.name ?: "arg",
+ type = param.typeReference?.toTypeName(innerContext) ?: ANY,
+ // Kotlin doesn't currently allow `val`/`var` directly on an expect class's primary
+ // constructor parameters at all ("Expected class constructor cannot have a property
+ // parameter") - not even for the constructor Kotlin itself would otherwise require one
+ // for, like a data class (which is why `expect data class` doesn't exist either). So
+ // this is always ValOrVar.NONE for real input; kept in case that restriction is ever
+ // relaxed, since handling it right there costs nothing extra.
+ valOrVar = when {
+ !param.hasValOrVar() -> ValOrVar.NONE
+ param.isMutable -> ValOrVar.VAR
+ else -> ValOrVar.VAL
+ },
+ )
+ }
+ val secondaryConstructorNodes = cls.secondaryConstructors
+ // A class only gets an implicit no-arg primary constructor when it declares no constructor at
+ // all; one with only secondary constructors has none, and those must delegate to `super(...)`
+ // instead of `this(...)` - see hasPrimaryConstructor's doc.
+ val hasPrimaryConstructor = primaryConstructor != null || secondaryConstructorNodes.isEmpty()
+
+ // Every entry here is a bare type reference, never a constructor call - Kotlin doesn't let an
+ // expect class initialize a real superclass at all ("Expected classes cannot initialize
+ // supertypes"), even a no-arg one; `expect class Foo : Base, Marker` only ever writes bare
+ // names, whether Base is a class or an interface. Without semantic resolution there's no way
+ // to tell which of these (if any) is a real class rather than an interface, so all of them are
+ // added the same way below - see ExpectClassInfo.supertypes's doc.
+ val supertypes = cls.superTypeListEntries.mapNotNull { entry -> entry.typeReference?.toTypeName(innerContext) }
+
+ val secondaryConstructors = secondaryConstructorNodes.map { ctor ->
+ ctor.valueParameters.map { param ->
+ ParamText(param.name ?: "arg", param.typeReference?.toTypeName(innerContext) ?: ANY)
+ }
+ }
+
+ val members = mutableListOf()
+ val nestedClasses = mutableListOf()
+ val enumEntries = mutableListOf()
+ for (member in cls.body?.declarations.orEmpty()) {
+ when {
+ // KtEnumEntry extends KtClass (itself a KtClassOrObject), so this has to come before
+ // the plain-function/nested-class checks below or an entry would be misread as one.
+ member is KtEnumEntry -> enumEntries += EnumEntryText(
+ name = member.name ?: "ENTRY",
+ argumentTexts = (member.superTypeListEntries.firstOrNull() as? KtSuperTypeCallEntry)
+ ?.valueArguments.orEmpty()
+ .mapNotNull { (it as? KtValueArgument)?.getArgumentExpression()?.text },
+ )
+ member is KtNamedFunction -> members += toFunctionMember(member, innerContext)
+ member is KtProperty -> members += toPropertyMember(member, innerContext)
+ // A nested class inside an expect class's body is always implicitly part of the
+ // expected shape too - Kotlin doesn't allow (and doesn't require) marking it `expect`
+ // itself ("Modifier 'expect' is not applicable to 'nested class'"), unlike a top-level
+ // expect class/object, which does need the explicit keyword.
+ member is KtClassOrObject -> nestedClasses += toClassInfo(member, innerContext)
+ // Secondary constructors are handled above; a companion object or init block doesn't
+ // need an actual stub of its own - out of scope.
+ }
+ }
+
+ return ExpectClassInfo(
+ name = cls.name ?: error("Actualizer: found an unnamed 'expect class'/'expect object' in ${cls.containingFile.name}"),
+ kind = kind,
+ constructorParams = constructorParams,
+ members = members,
+ typeVariables = typeVariables,
+ supertypes = supertypes,
+ secondaryConstructors = secondaryConstructors,
+ nestedClasses = nestedClasses,
+ enumEntries = enumEntries,
+ modalityModifier = modalityModifierOf(cls, kind),
+ hasPrimaryConstructor = hasPrimaryConstructor,
+ )
+}
+
+// ClassName.bestGuess("String") treats an unqualified name as a default-package class and
+// KotlinPoet then emits a bogus `import String`. Mapping common stdlib names to their real
+// kotlin/kotlin.collections package avoids that.
+private val KOTLIN_BUILTIN_TYPES = listOf(
+ "Any", "Unit", "Nothing", "String", "CharSequence", "Boolean",
+ "Byte", "Short", "Int", "Long", "Float", "Double", "Char", "Number", "Array",
+).associateWith { ClassName("kotlin", it) }
+
+private val KOTLIN_COLLECTION_TYPES = listOf(
+ "List", "MutableList", "Set", "MutableSet", "Map", "MutableMap",
+ "Collection", "MutableCollection", "Iterable", "MutableIterable", "Iterator", "MutableIterator",
+).associateWith { ClassName("kotlin.collections", it) }
+
+private val ANY: TypeName = KOTLIN_BUILTIN_TYPES.getValue("Any")
+private val UNIT: TypeName = KOTLIN_BUILTIN_TYPES.getValue("Unit")
+
+/**
+ * Converts a type reference to a KotlinPoet [TypeName] by walking the PSI type tree Kotlin's own
+ * parser already built, rather than re-parsing its text. That's what makes nested function types
+ * like `() -> () -> Screen` resolve correctly - `KtFunctionType.returnTypeReference` is itself
+ * another `KtFunctionType`, so recursing through real PSI nodes handles that, receivers, `suspend`,
+ * and nullable-wrapped lambdas for free.
+ *
+ * Still syntax only, not resolved semantics: an unqualified [KtUserType] is matched against
+ * [KOTLIN_BUILTIN_TYPES]/[KOTLIN_COLLECTION_TYPES], [TypeContext.imports], and any type parameter
+ * currently in scope ([TypeContext.typeParameterNames]), falling back to a sibling type in the
+ * same package. That fallback is a guess - a star-imported type, or one genuinely in the default
+ * package, resolves to the wrong package. Write fully-qualified types if that matters. A qualified
+ * reference like `HolderLookup.Provider` resolves its outer segment (`HolderLookup`) the same way,
+ * then nests - so an imported outer class's nested type resolves correctly, not just top-level
+ * types. Variance on type arguments (`out`/`in`/`*`) is preserved, since it's part of what makes an
+ * `actual` match its `expect` in the first place.
+ */
+private fun KtTypeReference.toTypeName(context: TypeContext): TypeName {
+ val element = typeElement ?: return ANY
+ val suspending = hasModifier(KtTokens.SUSPEND_KEYWORD)
+ return element.toTypeName(context, suspending)
+}
+
+private fun KtTypeElement.toTypeName(context: TypeContext, suspending: Boolean = false): TypeName = when (this) {
+ is KtNullableType -> {
+ val inner = innerType?.toTypeName(context, suspending) ?: ANY
+ inner.copy(nullable = true)
+ }
+ is KtFunctionType -> {
+ val paramTypes = parameterList?.parameters.orEmpty()
+ .map { it.typeReference?.toTypeName(context) ?: ANY }
+ .toTypedArray()
+ val returnType = returnTypeReference?.toTypeName(context) ?: UNIT
+ val receiver = receiverTypeReference?.toTypeName(context)
+ LambdaTypeName.get(receiver, *paramTypes, returnType = returnType).copy(suspending = suspending)
+ }
+ is KtUserType -> {
+ val simpleName = referencedName ?: "Any"
+ val qualifierNode = qualifier
+ if (qualifierNode == null && simpleName in context.typeParameterNames) {
+ TypeVariableName(simpleName)
+ } else {
+ // A qualifier like `HolderLookup` in `HolderLookup.Provider` is usually an imported
+ // outer class's simple name, not a package prefix - resolving it the same way a bare
+ // reference would (imports/builtins/same-package fallback) and nesting from there
+ // handles that. Only a qualifier that's genuinely unresolvable falls back to the old
+ // bestGuess behavior, which only works when the qualifier text already is a package.
+ val base = if (qualifierNode != null) {
+ (qualifierNode.toTypeName(context) as? ClassName)?.nestedClass(simpleName)
+ ?: ClassName.bestGuess("${qualifierNode.text}.$simpleName")
+ } else {
+ classNameFor(simpleName, context)
+ }
+ val typeArgs = typeArgumentList?.arguments.orEmpty().map { projection ->
+ when (projection.projectionKind) {
+ KtProjectionKind.STAR -> STAR
+ KtProjectionKind.OUT -> WildcardTypeName.producerOf(projection.typeReference?.toTypeName(context) ?: ANY)
+ KtProjectionKind.IN -> WildcardTypeName.consumerOf(projection.typeReference?.toTypeName(context) ?: ANY)
+ KtProjectionKind.NONE -> projection.typeReference?.toTypeName(context) ?: ANY
+ }
+ }
+ if (typeArgs.isEmpty()) base else base.parameterizedBy(typeArgs)
+ }
+ }
+ else -> ANY // KtDynamicType (JS-only) etc. - out of scope.
+}
+
+private fun classNameFor(simpleName: String, context: TypeContext): ClassName {
+ KOTLIN_BUILTIN_TYPES[simpleName]?.let { return it }
+ KOTLIN_COLLECTION_TYPES[simpleName]?.let { return it }
+ context.imports[simpleName]?.let { return ClassName.bestGuess(it) }
+ return ClassName(context.packageName, simpleName)
+}
+
+private val illegalStateExceptionClass = ClassName("kotlin", "IllegalStateException")
+
+// The IDE's live analysis of a plain kotlin("jvm") module would otherwise report expect/actual as
+// illegally coexisting, even though -Xmulti-platform makes the real build succeed - see the root
+// README's "IDE false positives" section. No manual @Suppress needed on the generated declarations
+// for that: ActualizerGradlePlugin.wireStubGeneration registers the same compiler plugin
+// wireCrossModuleActualization does, whose FIR extension (ActualizerFirExtensions.kt) auto-injects
+// it into every file containing an expect/actual, including this generated one.
+private fun throwStatement(fqName: String): CodeBlock = CodeBlock.of(
+ "throw %T(%S)",
+ illegalStateExceptionClass,
+ "$fqName was never actualized - this jar was built standalone with an auto-generated stub " +
+ "(see actualizer { stubUnfulfilledExpects() }). Depend on a module that actually links it " +
+ "(an actualizer { actualizes(...) } leaf) to get the real implementation.",
+)
+
+/**
+ * [keepAbstract] is for members of an `interface`/`fun interface`: an interface function's default
+ * modality is `open` if it has a body, but `abstract` if it doesn't, and the expect side is always
+ * `abstract` (interfaces can't have bodies at all in `expect` code) - giving it a throwing body
+ * here would make the actual `open`, an expect/actual modality mismatch. For a `fun interface`
+ * specifically there's a second reason too: Kotlin requires exactly one truly abstract member
+ * there, so a body would also make it stop being a functional interface. Leaving it abstract is
+ * fine either way - nothing here needs to implement it; whatever concretely implements the
+ * interface (or lambda, for a fun interface) does that.
+ */
+private fun buildFunctionStub(member: ExpectMember.Function, fqNamePrefix: String, keepAbstract: Boolean = false): FunSpec {
+ val builder = FunSpec.builder(member.name)
+ .addModifiers(KModifier.ACTUAL)
+ .addTypeVariables(member.typeVariables)
+ if (member.suspending) {
+ builder.addModifiers(KModifier.SUSPEND)
+ }
+ for (param in member.params) {
+ builder.addParameter(param.name, param.type)
+ }
+ if (member.returnType != UNIT) {
+ builder.returns(member.returnType)
+ }
+ if (keepAbstract) {
+ builder.addModifiers(KModifier.ABSTRACT)
+ } else {
+ builder.addCode(throwStatement("$fqNamePrefix.${member.name}"))
+ }
+ return builder.build()
+}
+
+// See buildFunctionStub's keepAbstract doc - the same modality mismatch applies to a property in
+// a plain (non-fun) interface: giving its accessor a body makes it `open` by default, not
+// `abstract` like the expect declares, so a member of an INTERFACE/FUN_INTERFACE stays abstract
+// (no accessor bodies at all) instead of throwing.
+private fun buildPropertyStub(member: ExpectMember.Property, fqNamePrefix: String, keepAbstract: Boolean = false): PropertySpec {
+ val type = member.type
+ val fqName = "$fqNamePrefix.${member.name}"
+ val builder = PropertySpec.builder(member.name, type)
+ .addModifiers(KModifier.ACTUAL)
+ .mutable(member.mutable)
+ if (keepAbstract) {
+ builder.addModifiers(KModifier.ABSTRACT)
+ return builder.build()
+ }
+ builder.getter(FunSpec.getterBuilder().addCode(throwStatement(fqName)).build())
+ if (member.mutable) {
+ builder.setter(
+ FunSpec.setterBuilder()
+ .addParameter("value", type)
+ .addCode(throwStatement(fqName))
+ .build()
+ )
+ }
+ return builder.build()
+}
+
+private fun buildClassStub(cls: ExpectClassInfo, packageName: String): TypeSpec {
+ val classFqName = "$packageName.${cls.name}"
+ val builder = when (cls.kind) {
+ ClassKind.OBJECT -> TypeSpec.objectBuilder(cls.name)
+ ClassKind.INTERFACE -> TypeSpec.interfaceBuilder(cls.name)
+ ClassKind.FUN_INTERFACE -> TypeSpec.funInterfaceBuilder(cls.name)
+ ClassKind.ENUM -> TypeSpec.enumBuilder(cls.name)
+ ClassKind.CLASS -> TypeSpec.classBuilder(cls.name)
+ }
+ .addModifiers(KModifier.ACTUAL)
+ .addTypeVariables(cls.typeVariables)
+ cls.modalityModifier?.let { builder.addModifiers(it) }
+
+ cls.supertypes.forEach { builder.addSuperinterface(it) }
+
+ // Enum constants have to be added before any other member (init block, constructors,
+ // functions) or KotlinPoet won't emit the `;` separator real Kotlin syntax requires between
+ // the constant list and the rest of an enum class body that has one.
+ for (entry in cls.enumEntries) {
+ if (entry.argumentTexts.isEmpty()) {
+ builder.addEnumConstant(entry.name)
+ } else {
+ val entryBody = TypeSpec.anonymousClassBuilder()
+ entry.argumentTexts.forEach { text -> entryBody.addSuperclassConstructorParameter("%L", text) }
+ builder.addEnumConstant(entry.name, entryBody.build())
+ }
+ }
+
+ // Interfaces/fun interfaces can't have a constructor or an init block at all - only their
+ // member stubs (built below) can throw.
+ if (cls.kind != ClassKind.INTERFACE && cls.kind != ClassKind.FUN_INTERFACE) {
+ // expect object has no primary constructor to actualize - it's a singleton.
+ if (cls.kind != ClassKind.OBJECT && cls.constructorParams.isNotEmpty()) {
+ // The primary constructor needs its own `actual` modifier, not just the class.
+ val ctor = FunSpec.constructorBuilder().addModifiers(KModifier.ACTUAL)
+ cls.constructorParams.forEach { param ->
+ ctor.addParameter(param.name, param.type)
+ // A constructor parameter declared `val`/`var` is also a property - KotlinPoet
+ // recognizes a property whose initializer is exactly its matching constructor
+ // parameter's name and inlines it into the parameter list (`actual constructor(val x: T)`)
+ // instead of emitting a separate property. See ConstructorParamText.valOrVar's doc
+ // for why this never actually triggers for a real expect class today.
+ when (param.valOrVar) {
+ ValOrVar.VAL -> builder.addProperty(PropertySpec.builder(param.name, param.type).initializer(param.name).build())
+ ValOrVar.VAR -> builder.addProperty(PropertySpec.builder(param.name, param.type).mutable(true).initializer(param.name).build())
+ ValOrVar.NONE -> {}
+ }
+ }
+ builder.primaryConstructor(ctor.build())
+ }
+ // Throws on construction so the error surfaces immediately rather than only when a
+ // particular member gets called - the member stubs still need to exist to match the
+ // expect class's shape, though. Skipped for ENUM: Kotlin doesn't let an expect enum class
+ // have a constructor at all, so there's no "construction" to intercept the same way - its
+ // entries are exactly the actualized shape already, not unimplemented behavior. It's also
+ // a KotlinPoet 1.18.1 rendering bug for this specific combination - an init block right
+ // after enum constants with no constructor doesn't get the required `;` separator.
+ if (cls.kind != ClassKind.ENUM) {
+ builder.addInitializerBlock(throwStatement(classFqName))
+ }
+
+ for (params in cls.secondaryConstructors) {
+ val ctor = FunSpec.constructorBuilder().addModifiers(KModifier.ACTUAL)
+ params.forEach { ctor.addParameter(it.name, it.type) }
+ if (cls.hasPrimaryConstructor) {
+ // Every delegation argument is a `throw` expression, which types as Nothing - a
+ // subtype of any parameter type, so this always type-checks regardless of what the
+ // primary constructor's real parameter types are. Only the first argument ever
+ // actually runs; it throws before a second one would be evaluated, so construction
+ // still fails with the same clear message as the primary constructor's own init
+ // block, not a confusing raw exception from a fabricated dummy value. The argument
+ // count has to match the *primary* constructor's arity, not this secondary
+ // constructor's own.
+ ctor.callThisConstructor(List(cls.constructorParams.size) { throwStatement(classFqName) })
+ } else {
+ // No primary constructor to delegate to - falls back to a no-arg super(), which is
+ // only correct if the real superclass also has a no-arg constructor. Out of scope:
+ // resolving the actual superclass constructor's real arity without semantic analysis.
+ ctor.callSuperConstructor(emptyList())
+ }
+ builder.addFunction(ctor.build())
+ }
+ }
+
+ val keepMembersAbstract = cls.kind == ClassKind.INTERFACE || cls.kind == ClassKind.FUN_INTERFACE
+ for (member in cls.members) {
+ when (member) {
+ is ExpectMember.Function -> builder.addFunction(buildFunctionStub(member, classFqName, keepMembersAbstract))
+ is ExpectMember.Property -> builder.addProperty(buildPropertyStub(member, classFqName, keepMembersAbstract))
+ }
+ }
+ for (nested in cls.nestedClasses) {
+ builder.addType(buildClassStub(nested, classFqName))
+ }
+ return builder.build()
+}
+
+/** Renders throwing `actual` stubs for every declaration [scanned] found, as Kotlin source text. */
+internal fun generateStubFileContent(scanned: ScannedExpectFile): String {
+ val fileSpec = FileSpec.builder(scanned.packageName, "${scanned.sourceFile.nameWithoutExtension}Stub")
+ .addFileComment("GENERATED by the Actualizer Gradle plugin (actualizer { stubUnfulfilledExpects() }).\n")
+ .addFileComment("Source: %L\n", scanned.sourceFile)
+ .addFileComment("Do not edit - regenerated on every build.")
+
+ for (member in scanned.topLevel) {
+ when (member) {
+ is ExpectMember.Function -> fileSpec.addFunction(buildFunctionStub(member, scanned.packageName))
+ is ExpectMember.Property -> fileSpec.addProperty(buildPropertyStub(member, scanned.packageName))
+ }
+ }
+ for (cls in scanned.classes) {
+ fileSpec.addType(buildClassStub(cls, scanned.packageName))
+ }
+
+ return fileSpec.build().toString()
+}
diff --git a/plugin-build/gradle-plugin/src/main/kotlin/net/kernelpanicsoft/actualizer/gradle/GenerateLinkReportWorkAction.kt b/plugin-build/gradle-plugin/src/main/kotlin/net/kernelpanicsoft/actualizer/gradle/GenerateLinkReportWorkAction.kt
new file mode 100644
index 0000000..ab0d52e
--- /dev/null
+++ b/plugin-build/gradle-plugin/src/main/kotlin/net/kernelpanicsoft/actualizer/gradle/GenerateLinkReportWorkAction.kt
@@ -0,0 +1,37 @@
+package net.kernelpanicsoft.actualizer.gradle
+
+import org.gradle.api.file.ConfigurableFileCollection
+import org.gradle.api.file.RegularFileProperty
+import org.gradle.api.provider.ListProperty
+import org.gradle.api.provider.Property
+import org.gradle.workers.WorkAction
+import org.gradle.workers.WorkParameters
+import java.io.File
+
+internal interface GenerateLinkReportParameters : WorkParameters {
+ val ownFiles: ConfigurableFileCollection
+ /** Each entry is `"::"`, matching [LinkReportModuleRoot]. */
+ val moduleMapEntries: ListProperty
+ val selfModule: Property
+ val reportOutput: RegularFileProperty
+}
+
+/**
+ * Runs [writeActualizationLinkReport] in a classloader isolated from the rest of the build - see
+ * [GenerateStubsWorkAction]'s doc for why standing up a `KotlinCoreEnvironment` needs that.
+ */
+internal abstract class GenerateLinkReportWorkAction : WorkAction {
+
+ override fun execute() {
+ val moduleMap = parameters.moduleMapEntries.get().mapNotNull { entry ->
+ val parts = entry.split("::", limit = 2)
+ if (parts.size != 2) null else LinkReportModuleRoot(File(parts[0]), parts[1])
+ }
+ writeActualizationLinkReport(
+ ownFiles = parameters.ownFiles.files.toList(),
+ moduleMap = moduleMap,
+ selfModule = parameters.selfModule.get(),
+ reportOutput = parameters.reportOutput.get().asFile,
+ )
+ }
+}
diff --git a/plugin-build/gradle-plugin/src/main/kotlin/net/kernelpanicsoft/actualizer/gradle/GenerateStubsWorkAction.kt b/plugin-build/gradle-plugin/src/main/kotlin/net/kernelpanicsoft/actualizer/gradle/GenerateStubsWorkAction.kt
new file mode 100644
index 0000000..080eb69
--- /dev/null
+++ b/plugin-build/gradle-plugin/src/main/kotlin/net/kernelpanicsoft/actualizer/gradle/GenerateStubsWorkAction.kt
@@ -0,0 +1,42 @@
+package net.kernelpanicsoft.actualizer.gradle
+
+import org.gradle.api.file.ConfigurableFileCollection
+import org.gradle.api.file.DirectoryProperty
+import org.gradle.api.provider.Property
+import org.gradle.workers.WorkAction
+import org.gradle.workers.WorkParameters
+import java.io.File
+
+internal interface GenerateStubsParameters : WorkParameters {
+ val mainFiles: ConfigurableFileCollection
+ val outputDir: DirectoryProperty
+ val projectPath: Property
+}
+
+/**
+ * Runs [scanForExpectFunctions]/[generateStubFileContent] in a classloader isolated from the rest
+ * of the build, via [org.gradle.workers.WorkerExecutor.classLoaderIsolation]. Standing up a
+ * [org.jetbrains.kotlin.cli.jvm.compiler.KotlinCoreEnvironment] pulls in `kotlin-compiler-embeddable`'s
+ * bundled IntelliJ platform classes, which can collide with whatever the Kotlin Gradle Plugin's
+ * own compilation machinery loads in the same process for a different Kotlin version, producing a
+ * split-classloader `IllegalAccessError` on classes like `Disposer`/`ObjectTree`. Running this in
+ * its own classloader sidesteps that collision entirely.
+ */
+internal abstract class GenerateStubsWorkAction : WorkAction {
+
+ override fun execute() {
+ val mainFiles = parameters.mainFiles.files.toList()
+ val outputDir = parameters.outputDir.get().asFile
+ val scanned = scanForExpectFunctions(mainFiles)
+ outputDir.deleteRecursively()
+ if (scanned.isEmpty()) {
+ return
+ }
+ outputDir.mkdirs()
+ for (file in scanned) {
+ val packageDir = File(outputDir, file.packageName.replace('.', '/')).apply { mkdirs() }
+ File(packageDir, "${file.sourceFile.nameWithoutExtension}Stub.kt")
+ .writeText(generateStubFileContent(file))
+ }
+ }
+}
diff --git a/plugin-build/settings.gradle.kts b/plugin-build/settings.gradle.kts
new file mode 100644
index 0000000..e6aca8e
--- /dev/null
+++ b/plugin-build/settings.gradle.kts
@@ -0,0 +1,18 @@
+pluginManagement {
+ repositories {
+ gradlePluginPortal()
+ mavenCentral()
+ google()
+ }
+}
+
+dependencyResolutionManagement {
+ repositories {
+ mavenCentral()
+ google()
+ }
+}
+
+rootProject.name = "actualizer-plugin-build"
+
+include(":compiler-plugin", ":gradle-plugin")
diff --git a/sample-architectury/.gitignore b/sample-architectury/.gitignore
new file mode 100644
index 0000000..abd785d
--- /dev/null
+++ b/sample-architectury/.gitignore
@@ -0,0 +1,7 @@
+.gradle/
+*/build/
+.kotlin/
+*.iml
+.idea/
+logs/
+run/
diff --git a/sample-architectury/README.md b/sample-architectury/README.md
new file mode 100644
index 0000000..662b4b4
--- /dev/null
+++ b/sample-architectury/README.md
@@ -0,0 +1,111 @@
+# sample-architectury
+
+A real Fabric + NeoForge multiloader mod build, via [Architectury Loom](https://github.com/architectury/architectury-loom),
+that uses Actualizer to actualize `expect` declarations from a common module against two
+independent, real Minecraft mod-loader toolchains - the case that originally motivated this plugin
+(see the root [README](../README.md)'s "Motivation" section).
+
+## Why this is a separate Gradle build
+
+Everything else in this repo lives inside `modular-kmp`'s own multi-project build. This sample
+doesn't: it has its own `settings.gradle.kts` and its own Gradle wrapper. The root build does
+`includeBuild("sample-architectury")` so this build is reachable from the root for IDE navigation
+and via explicit task paths (`gradle :sample-architectury:common:build`), but it stays out of the
+root's default `gradle build` - a bare build at the root doesn't pull in this build's tasks - for
+two reasons:
+
+- **It's heavy.** Architectury Loom downloads and remaps real Minecraft jars (client + server
+ merge, official Mojang mappings, access transformers, decompilation for source jars) - tens of
+ seconds to a few minutes on a clean cache, and real disk space. That shouldn't be part of the
+ root repo's ordinary `gradle build` feedback loop.
+- **It pins a different Gradle version on purpose.** Its own wrapper (`./gradlew`, Gradle 8.12)
+ matches what Architectury Loom 1.13.469 was built and tested against. Building it through the
+ root's own Gradle version (via `includeBuild`) works too - Loom 1.13.469 tolerates newer Gradle
+ - but `./gradlew` here is still the version this sample is actually pinned to and tested with.
+
+It still reaches the main build's `net.kernelpanicsoft.actualizer` plugin and
+`net.kernelpanicsoft.actualizer:compiler-plugin` the same way the root build does -
+`includeBuild("../plugin-build")` in both `pluginManagement` and at the top level of
+`settings.gradle.kts`.
+
+## Structure
+
+```
+common/ expect declarations (net.kernelpanicsoft.samplemod.common), stubUnfulfilledExpects()
+fabric/ architectury { fabric() }, real actual, actualizes(project(":common"))
+neoforge/ architectury { neoForge() }, real actual, actualizes(project(":common"))
+```
+
+`:common` declares:
+
+```kotlin
+expect val loaderName: String
+
+expect fun loaderSpecificGreeting(): String
+```
+
+No annotation is needed here either - `:common`, `:fabric`, and `:neoforge` are all plain
+`kotlin("jvm")` modules underneath the `architectury-plugin`/Architectury Loom wiring, so the same
+reasoning from the root README applies. The IDE-only false positives that would otherwise show up
+on these declarations and on the calls to them are suppressed automatically by the compiler
+plugin's FIR extension - see the root README's "IDE false positives" section.
+
+`:fabric` and `:neoforge` each merge `:common`'s source in
+(`actualizer { actualizes(project(":common")) }`) and provide a real `actual` that references
+platform-specific, remapped Minecraft classes - `net.minecraft.resources.ResourceLocation`,
+resolved against that platform's own Architectury Loom-provided Minecraft jar. Both platforms use
+official Mojang mappings rather than Yarn, which is the actual point of Architectury Loom over
+plain Fabric Loom or NeoForge's own ModDevGradle: one shared mapping namespace both loaders can
+consume natively, so `:common`'s merged code and both platforms' actuals see the same class/method
+names without a lossy mapping migration step.
+
+`:common` also calls `actualizer { stubUnfulfilledExpects() }`, the same as the main repo's
+`:sample:api`, so it builds a genuine standalone jar - `./gradlew :common:build` succeeds on its
+own, with an auto-generated throwing stub for both expects - that other common/API modules could
+depend on before any platform actual exists, not just a bare source-provider for `:fabric`/
+`:neoforge`.
+
+## Building and running
+
+```
+./gradlew :fabric:compileKotlin # merges :common's source + a real Fabric actual, against
+ # Architectury Loom's Fabric-platform Minecraft classpath
+./gradlew :neoforge:compileKotlin # same, against the NeoForge-platform Minecraft classpath
+
+./gradlew :fabric:runVerify :neoforge:runVerify
+```
+
+`runVerify` is a plain `JavaExec` against Loom's dev runtime classpath that calls the merged code
+directly (`modStartupMessage()`, which calls the platform's `actual fun loaderSpecificGreeting()`).
+It proves the actualized code executes correctly, not just compiles, without needing to launch a
+full Minecraft client. Expected output:
+
+```
+Sample mod starting on Fabric: hello from Architectury Loom's Fabric platform; resolved samplemod:hello against a real remapped Minecraft classpath
+Sample mod starting on NeoForge: hello from Architectury Loom's NeoForge platform; resolved samplemod:hello against a real remapped Minecraft classpath
+```
+
+Two different actuals, two different real Minecraft classpaths, one merged `:common` source file.
+
+Each platform module's `build/actualizer/report.json` records what got linked, e.g.:
+
+```json
+{
+ "consumingModule": ":fabric",
+ "links": [
+ {"actual": "net.kernelpanicsoft.samplemod.common.loaderName", "owningModules": [":common"]},
+ {"actual": "net.kernelpanicsoft.samplemod.common.loaderSpecificGreeting", "owningModules": [":common"]}
+ ]
+}
+```
+
+## Known limitations specific to this sample
+
+- **`loom.platform` must be set per module.** Architectury Loom reads which platform
+ (`fabric`/`neoforge`) a module targets from that module's own `gradle.properties`
+ (`loom.platform=fabric`/`loom.platform=neoforge`), read before the build script runs - it's not
+ enough to call `architectury { fabric() }`/`architectury { neoForge() }` in the script alone.
+- **This needs real network access** to Mojang's piston-meta/piston-data endpoints, Fabric's and
+ Architectury's Maven repos, and NeoForged's Maven repo, plus enough heap for Loom's
+ official-mappings decompile/remap pipeline (`org.gradle.jvmargs=-Xmx6G` in this directory's
+ `gradle.properties`). It won't build in a fully offline or memory-constrained environment.
diff --git a/sample-architectury/build.gradle.kts b/sample-architectury/build.gradle.kts
new file mode 100644
index 0000000..2077c99
--- /dev/null
+++ b/sample-architectury/build.gradle.kts
@@ -0,0 +1,43 @@
+import net.fabricmc.loom.api.LoomGradleExtensionAPI
+
+plugins {
+ kotlin("jvm") version "2.4.10" apply false
+ id("architectury-plugin") version "3.4.164"
+ id("dev.architectury.loom") version "1.13.469" apply false
+}
+
+architectury {
+ minecraft = "1.21.1"
+}
+
+subprojects {
+ apply(plugin = "dev.architectury.loom")
+ apply(plugin = "org.jetbrains.kotlin.jvm")
+ apply(plugin = "architectury-plugin")
+
+ group = "net.kernelpanicsoft.samplemod"
+ version = "1.0.0"
+
+ repositories {
+ mavenCentral()
+ maven("https://maven.fabricmc.net/") { name = "FabricMC" }
+ maven("https://maven.neoforged.net/releases/") { name = "NeoForged" }
+ }
+
+ val loom = extensions.getByName("loom")
+ loom.silentMojangMappingsLicense()
+
+ dependencies {
+ "minecraft"("com.mojang:minecraft:1.21.1")
+ // Official Mojang mappings, not Yarn: both Fabric and NeoForge can consume these natively
+ // (NeoForge is Mojang-mapped already), avoiding the lossy Yarn->NeoForge mapping
+ // migration path - which broke here with a TinyRemapper "Unfixable conflicts" error
+ // against this NeoForge build. This matches how real Architectury Loom projects targeting
+ // both loaders are set up (see the Archie reference project this sample follows).
+ "mappings"(loom.officialMojangMappings())
+ }
+
+ extensions.configure {
+ jvmToolchain(21)
+ }
+}
diff --git a/sample-architectury/common/build.gradle.kts b/sample-architectury/common/build.gradle.kts
new file mode 100644
index 0000000..e1d562e
--- /dev/null
+++ b/sample-architectury/common/build.gradle.kts
@@ -0,0 +1,11 @@
+plugins {
+ id("net.kernelpanicsoft.actualizer")
+}
+
+architectury {
+ common("fabric", "neoforge")
+}
+
+actualizer {
+ stubUnfulfilledExpects()
+}
diff --git a/sample-architectury/common/src/main/kotlin/net/kernelpanicsoft/samplemod/common/ModCommon.kt b/sample-architectury/common/src/main/kotlin/net/kernelpanicsoft/samplemod/common/ModCommon.kt
new file mode 100644
index 0000000..749e88e
--- /dev/null
+++ b/sample-architectury/common/src/main/kotlin/net/kernelpanicsoft/samplemod/common/ModCommon.kt
@@ -0,0 +1,12 @@
+package net.kernelpanicsoft.samplemod.common
+
+// Actualized independently by :fabric and :neoforge - two separate platform targets under
+// Architectury Loom, each with its own remapped Minecraft classpath, neither part of a shared
+// kotlin {} multiplatform hierarchy. This is the motivating case for Actualizer: a single KMP
+// module can't span two incompatible Gradle plugins like this, but merging source into each
+// loader's own compilation can.
+expect val loaderName: String
+
+expect fun loaderSpecificGreeting(): String
+
+fun modStartupMessage(): String = "Sample mod starting on $loaderName: ${loaderSpecificGreeting()}"
diff --git a/sample-architectury/fabric/build.gradle.kts b/sample-architectury/fabric/build.gradle.kts
new file mode 100644
index 0000000..6f454ab
--- /dev/null
+++ b/sample-architectury/fabric/build.gradle.kts
@@ -0,0 +1,25 @@
+plugins {
+ id("net.kernelpanicsoft.actualizer")
+}
+
+architectury {
+ platformSetupLoomIde()
+ fabric()
+}
+
+dependencies {
+ modImplementation("net.fabricmc:fabric-loader:0.16.14")
+}
+
+actualizer {
+ actualizes(project(":common"))
+}
+
+// Runs the merged expect/actual code path directly against Loom's dev runtime classpath,
+// without launching a full Minecraft client (which needs game assets/auth this sandbox doesn't
+// have) - proof the actualized code actually executes correctly, not just compiles.
+tasks.register("runVerify") {
+ group = "verification"
+ mainClass.set("net.kernelpanicsoft.samplemod.fabric.FabricSampleModKt")
+ classpath = sourceSets.main.get().runtimeClasspath
+}
diff --git a/sample-architectury/fabric/gradle.properties b/sample-architectury/fabric/gradle.properties
new file mode 100644
index 0000000..e846a8f
--- /dev/null
+++ b/sample-architectury/fabric/gradle.properties
@@ -0,0 +1 @@
+loom.platform=fabric
diff --git a/sample-architectury/fabric/src/main/kotlin/net/kernelpanicsoft/samplemod/common/Actual.kt b/sample-architectury/fabric/src/main/kotlin/net/kernelpanicsoft/samplemod/common/Actual.kt
new file mode 100644
index 0000000..ebb0d2c
--- /dev/null
+++ b/sample-architectury/fabric/src/main/kotlin/net/kernelpanicsoft/samplemod/common/Actual.kt
@@ -0,0 +1,19 @@
+// The `actual` declarations below must live in `net.kernelpanicsoft.samplemod.common` - the same
+// package as their `expect` counterparts in the unrelated `:common` Gradle module - even though
+// this file physically lives in `:fabric`, a separate platform target under Architectury Loom.
+
+package net.kernelpanicsoft.samplemod.common
+
+import net.minecraft.resources.ResourceLocation
+
+actual val loaderName: String = "Fabric"
+
+// ResourceLocation only resolves on :fabric's classpath, proving this actual was compiled
+// against Architectury Loom's Fabric-platform Minecraft jar. Both platforms use official Mojang
+// mappings, so the class name matches :neoforge's usage even though the underlying jar differs.
+// Not calling FabricLoader.getInstance() here - it needs Fabric's real launcher bootstrap, which
+// only exists when actually launching the game, out of scope for runVerify's bare JavaExec.
+actual fun loaderSpecificGreeting(): String {
+ val id = ResourceLocation.fromNamespaceAndPath("samplemod", "hello")
+ return "hello from Architectury Loom's Fabric platform; resolved $id against a real remapped Minecraft classpath"
+}
diff --git a/sample-architectury/fabric/src/main/kotlin/net/kernelpanicsoft/samplemod/fabric/FabricSampleMod.kt b/sample-architectury/fabric/src/main/kotlin/net/kernelpanicsoft/samplemod/fabric/FabricSampleMod.kt
new file mode 100644
index 0000000..3d4a764
--- /dev/null
+++ b/sample-architectury/fabric/src/main/kotlin/net/kernelpanicsoft/samplemod/fabric/FabricSampleMod.kt
@@ -0,0 +1,15 @@
+package net.kernelpanicsoft.samplemod.fabric
+
+import net.fabricmc.api.ModInitializer
+import net.kernelpanicsoft.samplemod.common.modStartupMessage
+
+class FabricSampleMod : ModInitializer {
+ override fun onInitialize() {
+ println(modStartupMessage())
+ }
+}
+
+/** Runs the merged expect/actual code path directly, without launching a Minecraft client. */
+fun main() {
+ println(modStartupMessage())
+}
diff --git a/sample-architectury/fabric/src/main/resources/fabric.mod.json b/sample-architectury/fabric/src/main/resources/fabric.mod.json
new file mode 100644
index 0000000..7ba0ad8
--- /dev/null
+++ b/sample-architectury/fabric/src/main/resources/fabric.mod.json
@@ -0,0 +1,16 @@
+{
+ "schemaVersion": 1,
+ "id": "samplemod",
+ "version": "1.0.0",
+ "name": "Actualizer Sample Mod (Fabric)",
+ "environment": "*",
+ "entrypoints": {
+ "main": [
+ "net.kernelpanicsoft.samplemod.fabric.FabricSampleMod"
+ ]
+ },
+ "depends": {
+ "fabricloader": ">=0.16.0",
+ "minecraft": "~1.21.1"
+ }
+}
diff --git a/sample-architectury/gradle.properties b/sample-architectury/gradle.properties
new file mode 100644
index 0000000..abb0eb5
--- /dev/null
+++ b/sample-architectury/gradle.properties
@@ -0,0 +1 @@
+org.gradle.jvmargs=-Xmx6G
diff --git a/sample-architectury/gradle/wrapper/gradle-wrapper.jar b/sample-architectury/gradle/wrapper/gradle-wrapper.jar
new file mode 100644
index 0000000..1b33c55
Binary files /dev/null and b/sample-architectury/gradle/wrapper/gradle-wrapper.jar differ
diff --git a/sample-architectury/gradle/wrapper/gradle-wrapper.properties b/sample-architectury/gradle/wrapper/gradle-wrapper.properties
new file mode 100644
index 0000000..cea7a79
--- /dev/null
+++ b/sample-architectury/gradle/wrapper/gradle-wrapper.properties
@@ -0,0 +1,7 @@
+distributionBase=GRADLE_USER_HOME
+distributionPath=wrapper/dists
+distributionUrl=https\://services.gradle.org/distributions/gradle-8.12-bin.zip
+networkTimeout=10000
+validateDistributionUrl=true
+zipStoreBase=GRADLE_USER_HOME
+zipStorePath=wrapper/dists
diff --git a/sample-architectury/gradlew b/sample-architectury/gradlew
new file mode 100755
index 0000000..23d15a9
--- /dev/null
+++ b/sample-architectury/gradlew
@@ -0,0 +1,251 @@
+#!/bin/sh
+
+#
+# Copyright © 2015-2021 the original authors.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# https://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+# SPDX-License-Identifier: Apache-2.0
+#
+
+##############################################################################
+#
+# Gradle start up script for POSIX generated by Gradle.
+#
+# Important for running:
+#
+# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
+# noncompliant, but you have some other compliant shell such as ksh or
+# bash, then to run this script, type that shell name before the whole
+# command line, like:
+#
+# ksh Gradle
+#
+# Busybox and similar reduced shells will NOT work, because this script
+# requires all of these POSIX shell features:
+# * functions;
+# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
+# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
+# * compound commands having a testable exit status, especially «case»;
+# * various built-in commands including «command», «set», and «ulimit».
+#
+# Important for patching:
+#
+# (2) This script targets any POSIX shell, so it avoids extensions provided
+# by Bash, Ksh, etc; in particular arrays are avoided.
+#
+# The "traditional" practice of packing multiple parameters into a
+# space-separated string is a well documented source of bugs and security
+# problems, so this is (mostly) avoided, by progressively accumulating
+# options in "$@", and eventually passing that to Java.
+#
+# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
+# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
+# see the in-line comments for details.
+#
+# There are tweaks for specific operating systems such as AIX, CygWin,
+# Darwin, MinGW, and NonStop.
+#
+# (3) This script is generated from the Groovy template
+# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
+# within the Gradle project.
+#
+# You can find Gradle at https://github.com/gradle/gradle/.
+#
+##############################################################################
+
+# Attempt to set APP_HOME
+
+# Resolve links: $0 may be a link
+app_path=$0
+
+# Need this for daisy-chained symlinks.
+while
+ APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
+ [ -h "$app_path" ]
+do
+ ls=$( ls -ld "$app_path" )
+ link=${ls#*' -> '}
+ case $link in #(
+ /*) app_path=$link ;; #(
+ *) app_path=$APP_HOME$link ;;
+ esac
+done
+
+# This is normally unused
+# shellcheck disable=SC2034
+APP_BASE_NAME=${0##*/}
+# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
+APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
+
+# Use the maximum available, or set MAX_FD != -1 to use that value.
+MAX_FD=maximum
+
+warn () {
+ echo "$*"
+} >&2
+
+die () {
+ echo
+ echo "$*"
+ echo
+ exit 1
+} >&2
+
+# OS specific support (must be 'true' or 'false').
+cygwin=false
+msys=false
+darwin=false
+nonstop=false
+case "$( uname )" in #(
+ CYGWIN* ) cygwin=true ;; #(
+ Darwin* ) darwin=true ;; #(
+ MSYS* | MINGW* ) msys=true ;; #(
+ NONSTOP* ) nonstop=true ;;
+esac
+
+CLASSPATH="\\\"\\\""
+
+
+# Determine the Java command to use to start the JVM.
+if [ -n "$JAVA_HOME" ] ; then
+ if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
+ # IBM's JDK on AIX uses strange locations for the executables
+ JAVACMD=$JAVA_HOME/jre/sh/java
+ else
+ JAVACMD=$JAVA_HOME/bin/java
+ fi
+ if [ ! -x "$JAVACMD" ] ; then
+ die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
+
+Please set the JAVA_HOME variable in your environment to match the
+location of your Java installation."
+ fi
+else
+ JAVACMD=java
+ if ! command -v java >/dev/null 2>&1
+ then
+ die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
+
+Please set the JAVA_HOME variable in your environment to match the
+location of your Java installation."
+ fi
+fi
+
+# Increase the maximum file descriptors if we can.
+if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
+ case $MAX_FD in #(
+ max*)
+ # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
+ # shellcheck disable=SC2039,SC3045
+ MAX_FD=$( ulimit -H -n ) ||
+ warn "Could not query maximum file descriptor limit"
+ esac
+ case $MAX_FD in #(
+ '' | soft) :;; #(
+ *)
+ # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
+ # shellcheck disable=SC2039,SC3045
+ ulimit -n "$MAX_FD" ||
+ warn "Could not set maximum file descriptor limit to $MAX_FD"
+ esac
+fi
+
+# Collect all arguments for the java command, stacking in reverse order:
+# * args from the command line
+# * the main class name
+# * -classpath
+# * -D...appname settings
+# * --module-path (only if needed)
+# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
+
+# For Cygwin or MSYS, switch paths to Windows format before running java
+if "$cygwin" || "$msys" ; then
+ APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
+ CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
+
+ JAVACMD=$( cygpath --unix "$JAVACMD" )
+
+ # Now convert the arguments - kludge to limit ourselves to /bin/sh
+ for arg do
+ if
+ case $arg in #(
+ -*) false ;; # don't mess with options #(
+ /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
+ [ -e "$t" ] ;; #(
+ *) false ;;
+ esac
+ then
+ arg=$( cygpath --path --ignore --mixed "$arg" )
+ fi
+ # Roll the args list around exactly as many times as the number of
+ # args, so each arg winds up back in the position where it started, but
+ # possibly modified.
+ #
+ # NB: a `for` loop captures its iteration list before it begins, so
+ # changing the positional parameters here affects neither the number of
+ # iterations, nor the values presented in `arg`.
+ shift # remove old arg
+ set -- "$@" "$arg" # push replacement arg
+ done
+fi
+
+
+# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
+
+# Collect all arguments for the java command:
+# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
+# and any embedded shellness will be escaped.
+# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
+# treated as '${Hostname}' itself on the command line.
+
+set -- \
+ "-Dorg.gradle.appname=$APP_BASE_NAME" \
+ -classpath "$CLASSPATH" \
+ -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
+ "$@"
+
+# Stop when "xargs" is not available.
+if ! command -v xargs >/dev/null 2>&1
+then
+ die "xargs is not available"
+fi
+
+# Use "xargs" to parse quoted args.
+#
+# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
+#
+# In Bash we could simply go:
+#
+# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
+# set -- "${ARGS[@]}" "$@"
+#
+# but POSIX shell has neither arrays nor command substitution, so instead we
+# post-process each arg (as a line of input to sed) to backslash-escape any
+# character that might be a shell metacharacter, then use eval to reverse
+# that process (while maintaining the separation between arguments), and wrap
+# the whole thing up as a single "set" statement.
+#
+# This will of course break if any of these variables contains a newline or
+# an unmatched quote.
+#
+
+eval "set -- $(
+ printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
+ xargs -n1 |
+ sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
+ tr '\n' ' '
+ )" '"$@"'
+
+exec "$JAVACMD" "$@"
diff --git a/sample-architectury/gradlew.bat b/sample-architectury/gradlew.bat
new file mode 100644
index 0000000..5eed7ee
--- /dev/null
+++ b/sample-architectury/gradlew.bat
@@ -0,0 +1,94 @@
+@rem
+@rem Copyright 2015 the original author or authors.
+@rem
+@rem Licensed under the Apache License, Version 2.0 (the "License");
+@rem you may not use this file except in compliance with the License.
+@rem You may obtain a copy of the License at
+@rem
+@rem https://www.apache.org/licenses/LICENSE-2.0
+@rem
+@rem Unless required by applicable law or agreed to in writing, software
+@rem distributed under the License is distributed on an "AS IS" BASIS,
+@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+@rem See the License for the specific language governing permissions and
+@rem limitations under the License.
+@rem
+@rem SPDX-License-Identifier: Apache-2.0
+@rem
+
+@if "%DEBUG%"=="" @echo off
+@rem ##########################################################################
+@rem
+@rem Gradle startup script for Windows
+@rem
+@rem ##########################################################################
+
+@rem Set local scope for the variables with windows NT shell
+if "%OS%"=="Windows_NT" setlocal
+
+set DIRNAME=%~dp0
+if "%DIRNAME%"=="" set DIRNAME=.
+@rem This is normally unused
+set APP_BASE_NAME=%~n0
+set APP_HOME=%DIRNAME%
+
+@rem Resolve any "." and ".." in APP_HOME to make it shorter.
+for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
+
+@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
+
+@rem Find java.exe
+if defined JAVA_HOME goto findJavaFromJavaHome
+
+set JAVA_EXE=java.exe
+%JAVA_EXE% -version >NUL 2>&1
+if %ERRORLEVEL% equ 0 goto execute
+
+echo. 1>&2
+echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
+echo. 1>&2
+echo Please set the JAVA_HOME variable in your environment to match the 1>&2
+echo location of your Java installation. 1>&2
+
+goto fail
+
+:findJavaFromJavaHome
+set JAVA_HOME=%JAVA_HOME:"=%
+set JAVA_EXE=%JAVA_HOME%/bin/java.exe
+
+if exist "%JAVA_EXE%" goto execute
+
+echo. 1>&2
+echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
+echo. 1>&2
+echo Please set the JAVA_HOME variable in your environment to match the 1>&2
+echo location of your Java installation. 1>&2
+
+goto fail
+
+:execute
+@rem Setup the command line
+
+set CLASSPATH=
+
+
+@rem Execute Gradle
+"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
+
+:end
+@rem End local scope for the variables with windows NT shell
+if %ERRORLEVEL% equ 0 goto mainEnd
+
+:fail
+rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
+rem the _cmd.exe /c_ return code!
+set EXIT_CODE=%ERRORLEVEL%
+if %EXIT_CODE% equ 0 set EXIT_CODE=1
+if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
+exit /b %EXIT_CODE%
+
+:mainEnd
+if "%OS%"=="Windows_NT" endlocal
+
+:omega
diff --git a/sample-architectury/neoforge/build.gradle.kts b/sample-architectury/neoforge/build.gradle.kts
new file mode 100644
index 0000000..827ed77
--- /dev/null
+++ b/sample-architectury/neoforge/build.gradle.kts
@@ -0,0 +1,25 @@
+plugins {
+ id("net.kernelpanicsoft.actualizer")
+}
+
+architectury {
+ platformSetupLoomIde()
+ neoForge()
+}
+
+dependencies {
+ "neoForge"("net.neoforged:neoforge:21.1.244")
+}
+
+actualizer {
+ actualizes(project(":common"))
+}
+
+// Runs the merged expect/actual code path directly against Loom's dev runtime classpath,
+// without launching a full Minecraft client (which needs game assets/auth this sandbox doesn't
+// have) - proof the actualized code actually executes correctly, not just compiles.
+tasks.register("runVerify") {
+ group = "verification"
+ mainClass.set("net.kernelpanicsoft.samplemod.neoforge.NeoForgeSampleModKt")
+ classpath = sourceSets.main.get().runtimeClasspath
+}
diff --git a/sample-architectury/neoforge/gradle.properties b/sample-architectury/neoforge/gradle.properties
new file mode 100644
index 0000000..7da18ea
--- /dev/null
+++ b/sample-architectury/neoforge/gradle.properties
@@ -0,0 +1 @@
+loom.platform=neoforge
diff --git a/sample-architectury/neoforge/src/main/kotlin/net/kernelpanicsoft/samplemod/common/Actual.kt b/sample-architectury/neoforge/src/main/kotlin/net/kernelpanicsoft/samplemod/common/Actual.kt
new file mode 100644
index 0000000..f6f7bbf
--- /dev/null
+++ b/sample-architectury/neoforge/src/main/kotlin/net/kernelpanicsoft/samplemod/common/Actual.kt
@@ -0,0 +1,17 @@
+// The `actual` declarations below must live in `net.kernelpanicsoft.samplemod.common` - the same
+// package as their `expect` counterparts in the unrelated `:common` Gradle module - even though
+// this file physically lives in `:neoforge`, a separate platform target under Architectury Loom.
+
+package net.kernelpanicsoft.samplemod.common
+
+import net.minecraft.resources.ResourceLocation
+
+actual val loaderName: String = "NeoForge"
+
+// References real, remapped Minecraft (ResourceLocation) that only resolves on :neoforge's
+// classpath - proof this actual was compiled against Architectury Loom's NeoForge-platform
+// Minecraft jar. See :fabric's Actual.kt for why both platforms share the same mapping names.
+actual fun loaderSpecificGreeting(): String {
+ val id = ResourceLocation.fromNamespaceAndPath("samplemod", "hello")
+ return "hello from Architectury Loom's NeoForge platform; resolved $id against a real remapped Minecraft classpath"
+}
diff --git a/sample-architectury/neoforge/src/main/kotlin/net/kernelpanicsoft/samplemod/neoforge/NeoForgeSampleMod.kt b/sample-architectury/neoforge/src/main/kotlin/net/kernelpanicsoft/samplemod/neoforge/NeoForgeSampleMod.kt
new file mode 100644
index 0000000..d63605e
--- /dev/null
+++ b/sample-architectury/neoforge/src/main/kotlin/net/kernelpanicsoft/samplemod/neoforge/NeoForgeSampleMod.kt
@@ -0,0 +1,16 @@
+package net.kernelpanicsoft.samplemod.neoforge
+
+import net.kernelpanicsoft.samplemod.common.modStartupMessage
+import net.neoforged.fml.common.Mod
+
+@Mod("samplemod")
+class NeoForgeSampleMod {
+ init {
+ println(modStartupMessage())
+ }
+}
+
+/** Runs the merged expect/actual code path directly, without launching a Minecraft client. */
+fun main() {
+ println(modStartupMessage())
+}
diff --git a/sample-architectury/neoforge/src/main/resources/META-INF/neoforge.mods.toml b/sample-architectury/neoforge/src/main/resources/META-INF/neoforge.mods.toml
new file mode 100644
index 0000000..11441fa
--- /dev/null
+++ b/sample-architectury/neoforge/src/main/resources/META-INF/neoforge.mods.toml
@@ -0,0 +1,22 @@
+modLoader="javafml"
+loaderVersion="[1,)"
+license="MIT"
+
+[[mods]]
+modId="samplemod"
+version="1.0.0"
+displayName="Actualizer Sample Mod (NeoForge)"
+
+[[dependencies.samplemod]]
+ modId="neoforge"
+ type="required"
+ versionRange="[21.1,)"
+ ordering="NONE"
+ side="BOTH"
+
+[[dependencies.samplemod]]
+ modId="minecraft"
+ type="required"
+ versionRange="[1.21.1,1.22)"
+ ordering="NONE"
+ side="BOTH"
diff --git a/sample-architectury/settings.gradle.kts b/sample-architectury/settings.gradle.kts
new file mode 100644
index 0000000..d06e5f1
--- /dev/null
+++ b/sample-architectury/settings.gradle.kts
@@ -0,0 +1,27 @@
+pluginManagement {
+ // Same composite-build inclusion the main `modular-kmp` build uses, so this deliberately
+ // separate build (see README.md in this directory for why it's separate) can apply
+ // `id("net.kernelpanicsoft.actualizer")` without publishing it anywhere first.
+ includeBuild("../plugin-build")
+ repositories {
+ gradlePluginPortal()
+ mavenCentral()
+ maven("https://maven.fabricmc.net/") { name = "FabricMC" }
+ maven("https://maven.neoforged.net/releases/") { name = "NeoForged" }
+ maven("https://maven.architectury.dev/") { name = "Architectury" }
+ }
+}
+
+// Mirrors the top-level `includeBuild("plugin-build")` in the main build's settings.gradle.kts:
+// needed so the plain coordinate "net.kernelpanicsoft.actualizer:compiler-plugin:0.1.0" (which the
+// Actualizer Gradle plugin resolves at apply-time for `-Xplugin=`) substitutes to the
+// `:compiler-plugin` project here instead of requiring a real Maven publish.
+includeBuild("../plugin-build")
+
+rootProject.name = "sample-architectury"
+
+include(
+ ":common",
+ ":fabric",
+ ":neoforge",
+)
diff --git a/sample/actual-jvm-published/build.gradle.kts b/sample/actual-jvm-published/build.gradle.kts
new file mode 100644
index 0000000..7b760b3
--- /dev/null
+++ b/sample/actual-jvm-published/build.gradle.kts
@@ -0,0 +1,33 @@
+plugins {
+ kotlin("jvm")
+ id("net.kernelpanicsoft.actualizer")
+}
+
+repositories {
+ maven {
+ name = "local"
+ url = uri(rootProject.layout.buildDirectory.dir("local-maven-repo"))
+ }
+ mavenCentral()
+}
+
+kotlin {
+ jvmToolchain(21)
+}
+
+actualizer {
+ // Same idea as :sample:actual-jvm, but actualizing a *published* library's expect instead of
+ // a sibling project - resolves net.kernelpanicsoft.sample:api:0.1.0's sources classifier
+ // artifact from the "local" repo above, rather than reading files off a project(...) reference.
+ // Passed as a version-catalog reference (gradle/libs.versions.toml) rather than a hand-typed
+ // "group:artifact:version" string, so the coordinate stays declared in one place.
+ //
+ // dependsOnTasks is needed because the coordinate isn't published yet when this build starts -
+ // this sample publishes and consumes the same library within one build, for a self-contained,
+ // reproducible demo. In real usage the library would already be published somewhere (e.g. by
+ // CI) before a consumer ever builds against it, and dependsOnTasks wouldn't be needed at all.
+ actualizes(
+ libs.sample.api,
+ dependsOnTasks = listOf(":sample:api:publishMavenPublicationToLocalRepository"),
+ )
+}
diff --git a/sample/actual-jvm-published/src/main/kotlin/net/kernelpanicsoft/sample/api/Actual.kt b/sample/actual-jvm-published/src/main/kotlin/net/kernelpanicsoft/sample/api/Actual.kt
new file mode 100644
index 0000000..8bdb037
--- /dev/null
+++ b/sample/actual-jvm-published/src/main/kotlin/net/kernelpanicsoft/sample/api/Actual.kt
@@ -0,0 +1,14 @@
+// Same package as the expect this actualizes (net.kernelpanicsoft.sample.api, declared in the
+// *published* net.kernelpanicsoft.sample:api:0.1.0 library, not a project(...) reference), even
+// though this file physically lives in a completely different Gradle module.
+
+package net.kernelpanicsoft.sample.api
+
+actual fun greetingSuffix(): String = "(actualized against the published api:0.1.0 library)"
+
+actual val platformName: String = ":sample:actual-jvm-published"
+
+actual class GreetingCounter actual constructor(start: Int) {
+ private var current = start
+ actual fun next(): Int = current++
+}
diff --git a/sample/actual-jvm/build.gradle.kts b/sample/actual-jvm/build.gradle.kts
new file mode 100644
index 0000000..6010f22
--- /dev/null
+++ b/sample/actual-jvm/build.gradle.kts
@@ -0,0 +1,22 @@
+plugins {
+ kotlin("jvm")
+ id("net.kernelpanicsoft.actualizer")
+}
+
+repositories {
+ mavenCentral()
+}
+
+kotlin {
+ jvmToolchain(21)
+}
+
+// Deliberately no dependency on :sample:api's or :sample:feature-common's compiled jars: both
+// projects' "main" source is merged in as source below (formatGreeting/greet/welcomeMessage come
+// along with it), and depending on their jars too would give the JVM two competing definitions
+// of the same classes on this module's classpath.
+
+actualizer {
+ actualizes(project(":sample:api"))
+ actualizes(project(":sample:feature-common"))
+}
diff --git a/sample/actual-jvm/src/main/kotlin/net/kernelpanicsoft/sample/api/Actual.kt b/sample/actual-jvm/src/main/kotlin/net/kernelpanicsoft/sample/api/Actual.kt
new file mode 100644
index 0000000..8f0e223
--- /dev/null
+++ b/sample/actual-jvm/src/main/kotlin/net/kernelpanicsoft/sample/api/Actual.kt
@@ -0,0 +1,14 @@
+// The `actual` declaration must live in the same package as its `expect` counterpart
+// (net.kernelpanicsoft.sample.api, declared in the unrelated :sample:api Gradle module), even
+// though this file physically lives in a completely different Gradle module.
+
+package net.kernelpanicsoft.sample.api
+
+actual fun greetingSuffix(): String = "(actualized independently by :sample:actual-jvm)"
+
+actual val platformName: String = ":sample:actual-jvm"
+
+actual class GreetingCounter actual constructor(start: Int) {
+ private var current = start
+ actual fun next(): Int = current++
+}
diff --git a/sample/api/build.gradle.kts b/sample/api/build.gradle.kts
new file mode 100644
index 0000000..b4a2cf6
--- /dev/null
+++ b/sample/api/build.gradle.kts
@@ -0,0 +1,50 @@
+import org.gradle.jvm.tasks.Jar
+
+plugins {
+ kotlin("jvm")
+ id("net.kernelpanicsoft.actualizer")
+ `maven-publish`
+}
+
+repositories {
+ mavenCentral()
+}
+
+kotlin {
+ jvmToolchain(21)
+}
+
+actualizer {
+ stubUnfulfilledExpects()
+}
+
+// A hand-configured sourcesJar, NOT the default `java { withSourcesJar() }` convenience:
+// stubUnfulfilledExpects() adds a generated stub directory to this project's own "main" source
+// set (see ActualizerGradlePlugin.wireStubGeneration), so `sourceSets.main.allSource` - what
+// withSourcesJar() would archive - now includes that generated stub too. A consumer merging this
+// sources jar via actualizes("...") would then get both the real expect AND our own generated
+// stub actual in one merge, which conflicts with whatever real actual that consumer provides.
+// Archiving only the real, hand-written directory keeps the published sources exactly as clean
+// as the project() case (see the build-directory exclusion in namedSourceSetDirs).
+val sourcesJar by tasks.registering(Jar::class) {
+ archiveClassifier.set("sources")
+ from("src/main/kotlin")
+}
+
+publishing {
+ publications {
+ create("maven") {
+ groupId = "net.kernelpanicsoft.sample"
+ artifactId = "api"
+ version = "0.1.0"
+ from(components["java"])
+ artifact(sourcesJar)
+ }
+ }
+ repositories {
+ maven {
+ name = "local"
+ url = uri(rootProject.layout.buildDirectory.dir("local-maven-repo"))
+ }
+ }
+}
diff --git a/sample/api/src/main/kotlin/net/kernelpanicsoft/sample/api/Greeting.kt b/sample/api/src/main/kotlin/net/kernelpanicsoft/sample/api/Greeting.kt
new file mode 100644
index 0000000..e212c97
--- /dev/null
+++ b/sample/api/src/main/kotlin/net/kernelpanicsoft/sample/api/Greeting.kt
@@ -0,0 +1,12 @@
+package net.kernelpanicsoft.sample.api
+
+// Actualized by :sample:actual-jvm, a separate Gradle module that isn't even a dependency of
+// this one. :api's own build stays green regardless, because stubUnfulfilledExpects() in
+// build.gradle.kts generates a throwing actual stub for it.
+expect fun greetingSuffix(): String
+
+fun formatGreeting(name: String, suffix: String): String = "Hello, $name! $suffix"
+
+// Calling this through :api's own jar throws IllegalStateException (the stub). It only does
+// something useful once merged, alongside a real actual, into actual-jvm's compilation.
+fun greet(name: String): String = formatGreeting(name, greetingSuffix())
diff --git a/sample/api/src/main/kotlin/net/kernelpanicsoft/sample/api/PlatformInfo.kt b/sample/api/src/main/kotlin/net/kernelpanicsoft/sample/api/PlatformInfo.kt
new file mode 100644
index 0000000..02b6762
--- /dev/null
+++ b/sample/api/src/main/kotlin/net/kernelpanicsoft/sample/api/PlatformInfo.kt
@@ -0,0 +1,12 @@
+package net.kernelpanicsoft.sample.api
+
+// Covers the stub generator's `expect val` path (Greeting.kt covers `expect fun`).
+expect val platformName: String
+
+// Covers `expect class`: a constructor parameter plus a member function, both of which the
+// generated actual stub has to match.
+expect class GreetingCounter(start: Int) {
+ fun next(): Int
+}
+
+fun describePlatform(): String = "Running on $platformName"
diff --git a/sample/app-published/build.gradle.kts b/sample/app-published/build.gradle.kts
new file mode 100644
index 0000000..9c71807
--- /dev/null
+++ b/sample/app-published/build.gradle.kts
@@ -0,0 +1,21 @@
+plugins {
+ kotlin("jvm")
+ application
+}
+
+repositories {
+ mavenCentral()
+}
+
+kotlin {
+ jvmToolchain(21)
+}
+
+application {
+ mainClass.set("net.kernelpanicsoft.sample.apppublished.MainKt")
+}
+
+dependencies {
+ // Ordinary binary dependency - no plugin, no special wiring, same as :sample:app.
+ implementation(project(":sample:actual-jvm-published"))
+}
diff --git a/sample/app-published/src/main/kotlin/net/kernelpanicsoft/sample/apppublished/Main.kt b/sample/app-published/src/main/kotlin/net/kernelpanicsoft/sample/apppublished/Main.kt
new file mode 100644
index 0000000..d63a6e7
--- /dev/null
+++ b/sample/app-published/src/main/kotlin/net/kernelpanicsoft/sample/apppublished/Main.kt
@@ -0,0 +1,12 @@
+package net.kernelpanicsoft.sample.apppublished
+
+import net.kernelpanicsoft.sample.api.GreetingCounter
+import net.kernelpanicsoft.sample.api.describePlatform
+import net.kernelpanicsoft.sample.api.greet
+
+fun main() {
+ println(greet("world"))
+ println(describePlatform())
+ val counter = GreetingCounter(10)
+ println("Greeting counter: ${counter.next()}, ${counter.next()}")
+}
diff --git a/sample/app/build.gradle.kts b/sample/app/build.gradle.kts
new file mode 100644
index 0000000..f8ad92d
--- /dev/null
+++ b/sample/app/build.gradle.kts
@@ -0,0 +1,22 @@
+plugins {
+ kotlin("jvm")
+ application
+}
+
+repositories {
+ mavenCentral()
+}
+
+kotlin {
+ jvmToolchain(21)
+}
+
+application {
+ mainClass.set("net.kernelpanicsoft.sample.app.MainKt")
+}
+
+dependencies {
+ // Ordinary binary dependency - no plugin, no special wiring. :app never even sees
+ // :sample:api directly; it only depends on the already-linked jar :actual-jvm produced.
+ implementation(project(":sample:actual-jvm"))
+}
diff --git a/sample/app/src/main/kotlin/net/kernelpanicsoft/sample/app/Main.kt b/sample/app/src/main/kotlin/net/kernelpanicsoft/sample/app/Main.kt
new file mode 100644
index 0000000..e9816ad
--- /dev/null
+++ b/sample/app/src/main/kotlin/net/kernelpanicsoft/sample/app/Main.kt
@@ -0,0 +1,12 @@
+package net.kernelpanicsoft.sample.app
+
+import net.kernelpanicsoft.sample.api.GreetingCounter
+import net.kernelpanicsoft.sample.api.describePlatform
+import net.kernelpanicsoft.sample.feature.welcomeMessage
+
+fun main() {
+ println(welcomeMessage("world"))
+ println(describePlatform())
+ val counter = GreetingCounter(1)
+ println("Greeting counter: ${counter.next()}, ${counter.next()}, ${counter.next()}")
+}
diff --git a/sample/feature-common/build.gradle.kts b/sample/feature-common/build.gradle.kts
new file mode 100644
index 0000000..a093204
--- /dev/null
+++ b/sample/feature-common/build.gradle.kts
@@ -0,0 +1,16 @@
+plugins {
+ kotlin("jvm")
+}
+
+repositories {
+ mavenCentral()
+}
+
+kotlin {
+ jvmToolchain(21)
+}
+
+dependencies {
+ // Ordinary project dependency on :api's plain JVM jar - no plugin, no special wiring.
+ implementation(project(":sample:api"))
+}
diff --git a/sample/feature-common/src/main/kotlin/net/kernelpanicsoft/sample/feature/Feature.kt b/sample/feature-common/src/main/kotlin/net/kernelpanicsoft/sample/feature/Feature.kt
new file mode 100644
index 0000000..3e693b3
--- /dev/null
+++ b/sample/feature-common/src/main/kotlin/net/kernelpanicsoft/sample/feature/Feature.kt
@@ -0,0 +1,9 @@
+package net.kernelpanicsoft.sample.feature
+
+import net.kernelpanicsoft.sample.api.greet
+
+// An ordinary implementation(project(":sample:api")) dependency - no plugin, no multiplatform.
+// greet() compiles fine here because :api's expect has a generated stub; calling this through
+// feature-common's own jar would throw until it's merged into a real actualizes(...) leaf, which
+// :sample:actual-jvm does.
+fun welcomeMessage(name: String): String = "[feature-common] " + greet(name)
diff --git a/settings.gradle.kts b/settings.gradle.kts
new file mode 100644
index 0000000..9869377
--- /dev/null
+++ b/settings.gradle.kts
@@ -0,0 +1,32 @@
+pluginManagement {
+ includeBuild("plugin-build")
+ repositories {
+ gradlePluginPortal()
+ mavenCentral()
+ google()
+ }
+}
+
+// A second, top-level includeBuild is required (in addition to the one inside
+// pluginManagement above) so that plain dependency coordinates like
+// "net.kernelpanicsoft.actualizer:compiler-plugin:0.1.0" - which the Actualizer Gradle plugin
+// resolves at apply-time to locate the compiler plugin's jar for `-Xplugin=` - get
+// substituted with the :compiler-plugin project from plugin-build instead of requiring a
+// real Maven publish.
+includeBuild("plugin-build")
+
+// sample-architectury is a real, separate Gradle build (its own settings.gradle.kts, its own
+// wrapper) - see its README for why - but included here too so it's part of the same composite
+// build for IDE navigation and so `gradle build` at the root reaches it as well.
+includeBuild("sample-architectury")
+
+rootProject.name = "modular-kmp"
+
+include(
+ ":sample:api",
+ ":sample:feature-common",
+ ":sample:actual-jvm",
+ ":sample:app",
+ ":sample:actual-jvm-published",
+ ":sample:app-published",
+)