app

Local-first trade for farms and co-ops
git clone https://radroots.dev/git/app.git
Log | Files | Refs | README | LICENSE

commit 477e12e2f55a0affeaf69482009ec23e6283fccf
parent b546e2b48e0d1c14e74cb935d4c9f94138672718
Author: triesap <tyson@radroots.org>
Date:   Mon, 10 Aug 2026 21:08:45 +0000

build: add the desktop application convention plugin

- centralize desktop JVM, Compose, dependency, and quality configuration
- move metadata generation and unit-test inventory into typed tasks
- reserve an integration source-set contract without claiming execution
- qualify application wiring and failure behavior through Gradle TestKit

Diffstat:
Mapp/desktop/build.gradle.kts | 145++-----------------------------------------------------------------------------
Mbuild-logic/plugins/build.gradle.kts | 2++
Mbuild-logic/plugins/src/functionalTest/kotlin/org/harvestcircle/buildlogic/plugins/ConventionPluginSmokeTest.kt | 135+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
Mbuild-logic/plugins/src/main/kotlin/org/harvestcircle/buildlogic/plugins/HarvestCircleConventionPlugins.kt | 4----
Abuild-logic/plugins/src/main/kotlin/org/harvestcircle/buildlogic/plugins/HarvestCircleDesktopAppPlugin.kt | 237+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mbuild-logic/plugins/src/main/kotlin/org/harvestcircle/buildlogic/plugins/HarvestCircleRootPlugin.kt | 5+++++
Abuild-logic/plugins/src/main/kotlin/org/harvestcircle/buildlogic/plugins/tasks/DesktopBuildMetadataTasks.kt | 73+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mbuild-logic/plugins/src/main/kotlin/org/harvestcircle/buildlogic/plugins/tasks/ProductCoordinateConsumers.kt | 11+++++++++--
Abuild-logic/plugins/src/main/kotlin/org/harvestcircle/buildlogic/plugins/tasks/VerifyTestInventory.kt | 27+++++++++++++++++++++++++++
9 files changed, 488 insertions(+), 151 deletions(-)

