app

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

commit a6c7eb3daee5d7ca86386a1dba0ba57434085de1
parent f00a0c900f960d0df4013d5794e7715b7c560af5
Author: triesap <tyson@radroots.org>
Date:   Mon, 10 Aug 2026 17:16:15 +0000

ffi: generate Kotlin compatibility expectations

- render typed Kotlin expectations from the checked-in FFI baseline
- compile generated compatibility source from ignored build output
- keep handwritten runtime code limited to compatibility comparisons
- verify clean generation, freshness, malformed inputs, and field mismatches

Diffstat:
Mapp/desktop/build.gradle.kts | 21+++++++++++++++++++--
Mapp/desktop/src/main/kotlin/org/harvestcircle/application/NativeCompatibility.kt | 57++++++++++++++++++++++-----------------------------------
Mapp/desktop/src/test/kotlin/org/harvestcircle/application/NativeCompatibilityTest.kt | 37++++++++++++++++++++++---------------
AbuildSrc/src/main/kotlin/org/harvestcircle/gradle/CompatibilityExpectations.kt | 98+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
MbuildSrc/src/main/kotlin/org/harvestcircle/gradle/FfiCompatibilityBaseline.kt | 10+++++++++-
MbuildSrc/src/main/kotlin/org/harvestcircle/gradle/FoundationBoundaryAudit.kt | 1+
MbuildSrc/src/main/kotlin/org/harvestcircle/gradle/ProductCoordinates.kt | 36++++++++++++++++++++++++++++--------
7 files changed, 199 insertions(+), 61 deletions(-)

