From 90b26e3a3e3a27720aaed3138631ed7a8842480a Mon Sep 17 00:00:00 2001 From: Rodolfo Hansen Date: Fri, 18 Sep 2026 16:05:36 +0200 Subject: [PATCH 1/2] fix(qa): score zio and kyo on the QA page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `site/tools/gen-qa-report.py`'s `MUTATION_MODULES` stopped at `jsoniter`, so the two effect-system integrations never had a row on the quality-assurance page even though `mutationAll` has been running stryker over both. The generator looked for reports it was never told to look for, which is the quietest possible failure: no error, no empty row, just absence. `.github/workflows/quality.yml`'s per-module loop had drifted the same way, and further: it was missing `schemesLaws` too. A module absent from that loop produces no report.json on the release-tag run, so its row falls back to dashes — or, worse, keeps whatever numbers a developer's local run happened to leave on disk. The loop now matches the `mutationAll` alias, and both lists carry a comment saying they must stay in step. The loop takes sbt PROJECT IDS (`zioIntegration`), `MUTATION_MODULES` takes DIRECTORIES (`zio`); getting that backwards is what silently finds nothing, so it is written down. Two honest edge cases the rows had to render correctly: - `zio` generates ZERO mutants on main — it is `ZEnvironment` / `ZLayer` / `Ref` wiring that delegates straight into ZIO's API, with no operator, literal or branch for a mutator to change. That is `n/a`, NOT 0%: a module with nothing to mutate has not failed a test-quality bar, it has no bar to fail. The old code divided only when the denominator was non-zero, but it rendered the empty case as the same em-dash it uses for "no report at all", conflating "nothing to score" with "nothing ran". `n/a` is also what stryker's own console prints here. - `kyo` scores 38.5% total / 83.3% covered, and the entire no-coverage block is `RecordIsoMacro` — quoted-macro code that expands at compile time, the same structural reason `generics` scores 0%. The Notes column now says so, so the total score does not read as a coverage hole. The all-NoCoverage shape (`generics`: 0.0% total, covered undefined) is deliberately left alone — those mutants exist, they are simply never executed. Rows verified end to end: stryker was run per-module in a clone (`project zioIntegration; stryker`, same for kyo — never `/stryker`) and the generator read the resulting report.json for both. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_0194EHFR4NamCpTHiqy7B74V --- .github/workflows/quality.yml | 9 ++++++++- site/docs/quality-assurance.md | 30 ++++++++++++++++++++---------- site/tools/gen-qa-report.py | 21 +++++++++++++++++++-- 3 files changed, 47 insertions(+), 13 deletions(-) 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..bea505eb 100644 --- a/site/docs/quality-assurance.md +++ b/site/docs/quality-assurance.md @@ -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` | 0 | 0 | 0 | 0 | 0 | n/a | n/a | No mutants exist to score: the module is ZEnvironment / ZLayer / `Ref` wiring that delegates straight into ZIO's own API, with no operator, literal or branch for a mutator to change. `n/a`, not 0% — nothing to mutate is not a score of zero. | +| `kyo` | 20 | 0 | 4 | 28 | 0 | 38.5% | 83.3% | 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. | @@ -191,16 +193,24 @@ per module), after the survivor-killing pass of the same day, measured: `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. +The two effect-system integrations are the newest rows in that table, and both +need reading with care — neither number means what it looks like: + +- **`zio`** scores `n/a`, **not 0 %**. Stryker generates *zero* mutants for it: + the module is `ZEnvironment` / `ZLayer` / `Ref` wiring that delegates straight + into ZIO's own API, with no conditional, arithmetic or boolean literal for a + mutator to change. A module that offers a mutator nothing to change has not + failed a test-quality bar — it has no bar to fail, and there is no pool to + take a percentage of. (`n/a` is also what stryker's own console prints here.) +- **`kyo`**'s low *total* score is a macro artefact, not a coverage hole: every + one of its no-coverage mutants is in `RecordIsoMacro`, quoted-macro code that + expands at compile time — the same structural reason `generics` scores 0 %. + The *covered* column, 83.3 % (20/24, all on `schema/StructureOptics.scala`), + is the one that describes the hand-written optics. It scores at all only + because the single-method `extension` block in that file is now **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. ### Known equivalent mutants diff --git a/site/tools/gen-qa-report.py b/site/tools/gen-qa-report.py index 85ea0021..47d9a00f 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", "No mutants exist to score: the module is ZEnvironment / ZLayer / `Ref` wiring that delegates straight into ZIO's own API, with no operator, literal or branch for a mutator to change. `n/a`, not 0% — nothing to mutate is not a score of zero."), + ("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} |" From eee9a1272fb81dd638c0c6ccfc0c8eead34a3c1d Mon Sep 17 00:00:00 2001 From: Rodolfo Hansen Date: Fri, 18 Sep 2026 18:30:19 +0200 Subject: [PATCH 2/2] fix(zio): brace the extension clauses, and score zio for real MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The rebase onto main invalidated this branch's own premise. When it was written, `zio` was `ZEnvironment` / `ZLayer` / `Ref` wiring that delegated straight into ZIO's API, so stryker generated ZERO mutants for it and the honest row was `n/a`. #92 then landed the ecosystem optics — the zio-schema `DynamicValue` kit, the zio-json AST and `JsonCursor` bridge, the STM focus ops, `Chunks` — and with them a great deal of genuinely mutatable code. `project zioIntegration; stryker` on the rebased tree does not print `n/a`. It aborts, with the same UnableToFixCompilerErrorsException that #109 fixed for kyo, on `json/JsonOptics.scala`: 'Extension without extension methods' 'Not found: type To' / 'Not found: self' / 'Not found: type A' Same mechanism, same fix. stryker4s 0.20.3 re-prints every mutated file through scalameta; a SINGLE-METHOD significant-indentation `extension` clause is not a `Term.Block`, so it comes back as the one-line form with the method's leading Scaladoc replayed between the two, and the forced newline lands `def` in column 0. Four such clauses exist in the module — two in `json/JsonOptics` (`JsonCursor.optional`, `JsonCodec.stringPrism`) and two in `schema/SchemaOptics` (`Schema.dynamicPrism`, `BinaryCodec.prism`). Bracing their bodies makes the printer emit the braced form and the newline harmless. `ZioOptics`'s single-method clause already hoists its Scaladoc above `extension`, which dodges the same trap, so it is left alone. Verified, not asserted: `javap -p -c` over `JsonOptics$package$` and `SchemaOptics$package$` is byte-for-byte identical before and after, and `zioIntegration/test` passes unchanged (268 examples). With that, both effect-system rows are real measurements rather than placeholders, regenerated by `site/tools/gen-qa-report.py` from the actual report.json: | `zio` | 37 | 0 | 6 | 0 | 0 | 86.0% | 86.0% | | `kyo` | 30 | 0 | 5 | 28 | 0 | 47.6% | 85.7% | zio is a clean high-signal row: 37 killed, 6 survived, and no no-coverage block at all. kyo moved off #109's 20/4/28 (38.5% / 83.3%) because #111's survivor-killing pass landed in between; its 28 no-coverage mutants are still all `RecordIsoMacro`, so the note stands. The generator's `n/a` branch stays. Nothing hits it today, but rendering a zero-mutant module as the same em-dash it uses for "no report at all" conflates "nothing to score" with "nothing ran", and printing 0.0% would read as a test-quality failure where there is no bar to fail. The page now says so explicitly rather than pointing at zio as the example. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_0194EHFR4NamCpTHiqy7B74V --- site/docs/quality-assurance.md | 55 +++++++++++-------- site/tools/gen-qa-report.py | 2 +- .../constructive/eo/zio/json/JsonOptics.scala | 19 ++++++- .../eo/zio/schema/SchemaOptics.scala | 19 ++++++- 4 files changed, 68 insertions(+), 27 deletions(-) diff --git a/site/docs/quality-assurance.md b/site/docs/quality-assurance.md index bea505eb..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,8 +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` | 0 | 0 | 0 | 0 | 0 | n/a | n/a | No mutants exist to score: the module is ZEnvironment / ZLayer / `Ref` wiring that delegates straight into ZIO's own API, with no operator, literal or branch for a mutator to change. `n/a`, not 0% — nothing to mutate is not a score of zero. | -| `kyo` | 20 | 0 | 4 | 28 | 0 | 38.5% | 83.3% | 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. | +| `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. | @@ -191,26 +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. - -The two effect-system integrations are the newest rows in that table, and both -need reading with care — neither number means what it looks like: - -- **`zio`** scores `n/a`, **not 0 %**. Stryker generates *zero* mutants for it: - the module is `ZEnvironment` / `ZLayer` / `Ref` wiring that delegates straight - into ZIO's own API, with no conditional, arithmetic or boolean literal for a - mutator to change. A module that offers a mutator nothing to change has not - failed a test-quality bar — it has no bar to fail, and there is no pool to - take a percentage of. (`n/a` is also what stryker's own console prints here.) +`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 no-coverage mutants is in `RecordIsoMacro`, quoted-macro code that - expands at compile time — the same structural reason `generics` scores 0 %. - The *covered* column, 83.3 % (20/24, all on `schema/StructureOptics.scala`), - is the one that describes the hand-written optics. It scores at all only - because the single-method `extension` block in that file is now **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. + 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 47d9a00f..f5444a86 100644 --- a/site/tools/gen-qa-report.py +++ b/site/tools/gen-qa-report.py @@ -68,7 +68,7 @@ ("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", "No mutants exist to score: the module is ZEnvironment / ZLayer / `Ref` wiring that delegates straight into ZIO's own API, with no operator, literal or branch for a mutator to change. `n/a`, not 0% — nothing to mutate is not a score of zero."), + ("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."), ] 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), ) + +}