Repository navigation
fix(kyo): brace the valuePrism extension so stryker4s can mutate the module - #109
Merged
Merged
Conversation
kryptt
changed the base branch from
fix/nominal-resolution-onto-main
to
main
September 18, 2026 13:31
…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
force-pushed
the
fix/kyo-stryker-extension-indentation
branch
from
September 18, 2026 13:45
3e7958b to
f6bef97
Compare
Contributor
|
🚀 Cloudflare Pages preview for https://f3414393.cats-eo-docs.pages.dev Branch alias: https://fix-kyo-stryker-extension-in.cats-eo-docs.pages.dev Built from commit |
Contributor
Benchmark A/BAllocation (B/op) — authoritative
Timing (ns/op) — directional only, same-VM but shared runner
base_sha: |
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What breaks
On an unmodified tree,
sbt mutationAll(build.sbt:1108,project kyoIntegration; stryker)aborts before it can score anything —
kyoIntegrationis the only module in the sweep thatdoes this:
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.scalaended with a single-method significant-indentation
extensionclause whose Scaladoc sitsinside the clause, on the method:
A one-method extension body is not a
Term.Block, so the printer emits the one-line formextension (…) def f = …. The method's leading Scaladoc is replayed verbatim between the two,which forces a newline and lands
defin column 0. Captured from the live sandbox(
kyo/target/stryker4s-<id>/…/StructureOptics.scala, lines 352-367):The
extensionclause is left with no method (Extension without extension methods), anddef valuePrismbecomes a top-level def whereAandselfare 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 aTerm.Block, theprinter emits
extension (…) { … }, and the newline beforedefis harmless.Alternatives considered:
Term.Blockprinting path regardless of what else might precede thedef.extensionvaluePrismwould render with no Scaladoc), and it only works while nothing else forces a newline before thedef.stryker.confstryker4s.confat all — stryker is configured entirely throughThisBuildsettings inbuild.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 -coverStructureOptics$package/StructureOptics$package$— byte-for-byte identical, no diff at all.javap -p -c -vover every class the file produces — the only deltas are 13LineNumberTableentries, all shifted by exactly
+13(the 13 comment lines added), and theTASTYattributechecksum (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/testunchanged: 12 + 23 + 27 + 30 examples, 0 failures, 0 errors.Before / after
From
kyo/target/stryker4s-report/1789735393824/report.json(schema v2). Matches the figure anearlier throwaway experiment reported. Per file:
schema/StructureOptics.scala— 20 Killed, 4 SurvivedRecordIsoMacro.scala— 28 NoCoverage, 15 Ignored (macro code: expands at compile time, somutants 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
extensionclause is apotential latent copy. Audited all 60
extensionclauses 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
extensionkeyword(
AvroJson,AvroPrism,AvroTraversal,AvroJsoniter,JsonPrism,JsonTraversal,JsoniterPrism,JsoniterTraversal,MultiFocus,Optic,Direct,Forget), so the printer'sone-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,zioall ran to completion with mutants in those very files(
AvroJsoniter.scala64,MultiFocus.scala55,JsoniterTraversal.scala17,AvroTraversal.scala16).5 latent copies remain, all in
kyo, all the exact broken shape (single method, Scaladocinside 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.scalaandSchemaOptics.scalacurrently generate zeromutants. 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)
kyocan now be added to the QA page. It has never appeared insite/docs/quality-assurance.md— partly because the module could not be scored, and partlybecause
site/tools/gen-qa-report.py's hardcodedMUTATION_MODULESlist stops atjsoniter(
ziois missing too). Adding("kyo", "kyo", "")there will make the nextquality.ymlrunrender 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 sbtproject,
sbt-stryker4s0.20.3, Scala 3.3.6, munit, one source file):sbt strykerre-prints it totarget/stryker4s-<id>/src/main/scala/example/Bug.scalaas:→
Extension without extension methods+Not found: self, thenUnableToFixCompilerErrorsException(exit 1).Five conditions, all necessary — each isolated by a passing control run:
extensionclause uses significant indentation (braces ⇒ passes, 40.0%);Term.Block⇒ braces ⇒ passes);Originis dropped, the printerre-synthesises it, the comment is discarded and
defstays on theextensionline ⇒ passes;Gates
All on Temurin JDK 25.0.3 (
JAVA_HOME=/usr/lib/jvm/openjdk-bin-25) — thekyomodule needsJDK 25 and silently leaves the root aggregate below it.
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,StructureOpticsSpecall ran, 0 failures across every modulesbt kyoIntegration/testsbt benchmarks/compile[success]sbt mimaReportBinaryIssues[success]sbt "docs/mdoc; docs/laikaSite"[success]sbt "scalafixAll; scalafmtAll"thensbt "scalafmtCheckAll; scalafixAll --check"project kyoIntegration; stryker🤖 Generated with Claude Code
https://claude.ai/code/session_0194EHFR4NamCpTHiqy7B74V