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 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/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 => diff --git a/build.sbt b/build.sbt index 4e104af5..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", @@ -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` @@ -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/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 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)