diff --git a/app/desktop/build.gradle.kts b/app/desktop/build.gradle.kts @@ -18,7 +18,9 @@ import org.gradle.api.tasks.TaskAction 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.ProductCoordinates +import org.harvestcircle.gradle.VerifyGeneratedCompatibilityExpectations import org.jetbrains.compose.desktop.application.dsl.TargetFormat import org.jetbrains.kotlin.gradle.dsl.JvmTarget import org.jetbrains.kotlin.gradle.tasks.KotlinCompile @@ -283,6 +285,20 @@ val buildRustCoreRelease by tasks.registering(Exec::class) { } val generatedUniFfiKotlin = layout.buildDirectory.dir("generated/uniffi/kotlin") +val generatedCompatibilityKotlin = layout.buildDirectory.dir("generated/compatibility/kotlin") +val generatedCompatibilityFile = + generatedCompatibilityKotlin.map { + it.file("org/harvestcircle/application/generated/NativeCompatibilityExpectations.kt") + } +val generateCompatibilityExpectations by tasks.registering(GenerateCompatibilityExpectations::class) { + baselineFile.set(ffiCompatibilityBaselineFile) + outputFile.set(generatedCompatibilityFile) +} +val verifyGeneratedSources by tasks.registering(VerifyGeneratedCompatibilityExpectations::class) { + dependsOn(generateCompatibilityExpectations) + baselineFile.set(ffiCompatibilityBaselineFile) + generatedFile.set(generatedCompatibilityFile) +} val generatedReleaseNativeResources = layout.buildDirectory.dir("generated/uniffi/release-native-resources") val cleanGeneratedUniFfiKotlin by tasks.registering(Delete::class) { delete(generatedUniFfiKotlin) @@ -729,6 +745,7 @@ tasks.withType<Test>().configureEach { tasks.named("check") { dependsOn(rootProject.tasks.named("verifyProductCoordinates")) dependsOn(rootProject.tasks.named("verifyProductCoordinateConsumers")) + dependsOn(verifyGeneratedSources) } kotlin { @@ -740,8 +757,8 @@ kotlin { } tasks.named<KotlinCompile>("compileKotlin") { - dependsOn(generateUniFfiKotlin) - source(generatedUniFfiKotlin) + dependsOn(generateUniFfiKotlin, generateCompatibilityExpectations) + source(generatedUniFfiKotlin, generatedCompatibilityKotlin) } tasks.named("runKtlintCheckOverMainSourceSet") { dependsOn(generateUniFfiKotlin) diff --git a/app/desktop/src/main/kotlin/org/harvestcircle/application/NativeCompatibility.kt b/app/desktop/src/main/kotlin/org/harvestcircle/application/NativeCompatibility.kt @@ -2,20 +2,7 @@ package org.harvestcircle.application import org.harvestcircle.ffi.CompatibilityDescriptor import org.harvestcircle.ffi.CompatibilityExpectation - -internal const val EXPECTED_FFI_CONTRACT_ID = "harvestcircle-desktop-ffi-v4" -internal const val EXPECTED_PRODUCT_VERSION = "0.1.0-alpha" -internal const val EXPECTED_CARGO_PACKAGE_VERSION = "0.1.0-alpha" -internal const val EXPECTED_DISTRIBUTION_PACKAGE_VERSION = "1.0.0" -internal const val EXPECTED_PRODUCT_COORDINATE_DIGEST = "93bf10e334e989b20ba5fb8ed05e5d55b83f4502efba5f893aef4dc1a66c8223" -internal const val EXPECTED_SOURCE_PROVENANCE_DIGEST = "db238195b4a5938a8d4d9ac5681c4b125e65c57aa8133ad03e59da4e4bd062bc" -internal const val EXPECTED_SOURCE_FOUNDATION_BASELINE = "a2038b3e25b9e34f0b8fd001f26a8ed10b5772cb" -internal const val EXPECTED_FFI_CONTRACT_HASH = "565f25d8a3ddf418b06a320c92284455ec4d8b82886cde8609a93bbb2486c3a4" -internal val EXPECTED_FFI_CONTRACT_MAJOR: UShort = 4.toUShort() -internal val MINIMUM_FFI_CONTRACT_MINOR: UShort = 0.toUShort() -internal const val EXPECTED_SNAPSHOT_SCHEMA: UInt = 1U -internal const val MINIMUM_STORAGE_SCHEMA: UInt = 5U -internal const val MAXIMUM_STORAGE_SCHEMA: UInt = 10U +import org.harvestcircle.application.generated.NativeCompatibilityExpectations as Expected internal class NativeCompatibilityException : IllegalStateException( @@ -24,28 +11,28 @@ internal class NativeCompatibilityException : internal fun verifyNativeCompatibility(descriptor: CompatibilityDescriptor): CompatibilityExpectation { val compatible = - descriptor.contractId == EXPECTED_FFI_CONTRACT_ID && - descriptor.productVersion == EXPECTED_PRODUCT_VERSION && - descriptor.cargoPackageVersion == EXPECTED_CARGO_PACKAGE_VERSION && - descriptor.distributionPackageVersion == EXPECTED_DISTRIBUTION_PACKAGE_VERSION && - descriptor.contractMajor == EXPECTED_FFI_CONTRACT_MAJOR && - descriptor.contractMinor >= MINIMUM_FFI_CONTRACT_MINOR && - descriptor.contractHash == EXPECTED_FFI_CONTRACT_HASH && - descriptor.productCoordinateDigest == EXPECTED_PRODUCT_COORDINATE_DIGEST && - descriptor.snapshotSchemaVersion == EXPECTED_SNAPSHOT_SCHEMA && - descriptor.currentSchemaVersion >= MINIMUM_STORAGE_SCHEMA && - descriptor.minimumSchemaVersion <= MAXIMUM_STORAGE_SCHEMA && - descriptor.sourceProvenanceDigest == EXPECTED_SOURCE_PROVENANCE_DIGEST && - descriptor.sourceFoundationBaseline == EXPECTED_SOURCE_FOUNDATION_BASELINE + descriptor.contractId == Expected.ffiContractId && + descriptor.productVersion == Expected.productVersion && + descriptor.cargoPackageVersion == Expected.cargoPackageVersion && + descriptor.distributionPackageVersion == Expected.distributionPackageVersion && + descriptor.contractMajor == Expected.ffiContractMajor && + descriptor.contractMinor >= Expected.minimumFfiContractMinor && + descriptor.contractHash == Expected.ffiContractHash && + descriptor.productCoordinateDigest == Expected.productCoordinateDigest && + descriptor.snapshotSchemaVersion == Expected.snapshotSchema && + descriptor.currentSchemaVersion >= Expected.minimumStorageSchema && + descriptor.minimumSchemaVersion <= Expected.maximumStorageSchema && + descriptor.sourceProvenanceDigest == Expected.sourceProvenanceDigest && + descriptor.sourceFoundationBaseline == Expected.sourceFoundationBaseline if (!compatible) throw NativeCompatibilityException() return CompatibilityExpectation( - contractId = EXPECTED_FFI_CONTRACT_ID, - contractMajor = EXPECTED_FFI_CONTRACT_MAJOR, - minimumContractMinor = MINIMUM_FFI_CONTRACT_MINOR, - contractHash = EXPECTED_FFI_CONTRACT_HASH, - productCoordinateDigest = EXPECTED_PRODUCT_COORDINATE_DIGEST, - snapshotSchemaVersion = EXPECTED_SNAPSHOT_SCHEMA, - minimumSchemaVersion = MINIMUM_STORAGE_SCHEMA, - maximumSchemaVersion = MAXIMUM_STORAGE_SCHEMA, + contractId = Expected.ffiContractId, + contractMajor = Expected.ffiContractMajor, + minimumContractMinor = Expected.minimumFfiContractMinor, + contractHash = Expected.ffiContractHash, + productCoordinateDigest = Expected.productCoordinateDigest, + snapshotSchemaVersion = Expected.snapshotSchema, + minimumSchemaVersion = Expected.minimumStorageSchema, + maximumSchemaVersion = Expected.maximumStorageSchema, ) } diff --git a/app/desktop/src/test/kotlin/org/harvestcircle/application/NativeCompatibilityTest.kt b/app/desktop/src/test/kotlin/org/harvestcircle/application/NativeCompatibilityTest.kt @@ -4,14 +4,21 @@ import org.harvestcircle.ffi.CompatibilityDescriptor import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFailsWith +import org.harvestcircle.application.generated.NativeCompatibilityExpectations as Expected class NativeCompatibilityTest { @Test fun acceptsOnlyTheDeclaredNativeContractAndSchemaWindow() { val descriptor = compatibleDescriptor() val expectation = verifyNativeCompatibility(descriptor) - assertEquals(EXPECTED_FFI_CONTRACT_MAJOR, expectation.contractMajor) - assertEquals(EXPECTED_FFI_CONTRACT_HASH, expectation.contractHash) + assertEquals(Expected.ffiContractMajor, expectation.contractMajor) + assertEquals(Expected.ffiContractHash, expectation.contractHash) + assertEquals( + expectation, + verifyNativeCompatibility( + descriptor.copy(contractMinor = (Expected.minimumFfiContractMinor.toUInt() + 1U).toUShort()), + ), + ) listOf( descriptor.copy(contractId = "wrong"), @@ -35,18 +42,18 @@ class NativeCompatibilityTest { private fun compatibleDescriptor() = CompatibilityDescriptor( - contractId = EXPECTED_FFI_CONTRACT_ID, - productVersion = EXPECTED_PRODUCT_VERSION, - cargoPackageVersion = EXPECTED_CARGO_PACKAGE_VERSION, - distributionPackageVersion = EXPECTED_DISTRIBUTION_PACKAGE_VERSION, - contractMajor = EXPECTED_FFI_CONTRACT_MAJOR, - contractMinor = MINIMUM_FFI_CONTRACT_MINOR, - contractHash = EXPECTED_FFI_CONTRACT_HASH, - productCoordinateDigest = EXPECTED_PRODUCT_COORDINATE_DIGEST, - snapshotSchemaVersion = EXPECTED_SNAPSHOT_SCHEMA, - minimumSchemaVersion = MINIMUM_STORAGE_SCHEMA, - currentSchemaVersion = MAXIMUM_STORAGE_SCHEMA, - sourceProvenanceDigest = EXPECTED_SOURCE_PROVENANCE_DIGEST, - sourceFoundationBaseline = EXPECTED_SOURCE_FOUNDATION_BASELINE, + contractId = Expected.ffiContractId, + productVersion = Expected.productVersion, + cargoPackageVersion = Expected.cargoPackageVersion, + distributionPackageVersion = Expected.distributionPackageVersion, + contractMajor = Expected.ffiContractMajor, + contractMinor = Expected.minimumFfiContractMinor, + contractHash = Expected.ffiContractHash, + productCoordinateDigest = Expected.productCoordinateDigest, + snapshotSchemaVersion = Expected.snapshotSchema, + minimumSchemaVersion = Expected.minimumStorageSchema, + currentSchemaVersion = Expected.maximumStorageSchema, + sourceProvenanceDigest = Expected.sourceProvenanceDigest, + sourceFoundationBaseline = Expected.sourceFoundationBaseline, ) } diff --git a/buildSrc/src/main/kotlin/org/harvestcircle/gradle/CompatibilityExpectations.kt b/buildSrc/src/main/kotlin/org/harvestcircle/gradle/CompatibilityExpectations.kt @@ -0,0 +1,98 @@ +package org.harvestcircle.gradle + +import org.gradle.api.DefaultTask +import org.gradle.api.file.RegularFileProperty +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 + +object CompatibilityExpectationsSource { + fun render(baseline: FfiCompatibilityBaseline): String = + """ + |// @generated by the HarvestCircle Gradle build. Do not edit. + |package org.harvestcircle.application.generated + | + |internal object NativeCompatibilityExpectations { + | const val ffiContractId = ${baseline["contract.id"].quoted()} + | const val productVersion = ${baseline["product.version"].quoted()} + | const val cargoPackageVersion = ${baseline["product.version"].quoted()} + | const val distributionPackageVersion = ${baseline["package.version"].quoted()} + | const val productCoordinateDigest = ${baseline["product.coordinate_digest"].quoted()} + | const val sourceProvenanceDigest = ${baseline["source.provenance_digest"].quoted()} + | const val sourceFoundationBaseline = ${baseline["source.foundation_baseline"].quoted()} + | const val ffiContractHash = ${baseline["contract.hash"].quoted()} + | val ffiContractMajor: UShort = ${baseline.ushort("contract.major")}.toUShort() + | val minimumFfiContractMinor: UShort = ${baseline.ushort("contract.minor")}.toUShort() + | const val snapshotSchema: UInt = ${baseline.uint("snapshot.schema")}U + | const val minimumStorageSchema: UInt = ${baseline.uint("storage.schema.minimum")}U + | const val maximumStorageSchema: UInt = ${baseline.uint("storage.schema.current")}U + |} + """.trimMargin() + "\n" + + fun requireFresh( + actual: String?, + expected: String, + ) { + require(actual != null) { "Generated Kotlin compatibility expectations are missing" } + require(actual == expected) { "Generated Kotlin compatibility expectations are stale" } + } + + private fun String.quoted(): String = + buildString { + append('"') + this@quoted.forEach { character -> + when (character) { + '\\' -> append("\\\\") + '"' -> append("\\\"") + '\n' -> append("\\n") + '\r' -> append("\\r") + '\t' -> append("\\t") + else -> append(character) + } + } + append('"') + } + + private fun FfiCompatibilityBaseline.uint(key: String): UInt = + this[key].toUIntOrNull() ?: error("FFI baseline $key must be an unsigned integer") + + private fun FfiCompatibilityBaseline.ushort(key: String): UShort = + this[key].toUShortOrNull() ?: error("FFI baseline $key must fit an unsigned short") +} + +abstract class GenerateCompatibilityExpectations : DefaultTask() { + @get:InputFile + @get:PathSensitive(PathSensitivity.RELATIVE) + abstract val baselineFile: RegularFileProperty + + @get:OutputFile + abstract val outputFile: RegularFileProperty + + @TaskAction + fun generate() { + val source = CompatibilityExpectationsSource.render(FfiCompatibilityBaseline.load(baselineFile.get().asFile)) + val output = outputFile.get().asFile + output.parentFile.mkdirs() + output.writeText(source) + } +} + +abstract class VerifyGeneratedCompatibilityExpectations : DefaultTask() { + @get:InputFile + @get:PathSensitive(PathSensitivity.RELATIVE) + abstract val baselineFile: RegularFileProperty + + @get:InputFile + @get:PathSensitive(PathSensitivity.RELATIVE) + abstract val generatedFile: RegularFileProperty + + @TaskAction + fun verify() { + val expected = CompatibilityExpectationsSource.render(FfiCompatibilityBaseline.load(baselineFile.get().asFile)) + check(runCatching { CompatibilityExpectationsSource.requireFresh(null, expected) }.isFailure) + check(runCatching { CompatibilityExpectationsSource.requireFresh("$expected// stale\n", expected) }.isFailure) + CompatibilityExpectationsSource.requireFresh(generatedFile.get().asFile.readText(), expected) + } +} diff --git a/buildSrc/src/main/kotlin/org/harvestcircle/gradle/FfiCompatibilityBaseline.kt b/buildSrc/src/main/kotlin/org/harvestcircle/gradle/FfiCompatibilityBaseline.kt @@ -28,6 +28,7 @@ class FfiCompatibilityBaseline private constructor( fun load(file: File): FfiCompatibilityBaseline = parse(file.readText()) fun parse(source: String): FfiCompatibilityBaseline { + require(!source.startsWith('\uFEFF')) { "FFI baseline must not contain a UTF-8 BOM" } val values = linkedMapOf<String, String>() source.lineSequence().forEachIndexed { index, raw -> val line = raw.trim() @@ -37,7 +38,12 @@ class FfiCompatibilityBaseline private constructor( val key = line.substring(0, separator).trim() val value = line.substring(separator + 1).trim() require(key in requiredKeys) { "Unknown FFI baseline key $key" } - require(value.isNotEmpty() && value.none(Char::isISOControl)) { + require( + key.isNotEmpty() && + key.none(Char::isISOControl) && + value.isNotEmpty() && + value.none(Char::isISOControl), + ) { "FFI baseline $key is empty or contains a control character" } require(values.put(key, value) == null) { "Duplicate FFI baseline key $key" } @@ -58,6 +64,8 @@ class FfiCompatibilityBaseline private constructor( require(values.getValue(key).matches(Regex("[0-9a-f]{64}"))) } require(values.getValue("source.foundation_baseline").matches(Regex("[0-9a-f]{40}"))) + require(values.getValue("product.version").matches(Regex("[0-9]+\\.[0-9]+\\.[0-9]+(?:-[0-9A-Za-z.-]+)?"))) + require(values.getValue("package.version").matches(Regex("[1-9][0-9]*(?:\\.[0-9]+){0,2}"))) return FfiCompatibilityBaseline(values.toMap()) } } diff --git a/buildSrc/src/main/kotlin/org/harvestcircle/gradle/FoundationBoundaryAudit.kt b/buildSrc/src/main/kotlin/org/harvestcircle/gradle/FoundationBoundaryAudit.kt @@ -151,6 +151,7 @@ private class FoundationBoundaryAudit( findings += "$relative: symbolic links are not allowed in public sources" } if (normalized.startsWith("core/target/") || normalized.contains("/build/") || + normalized.contains("/generated/") || normalized.contains("generated/uniffi") || normalized.endsWith(".dylib") || normalized.endsWith(".so") || normalized.endsWith(".dll") || normalized.endsWith(".class") ) { diff --git a/buildSrc/src/main/kotlin/org/harvestcircle/gradle/ProductCoordinates.kt b/buildSrc/src/main/kotlin/org/harvestcircle/gradle/ProductCoordinates.kt @@ -194,6 +194,27 @@ abstract class VerifyProductCoordinates : DefaultTask() { val baselineSource = ffiBaselineFile.get().asFile.readText() check(runCatching { FfiCompatibilityBaseline.parse(baselineSource + "\nunknown=value") }.isFailure) check(runCatching { FfiCompatibilityBaseline.parse(baselineSource.substringAfter('\n')) }.isFailure) + check(runCatching { FfiCompatibilityBaseline.parse("\uFEFF$baselineSource") }.isFailure) + check( + runCatching { + FfiCompatibilityBaseline.parse( + baselineSource.replace( + Regex("(?m)^contract\\.hash=.*$"), + "contract.hash=malformed", + ), + ) + }.isFailure, + ) + check( + runCatching { + FfiCompatibilityBaseline.parse( + baselineSource.replace( + Regex("(?m)^package\\.version=.*$"), + "package.version=invalid", + ), + ) + }.isFailure, + ) check( runCatching { FfiCompatibilityBaseline.parse( @@ -230,15 +251,14 @@ abstract class VerifyProductCoordinates : DefaultTask() { ).digest != provenance.digest, ) val nativeCompatibility = nativeCompatibilityFile.get().asFile.readText() + check(nativeCompatibility.contains("NativeCompatibilityExpectations as Expected")) listOf( - "contract.id" to "EXPECTED_FFI_CONTRACT_ID", - "contract.hash" to "EXPECTED_FFI_CONTRACT_HASH", - "product.coordinate_digest" to "EXPECTED_PRODUCT_COORDINATE_DIGEST", - "source.provenance_digest" to "EXPECTED_SOURCE_PROVENANCE_DIGEST", - "source.foundation_baseline" to "EXPECTED_SOURCE_FOUNDATION_BASELINE", - ).forEach { (key, constant) -> - check(nativeCompatibility.contains("$constant = \"${baseline[key]}\"")) - } + "contract.id", + "contract.hash", + "product.coordinate_digest", + "source.provenance_digest", + "source.foundation_baseline", + ).forEach { key -> check(!nativeCompatibility.contains(baseline[key])) } } private fun String.replaceCoordinate(