From 3db891d00701cc9d09694f0b567e27d09b041dd8 Mon Sep 17 00:00:00 2001 From: michalharakal Date: Wed, 29 Jul 2026 15:29:33 +0200 Subject: [PATCH 1/2] build: add sk.ainet.multiplatform and sk.ainet.npm-pins convention plugins First slice of #804. Two convention plugins land in build-logic/convention, and three representative modules migrate to prove the shapes. sk.ainet.multiplatform absorbs the Kotlin Multiplatform boilerplate that every library module copies today: the target list, the android { } body, explicitApi(), kotlin-test in commonTest, and the Karma hardening that was duplicated as 23 byte-identical karma.config.d files. The single shared copy now lives at gradle/karma.config.d/ and is wired in via KotlinKarma.useConfigDirectory. Which platforms a module builds comes from the skainet.targets Gradle property rather than the skainet { } DSL. Targets must exist before the module's own kotlin { } block runs -- the source-set convention accessors create source sets on access, and KGP then refuses to attach a compilation to one that already exists ("The compilation 'main' cannot be created after the source set 'jvmMain'"). A DSL block is evaluated too late for that; Gradle properties are readable while the plugin is applied. Everything non-structural stays in skainet { }. sk.ainet.npm-pins closes the Gradle/JS half of #847 and supersedes #894. PR #894 tried to bump ws 8.20.1 -> 8.21.1 (GHSA-96hv-2xvq-fx4p) by editing kotlin-js-store/wasm/yarn.lock, a generated file; kotlinWasmUpgradeYarnLock put it straight back, leaving a zero-line diff. Declaring npm("ws", ...) in a source set would not have worked either, since ws is transitive. The pin is now one npm-ws line in libs.versions.toml, turned into a Yarn resolutions entry in both the JS and Wasm root package.json files; verifyNpmPins reads the committed lockfiles back and fails on drift. Verified: ./gradlew :module:tasks --all is byte-identical before and after for all three pilots; apiCheck passes with no new dumps; publishToMavenLocal plus validate-published-poms.sh reports all 1131 POMs unchanged; both lockfiles regenerate onto ws 8.21.1; verifyNpmPins passes and fails as expected when a lockfile is tampered with; jsTest/wasmJsTest still receive the Karma timeouts; ./gradlew build is green and reuses the config cache. Progresses #804 Closes #847 Supersedes #894 --- .github/workflows/build.yml | 4 +- build-logic/convention/build.gradle.kts | 16 ++ .../kmp/SkainetMultiplatformExtension.kt | 38 ++++ .../kmp/SkainetMultiplatformPlugin.kt | 186 ++++++++++++++++++ .../sk/ainet/buildlogic/kmp/SkainetTargets.kt | 90 +++++++++ .../ainet/buildlogic/npm/NpmPinsExtension.kt | 52 +++++ .../sk/ainet/buildlogic/npm/NpmPinsPlugin.kt | 145 ++++++++++++++ .../ainet/buildlogic/npm/VerifyNpmPinsTask.kt | 89 +++++++++ .../sk/ainet/buildlogic/npm/YarnLockParser.kt | 68 +++++++ build.gradle.kts | 1 + .../pages/contributing/build-from-source.adoc | 74 +++++++ .../skainet-browser-resilience.js | 0 gradle/libs.versions.toml | 14 ++ kotlin-js-store/wasm/yarn.lock | 8 +- kotlin-js-store/yarn.lock | 13 +- .../skainet-backend-cpu/build.gradle.kts | 109 ++-------- .../skainet-data-source/build.gradle.kts | 16 +- .../skainet-data-source/gradle.properties | 4 + skainet-pipeline/build.gradle.kts | 55 +----- .../skainet-browser-resilience.js | 20 -- 20 files changed, 819 insertions(+), 183 deletions(-) create mode 100644 build-logic/convention/src/main/kotlin/sk/ainet/buildlogic/kmp/SkainetMultiplatformExtension.kt create mode 100644 build-logic/convention/src/main/kotlin/sk/ainet/buildlogic/kmp/SkainetMultiplatformPlugin.kt create mode 100644 build-logic/convention/src/main/kotlin/sk/ainet/buildlogic/kmp/SkainetTargets.kt create mode 100644 build-logic/convention/src/main/kotlin/sk/ainet/buildlogic/npm/NpmPinsExtension.kt create mode 100644 build-logic/convention/src/main/kotlin/sk/ainet/buildlogic/npm/NpmPinsPlugin.kt create mode 100644 build-logic/convention/src/main/kotlin/sk/ainet/buildlogic/npm/VerifyNpmPinsTask.kt create mode 100644 build-logic/convention/src/main/kotlin/sk/ainet/buildlogic/npm/YarnLockParser.kt rename {skainet-backends/skainet-backend-cpu => gradle}/karma.config.d/skainet-browser-resilience.js (100%) delete mode 100644 skainet-pipeline/karma.config.d/skainet-browser-resilience.js diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 265b05f2..fd3bd42d 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -32,8 +32,10 @@ jobs: include: - name: jvm tasks: jvmTest + # verifyNpmPins guards the npm-* pins in gradle/libs.versions.toml against + # lockfile drift; it belongs on the leg that already has the JS toolchain. - name: js-wasm - tasks: jsTest wasmJsTest wasmWasiTest + tasks: verifyNpmPins jsTest wasmJsTest wasmWasiTest - name: native tasks: linuxX64Test steps: diff --git a/build-logic/convention/build.gradle.kts b/build-logic/convention/build.gradle.kts index 5e573b4b..47f9dae8 100644 --- a/build-logic/convention/build.gradle.kts +++ b/build-logic/convention/build.gradle.kts @@ -5,6 +5,7 @@ plugins { } repositories { + google() gradlePluginPortal() mavenCentral() } @@ -17,6 +18,13 @@ dependencies { implementation(libs.asciidoctorj.core) implementation("org.jetbrains.dokka:dokka-gradle-plugin:2.2.0") implementation(gradleApi()) + + // compileOnly, never implementation: build-logic is an included build, so putting these + // on the plugin's runtime classpath would load a second copy of KGP/AGP in a different + // classloader and turn every `getByType(SomeKgpType::class)` into a ClassCastException. + // compileOnly gives us the types while deferring to the classes the consuming build loads. + compileOnly(libs.kotlin.gradlePlugin) + compileOnly(libs.android.gradlePlugin) } kotlin { @@ -39,5 +47,13 @@ gradlePlugin { id = "sk.ainet.transformers.bom-coverage" implementationClass = "sk.ainet.buildlogic.bom.BomCoveragePlugin" } + register("SKaiNetMultiplatform") { + id = "sk.ainet.multiplatform" + implementationClass = "sk.ainet.buildlogic.kmp.SkainetMultiplatformPlugin" + } + register("SKaiNetNpmPins") { + id = "sk.ainet.npm-pins" + implementationClass = "sk.ainet.buildlogic.npm.NpmPinsPlugin" + } } } diff --git a/build-logic/convention/src/main/kotlin/sk/ainet/buildlogic/kmp/SkainetMultiplatformExtension.kt b/build-logic/convention/src/main/kotlin/sk/ainet/buildlogic/kmp/SkainetMultiplatformExtension.kt new file mode 100644 index 00000000..38b3f93d --- /dev/null +++ b/build-logic/convention/src/main/kotlin/sk/ainet/buildlogic/kmp/SkainetMultiplatformExtension.kt @@ -0,0 +1,38 @@ +package sk.ainet.buildlogic.kmp + +import org.jetbrains.kotlin.gradle.dsl.JvmTarget + +/** + * The `skainet { }` block contributed by `sk.ainet.multiplatform`. + * + * Holds the settings that can be applied *after* the build script has been evaluated. + * Which platforms a module targets is deliberately **not** here — see [SkainetTargets] + * for why, and for the `skainet.targets` property that controls it. + */ +abstract class SkainetMultiplatformExtension { + + /** + * Android namespace. Required when `com.android.kotlin.multiplatform.library` is + * applied, ignored otherwise. + * + * Deliberately explicit rather than derived from the project path: several modules + * publish namespaces that do not match their path, and changing a published + * artifact's namespace is a separate decision from adopting this plugin. + */ + var namespace: String? = null + + /** + * `jvmTarget` for the Android compilation. Most modules use [JvmTarget.JVM_11]; the + * `skainet-io` family currently ships [JvmTarget.JVM_1_8] and passes it explicitly. + */ + var androidJvmTarget: JvmTarget = JvmTarget.JVM_11 + + /** Calls `explicitApi()`. */ + var explicitApi: Boolean = true + + /** Adds `-Xexpect-actual-classes` to every compilation. */ + var expectActualClasses: Boolean = false + + /** Adds `kotlin-test` to `commonTest`. */ + var kotlinTestInCommonTest: Boolean = true +} diff --git a/build-logic/convention/src/main/kotlin/sk/ainet/buildlogic/kmp/SkainetMultiplatformPlugin.kt b/build-logic/convention/src/main/kotlin/sk/ainet/buildlogic/kmp/SkainetMultiplatformPlugin.kt new file mode 100644 index 00000000..060c1895 --- /dev/null +++ b/build-logic/convention/src/main/kotlin/sk/ainet/buildlogic/kmp/SkainetMultiplatformPlugin.kt @@ -0,0 +1,186 @@ +package sk.ainet.buildlogic.kmp + +import com.android.build.api.dsl.KotlinMultiplatformAndroidLibraryTarget +import org.gradle.api.GradleException +import org.gradle.api.Plugin +import org.gradle.api.Project +import org.gradle.api.artifacts.VersionCatalog +import org.gradle.api.artifacts.VersionCatalogsExtension +import org.gradle.api.plugins.ExtensionAware +import org.jetbrains.kotlin.gradle.ExperimentalWasmDsl +import org.jetbrains.kotlin.gradle.dsl.KotlinMultiplatformExtension +import org.jetbrains.kotlin.gradle.targets.js.dsl.KotlinJsSubTargetDsl + +private const val ANDROID_KMP_PLUGIN_ID = "com.android.kotlin.multiplatform.library" +private const val NPM_PINS_PLUGIN_ID = "sk.ainet.npm-pins" +private const val SHARED_KARMA_CONFIG_DIR = "gradle/karma.config.d" + +/** + * SKaiNET's standard Kotlin Multiplatform module setup. + * + * Replaces the target list, `android { }` block, `explicitApi()`, `kotlin-test` wiring + * and Karma hardening that every library module used to copy by hand. + * + * ```kotlin + * plugins { + * id("sk.ainet.multiplatform") + * alias(libs.plugins.androidMultiplatformLibrary) // opt into Android + * } + * + * skainet { + * namespace = "sk.ainet.pipeline" + * } + * ``` + * + * Configuration is split in two, for reasons explained on [SkainetTargets]: which + * platforms are built comes from the `skainet.targets` Gradle property (readable while + * the plugin is applied, which is when targets must be created); everything else lives + * in the [SkainetMultiplatformExtension] DSL and is applied after evaluation. + */ +class SkainetMultiplatformPlugin : Plugin { + + override fun apply(project: Project) { + val extension = project.extensions.create("skainet", SkainetMultiplatformExtension::class.java) + val targets = SkainetTargets.from(project) + + project.pluginManager.apply("org.jetbrains.kotlin.multiplatform") + val kotlin = project.extensions.getByType(KotlinMultiplatformExtension::class.java) + + // Eager: targets must exist before the module's own `kotlin { }` block runs. + configureTargets(project, kotlin, targets) + + if (targets.web) { + // Yarn resolutions are root-project scoped; applying is idempotent, so every + // web-enabled module can ask for them without coordinating. + project.rootProject.pluginManager.apply(NPM_PINS_PLUGIN_ID) + } + + // Deferred: needs the values the build script sets in `skainet { }`. + project.afterEvaluate { applyExtension(this, kotlin, extension) } + } + + private fun applyExtension( + project: Project, + kotlin: KotlinMultiplatformExtension, + extension: SkainetMultiplatformExtension, + ) { + if (extension.explicitApi) kotlin.explicitApi() + + if (extension.expectActualClasses) { + kotlin.targets.configureEach { + compilations.configureEach { + compileTaskProvider.configure { + compilerOptions.freeCompilerArgs.add("-Xexpect-actual-classes") + } + } + } + } + + if (extension.kotlinTestInCommonTest) { + val kotlinTest = project.versionCatalog().findLibrary("kotlin-test").orElseThrow { + GradleException("[skainet-multiplatform] Version catalog is missing the 'kotlin-test' library alias") + } + kotlin.sourceSets.named("commonTest").configure { + dependencies { implementation(kotlinTest) } + } + } + + configureAndroid(project, kotlin, extension) + } + + /** + * Fills in the `android { }` body. The AGP KMP plugin creates the target itself when + * it is applied in the module's `plugins { }` block, so this only sets values — which + * is why it can run after evaluation, ahead of AGP's own `afterEvaluate`. + */ + private fun configureAndroid( + project: Project, + kotlin: KotlinMultiplatformExtension, + extension: SkainetMultiplatformExtension, + ) { + if (!project.pluginManager.hasPlugin(ANDROID_KMP_PLUGIN_ID)) return + + val namespace = extension.namespace + ?: throw GradleException( + "[skainet-multiplatform] ${project.path} applies $ANDROID_KMP_PLUGIN_ID, " + + "so it must declare a namespace: skainet { namespace = \"sk.ainet.…\" }" + ) + + val catalog = project.versionCatalog() + val android = (kotlin as ExtensionAware).extensions + .getByName("android") as KotlinMultiplatformAndroidLibraryTarget + + android.namespace = namespace + android.compileSdk = catalog.requiredVersion("android-compileSdk").toInt() + android.minSdk = catalog.requiredVersion("android-minSdk").toInt() + android.compilerOptions { jvmTarget.set(extension.androidJvmTarget) } + } + + @OptIn(ExperimentalWasmDsl::class) + private fun configureTargets( + project: Project, + kotlin: KotlinMultiplatformExtension, + targets: SkainetTargets, + ) { + if (targets.jvm) kotlin.jvm() + + if (targets.js) { + kotlin.js { + browser { hardenBrowserTests(project) } + } + } + + if (targets.wasmJs) { + kotlin.wasmJs { + browser { hardenBrowserTests(project) } + if (targets.wasmJsExecutable) binaries.executable() + } + } + + if (targets.wasmWasi) kotlin.wasmWasi { nodejs() } + + if (targets.apple) { + kotlin.iosArm64() + kotlin.iosSimulatorArm64() + kotlin.macosArm64() + } + + if (targets.linux) { + kotlin.linuxX64() + kotlin.linuxArm64() + } + + if (targets.androidNative) { + kotlin.androidNativeArm32() + kotlin.androidNativeArm64() + } + } + + /** + * Points Karma at the repository-wide config directory instead of a per-module + * `karma.config.d`. The shared config raises capture/disconnect timeouts so that + * parallel `allTests` runs do not fail on a starved headless browser. + * + * `useChromeHeadless()` is not redundant: KGP installs its own default only when no + * test framework has been set yet (`KotlinBrowserJsIr.configureDefaultTestFramework`), + * and `useKarma` replaces the framework wholesale. Dropping it leaves the task with + * "No browsers configured for jsBrowserTest". + */ + private fun KotlinJsSubTargetDsl.hardenBrowserTests(project: Project) { + val sharedConfigDir = project.rootProject.file(SHARED_KARMA_CONFIG_DIR) + testTask { + useKarma { + useChromeHeadless() + useConfigDirectory(sharedConfigDir) + } + } + } + + private fun Project.versionCatalog(): VersionCatalog = + extensions.getByType(VersionCatalogsExtension::class.java).named("libs") + + private fun VersionCatalog.requiredVersion(alias: String): String = + findVersion(alias) + .orElseThrow { GradleException("[skainet-multiplatform] Version catalog is missing the '$alias' version alias") } + .requiredVersion +} diff --git a/build-logic/convention/src/main/kotlin/sk/ainet/buildlogic/kmp/SkainetTargets.kt b/build-logic/convention/src/main/kotlin/sk/ainet/buildlogic/kmp/SkainetTargets.kt new file mode 100644 index 00000000..b8518a33 --- /dev/null +++ b/build-logic/convention/src/main/kotlin/sk/ainet/buildlogic/kmp/SkainetTargets.kt @@ -0,0 +1,90 @@ +package sk.ainet.buildlogic.kmp + +import org.gradle.api.InvalidUserDataException +import org.gradle.api.Project + +/** + * Which platforms a module targets. + * + * ## Why this is a Gradle property and not part of `skainet { }` + * + * Kotlin targets have to exist before the module's own `kotlin { }` block runs: the + * source-set convention accessors (`jvmMain`, `iosArm64Main`, …) create source sets on + * access, and KGP then refuses to attach a compilation to a source set that already + * exists — + * + * ``` + * ⛔ The compilation 'main' cannot be created after the source set 'jvmMain' + * ``` + * + * A `skainet { }` block is evaluated as part of the build script, so anything declared + * there is only known once it is already too late to create targets. Gradle properties, + * on the other hand, are readable while the plugin is being applied. Everything that is + * *not* structural stays in [SkainetMultiplatformExtension]. + * + * ## Usage + * + * Most modules want the default set and declare nothing. A module that differs adds one + * line to its own `gradle.properties`: + * + * ```properties + * skainet.targets=jvm + * skainet.wasmJs.executable=true + * ``` + * + * `skainet.targets` is a comma-separated list of the names below. `none` selects an + * empty set, for modules that declare every target themselves. + */ +internal data class SkainetTargets( + val jvm: Boolean, + val js: Boolean, + val wasmJs: Boolean, + val wasmWasi: Boolean, + /** `iosArm64`, `iosSimulatorArm64`, `macosArm64`. */ + val apple: Boolean, + /** `linuxX64`, `linuxArm64`. */ + val linux: Boolean, + /** `androidNativeArm32`, `androidNativeArm64` — vendor backends linking device libraries. */ + val androidNative: Boolean, + val wasmJsExecutable: Boolean, +) { + val web: Boolean get() = js || wasmJs + + companion object { + const val TARGETS_PROPERTY = "skainet.targets" + const val WASM_JS_EXECUTABLE_PROPERTY = "skainet.wasmJs.executable" + + private val DEFAULT = setOf("jvm", "js", "wasmJs", "wasmWasi", "apple", "linux") + private val KNOWN = DEFAULT + setOf("androidNative") + + fun from(project: Project): SkainetTargets { + // findProperty, not providers.gradleProperty: as of Gradle 9 the provider API + // only exposes build-level properties, and these are declared per module. + val declared = project.findProperty(TARGETS_PROPERTY)?.toString() + val selected = when { + declared == null -> DEFAULT + declared.isBlank() || declared.trim() == "none" -> emptySet() + else -> declared.split(",").map { it.trim() }.filter { it.isNotEmpty() }.toSet() + } + + val unknown = selected - KNOWN + if (unknown.isNotEmpty()) { + throw InvalidUserDataException( + "[skainet-multiplatform] ${project.path}: unknown $TARGETS_PROPERTY entries " + + "${unknown.sorted()}. Known values: ${KNOWN.sorted()}, or 'none'." + ) + } + + return SkainetTargets( + jvm = "jvm" in selected, + js = "js" in selected, + wasmJs = "wasmJs" in selected, + wasmWasi = "wasmWasi" in selected, + apple = "apple" in selected, + linux = "linux" in selected, + androidNative = "androidNative" in selected, + wasmJsExecutable = project.findProperty(WASM_JS_EXECUTABLE_PROPERTY)?.toString().toBoolean(), + ) + } + } +} diff --git a/build-logic/convention/src/main/kotlin/sk/ainet/buildlogic/npm/NpmPinsExtension.kt b/build-logic/convention/src/main/kotlin/sk/ainet/buildlogic/npm/NpmPinsExtension.kt new file mode 100644 index 00000000..754e67e6 --- /dev/null +++ b/build-logic/convention/src/main/kotlin/sk/ainet/buildlogic/npm/NpmPinsExtension.kt @@ -0,0 +1,52 @@ +package sk.ainet.buildlogic.npm + +import org.gradle.api.provider.MapProperty +import org.gradle.api.provider.Property + +/** + * Configuration for `sk.ainet.npm-pins`. + * + * The pins themselves are **not** declared here — they live in `[versions]` of + * `gradle/libs.versions.toml` under the `npm-` prefix, so a package is pinned by + * editing exactly one line: + * + * ```toml + * [versions] + * npm-ws = "8.21.1" # GHSA-96hv-2xvq-fx4p + * ``` + * + * This extension only carries the knobs needed to interpret that catalog. + */ +abstract class NpmPinsExtension { + + /** Version catalog to read pins from. Defaults to `libs`. */ + abstract val catalogName: Property + + /** Alias prefix marking a `[versions]` entry as an npm pin. Defaults to `npm`. */ + abstract val aliasPrefix: Property + + /** + * Escape hatch for package names the alias rule cannot express. + * + * Gradle normalises catalog aliases, so `npm-webpack-dev-server` arrives as + * `npm.webpack.dev.server` and maps back to `webpack-dev-server` by turning the + * separators into dashes. Packages whose real name contains a dot (`socket.io`) + * or a scope (`@types/node`) round-trip incorrectly and need an explicit entry, + * keyed by the normalised alias: + * + * ```kotlin + * npmPins { + * packageNameOverrides.put("npm.socket.io", "socket.io") + * } + * ``` + */ + abstract val packageNameOverrides: MapProperty + + /** + * Whether `verifyNpmPins` fails when a pinned package is absent from every + * lockfile. Defaults to `false`: a pin that outlives its package (because the + * Kotlin toolchain dropped the dependency) is stale rather than broken, and + * should be reported without breaking the build. + */ + abstract val failOnMissingPackage: Property +} diff --git a/build-logic/convention/src/main/kotlin/sk/ainet/buildlogic/npm/NpmPinsPlugin.kt b/build-logic/convention/src/main/kotlin/sk/ainet/buildlogic/npm/NpmPinsPlugin.kt new file mode 100644 index 00000000..d48beae7 --- /dev/null +++ b/build-logic/convention/src/main/kotlin/sk/ainet/buildlogic/npm/NpmPinsPlugin.kt @@ -0,0 +1,145 @@ +package sk.ainet.buildlogic.npm + +import org.gradle.api.Plugin +import org.gradle.api.Project +import org.gradle.api.artifacts.VersionCatalog +import org.gradle.api.artifacts.VersionCatalogsExtension +import org.gradle.api.provider.Provider +import org.jetbrains.kotlin.gradle.targets.js.yarn.YarnLockStoreTask +import org.jetbrains.kotlin.gradle.targets.js.yarn.YarnPlugin +import org.jetbrains.kotlin.gradle.targets.js.yarn.YarnRootExtension +import org.jetbrains.kotlin.gradle.targets.wasm.yarn.WasmYarnPlugin +import org.jetbrains.kotlin.gradle.targets.wasm.yarn.WasmYarnRootExtension +import org.jetbrains.kotlin.gradle.targets.web.yarn.BaseYarnRootExtension + +/** + * Pins selected npm packages to an audited version across every Kotlin/JS and + * Kotlin/Wasm lockfile in the build. + * + * ## Why this exists + * + * `kotlin-js-store/yarn.lock` and `kotlin-js-store/wasm/yarn.lock` are generated — + * editing them by hand does not survive the next `kotlinUpgradeYarnLock`. Nor does + * declaring `implementation(npm("ws", "8.21.1"))` in a source set help when the + * package is pulled in transitively: the transitive ranges still resolve on their + * own. The mechanism that actually constrains the graph is Yarn `resolutions`, which + * the Kotlin Gradle plugin writes into the generated root `package.json` from the + * Yarn root extension. + * + * There are two such extensions — one for JS, one for Wasm — each with its own + * lockfile, so a pin has to be applied twice. This plugin does that from a single + * declaration. + * + * ## Declaring a pin + * + * Add one line to `[versions]` in `gradle/libs.versions.toml`, prefixed with `npm-`: + * + * ```toml + * npm-ws = "8.21.1" # GHSA-96hv-2xvq-fx4p + * ``` + * + * Then regenerate and commit the lockfiles: + * + * ``` + * ./gradlew kotlinUpgradeYarnLock kotlinWasmUpgradeYarnLock + * ``` + * + * `verifyNpmPins` (wired into `check`) fails if a lockfile later drifts off a pin. + * + * Must be applied to the root project: the Yarn root extensions assert that they + * belong to `rootProject`. + */ +class NpmPinsPlugin : Plugin { + + override fun apply(project: Project) { + require(project == project.rootProject) { + "[npm-pins] sk.ainet.npm-pins must be applied to the root project — " + + "Yarn roots are root-project scoped, but it was applied to ${project.path}" + } + + val extension = project.extensions.create("npmPins", NpmPinsExtension::class.java).apply { + catalogName.convention("libs") + aliasPrefix.convention("npm") + packageNameOverrides.convention(emptyMap()) + failOnMissingPackage.convention(false) + } + + // Resolved lazily so overrides declared later in the root build script are seen. + val pinsProvider: Provider> = project.provider { readPins(project, extension) } + + // The Yarn plugins are applied by KGP only once a js/wasmJs target is configured, + // which happens well after this plugin is applied — hence the reactive hooks. + project.plugins.withType(YarnPlugin::class.java) { + project.extensions.getByType(YarnRootExtension::class.java).applyPins(pinsProvider.get()) + } + project.plugins.withType(WasmYarnPlugin::class.java) { + // getByName rather than WasmYarnRootExtension[project]: the latter applies + // WasmYarnPlugin as a side effect, dragging wasm Yarn setup into js-only builds. + val wasmYarn = project.extensions.getByName(WasmYarnRootExtension.YARN) as WasmYarnRootExtension + wasmYarn.applyPins(pinsProvider.get()) + } + + val verify = project.tasks.register("verifyNpmPins", VerifyNpmPinsTask::class.java) { + group = "verification" + description = "Check the committed Yarn lockfiles against the npm-* pins in the version catalog" + pins.set(pinsProvider) + failOnMissingPackage.set(extension.failOnMissingPackage) + rootDirectory.set(project.layout.projectDirectory) + lockFiles.from( + project.layout.projectDirectory.file("kotlin-js-store/yarn.lock"), + project.layout.projectDirectory.file("kotlin-js-store/wasm/yarn.lock"), + ) + // The lockfiles are outputs of the store tasks. Order after them rather than + // depending on them, so `verifyNpmPins` stays a cheap file check on its own but + // still sees the current state when a full build refreshes the lockfiles. + mustRunAfter(project.tasks.withType(YarnLockStoreTask::class.java)) + } + + project.pluginManager.withPlugin("base") { + project.tasks.named("check").configure { dependsOn(verify) } + } + } + + private fun BaseYarnRootExtension.applyPins(pins: Map) { + pins.forEach { (packageName, version) -> resolution(packageName, version) } + } + + private fun readPins(project: Project, extension: NpmPinsExtension): Map { + val catalogName = extension.catalogName.get() + val catalog: VersionCatalog = project.extensions + .getByType(VersionCatalogsExtension::class.java) + .find(catalogName) + .orElseThrow { + IllegalStateException("[npm-pins] Version catalog '$catalogName' not found") + } + + val prefix = extension.aliasPrefix.get() + val overrides = extension.packageNameOverrides.get() + + return catalog.versionAliases + .filter { it.normalizeSeparators().startsWith("$prefix.") } + .associate { alias -> + val packageName = overrides[alias.normalizeSeparators()] ?: alias.toPackageName(prefix) + val version = catalog.findVersion(alias) + .orElseThrow { IllegalStateException("[npm-pins] Version alias '$alias' disappeared from '$catalogName'") } + .requiredVersion + require(version.isNotEmpty()) { + "[npm-pins] Catalog alias '$alias' must declare an exact version (e.g. npm-ws = \"8.21.1\")" + } + packageName to version + } + } + + /** + * `npm-webpack-dev-server` reaches us as `npm.webpack.dev.server`; strip the prefix + * and turn the remaining separators back into dashes. + * + * Package names that genuinely contain a dot (`socket.io`) or a scope + * (`@types/node`) cannot be expressed as a catalog alias and need an entry in + * [NpmPinsExtension.packageNameOverrides]. + */ + private fun String.toPackageName(prefix: String): String = + normalizeSeparators().removePrefix("$prefix.").replace('.', '-') + + private fun String.normalizeSeparators(): String = replace('-', '.').replace('_', '.') +} diff --git a/build-logic/convention/src/main/kotlin/sk/ainet/buildlogic/npm/VerifyNpmPinsTask.kt b/build-logic/convention/src/main/kotlin/sk/ainet/buildlogic/npm/VerifyNpmPinsTask.kt new file mode 100644 index 00000000..58d98f6c --- /dev/null +++ b/build-logic/convention/src/main/kotlin/sk/ainet/buildlogic/npm/VerifyNpmPinsTask.kt @@ -0,0 +1,89 @@ +package sk.ainet.buildlogic.npm + +import org.gradle.api.DefaultTask +import org.gradle.api.GradleException +import org.gradle.api.file.ConfigurableFileCollection +import org.gradle.api.file.DirectoryProperty +import org.gradle.api.provider.MapProperty +import org.gradle.api.provider.Property +import org.gradle.api.tasks.Input +import org.gradle.api.tasks.InputFiles +import org.gradle.api.tasks.Internal +import org.gradle.api.tasks.PathSensitive +import org.gradle.api.tasks.PathSensitivity +import org.gradle.api.tasks.TaskAction +import org.gradle.work.DisableCachingByDefault + +/** + * Fails when a committed Yarn lockfile disagrees with a declared npm pin. + * + * Yarn `resolutions` make the pin take effect, but only the next time the lockfile + * is regenerated. Without this check a stale or hand-edited lockfile silently wins + * — which is exactly how PR #894's `ws` bump ended up as a zero-line diff. + */ +@DisableCachingByDefault(because = "Reads two small lockfiles; caching costs more than it saves") +abstract class VerifyNpmPinsTask : DefaultTask() { + + /** Package name -> pinned version, sourced from the `npm-*` catalog aliases. */ + @get:Input + abstract val pins: MapProperty + + @get:Input + abstract val failOnMissingPackage: Property + + /** The committed lockfiles under `kotlin-js-store/`. Missing files are skipped. */ + @get:InputFiles + @get:PathSensitive(PathSensitivity.RELATIVE) + abstract val lockFiles: ConfigurableFileCollection + + /** Only used to render lockfile paths relative to the repository root in messages. */ + @get:Internal + abstract val rootDirectory: DirectoryProperty + + @TaskAction + fun verify() { + val expected = pins.get() + if (expected.isEmpty()) { + logger.lifecycle("[npm-pins] No npm pins declared; nothing to verify.") + return + } + + val mismatches = mutableListOf() + val seen = mutableSetOf() + val rootDir = rootDirectory.get().asFile + + lockFiles.files.filter { it.isFile }.sortedBy { it.path }.forEach { lockFile -> + val relative = lockFile.relativeToOrSelf(rootDir).path + YarnLockParser.parse(lockFile.readText()).forEach { (packageName, version) -> + val pinned = expected[packageName] ?: return@forEach + seen += packageName + if (version != pinned) { + mismatches += "$relative: $packageName resolved to $version, pinned to $pinned" + } + } + } + + val missing = expected.keys - seen + if (missing.isNotEmpty()) { + val message = "[npm-pins] Pinned but absent from every lockfile: ${missing.sorted().joinToString(", ")}. " + + "The pin may be stale — drop it from gradle/libs.versions.toml if the package is gone for good." + if (failOnMissingPackage.get()) throw GradleException(message) + logger.warn(message) + } + + if (mismatches.isNotEmpty()) { + throw GradleException( + buildString { + appendLine("[npm-pins] Lockfile does not honour the declared npm pins:") + mismatches.sorted().forEach { appendLine(" - $it") } + appendLine() + appendLine("Do not edit kotlin-js-store/**/yarn.lock by hand — it is regenerated. Instead run:") + appendLine(" ./gradlew kotlinUpgradeYarnLock kotlinWasmUpgradeYarnLock") + append("and commit the regenerated lockfiles.") + } + ) + } + + logger.lifecycle("[npm-pins] ${expected.size} pin(s) verified against ${lockFiles.files.count { it.isFile }} lockfile(s).") + } +} diff --git a/build-logic/convention/src/main/kotlin/sk/ainet/buildlogic/npm/YarnLockParser.kt b/build-logic/convention/src/main/kotlin/sk/ainet/buildlogic/npm/YarnLockParser.kt new file mode 100644 index 00000000..1b561d39 --- /dev/null +++ b/build-logic/convention/src/main/kotlin/sk/ainet/buildlogic/npm/YarnLockParser.kt @@ -0,0 +1,68 @@ +package sk.ainet.buildlogic.npm + +/** + * Minimal reader for Yarn v1 lockfiles under `kotlin-js-store`. + * + * The format is a flat sequence of blocks: + * + * ``` + * ws@8.20.1, ws@~8.21.0: + * version "8.21.0" + * resolved "https://registry.yarnpkg.com/ws/-/ws-8.21.0.tgz#012e413f" + * ``` + * + * A block header is a non-indented line ending in `:`; the requested specs are + * comma-separated and optionally quoted. Only the package name and the resolved + * version are of interest here, so no attempt is made at a general TOML/YAML-ish + * parse — anything that is neither a header nor a `version "…"` line is skipped. + * + * Deliberately free of Gradle types so it can be exercised by a plain unit test + * once `build-logic` grows a test source set. + */ +internal object YarnLockParser { + + /** One entry per (spec, resolved version) pair; a package may legitimately appear more than once. */ + data class Entry(val packageName: String, val version: String) + + fun parse(text: String): List { + val entries = mutableListOf() + var pendingNames: List = emptyList() + + for (rawLine in text.lineSequence()) { + if (rawLine.isBlank() || rawLine.startsWith("#")) continue + + val isHeader = !rawLine.first().isWhitespace() && rawLine.trimEnd().endsWith(":") + if (isHeader) { + pendingNames = rawLine.trimEnd().dropLast(1) + .split(",") + .map { it.trim().trim('"') } + .filter { it.isNotEmpty() } + .map(::packageNameOf) + .distinct() + continue + } + + if (pendingNames.isEmpty()) continue + + val version = versionOf(rawLine.trim()) ?: continue + pendingNames.forEach { entries += Entry(it, version) } + pendingNames = emptyList() + } + + return entries + } + + /** + * `ws@8.20.1` -> `ws`, `@types/node@^8.5.12` -> `@types/node`. + * Splitting on the *last* `@` keeps scoped package names intact. + */ + private fun packageNameOf(spec: String): String { + val separator = spec.lastIndexOf('@') + return if (separator <= 0) spec else spec.substring(0, separator) + } + + private fun versionOf(line: String): String? { + if (!line.startsWith("version")) return null + return line.removePrefix("version").trim().removeSurrounding("\"").takeIf { it.isNotEmpty() } + } +} diff --git a/build.gradle.kts b/build.gradle.kts index d76ff097..ab22155b 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -10,6 +10,7 @@ plugins { alias(libs.plugins.asciidoctorJvm) apply false alias(libs.plugins.dokka) alias(libs.plugins.skainet.docs) + alias(libs.plugins.skainet.npmPins) id("org.jetbrains.kotlinx.benchmark") version "0.4.17" apply false } diff --git a/docs/modules/ROOT/pages/contributing/build-from-source.adoc b/docs/modules/ROOT/pages/contributing/build-from-source.adoc index dcaeec7b..9350b652 100644 --- a/docs/modules/ROOT/pages/contributing/build-from-source.adoc +++ b/docs/modules/ROOT/pages/contributing/build-from-source.adoc @@ -95,3 +95,77 @@ The workflow `.github/workflows/dokka-pages.yml` runs on push to `main` (and man ==== Operator docs (unchanged) The existing operator documentation pipeline (`./gradlew generateDocs`) is unrelated to Dokka and continues to work as before. + +=== Multiplatform Convention Plugin + +`sk.ainet.multiplatform` (`build-logic/convention/src/main/kotlin/sk/ainet/buildlogic/kmp/`) carries the Kotlin Multiplatform setup that every SKaiNET library module would otherwise copy: the target list, the `android ++{++ ++}++` body, `explicitApi()`, `kotlin-test` in `commonTest`, and the shared Karma configuration. + +==== Using it + +[source,kotlin] +---- +plugins { + id("sk.ainet.multiplatform") + alias(libs.plugins.androidMultiplatformLibrary) // opt in to Android + alias(libs.plugins.vanniktech.mavenPublish) + id("sk.ainet.dokka") +} + +skainet { + namespace = "sk.ainet.pipeline" +} +---- + +The default target set is `jvm`, `js`, `wasmJs`, `wasmWasi`, `apple` (`iosArm64`, `iosSimulatorArm64`, `macosArm64`) and `linux` (`linuxX64`, `linuxArm64`). Android is *not* a flag — the AGP plugin has to be applied in `plugins ++{++ ++}++`, and the convention plugin fills in its body once it sees it. + +==== Where each setting lives + +Which platforms a module builds comes from the *`skainet.targets` Gradle property*, not from `skainet ++{++ ++}++`: + +[source,properties] +---- +# skainet-data/skainet-data-source/gradle.properties +skainet.targets=jvm +---- + +Accepted values are `jvm`, `js`, `wasmJs`, `wasmWasi`, `apple`, `linux`, `androidNative`, or `none`. There is also `skainet.wasmJs.executable=true`. + +This split is not cosmetic. Kotlin targets must exist *before* the module's own `kotlin ++{++ ++}++` block runs, because the source-set convention accessors (`jvmMain`, `iosArm64Main`, …) create source sets on access and KGP then refuses to attach a compilation to a source set that already exists: + +---- +⛔ The compilation 'main' cannot be created after the source set 'jvmMain' +---- + +A `skainet ++{++ ++}++` block is part of the build script, so anything declared there is only known once it is too late to create targets. Gradle properties are readable while the plugin is being applied. Everything non-structural — `namespace`, `androidJvmTarget`, `explicitApi`, `expectActualClasses`, `kotlinTestInCommonTest` — stays in the `skainet ++{++ ++}++` DSL. + +==== Migrating a module + +[arabic] +. Replace `alias(libs.plugins.kotlinMultiplatform)` with `id("sk.ainet.multiplatform")`. +. Delete the target declarations, `explicitApi()`, the `android ++{++ ++}++` block and `commonTest`'s `kotlin-test`; move the namespace into `skainet ++{++ ++}++`. +. If the module does not build the full default set, add `skainet.targets` to its `gradle.properties`. +. Delete its `karma.config.d/` directory — the shared config at `gradle/karma.config.d/` is applied automatically. +. Rewrite any `val someSourceSet by getting ++{++ ++}++` to the convention accessors (`jvmMain ++{++ ++}++`, `iosArm64Main ++{++ ++}++`). The `by getting` form resolves eagerly and fails now that targets are created by the plugin. +. Confirm nothing moved: `./gradlew :module:tasks --all` before and after must be identical, and `./gradlew apiCheck` must pass without a new dump. + +=== Pinning npm Packages + +Kotlin/JS and Kotlin/Wasm dependencies are locked in `kotlin-js-store/yarn.lock` and `kotlin-js-store/wasm/yarn.lock`. Both files are *generated*. Editing them by hand does not survive the next lockfile refresh — see https://github.com/SKaiNET-developers/SKaiNET/pull/894[PR #894], where a hand-applied `ws` security bump was reverted by `kotlinWasmUpgradeYarnLock` in the same pull request. + +To pin a package, add one line to `++[++versions++]++` in `gradle/libs.versions.toml` with an `npm-` prefix: + +[source,toml] +---- +npm-ws = "8.21.1" # GHSA-96hv-2xvq-fx4p +---- + +`sk.ainet.npm-pins` (applied on the root project) turns every such entry into a Yarn `resolutions` entry in *both* generated root `package.json` files, so one declaration covers the JS and Wasm dependency graphs. Then regenerate and commit the lockfiles: + +[source,bash] +---- +./gradlew kotlinUpgradeYarnLock kotlinWasmUpgradeYarnLock +---- + +`verifyNpmPins` re-reads the committed lockfiles and fails if any pinned package resolved elsewhere. It is wired into `check` and runs on the `js-wasm` leg of `.github/workflows/build.yml`. + +Package names containing a dot (`socket.io`) or a scope (`++@++types/node`) cannot be spelled as a catalog alias; they need an entry in `npmPins ++{++ packageNameOverrides ++}++` in the root build script. diff --git a/skainet-backends/skainet-backend-cpu/karma.config.d/skainet-browser-resilience.js b/gradle/karma.config.d/skainet-browser-resilience.js similarity index 100% rename from skainet-backends/skainet-backend-cpu/karma.config.d/skainet-browser-resilience.js rename to gradle/karma.config.d/skainet-browser-resilience.js diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 49c7089e..ff9445ea 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -27,6 +27,15 @@ kotlinxIo = "0.9.1" pbandk = "0.16.0" kotest = "6.2.2" +# --- npm pins ----------------------------------------------------------------- +# Every `npm-` entry is forced into BOTH kotlin-js-store lockfiles by the +# sk.ainet.npm-pins convention plugin, via Yarn resolutions. Adding a line here is +# the whole job; then regenerate and commit the lockfiles with +# ./gradlew kotlinUpgradeYarnLock kotlinWasmUpgradeYarnLock +# Never edit kotlin-js-store/**/yarn.lock by hand — it is generated and will be +# overwritten (see PR #894). +npm-ws = "8.21.1" # GHSA-96hv-2xvq-fx4p + [libraries] jackson-databind = { module = "com.fasterxml.jackson.core:jackson-databind", version.ref = "jacksonDatabind" } json-schema-validator = { module = "com.networknt:json-schema-validator", version.ref = "jsonSchemaValidator" } @@ -41,6 +50,9 @@ kotlinx-io-core = { module = "org.jetbrains.kotlinx:kotlinx-io-core", version.re kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotlin" } +# Consumed only by build-logic (compileOnly) so convention plugins can use the KGP/AGP types. +kotlin-gradlePlugin = { module = "org.jetbrains.kotlin:kotlin-gradle-plugin", version.ref = "kotlin" } +android-gradlePlugin = { module = "com.android.tools.build:gradle", version.ref = "agp" } kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinxSerializationJson" } kotlinx-cli = { module = "org.jetbrains.kotlinx:kotlinx-cli", version.ref = "kotlinxCli" } kotlinx-benchmark-runtime = { module = "org.jetbrains.kotlinx:kotlinx-benchmark-runtime", version.ref = "kotlinxBenchmark" } @@ -91,5 +103,7 @@ asciidoctorPdf = { id = "org.asciidoctor.jvm.pdf", version = "4.0.5" } dokka = { id = "org.jetbrains.dokka", version.ref = "dokka" } skainet-docs = { id = "sk.ainet.documentation" } +skainet-multiplatform = { id = "sk.ainet.multiplatform" } +skainet-npmPins = { id = "sk.ainet.npm-pins" } kotlinx-benchmark = { id = "org.jetbrains.kotlinx.benchmark", version.ref = "kotlinxBenchmark" } shadow = { id = "com.gradleup.shadow", version = "9.6.1" } diff --git a/kotlin-js-store/wasm/yarn.lock b/kotlin-js-store/wasm/yarn.lock index b990b1c9..d12e41c5 100644 --- a/kotlin-js-store/wasm/yarn.lock +++ b/kotlin-js-store/wasm/yarn.lock @@ -2,7 +2,7 @@ # yarn lockfile v1 -ws@8.20.1: - version "8.20.1" - resolved "https://registry.yarnpkg.com/ws/-/ws-8.20.1.tgz#91a9ae2b312ccf98e0a85ec499b48cef45ab0ddb" - integrity sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w== +ws@8.20.1, ws@8.21.1: + version "8.21.1" + resolved "https://registry.yarnpkg.com/ws/-/ws-8.21.1.tgz#045650cd4b1207809e7547146223c3814a9af586" + integrity sha512-+0NTnW77fFN/DjQi6k/Sq/Yvk4Sgajw7urW8V+asjXnRgDs9gyGkdb7EzgfhA4goXsRIZKE28fzIXBHEzhuiWw== diff --git a/kotlin-js-store/yarn.lock b/kotlin-js-store/yarn.lock index 3f0a8065..d1c950eb 100644 --- a/kotlin-js-store/yarn.lock +++ b/kotlin-js-store/yarn.lock @@ -2136,15 +2136,10 @@ wrappy@1: resolved "https://registry.yarnpkg.com/wrappy/-/wrappy-1.0.2.tgz#b5243d8f3ec1aa35f1364605bc0d1036e30ab69f" integrity sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ== -ws@8.20.1: - version "8.20.1" - resolved "https://registry.yarnpkg.com/ws/-/ws-8.20.1.tgz#91a9ae2b312ccf98e0a85ec499b48cef45ab0ddb" - integrity sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w== - -ws@~8.21.0: - version "8.21.0" - resolved "https://registry.yarnpkg.com/ws/-/ws-8.21.0.tgz#012e413fc07429945121b0c153158c4343086951" - integrity sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g== +ws@8.20.1, ws@8.21.1, ws@~8.21.0: + version "8.21.1" + resolved "https://registry.yarnpkg.com/ws/-/ws-8.21.1.tgz#045650cd4b1207809e7547146223c3814a9af586" + integrity sha512-+0NTnW77fFN/DjQi6k/Sq/Yvk4Sgajw7urW8V+asjXnRgDs9gyGkdb7EzgfhA4goXsRIZKE28fzIXBHEzhuiWw== y18n@^5.0.5: version "5.0.8" diff --git a/skainet-backends/skainet-backend-cpu/build.gradle.kts b/skainet-backends/skainet-backend-cpu/build.gradle.kts index de132c13..f9ba32c0 100644 --- a/skainet-backends/skainet-backend-cpu/build.gradle.kts +++ b/skainet-backends/skainet-backend-cpu/build.gradle.kts @@ -1,47 +1,16 @@ -import org.jetbrains.kotlin.gradle.ExperimentalWasmDsl -import org.jetbrains.kotlin.gradle.dsl.JvmTarget - plugins { - alias(libs.plugins.kotlinMultiplatform) + id("sk.ainet.multiplatform") alias(libs.plugins.androidMultiplatformLibrary) alias(libs.plugins.vanniktech.mavenPublish) alias(libs.plugins.binary.compatibility.validator) id("sk.ainet.dokka") } -kotlin { - explicitApi() - android { - namespace = "sk.ainet.backend.cpu" - compileSdk = libs.versions.android.compileSdk.get().toInt() - minSdk = libs.versions.android.minSdk.get().toInt() - compilerOptions { - jvmTarget.set(JvmTarget.JVM_11) - } - } - - iosArm64() - iosSimulatorArm64() - macosArm64 () - linuxX64 () - linuxArm64 () - - jvm() - - js { - browser() - } - - @OptIn(ExperimentalWasmDsl::class) - wasmJs { - browser() - } - - @OptIn(ExperimentalWasmDsl::class) - wasmWasi { - nodejs() - } +skainet { + namespace = "sk.ainet.backend.cpu" +} +kotlin { sourceSets { commonMain.dependencies { // Every concrete backend should go through the neutral api @@ -50,68 +19,30 @@ kotlin { implementation(project(":skainet-lang:skainet-lang-core")) implementation(project(":skainet-compile:skainet-compile-core")) implementation(project(":skainet-lang:skainet-lang-ksp-annotations")) - } commonTest.dependencies { - implementation(libs.kotlin.test) implementation(project(":skainet-lang:skainet-lang-models")) } - val jvmMain by getting { - dependencies { - implementation(libs.kotlinx.coroutines) - } - } - val jvmTest by getting { - dependencies { - implementation(libs.kotlin.test) - } + jvmMain.dependencies { + implementation(libs.kotlinx.coroutines) } - val androidMain by getting - val wasmJsMain by getting - val commonMain by getting - - val nativeMain by creating { - dependsOn(commonMain) - } + // This module opts out of the default hierarchy template + // (kotlin.mpp.applyDefaultHierarchyTemplate=false in gradle.properties), + // so the native tree is wired by hand. + nativeMain { dependsOn(commonMain.get()) } + appleMain { dependsOn(nativeMain.get()) } + linuxMain { dependsOn(nativeMain.get()) } + iosMain { dependsOn(appleMain.get()) } + macosMain { dependsOn(appleMain.get()) } - val appleMain by creating { - dependsOn(nativeMain) - } - - val linuxMain by creating { - dependsOn(nativeMain) - } - - val iosMain by creating { - dependsOn(appleMain) - } - - val macosMain by creating { - dependsOn(appleMain) - } - - val iosArm64Main by getting { - dependsOn(iosMain) - } - - val iosSimulatorArm64Main by getting { - dependsOn(iosMain) - } - - val macosArm64Main by getting { - dependsOn(macosMain) - } - - val linuxX64Main by getting { - dependsOn(linuxMain) - } - - val linuxArm64Main by getting { - dependsOn(linuxMain) - } + iosArm64Main { dependsOn(iosMain.get()) } + iosSimulatorArm64Main { dependsOn(iosMain.get()) } + macosArm64Main { dependsOn(macosMain.get()) } + linuxX64Main { dependsOn(linuxMain.get()) } + linuxArm64Main { dependsOn(linuxMain.get()) } } } diff --git a/skainet-data/skainet-data-source/build.gradle.kts b/skainet-data/skainet-data-source/build.gradle.kts index bcd69b4e..240ca1ec 100644 --- a/skainet-data/skainet-data-source/build.gradle.kts +++ b/skainet-data/skainet-data-source/build.gradle.kts @@ -1,20 +1,12 @@ -import org.jetbrains.kotlin.gradle.dsl.JvmTarget - plugins { - alias(libs.plugins.kotlinMultiplatform) + id("sk.ainet.multiplatform") alias(libs.plugins.vanniktech.mavenPublish) id("sk.ainet.dokka") } -kotlin { - explicitApi() - - jvm { - compilerOptions { - jvmTarget.set(JvmTarget.JVM_11) - } - } +// JVM-only; see skainet.targets in this module's gradle.properties. +kotlin { sourceSets { commonMain.dependencies { implementation(libs.kotlinx.coroutines) @@ -23,7 +15,6 @@ kotlin { } commonTest.dependencies { - implementation(libs.kotlin.test) implementation(libs.kotlinx.coroutines.test) } @@ -33,6 +24,5 @@ kotlin { implementation(libs.ktor.client.plugins) implementation(libs.kotlinx.coroutines.core.jvm) } - } } diff --git a/skainet-data/skainet-data-source/gradle.properties b/skainet-data/skainet-data-source/gradle.properties index 3516f9dd..843dbcfd 100644 --- a/skainet-data/skainet-data-source/gradle.properties +++ b/skainet-data/skainet-data-source/gradle.properties @@ -1,2 +1,6 @@ POM_ARTIFACT_ID=skainet-data-source POM_NAME=skainet data source + +# JVM-only: the loader talks to Ktor CIO and the local filesystem, neither of which +# has a web or native counterpart yet. +skainet.targets=jvm diff --git a/skainet-pipeline/build.gradle.kts b/skainet-pipeline/build.gradle.kts index 61234ad7..3ee1af81 100644 --- a/skainet-pipeline/build.gradle.kts +++ b/skainet-pipeline/build.gradle.kts @@ -1,8 +1,5 @@ -import org.jetbrains.kotlin.gradle.ExperimentalWasmDsl -import org.jetbrains.kotlin.gradle.dsl.JvmTarget - plugins { - alias(libs.plugins.kotlinMultiplatform) + id("sk.ainet.multiplatform") alias(libs.plugins.androidMultiplatformLibrary) alias(libs.plugins.vanniktech.mavenPublish) alias(libs.plugins.binary.compatibility.validator) @@ -10,47 +7,11 @@ plugins { id("sk.ainet.dokka") } -kotlin { - explicitApi() - - android { - namespace = "sk.ainet.pipeline" - compileSdk = libs.versions.android.compileSdk.get().toInt() - minSdk = libs.versions.android.minSdk.get().toInt() - compilerOptions { - jvmTarget.set(JvmTarget.JVM_11) - } - } - - iosArm64() - iosSimulatorArm64() - macosArm64() - linuxX64() - linuxArm64() - - jvm() - - js { - browser() - } - - @OptIn(ExperimentalWasmDsl::class) - wasmJs { - browser() - } - - @OptIn(ExperimentalWasmDsl::class) - wasmWasi { - nodejs() - } - - sourceSets { - commonMain.dependencies { - // No dependencies on skainet-lang-core - keep it minimal - } - - commonTest.dependencies { - implementation(libs.kotlin.test) - } - } +// The default SKaiNET target set — jvm, js, wasmJs, wasmWasi, apple, linux (plus the +// Android target from the AGP plugin above) — together with explicitApi() and +// kotlin-test in commonTest, all come from sk.ainet.multiplatform. +skainet { + namespace = "sk.ainet.pipeline" } + +// No dependencies on skainet-lang-core - keep it minimal diff --git a/skainet-pipeline/karma.config.d/skainet-browser-resilience.js b/skainet-pipeline/karma.config.d/skainet-browser-resilience.js deleted file mode 100644 index 53d77540..00000000 --- a/skainet-pipeline/karma.config.d/skainet-browser-resilience.js +++ /dev/null @@ -1,20 +0,0 @@ -// SKaiNET: harden Karma browser tests against launch/capture flakiness. -// -// During `./gradlew allTests` many wasmJs/js browser test tasks start a -// ChromeHeadless instance at the same time. On a loaded machine the browser -// can take longer than Karma's defaults to capture or to emit progress, so -// Karma disconnects it ("Disconnected, no message in 30000 ms" / -// "ChromeHeadless was not killed"), ends up discovering zero tests and the -// build fails on Gradle's `failOnNoDiscoveredTests` check — even though the -// tests pass fine when the task runs in isolation. -// -// Generous capture/disconnect/no-activity timeouts plus a few disconnect -// retries make the run wait patiently for a starved browser instead of -// giving up. Kotlin's Gradle plugin merges every *.js here into karma.conf.js. -config.set({ - captureTimeout: 120000, - browserDisconnectTimeout: 30000, - browserDisconnectTolerance: 3, - browserNoActivityTimeout: 120000, - pingTimeout: 120000, -}); From d5475678ba6d4ee186ac0f8c92c88fc65a083c92 Mon Sep 17 00:00:00 2001 From: michalharakal Date: Thu, 30 Jul 2026 12:19:15 +0200 Subject: [PATCH 2/2] build: declare npm pins through an explicit skainet { npmPins { } } DSL Follow-up to 3db891d0. Pins were discovered by scanning `[versions]` for `npm-*` aliases and mangling the alias back into a package name, which meant the declaration was implicit, the package name was derived rather than written, and names a catalog alias cannot spell (socket.io, @types/node) needed a packageNameOverrides escape hatch. Pins are now declared: skainet { npmPins { pin("ws", libs.versions.npm.ws) } } The version still lives in libs.versions.toml, so dependency-update tooling keeps seeing a bumpable number; only the package-to-version mapping moves into the build script, where it is written out literally. readPins(), toPackageName(), normalizeSeparators() and packageNameOverrides are gone. The root `skainet { }` block is a new SkainetRootExtension, deliberately sharing its name with the module-level block from sk.ainet.multiplatform: one SKaiNET namespace to look for, different types behind it because the concerns differ. findOrCreate() lets further root conventions nest into the same block instead of adding top-level extensions. Two validations, both at configuration time, both previously impossible to express: a package pinned twice is an error (a Yarn resolution is global, so the second declaration would silently win), and a range such as ^8.21.1 is an error (verifyNpmPins compares the lockfile's resolved version for equality, so a range could never verify). SkainetMultiplatformPlugin no longer applies sk.ainet.npm-pins to the root project from a web-targeted module. That cross-project mutation is rejected under isolated projects, and with pins now declared in the root script body it could also apply the plugin too late for the block to exist. It is replaced by an assertion: a module that builds js/wasmJs fails at configuration time if the root does not apply the plugin, since without it no resolutions are written and verifyNpmPins does not exist to catch the drift -- a silent security regression rather than a build error. The check reads a build-scoped marker service (NpmPinsMarker) rather than rootProject.pluginManager, so it stays legal under isolated projects; the registration is lazy and the service is never instantiated. Verified: verifyNpmPins reports 1 pin against 2 lockfiles and the config cache entry is reused; rootPackageJson and wasmRootPackageJson both emit {"ws": "8.21.1"} into their resolutions; ./gradlew tasks --all configures every project; tampering kotlin-js-store/wasm/yarn.lock fails verifyNpmPins with the file, actual, expected and the regeneration command; a duplicate pin and a ^-range pin each fail with their own message; removing the root plugin fails :skainet-pipeline with the new assertion. Progresses #804 --- .../kmp/SkainetMultiplatformPlugin.kt | 34 +++++- .../ainet/buildlogic/npm/NpmPinsExtension.kt | 104 +++++++++++++----- .../sk/ainet/buildlogic/npm/NpmPinsMarker.kt | 41 +++++++ .../sk/ainet/buildlogic/npm/NpmPinsPlugin.kt | 71 ++++-------- .../buildlogic/root/SkainetRootExtension.kt | 66 +++++++++++ build.gradle.kts | 8 ++ .../pages/contributing/build-from-source.adoc | 36 +++++- gradle/libs.versions.toml | 8 +- 8 files changed, 280 insertions(+), 88 deletions(-) create mode 100644 build-logic/convention/src/main/kotlin/sk/ainet/buildlogic/npm/NpmPinsMarker.kt create mode 100644 build-logic/convention/src/main/kotlin/sk/ainet/buildlogic/root/SkainetRootExtension.kt diff --git a/build-logic/convention/src/main/kotlin/sk/ainet/buildlogic/kmp/SkainetMultiplatformPlugin.kt b/build-logic/convention/src/main/kotlin/sk/ainet/buildlogic/kmp/SkainetMultiplatformPlugin.kt index 060c1895..69861982 100644 --- a/build-logic/convention/src/main/kotlin/sk/ainet/buildlogic/kmp/SkainetMultiplatformPlugin.kt +++ b/build-logic/convention/src/main/kotlin/sk/ainet/buildlogic/kmp/SkainetMultiplatformPlugin.kt @@ -10,6 +10,7 @@ import org.gradle.api.plugins.ExtensionAware import org.jetbrains.kotlin.gradle.ExperimentalWasmDsl import org.jetbrains.kotlin.gradle.dsl.KotlinMultiplatformExtension import org.jetbrains.kotlin.gradle.targets.js.dsl.KotlinJsSubTargetDsl +import sk.ainet.buildlogic.npm.NpmPinsMarker private const val ANDROID_KMP_PLUGIN_ID = "com.android.kotlin.multiplatform.library" private const val NPM_PINS_PLUGIN_ID = "sk.ainet.npm-pins" @@ -43,18 +44,16 @@ class SkainetMultiplatformPlugin : Plugin { val extension = project.extensions.create("skainet", SkainetMultiplatformExtension::class.java) val targets = SkainetTargets.from(project) + // Checked before anything is configured, so a misconfigured build fails on the + // reason rather than on a symptom further down. + if (targets.web) requireNpmPins(project) + project.pluginManager.apply("org.jetbrains.kotlin.multiplatform") val kotlin = project.extensions.getByType(KotlinMultiplatformExtension::class.java) // Eager: targets must exist before the module's own `kotlin { }` block runs. configureTargets(project, kotlin, targets) - if (targets.web) { - // Yarn resolutions are root-project scoped; applying is idempotent, so every - // web-enabled module can ask for them without coordinating. - project.rootProject.pluginManager.apply(NPM_PINS_PLUGIN_ID) - } - // Deferred: needs the values the build script sets in `skainet { }`. project.afterEvaluate { applyExtension(this, kotlin, extension) } } @@ -88,6 +87,29 @@ class SkainetMultiplatformPlugin : Plugin { configureAndroid(project, kotlin, extension) } + /** + * Fails a `js`/`wasmJs` module when the root project does not apply + * `sk.ainet.npm-pins`. + * + * Without the root plugin the npm pins are simply absent: no Yarn `resolutions` are + * written, the lockfiles drift back to whatever the transitive ranges resolve to, and + * `verifyNpmPins` does not exist to catch it. That is a silent security regression, + * so it fails loudly at configuration time instead. + * + * The check reads a build-scoped service rather than `rootProject.pluginManager`, so + * it stays legal under isolated projects — see [NpmPinsMarker]. + */ + private fun requireNpmPins(project: Project) { + if (NpmPinsMarker.isRegistered(project)) return + + throw GradleException( + "[skainet-multiplatform] ${project.path} builds js/wasmJs targets, but " + + "$NPM_PINS_PLUGIN_ID is not applied to the root project, so npm pins " + + "are not in force. Add it to the root build.gradle.kts:\n" + + " plugins { alias(libs.plugins.skainet.npmPins) }" + ) + } + /** * Fills in the `android { }` body. The AGP KMP plugin creates the target itself when * it is applied in the module's `plugins { }` block, so this only sets values — which diff --git a/build-logic/convention/src/main/kotlin/sk/ainet/buildlogic/npm/NpmPinsExtension.kt b/build-logic/convention/src/main/kotlin/sk/ainet/buildlogic/npm/NpmPinsExtension.kt index 754e67e6..b81b3b35 100644 --- a/build-logic/convention/src/main/kotlin/sk/ainet/buildlogic/npm/NpmPinsExtension.kt +++ b/build-logic/convention/src/main/kotlin/sk/ainet/buildlogic/npm/NpmPinsExtension.kt @@ -2,45 +2,44 @@ package sk.ainet.buildlogic.npm import org.gradle.api.provider.MapProperty import org.gradle.api.provider.Property +import org.gradle.api.provider.Provider /** - * Configuration for `sk.ainet.npm-pins`. + * The `npmPins { }` block nested inside the root `skainet { }` extension. * - * The pins themselves are **not** declared here — they live in `[versions]` of - * `gradle/libs.versions.toml` under the `npm-` prefix, so a package is pinned by - * editing exactly one line: + * Every pin names its package literally and takes its version from the version + * catalog, so the number stays in one place and stays bumpable by tooling: * - * ```toml + * ```kotlin + * // gradle/libs.versions.toml * [versions] * npm-ws = "8.21.1" # GHSA-96hv-2xvq-fx4p + * + * // root build.gradle.kts + * skainet { + * npmPins { + * pin("ws", libs.versions.npm.ws) + * } + * } * ``` * - * This extension only carries the knobs needed to interpret that catalog. + * Because the package name is written out rather than derived from the alias, names + * that a catalog alias cannot spell — a dot (`socket.io`), a scope (`@types/node`) — + * need nothing special: + * + * ```kotlin + * pin("socket.io", libs.versions.npm.socketio) + * pin("@types/node", libs.versions.npm.typesNode) + * ``` */ abstract class NpmPinsExtension { - /** Version catalog to read pins from. Defaults to `libs`. */ - abstract val catalogName: Property - - /** Alias prefix marking a `[versions]` entry as an npm pin. Defaults to `npm`. */ - abstract val aliasPrefix: Property - /** - * Escape hatch for package names the alias rule cannot express. - * - * Gradle normalises catalog aliases, so `npm-webpack-dev-server` arrives as - * `npm.webpack.dev.server` and maps back to `webpack-dev-server` by turning the - * separators into dashes. Packages whose real name contains a dot (`socket.io`) - * or a scope (`@types/node`) round-trip incorrectly and need an explicit entry, - * keyed by the normalised alias: - * - * ```kotlin - * npmPins { - * packageNameOverrides.put("npm.socket.io", "socket.io") - * } - * ``` + * Package name -> exact version, as declared by [pin]. Consumed by + * [NpmPinsPlugin] and [VerifyNpmPinsTask]; declare pins through [pin] rather than + * mutating this directly, which skips validation and duplicate detection. */ - abstract val packageNameOverrides: MapProperty + abstract val pins: MapProperty /** * Whether `verifyNpmPins` fails when a pinned package is absent from every @@ -49,4 +48,57 @@ abstract class NpmPinsExtension { * should be reported without breaking the build. */ abstract val failOnMissingPackage: Property + + private val declared = mutableSetOf() + + /** Pins [packageName] to a version held in the version catalog. */ + fun pin(packageName: String, version: Provider) { + val name = validatePackageName(packageName) + pins.put(name, version.map { validateVersion(name, it) }) + } + + /** + * Pins [packageName] to a literal version. + * + * Prefer the [Provider] overload — a number in `libs.versions.toml` is visible to + * dependency-update tooling, a number in the build script is not. + */ + fun pin(packageName: String, version: String) { + val name = validatePackageName(packageName) + pins.put(name, validateVersion(name, version)) + } + + private fun validatePackageName(packageName: String): String { + val name = packageName.trim() + require(name.isNotEmpty()) { "[npm-pins] Package name must not be blank" } + require(name.none { it.isWhitespace() }) { + "[npm-pins] Package name '$packageName' must not contain whitespace" + } + require(declared.add(name)) { + "[npm-pins] '$name' is pinned twice — a Yarn resolution is global, so the " + + "second declaration would silently win" + } + return name + } + + /** + * Rejects ranges. `verifyNpmPins` compares the lockfile's resolved version for exact + * equality, so a range pin such as `^8.21.1` could never verify — better to fail at + * configuration time with the reason than at `check` with a confusing mismatch. + */ + private fun validateVersion(packageName: String, version: String): String { + val exact = version.trim() + require(exact.isNotEmpty()) { + "[npm-pins] Pin for '$packageName' must declare a version (e.g. \"8.21.1\")" + } + require(exact.none { it in RANGE_CHARACTERS }) { + "[npm-pins] Pin for '$packageName' must be an exact version, but was '$exact'. " + + "Ranges cannot be verified against a lockfile — write \"8.21.1\", not \"^8.21.1\"." + } + return exact + } + + private companion object { + private val RANGE_CHARACTERS = setOf('^', '~', '>', '<', '=', '*', '|', ' ') + } } diff --git a/build-logic/convention/src/main/kotlin/sk/ainet/buildlogic/npm/NpmPinsMarker.kt b/build-logic/convention/src/main/kotlin/sk/ainet/buildlogic/npm/NpmPinsMarker.kt new file mode 100644 index 00000000..d6fc45ac --- /dev/null +++ b/build-logic/convention/src/main/kotlin/sk/ainet/buildlogic/npm/NpmPinsMarker.kt @@ -0,0 +1,41 @@ +package sk.ainet.buildlogic.npm + +import org.gradle.api.Project +import org.gradle.api.services.BuildService +import org.gradle.api.services.BuildServiceParameters + +/** + * Build-scoped marker announcing that `sk.ainet.npm-pins` is applied to the root project. + * + * ## Why a build service and not `rootProject.pluginManager.hasPlugin(...)` + * + * A module that builds `js`/`wasmJs` has to be able to tell whether pins are in force — + * without them a security pin silently stops applying and the lockfiles drift on the + * next refresh, with no failing task to notice it (`verifyNpmPins` would not exist + * either). Reading the root project's plugin state from a subproject answers that, but + * it is exactly the cross-project access Gradle's isolated-projects mode rejects. + * + * Build services are build-scoped by design and safe to look up from any project. The + * registration is lazy — [NpmPinsMarker] is never instantiated, only registered — so + * this costs nothing at execution time. + * + * The ordering the check relies on is guaranteed: the root build script is evaluated + * before any subproject is configured, so a root-applied `sk.ainet.npm-pins` has always + * registered by the time a module plugin asks. + */ +abstract class NpmPinsMarker : BuildService { + + companion object { + + private const val NAME = "skainetNpmPinsMarker" + + /** Called by [NpmPinsPlugin] once it has verified it is on the root project. */ + fun register(project: Project) { + project.gradle.sharedServices.registerIfAbsent(NAME, NpmPinsMarker::class.java) {} + } + + /** Whether `sk.ainet.npm-pins` was applied to the root project of this build. */ + fun isRegistered(project: Project): Boolean = + project.gradle.sharedServices.registrations.findByName(NAME) != null + } +} diff --git a/build-logic/convention/src/main/kotlin/sk/ainet/buildlogic/npm/NpmPinsPlugin.kt b/build-logic/convention/src/main/kotlin/sk/ainet/buildlogic/npm/NpmPinsPlugin.kt index d48beae7..3981a6cd 100644 --- a/build-logic/convention/src/main/kotlin/sk/ainet/buildlogic/npm/NpmPinsPlugin.kt +++ b/build-logic/convention/src/main/kotlin/sk/ainet/buildlogic/npm/NpmPinsPlugin.kt @@ -2,8 +2,6 @@ package sk.ainet.buildlogic.npm import org.gradle.api.Plugin import org.gradle.api.Project -import org.gradle.api.artifacts.VersionCatalog -import org.gradle.api.artifacts.VersionCatalogsExtension import org.gradle.api.provider.Provider import org.jetbrains.kotlin.gradle.targets.js.yarn.YarnLockStoreTask import org.jetbrains.kotlin.gradle.targets.js.yarn.YarnPlugin @@ -11,6 +9,7 @@ import org.jetbrains.kotlin.gradle.targets.js.yarn.YarnRootExtension import org.jetbrains.kotlin.gradle.targets.wasm.yarn.WasmYarnPlugin import org.jetbrains.kotlin.gradle.targets.wasm.yarn.WasmYarnRootExtension import org.jetbrains.kotlin.gradle.targets.web.yarn.BaseYarnRootExtension +import sk.ainet.buildlogic.root.SkainetRootExtension /** * Pins selected npm packages to an audited version across every Kotlin/JS and @@ -32,12 +31,21 @@ import org.jetbrains.kotlin.gradle.targets.web.yarn.BaseYarnRootExtension * * ## Declaring a pin * - * Add one line to `[versions]` in `gradle/libs.versions.toml`, prefixed with `npm-`: + * Put the version in `[versions]` of `gradle/libs.versions.toml` and name the package + * in the root build script: * * ```toml * npm-ws = "8.21.1" # GHSA-96hv-2xvq-fx4p * ``` * + * ```kotlin + * skainet { + * npmPins { + * pin("ws", libs.versions.npm.ws) + * } + * } + * ``` + * * Then regenerate and commit the lockfiles: * * ``` @@ -47,7 +55,8 @@ import org.jetbrains.kotlin.gradle.targets.web.yarn.BaseYarnRootExtension * `verifyNpmPins` (wired into `check`) fails if a lockfile later drifts off a pin. * * Must be applied to the root project: the Yarn root extensions assert that they - * belong to `rootProject`. + * belong to `rootProject`, and pins must be declared while the root script is + * evaluated, before any module configures a web target. */ class NpmPinsPlugin : Plugin { @@ -57,15 +66,16 @@ class NpmPinsPlugin : Plugin { "Yarn roots are root-project scoped, but it was applied to ${project.path}" } - val extension = project.extensions.create("npmPins", NpmPinsExtension::class.java).apply { - catalogName.convention("libs") - aliasPrefix.convention("npm") - packageNameOverrides.convention(emptyMap()) + val extension = SkainetRootExtension.findOrCreate(project).npmPins.apply { failOnMissingPackage.convention(false) } - // Resolved lazily so overrides declared later in the root build script are seen. - val pinsProvider: Provider> = project.provider { readPins(project, extension) } + // Lets web-targeted modules assert that pins are in force; see NpmPinsMarker. + NpmPinsMarker.register(project) + + // Resolved lazily: pins are declared in the root script body, which runs after + // the plugins block that applies this plugin. + val pinsProvider: Provider> = extension.pins // The Yarn plugins are applied by KGP only once a js/wasmJs target is configured, // which happens well after this plugin is applied — hence the reactive hooks. @@ -81,7 +91,7 @@ class NpmPinsPlugin : Plugin { val verify = project.tasks.register("verifyNpmPins", VerifyNpmPinsTask::class.java) { group = "verification" - description = "Check the committed Yarn lockfiles against the npm-* pins in the version catalog" + description = "Check the committed Yarn lockfiles against the pins declared in skainet { npmPins { } }" pins.set(pinsProvider) failOnMissingPackage.set(extension.failOnMissingPackage) rootDirectory.set(project.layout.projectDirectory) @@ -103,43 +113,4 @@ class NpmPinsPlugin : Plugin { private fun BaseYarnRootExtension.applyPins(pins: Map) { pins.forEach { (packageName, version) -> resolution(packageName, version) } } - - private fun readPins(project: Project, extension: NpmPinsExtension): Map { - val catalogName = extension.catalogName.get() - val catalog: VersionCatalog = project.extensions - .getByType(VersionCatalogsExtension::class.java) - .find(catalogName) - .orElseThrow { - IllegalStateException("[npm-pins] Version catalog '$catalogName' not found") - } - - val prefix = extension.aliasPrefix.get() - val overrides = extension.packageNameOverrides.get() - - return catalog.versionAliases - .filter { it.normalizeSeparators().startsWith("$prefix.") } - .associate { alias -> - val packageName = overrides[alias.normalizeSeparators()] ?: alias.toPackageName(prefix) - val version = catalog.findVersion(alias) - .orElseThrow { IllegalStateException("[npm-pins] Version alias '$alias' disappeared from '$catalogName'") } - .requiredVersion - require(version.isNotEmpty()) { - "[npm-pins] Catalog alias '$alias' must declare an exact version (e.g. npm-ws = \"8.21.1\")" - } - packageName to version - } - } - - /** - * `npm-webpack-dev-server` reaches us as `npm.webpack.dev.server`; strip the prefix - * and turn the remaining separators back into dashes. - * - * Package names that genuinely contain a dot (`socket.io`) or a scope - * (`@types/node`) cannot be expressed as a catalog alias and need an entry in - * [NpmPinsExtension.packageNameOverrides]. - */ - private fun String.toPackageName(prefix: String): String = - normalizeSeparators().removePrefix("$prefix.").replace('.', '-') - - private fun String.normalizeSeparators(): String = replace('-', '.').replace('_', '.') } diff --git a/build-logic/convention/src/main/kotlin/sk/ainet/buildlogic/root/SkainetRootExtension.kt b/build-logic/convention/src/main/kotlin/sk/ainet/buildlogic/root/SkainetRootExtension.kt new file mode 100644 index 00000000..ee62daec --- /dev/null +++ b/build-logic/convention/src/main/kotlin/sk/ainet/buildlogic/root/SkainetRootExtension.kt @@ -0,0 +1,66 @@ +package sk.ainet.buildlogic.root + +import org.gradle.api.Action +import org.gradle.api.model.ObjectFactory +import sk.ainet.buildlogic.npm.NpmPinsExtension +import javax.inject.Inject + +/** + * The `skainet { }` block contributed by SKaiNET's **root-project** conventions. + * + * Deliberately shares its name with the module-level block created by + * `sk.ainet.multiplatform`, so a build script author sees one SKaiNET namespace + * regardless of where they are. The types differ because the concerns do: a module + * configures its own compilation, the root configures things that are global to the + * build and have nowhere else to live. + * + * ```kotlin + * // root build.gradle.kts + * skainet { + * npmPins { + * pin("ws", libs.versions.npm.ws) + * } + * } + * ``` + * + * Nest further root-level conventions here rather than adding top-level extensions; + * [sk.ainet.buildlogic.root.SkainetRootExtension.Companion.findOrCreate] lets several + * plugins contribute to the same block. + */ +abstract class SkainetRootExtension @Inject constructor(objects: ObjectFactory) { + + /** + * npm packages forced onto an exact version across every Kotlin/JS and Kotlin/Wasm + * lockfile. Populated by `sk.ainet.npm-pins`; see [NpmPinsExtension]. + */ + val npmPins: NpmPinsExtension = objects.newInstance(NpmPinsExtension::class.java) + + /** Configures [npmPins]. */ + fun npmPins(action: Action) { + action.execute(npmPins) + } + + companion object { + + const val NAME: String = "skainet" + + /** + * Returns the root `skainet { }` extension, creating it if this is the first + * root convention plugin to ask. + * + * @throws IllegalStateException if something else already claimed the name — most + * likely `sk.ainet.multiplatform` applied to the root project, which is not + * supported: the root project is not a library module. + */ + fun findOrCreate(project: org.gradle.api.Project): SkainetRootExtension { + val existing = project.extensions.findByName(NAME) ?: return project.extensions + .create(NAME, SkainetRootExtension::class.java) + + return existing as? SkainetRootExtension ?: error( + "[skainet] ${project.path} already has a '$NAME' extension of type " + + "${existing.javaClass.name}. The root-project '$NAME { }' block cannot " + + "coexist with it — is sk.ainet.multiplatform applied to the root project?" + ) + } + } +} diff --git a/build.gradle.kts b/build.gradle.kts index ab22155b..579a9114 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -19,6 +19,14 @@ allprojects { version = providers.gradleProperty("VERSION_NAME").getOrElse("unspecified") } +// Root-project SKaiNET conventions. npm pins are forced onto both kotlin-js-store +// lockfiles via Yarn resolutions; see docs "Pinning npm Packages". +skainet { + npmPins { + pin("ws", libs.versions.npm.ws) + } +} + // Require JDK 21+ but allow any newer version (produces Java 21 bytecode via --release / jvmTarget) subprojects { require(JavaVersion.current() >= JavaVersion.VERSION_21) { diff --git a/docs/modules/ROOT/pages/contributing/build-from-source.adoc b/docs/modules/ROOT/pages/contributing/build-from-source.adoc index 9350b652..a6247f27 100644 --- a/docs/modules/ROOT/pages/contributing/build-from-source.adoc +++ b/docs/modules/ROOT/pages/contributing/build-from-source.adoc @@ -138,6 +138,23 @@ This split is not cosmetic. Kotlin targets must exist *before* the module's own A `skainet ++{++ ++}++` block is part of the build script, so anything declared there is only known once it is too late to create targets. Gradle properties are readable while the plugin is being applied. Everything non-structural — `namespace`, `androidJvmTarget`, `explicitApi`, `expectActualClasses`, `kotlinTestInCommonTest` — stays in the `skainet ++{++ ++}++` DSL. +==== The two `skainet ++{++ ++}++` blocks + +The name appears in two places, deliberately: there is one SKaiNET namespace to look for wherever you are. The types behind it differ, because the concerns do. + +[cols="1,3"] +|=== +| Where | What it configures + +| module `build.gradle.kts` +| That module's own compilation — `namespace`, `androidJvmTarget`, `explicitApi`, `expectActualClasses`, `kotlinTestInCommonTest`. Created by `sk.ainet.multiplatform`. + +| root `build.gradle.kts` +| Conventions global to the build, which have nowhere else to live — currently `npmPins ++{++ ++}++`. Created by whichever root convention plugin is applied first; each nests its own sub-block. +|=== + +Applying `sk.ainet.multiplatform` to the *root* project is not supported and fails with a message saying so: the root project is not a library module, and its `skainet ++{++ ++}++` block means something else. + ==== Migrating a module [arabic] @@ -152,14 +169,25 @@ A `skainet ++{++ ++}++` block is part of the build script, so anything declared Kotlin/JS and Kotlin/Wasm dependencies are locked in `kotlin-js-store/yarn.lock` and `kotlin-js-store/wasm/yarn.lock`. Both files are *generated*. Editing them by hand does not survive the next lockfile refresh — see https://github.com/SKaiNET-developers/SKaiNET/pull/894[PR #894], where a hand-applied `ws` security bump was reverted by `kotlinWasmUpgradeYarnLock` in the same pull request. -To pin a package, add one line to `++[++versions++]++` in `gradle/libs.versions.toml` with an `npm-` prefix: +Pinning a package takes two edits. The version goes in `++[++versions++]++` of `gradle/libs.versions.toml`: [source,toml] ---- npm-ws = "8.21.1" # GHSA-96hv-2xvq-fx4p ---- -`sk.ainet.npm-pins` (applied on the root project) turns every such entry into a Yarn `resolutions` entry in *both* generated root `package.json` files, so one declaration covers the JS and Wasm dependency graphs. Then regenerate and commit the lockfiles: +and the root build script names the package it belongs to: + +[source,kotlin] +---- +skainet { + npmPins { + pin("ws", libs.versions.npm.ws) + } +} +---- + +`sk.ainet.npm-pins` (applied on the root project) turns each `pin` into a Yarn `resolutions` entry in *both* generated root `package.json` files, so one declaration covers the JS and Wasm dependency graphs. Then regenerate and commit the lockfiles: [source,bash] ---- @@ -168,4 +196,6 @@ npm-ws = "8.21.1" # GHSA-96hv-2xvq-fx4p `verifyNpmPins` re-reads the committed lockfiles and fails if any pinned package resolved elsewhere. It is wired into `check` and runs on the `js-wasm` leg of `.github/workflows/build.yml`. -Package names containing a dot (`socket.io`) or a scope (`++@++types/node`) cannot be spelled as a catalog alias; they need an entry in `npmPins ++{++ packageNameOverrides ++}++` in the root build script. +The root plugin is not optional for web modules: `sk.ainet.multiplatform` fails at configuration time if a module builds `js`/`wasmJs` while the root project does not apply `sk.ainet.npm-pins`. Without it no `resolutions` are written *and* `verifyNpmPins` does not exist to notice — a silent security regression rather than a build error. + +The package name is written out rather than derived from the catalog alias, so names a catalog alias cannot spell need nothing special — `pin("socket.io", …)`, `pin("++@++types/node", …)`. Versions must be exact: a range such as `++^++8.21.1` is rejected at configuration time, because `verifyNpmPins` compares the lockfile's resolved version for equality and a range could never match. diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index ff9445ea..23814724 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -28,9 +28,11 @@ pbandk = "0.16.0" kotest = "6.2.2" # --- npm pins ----------------------------------------------------------------- -# Every `npm-` entry is forced into BOTH kotlin-js-store lockfiles by the -# sk.ainet.npm-pins convention plugin, via Yarn resolutions. Adding a line here is -# the whole job; then regenerate and commit the lockfiles with +# Versions for npm packages forced into BOTH kotlin-js-store lockfiles via Yarn +# resolutions. A version here does nothing on its own — it takes effect once the +# root build script names the package: +# skainet { npmPins { pin("ws", libs.versions.npm.ws) } } +# Then regenerate and commit the lockfiles with # ./gradlew kotlinUpgradeYarnLock kotlinWasmUpgradeYarnLock # Never edit kotlin-js-store/**/yarn.lock by hand — it is generated and will be # overwritten (see PR #894).