Skip to content

Repository files navigation

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 rather than hand-rolled string concatenation), e.g.:

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=<the hand-written files with expects> 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:

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=<absolute paths of every merged-in project's .kt files>

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:

{ "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"):

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 <actual-jvm>-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 <FileName>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.

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages