Skip to content

fix(kyo): brace the valuePrism extension so stryker4s can mutate the module - #109

Merged
kryptt merged 1 commit into
mainfrom
fix/kyo-stryker-extension-indentation
Sep 18, 2026
Merged

kryptt merged 1 commit into
mainfrom
fix/kyo-stryker-extension-indentation

Conversation

@kryptt

@kryptt kryptt commented Sep 18, 2026 •

Copy link
Copy Markdown
Contributor

What breaks

On an unmodified tree, sbt mutationAll (build.sbt:1108, project kyoIntegration; stryker)
aborts before it can score anything — kyoIntegration is the only module in the sweep that
does this:

[info] Setting up mutated environment...
[info] 67 mutant(s) generated. Of which 15 mutant(s) are excluded.
[error] -- Error: …/kyo/target/stryker4s-17530133119958744035/src/main/scala/dev/constructive/eo/kyo/schema/StructureOptics.scala:364:0
[error] 364 |def valuePrism(using Frame): optics.MendTearPrism[Value, Value, A, A] = {
[error]     |^^^
[error]     |Extension without extension methods
[error] -- [E006] Not Found Error: …/StructureOptics.scala:364:64
[error] 364 |def valuePrism(using Frame): optics.MendTearPrism[Value, Value, A, A] = {
[error]     |                                                                ^
[error]     |                                                     Not found: type A
[error] -- [E006] Not Found Error: …/StructureOptics.scala:365:15
[error] 365 |  given Schema[A] = self
[error]     |               ^
[error]     |               Not found: type A
[error] -- [E006] Not Found Error: …/StructureOptics.scala:365:20
[error] 365 |  given Schema[A] = self
[error]     |                    ^^^^
[error]     |                    Not found: self
[error] 7 errors found
[error] (Compile / compileIncremental) Compilation failed
[info] Attempting to remove 7 mutant(s) that gave a compile error...
[info] 7 mutant(s) gave a compiler error. They will be marked as such in the report.
[error] No mutants were removed in …/kyo/src/main/scala/dev/constructive/eo/kyo/schema/StructureOptics.scala even though there were 7 compile errors.
[error] stryker4s.exception.UnableToFixCompilerErrorsException: Unable to remove non-compiling mutants in the mutated files. As a work-around you can exclude them in the stryker.conf. Please report this issue at https://github.com/stryker-mutator/stryker4s/issues
[error] src/main/scala/dev/constructive/eo/kyo/schema/StructureOptics.scala: 'Extension without extension methods'
[error] src/main/scala/dev/constructive/eo/kyo/schema/StructureOptics.scala: 'Not found: type A'   (×4)
[error] src/main/scala/dev/constructive/eo/kyo/schema/StructureOptics.scala: 'Not found: self'
[error] (stryker) stryker4s.exception.UnableToFixCompilerErrorsException: …

The breakage is structural, not a bad mutant, so stryker's mutant-removal recovery has nothing
to remove ("No mutants were removed … even though there were 7 compile errors") and the whole
module run dies. Reproduced twice, byte-identical.

Root cause

stryker4s 0.20.3 re-prints every file it mutates through scalameta. StructureOptics.scala
ended with a single-method significant-indentation extension clause whose Scaladoc sits
inside the clause, on the method:

extension [A](self: Schema[A])

  /** Prism between the untyped `Structure.Value` tree and `A` … */
  def valuePrism(using Frame): optics.MendTearPrism[Value, Value, A, A] =
    given Schema[A] = self
    …

A one-method extension body is not a Term.Block, so the printer emits the one-line form
extension (…) def f = …. The method's leading Scaladoc is replayed verbatim between the two,
which forces a newline and lands def in column 0. Captured from the live sandbox
(kyo/target/stryker4s-<id>/…/StructureOptics.scala, lines 352-367):

end StructureValues
extension [A](self: Schema[A]) 
/** Prism between the untyped `Structure.Value` tree and `A` — the typed ↔ untyped face beside
    * [[prism(Schema)]] (bytes) and [[stringPrism(Schema)]] (String), built on the public
    …
    */
def valuePrism(using Frame): optics.MendTearPrism[Value, Value, A, A] = {
  given Schema[A] = self
  Prism[Value, A](v => Structure.decode[A](v).foldError(Right(_), _ => Left(v)), a => Structure.encode(a))
}

The extension clause is left with no method (Extension without extension methods), and
def valuePrism becomes a top-level def where A and self are unbound
(Not found: type A, Not found: self). Exactly the three reported errors.

The fix, and why this one

Braces around the extension body. extension (…) { … } parses the body as a Term.Block, the
printer emits extension (…) { … }, and the newline before def is harmless.

Alternatives considered:

Option Verdict
Braces around the body Chosen. Bytecode-identical no-op, keeps the Scaladoc on the method it documents, and is structurally robust — it forces the Term.Block printing path regardless of what else might precede the def.
Hoist the Scaladoc above extension Works (verified: identical 38.46% / 83.33% score) and is the pattern the avro / circe / jsoniter clauses already use — which is exactly why they have never tripped. Rejected because it detaches a substantial doc comment from the method it documents (valuePrism would render with no Scaladoc), and it only works while nothing else forces a newline before the def.
Add a second method to the clause Rejected: invents public API to dodge a printer bug, and is fragile — the failure returns the moment the clause is back to one method.
File exclusion in stryker.conf Rejected: loses coverage of the one file in the module that actually scores (20 of the 24 kills come from it). Noted for the record: the repo has no stryker4s.conf at all — stryker is configured entirely through ThisBuild settings in build.sbt (strykerReporters, strykerThresholdsBreak, strykerExcludedMutations), so this option would also mean introducing a new config file.

Semantic identity

Compiled before and after, same JDK, same compiler flags:

  • javap -p -c over StructureOptics$package / StructureOptics$package$ — byte-for-byte identical, no diff at all.
  • javap -p -c -v over every class the file produces — the only deltas are 13 LineNumberTable
    entries, all shifted by exactly +13 (the 13 comment lines added), and the TASTY attribute
    checksum (which changes for every class in a source file whenever the file's text changes).
    No instruction, signature, member, or constant-pool-semantic difference.
  • sbt kyoIntegration/test unchanged: 12 + 23 + 27 + 30 examples, 0 failures, 0 errors.

Before / after

mutants Killed Survived NoCoverage Ignored score
before 67 generated — — — — module unmutatable — run aborts
after 67 20 4 28 15 38.46% total / 83.33% of covered

From kyo/target/stryker4s-report/1789735393824/report.json (schema v2). Matches the figure an
earlier throwaway experiment reported. Per file:

  • schema/StructureOptics.scala — 20 Killed, 4 Survived
  • RecordIsoMacro.scala — 28 NoCoverage, 15 Ignored (macro code: expands at compile time, so
    mutants leave no runtime footprint — the same caveat already documented for generics)

Sibling-risk audit

This is a Scala 3 significant-indentation re-printing bug, so every other extension clause is a
potential latent copy. Audited all 60 extension clauses across every module (main + test):

  • 17 are braced, multi-method, or already one-line → the printer emits a Term.Block, safe by construction.

  • 43 are single-method significant-indentation clauses — but the trigger is the comment position,
    not the method count. 38 of them carry their Scaladoc above the extension keyword
    (AvroJson, AvroPrism, AvroTraversal, AvroJsoniter, JsonPrism, JsonTraversal,
    JsoniterPrism, JsoniterTraversal, MultiFocus, Optic, Direct, Forget), so the printer's
    one-line form stays on one line. Confirmed empirically, not by inspection: the completed sweep
    reports for avro (614 mutants), circe (86), core (234), jsoniter (420), laws, schemes,
    schemes-laws, generics, zio all ran to completion with mutants in those very files
    (AvroJsoniter.scala 64, MultiFocus.scala 55, JsoniterTraversal.scala 17, AvroTraversal.scala 16).

  • 5 latent copies remain, all in kyo, all the exact broken shape (single method, Scaladoc
    inside the clause):

    • kyo/…/KyoOptics.scala:34 (Env.focus), :42 (Layer.focus)
    • kyo/…/schema/SchemaOptics.scala:29 (Focus.lens), :35 (Focus.toOptional), :47 (Focus.traversal)

    They are latent, not broken: stryker only re-prints files that contain at least one mutant, and
    KyoOptics.scala, RecordOptics.scala and SchemaOptics.scala currently generate zero
    mutants. Left alone deliberately — nothing demonstrably breaks today, and pre-emptively bracing
    five clauses would be a style rewrite against a hypothetical. They become real the moment a
    mutatable expression lands in one of those files; this PR's comment block is the breadcrumb.

Follow-ups (not in this PR)

  • kyo can now be added to the QA page. It has never appeared in
    site/docs/quality-assurance.md — partly because the module could not be scored, and partly
    because site/tools/gen-qa-report.py's hardcoded MUTATION_MODULES list stops at jsoniter
    (zio is missing too). Adding ("kyo", "kyo", "") there will make the next quality.yml run
    render the row.

  • Upstream: this is a stryker4s source-re-printing bug, worth filing at
    stryker-mutator/stryker4s. A standalone minimal reproducer was built and verified (one sbt
    project, sbt-stryker4s 0.20.3, Scala 3.3.6, munit, one source file):

    package example
    
    object Mutated:
      // A mutatable expression SOMEWHERE in the file: this is what makes
      // stryker4s re-print the file at all.
      def clamp(i: Int): Int = if i > 0 then i else 0
    
    extension [A](self: List[A])
    
      /** A leading Scaladoc on the ONLY method of a significant-indentation
        * `extension` clause. The method body itself must contain no mutant, so
        * that scalameta replays its original tokens (comment included) instead
        * of re-synthesising them.
        */
      def sizeTwice: Int = self.length * 2

    sbt stryker re-prints it to target/stryker4s-<id>/src/main/scala/example/Bug.scala as:

    extension [A](self: List[A]) 
    /** A leading Scaladoc on the ONLY method of a significant-indentation
        * `extension` clause. …
        */
    def sizeTwice: Int = self.length * 2

    → Extension without extension methods + Not found: self, then
    UnableToFixCompilerErrorsException (exit 1).

    Five conditions, all necessary — each isolated by a passing control run:

    1. the file contains at least one mutant (otherwise it is never re-printed);
    2. the extension clause uses significant indentation (braces ⇒ passes, 40.0%);
    3. the clause has exactly one method (2+ methods make the body a Term.Block ⇒ braces ⇒ passes);
    4. that method is not itself mutated — if it is, its Origin is dropped, the printer
      re-synthesises it, the comment is discarded and def stays on the extension line ⇒ passes;
    5. that method carries a leading comment inside the clause (comment removed ⇒ passes, 40.0%).

Gates

All on Temurin JDK 25.0.3 (JAVA_HOME=/usr/lib/jvm/openjdk-bin-25) — the kyo module needs
JDK 25 and silently leaves the root aggregate below it.

Gate Result
sbt "print kyoIntegration/name" ✅ cats-eo-kyo (module present)
sbt "++ 3" compile test (root aggregate) ✅ [success] 105 s — kyo was in it: KyoOpticsSpec, RecordOpticsSpec, SchemaOpticsSpec, StructureOpticsSpec all ran, 0 failures across every module
sbt kyoIntegration/test ✅ 12 + 23 + 27 + 30 examples, 0 failure, 0 error
sbt benchmarks/compile ✅ [success]
sbt mimaReportBinaryIssues ✅ [success]
sbt "docs/mdoc; docs/laikaSite" ✅ [success]
sbt "scalafixAll; scalafmtAll" then sbt "scalafmtCheckAll; scalafixAll --check" ✅ clean (scalafmt added one blank line before the closing brace; folded into the commit)
project kyoIntegration; stryker ✅ completes — 20 K / 4 S / 28 NC / 15 I

🤖 Generated with Claude Code

https://claude.ai/code/session_0194EHFR4NamCpTHiqy7B74V

…module

`sbt mutationAll` aborted on `project kyoIntegration; stryker` with
UnableToFixCompilerErrorsException before scoring a single mutant — on an
unmodified tree. The module has therefore never appeared in the QA page.

stryker4s 0.20.3 re-prints every file it mutates through scalameta. A
single-method significant-indentation `extension` clause is not a `Term.Block`,
so the printer emits the one-line form `extension (…) def f = …`; the method's
leading Scaladoc is replayed verbatim between the two, forcing a newline that
lands `def valuePrism` in column 0. The clause is left with no extension method
('Extension without extension methods') and `A` / `self` fall out of scope
('Not found: type A', 'Not found: self'), so the file stops compiling. Because
the breakage is structural rather than a bad mutant, stryker's mutant-removal
recovery has nothing to remove and the whole module run dies.

Bracing the extension body makes the body a `Term.Block`, the printer emits
`extension (…) { … }`, and the newline before `def` is harmless. Hoisting the
Scaladoc above `extension` also works — it is why the avro / circe / jsoniter
clauses have never tripped — but it detaches a substantial doc comment from the
method it documents, so braces win.

Semantically identical: `javap -p -c` over the extension's owner classes is
byte-for-byte unchanged, and a full `javap -v` diff shows only the +13
LineNumberTable shift from the added comment plus the TASTY checksum. The
module's suites pass unchanged.

kyoIntegration now scores: 20 Killed / 4 Survived / 28 NoCoverage / 15 Ignored
— 38.46% total, 83.33% of covered code.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0194EHFR4NamCpTHiqy7B74V
kryptt added a commit that referenced this pull request Sep 18, 2026
The mutation sweep keeps getting re-triaged from scratch because the page
records only scores, never which survivors are unkillable. Forty of them are:
perf fast paths whose general path computes the same value, and loop-boundary
operators on cursors that advance by one. They are now tabulated per module,
keyed (file, line:column, mutator -> replacement), each with the reason read
off the source — core 19 (its entire survivor set), circe 6 (likewise),
schemes 8 (likewise), jsoniter 7 of 58.

Deliberately NOT certified: the 51 remaining JsonPathScanner survivors and
JsoniterPrism:130:8. One class of them was measured wrong today — 254:13
`>=` -> `>` was called equivalent and is killable, because the mutant makes the
left disjunct dead-false and the right one then indexes at
`kpos == bytes.length`. Any guard whose right-hand side indexes the array needs
`pos == length` reachability checked individually first; the page says so
instead of pretending.

Two caveats on this page were false and are retired, both re-tested today:
avro ("stryker's forked test-runner fails to initialise") scores in ~2 min, and
jsoniter ("instrumenting PathParser.parseField overflows the 64 KB method
limit") mutates clean with 0 compile errors. Root cause of the staleness:
gen-qa-report.py hard-codes the Notes column per module, so the caveat printed
over real numbers for months. The notes are corrected at the source and the
comment above them now says a claim of unscoreability must be retired when
numbers land.

Recorded as structural and permanent instead: generics' 86 NoCoverage mutants,
avro's 17 AvroPrismMacro ones and kyo's 28 RecordIsoMacro ones are all
quoted-macro bodies that expand in the compiler and have no runtime footprint.
Also adds the never-before-reported zio (0 mutants exist — no pool, score n/a)
and kyo (83.3% covered, needs the braced extension of #109 to run at all)
readings, and a note that the generated table lags between release tags, with
the current sweep's numbers.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0194EHFR4NamCpTHiqy7B74V
@kryptt
kryptt force-pushed the fix/kyo-stryker-extension-indentation branch from 3e7958b to f6bef97 Compare September 18, 2026 13:45
@github-actions

Copy link
Copy Markdown
Contributor

🚀 Cloudflare Pages preview for fix/kyo-stryker-extension-indentation is live:

https://f3414393.cats-eo-docs.pages.dev

Branch alias: https://fix-kyo-stryker-extension-in.cats-eo-docs.pages.dev

Built from commit f6bef97f06f92354cdde0a382a52184cfae3aa2e · updated on every push.

@github-actions

Copy link
Copy Markdown
Contributor

Benchmark A/B

Allocation (B/op) — authoritative

Benchmark params base head Δ
KyoDiBench.eoMapGet - 0.0 0.0 -0.2%
KyoDiBench.handMapGet - 0.0 0.0 +0.2%
KyoDiBench.handEnvUse - 304.0 304.0 +0.0%
KyoDiBench.eoEnvFocus - 312.0 312.0 -0.0%
KyoDiBench.eoVarUpdateFocus - 200.0 200.0 -0.0%
KyoDiBench.eoMapDrillModify - 216.0 216.0 +0.0%
KyoDiBench.handVarUpdate - 184.0 184.0 +0.0%
KyoDiBench.handMapDrillModify - 216.0 216.0 -0.0%
Timing (ns/op) — directional only, same-VM but shared runner
Benchmark params base head Δ
KyoDiBench.handEnvUse - 60.7 69.6 +14.6%
KyoDiBench.eoEnvFocus - 67.1 62.7 -6.6%
KyoDiBench.eoVarUpdateFocus - 56.8 54.6 -3.9%
KyoDiBench.eoMapDrillModify - 40.7 42.0 +3.3%
KyoDiBench.handVarUpdate - 50.5 51.4 +1.7%
KyoDiBench.handMapDrillModify - 35.8 35.7 -0.5%
KyoDiBench.eoMapGet - 2.8 2.8 -0.1%
KyoDiBench.handMapGet - 2.2 2.2 -0.0%

base_sha: 75c7eaa712a23c7503b51858dcdfc61347e600c8 · head_sha: f6bef97f06f92354cdde0a382a52184cfae3aa2e · jdk: temurin-21 · runner: ubuntu-22.04 · jmh_params: -i 3 -wi 2 -f 1 -t 1 -foe true -prof gc -rf json · profile: pr:-i3-wi2-f1-t1-gc

@kryptt
kryptt merged commit d095486 into main Sep 18, 2026
16 checks passed
kryptt added a commit that referenced this pull request Sep 18, 2026
…e, circe bounds property, core exists/Index, and the equivalent-mutant catalogue (#111)

* test(jsoniter): structural scanner oracle replaces the no-throw prefix property — kills the end-of-input guard cluster

The prefix property drove every skip*/find* guard already; its oracle was
`Try(...).isSuccess`, i.e. "did not throw". Every mutant in the cluster turns
a Miss into a wrong-but-non-throwing Span, so the oracle was blind to all of
them.

Replaced in place with a model-generator + three-part oracle:
  (a) ABSOLUTE — the generator returns the rendered children alongside the
      document, so each top-level step`s expected span text is known without
      a second parse. A purely differential oracle compares two runs of the
      SAME mutated code and passes any uniformly-wrong scanner.
  (b) BOUNDS — 0 <= start <= end <= len on both `find` and `findAll`.
  (c) DIFFERENTIAL — R1/R2/R3 between prefix and full.

Separators became ", " and child counts became frequency-weighted so empty
containers occur in value position and a post-comma-space cut is reachable.
Exception = failure now, so the no-throw guarantee is kept for free.

Subsumed and deleted: section 8`s rootHit/negHit/untermOk clauses, and
literalCases rows 1-6 (valid + truncated literals). Widened the existing
numberGen exponent alternatives with digit-less "e"/"E".

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0194EHFR4NamCpTHiqy7B74V

* test(jsoniter): nine malformed constants for the guards no generator can reach — kills 11 mutants in 9 data rows

Each row is a byte layout whose broken syntax is followed by bytes that
accidentally spell the probed member/element, so a dropped guard mis-parses
onto the target instead of being rejected by a later guard:

  1"target":2         a step applied to a non-container value (78/85/108/113/118)
  {"a":}              a value position holding no value at all (240:41)
  {Xk0":1}            key-quote guard; skipString finds the CLOSING quote (168)
  {"k0"1 2}           colon guard (173:8)
  ...nested twins     the same two inside a SKIPPED value (254:8, 258:8)

The existing malformed block`s oracle widened from `!find(...).isHit` to also
require `findAll(...) == Nil`, which is what makes the walkAll guards
observable; the widening covers the six pre-existing rows at no extra lines.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0194EHFR4NamCpTHiqy7B74V

* test(jsoniter): delete the object-count and array-index sweeps — subsumed, 0 kills lost

The new absolute oracle asserts each top-level child`s span text by index on
every generated document, which is exactly what these two enumerated sweeps
were: a target-position sweep plus absent-key / index-past-end / negative-index
Miss rows plus a skip-over-a-sibling assertion.

Measured, not assumed: stryker before the deletion 328K/58S, after 328K/58S —
zero regressions by mutant key (file, line, column, mutator, replacement).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0194EHFR4NamCpTHiqy7B74V

* test(circe): differential index-bounds oracle — kills 6 mutants in -10 lines

`JsonWalk` carries two copies of `idx < 0 || idx >= arr.length`: one in
`readPath` (:56), one in `modifyPath` (:81). `JsonIndexBoundsSpec` was written
for :56 but drove only `.modify(...)`, so all six operator mutants on the read
copy survived while the write copy was 11/11 killed.

Replace the hard-coded example with one `forAll(Gen.chooseNum(-2, 5))` property
over a fixed size-3 array whose expectations are DERIVED from the backing
`Vector`, driving three surfaces per index: `get` (read → :56), `modify` (Ior
write → :81) and `modifyUnsafe` (silent pass-through). The range straddles all
five discriminating classes — i<0, i=0, 0<i<3, i=3, i>3 — which is what the
two `>=` variants need (i=3 alone kills `>=`→`>`, i>=4 alone kills `>=`→`==`).

Deletions: the subsumed `unsafeOOR` / `defaultOOR` / `negIndex` assertions in
JsonPrismSpec. `defaultOOR` was already redundant with JsonFailureSpec:66-71;
the other two are subsumed by the property's `silent` conjunct, which asserts
pass-through across every out-of-range class rather than two constants.

Measured: circe 38K/12S → 44K/6S (76.00% → 88.00%), net -10 test lines, suite
test count unchanged. The remaining 6 survivors are provably equivalent mutants
(`i >= n` → `i == n` on two `+ 1`-incremented loop heads, and four empty-path
shortcuts whose general branch computes the same value), so circe is now at its
killable ceiling.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0194EHFR4NamCpTHiqy7B74V

* test(core): Index miss-write at the setter surface — kills 2 mutants in 2 lines

`Index`'s scaladoc promises a missed write is a silent pass-through: the Seq
setter must not throw and the Map setter must not insert. Both guards survived
mutation despite ContainerOpticsSpec already writing through a missed Index —
`Optional#modify` returns `getOrModify`'s `Left(t)` directly and never calls
the stored `reverseGet`, where the guard lives. No optic-surface operation
reaches it (`from` only calls it on `Affine.Hit`; `fuseToOptional` only on
`Right`; `OptionalLaws` holds the erased `Optic[S, S, A, A, Affine]`), so the
assertion is made against the public `reverseGet` field.

kills: optics/Index.scala:37:27, optics/Index.scala:55:27

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0194EHFR4NamCpTHiqy7B74V

* test(core): fold-surface agreement property — kills 2 mutants, 1 example out / 1 property in

Replaces OpticsBehaviorSpec's 14 hand-written "ForgetfulFold extensions"
assertions with one property over an independent oracle: for each carrier row,
foldMap / headOption / length / exists must agree with an expected focus list.

Two receiver shapes are load-bearing. `extAgrees` ascribes the optic to the
bare `Optic[…]`, the only way to reach the Optic.scala EXTENSION — `Traversal`
and `ForgetFold` (via `CanFold`) both declare member twins that win by
precedence, which is why the old `each.exists(…)` / `listFold.exists(…)` lines
never touched it. `cfAgrees` ascribes to `CanFold`, reaching the trait body.
The generator straddles both monoid classes on purpose: k in {-1, 4} with
n >= 1 gives "some focus satisfies" (needed for `||` -> `&&`), k = 10 and n = 0
give "none satisfies / empty fold" (needed for the `false` seed).

Also drops CapsMatrixSpec's `(zipOptional: CanFold[…]).exists(_ > 999999)`
line and its comment: the claim was false. `zipOptional`'s carrier is `Affine`
and `addr` HITS, so `foldMap` returns `f(a)` and the monoid identity is never
consulted — measured, that assertion killed nothing.

kills: CanFold.scala:27:41, optics/Optic.scala:443:69

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0194EHFR4NamCpTHiqy7B74V

* test(circe): fold the IndexOutOfRange fire scenario into the bounds property

`JsonFailureSpec` asserted that `at(5)` on a one-element array yields
`Ior.Both(IndexOutOfRange(Index(5), 1), …)`. That is one point of what the
rewritten `JsonIndexBoundsSpec` property now asserts across the whole index
range (-2..5 against a size-3 array), against an expectation derived from the
backing `Vector` rather than hard-coded — so the example adds no
discrimination the property does not already have. The message projection
(`size=3`), which the property does not look at, stays.

Measured: `project circeIntegration; stryker` re-run after the deletion gives
44 Killed / 6 Survived / 36 Ignored, byte-identical to the run before it on the
full (file, line, column, mutator, replacement) key — 0 mutants flipped, 0 keys
added or removed. -7 body lines, suite_test_count unchanged (the block is a
composite that keeps its four other cases).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0194EHFR4NamCpTHiqy7B74V

* docs(qa): document the equivalent mutants, retire two disproven caveats

The mutation sweep keeps getting re-triaged from scratch because the page
records only scores, never which survivors are unkillable. Forty of them are:
perf fast paths whose general path computes the same value, and loop-boundary
operators on cursors that advance by one. They are now tabulated per module,
keyed (file, line:column, mutator -> replacement), each with the reason read
off the source — core 19 (its entire survivor set), circe 6 (likewise),
schemes 8 (likewise), jsoniter 7 of 58.

Deliberately NOT certified: the 51 remaining JsonPathScanner survivors and
JsoniterPrism:130:8. One class of them was measured wrong today — 254:13
`>=` -> `>` was called equivalent and is killable, because the mutant makes the
left disjunct dead-false and the right one then indexes at
`kpos == bytes.length`. Any guard whose right-hand side indexes the array needs
`pos == length` reachability checked individually first; the page says so
instead of pretending.

Two caveats on this page were false and are retired, both re-tested today:
avro ("stryker's forked test-runner fails to initialise") scores in ~2 min, and
jsoniter ("instrumenting PathParser.parseField overflows the 64 KB method
limit") mutates clean with 0 compile errors. Root cause of the staleness:
gen-qa-report.py hard-codes the Notes column per module, so the caveat printed
over real numbers for months. The notes are corrected at the source and the
comment above them now says a claim of unscoreability must be retired when
numbers land.

Recorded as structural and permanent instead: generics' 86 NoCoverage mutants,
avro's 17 AvroPrismMacro ones and kyo's 28 RecordIsoMacro ones are all
quoted-macro bodies that expand in the compiler and have no runtime footprint.
Also adds the never-before-reported zio (0 mutants exist — no pool, score n/a)
and kyo (83.3% covered, needs the braced extension of #109 to run at all)
readings, and a note that the generated table lags between release tags, with
the current sweep's numbers.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0194EHFR4NamCpTHiqy7B74V

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
kryptt added a commit that referenced this pull request Sep 18, 2026
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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0194EHFR4NamCpTHiqy7B74V
kryptt added a commit that referenced this pull request Sep 18, 2026
…run (#113)

* fix(qa): score zio and kyo on the QA page

`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 `<module>/stryker`)
and the generator read the resulting report.json for both.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0194EHFR4NamCpTHiqy7B74V

* fix(zio): brace the extension clauses, and score zio for real

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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0194EHFR4NamCpTHiqy7B74V

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant