Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion .github/workflows/quality.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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)"
Expand Down
47 changes: 34 additions & 13 deletions site/docs/quality-assurance.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

<!-- BEGIN GENERATED: mutation -->
Expand All @@ -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. |

<!-- END GENERATED: mutation -->

Expand All @@ -189,18 +191,37 @@ tree. The most recent full sweep (**2026-09-18**, JDK 25, `project <m>; 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
Expand Down
21 changes: 19 additions & 2 deletions site/tools/gen-qa-report.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<dir>/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
Expand All @@ -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."),
]


Expand Down Expand Up @@ -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} |"
Expand Down
19 changes: 17 additions & 2 deletions zio/src/main/scala/dev/constructive/eo/zio/json/JsonOptics.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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.
*/
Expand Down Expand Up @@ -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
Expand All @@ -233,3 +246,5 @@ extension [A](self: JsonCodec[A])
s => self.decodeJson(s).fold(_ => Left(s), Right(_)),
a => self.encodeJson(a, None).toString,
)

}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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,
Expand All @@ -85,3 +98,5 @@ extension [A](self: BinaryCodec[A])
bytes => self.decode(bytes).fold(_ => Left(bytes), Right(_)),
a => self.encode(a),
)

}