diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml index 068186fa..d887fe8c 100644 --- a/.github/workflows/quality.yml +++ b/.github/workflows/quality.yml @@ -129,7 +129,14 @@ jobs: "project laws" stryker \ || echo "::warning title=stryker::mutation run failed for laws (see log / quality-assurance.md notes)" echo "::endgroup::" - for p in generics schemes circeIntegration avroIntegration jsoniterIntegration; do + # Keep this list in step with the `mutationAll` alias in build.sbt and + # with MUTATION_MODULES in site/tools/gen-qa-report.py: a module missing + # here produces no report.json, so its row on the QA page silently falls + # back to dashes (or, worse, keeps whatever a developer's local run left + # behind). These are sbt PROJECT IDS, which differ from the directory + # names the generator globs. + for p in generics schemes schemesLaws circeIntegration avroIntegration \ + jsoniterIntegration zioIntegration kyoIntegration; do echo "::group::stryker $p" sbt -batch "set ThisBuild/tlFatalWarnings := false" "project $p" stryker \ || echo "::warning title=stryker::mutation run failed for $p (see log / quality-assurance.md notes)" diff --git a/site/docs/quality-assurance.md b/site/docs/quality-assurance.md index e5e9c5d6..5fd32b4b 100644 --- a/site/docs/quality-assurance.md +++ b/site/docs/quality-assurance.md @@ -166,7 +166,7 @@ What *is* structural and permanent — do not spend a test-writing budget on it: is the entire reason kyo's *total* score reads far below its *covered* score. The high-signal rows are `core` and `laws` (via the borrowed suite), `schemes`, -`circe`, `jsoniter` and `avro` — modules whose mutated code is genuinely +`circe`, `jsoniter`, `avro` and `zio` — modules whose mutated code is genuinely exercised at run time by the suite stryker runs. @@ -181,6 +181,8 @@ exercised at run time by the suite stryker runs. | `circe` | 35 | 0 | 3 | 15 | 0 | 66.0% | 92.1% | | | `avro` | 283 | 0 | 57 | 34 | 6 | 75.7% | 83.2% | Scores fine (~2 min): the old "forked test-runner fails to initialise" caveat no longer reproduces. Its no-coverage mutants are `AvroPrismMacro` quoted-macro bodies — compile-time only, like `generics`. | | `jsoniter` | 321 | 4 | 77 | 0 | 0 | 80.8% | 80.8% | Mutates clean end to end (0 compile errors): the old `PathParser.parseField` 64 KB method-size caveat no longer reproduces. | +| `zio` | 37 | 0 | 6 | 0 | 0 | 86.0% | 86.0% | | +| `kyo` | 30 | 0 | 5 | 28 | 0 | 47.6% | 85.7% | The no-coverage block is all `RecordIsoMacro`: quoted-macro code that expands at compile time, so like `generics` its mutants leave no runtime footprint. The covered score is the one that reads the hand-written optics. | @@ -189,18 +191,37 @@ tree. The most recent full sweep (**2026-09-18**, JDK 25, `project ; stryker` per module), after the survivor-killing pass of the same day, measured: `core` 193 K / 4 T / 19 S, `laws` 85 K / 0 S, `schemes` 48 K / 8 S, `circe` 44 K / 6 S, `jsoniter` 328 K / 4 T / 58 S, `avro` 391 K / 78 S, -`generics` 0 K / 86 NC. - -Two modules in the `mutationAll` alias have never had a row here: - -- **`zio`** — **0 mutants exist**. The module is pure optic construction: no - conditional, no arithmetic, no boolean literal for stryker to mutate. Its - score is `n/a`, not 0 % — there is no pool. -- **`kyo`** — scores 83.3 % *covered* (20/24 on `schema/StructureOptics.scala`; - everything else is `RecordIsoMacro`, compile-time only), but only once the - single-method `extension` block in that file is **braced**: re-printed by - stryker4s, a significant-indentation `extension` clause loses its method to - column 0 and the whole file stops compiling, aborting the module. +`generics` 0 K / 86 NC, `zio` 37 K / 6 S, `kyo` 30 K / 5 S / 28 NC. + +The two effect-system integrations are the newest rows in that table, and each +needs one line of reading: + +- **`zio`** is a clean, high-signal row: 37 killed / 6 survived, **no** + no-coverage block at all. Everything stryker can mutate in the module — the + `DynamicValue` and `zio.json` tree navigation, the `JsonCursor` write descent, + the `Chunk` index guards — is genuinely executed by the suite. (It was not + always: before the ecosystem optics landed, the module was `ZEnvironment` / + `ZLayer` / `Ref` wiring that delegated straight into ZIO's own API and offered + a mutator no operator, literal or branch to change.) +- **`kyo`**'s low *total* score is a macro artefact, not a coverage hole: every + one of its 28 no-coverage mutants is in `RecordIsoMacro`, quoted-macro code + that expands at compile time — the same structural reason `generics` scores + 0 %. The *covered* column, 85.7 % (30/35, the rest on + `schema/StructureOptics.scala`), is the one that describes the hand-written + optics. + +Both modules score at all only because their single-method `extension` clauses +are **braced**. Re-printed by stryker4s, a significant-indentation `extension` +clause with a Scaladoc'd body loses its method to column 0, the file stops +compiling, and the whole module run aborts before scoring anything — see the +comment on `kyo/schema/StructureOptics.scala` for the mechanism. Any new +single-method `extension` in these modules needs the same treatment. + +The generator distinguishes a module that produced **no mutants at all** from +one with **no report**: the first renders `n/a`, the second an em-dash. Nothing +currently hits the `n/a` path, but printing `0.0 %` for a module a mutator +cannot touch would read as a test-quality failure where there is no bar to +fail. ### Known equivalent mutants diff --git a/site/tools/gen-qa-report.py b/site/tools/gen-qa-report.py index 85ea0021..f5444a86 100644 --- a/site/tools/gen-qa-report.py +++ b/site/tools/gen-qa-report.py @@ -48,6 +48,11 @@ ] # Modules stryker mutates. value = (on-disk module dir, human label, note). +# The FIRST element is the DIRECTORY, not the sbt project id — `latest_report` +# globs `/target/stryker4s-report/*/report.json`. Several ids differ from +# their directory (`circeIntegration` → `circe/`, `zioIntegration` → `zio/`, +# `schemesLaws` → `schemes-laws/`); use the directory here and the id in the +# `mutationAll` alias / quality.yml loop. # The note is the Notes-column annotation; for modules that can't be scored it # doubles as the "why" shown when no report.json exists. If a report later # appears, its numbers take over and the note still annotates the row — so a @@ -63,6 +68,8 @@ ("circe", "circe", ""), ("avro", "avro", "Scores fine (~2 min): the old \"forked test-runner fails to initialise\" caveat no longer reproduces. Its no-coverage mutants are `AvroPrismMacro` quoted-macro bodies — compile-time only, like `generics`."), ("jsoniter", "jsoniter", "Mutates clean end to end (0 compile errors): the old `PathParser.parseField` 64 KB method-size caveat no longer reproduces."), + ("zio", "zio", ""), + ("kyo", "kyo", "The no-coverage block is all `RecordIsoMacro`: quoted-macro code that expands at compile time, so like `generics` its mutants leave no runtime footprint. The covered score is the one that reads the hand-written optics."), ] @@ -206,8 +213,18 @@ def gen_mutation() -> str: detected = killed + timeout scored = detected + survived + nocov covered = detected + survived - total_score = f"{100.0 * detected / scored:.1f}%" if scored else "—" - cov_score = f"{100.0 * detected / covered:.1f}%" if covered else "—" + if scored: + total_score = f"{100.0 * detected / scored:.1f}%" + # All-NoCoverage modules (`generics`) do have mutants, they are just + # never executed: total score 0%, covered score undefined. + cov_score = f"{100.0 * detected / covered:.1f}%" if covered else "—" + else: + # A report exists but stryker generated NO mutants at all (excluding + # Ignored ones). There is nothing to score, which is emphatically not + # a score of zero — printing 0.0% here would read as a test-quality + # failure for a module that offers a mutator no purchase. stryker's + # own console prints `n/a%` in this case; match it. + total_score = cov_score = "n/a" rows.append( f"| `{label}` | {killed} | {timeout} | {survived} | {nocov} | {cerr} | " f"{total_score} | {cov_score} | {note} |" diff --git a/zio/src/main/scala/dev/constructive/eo/zio/json/JsonOptics.scala b/zio/src/main/scala/dev/constructive/eo/zio/json/JsonOptics.scala index cae8d07d..be2df20c 100644 --- a/zio/src/main/scala/dev/constructive/eo/zio/json/JsonOptics.scala +++ b/zio/src/main/scala/dev/constructive/eo/zio/json/JsonOptics.scala @@ -167,7 +167,17 @@ object JsonValues: end JsonValues -extension [To <: Json](self: JsonCursor[?, To]) +// Braced body, against the significant-indentation house style, on purpose - the +// same stryker4s 0.20.3 re-print hazard that `kyo/schema/StructureOptics.scala` +// documents at length. A SINGLE-METHOD significant-indentation `extension` clause +// comes back from scalameta as the one-line form, and the method's leading +// Scaladoc is replayed verbatim between the two: the forced newline lands `def` in +// column 0, the clause is left with no extension method, and the file stops +// compiling - so `project zioIntegration; stryker` aborts with +// UnableToFixCompilerErrorsException before scoring a single mutant. Braces make +// the body a `Term.Block`, the printer emits the braced form, and the newline is +// harmless. Bytecode-identical. Revert when stryker4s fixes the re-print. +extension [To <: Json](self: JsonCursor[?, To]) { /** The cursor as a sibling-preserving eo Optional — reads via zio-json's own `Json.get`, writes * by rebuilding exactly the spine the cursor describes. A cursor that misses (wrong shape, @@ -183,6 +193,8 @@ extension [To <: Json](self: JsonCursor[?, To]) (j, b) => writeSteps(cursorSteps(self, Nil), j, b).getOrElse(j), ) +} + /** The cursor's steps root-first — `JsonCursor` is parent-linked (leaf outermost), so the chain is * reversed with a `@tailrec` accumulator before the descent. */ @@ -222,7 +234,8 @@ private def writeSteps(steps: List[JsonCursor[?, ?]], node: Json, b: Json): Opti else None case _ :: rest => writeSteps(rest, node, b) // Identity mid-chain (unreachable: stripped above) -extension [A](self: JsonCodec[A]) +// Braced for the same stryker4s re-print reason as the clause above. +extension [A](self: JsonCodec[A]) { /** Prism between JSON text and `A` — encode/decode as the two halves, the same laws and caveats * as every byte face (roundtrip identity one way, re-encode normalisation the other, misses pass @@ -233,3 +246,5 @@ extension [A](self: JsonCodec[A]) s => self.decodeJson(s).fold(_ => Left(s), Right(_)), a => self.encodeJson(a, None).toString, ) + +} diff --git a/zio/src/main/scala/dev/constructive/eo/zio/schema/SchemaOptics.scala b/zio/src/main/scala/dev/constructive/eo/zio/schema/SchemaOptics.scala index 77c3c726..5164dbe1 100644 --- a/zio/src/main/scala/dev/constructive/eo/zio/schema/SchemaOptics.scala +++ b/zio/src/main/scala/dev/constructive/eo/zio/schema/SchemaOptics.scala @@ -60,7 +60,17 @@ object EoAccessorBuilder extends AccessorBuilder: .Lens[S, Chunk[A]](collection.toChunk, (_, ch) => collection.fromChunk(ch)) .andThen(Chunks.each[A, A]) -extension [A](self: Schema[A]) +// Braced body, against the significant-indentation house style, on purpose - the +// same stryker4s 0.20.3 re-print hazard that `kyo/schema/StructureOptics.scala` +// documents at length. A SINGLE-METHOD significant-indentation `extension` clause +// comes back from scalameta as the one-line form, and the method's leading +// Scaladoc is replayed verbatim between the two: the forced newline lands `def` in +// column 0, the clause is left with no extension method, and the file stops +// compiling - so `project zioIntegration; stryker` aborts with +// UnableToFixCompilerErrorsException before scoring a single mutant. Braces make +// the body a `Term.Block`, the printer emits the braced form, and the newline is +// harmless. Bytecode-identical. Revert when stryker4s fixes the re-print. +extension [A](self: Schema[A]) { /** Prism between the untyped `DynamicValue` tree and `A` — the typed ↔ untyped face. A failed * `toTypedValue` is a miss carrying the original tree back losslessly; decode-then-encode @@ -74,7 +84,10 @@ extension [A](self: Schema[A]) a => self.toDynamic(a), ) -extension [A](self: BinaryCodec[A]) +} + +// Braced for the same stryker4s re-print reason as the clause above. +extension [A](self: BinaryCodec[A]) { /** Prism between `self`-encoded bytes and `A` — `JsonCodec.schemaBasedBinaryCodec[A].prism` * reads/rewrites a typed value inside an encoded payload; compose outward with byte transports, @@ -85,3 +98,5 @@ extension [A](self: BinaryCodec[A]) bytes => self.decode(bytes).fold(_ => Left(bytes), Right(_)), a => self.encode(a), ) + +}