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..69861982 --- /dev/null +++ b/build-logic/convention/src/main/kotlin/sk/ainet/buildlogic/kmp/SkainetMultiplatformPlugin.kt @@ -0,0 +1,208 @@ +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 +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" +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) + + // 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) + + // 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) + } + + /** + * 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 + * 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..b81b3b35 --- /dev/null +++ b/build-logic/convention/src/main/kotlin/sk/ainet/buildlogic/npm/NpmPinsExtension.kt @@ -0,0 +1,104 @@ +package sk.ainet.buildlogic.npm + +import org.gradle.api.provider.MapProperty +import org.gradle.api.provider.Property +import org.gradle.api.provider.Provider + +/** + * The `npmPins { }` block nested inside the root `skainet { }` extension. + * + * 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: + * + * ```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) + * } + * } + * ``` + * + * 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 { + + /** + * 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 pins: 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 + + 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 new file mode 100644 index 00000000..3981a6cd --- /dev/null +++ b/build-logic/convention/src/main/kotlin/sk/ainet/buildlogic/npm/NpmPinsPlugin.kt @@ -0,0 +1,116 @@ +package sk.ainet.buildlogic.npm + +import org.gradle.api.Plugin +import org.gradle.api.Project +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 +import sk.ainet.buildlogic.root.SkainetRootExtension + +/** + * 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 + * + * 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: + * + * ``` + * ./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`, and pins must be declared while the root script is + * evaluated, before any module configures a web target. + */ +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 = SkainetRootExtension.findOrCreate(project).npmPins.apply { + failOnMissingPackage.convention(false) + } + + // 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. + 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 pins declared in skainet { npmPins { } }" + 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) } + } +} 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-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 d76ff097..579a9114 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 } @@ -18,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 dcaeec7b..a6247f27 100644 --- a/docs/modules/ROOT/pages/contributing/build-from-source.adoc +++ b/docs/modules/ROOT/pages/contributing/build-from-source.adoc @@ -95,3 +95,107 @@ 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. + +==== 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] +. 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. + +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 +---- + +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] +---- +./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`. + +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/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..23814724 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -27,6 +27,17 @@ kotlinxIo = "0.9.1" pbandk = "0.16.0" kotest = "6.2.2" +# --- npm pins ----------------------------------------------------------------- +# 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). +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 +52,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 +105,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, -});