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:
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(