diff --git a/.github/workflows/samples-minimal.yml b/.github/workflows/samples-minimal.yml new file mode 100644 index 0000000..62e7b8e --- /dev/null +++ b/.github/workflows/samples-minimal.yml @@ -0,0 +1,70 @@ +# Guard for #195: samples/minimal is a from-published consumer (it resolves the +# released kplusplus plugin from mavenCentral/gradlePluginPortal, not the in-tree +# generator — see samples/minimal/README.md), so it cannot catch a generated-shape +# change at the moment the generator changes. The only moment it CAN catch one is +# when this sample itself next builds against a plugin version — which is exactly +# what silently broke here: the context-parameter migration (#145/#146) changed +# `origin` from a property to `origin()`/`_origin(v)` accessors, and nothing ran +# `samples/minimal:nativeTest` to notice until the 0.3.4 self-host bump (#193). +# +# This workflow closes that gap: any PR/push that touches samples/minimal (most +# notably a plugin-version bump in samples/minimal/build.gradle.kts, which is +# precisely the trigger that caused #195) runs `:nativeTest`, which forces +# src/nativeMain to compile AND exercises the generated bindings' behavior +# (src/nativeTest/kotlin/GeometryTest.kt). A shape drift fails the compile step +# loudly, the same way it did when this was reproduced by hand for #195. +# +# Deliberately NOT wired into the root build / release.yml: samples/minimal is +# explicitly not part of the root settings.gradle.kts (it's a standalone, +# from-published consumer proving the published artifact, not the in-tree one — +# see docs/ARCHITECTURE.md), and this workflow doesn't need the in-tree LLVM-linked +# :krapper build at all, so it's cheap and independent of the main build's slower +# self-hosting path. +name: samples/minimal + +on: + push: + branches: [main] + paths: + - 'samples/minimal/**' + - '.github/workflows/samples-minimal.yml' + pull_request: + paths: + - 'samples/minimal/**' + - '.github/workflows/samples-minimal.yml' + workflow_dispatch: + +jobs: + native-test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up JDK 21 + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: '21' + + - name: Set up Gradle + uses: gradle/actions/setup-gradle@v4 + + # The published krapper tool binary is LLVM-linked and needs clang++ on PATH at + # RUNTIME to parse cpp/geometry.h and compile it (see samples/minimal/README.md + # "Requirements") — no in-tree LLVM build required, unlike release.yml. + - name: Install LLVM/Clang 22 (apt.llvm.org) + run: | + set -euxo pipefail + sudo apt-get update + sudo apt-get install -y wget lsb-release gnupg ca-certificates software-properties-common + wget -qO /tmp/llvm.sh https://apt.llvm.org/llvm.sh + chmod +x /tmp/llvm.sh + sudo /tmp/llvm.sh 22 all + sudo ln -sf "$(command -v clang++-22)" /usr/local/bin/clang++ + sudo ln -sf "$(command -v clang-22)" /usr/local/bin/clang + sudo ln -sf "$(command -v llvm-config-22)" /usr/local/bin/llvm-config + echo "clang++: $(clang++ --version | head -1); llvm-config: $(llvm-config --version)" + + - name: samples/minimal :nativeTest + working-directory: samples/minimal + run: ./gradlew nativeTest --no-daemon diff --git a/docs/releasing.md b/docs/releasing.md index 91f6e6c..abbd18a 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -96,6 +96,9 @@ export JAVA_HOME=/usr/lib/jvm/java-21-openjdk -Pkpp.bundleTools.krapper="$PWD/krapper/build/bin/klinker/krapperRelease/krapper" # 2. Prove a from-published consumer resolves the bundled tool + runs from mavenLocal: (cd samples/minimal && ./gradlew runReleaseExecutableKlinker) +# 3. ...and that the generated bindings still match what the sample expects (the +# `samples/minimal:nativeTest` guard for #195 — see samples/minimal/README.md): +(cd samples/minimal && ./gradlew nativeTest) ``` `publishToMavenLocal` does **not** sign (no key in the environment) — signing is exercised only diff --git a/samples/minimal/README.md b/samples/minimal/README.md index b37aeb4..52c7955 100644 --- a/samples/minimal/README.md +++ b/samples/minimal/README.md @@ -53,6 +53,7 @@ Expected output: ``` Rect area: 6.0 +Rect origin: (0.0, 0.0) distance (0,0)->(3,4): 5.0 center: (1.0, 1.5) ``` @@ -67,6 +68,20 @@ parses the header and compiles `cpp/geometry.cc`. JDK 21 is required to run the The first build is slow (it builds the self-hosted front-end, generates the bindings, then compiles and links the generated C++ wrapper); later runs are incremental. +## Keeping this sample honest + +Because this sample resolves the **published** plugin rather than the in-tree generator, it +can't catch a generated-shape change (e.g. #145/#146's context-parameter migration) the moment +the generator changes — only the next time this sample itself builds against a new plugin +version. `src/nativeTest/kotlin/GeometryTest.kt` exercises every accessor kind the generator +emits for `cpp/geometry.h` (scalar `var` properties, `context(scope: MemScope)` get/set-style +accessors for object-valued fields, and a plain const-ref method), so `./gradlew nativeTest` +fails to compile — loudly — the moment the generated shape drifts from what `Main.kt`/the test +assume, exactly the way `rect.origin = …` did in #195. This is wired into CI at +[`.github/workflows/samples-minimal.yml`](../../.github/workflows/samples-minimal.yml) (runs on +any change under `samples/minimal/**`, most notably a plugin-version bump) and into the release +[local dry run](../../docs/releasing.md#local-dry-run-no-outward-upload). + ## See also - The repository [README](../../README.md) — quick start and the `kplusplus { }` DSL. diff --git a/samples/minimal/build.gradle.kts b/samples/minimal/build.gradle.kts index 9a4a27e..036513b 100644 --- a/samples/minimal/build.gradle.kts +++ b/samples/minimal/build.gradle.kts @@ -130,6 +130,18 @@ kotlin { } } } + + // `:nativeTest` (src/nativeTest/kotlin) asserts the generated bindings actually + // behave as documented, and — just as importantly — forces src/nativeMain to + // compile as part of an ordinary `check`/test run. This is the guard for #195: + // samples/minimal is a from-published consumer (resolves the released plugin, not + // the in-tree generator), so it can't catch a generated-shape change at the moment + // the generator changes — only at the moment this sample next builds against a new + // plugin version. Wiring nativeTest in means that moment fails loudly instead of + // silently, the way the `rect.origin = …` break in this issue did. + sourceSets["nativeTest"].dependencies { + implementation(kotlin("test")) + } } // The bindings can only be generated after libgeometry.a exists: kplusplusSync compiles diff --git a/samples/minimal/src/nativeMain/kotlin/Main.kt b/samples/minimal/src/nativeMain/kotlin/Main.kt index 3d3b166..5bd104d 100644 --- a/samples/minimal/src/nativeMain/kotlin/Main.kt +++ b/samples/minimal/src/nativeMain/kotlin/Main.kt @@ -28,12 +28,20 @@ fun main(args: Array) { // when it ends. `Point()` / `Rect()` are MemScope extension factories (see // the generated companion objects), in scope thanks to the imports above. memScoped { - // A 2x3 rectangle at the origin. + // A 2x3 rectangle at the origin. `origin` is a Point-VALUED field, so its + // accessors are `origin()` (get, needs a MemScope to allocate the returned + // Point into) / `_origin(value)` (set, no allocation needed) -- NOT a Kotlin + // `var` property. A getter that allocates can't be an ordinary property + // getter (no way to thread a MemScope through `rect.origin`), so the + // generator falls back to plain functions for object-valued fields. Scalar + // fields (width, height below; x, y on Point) need no allocation and DO stay + // ordinary `var` properties. val rect = Rect() - rect.origin = Point().apply { x = 0.0; y = 0.0 } + rect._origin(Point().apply { x = 0.0; y = 0.0 }) rect.width = 2.0 rect.height = 3.0 println("Rect area: ${rect.area()}") + println("Rect origin: (${rect.origin()!!.x}, ${rect.origin()!!.y})") // distanceTo takes the other Point by const-ref in C++ -> a plain Kotlin // parameter here; the classic 3-4-5 right triangle gives 5.0. diff --git a/samples/minimal/src/nativeTest/kotlin/GeometryTest.kt b/samples/minimal/src/nativeTest/kotlin/GeometryTest.kt new file mode 100644 index 0000000..b9acaf4 --- /dev/null +++ b/samples/minimal/src/nativeTest/kotlin/GeometryTest.kt @@ -0,0 +1,68 @@ +/* + * Copyright 2026 Jason Monk + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import geo.Point +import geo.Point.Companion.Point +import geo.Rect +import geo.Rect.Companion.Rect +import kotlinx.cinterop.memScoped +import kotlin.test.Test +import kotlin.test.assertEquals + +// The guard for #195: samples/minimal is a from-published consumer, so it only ever +// sees a generated-shape change (e.g. #145/#146's context-parameter migration) at the +// point this sample next resolves a new plugin version -- there is no in-tree signal. +// This test (and Main.kt, which it deliberately mirrors) exercises every accessor kind +// the generator emits for this header: scalar `var` properties (x/y/width/height), +// object-valued get/set pairs that need a MemScope (`origin()` / `_origin(v)`), a +// by-value-returning method that needs a MemScope (`center()`), and a plain const-ref +// method that doesn't (`distanceTo`). If the generated shape drifts, this fails to +// COMPILE (the same way `rect.origin = …` did) before it can fail to assert. +class GeometryTest { + @Test + fun rectExposesOriginAsAccessorsNotAProperty() = + memScoped { + val rect = Rect() + rect._origin(Point().apply { x = 1.0; y = 2.0 }) + + val origin = rect.origin()!! + assertEquals(1.0, origin.x) + assertEquals(2.0, origin.y) + } + + @Test + fun rectAreaAndCenterMatchTheGeometry() = + memScoped { + val rect = Rect() + rect._origin(Point().apply { x = 0.0; y = 0.0 }) + rect.width = 2.0 + rect.height = 3.0 + + assertEquals(6.0, rect.area()) + + val center = rect.center() + assertEquals(1.0, center.x) + assertEquals(1.5, center.y) + } + + @Test + fun pointDistanceToIsAPlainConstRefMethod() = + memScoped { + val a = Point().apply { x = 0.0; y = 0.0 } + val b = Point().apply { x = 3.0; y = 4.0 } + + assertEquals(5.0, a.distanceTo(b)) + } +}