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.
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.
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.
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.
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 realformatGreeting/greet/describePlatformand the auto-generated throwing stubs forgreetingSuffix,platformName, andGreetingCounter(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) throwsIllegalStateExceptionwith 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-writtenmainsources and links them against the one real actual;build/actualizer/report.jsonshows 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:appitself../gradlew :sample:actual-jvm-published:build- publishes:sample:apito 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.jsonshows the link with the Maven coordinate (not a project path) as the owning module../gradlew :sample:app-published:run- printsHello, 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 buildat 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
actualdeclaration from:sample:actual-jvmand re-runningcompileKotlinfails 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.
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
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:Suppressis required anywhere in this repo's sample code any more - none ofsample/api,sample/actual-jvm,sample/actual-jvm-published, or the threesample-architecturymodules 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 viaERROR_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 asNoSuchMethodError/NoSuchFieldError/LinkageError, none of which areExceptionsubtypes.ActualizerFirExtensions.ktand its registration inActualizerCompilerPluginRegistrar.ktboth catchThrowablearound 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:Suppressconvention 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
expectandactual) comes from genuine Kotlin Multiplatform module structure -dependsOnsource-set edges the IDE's Gradle importer understands - which a plainkotlin("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.
- JVM only. The
-Xcommon-sourcessource-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 asources-classifier artifact (published coordinates) - it can't actualize against something that only ships compiled klibs/jars with no sources, since-Xcommon-sourcestakes source paths, not bytecode. - A published library's sources jar must be scoped the same way
stubUnfulfilledExpects()requires for aproject(...)reference. It has to contain the library's real, hand-written source only, not a generated stub. The defaultjava { withSourcesJar() }convenience archives a source set's fullallSource, which on a module also usingstubUnfulfilledExpects()includes the generated stub directory - publishing that would merge the library's own stubactualin alongside the real one the leaf module provides, breaking the build with a duplicate-actualconflict. Configure a hand-scopedsourcesJartask instead (see:sample:api'sbuild.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, soactualizes(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-platformand-Xcommon-sourcesaren't a supported public API for third-party use; a future Kotlin release could change or remove this behavior without notice. - The
expectstub scanner is real PSI parsing, but syntax-only - a deliberately scoped subset, not a full compiler frontend.ExpectStubGenerator.ktparses each file withKtPsiFactory(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 objectmembers come from the realKtClassBody, andsuspend funis 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.toTypeNamewalks the real PSI type tree (KtUserType/KtFunctionType/KtNullableType) rather than re-parsing text, so function types - including nested ones like() -> () -> Screen, extension-receiver lambdas likeInt.(String) -> Boolean, nullable lambdas, and generic type arguments - are all handled correctly. A bare type name is resolved against the file's ownimportdirectives (skipping star imports, which need semantic resolution to expand), commonkotlin/kotlin.collectionsnames, 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-buildand the consuming build.ActualizerGradlePluginavoids importing Kotlin Gradle Plugin types (KotlinJvmProjectExtension,KotlinCompile, etc.) and uses reflection by name instead.plugin-buildresolves its own copy ofkotlin-gradle-pluginto compile against, which loads as a differentClassinstance than the one the consuming build'splugins { kotlin(...) }loads, soextensions.findByType(...)/tasks.withType(...)find nothing across that boundary directly. Documented in code comments inActualizerGradlePlugin.kt. - A leaf module must not depend on the compiled jar of a project it also
actualizes(...).:actual-jvmmerges: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>KtJVM 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 aNoSuchMethodErrorfor whatever loaded second. Keep filenames distinct within a shared package across anything that might get merged together.