diff --git a/app/desktop/build.gradle.kts b/app/desktop/build.gradle.kts @@ -1,7 +1,5 @@ -import com.github.jk1.license.filter.SpdxLicenseBundleNormalizer 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.file.RegularFileProperty import org.gradle.api.provider.ListProperty @@ -11,7 +9,6 @@ import org.gradle.api.tasks.Exec import org.gradle.api.tasks.Input import org.gradle.api.tasks.InputDirectory import org.gradle.api.tasks.InputFile -import org.gradle.api.tasks.InputFiles import org.gradle.api.tasks.PathSensitive import org.gradle.api.tasks.PathSensitivity import org.gradle.api.tasks.TaskAction @@ -19,68 +16,22 @@ import org.gradle.api.tasks.testing.Test import org.gradle.jvm.tasks.Jar import org.harvestcircle.gradle.FfiCompatibilityBaseline import org.harvestcircle.gradle.GenerateCompatibilityExpectations -import org.harvestcircle.gradle.GenerateDesktopBuildMetadata import org.harvestcircle.gradle.ProductCoordinates import org.harvestcircle.gradle.VerifyGeneratedCompatibilityExpectations -import org.harvestcircle.gradle.VerifyGeneratedDesktopBuildMetadata import org.jetbrains.compose.desktop.application.dsl.TargetFormat -import org.jetbrains.kotlin.gradle.dsl.JvmTarget import org.jetbrains.kotlin.gradle.tasks.KotlinCompile import java.io.File import java.security.MessageDigest import java.util.jar.JarFile plugins { - 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) -} - -licenseReport { - projects = arrayOf(project) - configurations = arrayOf("runtimeClasspath") - excludeGroups = arrayOf("harvestcircle.app") - filters = arrayOf(SpdxLicenseBundleNormalizer()) - allowedLicensesFile = rootProject.layout.projectDirectory.file("config/licenses/allowed-licenses.json") -} - -dependencyCheck { - failBuildOnCVSS = 0.0F - failOnError = true - formats = listOf("HTML", "JSON") - scanConfigurations = listOf("runtimeClasspath") - skipTestGroups = true - providers.environmentVariable("NVD_API_KEY").orNull?.takeIf(String::isNotBlank)?.let { - nvd.apiKey = it - } -} - -tasks.matching { it.name.startsWith("dependencyCheck") }.configureEach { - notCompatibleWithConfigurationCache("Advisory data and environment-only credentials must not be cached") -} - -configure<org.jlleitschuh.gradle.ktlint.KtlintExtension> { - additionalEditorconfig.set( - mapOf( - "ktlint_function_naming_ignore_when_annotated_with" to "Composable", - ), - ) - filter { - exclude("**/generated/uniffi/**") - } + id("org.harvestcircle.build.desktop-app") } val rustCoreSource = rootProject.layout.projectDirectory .dir("core") .asFile -providers.environmentVariable("EXT_BUILD_GRADLE_BUILD_DIR").orNull?.let { extBuildGradleRoot -> - layout.buildDirectory.set(file(extBuildGradleRoot).resolve("app-desktop")) -} val cargoTargetRoot = providers.environmentVariable("CARGO_TARGET_DIR").orNull?.let(::file) ?: rustCoreSource.resolve("target") @@ -137,7 +88,6 @@ check(Regex("""[1-9]\d*(\.\d+){0,2}""").matches(installableVersion)) { val applicationName = productCoordinates["product.name"] val productSlug = productCoordinates["product.slug"] val bundleId = productCoordinates["desktop.bundle_id"] -val desktopMainClass = productCoordinates["desktop.main_class"] val ffiKotlinPackage = productCoordinates["ffi.kotlin_package"] val environmentPrefix = productCoordinates["environment.prefix"] @@ -146,9 +96,6 @@ val developmentDataDirectoryEnvironment = productEnvironment("DEVELOPMENT_DATA_DIR") val copyrightNotice = productCoordinates["copyright.notice"] val vendorName = productCoordinates["vendor.name"] -group = productCoordinates["desktop.application_id"] -version = appVersion - val rustSources = fileTree(rustCoreSource) { include( @@ -292,34 +239,12 @@ val generatedCompatibilityFile = generatedCompatibilityKotlin.map { it.file("org/harvestcircle/application/generated/NativeCompatibilityExpectations.kt") } -val generatedDesktopBuildMetadataFile = - generatedCompatibilityKotlin.map { - it.file("org/harvestcircle/application/generated/DesktopBuildMetadata.kt") - } - -fun org.harvestcircle.gradle.DesktopBuildMetadataTask.configureDesktopBuildMetadata() { - productVersion.set(appVersion) - distributionPackageVersion.set(installableVersion) - gradleToolchain.set(gradle.gradleVersion) - javaToolchain.set(System.getProperty("java.version")) - kotlinToolchain.set(libs.versions.kotlin.get()) - composeMultiplatformVersion.set(libs.versions.compose.get()) -} -val generateDesktopBuildMetadata by tasks.registering(GenerateDesktopBuildMetadata::class) { - configureDesktopBuildMetadata() - outputFile.set(generatedDesktopBuildMetadataFile) -} val generateCompatibilityExpectations by tasks.registering(GenerateCompatibilityExpectations::class) { baselineFile.set(ffiCompatibilityBaselineFile) outputFile.set(generatedCompatibilityFile) } -val verifyGeneratedDesktopBuildMetadata by tasks.registering(VerifyGeneratedDesktopBuildMetadata::class) { - dependsOn(generateDesktopBuildMetadata) - configureDesktopBuildMetadata() - generatedFile.set(generatedDesktopBuildMetadataFile) -} val verifyGeneratedSources by tasks.registering(VerifyGeneratedCompatibilityExpectations::class) { - dependsOn(generateCompatibilityExpectations, verifyGeneratedDesktopBuildMetadata) + dependsOn(generateCompatibilityExpectations) baselineFile.set(ffiCompatibilityBaselineFile) generatedFile.set(generatedCompatibilityFile) } @@ -758,77 +683,15 @@ abstract class VerifyMacOsNotarization : DefaultTask() { } } -dependencies { - implementation(projects.app.shared) - implementation(compose.desktop.currentOs) - implementation(libs.compose.foundation) - implementation(libs.jna) - implementation(libs.kotlinx.coroutines.core) - - testImplementation(kotlin("test-junit")) - testImplementation(libs.compose.ui.test.junit4) - testImplementation(libs.kotlinx.coroutines.test) -} - -val testInventoryRoot = - providers - .gradleProperty("testInventoryRoot") - .orElse("src/test/kotlin") -val expectedTests = - fileTree(testInventoryRoot.get()) { - include("**/*Test.kt") - } - -abstract class VerifyTestInventory : DefaultTask() { - @get:InputFiles - @get:PathSensitive(PathSensitivity.RELATIVE) - abstract val testFiles: ConfigurableFileCollection - - @get:Input - abstract val sourceRoot: Property<String> - - @TaskAction - fun verify() { - if (testFiles.isEmpty) { - throw GradleException("No Kotlin tests found under ${sourceRoot.get()}") - } - } -} -val verifyTestInventory by tasks.registering(VerifyTestInventory::class) { - testFiles.from(expectedTests) - sourceRoot.set(testInventoryRoot) -} - -tasks.withType<Test>().configureEach { - dependsOn(verifyTestInventory) - failOnNoDiscoveredTests.set(true) -} - tasks.named("check") { - dependsOn(rootProject.tasks.named("verifyProductCoordinates")) - dependsOn(rootProject.tasks.named("verifyProductCoordinateConsumers")) dependsOn(verifyGeneratedSources) dependsOn(verifyDesktopBuildMetadataArtifact) } -kotlin { - compilerOptions { - jvmTarget.set(JvmTarget.JVM_21) - } - - jvmToolchain(21) -} - tasks.named<KotlinCompile>("compileKotlin") { - dependsOn(generateUniFfiKotlin, generateCompatibilityExpectations, generateDesktopBuildMetadata) + dependsOn(generateUniFfiKotlin, generateCompatibilityExpectations) source(generatedUniFfiKotlin, generatedCompatibilityKotlin) } -tasks.named("runKtlintCheckOverMainSourceSet") { - dependsOn(generateUniFfiKotlin) -} -tasks.named("runKtlintFormatOverMainSourceSet") { - dependsOn(generateUniFfiKotlin) -} tasks.withType<Test>().configureEach { dependsOn(buildRustCoreDebug) val nativeTestData = @@ -863,8 +726,6 @@ compose.desktop { val desktopJar = tasks.named<Jar>("jar").flatMap { it.archiveFile } mainJar.set(desktopJar) fromFiles(desktopJar, configurations.runtimeClasspath, releaseNativeRuntimeJar) - mainClass = desktopMainClass - if (isMacOsHost) { jvmArgs += listOf( diff --git a/build-logic/plugins/build.gradle.kts b/build-logic/plugins/build.gradle.kts @@ -17,6 +17,8 @@ dependencies { 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()}") + implementation("com.github.jk1:gradle-license-report:${libs.versions.license.report.get()}") + implementation("org.owasp:dependency-check-gradle:${libs.versions.owasp.dependency.check.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,14 +23,27 @@ class ConventionPluginSmokeTest { pluginIds.forEach { pluginId -> val fixture = temporaryDirectory.resolve(pluginId.substringAfterLast('.')).createDirectories() + val desktopFixture = pluginId == "org.harvestcircle.build.desktop-app" fixture.resolve("settings.gradle.kts").writeText( - "pluginManagement { repositories { gradlePluginPortal(); mavenCentral() } }\nrootProject.name = \"fixture\"\n", + buildString { + append("pluginManagement { repositories { gradlePluginPortal(); mavenCentral() } }\n") + append("dependencyResolutionManagement { repositories { mavenCentral() } }\n") + append("rootProject.name = \"fixture\"\n") + if (desktopFixture) append("include(\":app:shared\", \":app:desktop\")\n") + }, ) - if (pluginId == "org.harvestcircle.build.kmp-shared") { + if (pluginId in setOf("org.harvestcircle.build.kmp-shared", "org.harvestcircle.build.desktop-app")) { fixture.resolve("gradle").createDirectories().resolve("libs.versions.toml").writeText(kmpCatalog) } + val buildFile = + if (desktopFixture) { + prepareDesktopFixture(fixture) + fixture.resolve("app/desktop/build.gradle.kts") + } else { + fixture.resolve("build.gradle.kts") + } val pluginBlock = if (pluginId == "org.harvestcircle.build.kmp-shared") kmpPlugins else "id(\"$pluginId\")" - fixture.resolve("build.gradle.kts").writeText("plugins { $pluginBlock }\n") + buildFile.writeText("plugins { $pluginBlock }\n") val runner = GradleRunner.create() @@ -47,6 +60,26 @@ class ConventionPluginSmokeTest { } } + private fun prepareDesktopFixture( + fixture: java.nio.file.Path, + withUnitTest: Boolean = true, + ) { + fixture.resolve("build.gradle.kts").writeText("plugins { id(\"org.harvestcircle.build.root\") }\n") + fixture.resolve("app/shared").createDirectories().resolve("build.gradle.kts").writeText("plugins { `java-library` }\n") + fixture.resolve("app/desktop").createDirectories() + fixture.resolve("config/product").createDirectories().resolve("harvestcircle-v1.properties").writeText(productCoordinates) + fixture.resolve("config/licenses").createDirectories().resolve("allowed-licenses.json").writeText("{}\n") + fixture.resolve("core/compatibility").createDirectories().resolve("harvestcircle-ffi-v4.properties").writeText(ffiBaseline) + fixture.resolve("core").resolve("Cargo.toml").writeText( + "[workspace.package]\nversion = \"0.1.0-alpha\"\n", + ) + if (withUnitTest) { + fixture.resolve("app/desktop/src/test/kotlin").createDirectories().resolve("FixtureTest.kt").writeText( + "class FixtureTest\n", + ) + } + } + @Test fun rootPluginRejectsApplicationToASubproject() { val fixture = createTempDirectory("harvestcircle-root-plugin-") @@ -90,6 +123,63 @@ class ConventionPluginSmokeTest { assertTrue(result.output.contains("prohibited common-source dependency"), result.output) } + @Test + fun desktopPluginPublishesTheApplicationContractWithoutClaimingIntegrationExecution() { + val fixture = createTempDirectory("harvestcircle-desktop-plugin-") + prepareDesktopBuild(fixture, withUnitTest = true) + + val result = + GradleRunner.create() + .withProjectDir(fixture.toFile()) + .withPluginClasspath() + .withArguments(":app:desktop:tasks", "--all", "--stacktrace") + .build() + + listOf( + "checkLicense", + "dependencyCheckAnalyze", + "generateDesktopBuildMetadata", + "verifyGeneratedDesktopBuildMetadata", + "verifyTestInventory", + "compileIntegrationTestKotlin", + ).forEach { taskName -> assertTrue(result.output.contains(taskName), result.output) } + assertTrue(!result.output.lineSequence().any { it.startsWith("integrationTest -") }, result.output) + } + + @Test + fun desktopPluginRejectsAnEmptyUnitTestInventory() { + val fixture = createTempDirectory("harvestcircle-desktop-no-tests-") + prepareDesktopBuild(fixture, withUnitTest = false) + + val result = + GradleRunner.create() + .withProjectDir(fixture.toFile()) + .withPluginClasspath() + .withArguments(":app:desktop:verifyTestInventory", "--stacktrace") + .buildAndFail() + + assertTrue(result.output.contains("No Kotlin tests found under src/test/kotlin"), result.output) + } + + private fun prepareDesktopBuild( + fixture: java.nio.file.Path, + withUnitTest: Boolean, + ) { + fixture.resolve("settings.gradle.kts").writeText( + """ + pluginManagement { repositories { gradlePluginPortal(); mavenCentral() } } + dependencyResolutionManagement { repositories { mavenCentral() } } + rootProject.name = "fixture" + include(":app:shared", ":app:desktop") + """.trimIndent() + "\n", + ) + fixture.resolve("gradle").createDirectories().resolve("libs.versions.toml").writeText(kmpCatalog) + prepareDesktopFixture(fixture, withUnitTest) + fixture.resolve("app/desktop/build.gradle.kts").writeText( + "plugins { id(\"org.harvestcircle.build.desktop-app\") }\n", + ) + } + private val kmpCatalog = """ [versions] @@ -104,10 +194,49 @@ class ConventionPluginSmokeTest { 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" } + jna = { module = "net.java.dev.jna:jna", version = "5.17.0" } """.trimIndent() + "\n" private val kmpPlugins = """ id("org.harvestcircle.build.kmp-shared") """.trimIndent() + + private val productCoordinates = + """ + schema=harvestcircle.product.v1 + product.name=HarvestCircle + product.slug=harvestcircle + kotlin.root_namespace=org.harvestcircle + desktop.application_id=org.harvestcircle.desktop + desktop.bundle_id=org.harvestcircle.desktop + desktop.main_class=org.harvestcircle.desktop.MainKt + ffi.kotlin_package=org.harvestcircle.ffi + ffi.cdylib_name=harvestcircle_ffi + database.qualifier=org + database.organization=harvestcircle + database.application=desktop + database.filename=harvestcircle.sqlite3 + keyring.service=org.harvestcircle.desktop.nostr + environment.prefix=HARVESTCIRCLE_ + vendor.name=Radroots Labs + copyright.notice=Copyright © 2026 HarvestCircle contributors + """.trimIndent() + "\n" + + private val ffiBaseline = + """ + schema=harvestcircle.ffi.v4 + contract.id=harvestcircle-desktop-ffi-v4 + contract.major=4 + contract.minor=1 + contract.hash=c7a84960e53cd9df35d676bab28294eb048a8b86c766d81cded2635b64a7f3d6 + product.coordinate_digest=93bf10e334e989b20ba5fb8ed05e5d55b83f4502efba5f893aef4dc1a66c8223 + snapshot.schema=1 + storage.schema.minimum=5 + storage.schema.current=10 + product.version=0.1.0-alpha + package.version=1.0.0 + source.provenance_digest=db238195b4a5938a8d4d9ac5681c4b125e65c57aa8133ad03e59da4e4bd062bc + source.foundation_baseline=a2038b3e25b9e34f0b8fd001f26a8ed10b5772cb + """.trimIndent() + "\n" } 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 HarvestCircleDesktopAppPlugin : Plugin<Project> { - override fun apply(target: Project) = Unit -} - public class HarvestCircleRustFfiPlugin : Plugin<Project> { override fun apply(target: Project) = Unit } diff --git a/build-logic/plugins/src/main/kotlin/org/harvestcircle/buildlogic/plugins/HarvestCircleDesktopAppPlugin.kt b/build-logic/plugins/src/main/kotlin/org/harvestcircle/buildlogic/plugins/HarvestCircleDesktopAppPlugin.kt @@ -0,0 +1,237 @@ +package org.harvestcircle.buildlogic.plugins + +import com.github.jk1.license.LicenseReportExtension +import com.github.jk1.license.filter.SpdxLicenseBundleNormalizer +import org.gradle.api.Plugin +import org.gradle.api.Project +import org.gradle.api.artifacts.VersionCatalogsExtension +import org.gradle.api.file.ConfigurableFileCollection +import org.gradle.api.plugins.ExtensionAware +import org.gradle.api.tasks.SourceSetContainer +import org.gradle.api.tasks.testing.Test +import org.harvestcircle.buildlogic.contracts.FfiCompatibilityBaseline +import org.harvestcircle.buildlogic.contracts.ProductCoordinates +import org.harvestcircle.buildlogic.contracts.resolveNativeTarget +import org.harvestcircle.buildlogic.plugins.tasks.DesktopBuildMetadataTask +import org.harvestcircle.buildlogic.plugins.tasks.GenerateDesktopBuildMetadata +import org.harvestcircle.buildlogic.plugins.tasks.VerifyGeneratedDesktopBuildMetadata +import org.harvestcircle.buildlogic.plugins.tasks.VerifyTestInventory +import org.jetbrains.compose.ComposeExtension +import org.jetbrains.compose.desktop.DesktopExtension +import org.jetbrains.kotlin.gradle.dsl.JvmTarget +import org.jetbrains.kotlin.gradle.dsl.KotlinJvmProjectExtension +import org.jetbrains.kotlin.gradle.tasks.KotlinCompile +import org.owasp.dependencycheck.gradle.extension.DependencyCheckExtension + +public abstract class HarvestCircleDesktopAppExtension { + public abstract val generatedKotlinSources: ConfigurableFileCollection +} + +public class HarvestCircleDesktopAppPlugin : Plugin<Project> { + override fun apply(target: Project) { + require(target != target.rootProject) { + "HarvestCircle desktop application convention must be applied to a subproject" + } + target.pluginManager.apply("org.jetbrains.kotlin.jvm") + 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") + target.pluginManager.apply("com.github.jk1.dependency-license-report") + target.pluginManager.apply("org.owasp.dependencycheck") + + val catalog = target.extensions.getByType(VersionCatalogsExtension::class.java).named("libs") + val coordinatesFile = target.rootProject.layout.projectDirectory.file("config/product/harvestcircle-v1.properties") + val productCoordinates = ProductCoordinates.load(coordinatesFile.asFile) + val cargoManifest = target.rootProject.layout.projectDirectory.file("core/Cargo.toml").asFile + val baseline = + FfiCompatibilityBaseline.load( + target.rootProject.layout.projectDirectory + .file("core/compatibility/harvestcircle-ffi-v4.properties") + .asFile, + ) + val appVersion = workspacePackageValue(cargoManifest.readText(), "version") + check(baseline["product.version"] == appVersion) { + "FFI compatibility product version must match the Cargo workspace version" + } + check(baseline["product.coordinate_digest"] == productCoordinates.digest) { + "FFI compatibility product-coordinate digest is stale" + } + + target.group = productCoordinates["desktop.application_id"] + target.version = appVersion + target.providers.environmentVariable("EXT_BUILD_GRADLE_BUILD_DIR").orNull?.let { outputRoot -> + target.layout.buildDirectory.set(target.file(outputRoot).resolve("app-desktop")) + } + + configureKtlint(target) + configureQuality(target) + configureKotlin(target) + configureDependencies(target, catalog) + configureIntegrationContract(target) + configureTests(target) + configureMetadata(target, catalog, appVersion, baseline["package.version"]) + configureCompose(target, productCoordinates["desktop.main_class"]) + } + + private fun configureKtlint(target: Project) { + val ktlintExtension: Any = target.extensions.getByName("ktlint") + @Suppress("UNCHECKED_CAST") + val property = + ktlintExtension.javaClass + .getMethod("getAdditionalEditorconfig") + .invoke(ktlintExtension) as org.gradle.api.provider.MapProperty<String, String> + property.set(mapOf("ktlint_function_naming_ignore_when_annotated_with" to "Composable")) + } + + private fun configureQuality(target: Project) { + target.extensions.configure(LicenseReportExtension::class.java) { extension -> + extension.projects = arrayOf(target) + extension.configurations = arrayOf("runtimeClasspath") + extension.excludeGroups = arrayOf("harvestcircle.app") + extension.filters = arrayOf(SpdxLicenseBundleNormalizer()) + extension.allowedLicensesFile = + target.rootProject.layout.projectDirectory.file("config/licenses/allowed-licenses.json") + } + target.extensions.configure(DependencyCheckExtension::class.java) { extension -> + extension.failBuildOnCVSS.set(0.0F) + extension.failOnError.set(true) + extension.formats.set(listOf("HTML", "JSON")) + extension.scanConfigurations.set(listOf("runtimeClasspath")) + extension.skipTestGroups.set(true) + target.providers.environmentVariable("NVD_API_KEY").orNull?.takeIf(String::isNotBlank)?.let { + extension.nvd.apiKey.set(it) + } + } + target.tasks.matching { it.name.startsWith("dependencyCheck") }.configureEach { + it.notCompatibleWithConfigurationCache("Advisory data and environment-only credentials must not be cached") + } + } + + private fun configureKotlin(target: Project) { + target.extensions.configure(KotlinJvmProjectExtension::class.java) { kotlin -> + kotlin.compilerOptions.jvmTarget.set(JvmTarget.JVM_21) + kotlin.jvmToolchain(21) + } + } + + private fun configureDependencies( + target: Project, + catalog: org.gradle.api.artifacts.VersionCatalog, + ) { + target.dependencies.add("implementation", target.project(":app:shared")) + val composeVersion = catalog.findVersion("compose").get().requiredVersion + 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") + } + target.dependencies.add("implementation", "org.jetbrains.compose.desktop:desktop-jvm-$suffix:$composeVersion") + target.dependencies.add("implementation", catalog.findLibrary("compose-foundation").get()) + target.dependencies.add("implementation", catalog.findLibrary("jna").get()) + target.dependencies.add("implementation", catalog.findLibrary("kotlinx-coroutines-core").get()) + target.dependencies.add( + "testImplementation", + "org.jetbrains.kotlin:kotlin-test-junit:${catalog.findVersion("kotlin").get().requiredVersion}", + ) + target.dependencies.add("testImplementation", catalog.findLibrary("compose-ui-test-junit4").get()) + target.dependencies.add("testImplementation", catalog.findLibrary("kotlinx-coroutines-test").get()) + } + + private fun configureIntegrationContract(target: Project) { + val sourceSets = target.extensions.getByType(SourceSetContainer::class.java) + sourceSets.maybeCreate("integrationTest") + target.configurations.named("integrationTestImplementation") { + it.extendsFrom(target.configurations.getByName("testImplementation")) + } + target.configurations.named("integrationTestRuntimeOnly") { + it.extendsFrom(target.configurations.getByName("testRuntimeOnly")) + } + } + + private fun configureTests(target: Project) { + val sourceRoot = target.providers.gradleProperty("testInventoryRoot").orElse("src/test/kotlin") + val inventory = + target.tasks.register("verifyTestInventory", VerifyTestInventory::class.java) { task -> + task.testFiles.from(target.fileTree(sourceRoot.get()) { it.include("**/*Test.kt") }) + task.sourceRoot.set(sourceRoot) + } + target.tasks.withType(Test::class.java).configureEach { task -> + task.dependsOn(inventory) + task.failOnNoDiscoveredTests.set(true) + } + } + + private fun configureMetadata( + target: Project, + catalog: org.gradle.api.artifacts.VersionCatalog, + appVersion: String, + packageVersion: String, + ) { + val generatedRoot = target.layout.buildDirectory.dir("generated/compatibility/kotlin") + val metadataFile = + generatedRoot.map { it.file("org/harvestcircle/application/generated/DesktopBuildMetadata.kt") } + fun DesktopBuildMetadataTask.configureValues() { + productVersion.set(appVersion) + distributionPackageVersion.set(packageVersion) + gradleToolchain.set(target.gradle.gradleVersion) + javaToolchain.set(System.getProperty("java.version")) + kotlinToolchain.set(catalog.findVersion("kotlin").get().requiredVersion) + composeMultiplatformVersion.set(catalog.findVersion("compose").get().requiredVersion) + } + val generate = + target.tasks.register("generateDesktopBuildMetadata", GenerateDesktopBuildMetadata::class.java) { task -> + task.configureValues() + task.outputFile.set(metadataFile) + } + val verify = + target.tasks.register( + "verifyGeneratedDesktopBuildMetadata", + VerifyGeneratedDesktopBuildMetadata::class.java, + ) { task -> + task.dependsOn(generate) + task.configureValues() + task.generatedFile.set(metadataFile) + } + val extension = target.extensions.create("harvestCircleDesktop", HarvestCircleDesktopAppExtension::class.java) + extension.generatedKotlinSources.from(generatedRoot) + target.tasks.named("compileKotlin", KotlinCompile::class.java) { task -> + task.dependsOn(generate) + task.source(extension.generatedKotlinSources) + } + target.tasks.named("check") { task -> + task.dependsOn(target.rootProject.tasks.named("verifyProductCoordinates")) + task.dependsOn(target.rootProject.tasks.named("verifyProductCoordinateConsumers")) + task.dependsOn(verify) + } + } + + private fun configureCompose( + target: Project, + mainClass: String, + ) { + val compose = target.extensions.getByType(ComposeExtension::class.java) + val desktop = (compose as ExtensionAware).extensions.getByType(DesktopExtension::class.java) + desktop.application { application -> application.mainClass = mainClass } + } + + private fun workspacePackageValue( + manifest: String, + key: String, + ): String { + val workspacePackage = manifest.substringAfter("[workspace.package]", "").substringBefore("\n[") + require(workspacePackage.isNotBlank()) { "Cargo manifest is missing [workspace.package]" } + return Regex("(?m)^${Regex.escape(key)}\\s*=\\s*\"([^\"]+)\"\\s*$") + .find(workspacePackage) + ?.groupValues + ?.get(1) + ?: error("Cargo workspace package metadata is missing $key") + } +} diff --git a/build-logic/plugins/src/main/kotlin/org/harvestcircle/buildlogic/plugins/HarvestCircleRootPlugin.kt b/build-logic/plugins/src/main/kotlin/org/harvestcircle/buildlogic/plugins/HarvestCircleRootPlugin.kt @@ -52,6 +52,11 @@ public class HarvestCircleRootPlugin : Plugin<Project> { task.description = "Validates that build and runtime identities consume the product manifest." task.manifestFile.set(productCoordinatesFile) task.desktopBuildFile.set(target.layout.projectDirectory.file("app/desktop/build.gradle.kts")) + task.desktopPluginFile.set( + target.layout.projectDirectory.file( + "build-logic/plugins/src/main/kotlin/org/harvestcircle/buildlogic/plugins/HarvestCircleDesktopAppPlugin.kt", + ), + ) task.uniFfiConfigFile.set(target.layout.projectDirectory.file("core/crates/harvestcircle_ffi/uniffi.toml")) task.productBuildFile.set(target.layout.projectDirectory.file("core/crates/harvestcircle_product/build.rs")) task.ffiConsumerFile.set(target.layout.projectDirectory.file("core/crates/harvestcircle_ffi/src/commands.rs")) diff --git a/build-logic/plugins/src/main/kotlin/org/harvestcircle/buildlogic/plugins/tasks/DesktopBuildMetadataTasks.kt b/build-logic/plugins/src/main/kotlin/org/harvestcircle/buildlogic/plugins/tasks/DesktopBuildMetadataTasks.kt @@ -0,0 +1,73 @@ +package org.harvestcircle.buildlogic.plugins.tasks + +import org.gradle.api.DefaultTask +import org.gradle.api.file.RegularFileProperty +import org.gradle.api.provider.Property +import org.gradle.api.tasks.CacheableTask +import org.gradle.api.tasks.Input +import org.gradle.api.tasks.InputFile +import org.gradle.api.tasks.OutputFile +import org.gradle.api.tasks.PathSensitive +import org.gradle.api.tasks.PathSensitivity +import org.gradle.api.tasks.TaskAction +import org.harvestcircle.buildlogic.contracts.DesktopBuildMetadataValues +import org.harvestcircle.buildlogic.contracts.GeneratedKotlin + +public abstract class DesktopBuildMetadataTask : DefaultTask() { + @get:Input + public abstract val productVersion: Property<String> + + @get:Input + public abstract val distributionPackageVersion: Property<String> + + @get:Input + public abstract val gradleToolchain: Property<String> + + @get:Input + public abstract val javaToolchain: Property<String> + + @get:Input + public abstract val kotlinToolchain: Property<String> + + @get:Input + public abstract val composeMultiplatformVersion: Property<String> + + protected fun values(): DesktopBuildMetadataValues = + DesktopBuildMetadataValues( + productVersion = productVersion.get(), + distributionPackageVersion = distributionPackageVersion.get(), + gradleToolchain = gradleToolchain.get(), + javaToolchain = javaToolchain.get(), + kotlinToolchain = kotlinToolchain.get(), + composeMultiplatformVersion = composeMultiplatformVersion.get(), + ) +} + +@CacheableTask +public abstract class GenerateDesktopBuildMetadata : DesktopBuildMetadataTask() { + @get:OutputFile + public abstract val outputFile: RegularFileProperty + + @TaskAction + public fun generate() { + val output = outputFile.get().asFile + output.parentFile.mkdirs() + output.writeText(GeneratedKotlin.desktopBuildMetadata(values())) + } +} + +@CacheableTask +public abstract class VerifyGeneratedDesktopBuildMetadata : DesktopBuildMetadataTask() { + @get:InputFile + @get:PathSensitive(PathSensitivity.RELATIVE) + public abstract val generatedFile: RegularFileProperty + + @TaskAction + public fun verify() { + GeneratedKotlin.requireFresh( + generatedFile.get().asFile.takeIf { it.isFile }?.readText(), + GeneratedKotlin.desktopBuildMetadata(values()), + "Generated desktop build metadata", + ) + } +} diff --git a/build-logic/plugins/src/main/kotlin/org/harvestcircle/buildlogic/plugins/tasks/ProductCoordinateConsumers.kt b/build-logic/plugins/src/main/kotlin/org/harvestcircle/buildlogic/plugins/tasks/ProductCoordinateConsumers.kt @@ -19,6 +19,10 @@ abstract class VerifyProductCoordinateConsumers : DefaultTask() { @get:InputFile @get:PathSensitive(PathSensitivity.RELATIVE) + abstract val desktopPluginFile: RegularFileProperty + + @get:InputFile + @get:PathSensitive(PathSensitivity.RELATIVE) abstract val uniFfiConfigFile: RegularFileProperty @get:InputFile @@ -36,7 +40,10 @@ abstract class VerifyProductCoordinateConsumers : DefaultTask() { @TaskAction fun verify() { val coordinates = ProductCoordinates.load(manifestFile.get().asFile) - val desktopBuild = desktopBuildFile.get().asFile.readText() + val desktopBuild = + desktopBuildFile.get().asFile.readText() + + "\n" + + desktopPluginFile.get().asFile.readText() listOf( "product.name", "product.slug", @@ -50,7 +57,7 @@ abstract class VerifyProductCoordinateConsumers : DefaultTask() { "copyright.notice", ).forEach { key -> check(desktopBuild.contains("productCoordinates[\"$key\"]")) { - "Desktop build does not consume product coordinate $key" + "Desktop build logic does not consume product coordinate $key" } } listOf( diff --git a/build-logic/plugins/src/main/kotlin/org/harvestcircle/buildlogic/plugins/tasks/VerifyTestInventory.kt b/build-logic/plugins/src/main/kotlin/org/harvestcircle/buildlogic/plugins/tasks/VerifyTestInventory.kt @@ -0,0 +1,27 @@ +package org.harvestcircle.buildlogic.plugins.tasks + +import org.gradle.api.DefaultTask +import org.gradle.api.GradleException +import org.gradle.api.file.ConfigurableFileCollection +import org.gradle.api.provider.Property +import org.gradle.api.tasks.Input +import org.gradle.api.tasks.InputFiles +import org.gradle.api.tasks.PathSensitive +import org.gradle.api.tasks.PathSensitivity +import org.gradle.api.tasks.TaskAction + +public abstract class VerifyTestInventory : DefaultTask() { + @get:InputFiles + @get:PathSensitive(PathSensitivity.RELATIVE) + public abstract val testFiles: ConfigurableFileCollection + + @get:Input + public abstract val sourceRoot: Property<String> + + @TaskAction + public fun verify() { + if (testFiles.isEmpty) { + throw GradleException("No Kotlin tests found under ${sourceRoot.get()}") + } + } +}