From 1e7dcd66f9fa367fd3cc2e125959826f6c558f4d Mon Sep 17 00:00:00 2001 From: Ruslan Ibrahimau Date: Tue, 18 Aug 2026 02:07:24 +0300 Subject: [PATCH 1/3] gradle: io.heapy.kinetica plugin, released as 0.4.0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Kinetica is compiler-plugin-only, so a Gradle consumer had to resolve the plugin jar through its own configuration and push -Xplugin into every KotlinCompilationTask by hand. The new kinetica-gradle-plugin module does that wiring, adds the version-matched runtime dependencies, and keeps sourcePipeline=psi on JVM compilations only — the one rule the manual wiring cannot express. Its marker publication is written by scripts/gradle-plugin-marker.sh, which the release script calls before staging. Two publication defects had to be fixed before any of that could work: - the toolchain publishes every dependency as runtime-scoped, so consumers had nothing on the compile classpath and failed on SerializerFactory, a supertype of Role.Companion. Coroutines and serialization-json are exported now (both leak into the public API, EffectScope extends CoroutineScope), as is kinetica-runtime from kinetica-markdown. - kinetica-compiler was Java 21 bytecode, and it is loaded into the consumer's Kotlin compile daemon: a JDK 17 daemon died with UnsupportedClassVersionError before compiling a line. It targets 17 now, like the Gradle plugin itself. Verified by scripts/verify-gradle-plugin.mjs — two fixtures, multiplatform and single-target JVM, whose build scripts carry no Kinetica wiring at all; an authoring-rule violation rejected by the FIR checkers on both the JVM and the JS compilation; a configuration-cache entry stored and reused. It runs in the macOS CI job, the only one that can publish the macosArm64 targets those fixtures resolve. examples/gradle-ssr stays on 0.3.0 with its manual wiring until 0.4.0 is on Central. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/ci.yml | 10 +- README.md | 1 + bench-jvm/module.yaml | 2 +- common.module-template.yaml | 9 +- .../resources/docs/compiler-plugin.md | 28 ++- .../resources/docs/getting-started.md | 45 ++++ examples/gradle-ssr/README.md | 6 +- kinetica-compiler/module.yaml | 5 + kinetica-compiler/src/CompilerContract.kt | 2 +- kinetica-compiler/test/CompilerModelTest.kt | 4 +- kinetica-gradle-plugin/module.yaml | 36 +++ .../io.heapy.kinetica.properties | 1 + .../src/KineticaCoordinates.kt | 32 +++ .../src/KineticaExtension.kt | 55 +++++ .../src/KineticaGradlePlugin.kt | 225 ++++++++++++++++++ .../test/KineticaPluginContractTest.kt | 144 +++++++++++ kinetica-markdown/module.yaml | 5 +- plan.md | 14 ++ project.yaml | 1 + publish.module-template.yaml | 2 +- samples/annotated-js/module.yaml | 2 +- samples/annotated/module.yaml | 2 +- samples/annotated/src/main.kt | 2 +- samples/annotated/test/AnnotatedAppTest.kt | 2 +- samples/browser-bench/module.yaml | 2 +- .../gradle-plugin-consumer-jvm/.gitignore | 2 + .../build.gradle.kts | 37 +++ .../gradle.properties | 3 + .../settings.gradle.kts | 21 ++ .../src/main/kotlin/fixture/Counter.kt | 25 ++ .../src/test/kotlin/fixture/CounterTest.kt | 23 ++ .../gradle-plugin-consumer/.gitignore | 2 + .../gradle-plugin-consumer/build.gradle.kts | 45 ++++ .../gradle-plugin-consumer/gradle.properties | 6 + .../negative/SlotOutsideComponent.kt | 13 + .../settings.gradle.kts | 23 ++ .../src/commonMain/kotlin/fixture/Counter.kt | 25 ++ .../src/jvmTest/kotlin/fixture/CounterTest.kt | 23 ++ scripts/gradle-plugin-marker.sh | 70 ++++++ scripts/release.sh | 15 +- scripts/verify-gradle-plugin.mjs | 162 +++++++++++++ 41 files changed, 1112 insertions(+), 20 deletions(-) create mode 100644 kinetica-gradle-plugin/module.yaml create mode 100644 kinetica-gradle-plugin/resources/META-INF/gradle-plugins/io.heapy.kinetica.properties create mode 100644 kinetica-gradle-plugin/src/KineticaCoordinates.kt create mode 100644 kinetica-gradle-plugin/src/KineticaExtension.kt create mode 100644 kinetica-gradle-plugin/src/KineticaGradlePlugin.kt create mode 100644 kinetica-gradle-plugin/test/KineticaPluginContractTest.kt create mode 100644 scripts/fixtures/gradle-plugin-consumer-jvm/.gitignore create mode 100644 scripts/fixtures/gradle-plugin-consumer-jvm/build.gradle.kts create mode 100644 scripts/fixtures/gradle-plugin-consumer-jvm/gradle.properties create mode 100644 scripts/fixtures/gradle-plugin-consumer-jvm/settings.gradle.kts create mode 100644 scripts/fixtures/gradle-plugin-consumer-jvm/src/main/kotlin/fixture/Counter.kt create mode 100644 scripts/fixtures/gradle-plugin-consumer-jvm/src/test/kotlin/fixture/CounterTest.kt create mode 100644 scripts/fixtures/gradle-plugin-consumer/.gitignore create mode 100644 scripts/fixtures/gradle-plugin-consumer/build.gradle.kts create mode 100644 scripts/fixtures/gradle-plugin-consumer/gradle.properties create mode 100644 scripts/fixtures/gradle-plugin-consumer/negative/SlotOutsideComponent.kt create mode 100644 scripts/fixtures/gradle-plugin-consumer/settings.gradle.kts create mode 100644 scripts/fixtures/gradle-plugin-consumer/src/commonMain/kotlin/fixture/Counter.kt create mode 100644 scripts/fixtures/gradle-plugin-consumer/src/jvmTest/kotlin/fixture/CounterTest.kt create mode 100755 scripts/gradle-plugin-marker.sh create mode 100644 scripts/verify-gradle-plugin.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 093983a..6080e39 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -41,6 +41,7 @@ jobs: set -euo pipefail ./kotlin test --platform jvm \ -m kinetica-compiler \ + -m kinetica-gradle-plugin \ -m kinetica-runtime \ -m kinetica-markdown \ -m kinetica-router \ @@ -202,7 +203,9 @@ jobs: macos: name: macOS native runs-on: macos-latest - timeout-minutes: 30 + # 40 rather than 30: this job also publishes the release modules and builds the Gradle + # consumer fixture. + timeout-minutes: 40 steps: - uses: actions/checkout@v6 @@ -245,6 +248,11 @@ jobs: -m kinetica-motion \ -m kinetica-theme + # Runs here rather than in the JVM job: it publishes kinetica-runtime & co to the local + # Maven repository, and their macosArm64 targets cannot be built on a Linux runner. + - name: Gradle plugin consumer check + run: node scripts/verify-gradle-plugin.mjs + # Smoke-build the native macOS sample: compiles kinetica-appkit (the AppKit renderer) and # links a macosArm64 executable. GUI launch is not automated (no display in CI); the build # itself exercises the full toolchain path: K2 plugin + Native IR + AppKit interop. diff --git a/README.md b/README.md index 06049b9..33307dd 100644 --- a/README.md +++ b/README.md @@ -49,6 +49,7 @@ See [`docs/README.md`](docs/README.md). | `kinetica-router` / `-forms` / `-motion` / `-data` / `-persist` / `-theme` / `-markdown` | first-party batteries | | `kinetica-test` | headless component test harness | | `kinetica-compiler` | K2 compiler plugin — mandatory: frame/slot ordinals, skip transform, FIR authoring rules, server/client boundary | +| `kinetica-gradle-plugin` | `io.heapy.kinetica` for Gradle consumers: applies the compiler plugin to every compilation, `kinetica { }` options, version-matched runtime dependencies | | `samples/` | browser apps, four-way Game of Life comparison, server-components demo, annotated (compiler-plugin) sample | | `docs/` | the documentation site + Docker packaging | | `examples/gradle-ssr` | standalone Gradle 9.7 consumer of the released artifacts: SSR + island hydration + the SEO metadata that goes with it | diff --git a/bench-jvm/module.yaml b/bench-jvm/module.yaml index 974fbd1..bdf1c51 100644 --- a/bench-jvm/module.yaml +++ b/bench-jvm/module.yaml @@ -14,7 +14,7 @@ settings: kotlin: compilerPlugins: - id: io.heapy.kinetica.compiler - dependency: io.heapy.kinetica:kinetica-compiler:0.3.0 + dependency: io.heapy.kinetica:kinetica-compiler:0.4.0 options: moduleId: bench-jvm diff --git a/common.module-template.yaml b/common.module-template.yaml index 15f6854..0cf97bb 100644 --- a/common.module-template.yaml +++ b/common.module-template.yaml @@ -8,7 +8,7 @@ settings: - -Xexpect-actual-classes compilerPlugins: - id: io.heapy.kinetica.compiler - dependency: io.heapy.kinetica:kinetica-compiler:0.3.0 + dependency: io.heapy.kinetica:kinetica-compiler:0.4.0 repositories: # Serves two roles: the compiler plugin is consumed from here, and published modules can be @@ -22,7 +22,12 @@ settings@android: namespace: io.heapy.kinetica dependencies: - - org.jetbrains.kotlinx:kotlinx-coroutines-core:1.10.1 + # Both leak into Kinetica's public API — EffectScope extends CoroutineScope, and the companions + # of @Serializable types like Role implement kotlinx.serialization's SerializerFactory — so + # consumers must get them on the compile classpath. Without `exported` the toolchain publishes + # every dependency as runtime-scoped, and a Gradle consumer cannot compile against Kinetica. + - org.jetbrains.kotlinx:kotlinx-coroutines-core:1.10.1: exported + - org.jetbrains.kotlinx:kotlinx-serialization-json:1.11.0: exported test-dependencies: - org.jetbrains.kotlinx:kotlinx-coroutines-test:1.10.1 diff --git a/docs/docs-site/resources/docs/compiler-plugin.md b/docs/docs-site/resources/docs/compiler-plugin.md index 86aed43..a80088e 100644 --- a/docs/docs-site/resources/docs/compiler-plugin.md +++ b/docs/docs-site/resources/docs/compiler-plugin.md @@ -39,7 +39,7 @@ fun AnnotatedApp() { ## Enabling it - + Every Kinetica module applies the shared template (`common.module-template.yaml`), which wires: @@ -49,7 +49,7 @@ settings: kotlin: compilerPlugins: - id: io.heapy.kinetica.compiler - dependency: io.heapy.kinetica:kinetica-compiler:0.3.0 + dependency: io.heapy.kinetica:kinetica-compiler:0.4.0 options: moduleId: my-app serverSourceSet: serverMain @@ -58,8 +58,28 @@ settings: Further options and their defaults: `sourcePipeline: lightTree` (`psi` turns on source generation), `transforms: all` (`off` is the IR kill switch for debugging), and -`checks: error` (FIR authoring-rule diagnostics; `warning` downgrades them). See -`samples/annotated` for the working wiring. +`checks: error` (FIR authoring-rule diagnostics; the only other value is `off`, which +unregisters the checkers — there is no severity downgrade). See `samples/annotated` for the +working wiring. + +In a Gradle build the same options live in the `kinetica { }` block that the +[`io.heapy.kinetica` plugin](/docs/getting-started) adds — one name per compiler option: + +```kotlin +// build.gradle.kts +kinetica { + moduleId = "my-app" + serverSourceSet = "jvmMain" + clientSourceSet = "jsMain" + sourcePipeline = "psi" // passed to JVM compilations only + transforms = "all" + checks = "error" +} +``` + +`sourcePipeline = "psi"` is the one place the two build systems differ: the PSI pipeline exists +only in the JVM compiler pipeline, so in a multiplatform project the Gradle plugin passes it to +the JVM compilations and withholds it everywhere else, where it would fail the build. ## IR passes diff --git a/docs/docs-site/resources/docs/getting-started.md b/docs/docs-site/resources/docs/getting-started.md index 776e45f..b28bb9d 100644 --- a/docs/docs-site/resources/docs/getting-started.md +++ b/docs/docs-site/resources/docs/getting-started.md @@ -67,6 +67,51 @@ fun main() { open the page. JVM apps (`product: jvm/app`) run with `./kotlin run -m my-server` and package to an executable jar with `./kotlin package`. +## From Gradle + + + +Kinetica is built with the toolchain but consumed from any Kotlin build. For Gradle, the +`io.heapy.kinetica` plugin does the wiring: + +```kotlin +// settings.gradle.kts — a fresh project resolves plugins from the portal only +pluginManagement { + repositories { + gradlePluginPortal() + mavenCentral() + } +} +``` + +```kotlin +// build.gradle.kts +plugins { + kotlin("multiplatform") version "2.4.10" + id("io.heapy.kinetica") version "0.4.0" +} + +repositories { + mavenCentral() +} + +kotlin { + jvm() + js { browser() } +} +``` + +That is the whole setup. The plugin applies the mandatory +[compiler plugin](/docs/compiler-plugin) to every compilation of every target and adds +`kinetica-runtime` to `commonMain` — plus `kinetica-browser` to a JS target's main source set — at +its own version. `kinetica { addRuntimeDependencies = false }` hands the dependencies back to you; +everything else the plugin exposes is on the compiler-plugin page. + +Kotlin **2.4.10** is the version Kinetica is published with. klib metadata is not forward +compatible, so a mismatch fails the compilation — the plugin warns about it before that happens. +The plugin itself needs Gradle 8.11+ and a JDK 17+ Kotlin daemon; the compiled application still +targets whatever your toolchain says. + ## Components are plain functions diff --git a/examples/gradle-ssr/README.md b/examples/gradle-ssr/README.md index eb4b0b7..3ad7780 100644 --- a/examples/gradle-ssr/README.md +++ b/examples/gradle-ssr/README.md @@ -44,7 +44,11 @@ the distinction does not matter — the content is in the first response either ## Gradle wiring worth copying -Kinetica is compiler-plugin-only and does not publish a Gradle subplugin yet, so the plugin jar is +**This section is on its way out.** From 0.4.0 Kinetica publishes the `io.heapy.kinetica` Gradle +plugin, which does all of the wiring below plus the runtime dependencies; this example keeps the +manual form until that release is on Central, since it consumes only published artifacts. + +Kinetica 0.3.0 is compiler-plugin-only and publishes no Gradle subplugin, so the plugin jar is resolved through its own configuration and passed to every Kotlin compilation: ```kotlin diff --git a/kinetica-compiler/module.yaml b/kinetica-compiler/module.yaml index d253993..cf5c8e7 100644 --- a/kinetica-compiler/module.yaml +++ b/kinetica-compiler/module.yaml @@ -9,6 +9,11 @@ repositories: publish: true settings: + jvm: + # The plugin jar is loaded into the consumer's Kotlin compile daemon, whose JVM this project + # does not control — a JDK 17 daemon (the common case for Android and Spring builds) cannot + # load the toolchain's default Java 21 bytecode and fails with UnsupportedClassVersionError. + release: 17 kotlin: version: 2.4.10 languageVersion: 2.4 diff --git a/kinetica-compiler/src/CompilerContract.kt b/kinetica-compiler/src/CompilerContract.kt index c946bc4..79b2e19 100644 --- a/kinetica-compiler/src/CompilerContract.kt +++ b/kinetica-compiler/src/CompilerContract.kt @@ -2,7 +2,7 @@ package io.heapy.kinetica.compiler public object KineticaCompilerContract { public const val pluginId: String = "io.heapy.kinetica.compiler" - public const val pluginVersion: String = "0.3.0" + public const val pluginVersion: String = "0.4.0" public const val optionModuleId: String = "moduleId" public const val optionServerSourceSet: String = "serverSourceSet" public const val optionClientSourceSet: String = "clientSourceSet" diff --git a/kinetica-compiler/test/CompilerModelTest.kt b/kinetica-compiler/test/CompilerModelTest.kt index 3831e35..1486856 100644 --- a/kinetica-compiler/test/CompilerModelTest.kt +++ b/kinetica-compiler/test/CompilerModelTest.kt @@ -10,7 +10,7 @@ class CompilerModelTest { @Test fun compilerContractListsResponsibilitiesAndDescriptorDefaults() { assertEquals("io.heapy.kinetica.compiler", KineticaCompilerContract.pluginId) - assertEquals("0.3.0", KineticaCompilerContract.pluginVersion) + assertEquals("0.4.0", KineticaCompilerContract.pluginVersion) assertEquals("moduleId", KineticaCompilerContract.optionModuleId) assertEquals("serverSourceSet", KineticaCompilerContract.optionServerSourceSet) assertEquals("clientSourceSet", KineticaCompilerContract.optionClientSourceSet) @@ -743,7 +743,7 @@ class CompilerModelTest { val transforms = generated.getValue("generated/io/heapy/kinetica/generated/KineticaComponentTransforms.kt").text assertTrue("public const val KineticaGeneratedCompilerPluginId: String = \"io.heapy.kinetica.compiler\"" in transforms) - assertTrue("public const val KineticaGeneratedCompilerPluginVersion: String = \"0.3.0\"" in transforms) + assertTrue("public const val KineticaGeneratedCompilerPluginVersion: String = \"0.4.0\"" in transforms) assertTrue("public val KineticaGeneratedComponentTransforms: List" in transforms) assertTrue("componentFqName = \"app.ShopScreen\"" in transforms) assertTrue("name = \"title\"" in transforms) diff --git a/kinetica-gradle-plugin/module.yaml b/kinetica-gradle-plugin/module.yaml new file mode 100644 index 0000000..e2f578a --- /dev/null +++ b/kinetica-gradle-plugin/module.yaml @@ -0,0 +1,36 @@ +product: jvm/lib + +# common.module-template.yaml is deliberately not applied: it registers the Kinetica compiler +# plugin, and this module is build tooling, not a Kinetica UI module. +apply: + - ../publish.module-template.yaml + +repositories: + - id: mavenLocal + url: mavenLocal + publish: true + +settings: + jvm: + # Gradle daemons run on JDK 17+; the toolchain default (21) would make the plugin unloadable + # for anyone whose daemon is on 17. + release: 17 + kotlin: + version: 2.4.10 + # Gradle 9.7 embeds Kotlin 2.4.0, so the plugin must not carry newer metadata than that. + languageVersion: 2.4 + apiVersion: 2.4 + +dependencies: + # Gradle publishes no API jar to Maven Central; dev.gradleplugins redistributes it. 8.11.1 is + # the newest redistribution and sets the supported floor: Gradle 8.11+, 9.x included. + - dev.gradleplugins:gradle-api:8.11.1: compile-only + - org.jetbrains.kotlin:kotlin-gradle-plugin-api:2.4.10: compile-only + +test-dependencies: + - dev.gradleplugins:gradle-api:8.11.1 + - org.jetbrains.kotlin:kotlin-gradle-plugin-api:2.4.10 + # Only for the contract test: the option names below must equal KineticaCompilerContract's. + - ../kinetica-compiler + +description: "Gradle plugin that wires the Kinetica K2 compiler plugin and runtime into Kotlin builds." diff --git a/kinetica-gradle-plugin/resources/META-INF/gradle-plugins/io.heapy.kinetica.properties b/kinetica-gradle-plugin/resources/META-INF/gradle-plugins/io.heapy.kinetica.properties new file mode 100644 index 0000000..7b5d8d4 --- /dev/null +++ b/kinetica-gradle-plugin/resources/META-INF/gradle-plugins/io.heapy.kinetica.properties @@ -0,0 +1 @@ +implementation-class=io.heapy.kinetica.gradle.KineticaGradlePlugin diff --git a/kinetica-gradle-plugin/src/KineticaCoordinates.kt b/kinetica-gradle-plugin/src/KineticaCoordinates.kt new file mode 100644 index 0000000..7204aa1 --- /dev/null +++ b/kinetica-gradle-plugin/src/KineticaCoordinates.kt @@ -0,0 +1,32 @@ +package io.heapy.kinetica.gradle + +/** + * Coordinates and option names baked into the plugin at build time. + * + * The module has no compile dependency on `kinetica-compiler` (it must not compile with the + * Kinetica compiler plugin), so the compiler contract is duplicated here as string constants. + * `KineticaPluginContractTest` asserts every one of them against `KineticaCompilerContract`, and + * [version] against `publish.module-template.yaml` — the same line `scripts/release.sh` parses. + */ +public object KineticaCoordinates { + public const val group: String = "io.heapy.kinetica" + public const val version: String = "0.4.0" + + public const val compilerPluginId: String = "io.heapy.kinetica.compiler" + public const val compilerArtifact: String = "kinetica-compiler" + public const val runtimeArtifact: String = "kinetica-runtime" + public const val browserArtifact: String = "kinetica-browser" + + /** The Kotlin version Kinetica is published with; klib metadata is not forward compatible. */ + public const val kotlinVersion: String = "2.4.10" + + public const val optionModuleId: String = "moduleId" + public const val optionServerSourceSet: String = "serverSourceSet" + public const val optionClientSourceSet: String = "clientSourceSet" + public const val optionTransforms: String = "transforms" + public const val optionSourcePipeline: String = "sourcePipeline" + public const val optionChecks: String = "checks" + + /** The only [optionSourcePipeline] value that is JVM-only. */ + public const val sourcePipelinePsi: String = "psi" +} diff --git a/kinetica-gradle-plugin/src/KineticaExtension.kt b/kinetica-gradle-plugin/src/KineticaExtension.kt new file mode 100644 index 0000000..553f335 --- /dev/null +++ b/kinetica-gradle-plugin/src/KineticaExtension.kt @@ -0,0 +1,55 @@ +package io.heapy.kinetica.gradle + +import org.gradle.api.provider.Property + +/** + * `kinetica { }` in a consumer's build script. + * + * Option names and accepted values are the compiler plugin's own — see + * `kinetica-compiler/src/KineticaCommandLineProcessor.kt` for what each one does. + */ +public abstract class KineticaExtension { + /** Master switch: `false` applies neither the compiler plugin nor the dependencies below. */ + public abstract val enabled: Property + + /** First segment of generated SlotId values. Unset: the compilation's Kotlin module name. */ + public abstract val moduleId: Property + + /** Source set treated as the server side of the server/client boundary, e.g. `jvmMain`. */ + public abstract val serverSourceSet: Property + + /** Source set treated as the client side of the server/client boundary, e.g. `jsMain`. */ + public abstract val clientSourceSet: Property + + /** + * `psi` or `lightTree`. `psi` enables the JVM-only source-processing pipeline and is passed + * to JVM compilations only — on JS and Native it must stay unset, so the plugin drops it + * there instead of failing the build of a multiplatform module. + */ + public abstract val sourcePipeline: Property + + /** `all` or `off` — kill switch for the IR perf transforms. */ + public abstract val transforms: Property + + /** `error` or `off` — the authoring-rule checkers. Turning them off is a migration escape. */ + public abstract val checks: Property + + /** + * Adds `kinetica-runtime` (and `kinetica-browser` for JS targets) at [kineticaVersion]. + * Set to `false` to declare them yourself, e.g. to pin a different version. + */ + public abstract val addRuntimeDependencies: Property + + /** + * Version of the Kinetica artifacts: the compiler plugin and, when + * [addRuntimeDependencies] is on, the runtime ones. Defaults to the version of this plugin. + */ + public abstract val kineticaVersion: Property + + /** + * Version of `io.heapy.kinetica:kinetica-compiler` alone; defaults to [kineticaVersion]. + * Override it to compile against a different compiler build than the runtime — a compiler + * bug hunt, not an everyday setting. + */ + public abstract val compilerVersion: Property +} diff --git a/kinetica-gradle-plugin/src/KineticaGradlePlugin.kt b/kinetica-gradle-plugin/src/KineticaGradlePlugin.kt new file mode 100644 index 0000000..5cf6a81 --- /dev/null +++ b/kinetica-gradle-plugin/src/KineticaGradlePlugin.kt @@ -0,0 +1,225 @@ +package io.heapy.kinetica.gradle + +import org.gradle.api.GradleException +import org.gradle.api.Project +import org.gradle.api.provider.Provider +import org.jetbrains.kotlin.gradle.plugin.KotlinBasePlugin +import org.jetbrains.kotlin.gradle.plugin.KotlinCompilation +import org.jetbrains.kotlin.gradle.plugin.KotlinCompilerPluginSupportPlugin +import org.jetbrains.kotlin.gradle.plugin.KotlinPlatformType +import org.jetbrains.kotlin.gradle.plugin.KotlinSourceSet +import org.jetbrains.kotlin.gradle.plugin.KotlinSourceSetContainer +import org.jetbrains.kotlin.gradle.plugin.KotlinTarget +import org.jetbrains.kotlin.gradle.plugin.KotlinTargetsContainer +import org.jetbrains.kotlin.gradle.plugin.SubpluginArtifact +import org.jetbrains.kotlin.gradle.plugin.SubpluginOption + +/** + * Applies the Kinetica K2 compiler plugin to every Kotlin compilation of the project and, unless + * turned off, the matching runtime dependencies. + * + * The compiler plugin is mandatory: without it `state`/`event` throw `MissingKineticaPluginException` + * at runtime and the authoring-rule checkers stop reporting at compile time. + */ +public class KineticaGradlePlugin : KotlinCompilerPluginSupportPlugin { + private lateinit var extension: KineticaExtension + + override fun apply(target: Project) { + extension = target.extensions.create("kinetica", KineticaExtension::class.java).apply { + enabled.convention(true) + addRuntimeDependencies.convention(true) + kineticaVersion.convention(KineticaCoordinates.version) + // Moving kineticaVersion moves the compiler with it; setting compilerVersion pins + // only the compiler, which is what a compiler-plugin bug hunt needs. + compilerVersion.convention(kineticaVersion) + } + + target.plugins.withType(KotlinBasePlugin::class.java) { kotlinPlugin -> + warnOnKotlinVersionMismatch(target, kotlinPlugin.pluginVersion) + } + + target.afterEvaluate { project -> + if (!extension.enabled.get()) return@afterEvaluate + + if (project.extensions.findByName(KOTLIN_EXTENSION_NAME) == null) { + throw GradleException( + "The io.heapy.kinetica plugin needs a Kotlin plugin in the same project. Apply " + + "org.jetbrains.kotlin.multiplatform (or .jvm) alongside it, or set " + + "kinetica { enabled = false }.", + ) + } + validateOptions() + if (extension.addRuntimeDependencies.get()) { + addRuntimeDependencies(project) + } + warnOnUnusablePsiPipeline(project) + } + } + + override fun getCompilerPluginId(): String = + KineticaCoordinates.compilerPluginId + + override fun getPluginArtifact(): SubpluginArtifact = + SubpluginArtifact( + KineticaCoordinates.group, + KineticaCoordinates.compilerArtifact, + extension.compilerVersion.get(), + ) + + override fun isApplicable(kotlinCompilation: KotlinCompilation<*>): Boolean = + extension.enabled.get() + + override fun applyToCompilation( + kotlinCompilation: KotlinCompilation<*>, + ): Provider> { + val project = kotlinCompilation.project + // The PSI pipeline exists only in the JVM compiler pipeline; passing it to a JS, Native + // or metadata compilation is a hard error there, so a multiplatform module that opts in + // gets it on its JVM compilations and nowhere else. + val acceptsPsi = kotlinCompilation.platformType.isJvmLike() + if (!acceptsPsi && extension.sourcePipeline.orNull == KineticaCoordinates.sourcePipelinePsi) { + project.logger.info( + "Kinetica: sourcePipeline=psi not passed to ${kotlinCompilation.name} of target " + + "${kotlinCompilation.target.name} (${kotlinCompilation.platformType}); it is " + + "supported on JVM compilations only.", + ) + } + + // Only the Property instances are captured, never the compilation, the project or the + // logger: this provider is an input of the compile task and gets serialized by the + // configuration cache. + val moduleId = extension.moduleId + val serverSourceSet = extension.serverSourceSet + val clientSourceSet = extension.clientSourceSet + val transforms = extension.transforms + val checks = extension.checks + val sourcePipeline = extension.sourcePipeline + + return project.provider { + buildList { + addOption(KineticaCoordinates.optionModuleId, moduleId.orNull) + addOption(KineticaCoordinates.optionServerSourceSet, serverSourceSet.orNull) + addOption(KineticaCoordinates.optionClientSourceSet, clientSourceSet.orNull) + addOption(KineticaCoordinates.optionTransforms, transforms.orNull) + addOption(KineticaCoordinates.optionChecks, checks.orNull) + + val pipeline = sourcePipeline.orNull + if (pipeline != null && (acceptsPsi || pipeline != KineticaCoordinates.sourcePipelinePsi)) { + addOption(KineticaCoordinates.optionSourcePipeline, pipeline) + } + } + } + } + + private fun MutableList.addOption(name: String, value: String?) { + if (value != null) add(SubpluginOption(name, value)) + } + + /** The compiler stores unknown option values without complaining, so a typo would be silent. */ + private fun validateOptions() { + checkOption(KineticaCoordinates.optionSourcePipeline, extension.sourcePipeline.orNull, SOURCE_PIPELINES) + checkOption(KineticaCoordinates.optionTransforms, extension.transforms.orNull, TRANSFORMS) + checkOption(KineticaCoordinates.optionChecks, extension.checks.orNull, CHECKS) + } + + private fun checkOption(name: String, value: String?, allowed: Set) { + if (value != null && value !in allowed) { + throw GradleException( + "kinetica { $name = \"$value\" } is not a value the Kinetica compiler plugin " + + "accepts. Allowed: ${allowed.joinToString()}.", + ) + } + } + + private fun warnOnKotlinVersionMismatch(project: Project, kotlinVersion: String) { + if (kotlinVersion != KineticaCoordinates.kotlinVersion) { + project.logger.warn( + "Kinetica ${KineticaCoordinates.version} is published for Kotlin " + + "${KineticaCoordinates.kotlinVersion}, this build uses Kotlin $kotlinVersion. " + + "klib metadata is not forward compatible — expect compilation failures until " + + "both versions match.", + ) + } + } + + private fun warnOnUnusablePsiPipeline(project: Project) { + if (extension.sourcePipeline.orNull != KineticaCoordinates.sourcePipelinePsi) return + if (!hasJvmTarget(project)) { + project.logger.warn( + "Kinetica: sourcePipeline=psi has no effect here — the project has no JVM target.", + ) + } + } + + private fun addRuntimeDependencies(project: Project) { + val version = extension.kineticaVersion.get() + val runtime = "${KineticaCoordinates.group}:${KineticaCoordinates.runtimeArtifact}:$version" + val browser = "${KineticaCoordinates.group}:${KineticaCoordinates.browserArtifact}:$version" + val sourceSets = sourceSetsOf(project) + + val common = sourceSets.findByName(KotlinSourceSet.COMMON_MAIN_SOURCE_SET_NAME) + if (common != null) { + // Multiplatform: commonMain carries the runtime for every target at once. + common.addImplementation(runtime) + } else { + sourceSets.findByName(SINGLE_TARGET_MAIN_SOURCE_SET_NAME)?.addImplementation(runtime) + } + + jsMainSourceSetNames(project).forEach { name -> + sourceSets.findByName(name)?.addImplementation(browser) + } + } + + private fun KotlinSourceSet.addImplementation(notation: String) { + dependencies { handler -> handler.implementation(notation) } + } + + // Single-target JS is not a shape Kinetica can be consumed from: the `kotlin-js` plugin is a + // hard error in Kotlin 2.4.10 ("use kotlin(\"multiplatform\") with a js() target"), so a JS + // target always comes from the multiplatform extension and its targets container. + private fun jsMainSourceSetNames(project: Project): List = + targetsOf(project) + .filter { target -> target.platformType == KotlinPlatformType.js } + .map { target -> + target.compilations + .findByName(KotlinCompilation.MAIN_COMPILATION_NAME) + ?.defaultSourceSet + ?.name + ?: "${target.name}$MAIN_SOURCE_SET_SUFFIX" + } + + private fun hasJvmTarget(project: Project): Boolean { + val targets = targetsOf(project) + if (targets.isNotEmpty()) return targets.any { target -> target.platformType.isJvmLike() } + // Single-target projects have no targets container (KotlinJvmProjectExtension is a + // KotlinSingleTargetExtension), so the applied plugin is the only signal. + return project.pluginManager.hasPlugin(KOTLIN_JVM_PLUGIN_ID) || + project.pluginManager.hasPlugin(KOTLIN_ANDROID_PLUGIN_ID) + } + + private fun sourceSetsOf(project: Project) = + (project.extensions.getByName(KOTLIN_EXTENSION_NAME) as KotlinSourceSetContainer).sourceSets + + private fun targetsOf(project: Project): List = + (project.extensions.getByName(KOTLIN_EXTENSION_NAME) as? KotlinTargetsContainer) + ?.targets + ?.toList() + .orEmpty() + + private fun KotlinPlatformType.isJvmLike(): Boolean = + this == KotlinPlatformType.jvm || this == KotlinPlatformType.androidJvm + + private companion object { + const val KOTLIN_EXTENSION_NAME = "kotlin" + const val KOTLIN_JVM_PLUGIN_ID = "org.jetbrains.kotlin.jvm" + const val KOTLIN_ANDROID_PLUGIN_ID = "org.jetbrains.kotlin.android" + + // Single-target projects (kotlin("jvm"), kotlin("js")) name their source sets main/test. + const val SINGLE_TARGET_MAIN_SOURCE_SET_NAME = "main" + const val MAIN_SOURCE_SET_SUFFIX = "Main" + + val SOURCE_PIPELINES = setOf("psi", "lightTree") + val TRANSFORMS = setOf("all", "off") + val CHECKS = setOf("error", "off") + } +} diff --git a/kinetica-gradle-plugin/test/KineticaPluginContractTest.kt b/kinetica-gradle-plugin/test/KineticaPluginContractTest.kt new file mode 100644 index 0000000..33a3430 --- /dev/null +++ b/kinetica-gradle-plugin/test/KineticaPluginContractTest.kt @@ -0,0 +1,144 @@ +package io.heapy.kinetica.gradle + +import io.heapy.kinetica.compiler.KineticaCompilerContract +import org.gradle.api.Plugin +import org.jetbrains.kotlin.gradle.plugin.KotlinCompilerPluginSupportPlugin +import java.io.File +import java.util.Properties +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +/** + * The Gradle plugin duplicates the compiler contract as string constants (it must not depend on + * `kinetica-compiler` at compile time) and its coordinates are baked in by hand. Both are only + * safe because this test fails when they drift. + */ +class KineticaPluginContractTest { + @Test + fun bakedVersionMatchesThePublishedOne() { + val published = valueOf(repositoryRoot().resolve("publish.module-template.yaml"), "version") + assertEquals(published, KineticaCoordinates.version, "KineticaCoordinates.version is stale") + assertEquals( + published, + KineticaCompilerContract.pluginVersion, + "KineticaCompilerContract.pluginVersion is stale", + ) + } + + @Test + fun coordinatesPointAtArtifactsThisRepositoryPublishes() { + val root = repositoryRoot() + assertEquals( + valueOf(root.resolve("publish.module-template.yaml"), "group"), + KineticaCoordinates.group, + ) + for (artifact in listOf( + KineticaCoordinates.compilerArtifact, + KineticaCoordinates.runtimeArtifact, + KineticaCoordinates.browserArtifact, + )) { + // Module directory name is the artifactId the toolchain publishes under. + assertTrue( + root.resolve("$artifact/module.yaml").isFile, + "$artifact is not a module of this project", + ) + } + } + + /** The two build systems must ask for the same compiler plugin build. */ + @Test + fun compilerCoordinateMatchesTheToolchainTemplate() { + val template = repositoryRoot().resolve("common.module-template.yaml") + val expected = "${KineticaCoordinates.group}:${KineticaCoordinates.compilerArtifact}:" + + KineticaCoordinates.version + assertTrue( + template.readText().contains("dependency: $expected"), + "common.module-template.yaml does not wire $expected", + ) + assertEquals( + valueOf(template, "version"), + KineticaCoordinates.kotlinVersion, + "KineticaCoordinates.kotlinVersion no longer matches the Kotlin version modules build with", + ) + } + + @Test + fun optionNamesMatchTheCompilerContract() { + assertEquals(KineticaCompilerContract.pluginId, KineticaCoordinates.compilerPluginId) + assertEquals(KineticaCompilerContract.optionModuleId, KineticaCoordinates.optionModuleId) + assertEquals(KineticaCompilerContract.optionServerSourceSet, KineticaCoordinates.optionServerSourceSet) + assertEquals(KineticaCompilerContract.optionClientSourceSet, KineticaCoordinates.optionClientSourceSet) + assertEquals(KineticaCompilerContract.optionTransforms, KineticaCoordinates.optionTransforms) + assertEquals(KineticaCompilerContract.optionSourcePipeline, KineticaCoordinates.optionSourcePipeline) + assertEquals(KineticaCompilerContract.optionChecks, KineticaCoordinates.optionChecks) + } + + /** Gradle resolves `id("io.heapy.kinetica")` through this descriptor and nothing else. */ + @Test + fun pluginDescriptorNamesALoadablePluginClass() { + val resource = javaClass.classLoader.getResource(DESCRIPTOR_PATH) + assertNotNull(resource, "$DESCRIPTOR_PATH is not packaged onto the classpath") + + val implementationClass = resource.openStream().use { stream -> + Properties().apply { load(stream) }.getProperty("implementation-class") + } + assertNotNull(implementationClass, "descriptor has no implementation-class") + + val pluginClass = Class.forName(implementationClass) + assertTrue( + Plugin::class.java.isAssignableFrom(pluginClass), + "$implementationClass is not a Gradle Plugin", + ) + assertTrue( + KotlinCompilerPluginSupportPlugin::class.java.isAssignableFrom(pluginClass), + "$implementationClass would not register with the Kotlin Gradle plugin", + ) + } + + /** + * Gradle derives the marker coordinates from the plugin id, so the descriptor's file name and + * the marker the release script writes have to agree — nothing else checks this pairing. + */ + @Test + fun markerScriptPublishesTheIdTheDescriptorDeclares() { + val root = repositoryRoot() + assertTrue( + root.resolve("kinetica-gradle-plugin/resources/$DESCRIPTOR_PATH").isFile, + "plugin id changed without renaming the descriptor", + ) + + val marker = root.resolve("scripts/gradle-plugin-marker.sh").readText() + assertTrue( + marker.contains("artifact=\"$PLUGIN_ID.gradle.plugin\""), + "the marker script does not publish the marker for $PLUGIN_ID", + ) + assertTrue( + marker.contains("kinetica-gradle-plugin"), + "the marker does not depend on this module", + ) + } + + private fun valueOf(file: File, key: String): String { + assertTrue(file.isFile, "missing $file") + val pattern = Regex("""$key:\s*(\S+)""") + return file.readLines() + .firstNotNullOfOrNull { line -> pattern.matchEntire(line.trim())?.groupValues?.get(1) } + ?: error("no `$key:` line in $file") + } + + private fun repositoryRoot(): File { + var candidate: File? = File(System.getProperty("user.dir")).absoluteFile + while (candidate != null) { + if (candidate.resolve("publish.module-template.yaml").isFile) return candidate + candidate = candidate.parentFile + } + error("no repository root above ${System.getProperty("user.dir")}") + } + + private companion object { + const val PLUGIN_ID = "io.heapy.kinetica" + const val DESCRIPTOR_PATH = "META-INF/gradle-plugins/$PLUGIN_ID.properties" + } +} diff --git a/kinetica-markdown/module.yaml b/kinetica-markdown/module.yaml index 71681ff..7249d3c 100644 --- a/kinetica-markdown/module.yaml +++ b/kinetica-markdown/module.yaml @@ -7,6 +7,9 @@ apply: - ../publish.module-template.yaml dependencies: - - ../kinetica-runtime + # exported, like every other battery: public signatures here take ComponentScope + # (MarkdownRenderer.kt:14, CodeHighlight.kt:111), so consumers cannot compile against them + # without the runtime on their compile classpath. + - ../kinetica-runtime: exported description: "Markdown parser and renderer emitting Kinetica host nodes — powers the documentation site." diff --git a/plan.md b/plan.md index f21c683..a74c76d 100644 --- a/plan.md +++ b/plan.md @@ -260,6 +260,20 @@ Tag mapping: `column`/`row` → `GtkBox`, `button` → `GtkButton` (signal `clic - CI: new ubuntu `linux` job in `ci.yml` mirroring the `macos` job (konan cache keyed on `common.module-template.yaml`, publish compiler plugin, `apt-get install -y libgtk-4-dev`, `./kotlin build -m native-counter-gtk`); add to `required.needs`. Smoke-build only (no display), GUI acceptance manual on a Linux box/VM. - **Related:** KNT-0044, KNT-0046. +### KNT-0048 — `io.heapy.kinetica` Gradle plugin +**Status:** Landed 2026-08-17 on `examples/gradle-ssr-consumer` (this branch), unreleased. Verified locally: `./kotlin test -m kinetica-gradle-plugin` (6/0 contract tests) and `node scripts/verify-gradle-plugin.mjs` (10 assertions over two fixtures — multiplatform and single-target JVM — each building with zero Kinetica wiring in the build script, plus `sourcePipeline=psi` withheld from JS, an authoring-rule violation rejected by the FIR checkers on **both** the JVM and the JS compilation, and a configuration-cache entry stored *and* reused). +- **Why:** Kinetica is compiler-plugin-only, so a Gradle consumer had to resolve the plugin jar through a private configuration and push `-Xplugin=` into every `KotlinCompilationTask` by hand (`examples/gradle-ssr/build.gradle.kts`) — 15 lines nobody invents, and the failure mode when they rot is silent (checkers stop reporting). +- **Module `kinetica-gradle-plugin`** (`jvm/lib`, publish template only — applying the common template would compile the build tooling with the Kinetica compiler plugin): `KotlinCompilerPluginSupportPlugin`, compile-only `dev.gradleplugins:gradle-api:8.11.1` + `org.jetbrains.kotlin:kotlin-gradle-plugin-api:2.4.10`, `settings.jvm.release: 17` (toolchain default is 21 bytecode — unloadable on a JDK 17 daemon), `languageVersion/apiVersion 2.4` (Gradle 9.7 embeds Kotlin 2.4.0). Everything needed is in the KGP **api** artifact: `KotlinSourceSetContainer`, `KotlinTargetsContainer`, `KotlinCompilation`, `SubpluginOption` — no dependency on the KGP implementation. +- **`kinetica { }`**: the six compiler options verbatim, plus `enabled` and `addRuntimeDependencies` (adds `kinetica-runtime` to commonMain and `kinetica-browser` to a JS target's main source set, both gated on `enabled`). `sourcePipeline=psi` reaches JVM compilations only — the rule the raw `-Xplugin` wiring cannot express. +- **Publication:** the plugin marker (`io.heapy.kinetica:io.heapy.kinetica.gradle.plugin`) is pom-only and generated by `scripts/gradle-plugin-marker.sh` into mavenLocal; `release.sh` calls it after publishing and its staging glob signs/bundles it like any other artifact. +- **Publication fix that made consumption possible at all:** the toolchain publishes every dependency as runtime-scoped, so consumers had *nothing* on the compile classpath — `Cannot access 'kotlinx.serialization.internal.SerializerFactory' which is a supertype of 'Role.Companion'`. `common.module-template.yaml` now marks coroutines + serialization-json `exported`; both genuinely leak into the public API (`EffectScope : CoroutineScope`, `@Serializable` companions). +- **Gotcha worth keeping:** an unrestricted `mavenLocal()` also serves the partial third-party copies other tools leave in `~/.m2` (jar + pom, no `.module`), and Gradle then resolves a **JVM** artifact into a JS compilation. Scope it: `mavenLocal { content { includeGroup("io.heapy.kinetica") } }`. +- **CI:** the fixture check runs in the **macOS** job — it publishes `kinetica-runtime` & co, whose macosArm64 targets cannot be built on a Linux runner; the module's own tests run in the JVM job. +- **Release sequencing:** version bumped repo-wide to 0.4.0 (0.3.0 is immutable on Central). `examples/gradle-ssr` deliberately stays on 0.3.0 with its manual wiring — switch it to `id("io.heapy.kinetica")` only *after* 0.4.0 is released, or the example stops building against Central. +- **Bytecode floor, found by the single-target fixture:** `kinetica-compiler` was Java 21 bytecode, and it is loaded *into the consumer's Kotlin compile daemon* — a JDK 17 daemon (Android, Spring, anything on `jvmToolchain(17)`) died with `UnsupportedClassVersionError` before compiling a line. `kinetica-compiler/module.yaml` now sets `jvm.release: 17`, same as the Gradle plugin. The runtime modules stay at 21 on purpose: they are read by the compiler, not loaded by it, and 21 is the library's target. +- **Adversarial review (codex, 2026-08-17) also produced:** `compilerVersion` silently drove the runtime coordinates too → split into `kineticaVersion` + `compilerVersion`; `enabled = false` still demanded a Kotlin plugin; option typos (`checks = "warn"`) were silently inert → validated against the accepted sets; the psi log lived inside the provider that the configuration cache serializes → only `Property` instances are captured now; the verifier could pass with an up-to-date `compileKotlinJs` → both backends now run a negative pass, which is what proves the checkers are live on JS. `kotlin("js")` single-target needs no support: the plugin is a hard error in Kotlin 2.4.10. +- **Open:** Kotlin/Native. KGP 2.4.10's `KotlinCompilerPluginSupportPlugin` has no `getPluginArtifactForNative()` at all — Native goes through the same `getPluginArtifact()` path — but no Native compilation was exercised from Gradle; the fixtures are jvm+js. Verify before promising Native support to Gradle consumers. + ## Order of work 1. The backlog is unscheduled. Per-ticket starting points: KNT-0024 → re-profile, KNT-0028/KNT-0035 → spec decision, KNT-0036 → design decision, KNT-0045 → runtime listener API first (it unblocks both marshals), KNT-0046 → ListReconcile move + docs code-links, KNT-0047 → gtk-kn vs generated-def spike. Native-renderer sequence: commit Phase 1 (with `.zcode/` gitignored) → KNT-0045 → KNT-0046 → KNT-0047. (KNT-0031/0033/0033b/0034/0038/0039 landed on `mem-opt-experiments`, pending merge review — see the "Landed" one-liners above; full detail in git history.) diff --git a/project.yaml b/project.yaml index 7851404..e581e48 100644 --- a/project.yaml +++ b/project.yaml @@ -7,6 +7,7 @@ modules: - ./kinetica-compiler - ./kinetica-data - ./kinetica-forms + - ./kinetica-gradle-plugin - ./kinetica-gtk - ./kinetica-markdown - ./kinetica-motion diff --git a/publish.module-template.yaml b/publish.module-template.yaml index 4fdd234..239f133 100644 --- a/publish.module-template.yaml +++ b/publish.module-template.yaml @@ -26,7 +26,7 @@ settings: publishing: enabled: true group: io.heapy.kinetica - version: 0.3.0 + version: 0.4.0 publishSources: true # mavenCentral: enabled # signArtifacts: true diff --git a/samples/annotated-js/module.yaml b/samples/annotated-js/module.yaml index 60b9e4d..8cdaf57 100644 --- a/samples/annotated-js/module.yaml +++ b/samples/annotated-js/module.yaml @@ -13,7 +13,7 @@ settings: kotlin: compilerPlugins: - id: io.heapy.kinetica.compiler - dependency: io.heapy.kinetica:kinetica-compiler:0.3.0 + dependency: io.heapy.kinetica:kinetica-compiler:0.4.0 options: moduleId: annotated-js serverSourceSet: serverMain diff --git a/samples/annotated/module.yaml b/samples/annotated/module.yaml index 5da09b6..c0a1f44 100644 --- a/samples/annotated/module.yaml +++ b/samples/annotated/module.yaml @@ -16,7 +16,7 @@ settings: kotlin: compilerPlugins: - id: io.heapy.kinetica.compiler - dependency: io.heapy.kinetica:kinetica-compiler:0.3.0 + dependency: io.heapy.kinetica:kinetica-compiler:0.4.0 options: moduleId: annotated sourcePipeline: psi diff --git a/samples/annotated/src/main.kt b/samples/annotated/src/main.kt index d9f90e3..2463b8e 100644 --- a/samples/annotated/src/main.kt +++ b/samples/annotated/src/main.kt @@ -89,7 +89,7 @@ private fun verifyHoisting() { fun main() { check(KineticaGeneratedCompilerPluginId == "io.heapy.kinetica.compiler") - check(KineticaGeneratedCompilerPluginVersion == "0.3.0") + check(KineticaGeneratedCompilerPluginVersion == "0.4.0") check(KineticaGeneratedComponentTransforms.any { it.componentFqName == "app.annotated.AnnotatedApp" }) check(KineticaGeneratedPreviews.any { it.componentFqName == "app.annotated.AnnotatedApp" }) diff --git a/samples/annotated/test/AnnotatedAppTest.kt b/samples/annotated/test/AnnotatedAppTest.kt index 30dde08..ceb625d 100644 --- a/samples/annotated/test/AnnotatedAppTest.kt +++ b/samples/annotated/test/AnnotatedAppTest.kt @@ -17,7 +17,7 @@ class AnnotatedAppTest { @Test fun compilerPluginGeneratesMetadataAndAnnotatedAppRenders() { assertEquals("io.heapy.kinetica.compiler", KineticaGeneratedCompilerPluginId) - assertEquals("0.3.0", KineticaGeneratedCompilerPluginVersion) + assertEquals("0.4.0", KineticaGeneratedCompilerPluginVersion) assertEquals(emptyList(), KineticaGeneratedServerActions) assertEquals(emptyList(), KineticaGeneratedServerActionStubs) assertEquals(emptyList(), KineticaGeneratedClientManifest.components) diff --git a/samples/browser-bench/module.yaml b/samples/browser-bench/module.yaml index c186603..f1c1bc4 100644 --- a/samples/browser-bench/module.yaml +++ b/samples/browser-bench/module.yaml @@ -14,7 +14,7 @@ settings: kotlin: compilerPlugins: - id: io.heapy.kinetica.compiler - dependency: io.heapy.kinetica:kinetica-compiler:0.3.0 + dependency: io.heapy.kinetica:kinetica-compiler:0.4.0 options: moduleId: browser-bench diff --git a/scripts/fixtures/gradle-plugin-consumer-jvm/.gitignore b/scripts/fixtures/gradle-plugin-consumer-jvm/.gitignore new file mode 100644 index 0000000..478dbaa --- /dev/null +++ b/scripts/fixtures/gradle-plugin-consumer-jvm/.gitignore @@ -0,0 +1,2 @@ +# Gradle's own project cache; build/ and .kotlin/ are ignored repo-wide. +.gradle/ diff --git a/scripts/fixtures/gradle-plugin-consumer-jvm/build.gradle.kts b/scripts/fixtures/gradle-plugin-consumer-jvm/build.gradle.kts new file mode 100644 index 0000000..f056eaa --- /dev/null +++ b/scripts/fixtures/gradle-plugin-consumer-jvm/build.gradle.kts @@ -0,0 +1,37 @@ +// Single-target Kotlin/JVM — the shape an SSR server has. Its extension is a +// KotlinSingleTargetExtension with no targets container and no commonMain source set, so the +// plugin has to take a different path than in the multiplatform fixture: `main` gets the runtime, +// and `sourcePipeline = "psi"` must not produce the "project has no JVM target" warning. +plugins { + id("org.jetbrains.kotlin.jvm") + id("io.heapy.kinetica") +} + +repositories { + mavenLocal { + content { + includeGroup("io.heapy.kinetica") + } + } + mavenCentral() +} + +val kineticaVersion = providers.gradleProperty("kineticaVersion").get() + +kinetica { + moduleId = "fixture-jvm" + sourcePipeline = "psi" +} + +kotlin { + jvmToolchain(21) +} + +dependencies { + testImplementation(kotlin("test")) + testImplementation("io.heapy.kinetica:kinetica-test:$kineticaVersion") +} + +tasks.named("test") { + useJUnitPlatform() +} diff --git a/scripts/fixtures/gradle-plugin-consumer-jvm/gradle.properties b/scripts/fixtures/gradle-plugin-consumer-jvm/gradle.properties new file mode 100644 index 0000000..8c3e582 --- /dev/null +++ b/scripts/fixtures/gradle-plugin-consumer-jvm/gradle.properties @@ -0,0 +1,3 @@ +org.gradle.jvmargs=-Xmx2g -XX:MaxMetaspaceSize=768m +org.gradle.caching=true +kotlin.code.style=official diff --git a/scripts/fixtures/gradle-plugin-consumer-jvm/settings.gradle.kts b/scripts/fixtures/gradle-plugin-consumer-jvm/settings.gradle.kts new file mode 100644 index 0000000..5fc21ee --- /dev/null +++ b/scripts/fixtures/gradle-plugin-consumer-jvm/settings.gradle.kts @@ -0,0 +1,21 @@ +pluginManagement { + repositories { + mavenLocal { + content { + includeGroup("io.heapy.kinetica") + } + } + gradlePluginPortal() + mavenCentral() + } + + val kineticaVersion = providers.gradleProperty("kineticaVersion").orNull + ?: error("pass -PkineticaVersion=, or run scripts/verify-gradle-plugin.mjs") + + plugins { + id("org.jetbrains.kotlin.jvm") version "2.4.10" + id("io.heapy.kinetica") version kineticaVersion + } +} + +rootProject.name = "gradle-plugin-consumer-jvm" diff --git a/scripts/fixtures/gradle-plugin-consumer-jvm/src/main/kotlin/fixture/Counter.kt b/scripts/fixtures/gradle-plugin-consumer-jvm/src/main/kotlin/fixture/Counter.kt new file mode 100644 index 0000000..4615369 --- /dev/null +++ b/scripts/fixtures/gradle-plugin-consumer-jvm/src/main/kotlin/fixture/Counter.kt @@ -0,0 +1,25 @@ +package fixture + +import io.heapy.kinetica.ComponentScope +import io.heapy.kinetica.Role +import io.heapy.kinetica.Semantics +import io.heapy.kinetica.UiComponent +import io.heapy.kinetica.button +import io.heapy.kinetica.column +import io.heapy.kinetica.state +import io.heapy.kinetica.text + +@UiComponent +fun ComponentScope.Counter(start: Int = 0) { + var count by state { start } + + column { + text("count: $count") + button( + onClick = { count += 1 }, + semantics = Semantics(role = Role.Button, testTag = "increment", focusable = true), + ) { + text("+") + } + } +} diff --git a/scripts/fixtures/gradle-plugin-consumer-jvm/src/test/kotlin/fixture/CounterTest.kt b/scripts/fixtures/gradle-plugin-consumer-jvm/src/test/kotlin/fixture/CounterTest.kt new file mode 100644 index 0000000..6d71499 --- /dev/null +++ b/scripts/fixtures/gradle-plugin-consumer-jvm/src/test/kotlin/fixture/CounterTest.kt @@ -0,0 +1,23 @@ +package fixture + +import io.heapy.kinetica.TextNode +import io.heapy.kinetica.testing.KineticaTest +import io.heapy.kinetica.testing.hasTestTag +import io.heapy.kinetica.testing.hasText +import kotlin.test.Test +import kotlin.test.assertEquals + +class CounterTest { + /** + * Without the compiler plugin `state` throws MissingKineticaPluginException, so a green run + * here is proof the Gradle plugin wired it into this compilation. + */ + @Test + fun stateAndEventsWorkInAGradleBuild() { + val root = KineticaTest.render { Counter() } + + assertEquals("count: 0", (root.node(hasText("count: 0")).node as TextNode).value) + root.click(hasTestTag("increment")) + assertEquals("count: 1", (root.node(hasText("count: 1")).node as TextNode).value) + } +} diff --git a/scripts/fixtures/gradle-plugin-consumer/.gitignore b/scripts/fixtures/gradle-plugin-consumer/.gitignore new file mode 100644 index 0000000..478dbaa --- /dev/null +++ b/scripts/fixtures/gradle-plugin-consumer/.gitignore @@ -0,0 +1,2 @@ +# Gradle's own project cache; build/ and .kotlin/ are ignored repo-wide. +.gradle/ diff --git a/scripts/fixtures/gradle-plugin-consumer/build.gradle.kts b/scripts/fixtures/gradle-plugin-consumer/build.gradle.kts new file mode 100644 index 0000000..3e18eab --- /dev/null +++ b/scripts/fixtures/gradle-plugin-consumer/build.gradle.kts @@ -0,0 +1,45 @@ +@file:OptIn(org.jetbrains.kotlin.gradle.ExperimentalKotlinGradlePluginApi::class) + +// What this fixture asserts: everything a Kinetica consumer needs is in the two plugin lines +// below. No -Xplugin wiring, no kinetica-runtime/kinetica-browser declarations — if the Gradle +// plugin stops doing either job, this project fails to compile. +plugins { + id("org.jetbrains.kotlin.multiplatform") + id("io.heapy.kinetica") +} + +repositories { + // Scoped to our own group on purpose: an unrestricted mavenLocal() also serves the partial + // copies of third-party libraries that other tools leave in ~/.m2 (jar + pom, no Gradle + // module metadata), and Gradle then resolves a JVM artifact into the JS compilation. + mavenLocal { + content { + includeGroup("io.heapy.kinetica") + } + } + mavenCentral() +} + +val kineticaVersion = providers.gradleProperty("kineticaVersion").get() + +kinetica { + moduleId = "fixture" + // JVM-only. The JS compilation must not receive it — the plugin is what keeps them apart. + sourcePipeline = "psi" +} + +kotlin { + jvmToolchain(21) + + jvm() + js { + nodejs() + } + + sourceSets { + jvmTest.dependencies { + implementation(kotlin("test")) + implementation("io.heapy.kinetica:kinetica-test:$kineticaVersion") + } + } +} diff --git a/scripts/fixtures/gradle-plugin-consumer/gradle.properties b/scripts/fixtures/gradle-plugin-consumer/gradle.properties new file mode 100644 index 0000000..34d43a6 --- /dev/null +++ b/scripts/fixtures/gradle-plugin-consumer/gradle.properties @@ -0,0 +1,6 @@ +org.gradle.jvmargs=-Xmx2g -XX:MaxMetaspaceSize=768m +org.gradle.caching=true +# Kotlin/JS compile tasks (KGP 2.4.10) are not configuration-cache safe yet — same reason as in +# examples/gradle-ssr. +org.gradle.configuration-cache=false +kotlin.code.style=official diff --git a/scripts/fixtures/gradle-plugin-consumer/negative/SlotOutsideComponent.kt b/scripts/fixtures/gradle-plugin-consumer/negative/SlotOutsideComponent.kt new file mode 100644 index 0000000..92b344f --- /dev/null +++ b/scripts/fixtures/gradle-plugin-consumer/negative/SlotOutsideComponent.kt @@ -0,0 +1,13 @@ +package fixture + +import io.heapy.kinetica.ComponentScope +import io.heapy.kinetica.state + +// Copied into src/commonMain by scripts/verify-gradle-plugin.mjs for the negative pass: `state` +// outside a @UiComponent must be rejected by the plugin's FIR checker +// (SLOT_CALL_OUTSIDE_COMPONENT). A build that accepts this file has lost the checkers, which is +// the failure mode the raw -Xplugin wiring hides. +fun ComponentScope.notAComponent(): Int { + val count by state { 0 } + return count +} diff --git a/scripts/fixtures/gradle-plugin-consumer/settings.gradle.kts b/scripts/fixtures/gradle-plugin-consumer/settings.gradle.kts new file mode 100644 index 0000000..0866233 --- /dev/null +++ b/scripts/fixtures/gradle-plugin-consumer/settings.gradle.kts @@ -0,0 +1,23 @@ +pluginManagement { + // mavenLocal first: this fixture always verifies the plugin that was just built, never a + // released one. + repositories { + mavenLocal { + content { + includeGroup("io.heapy.kinetica") + } + } + gradlePluginPortal() + mavenCentral() + } + + val kineticaVersion = providers.gradleProperty("kineticaVersion").orNull + ?: error("pass -PkineticaVersion=, or run scripts/verify-gradle-plugin.mjs") + + plugins { + id("org.jetbrains.kotlin.multiplatform") version "2.4.10" + id("io.heapy.kinetica") version kineticaVersion + } +} + +rootProject.name = "gradle-plugin-consumer" diff --git a/scripts/fixtures/gradle-plugin-consumer/src/commonMain/kotlin/fixture/Counter.kt b/scripts/fixtures/gradle-plugin-consumer/src/commonMain/kotlin/fixture/Counter.kt new file mode 100644 index 0000000..4615369 --- /dev/null +++ b/scripts/fixtures/gradle-plugin-consumer/src/commonMain/kotlin/fixture/Counter.kt @@ -0,0 +1,25 @@ +package fixture + +import io.heapy.kinetica.ComponentScope +import io.heapy.kinetica.Role +import io.heapy.kinetica.Semantics +import io.heapy.kinetica.UiComponent +import io.heapy.kinetica.button +import io.heapy.kinetica.column +import io.heapy.kinetica.state +import io.heapy.kinetica.text + +@UiComponent +fun ComponentScope.Counter(start: Int = 0) { + var count by state { start } + + column { + text("count: $count") + button( + onClick = { count += 1 }, + semantics = Semantics(role = Role.Button, testTag = "increment", focusable = true), + ) { + text("+") + } + } +} diff --git a/scripts/fixtures/gradle-plugin-consumer/src/jvmTest/kotlin/fixture/CounterTest.kt b/scripts/fixtures/gradle-plugin-consumer/src/jvmTest/kotlin/fixture/CounterTest.kt new file mode 100644 index 0000000..6d71499 --- /dev/null +++ b/scripts/fixtures/gradle-plugin-consumer/src/jvmTest/kotlin/fixture/CounterTest.kt @@ -0,0 +1,23 @@ +package fixture + +import io.heapy.kinetica.TextNode +import io.heapy.kinetica.testing.KineticaTest +import io.heapy.kinetica.testing.hasTestTag +import io.heapy.kinetica.testing.hasText +import kotlin.test.Test +import kotlin.test.assertEquals + +class CounterTest { + /** + * Without the compiler plugin `state` throws MissingKineticaPluginException, so a green run + * here is proof the Gradle plugin wired it into this compilation. + */ + @Test + fun stateAndEventsWorkInAGradleBuild() { + val root = KineticaTest.render { Counter() } + + assertEquals("count: 0", (root.node(hasText("count: 0")).node as TextNode).value) + root.click(hasTestTag("increment")) + assertEquals("count: 1", (root.node(hasText("count: 1")).node as TextNode).value) + } +} diff --git a/scripts/gradle-plugin-marker.sh b/scripts/gradle-plugin-marker.sh new file mode 100755 index 0000000..969a47e --- /dev/null +++ b/scripts/gradle-plugin-marker.sh @@ -0,0 +1,70 @@ +#!/usr/bin/env bash +# Writes the Gradle plugin marker publication into the local Maven repository. +# +# `plugins { id("io.heapy.kinetica") }` resolves a pom-only artifact whose coordinates Gradle +# derives from the plugin id: io.heapy.kinetica:io.heapy.kinetica.gradle.plugin. That marker +# depends on the real implementation module. Nothing but `java-gradle-plugin` generates it, and +# this repository builds with the Kotlin Toolchain, so it is written here. +# +# The marker lands next to the modules the toolchain publishes, which is all `scripts/release.sh` +# needs: its staging step globs every artifact directory under the group, so the marker is copied, +# checksummed and signed with everything else. +# +# scripts/gradle-plugin-marker.sh write into ~/.m2/repository +# MAVEN_LOCAL_REPO=/tmp/repo scripts/gradle-plugin-marker.sh +set -euo pipefail + +repo_root="$(cd "$(dirname "$0")/.." && pwd)" +cd "$repo_root" + +version="$(sed -n 's/^ *version: *//p' publish.module-template.yaml | head -1)" +[ -n "$version" ] || { echo "cannot read version from publish.module-template.yaml" >&2; exit 1; } + +maven_local="${MAVEN_LOCAL_REPO:-$HOME/.m2/repository}" +artifact="io.heapy.kinetica.gradle.plugin" +target="$maven_local/io/heapy/kinetica/$artifact/$version" +pom="$target/$artifact-$version.pom" + +mkdir -p "$target" +# The metadata block mirrors publish.module-template.yaml: Central validates the marker POM like +# any other artifact and rejects it without name, description, url, licenses, developers and scm. +cat > "$pom" < + + 4.0.0 + io.heapy.kinetica + $artifact + $version + pom + $artifact + Gradle plugin marker for io.heapy.kinetica. + https://github.com/Heapy/kinetica + + + The Apache License, Version 2.0 + https://www.apache.org/licenses/LICENSE-2.0.txt + + + + + Ruslan Ibrahimau + + + + scm:git:https://github.com/Heapy/kinetica.git + scm:git:https://github.com/Heapy/kinetica.git + https://github.com/Heapy/kinetica.git + + + + io.heapy.kinetica + kinetica-gradle-plugin + $version + + + +EOF + +echo "marker: $pom" diff --git a/scripts/release.sh b/scripts/release.sh index 7989ef8..4c7824e 100755 --- a/scripts/release.sh +++ b/scripts/release.sh @@ -27,9 +27,16 @@ cd "$repo_root" # kinetica-gtk is deliberately absent: it targets linuxX64 and needs GTK dev headers, so it can # only be published from a Linux host. Run this script there with PUBLISH_MODULES=kinetica-gtk # to add it to a release. -default_modules="kinetica-compiler,kinetica-runtime,kinetica-render-core,kinetica-browser,kinetica-appkit,kinetica-data,kinetica-forms,kinetica-markdown,kinetica-motion,kinetica-persist,kinetica-router,kinetica-test,kinetica-theme" +default_modules="kinetica-compiler,kinetica-gradle-plugin,kinetica-runtime,kinetica-render-core,kinetica-browser,kinetica-appkit,kinetica-data,kinetica-forms,kinetica-markdown,kinetica-motion,kinetica-persist,kinetica-router,kinetica-test,kinetica-theme" modules="${PUBLISH_MODULES:-$default_modules}" +# The Gradle plugin resolves io.heapy.kinetica:kinetica-compiler at its own version, so a bundle +# carrying one without the other is broken on arrival. Checked before anything is published. +if [[ ",$modules," == *",kinetica-gradle-plugin,"* && ",$modules," != *",kinetica-compiler,"* ]]; then + echo "kinetica-gradle-plugin needs kinetica-compiler in the same release: add it to PUBLISH_MODULES" >&2 + exit 1 +fi + group="io.heapy.kinetica" group_path="io/heapy/kinetica" version="$(sed -n 's/^ *version: *//p' publish.module-template.yaml | head -1)" @@ -54,6 +61,12 @@ rm -rf "${maven_local:?}/$group_path"/*/"$version" # `publish` takes one comma-separated -m; repeating the flag silently keeps only the last module. ./kotlin publish mavenLocal -m "$modules" +# `plugins { id("io.heapy.kinetica") }` resolves a pom-only marker the toolchain knows nothing +# about; it is written into the same local repository and picked up by the staging loop below. +if [[ ",$modules," == *",kinetica-gradle-plugin,"* ]]; then + MAVEN_LOCAL_REPO="$maven_local" "$repo_root/scripts/gradle-plugin-marker.sh" +fi + echo "==> staging $stage" rm -rf "$stage" "$bundle" while IFS= read -r dir; do diff --git a/scripts/verify-gradle-plugin.mjs b/scripts/verify-gradle-plugin.mjs new file mode 100644 index 0000000..76d78a8 --- /dev/null +++ b/scripts/verify-gradle-plugin.mjs @@ -0,0 +1,162 @@ +// End-to-end verification of the io.heapy.kinetica Gradle plugin: publishes the current sources +// to the local Maven repository, writes the plugin marker, and builds scripts/fixtures/ +// gradle-plugin-consumer — a project whose only Kinetica wiring is `id("io.heapy.kinetica")`. +// +// node scripts/verify-gradle-plugin.mjs full run +// node scripts/verify-gradle-plugin.mjs --no-publish reuse what is already in ~/.m2 +// +// Needs a JDK 17+ on JAVA_HOME for the Gradle wrapper (CI gets one from setup-kinetica; the +// `./kotlin` CLI provisions its own and does not export it). +// +// Three things are proven, in order: the plugin resolves through its marker and compiles a +// multiplatform project; the compiler plugin actually ran (state/event work at runtime, which +// is what MissingKineticaPluginException would otherwise report); and the FIR checkers still +// reject invalid code — the silent failure mode of hand-rolled -Xplugin wiring. +import { spawnSync } from "node:child_process"; +import { copyFileSync, existsSync, readFileSync, rmSync } from "node:fs"; +import { basename, dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +const repoRoot = dirname(dirname(fileURLToPath(import.meta.url))); +// Two project shapes, because the plugin takes a different path through each: multiplatform has a +// targets container and a commonMain source set, single-target JVM has neither. +const fixture = join(repoRoot, "scripts", "fixtures", "gradle-plugin-consumer"); +const jvmFixture = join(repoRoot, "scripts", "fixtures", "gradle-plugin-consumer-jvm"); +// The example's wrapper is the only Gradle distribution in the repository; the fixtures borrow it. +const gradlew = join(repoRoot, "examples", "gradle-ssr", "gradlew"); +const negativeSource = join(fixture, "negative", "SlotOutsideComponent.kt"); +const negativeTarget = join(fixture, "src", "commonMain", "kotlin", "fixture", "SlotOutsideComponent.kt"); +let negativeCopied = false; + +const publish = !process.argv.includes("--no-publish"); + +function version() { + const template = readFileSync(join(repoRoot, "publish.module-template.yaml"), "utf8"); + const match = template.match(/^\s*version:\s*(\S+)\s*$/m); + if (!match) throw new Error("no `version:` line in publish.module-template.yaml"); + return match[1]; +} + +// What the fixture resolves, computed rather than listed: a hand-written list silently misses a +// transitive module (kinetica-browser needs kinetica-render-core) and a full release publish +// would drag in modules this fixture never touches. +function moduleClosure(roots) { + const seen = new Set(); + const queue = [...roots]; + while (queue.length > 0) { + const name = queue.shift(); + if (seen.has(name)) continue; + seen.add(name); + const manifest = readFileSync(join(repoRoot, name, "module.yaml"), "utf8"); + for (const match of manifest.matchAll(/^\s*-\s*(\.\.[^:\s]+)/gm)) { + const dependency = basename(match[1]); + if (existsSync(join(repoRoot, dependency, "module.yaml"))) queue.push(dependency); + } + } + return [...seen]; +} + +function run(command, args, options = {}) { + console.log(`\n> ${command} ${args.join(" ")}`); + const result = spawnSync(command, args, { + cwd: repoRoot, + encoding: "utf8", + stdio: options.capture ? "pipe" : "inherit", + ...options, + }); + if (result.error) throw result.error; + if (options.capture) process.stdout.write(result.stdout + result.stderr); + return { status: result.status, output: options.capture ? result.stdout + result.stderr : "" }; +} + +function check(condition, message) { + if (!condition) { + console.error(`FAIL: ${message}`); + process.exitCode = 1; + throw new Error(message); + } + console.log(`ok: ${message}`); +} + +const kineticaVersion = version(); +console.log(`verifying the Gradle plugin at ${kineticaVersion}`); + +try { + if (publish) { + const modules = moduleClosure([ + "kinetica-compiler", + "kinetica-gradle-plugin", + "kinetica-runtime", + "kinetica-browser", + "kinetica-test", + ]).join(","); + const published = run("./kotlin", ["publish", "mavenLocal", "-m", modules]); + check(published.status === 0, `published ${modules.split(",").length} modules to mavenLocal`); + const marker = run(join(repoRoot, "scripts", "gradle-plugin-marker.sh"), []); + check(marker.status === 0, "wrote the io.heapy.kinetica.gradle.plugin marker"); + } + + const gradleArgs = [ + "-p", fixture, + `-PkineticaVersion=${kineticaVersion}`, + "--info", + ]; + + // --rerun belongs to the task before it: an up-to-date jvmTest or compileKotlinJs would + // assert nothing about the compiler plugin. + const positive = run( + gradlew, + [...gradleArgs, "jvmTest", "--rerun", "compileKotlinJs", "--rerun"], + { capture: true }, + ); + check(positive.status === 0, "the multiplatform fixture builds and its jvmTest passes with no manual wiring"); + check( + /sourcePipeline=psi not passed to .*(js|main of target js)/i.test(positive.output), + "sourcePipeline=psi was withheld from the non-JVM compilations", + ); + + // The configuration cache runs here rather than in the multiplatform fixture, whose KGP 2.4.10 + // JS tasks are not cache-safe: this is the only shape that can catch the plugin capturing + // something unserializable (a Project, a Logger) in the options provider. + const jvmArgs = [ + "-p", jvmFixture, + `-PkineticaVersion=${kineticaVersion}`, + "--info", + "--configuration-cache", + "test", "--rerun", + ]; + const jvmOnly = run(gradlew, jvmArgs, { capture: true }); + check(jvmOnly.status === 0, "the single-target JVM fixture builds and its test passes"); + check( + !/sourcePipeline=psi has no effect here/.test(jvmOnly.output), + "a single-target JVM project is not told its psi pipeline is useless", + ); + check( + /Configuration cache entry (stored|reused)/.test(jvmOnly.output), + "the plugin's configuration is storable in the configuration cache", + ); + + const jvmCached = run(gradlew, jvmArgs, { capture: true }); + check( + jvmCached.status === 0 && /Configuration cache entry reused/.test(jvmCached.output), + "the second run reuses that entry instead of discarding it", + ); + + copyFileSync(negativeSource, negativeTarget); + negativeCopied = true; + // Once per backend: a plugin that silently stops running on one of them still compiles valid + // code, so only the rejected file proves the checkers are live there. + for (const task of ["compileKotlinJvm", "compileKotlinJs"]) { + const negative = run(gradlew, [...gradleArgs, task], { capture: true }); + check(negative.status !== 0, `the authoring-rule violation fails ${task}`); + check( + negative.output.includes("can only be called inside a @UiComponent function"), + `${task} failed with the Kinetica FIR diagnostic, not an unrelated error`, + ); + } +} finally { + // Only ever removes the file this run put there. + if (negativeCopied) rmSync(negativeTarget, { force: true }); +} + +console.log("\ngradle plugin verification passed"); From 72284a28e3ef1eb5453cd3bdad6622c5c9dcf27b Mon Sep 17 00:00:00 2001 From: Ruslan Ibrahimau Date: Tue, 18 Aug 2026 02:10:24 +0300 Subject: [PATCH 2/3] build: publish the compiler plugin before the modules that compile with it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit release.sh drops every local artifact of the version being released so a stale one cannot end up in the bundle — and that includes kinetica-compiler, which every other module compiles with. The toolchain resolves it as an ordinary external dependency and has no ordering edge to the module that produces it, so the first 0.4.0 attempt died on `Unable to resolve dependency io.heapy.kinetica:kinetica-compiler:0.4.0`. Earlier releases only worked because the version already happened to be in ~/.m2. Publish it on its own first, exactly like every build in this repository starts. Co-Authored-By: Claude Opus 5 (1M context) --- scripts/release.sh | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/scripts/release.sh b/scripts/release.sh index 4c7824e..b91f936 100755 --- a/scripts/release.sh +++ b/scripts/release.sh @@ -58,6 +58,13 @@ echo "==> publishing $group:*:$version to $maven_local" # A stale artifact of the same version would silently end up in the bundle, so drop the previous # staging of this group/version first. Only our own coordinates are touched. rm -rf "${maven_local:?}/$group_path"/*/"$version" +# The line above just deleted the compiler plugin every other module compiles with, and the +# toolchain resolves it as an ordinary external dependency — it has no ordering edge to the +# module that produces it. So publish it on its own first, exactly like every build in this +# repository starts, and only then the rest. +if [[ ",$modules," == *",kinetica-compiler,"* ]]; then + ./kotlin publish mavenLocal -m kinetica-compiler +fi # `publish` takes one comma-separated -m; repeating the flag silently keeps only the last module. ./kotlin publish mavenLocal -m "$modules" From 40093e5a78adc9ff77c879f6218551bddc32ddf5 Mon Sep 17 00:00:00 2001 From: Ruslan Ibrahimau Date: Wed, 19 Aug 2026 04:05:42 +0300 Subject: [PATCH 3/3] examples: consume the released io.heapy.kinetica plugin from Central MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 0.4.0 is on Maven Central, so the example drops the wiring it existed to demonstrate: the resolvable kineticaCompiler configuration, the -Xplugin argument pushed into every KotlinCompilationTask, and the explicit kinetica-runtime / kinetica-browser dependencies. All of it is now `alias(libs.plugins.kinetica)`. kotlinx-serialization-json stays declared, with a different reason: this example's own code builds island props and JSON-LD with it. Consumers who only use Kinetica no longer need it — 0.4.0 exports serialization and coroutines to the compile classpath. Verified against Central with a clean, cache-free build: jvmTest (the SEO contract tests) and jsBrowserDistribution both green with no Kinetica artifact declared anywhere in the build. Co-Authored-By: Claude Opus 5 (1M context) --- examples/gradle-ssr/README.md | 45 +++++++++---------- examples/gradle-ssr/build.gradle.kts | 34 +++----------- examples/gradle-ssr/gradle/libs.versions.toml | 9 ++-- plan.md | 2 +- 4 files changed, 34 insertions(+), 56 deletions(-) diff --git a/examples/gradle-ssr/README.md b/examples/gradle-ssr/README.md index 3ad7780..c7f72b9 100644 --- a/examples/gradle-ssr/README.md +++ b/examples/gradle-ssr/README.md @@ -5,7 +5,7 @@ everybody, rendered on the server, with the interactive parts hydrated as island user-agent sniffing, no bot-only rendering path. It is also a consumer test of the release: nothing here is built from this repository's sources — -the `io.heapy.kinetica:*:0.3.0` artifacts come from Maven Central, and the build system is plain +the `io.heapy.kinetica:*:0.4.0` artifacts come from Maven Central, and the build system is plain Gradle 9.7.0, not the Kotlin Toolchain the rest of the repo uses. ## Run @@ -44,36 +44,35 @@ the distinction does not matter — the content is in the first response either ## Gradle wiring worth copying -**This section is on its way out.** From 0.4.0 Kinetica publishes the `io.heapy.kinetica` Gradle -plugin, which does all of the wiring below plus the runtime dependencies; this example keeps the -manual form until that release is on Central, since it consumes only published artifacts. - -Kinetica 0.3.0 is compiler-plugin-only and publishes no Gradle subplugin, so the plugin jar is -resolved through its own configuration and passed to every Kotlin compilation: +Kinetica is compiler-plugin-only: without its K2 plugin on the compilation, `state`/`event` calls +throw `MissingKineticaPluginException` at runtime and the `@UiComponent` authoring rules stop +being enforced at compile time. From 0.4.0 that wiring is one plugin id: ```kotlin -val kineticaCompiler = configurations.resolvable("kineticaCompiler") { isTransitive = false } -dependencies { add(kineticaCompiler.name, libs.kinetica.compiler) } - -val kineticaPluginArgument = kineticaCompiler.flatMap { configuration -> - configuration.elements.map { jars -> "-Xplugin=${jars.single().asFile.absolutePath}" } -} -tasks.withType>().configureEach { - compilerOptions.freeCompilerArgs.add(kineticaPluginArgument) +plugins { + kotlin("multiplatform") version "2.4.10" + id("io.heapy.kinetica") version "0.4.0" } ``` -If the wiring is ever lost, the failure is loud rather than silent: the plugin's FIR checkers stop -running, and `state`/`event` calls throw `MissingKineticaPluginException` — but note the plugin -also *is* the thing that enforces `@UiComponent` call rules at compile time, so treat a build that -suddenly stops reporting those errors as suspicious. +It applies the compiler plugin to every compilation of every target and adds `kinetica-runtime` to +`commonMain` and `kinetica-browser` to `jsMain` at its own version — which is why neither appears +in this build's dependency blocks. `kinetica { addRuntimeDependencies = false }` hands those back +to you, and the same block carries the compiler options (`moduleId`, `serverSourceSet`, +`clientSourceSet`, `sourcePipeline`, `transforms`, `checks`). + +The plugin resolves through Maven Central, not the Gradle Plugin Portal, so `settings.gradle.kts` +lists `mavenCentral()` in `pluginManagement.repositories` — a fresh project has only the portal +there and would fail with `UnknownPluginException`. Other notes on the build: -- Kotlin **2.4.10** matches the version Kinetica 0.3.0 was published with; klib metadata is not - forward compatible, so do not bump one without the other. -- `kotlinx-serialization-json` is declared explicitly: Kinetica exposes it as a runtime-scoped - transitive dependency, which is not on the compile classpath. +- Kotlin **2.4.10** matches the version Kinetica 0.4.0 was published with; klib metadata is not + forward compatible, so do not bump one without the other. The plugin warns when they diverge. +- The Kotlin compile daemon needs JDK 17 or newer: the compiler plugin is loaded into it. +- `kotlinx-serialization-json` is declared explicitly because this example's own code builds + island props and JSON-LD with it. Code that only uses Kinetica does not need the declaration — + 0.4.0 puts serialization and coroutines on the compile classpath. - Repositories live in `build.gradle.kts`, not `settings.gradle.kts` — the Kotlin/JS plugin adds its own Node.js repository to the project, which a settings-only setup rejects or shadows. - The configuration cache is off: Kotlin/JS compile tasks in KGP 2.4.10 are not yet compatible. diff --git a/examples/gradle-ssr/build.gradle.kts b/examples/gradle-ssr/build.gradle.kts index 71f30a9..b20aa7e 100644 --- a/examples/gradle-ssr/build.gradle.kts +++ b/examples/gradle-ssr/build.gradle.kts @@ -1,10 +1,13 @@ @file:OptIn(org.jetbrains.kotlin.gradle.ExperimentalKotlinGradlePluginApi::class) -import org.jetbrains.kotlin.gradle.tasks.KotlinCompilationTask - plugins { alias(libs.plugins.kotlin.multiplatform) alias(libs.plugins.kotlin.serialization) + // Everything Kinetica needs: the mandatory K2 compiler plugin on every compilation of every + // target, plus kinetica-runtime in commonMain and kinetica-browser in jsMain at the same + // version. Before 0.4.0 this file resolved the plugin jar itself and pushed -Xplugin into + // each KotlinCompilationTask by hand. + alias(libs.plugins.kinetica) } repositories { @@ -13,21 +16,6 @@ repositories { mavenCentral() } -// The Kinetica compiler plugin ships as a plain jar (no Gradle subplugin yet), so it is resolved -// through its own configuration and handed to every Kotlin compilation as -Xplugin=. -val kineticaCompiler = configurations.resolvable("kineticaCompiler") { - isTransitive = false -} - -dependencies { - add(kineticaCompiler.name, libs.kinetica.compiler) -} - -val kineticaPluginArgument: Provider = - kineticaCompiler.flatMap { configuration -> - configuration.elements.map { jars -> "-Xplugin=${jars.single().asFile.absolutePath}" } - } - kotlin { jvmToolchain(21) @@ -48,9 +36,8 @@ kotlin { sourceSets { commonMain.dependencies { - implementation(libs.kinetica.runtime) - // Kinetica exposes kotlinx.serialization at runtime only; island props and JSON-LD - // are built here, so the compile-time dependency is declared explicitly. + // Island props and JSON-LD are built by this example's own code, so it declares the + // serialization library it uses directly rather than leaning on Kinetica's. implementation(libs.kotlinx.serialization.json) } jvmMain.dependencies { @@ -65,16 +52,9 @@ kotlin { implementation(kotlin("test")) implementation(libs.ktor.server.test.host) } - jsMain.dependencies { - implementation(libs.kinetica.browser) - } } } -tasks.withType>().configureEach { - compilerOptions.freeCompilerArgs.add(kineticaPluginArgument) -} - // One `./gradlew jvmRun` builds the browser island too: the webpack output is packed into the // server's resources, where Ktor serves it from /static. tasks.named("jvmProcessResources") { diff --git a/examples/gradle-ssr/gradle/libs.versions.toml b/examples/gradle-ssr/gradle/libs.versions.toml index c35de01..d6106c4 100644 --- a/examples/gradle-ssr/gradle/libs.versions.toml +++ b/examples/gradle-ssr/gradle/libs.versions.toml @@ -1,16 +1,14 @@ [versions] kotlin = "2.4.10" -kinetica = "0.3.0" +kinetica = "0.4.0" ktor = "3.5.1" serialization = "1.11.0" slf4j = "2.0.17" [libraries] kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "serialization" } -kinetica-runtime = { module = "io.heapy.kinetica:kinetica-runtime", version.ref = "kinetica" } -kinetica-browser = { module = "io.heapy.kinetica:kinetica-browser", version.ref = "kinetica" } -# Plain jar of the K2 plugin, wired into every Kotlin compilation as -Xplugin=. -kinetica-compiler = { module = "io.heapy.kinetica:kinetica-compiler", version.ref = "kinetica" } +# kinetica-runtime and kinetica-browser are deliberately absent: the io.heapy.kinetica plugin +# adds them at its own version, together with the mandatory compiler plugin. ktor-server-core = { module = "io.ktor:ktor-server-core", version.ref = "ktor" } ktor-server-cio = { module = "io.ktor:ktor-server-cio", version.ref = "ktor" } @@ -23,3 +21,4 @@ slf4j-simple = { module = "org.slf4j:slf4j-simple", version.ref = "slf4j" } [plugins] kotlin-multiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" } kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } +kinetica = { id = "io.heapy.kinetica", version.ref = "kinetica" } diff --git a/plan.md b/plan.md index a74c76d..303ea3e 100644 --- a/plan.md +++ b/plan.md @@ -261,7 +261,7 @@ Tag mapping: `column`/`row` → `GtkBox`, `button` → `GtkButton` (signal `clic - **Related:** KNT-0044, KNT-0046. ### KNT-0048 — `io.heapy.kinetica` Gradle plugin -**Status:** Landed 2026-08-17 on `examples/gradle-ssr-consumer` (this branch), unreleased. Verified locally: `./kotlin test -m kinetica-gradle-plugin` (6/0 contract tests) and `node scripts/verify-gradle-plugin.mjs` (10 assertions over two fixtures — multiplatform and single-target JVM — each building with zero Kinetica wiring in the build script, plus `sourcePipeline=psi` withheld from JS, an authoring-rule violation rejected by the FIR checkers on **both** the JVM and the JS compilation, and a configuration-cache entry stored *and* reused). +**Status:** Released as 0.4.0 on 2026-08-18 (Central deployment `5e7b25bc`, 314 signed artifacts incl. the plugin marker). `examples/gradle-ssr` now consumes it as `id("io.heapy.kinetica")` from Central with no manual wiring — clean `jvmTest` + `jsBrowserDistribution` green, which is the end-to-end proof of the release. Landed 2026-08-17 on `gradle-plugin`. Verified locally: `./kotlin test -m kinetica-gradle-plugin` (6/0 contract tests) and `node scripts/verify-gradle-plugin.mjs` (10 assertions over two fixtures — multiplatform and single-target JVM — each building with zero Kinetica wiring in the build script, plus `sourcePipeline=psi` withheld from JS, an authoring-rule violation rejected by the FIR checkers on **both** the JVM and the JS compilation, and a configuration-cache entry stored *and* reused). - **Why:** Kinetica is compiler-plugin-only, so a Gradle consumer had to resolve the plugin jar through a private configuration and push `-Xplugin=` into every `KotlinCompilationTask` by hand (`examples/gradle-ssr/build.gradle.kts`) — 15 lines nobody invents, and the failure mode when they rot is silent (checkers stop reporting). - **Module `kinetica-gradle-plugin`** (`jvm/lib`, publish template only — applying the common template would compile the build tooling with the Kinetica compiler plugin): `KotlinCompilerPluginSupportPlugin`, compile-only `dev.gradleplugins:gradle-api:8.11.1` + `org.jetbrains.kotlin:kotlin-gradle-plugin-api:2.4.10`, `settings.jvm.release: 17` (toolchain default is 21 bytecode — unloadable on a JDK 17 daemon), `languageVersion/apiVersion 2.4` (Gradle 9.7 embeds Kotlin 2.4.0). Everything needed is in the KGP **api** artifact: `KotlinSourceSetContainer`, `KotlinTargetsContainer`, `KotlinCompilation`, `SubpluginOption` — no dependency on the KGP implementation. - **`kinetica { }`**: the six compiler options verbatim, plus `enabled` and `addRuntimeDependencies` (adds `kinetica-runtime` to commonMain and `kinetica-browser` to a JS target's main source set, both gated on `enabled`). `sourcePipeline=psi` reaches JVM compilations only — the rule the raw `-Xplugin` wiring cannot express.