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..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,32 +44,35 @@ 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 -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/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..303ea3e 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:** 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. +- **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..b91f936 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)" @@ -51,9 +58,22 @@ 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" +# `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");