From a9cfdce39369e34c4269622c401898491e36d71c Mon Sep 17 00:00:00 2001 From: Rodolfo Hansen Date: Fri, 2 Oct 2026 16:24:21 +0200 Subject: [PATCH 1/6] build(deps): bump actions/cache from v4 to v6 Supersedes #118. Drop-in verified: action.yml inputs are byte-identical between v4.3.0 and v6.1.0 (only the node20 -> node24 runtime line differs); all 18 steps pass exactly {path, key, restore-keys}; @v6 resolves to v6.1.0, which adds graceful handling of read-only cache tokens (the accurate 'cache write denied' warning on fork PRs instead of the bogus 'another job may be creating this cache'). --- .github/workflows/bench-pr.yml | 2 +- .github/workflows/bench-sweep.yml | 2 +- .github/workflows/benchmarks.yml | 2 +- .github/workflows/ci.yml | 24 ++++++++++++------------ .github/workflows/deploy-site.yml | 2 +- .github/workflows/quality.yml | 4 ++-- 6 files changed, 18 insertions(+), 18 deletions(-) diff --git a/.github/workflows/bench-pr.yml b/.github/workflows/bench-pr.yml index aac5578c..c5b77b1a 100644 --- a/.github/workflows/bench-pr.yml +++ b/.github/workflows/bench-pr.yml @@ -136,7 +136,7 @@ jobs: distribution: temurin java-version: 25 # kyoIntegration (KyoDiBench dep) targets JDK 25 since v0.15.0 - name: Cache coursier + ivy + sbt boot - uses: actions/cache@v4 + uses: actions/cache@v6 with: path: | ~/.cache/coursier diff --git a/.github/workflows/bench-sweep.yml b/.github/workflows/bench-sweep.yml index 5b74994a..ef47b53f 100644 --- a/.github/workflows/bench-sweep.yml +++ b/.github/workflows/bench-sweep.yml @@ -127,7 +127,7 @@ jobs: distribution: temurin java-version: 25 # kyoIntegration (KyoDiBench dep) targets JDK 25 since v0.15.0 - name: Cache coursier + ivy + sbt boot - uses: actions/cache@v4 + uses: actions/cache@v6 with: path: | ~/.cache/coursier diff --git a/.github/workflows/benchmarks.yml b/.github/workflows/benchmarks.yml index 419a8120..2ff77d9d 100644 --- a/.github/workflows/benchmarks.yml +++ b/.github/workflows/benchmarks.yml @@ -62,7 +62,7 @@ jobs: distribution: temurin java-version: 21 - name: Cache coursier + ivy + sbt boot - uses: actions/cache@v4 + uses: actions/cache@v6 with: path: | ~/.cache/coursier diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 708d447b..1f47fdd0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -52,7 +52,7 @@ jobs: - name: Cache coursier + ivy + sbt boot id: coursier-cache-temurin-25 if: matrix.java == 'temurin@25' - uses: actions/cache@v4 + uses: actions/cache@v6 with: path: | ~/.cache/coursier @@ -76,7 +76,7 @@ jobs: - name: Cache coursier + ivy + sbt boot id: coursier-cache-temurin-17 if: matrix.java == 'temurin@17' - uses: actions/cache@v4 + uses: actions/cache@v6 with: path: | ~/.cache/coursier @@ -100,7 +100,7 @@ jobs: - name: Cache coursier + ivy + sbt boot id: coursier-cache-temurin-21 if: matrix.java == 'temurin@21' - uses: actions/cache@v4 + uses: actions/cache@v6 with: path: | ~/.cache/coursier @@ -177,7 +177,7 @@ jobs: - name: Cache coursier + ivy + sbt boot id: coursier-cache-temurin-25 if: matrix.java == 'temurin@25' - uses: actions/cache@v4 + uses: actions/cache@v6 with: path: | ~/.cache/coursier @@ -201,7 +201,7 @@ jobs: - name: Cache coursier + ivy + sbt boot id: coursier-cache-temurin-17 if: matrix.java == 'temurin@17' - uses: actions/cache@v4 + uses: actions/cache@v6 with: path: | ~/.cache/coursier @@ -225,7 +225,7 @@ jobs: - name: Cache coursier + ivy + sbt boot id: coursier-cache-temurin-21 if: matrix.java == 'temurin@21' - uses: actions/cache@v4 + uses: actions/cache@v6 with: path: | ~/.cache/coursier @@ -299,7 +299,7 @@ jobs: - name: Cache coursier + ivy + sbt boot id: coursier-cache-temurin-25 if: matrix.java == 'temurin@25' - uses: actions/cache@v4 + uses: actions/cache@v6 with: path: | ~/.cache/coursier @@ -323,7 +323,7 @@ jobs: - name: Cache coursier + ivy + sbt boot id: coursier-cache-temurin-17 if: matrix.java == 'temurin@17' - uses: actions/cache@v4 + uses: actions/cache@v6 with: path: | ~/.cache/coursier @@ -347,7 +347,7 @@ jobs: - name: Cache coursier + ivy + sbt boot id: coursier-cache-temurin-21 if: matrix.java == 'temurin@21' - uses: actions/cache@v4 + uses: actions/cache@v6 with: path: | ~/.cache/coursier @@ -393,7 +393,7 @@ jobs: - name: Cache coursier + ivy + sbt boot id: coursier-cache-temurin-25 if: matrix.java == 'temurin@25' - uses: actions/cache@v4 + uses: actions/cache@v6 with: path: | ~/.cache/coursier @@ -417,7 +417,7 @@ jobs: - name: Cache coursier + ivy + sbt boot id: coursier-cache-temurin-17 if: matrix.java == 'temurin@17' - uses: actions/cache@v4 + uses: actions/cache@v6 with: path: | ~/.cache/coursier @@ -441,7 +441,7 @@ jobs: - name: Cache coursier + ivy + sbt boot id: coursier-cache-temurin-21 if: matrix.java == 'temurin@21' - uses: actions/cache@v4 + uses: actions/cache@v6 with: path: | ~/.cache/coursier diff --git a/.github/workflows/deploy-site.yml b/.github/workflows/deploy-site.yml index d6852b8e..2d94a58b 100644 --- a/.github/workflows/deploy-site.yml +++ b/.github/workflows/deploy-site.yml @@ -70,7 +70,7 @@ jobs: distribution: temurin java-version: 25 - name: Cache coursier + ivy + sbt boot - uses: actions/cache@v4 + uses: actions/cache@v6 with: path: | ~/.cache/coursier diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml index 780bafa5..7582cbdc 100644 --- a/.github/workflows/quality.yml +++ b/.github/workflows/quality.yml @@ -61,7 +61,7 @@ jobs: distribution: temurin java-version: 25 - name: Cache coursier + ivy + sbt boot - uses: actions/cache@v4 + uses: actions/cache@v6 with: path: | ~/.cache/coursier @@ -97,7 +97,7 @@ jobs: distribution: temurin java-version: 25 - name: Cache coursier + ivy + sbt boot - uses: actions/cache@v4 + uses: actions/cache@v6 with: path: | ~/.cache/coursier From dad1ad59963f75231b1e23ff73565d7cfd11c1cf Mon Sep 17 00:00:00 2001 From: Rodolfo Hansen Date: Fri, 2 Oct 2026 16:25:12 +0200 Subject: [PATCH 2/6] build(deps): bump droste-core from 0.9.0-M3 to 0.10.0 Supersedes #124. Benchmark-only dependency (never published downstream). 0.10.0 is dependency maintenance upstream (cats update, scala-collection-compat 2.13.0, Scala.js 1.18.2); our droste surface (Fix, scheme.{cata,ana,hylo}, Algebra/Coalgebra) compiles unchanged and all nine SchemesBench benchmarks execute on the new version. --- build.sbt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build.sbt b/build.sbt index 4e104af5..e9ecd931 100644 --- a/build.sbt +++ b/build.sbt @@ -356,7 +356,7 @@ lazy val scalacheck = ScalaCheckOrg %% "scalacheck" % "1.20.0" lazy val monocle = Optics %% "monocle-core" % "3.3.0" // droste — the recursion-scheme baseline for the schemes benchmarks (pattern // functor + Fix encoding). Benchmark-only; never a published dependency. -lazy val drosteCore = "io.higherkindness" %% "droste-core" % "0.9.0-M3" +lazy val drosteCore = "io.higherkindness" %% "droste-core" % "0.10.0" // kindlings 0.3.x (all three) ship a configurable macro-expansion timeout // (`DerivationTimeout`, default 5s) and pull hearth 0.4.2 + kindlings-derivation-commons. // We raise it to 30s via `-Xmacro-settings:{circe,cats,avro}Derivation.timeout=30s` From acb52db3db44f8899e31d74469f7ad58f71a7291 Mon Sep 17 00:00:00 2001 From: Rodolfo Hansen Date: Fri, 2 Oct 2026 16:25:23 +0200 Subject: [PATCH 3/6] build(deps): bump sbt-stryker4s from 0.20.4 to 1.1.1 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Supersedes #126. No removed or renamed features reach this build: the setting keys used in build.sbt (strykerReporters, strykerExcludedMutations, strykerThresholdsBreak) are unchanged, the borrowed-tests wiring still resolves, and 1.x requires sbt >= 1.11.2 (we run 1.13.0). Verified with a schemes smoke run (48/58 killed, 0 NoCoverage) and a full avroIntegration run (481 killed / 51 survived / 58 known-macro NoCoverage, no crash). Decisive for issue #115: 1.1.1 fixes stryker4s' rollback invariant crash ('cases should be non-empty') that killed avro's mutation run on main — 0.20.4's mutant removal could empty a Term.Match by deleting its default Pat.Wildcard case; 1.1.1 filters it. Also refreshes the invocation-doctrine comment: the module-scoped /stryker form works again since 0.20.4. --- project/plugins.sbt | 23 +++++++++++++++-------- 1 file changed, 15 insertions(+), 8 deletions(-) diff --git a/project/plugins.sbt b/project/plugins.sbt index ae078589..96afd0b6 100644 --- a/project/plugins.sbt +++ b/project/plugins.sbt @@ -9,14 +9,21 @@ addSbtPlugin("pl.project13.scala" % "sbt-jmh" % "0.4.8") // at release rather than as a per-PR gate (see `mutationAll` in // build.sbt and site/docs/quality-assurance.md). // Stryker runs each module's OWN `Test / test` against that module's -// mutants. NB: invoke it as `project ; stryker`, NOT -// `/stryker` — the module-scoped task form resolves -// `loadedTestFrameworks` from the aggregating root project (which has no -// test deps), so every mutant comes back NoCoverage; switching the -// current project first makes specs2 visible. 0.20.x auto-derives the -// Scala 3 dialect from scalaVersion. -// 0.20.4 fixed multi-module invocation; the `project ; stryker` workaround still works. -addSbtPlugin("io.stryker-mutator" % "sbt-stryker4s" % "0.20.4") +// mutants. This build invokes it as `project ; stryker` (see +// `mutationAll` in build.sbt and the quality.yml release sweep). +// Upstream fixed the multi-module invocation in 0.20.4 by scoping the +// task to the invoking project, so `/stryker` is correct too on +// 1.x; the current-project form is kept because the alias switches +// projects anyway and the borrowed-tests `set` lines are expressed with +// `project ;` as well. Before 0.20.4 the module-scoped form resolved +// `loadedTestFrameworks` from the aggregating root project (no test +// deps), so every mutant came back NoCoverage. +// The Scala 3 dialect is still auto-derived from scalaVersion. Bumped +// 0.20.4 → 1.1.1: v1 has no removed or renamed features, the sbt +// setting keys used in build.sbt (`strykerReporters`, +// `strykerExcludedMutations`, `strykerThresholdsBreak`) are unchanged, +// and 1.x requires sbt >= 1.11.2 (we run 1.13.0). +addSbtPlugin("io.stryker-mutator" % "sbt-stryker4s" % "1.1.1") // Format check gate for CI (`sbt scalafmtCheckAll scalafmtSbtCheck` // in the workflow). The project ships a `.scalafmt.conf` pinned to From 4910cc54affba3be462d69e1c3517289df3a61c2 Mon Sep 17 00:00:00 2001 From: Rodolfo Hansen Date: Fri, 2 Oct 2026 16:25:23 +0200 Subject: [PATCH 4/6] fix(avro): keep AvroWalk's null-narrowing outside stryker's reach MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses #115 blocker 1. The flow-typed 'val here = if index != null then index else ...' loses its narrowing the moment stryker4s' mutator rewrites the condition, so two mutants died as compile errors instead of being exercised (and on 0.20.4 that rollback crashed the whole run). Express the narrowing as a match on the null sentinel with an explicit JMap[String, Integer] type: a match has no condition to mutate, so the narrowing is structural rather than flow-based. (The ascribe + 'index.nn' spelling from the issue does not compile here — the guard already narrows, E216 fires, and -Werror rejects the warning.) Applied at both occurrences (totalNominalIndex and recordSlots); AvroCompileError mutants from AvroWalk go to zero. --- .../dev/constructive/eo/avro/AvroWalk.scala | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/avro/src/main/scala/dev/constructive/eo/avro/AvroWalk.scala b/avro/src/main/scala/dev/constructive/eo/avro/AvroWalk.scala index 152d3ccc..0644bb70 100644 --- a/avro/src/main/scala/dev/constructive/eo/avro/AvroWalk.scala +++ b/avro/src/main/scala/dev/constructive/eo/avro/AvroWalk.scala @@ -582,7 +582,16 @@ private[avro] object AvroWalk: out(i) = idx loop(i + 1, t, index) else - val here = if index != null then index else normalisedNameIndex(record) + // A `match` on the null sentinel rather than `if index != null`: stryker4s's + // `ConditionalExpression` mutant replaces an `if` condition with `true`/`false`, which + // would drop the flow narrowing and leave `index` typed `JMap[String, Integer] | Null`, + // so `here.get(...)` stops compiling and the mutant dies as a compile error instead of + // being exercised (issue #115). A match has no condition to replace. Writing the guard + // as `if … then index.nn` fixes the mutant too, but the guard already narrows `index`, + // so the compiler reports E216 "Unnecessary .nn" and `-Werror` rejects it. + val here: JMap[String, Integer] = index match + case null => normalisedNameIndex(record) + case m => m val hit = here.get(normalisedName(n)) // A key present with `Ambiguous` is the same verdict the scan produced on its SECOND // hit: more than one schema field normalises to this name, so the signal is no signal. @@ -646,7 +655,11 @@ private[avro] object AvroWalk: val resolved: Integer | Null = if exact != null then Integer.valueOf(exact.pos) else - val here = if index != null then index else normalisedNameIndex(record) + // Same explicit-nulls / stryker `ConditionalExpression` hazard as in [[totalNominalIndex]]: + // a `match` keeps the null-narrowing unmutatable and the non-null type explicit. + val here: JMap[String, Integer] = index match + case null => normalisedNameIndex(record) + case m => m here.get(normalisedName(n)) resolved match case null => From 58db95d862bf9c6a9d3115631ba335b84c777dd4 Mon Sep 17 00:00:00 2001 From: Rodolfo Hansen Date: Fri, 2 Oct 2026 16:25:23 +0200 Subject: [PATCH 5/6] docs: refresh version pins and the stryker invocation doctrine CLAUDE.md said Scala 3.8.3 / sbt 1.12.9; the project builds Scala 3.9.0 (build.sbt scala3Version) on sbt 1.13.0 (project/build.properties). CONTRIBUTING.md's bootstrap list pointed Scala at build.properties and omitted the JDK 25 requirement for kyo + the docs site. The 'invoke as project ; stryker, NOT /stryker' claim stopped being true in 0.20.4 (module-scoped task resolution was fixed upstream); state the history instead of forbidding the working form. The mutationAll alias also covers zio and kyo now, not just the original eight modules. quality-assurance.md's scoverage path follows the Scala version. --- CLAUDE.md | 24 ++++++++++++------------ CONTRIBUTING.md | 6 +++--- build.sbt | 8 ++++---- site/docs/quality-assurance.md | 7 ++++--- 4 files changed, 23 insertions(+), 22 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index f8f39344..b1b26934 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -3,8 +3,8 @@ ## Project `cats-eo` — an Existential Optics library for Scala 3, built on top of -[cats](https://typelevel.org/cats/). Scala `3.8.3` via sbt `1.12.9` -(`project/build.properties`), runs on JDK 17, 21, or 25 — but the `kyo` module (and therefore the docs site) needs JDK 25; on older JVMs it drops out of the root aggregate. +[cats](https://typelevel.org/cats/). Scala `3.9.0` (the `scala3Version` pin in +`build.sbt`) via sbt `1.13.0` (`project/build.properties`), runs on JDK 17, 21, or 25 — but the `kyo` module (and therefore the docs site) needs JDK 25; on older JVMs it drops out of the root aggregate. Human contributors: see [`CONTRIBUTING.md`](./CONTRIBUTING.md) for the day-one bootstrap. This file is the parallel guide for AI agents. @@ -44,7 +44,7 @@ curl -fLo /usr/local/bin/cs \ https://github.com/coursier/coursier/releases/latest/download/cs-x86_64-pc-linux.gz \ && gunzip -f /usr/local/bin/cs && chmod +x /usr/local/bin/cs -# A recent JVM (sbt 1.12 supports JDK 17 and JDK 21) +# A recent JVM (the project runs on JDK 17, 21, or 25; `kyo` needs 25) cs java --jvm temurin:21 --setup # writes JAVA_HOME into ~/.profile # Scala dev tools @@ -54,10 +54,10 @@ cs install --install-dir /usr/local/bin sbt scala scalafmt scalafix metals metal After `cs java --setup`, open a fresh shell (or `source ~/.profile`) so `JAVA_HOME` is on your PATH. -Installed versions in this environment: `sbt 1.12.9`, `scala-runner 1.12.4` -(Scala `3.8.3` by default, matching the project), `scalafmt 3.11.0` (honours -the `version` pin in `.scalafmt.conf`), `scalafix 0.14.6`, `metals 1.6.7`, -`metals-mcp 1.6.7`. +Installed versions in this environment: `sbt 1.12.9` (the launcher script — it +boots the sbt version pinned in `project/build.properties`), `scala-runner +1.12.4`, `scalafmt 3.11.0` (honours the `version` pin in `.scalafmt.conf`), +`scalafix 0.14.6`, `metals 1.6.7`, `metals-mcp 1.6.7`. ### Day-to-day commands @@ -136,12 +136,12 @@ SBT_OPTS="-Xmx6g" sbt mutationAll Key facts, all the hard-won kind: -- **Invoke as `project ; stryker`, NOT `/stryker`.** The - module-scoped task form reads `loadedTestFrameworks` from the - aggregating root project (no test deps), so specs2 is invisible and - *every* mutant comes back `NoCoverage`. The `mutationAll` alias uses the +- **Invoke as `project ; stryker`** — the `mutationAll` alias uses the project-switch form across core, laws, generics, schemes, schemesLaws, - circe, avro, jsoniter. + circe, avro, jsoniter, zio, kyo. The module-scoped `/stryker` form works + again since 0.20.4; before that it resolved `loadedTestFrameworks` from the + aggregating root project (no test deps), so specs2 was invisible and + *every* mutant came back `NoCoverage`. - **It's a report, not a gate** (`strykerThresholdsBreak := 0`): a low score never fails the build. - **`core` and `laws` are scored against the `tests/` suite via diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 9da95010..8ad58947 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -14,9 +14,9 @@ canonical reference and stays current with what CI runs. In short, you need: -- JDK 17 or JDK 21 (Temurin is fine). -- `sbt` 1.12.x. -- Scala 3.8.x (matches `project/build.properties`). +- JDK 17 or JDK 21 (Temurin is fine) — JDK 25 for the `kyo` module and the docs site. +- `sbt` 1.13.x (the launcher boots the version pinned in `project/build.properties`). +- Scala 3.9.x (pinned as `scala3Version` in `build.sbt`). - `scalafmt` 3.11.x (honours the pin in `.scalafmt.conf`). Clone the repo, then optionally enable the project git hooks so your diff --git a/build.sbt b/build.sbt index e9ecd931..5573f859 100644 --- a/build.sbt +++ b/build.sbt @@ -1176,10 +1176,10 @@ addCommandAlias( ) // Mutation testing across the published modules (tests/benchmarks/docs -// aren't published). Uses the `project ; stryker` form, NOT -// `/stryker`: see the plugins.sbt note — the module-scoped task reads -// `loadedTestFrameworks` from the empty root project and marks every -// mutant NoCoverage. +// aren't published). Uses the `project ; stryker` form — the alias +// switches projects anyway, and the borrowed-tests `set` lines below are +// expressed against those switches. (The `/stryker` form works too +// since 0.20.4; see the plugins.sbt note.) // // The `set` lines borrow the `tests` module's compiled suite into core's // and laws' Test scopes so their mutants get killed by the behavioural diff --git a/site/docs/quality-assurance.md b/site/docs/quality-assurance.md index 1672b53d..cf46564c 100644 --- a/site/docs/quality-assurance.md +++ b/site/docs/quality-assurance.md @@ -386,7 +386,7 @@ no change to the suite. Do not read such a delta as a regression. ```sh # Statement / branch coverage (cross-module aggregate): SBT_OPTS="-Xmx6g" sbt coverageAll -# → target/scala-3.8.3/scoverage-report/ (HTML + scoverage.xml) +# → target/scala-3.9.0/scoverage-report/ (HTML + scoverage.xml) # Mutation testing across the runtime-logic modules: SBT_OPTS="-Xmx6g" sbt mutationAll @@ -400,8 +400,9 @@ python3 site/tools/gen-qa-report.py --check # CI: non-zero if stale Both aliases relax the always-on `-Werror` (`tlFatalWarnings`) first, since instrumented sources can surface `-Wunused` warnings; the larger heap is because the `set` reapply re-evaluates the Laika docs settings. Mutation runs -with `project ; stryker` (not `/stryker`) so specs2 is visible to the -test runner — see +use `project ; stryker`; the module-scoped `/stryker` form works too +(upstream fixed it in 0.20.4 — before that it resolved `loadedTestFrameworks` +from the aggregating root project and marked every mutant `NoCoverage`) — see [`project/plugins.sbt`](https://github.com/Constructive-Programming/eo/blob/main/project/plugins.sbt). The [`quality.yml`](https://github.com/Constructive-Programming/eo/blob/main/.github/workflows/quality.yml) From 508b74fa6dac5e65529ffbfd30b3917b20aa63da Mon Sep 17 00:00:00 2001 From: Rodolfo Hansen Date: Fri, 2 Oct 2026 20:28:18 +0200 Subject: [PATCH 6/6] build(ci): bump the workflow generator's actions/cache ref to v6 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The cache bump landed as a hand-edit of the generated ci.yml (mirroring Dependabot's #118 diff), which githubWorkflowCheck rightly flags: ci.yml is generator-owned (CONTRIBUTING.md: never hand-edit it). This pairs the edit with its source of truth — UseRef.Public("actions", "cache", "v6") in the setup-java cache patch. githubWorkflowGenerate now emits zero workflow drift, so the committed ci.yml is exactly generated output, and githubWorkflowCheck passes. --- build.sbt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build.sbt b/build.sbt index 5573f859..cbf57a80 100644 --- a/build.sbt +++ b/build.sbt @@ -248,7 +248,7 @@ ThisBuild / githubWorkflowJobSetup ~= { steps => case s: WorkflowStep.Use if s.id.exists(_.startsWith("setup-java-")) => val javaId = s.id.get.stripPrefix("setup-java-") val cacheStep = WorkflowStep.Use( - UseRef.Public("actions", "cache", "v4"), + UseRef.Public("actions", "cache", "v6"), params = Map( "path" -> Seq( "~/.cache/coursier",