commit b546e2b48e0d1c14e74cb935d4c9f94138672718 parent f2db0a06f4281d1e1dc54c4d1f04c01cb5d63c3b Author: triesap <tyson@radroots.org> Date: Mon, 10 Aug 2026 20:53:31 +0000 build: add the shared KMP convention plugin - centralize shared KMP, Compose, lint, and JVM 21 configuration - preserve the single desktop target and existing dependency graph - enforce common-source boundaries with a typed verification task - prove target wiring and negative fixtures through Gradle TestKit Diffstat:
11 files changed, 190 insertions(+), 86 deletions(-)
diff --git a/app/desktop/build.gradle.kts b/app/desktop/build.gradle.kts @@ -31,11 +31,11 @@ import java.security.MessageDigest import java.util.jar.JarFile plugins { - alias(libs.plugins.kotlin.jvm) - alias(libs.plugins.compose.multiplatform) - alias(libs.plugins.compose.compiler) - alias(libs.plugins.detekt) - alias(libs.plugins.ktlint) + id("org.jetbrains.kotlin.jvm") + id("org.jetbrains.compose") + id("org.jetbrains.kotlin.plugin.compose") + id("dev.detekt") + id("org.jlleitschuh.gradle.ktlint") alias(libs.plugins.license.report) alias(libs.plugins.owasp.dependency.check) } diff --git a/app/shared/build.gradle.kts b/app/shared/build.gradle.kts @@ -1,71 +1,3 @@ -import org.harvestcircle.gradle.VerifySharedBoundary -import org.jetbrains.kotlin.gradle.dsl.JvmTarget -import org.jetbrains.kotlin.gradle.plugin.KotlinPlatformType - plugins { - alias(libs.plugins.kotlin.multiplatform) - alias(libs.plugins.compose.multiplatform) - alias(libs.plugins.compose.compiler) - alias(libs.plugins.detekt) - alias(libs.plugins.ktlint) -} - -configure<org.jlleitschuh.gradle.ktlint.KtlintExtension> { - additionalEditorconfig.set( - mapOf( - "ktlint_function_naming_ignore_when_annotated_with" to "Composable", - ), - ) -} - -providers.environmentVariable("EXT_BUILD_GRADLE_BUILD_DIR").orNull?.let { extBuildGradleRoot -> - layout.buildDirectory.set(file(extBuildGradleRoot).resolve("app-shared")) -} - -kotlin { - jvm("desktop") { - compilerOptions { - jvmTarget.set(JvmTarget.JVM_21) - } - } - - sourceSets { - commonMain.dependencies { - implementation(libs.compose.foundation) - implementation(libs.compose.runtime) - implementation(libs.compose.ui) - implementation(libs.kotlinx.coroutines.core) - } - commonTest.dependencies { - implementation(kotlin("test")) - implementation(libs.kotlinx.coroutines.test) - } - getByName("desktopTest").dependencies { - implementation(compose.desktop.currentOs) - implementation(kotlin("test-junit")) - implementation(libs.compose.ui.test.junit4) - } - } - - jvmToolchain(21) - - check( - targets - .filter { it.platformType != KotlinPlatformType.common } - .map { it.name } == listOf("desktop"), - ) { - "HarvestCircle shared must declare exactly one KMP platform target named desktop" - } -} - -val verifySharedBoundary by tasks.registering(VerifySharedBoundary::class) { - commonSources.from( - fileTree("src/commonMain/kotlin") { - include("**/*.kt") - }, - ) -} - -tasks.named("check") { - dependsOn(verifySharedBoundary) + id("org.harvestcircle.build.kmp-shared") } diff --git a/build-logic/plugins/build.gradle.kts b/build-logic/plugins/build.gradle.kts @@ -12,6 +12,11 @@ kotlin { dependencies { implementation(project(":contracts")) + implementation("org.jetbrains.kotlin:kotlin-gradle-plugin:${libs.versions.kotlin.get()}") + implementation("org.jetbrains.kotlin:compose-compiler-gradle-plugin:${libs.versions.kotlin.get()}") + implementation("org.jetbrains.compose:compose-gradle-plugin:${libs.versions.compose.get()}") + implementation("dev.detekt:detekt-gradle-plugin:${libs.versions.detekt.get()}") + implementation("org.jlleitschuh.gradle:ktlint-gradle:${libs.versions.ktlint.get()}") testImplementation(kotlin("test")) } diff --git a/build-logic/plugins/src/functionalTest/kotlin/org/harvestcircle/buildlogic/plugins/ConventionPluginSmokeTest.kt b/build-logic/plugins/src/functionalTest/kotlin/org/harvestcircle/buildlogic/plugins/ConventionPluginSmokeTest.kt @@ -23,8 +23,14 @@ class ConventionPluginSmokeTest { pluginIds.forEach { pluginId -> val fixture = temporaryDirectory.resolve(pluginId.substringAfterLast('.')).createDirectories() - fixture.resolve("settings.gradle.kts").writeText("rootProject.name = \"fixture\"\n") - fixture.resolve("build.gradle.kts").writeText("plugins { id(\"$pluginId\") }\n") + fixture.resolve("settings.gradle.kts").writeText( + "pluginManagement { repositories { gradlePluginPortal(); mavenCentral() } }\nrootProject.name = \"fixture\"\n", + ) + if (pluginId == "org.harvestcircle.build.kmp-shared") { + fixture.resolve("gradle").createDirectories().resolve("libs.versions.toml").writeText(kmpCatalog) + } + val pluginBlock = if (pluginId == "org.harvestcircle.build.kmp-shared") kmpPlugins else "id(\"$pluginId\")" + fixture.resolve("build.gradle.kts").writeText("plugins { $pluginBlock }\n") val runner = GradleRunner.create() @@ -61,4 +67,47 @@ class ConventionPluginSmokeTest { assertTrue(failure is UnexpectedBuildFailure) assertTrue(failure.message.orEmpty().contains("may only be applied to the root project")) } + + @Test + fun sharedPluginRejectsPlatformDependenciesFromCommonSources() { + val fixture = createTempDirectory("harvestcircle-shared-plugin-") + fixture.resolve("settings.gradle.kts").writeText( + "pluginManagement { repositories { gradlePluginPortal(); mavenCentral() } }\nrootProject.name = \"fixture\"\n", + ) + fixture.resolve("gradle").createDirectories().resolve("libs.versions.toml").writeText(kmpCatalog) + fixture.resolve("build.gradle.kts").writeText("plugins { id(\"org.harvestcircle.build.kmp-shared\") }\n") + fixture.resolve("src/commonMain/kotlin").createDirectories().resolve("Leak.kt").writeText( + "package fixture\nimport org.harvestcircle.ffi.BuildInfoDto\n", + ) + + val result = + GradleRunner.create() + .withProjectDir(fixture.toFile()) + .withPluginClasspath() + .withArguments("verifySharedBoundary", "--stacktrace") + .buildAndFail() + + assertTrue(result.output.contains("prohibited common-source dependency"), result.output) + } + + private val kmpCatalog = + """ + [versions] + kotlin = "2.4.10" + compose = "1.11.1" + coroutines = "1.9.0" + + [libraries] + compose-foundation = { module = "org.jetbrains.compose.foundation:foundation", version.ref = "compose" } + compose-runtime = { module = "org.jetbrains.compose.runtime:runtime", version.ref = "compose" } + compose-ui = { module = "org.jetbrains.compose.ui:ui", version.ref = "compose" } + compose-ui-test-junit4 = { module = "org.jetbrains.compose.ui:ui-test-junit4", version.ref = "compose" } + kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "coroutines" } + kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "coroutines" } + """.trimIndent() + "\n" + + private val kmpPlugins = + """ + id("org.harvestcircle.build.kmp-shared") + """.trimIndent() } diff --git a/build-logic/plugins/src/main/kotlin/org/harvestcircle/buildlogic/plugins/HarvestCircleConventionPlugins.kt b/build-logic/plugins/src/main/kotlin/org/harvestcircle/buildlogic/plugins/HarvestCircleConventionPlugins.kt @@ -3,10 +3,6 @@ package org.harvestcircle.buildlogic.plugins import org.gradle.api.Plugin import org.gradle.api.Project -public class HarvestCircleKmpSharedPlugin : Plugin<Project> { - override fun apply(target: Project) = Unit -} - public class HarvestCircleDesktopAppPlugin : Plugin<Project> { override fun apply(target: Project) = Unit } diff --git a/build-logic/plugins/src/main/kotlin/org/harvestcircle/buildlogic/plugins/HarvestCircleKmpSharedPlugin.kt b/build-logic/plugins/src/main/kotlin/org/harvestcircle/buildlogic/plugins/HarvestCircleKmpSharedPlugin.kt @@ -0,0 +1,89 @@ +package org.harvestcircle.buildlogic.plugins + +import org.gradle.api.Plugin +import org.gradle.api.Project +import org.gradle.api.artifacts.VersionCatalogsExtension +import org.gradle.api.provider.MapProperty +import org.harvestcircle.buildlogic.contracts.resolveNativeTarget +import org.harvestcircle.buildlogic.plugins.tasks.VerifySharedBoundary +import org.jetbrains.kotlin.gradle.dsl.JvmTarget +import org.jetbrains.kotlin.gradle.dsl.KotlinMultiplatformExtension +import org.jetbrains.kotlin.gradle.plugin.KotlinPlatformType + +public class HarvestCircleKmpSharedPlugin : Plugin<Project> { + override fun apply(target: Project) { + target.pluginManager.apply("org.jetbrains.kotlin.multiplatform") + target.pluginManager.apply("org.jetbrains.compose") + target.pluginManager.apply("org.jetbrains.kotlin.plugin.compose") + target.pluginManager.apply("dev.detekt") + target.pluginManager.apply("org.jlleitschuh.gradle.ktlint") + + val catalog = target.extensions.getByType(VersionCatalogsExtension::class.java).named("libs") + val kotlinVersion = catalog.findVersion("kotlin").get().requiredVersion + val composeVersion = catalog.findVersion("compose").get().requiredVersion + + val ktlintExtension: Any = target.extensions.getByName("ktlint") + @Suppress("UNCHECKED_CAST") + val additionalEditorconfig = + ktlintExtension.javaClass + .getMethod("getAdditionalEditorconfig") + .invoke(ktlintExtension) as MapProperty<String, String> + additionalEditorconfig.set( + mapOf("ktlint_function_naming_ignore_when_annotated_with" to "Composable"), + ) + target.extensions.configure(KotlinMultiplatformExtension::class.java) { kotlin -> + kotlin.jvm("desktop") { jvm -> + jvm.compilerOptions.jvmTarget.set(JvmTarget.JVM_21) + } + kotlin.jvmToolchain(21) + check( + kotlin.targets + .filter { it.platformType != KotlinPlatformType.common } + .map { it.name } == listOf("desktop"), + ) { + "HarvestCircle shared must declare exactly one KMP platform target named desktop" + } + } + + target.dependencies.add("commonMainImplementation", catalog.findLibrary("compose-foundation").get()) + target.dependencies.add("commonMainImplementation", catalog.findLibrary("compose-runtime").get()) + target.dependencies.add("commonMainImplementation", catalog.findLibrary("compose-ui").get()) + target.dependencies.add("commonMainImplementation", catalog.findLibrary("kotlinx-coroutines-core").get()) + target.dependencies.add("commonTestImplementation", "org.jetbrains.kotlin:kotlin-test:$kotlinVersion") + target.dependencies.add("commonTestImplementation", catalog.findLibrary("kotlinx-coroutines-test").get()) + target.dependencies.add("desktopTestImplementation", composeDesktopDependency(target, composeVersion)) + target.dependencies.add("desktopTestImplementation", "org.jetbrains.kotlin:kotlin-test-junit:$kotlinVersion") + target.dependencies.add("desktopTestImplementation", catalog.findLibrary("compose-ui-test-junit4").get()) + + target.providers.environmentVariable("EXT_BUILD_GRADLE_BUILD_DIR").orNull?.let { outputRoot -> + target.layout.buildDirectory.set(target.file(outputRoot).resolve("app-shared")) + } + + val verifySharedBoundary = + target.tasks.register("verifySharedBoundary", VerifySharedBoundary::class.java) { task -> + task.commonSources.from( + target.fileTree("src/commonMain/kotlin") { tree -> tree.include("**/*.kt") }, + ) + } + target.tasks.named("check") { task -> task.dependsOn(verifySharedBoundary) } + } + + private fun composeDesktopDependency( + target: Project, + composeVersion: String, + ): String { + val osName = target.providers.gradleProperty("nativeOs").getOrElse(System.getProperty("os.name")) + val architecture = target.providers.gradleProperty("nativeArch").getOrElse(System.getProperty("os.arch")) + val suffix = + when (resolveNativeTarget(osName, architecture, "harvestcircle_ffi").jnaPrefix) { + "darwin-aarch64" -> "macos-arm64" + "darwin-x86-64" -> "macos-x64" + "linux-aarch64" -> "linux-arm64" + "linux-x86-64" -> "linux-x64" + "win32-aarch64" -> "windows-arm64" + "win32-x86-64" -> "windows-x64" + else -> error("Unsupported Compose desktop host: $osName/$architecture") + } + return "org.jetbrains.compose.desktop:desktop-jvm-$suffix:$composeVersion" + } +} diff --git a/build-logic/plugins/src/main/kotlin/org/harvestcircle/buildlogic/plugins/tasks/FoundationBoundaryAudit.kt b/build-logic/plugins/src/main/kotlin/org/harvestcircle/buildlogic/plugins/tasks/FoundationBoundaryAudit.kt @@ -273,7 +273,7 @@ private class FoundationBoundaryAudit( findings += "core/compatibility/harvestcircle-ffi-v4.properties: FFI v4 identity changed" } val sharedBuild = text("app/shared/build.gradle.kts") - if (Regex("(?m)^\\s*jvm\\(\"desktop\"\\)").findAll(sharedBuild).count() != 1 || + if (!sharedBuild.contains("id(\"org.harvestcircle.build.kmp-shared\")") || listOf("androidTarget", "iosArm", "iosX", "js(", "wasm").any(sharedBuild::contains) ) { findings += "app/shared/build.gradle.kts: shared KMP target boundary changed" diff --git a/build-logic/plugins/src/main/kotlin/org/harvestcircle/buildlogic/plugins/tasks/VerifySharedBoundary.kt b/build-logic/plugins/src/main/kotlin/org/harvestcircle/buildlogic/plugins/tasks/VerifySharedBoundary.kt @@ -0,0 +1,36 @@ +package org.harvestcircle.buildlogic.plugins.tasks + +import org.gradle.api.DefaultTask +import org.gradle.api.file.ConfigurableFileCollection +import org.gradle.api.tasks.InputFiles +import org.gradle.api.tasks.PathSensitive +import org.gradle.api.tasks.PathSensitivity +import org.gradle.api.tasks.TaskAction + +abstract class VerifySharedBoundary : DefaultTask() { + @get:InputFiles + @get:PathSensitive(PathSensitivity.RELATIVE) + abstract val commonSources: ConfigurableFileCollection + + @TaskAction + fun verify() { + val forbidden = + listOf( + "org.harvestcircle." + "ffi", + "com.sun." + "jna", + "java." + "awt", + "javax." + "swing", + "java." + "io", + "java." + "nio", + ) + val findings = + commonSources.files + .filter { it.isFile && it.extension == "kt" } + .flatMap { file -> + forbidden + .filter(file.readText()::contains) + .map { token -> "${file.name}: prohibited common-source dependency $token" } + } + check(findings.isEmpty()) { findings.sorted().joinToString("\n") } + } +} diff --git a/build-logic/settings.gradle.kts b/build-logic/settings.gradle.kts @@ -10,6 +10,7 @@ pluginManagement { dependencyResolutionManagement { repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) repositories { + gradlePluginPortal() mavenCentral() } versionCatalogs { diff --git a/build.gradle.kts b/build.gradle.kts @@ -1,7 +1,3 @@ plugins { id("org.harvestcircle.build.root") - alias(libs.plugins.kotlin.multiplatform) apply false - alias(libs.plugins.kotlin.jvm) apply false - alias(libs.plugins.compose.multiplatform) apply false - alias(libs.plugins.compose.compiler) apply false } diff --git a/buildSrc/src/main/kotlin/org/harvestcircle/gradle/FoundationBoundaryAudit.kt b/buildSrc/src/main/kotlin/org/harvestcircle/gradle/FoundationBoundaryAudit.kt @@ -272,7 +272,7 @@ private class FoundationBoundaryAudit( findings += "core/compatibility/harvestcircle-ffi-v4.properties: FFI v4 identity changed" } val sharedBuild = text("app/shared/build.gradle.kts") - if (Regex("(?m)^\\s*jvm\\(\"desktop\"\\)").findAll(sharedBuild).count() != 1 || + if (!sharedBuild.contains("id(\"org.harvestcircle.build.kmp-shared\")") || listOf("androidTarget", "iosArm", "iosX", "js(", "wasm").any(sharedBuild::contains) ) { findings += "app/shared/build.gradle.kts: shared KMP target boundary changed"