From da504c07c5f550c1c733fb76f42e0ebd5ecb0959 Mon Sep 17 00:00:00 2001 From: Rodolfo Hansen Date: Fri, 18 Sep 2026 14:52:32 +0200 Subject: [PATCH 1/8] =?UTF-8?q?test(jsoniter):=20structural=20scanner=20or?= =?UTF-8?q?acle=20replaces=20the=20no-throw=20prefix=20property=20?= =?UTF-8?q?=E2=80=94=20kills=20the=20end-of-input=20guard=20cluster?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) Claude-Session: https://claude.ai/code/session_0194EHFR4NamCpTHiqy7B74V --- .../jsoniter/JsonScannerRobustnessSpec.scala | 222 ++++++++++++------ 1 file changed, 149 insertions(+), 73 deletions(-) diff --git a/jsoniter/src/test/scala/dev/constructive/eo/jsoniter/JsonScannerRobustnessSpec.scala b/jsoniter/src/test/scala/dev/constructive/eo/jsoniter/JsonScannerRobustnessSpec.scala index 79dd96e2..9dbbc251 100644 --- a/jsoniter/src/test/scala/dev/constructive/eo/jsoniter/JsonScannerRobustnessSpec.scala +++ b/jsoniter/src/test/scala/dev/constructive/eo/jsoniter/JsonScannerRobustnessSpec.scala @@ -6,8 +6,9 @@ import org.scalacheck.{Gen, Prop} import org.specs2.ScalaCheck import org.specs2.mutable.Specification -/** Grammar-sweep and robustness specs for [[JsonPathScanner]] — truncation-safety across every - * prefix of a generated document, literal/number/object/array skip grammars, and `findAll` fan-out +/** Grammar-sweep and robustness specs for [[JsonPathScanner]] — a structural scanner property that + * checks every prefix of a generated document against a model (absolute spans, in-bounds spans, + * prefix/full agreement), plus literal/number/object/array skip grammars and `findAll` fan-out * over mixed steps. Complements [[JsoniterPrismSpec]] / [[JsoniterTraversalSpec]], which exercise * the optic-level surface rather than the scanner's byte-level dispatch. See per-block `covers` * comments for `JsonPathScanner.scala` line ranges targeted. @@ -19,76 +20,159 @@ class JsonScannerRobustnessSpec extends Specification with ScalaCheck: private def text(b: Array[Byte], span: JsonPathScanner.Span): String = new String(b.slice(span.start, span.end), "UTF-8") - // ----- 1: truncation safety ------------------------------------------- - - private def genValue(depth: Int): Gen[String] = - val leaves = Gen.oneOf( - Gen.const("true"), - Gen.const("false"), - Gen.const("null"), - Gen.choose(-100, 100).map(_.toString), - Gen.oneOf("a", "bb", "").map(s => s""""$s""""), - ) - if depth <= 0 then leaves + // ----- 1: structural scanner property ---------------------------------- + + /** A generated document together with the rendered text of its top-level children. Carrying the + * children lets the oracle state what each top-level step MUST resolve to without re-parsing — + * an absolute check that a purely differential prefix-vs-full oracle cannot make, because a + * uniformly-wrong scanner agrees with itself. + */ + final private case class DocModel(text: String, kind: Char, kids: List[String]) + + private val genLeaf: Gen[String] = Gen.oneOf( + Gen.const("true"), + Gen.const("false"), + Gen.const("null"), + Gen.choose(-100, 100).map(_.toString), + Gen.oneOf("a", "bb", "").map(s => s""""$s""""), + ) + + // Child counts are frequency-weighted rather than uniform so the two structural boundaries both + // occur often: 0 children puts `{}` / `[]` in VALUE position (the empty-container fast paths at + // JsonPathScanner 130/159/206/245), >= 2 children force a sibling to be SKIPPED before the + // target is reached (the find*Loop / skip*Loop advance arms). + private val genKidCount: Gen[Int] = Gen.frequency((2, 0), (3, 1), (3, 2), (2, 3)) + + // Members and elements are joined with ", " — the space after the comma is load-bearing: it is + // the only way a prefix cut can leave `skipObjectLoop` at a position whose whitespace run reaches + // end-of-input (line 264 advances to `np + 1` without a skipWs, unlike line 181). + private def genModel(depth: Int): Gen[DocModel] = + val leaf = genLeaf.map(DocModel(_, 'l', Nil)) + if depth <= 0 then leaf else + val kids = genKidCount.flatMap(n => Gen.listOfN(n, genModel(depth - 1).map(_.text))) Gen.oneOf( - leaves, - Gen - .choose(0, 3) - .flatMap(n => Gen.listOfN(n, genValue(depth - 1))) - .map(_.mkString("[", ",", "]")), - Gen - .choose(0, 3) - .flatMap(n => Gen.listOfN(n, genValue(depth - 1))) - .map(vs => vs.zipWithIndex.map((v, i) => s""""k$i":$v""").mkString("{", ",", "}")), + leaf, + kids.map(ks => DocModel(ks.mkString("[", ", ", "]"), 'a', ks)), + kids.map(ks => + DocModel( + ks.zipWithIndex.map((v, i) => s""""k$i":$v""").mkString("{", ", ", "}"), + 'o', + ks, + ) + ), ) - private val genDoc: Gen[String] = genValue(3) - private val repPaths: List[List[PathStep]] = List( Nil, List(PathStep.Field("k0")), + List(PathStep.Field("k1")), List(PathStep.Index(0)), List(PathStep.Wildcard), List(PathStep.Field("k0"), PathStep.Index(1)), ) - "find/findAll never throw on any prefix of a generated document" >> { - // covers: every skip*/find* function in JsonPathScanner.scala — a mid-document cut must - // resolve to Miss/partial-Nil at any byte boundary, never an exception (bounds checks at - // lines 142/166/226/260/278/300/etc). - Prop.forAll(genDoc) { doc => - val docBytes = bytes(doc) - @tailrec def loop(len: Int): Boolean = - if len > docBytes.length then true + /** Absolute oracle: what the scanner must return for the WHOLE document, stated from the model + * rather than from a second run of the scanner. + */ + private def absoluteViolations(model: DocModel, full: Array[Byte]): List[String] = + val root = JsonPathScanner.find(full, Nil) + val rootV = + if root == JsonPathScanner.Span(0, full.length) then Nil + else List(s"root: $root != Span(0,${full.length})") + val kidV = model.kind match + case 'o' => + val hits = model.kids.zipWithIndex.flatMap { (kid, i) => + val sp = JsonPathScanner.find(full, List(PathStep.Field(s"k$i"))) + if sp.isHit && text(full, sp) == kid then Nil else List(s"$$.k$i -> $sp, want '$kid'") + } + val absent = JsonPathScanner.find(full, List(PathStep.Field("zz"))) + hits ::: (if absent.isHit then List(s"$$.zz -> $absent, want Miss") else Nil) + case 'a' => + val hits = model.kids.zipWithIndex.flatMap { (kid, i) => + val sp = JsonPathScanner.find(full, List(PathStep.Index(i))) + if sp.isHit && text(full, sp) == kid then Nil else List(s"$$[$i] -> $sp, want '$kid'") + } + val oob = List(model.kids.length, -1) + .filter(i => JsonPathScanner.find(full, List(PathStep.Index(i))).isHit) + .map(i => s"$$[$i] hit, want Miss") + val starTexts = + JsonPathScanner.findAll(full, List(PathStep.Wildcard)).map(s => text(full, s)) + val starV = + if starTexts == model.kids then Nil else List(s"$$[*] -> $starTexts, want ${model.kids}") + hits ::: oob ::: starV + case _ => + val f = JsonPathScanner.find(full, List(PathStep.Field("k0"))) + val i = JsonPathScanner.find(full, List(PathStep.Index(0))) + (if f.isHit then List(s"leaf $$.k0 -> $f, want Miss") else Nil) ::: + (if i.isHit then List(s"leaf $$[0] -> $i, want Miss") else Nil) + rootV ::: kidV + + /** Bounds + differential oracle over every prefix `full.take(len)`, `len` enumerated exhaustively + * so byte-exact cuts (the last byte of a literal, the space after a comma) are straddled by + * construction rather than sampled. + * + * - bounds: every returned span satisfies `0 <= start <= end <= len`, on BOTH surfaces. + * - R1: a prefix hit is a truncation of the full hit (same start, end no further). + * - R2: when the full span fits entirely inside the prefix, the prefix answer IS the full + * answer. (The converse — "must be a Miss otherwise" — is false: the scanner is permissive + * about truncated numbers, so `{"k0":12` resolves `$.k0` to the Hit `1`.) + * - R3: `findAll` agrees on every span that ends strictly inside the prefix. + */ + private def prefixViolations(full: Array[Byte]): List[String] = + repPaths.flatMap { p => + val sf = JsonPathScanner.find(full, p) + val af = JsonPathScanner.findAll(full, p) + @tailrec def loop(len: Int, acc: List[String]): List[String] = + if len > full.length then acc else - val prefix = docBytes.take(len) - val stepsOk = repPaths.forall { p => - scala.util.Try(JsonPathScanner.find(prefix, p)).isSuccess && - scala.util.Try(JsonPathScanner.findAll(prefix, p)).isSuccess - } - if !stepsOk then false else loop(len + 1) - loop(0) + val pre = full.take(len) + val sp = JsonPathScanner.find(pre, p) + val ap = JsonPathScanner.findAll(pre, p) + val spans = (if sp.isHit then List(sp) else Nil) ::: ap + val v1 = + if spans.forall(s => s.start >= 0 && s.start <= s.end && s.end <= len) then Nil + else List(s"bounds len=$len p=$p sp=$sp ap=$ap") + val v2 = + if !sp.isHit || (sf.isHit && sp.start == sf.start && sp.end <= sf.end) then Nil + else List(s"R1 len=$len p=$p sp=$sp sf=$sf") + val v3 = + if !(sf.isHit && sf.end <= len) || sp == sf then Nil + else List(s"R2 len=$len p=$p sp=$sp sf=$sf") + val v4 = + if ap.filter(_.end < len) == af.filter(_.end < len) then Nil + else List(s"R3 len=$len p=$p ap=$ap af=$af") + loop(len + 1, v4 ::: v3 ::: v2 ::: v1 ::: acc) + loop(0, Nil) + } + + "scanner: absolute spans, in-bounds spans, and prefix/full agreement at every cut" >> { + // covers: JsonPathScanner.scala 104:12 (walkAll pushes a Span(pos,-1)), 245:8 + 245:14 + // (skipObject's `{}` fast path), 254:29 (skipObjectLoop's `||` un-short-circuited at + // end-of-input), 348:28 + 348:48 (`matches` width guard) — plus every skip*/find* bounds guard + // the previous `Try(...).isSuccess` oracle could only observe as "did not throw". An exception + // now fails the property directly, so the no-throw guarantee is kept for free. + Prop.forAll(genModel(3)) { model => + val full = bytes(model.text) + val all = absoluteViolations(model, full) ::: prefixViolations(full) + Prop.propBoolean(all.isEmpty) :| all.take(3).mkString(" | ") } } // ----- 2: literal sweep ------------------------------------------------- + // Valid and truncated literals are reached by the section-1 generator (literal leaves, every + // prefix); a CORRUPTED interior byte is not a prefix of any well-formed document, so these rows + // stay as pinned examples. private val literalCases: List[(String, Boolean)] = List( - ("""{"a":true,"b":1}""", true), - ("""{"a":false,"b":1}""", true), - ("""{"a":null,"b":1}""", true), - ("""{"a":tru""", false), - ("""{"a":fals""", false), - ("""{"a":nul""", false), ("""{"a":txue,"b":1}""", false), ("""{"a":falze,"b":1}""", false), ("""{"a":nudl,"b":1}""", false), ("""{"a":true ,"b":1}trailing""", true), ) - "literal skip: true/false/null valid/truncated/wrong-char/trailing, never throws" >> { - // covers: skipLiteral dispatch + width checks (lines 312-317). + "literal skip: wrong-char literals Miss, whitespace-before-comma and trailing bytes Hit" >> { + // covers: skipLiteral dispatch + width checks (lines 303-309). val allOk = literalCases.forall { case (doc, expectHit) => val r = scala.util.Try(JsonPathScanner.find(bytes(doc), List(PathStep.Field("b")))) @@ -99,16 +183,18 @@ class JsonScannerRobustnessSpec extends Specification with ScalaCheck: // ----- 3: number-format sweep ------------------------------------------- + // "e" / "E" with no exponent digits: malformed, but the scanner is documented as permissive and + // must not derail — it is what forces the exponent-sign guard (line 323) to be read. private val numberGen: Gen[String] = for sign <- Gen.oneOf("", "-") intPart <- Gen.oneOf("0", "7", "123") frac <- Gen.oneOf("", ".5", ".0") - exp <- Gen.oneOf("", "e1", "e+1", "e-1", "E2", "E+2", "E-2") + exp <- Gen.oneOf("", "e1", "e+1", "e-1", "E2", "E+2", "E-2", "e", "E") yield s"$sign$intPart$frac$exp" "number skip: sign/fraction/exponent grammar sweep — $.after always Hits" >> { - // covers: skipNumber sign/frac/exponent-sign branches (lines 322-334). + // covers: skipNumber sign/frac/exponent-sign branches (lines 314-326), incl. 323:12. Prop.forAll(numberGen) { num => val doc = bytes(s"""{"n":$num,"after":1}""") JsonPathScanner.find(doc, List(PathStep.Field("after"))).isHit @@ -150,11 +236,11 @@ class JsonScannerRobustnessSpec extends Specification with ScalaCheck: } } - // Malformed or truncated scalar/object inputs that must resolve to a no-throw Miss. - // (Missing-]/-array truncation is already exercised by the section-1 truncation property, - // which drives k0[idx] into every prefix of a generated array — skipArray + findArray guards.) + // Malformed inputs a document generator cannot produce: each has broken syntax whose bytes + // nonetheless spell a resolvable member/element, so the mis-parse a dropped guard causes lands + // exactly on the probed path instead of being rejected by a later guard. private val malformedNoThrowCases: List[(String, List[PathStep])] = List( - // truncated numbers — skipNumber/skipDigits end-of-buffer guards (lines 322-338) + // truncated numbers — skipNumber/skipDigits end-of-buffer guards (lines 314-331) ("""{"n":1e+""", List(PathStep.Field("after"))), ("""{"n":1.""", List(PathStep.Field("after"))), ("""{"n":-""", List(PathStep.Field("after"))), @@ -164,11 +250,13 @@ class JsonScannerRobustnessSpec extends Specification with ScalaCheck: ("""{"a" 1,"target":2}""", List(PathStep.Field("target"))), ) - "malformed number/object inputs never throw, resolve to Miss" >> { + "malformed number/object inputs never throw, resolve to Miss on both surfaces" >> { val allOk = malformedNoThrowCases.forall { case (doc, path) => - val r = scala.util.Try(JsonPathScanner.find(bytes(doc), path)) - r.isSuccess && !r.get.isHit + val b = bytes(doc) + val one = scala.util.Try(JsonPathScanner.find(b, path)) + val many = scala.util.Try(JsonPathScanner.findAll(b, path)) + one.isSuccess && !one.get.isHit && many.isSuccess && many.get.isEmpty } allOk must beTrue } @@ -184,7 +272,7 @@ class JsonScannerRobustnessSpec extends Specification with ScalaCheck: List((0, 0), (1, 0), (1, 1), (2, 0), (2, 1), (2, 2), (5, 0), (5, 2), (5, 4), (5, 5), (5, -1)) "array scan: length x index sweep — Hit iff 0<=idx> { - // covers: findArrayElementLoop (lines 220-234), skipArray (lines 276-292). + // covers: findArrayElementLoop (lines 212-226), skipArray (lines 268-284). Prop.forAll(Gen.oneOf(arraySwScenarios)) { case (len, idx) => val doc = buildArrayDoc(len) @@ -227,21 +315,9 @@ class JsonScannerRobustnessSpec extends Specification with ScalaCheck: // ----- 8: small pinned examples -------------------------------------------- - "small examples: root Nil-path, negative number, unterminated string, key-length mismatch" >> { - // covers: find() start boundary (lines 49-51), skipNumber '-' branch (line 323), skipString - // unterminated guard (line 308), stringEqualsAscii length guard (lines 197-198). - val root = bytes("""{"a":1}""") - val rootHit = JsonPathScanner.find(root, Nil).isHit must beTrue - - val neg = bytes("""{"x":-5,"y":1}""") - val negHit = JsonPathScanner.find(neg, List(PathStep.Field("y"))).isHit must beTrue - - val unterminated = bytes("""{"a":"oops""") - val untermR = scala.util.Try(JsonPathScanner.find(unterminated, List(PathStep.Field("a")))) - val untermOk = (untermR.isSuccess must beTrue).and(untermR.get.isHit must beFalse) - + "small example: a key-length mismatch must not match" >> { + // covers: stringEqualsAscii length guard (line 197). The generator's keys and probes are all + // two bytes wide, so the length half of that guard never fires with a mismatch under it. val lenMismatch = bytes("""{"ab":1,"target":2}""") - val lenOk = JsonPathScanner.find(lenMismatch, List(PathStep.Field("abc"))).isHit must beFalse - - rootHit.and(negHit).and(untermOk).and(lenOk) + JsonPathScanner.find(lenMismatch, List(PathStep.Field("abc"))).isHit must beFalse } From ff448acaa13fa9aa264e20334af4fb80e26e3c61 Mon Sep 17 00:00:00 2001 From: Rodolfo Hansen Date: Fri, 18 Sep 2026 14:52:32 +0200 Subject: [PATCH 2/8] =?UTF-8?q?test(jsoniter):=20nine=20malformed=20consta?= =?UTF-8?q?nts=20for=20the=20guards=20no=20generator=20can=20reach=20?= =?UTF-8?q?=E2=80=94=20kills=2011=20mutants=20in=209=20data=20rows?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) Claude-Session: https://claude.ai/code/session_0194EHFR4NamCpTHiqy7B74V --- .../jsoniter/JsonScannerRobustnessSpec.scala | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/jsoniter/src/test/scala/dev/constructive/eo/jsoniter/JsonScannerRobustnessSpec.scala b/jsoniter/src/test/scala/dev/constructive/eo/jsoniter/JsonScannerRobustnessSpec.scala index 9dbbc251..79f017d0 100644 --- a/jsoniter/src/test/scala/dev/constructive/eo/jsoniter/JsonScannerRobustnessSpec.scala +++ b/jsoniter/src/test/scala/dev/constructive/eo/jsoniter/JsonScannerRobustnessSpec.scala @@ -248,6 +248,24 @@ class JsonScannerRobustnessSpec extends Specification with ScalaCheck: // unquoted key / missing colon — findFieldValueLoop quote/colon guards (lines 168, 173) ("""{a:1,"target":2}""", List(PathStep.Field("target"))), ("""{"a" 1,"target":2}""", List(PathStep.Field("target"))), + // covers: 78:12, 85:12, 108:12, 113:12, 118:12 — a step applied to a NON-container value must + // Miss even when the bytes that follow spell a valid member/element. Dropping the container + // check makes `find` resolve `$.target` to `2` and `$[0]` / `$[*]` to `"target"`. + ("""1"target":2""", List(PathStep.Field("target"))), + ("""1"target":2""", List(PathStep.Index(0))), + ("""1"target":2""", List(PathStep.Wildcard)), + // covers: 240:41 — with the digit-range `&&` flipped to `||` every byte starts a number, so + // skipValue resolves the missing value to the zero-length Span(5,5) instead of -1. + ("""{"a":}""", List(PathStep.Field("a"))), + // covers: 168:8, 168:29, 173:8 — findFieldValueLoop's key-shape and colon guards, with the + // bytes laid out so the mis-parse lands exactly on the probed key: in `{Xk0":1}` a dropped + // quote check makes skipString find the CLOSING quote, so the compared key IS `k0`. + ("""{Xk0":1}""", List(PathStep.Field("k0"))), + ("""{"k0"1 2}""", List(PathStep.Field("k0"))), + // covers: 254:8, 258:8 — the skipObjectLoop twins of the two rows above, nested inside a value + // that must be skipped over on the way to a later member. + ("""{"a":{Xk0":1}, "target":2}""", List(PathStep.Field("target"))), + ("""{"a":{"k0"1 2}, "target":2}""", List(PathStep.Field("target"))), ) "malformed number/object inputs never throw, resolve to Miss on both surfaces" >> { From 958ea9a17da14557b12be34194c36535aa73b360 Mon Sep 17 00:00:00 2001 From: Rodolfo Hansen Date: Fri, 18 Sep 2026 14:59:49 +0200 Subject: [PATCH 3/8] =?UTF-8?q?test(jsoniter):=20delete=20the=20object-cou?= =?UTF-8?q?nt=20and=20array-index=20sweeps=20=E2=80=94=20subsumed,=200=20k?= =?UTF-8?q?ills=20lost?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) Claude-Session: https://claude.ai/code/session_0194EHFR4NamCpTHiqy7B74V --- .../jsoniter/JsonScannerRobustnessSpec.scala | 63 +------------------ 1 file changed, 2 insertions(+), 61 deletions(-) diff --git a/jsoniter/src/test/scala/dev/constructive/eo/jsoniter/JsonScannerRobustnessSpec.scala b/jsoniter/src/test/scala/dev/constructive/eo/jsoniter/JsonScannerRobustnessSpec.scala index 79f017d0..b0c24939 100644 --- a/jsoniter/src/test/scala/dev/constructive/eo/jsoniter/JsonScannerRobustnessSpec.scala +++ b/jsoniter/src/test/scala/dev/constructive/eo/jsoniter/JsonScannerRobustnessSpec.scala @@ -201,41 +201,6 @@ class JsonScannerRobustnessSpec extends Specification with ScalaCheck: } } - // ----- 4: object sweep --------------------------------------------------- - - private def buildObject(count: Int, targetIdx: Option[Int]): (Array[Byte], Option[String]) = - val value = "999" - val fields: Vector[(String, String)] = (0 until count).map { i => - if targetIdx.contains(i) then ("target", value) else (s"f$i", i.toString) - }.toVector - (bytes(fields.map((k, v) => s""""$k":$v""").mkString("{", ",", "}")), targetIdx.map(_ => value)) - - // (count, target index) — enumerated so every count x position combo that makes sense occurs. - private val objectScenarios: List[(Int, Option[Int])] = List( - (0, None), - (1, Some(0)), - (1, None), - (2, Some(0)), - (2, Some(1)), - (2, None), - (5, Some(0)), - (5, Some(2)), - (5, Some(4)), - (5, None), - ) - - "object scan: member-count x target-position sweep — Hit iff present, span decodes correctly" >> { - // covers: findFieldValueLoop key-match / skip-and-advance (lines 165-182). - Prop.forAll(Gen.oneOf(objectScenarios)) { - case (count, targetIdx) => - val (doc, expected) = buildObject(count, targetIdx) - val span = JsonPathScanner.find(doc, List(PathStep.Field("target"))) - val hitOk = span.isHit == expected.isDefined - val valueOk = expected.forall(v => text(doc, span) == v) - hitOk && valueOk - } - } - // Malformed inputs a document generator cannot produce: each has broken syntax whose bytes // nonetheless spell a resolvable member/element, so the mis-parse a dropped guard causes lands // exactly on the probed path instead of being rejected by a later guard. @@ -279,31 +244,7 @@ class JsonScannerRobustnessSpec extends Specification with ScalaCheck: allOk must beTrue } - // ----- 5: array sweep ----------------------------------------------------- - - private def buildArrayDoc(len: Int): Array[Byte] = - val elems = (0 until len).map(i => (i * 10).toString).mkString(",") - bytes(s"""{"arr":[$elems],"after":1}""") - - // (length, target index) — includes first/mid/last/just-past/negative-oob. - private val arraySwScenarios: List[(Int, Int)] = - List((0, 0), (1, 0), (1, 1), (2, 0), (2, 1), (2, 2), (5, 0), (5, 2), (5, 4), (5, 5), (5, -1)) - - "array scan: length x index sweep — Hit iff 0<=idx> { - // covers: findArrayElementLoop (lines 212-226), skipArray (lines 268-284). - Prop.forAll(Gen.oneOf(arraySwScenarios)) { - case (len, idx) => - val doc = buildArrayDoc(len) - val span = JsonPathScanner.find(doc, List(PathStep.Field("arr"), PathStep.Index(idx))) - val expectHit = idx >= 0 && idx < len - val hitOk = span.isHit == expectHit - val valueOk = !expectHit || text(doc, span) == (idx * 10).toString - val afterOk = JsonPathScanner.find(doc, List(PathStep.Field("after"))).isHit - hitOk && valueOk && afterOk - } - } - - // ----- 6: findAll mixed-step ----------------------------------------------- + // ----- 4: findAll mixed-step ----------------------------------------------- private val mixedDoc = bytes( """{"rows":[{"xs":[1,2]},{"xs":[3,4,5]},{"xs":[]},{"xs":[9,BAD,7]}]}""" @@ -331,7 +272,7 @@ class JsonScannerRobustnessSpec extends Specification with ScalaCheck: valuesOk.and(row0Ok).and(topOk).and(noThrow must beTrue) } - // ----- 8: small pinned examples -------------------------------------------- + // ----- 5: small pinned examples -------------------------------------------- "small example: a key-length mismatch must not match" >> { // covers: stringEqualsAscii length guard (line 197). The generator's keys and probes are all From d663f72885a7ab8ee1b04065faeca5c18a4711b1 Mon Sep 17 00:00:00 2001 From: Rodolfo Hansen Date: Fri, 18 Sep 2026 14:54:00 +0200 Subject: [PATCH 4/8] =?UTF-8?q?test(circe):=20differential=20index-bounds?= =?UTF-8?q?=20oracle=20=E2=80=94=20kills=206=20mutants=20in=20-10=20lines?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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, 03 — 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) Claude-Session: https://claude.ai/code/session_0194EHFR4NamCpTHiqy7B74V --- .../eo/circe/JsonIndexBoundsSpec.scala | 58 ++++++++++++------- .../constructive/eo/circe/JsonPrismSpec.scala | 24 +------- 2 files changed, 39 insertions(+), 43 deletions(-) diff --git a/circe/src/test/scala/dev/constructive/eo/circe/JsonIndexBoundsSpec.scala b/circe/src/test/scala/dev/constructive/eo/circe/JsonIndexBoundsSpec.scala index a686d8b4..2b795967 100644 --- a/circe/src/test/scala/dev/constructive/eo/circe/JsonIndexBoundsSpec.scala +++ b/circe/src/test/scala/dev/constructive/eo/circe/JsonIndexBoundsSpec.scala @@ -2,31 +2,47 @@ package dev.constructive.eo.circe import scala.language.implicitConversions -import cats.data.Ior import io.circe.syntax.* -import org.specs2.mutable.Specification +import org.scalacheck.Gen +import org.scalacheck.Prop.forAll +import org.specs2.ScalaCheck -/** Index-bounds discrimination for the array walk: the existing specs exercise out-of-range-high on - * a 1-element array and index 0 on non-arrays, but never a SUCCESSFUL walk at indices 0 and >0 of - * one array, nor a negative index — so operator mutants on the `idx < 0 || idx >= arr.length` - * check survived. +/** Index-bounds discrimination for the array walk. + * + * `JsonWalk` carries TWO copies of `idx < 0 || idx >= arr.length` — one in `readPath`, one in + * `modifyPath`. The previous version of this spec drove only `.modify(…)`, so every operator + * mutant on the `readPath` copy survived. The property below drives the read, the Ior write and + * the silent write from one index, against an oracle DERIVED from the backing `Vector` rather than + * hard-coded, so a guard that mis-fires is caught whichever direction it mis-fires in. */ -class JsonIndexBoundsSpec extends Specification: +class JsonIndexBoundsSpec extends JsonSpecBase with ScalaCheck: import JsonSpecFixtures.* - // covers: JsonWalk.walkStep Index bounds (JsonWalk.scala:62), every operator variant — - // `idx < 0` weakened to `> 0` breaks at(1), to `<= 0` breaks at(0); a weakened - // `idx >= length` lets at(3)/at(-1) walk off the array. - "indices 0 and 1 read their elements; -1 and length fail IndexOutOfRange" >> { - val basket = Basket("Alice", Vector(Order("A"), Order("B"), Order("C"))).asJson - def run(i: Int) = codecPrism[Basket].items.at(i).modify(identity)(basket) - val valid = (run(0), run(1)) match - case (Ior.Right(_), Ior.Right(_)) => true - case _ => false - def oob(i: Int) = run(i) match - case Ior.Both(chain, _) => - chain.headOption.get == JsonFailure.IndexOutOfRange(PathStep.Index(i), 3) - case _ => false - (valid, oob(-1), oob(3)) must beEqualTo((true, true, true)) + // covers: JsonWalk.scala:56 readPath Index bounds, every operator variant (:20 `→false`, + // :24 `<`→`<=`, :24 `<`→`==`, :28 `||`→`&&`, :35 `>=`→`>`, :35 `>=`→`==`) and the + // already-covered twin at JsonWalk.scala:81 (modifyPath). + // Range -2..5 against a FIXED size-3 array straddles all five discriminating classes: + // i<0, i=0 (low boundary), 03 (strictly past length). + // i=3 alone kills `>=`→`>`; i>=4 alone kills `>=`→`==`; i=0 alone kills both `<` variants. + "read / Ior write / silent write agree with the Vector oracle at every index class" >> { + val items = Vector(Order("A"), Order("B"), Order("C")) + val basket: Json = Basket("Alice", items).asJson + forAll(Gen.chooseNum(-2, 5)) { (i: Int) => + val p = codecPrism[Basket].items.at(i) + val hit = i >= 0 && i < items.length + val oor: JsonFailure = JsonFailure.IndexOutOfRange(PathStep.Index(i), items.length) + + val read: Ior[Chain[JsonFailure], Order] = p.get(basket) + val expectedRead: Ior[Chain[JsonFailure], Order] = + if hit then Ior.Right(items(i)) else Ior.Left(Chain.one(oor)) + + val write: Ior[Chain[JsonFailure], Json] = p.modify(identity)(basket) + val expectedWrite: Ior[Chain[JsonFailure], Json] = + if hit then Ior.Right(basket) else Ior.Both(Chain.one(oor), basket) + + val silent: Json = p.modifyUnsafe(identity)(basket) + + (read == expectedRead) && (write == expectedWrite) && (silent == basket) + } } diff --git a/circe/src/test/scala/dev/constructive/eo/circe/JsonPrismSpec.scala b/circe/src/test/scala/dev/constructive/eo/circe/JsonPrismSpec.scala index 0aa76324..b01f85bf 100644 --- a/circe/src/test/scala/dev/constructive/eo/circe/JsonPrismSpec.scala +++ b/circe/src/test/scala/dev/constructive/eo/circe/JsonPrismSpec.scala @@ -157,10 +157,8 @@ class JsonPrismSpec extends Specification with ScalaCheck: // covers: at(i) on root-level Vector modify the i-th element + leave siblings byte-identical, // at(i) on nested Basket.items modifies the right element + leaves others alone, // nested at(i) getOptionUnsafe returns the element, - // *Unsafe out-of-range index leaves input unchanged, - // default-Ior out-of-range surfaces Ior.Both(IndexOutOfRange, inputJson), - // *Unsafe negative index leaves input unchanged - "at(i) on Vector focus: index modify + sibling preservation + OOR / negative index handling" >> { + // (out-of-range / negative index: see JsonIndexBoundsSpec's oracle property) + "at(i) on Vector focus: index modify + sibling preservation" >> { val orders = Vector(Order("A"), Order("B"), Order("C")) val json = orders.asJson val outAt1 = codecPrism[Vector[Order]].at(1).name.modifyUnsafe(_.toUpperCase)(json) @@ -176,21 +174,6 @@ class JsonPrismSpec extends Specification with ScalaCheck: basket.copy(items = Vector(Order("X".toUpperCase), Order("Y"))).asJson val r4 = codecPrism[Basket].items.at(1).getOptionUnsafe(basket.asJson) === Some(Order("Y")) - // ---- OOR / negative index branches ---- - val basket1 = Basket(owner = "Alice", items = Vector(Order("X"))) - val json1 = basket1.asJson - val unsafeOOR = - codecPrism[Basket].items.at(5).name.modifyUnsafe(_.toUpperCase)(json1) === json1 - val defaultOOR = codecPrism[Basket].items.at(5).name.modify(_.toUpperCase)(json1) match - case Ior.Both(chain, out) => - (out === json1) - .and(chain.length === 1L) - .and(chain.headOption.get === JsonFailure.IndexOutOfRange(PathStep.Index(5), 1)) - case _ => org.specs2.execute.Failure("expected Ior.Both"): org.specs2.execute.Result - val negIndex = - codecPrism[Basket].items.at(-1).name.modifyUnsafe(_.toUpperCase)(basket.asJson) === - basket.asJson - // ---- .each Unsafe surface (absorbed) ---- // covers: .each modifyUnsafe applies to every element, transformUnsafe applies to each // raw Json leaf, getAllUnsafe collects every focus, modifyUnsafe on empty array no-op, @@ -228,9 +211,6 @@ class JsonPrismSpec extends Specification with ScalaCheck: r1.and(r2) .and(r3) .and(r4) - .and(unsafeOOR) - .and(defaultOOR) - .and(negIndex) .and(rEach1) .and(rEach2) .and(rEach3) From ec5ccbf4936098f294c1da763d34f418247f99d2 Mon Sep 17 00:00:00 2001 From: Rodolfo Hansen Date: Fri, 18 Sep 2026 14:56:17 +0200 Subject: [PATCH 5/8] =?UTF-8?q?test(core):=20Index=20miss-write=20at=20the?= =?UTF-8?q?=20setter=20surface=20=E2=80=94=20kills=202=20mutants=20in=202?= =?UTF-8?q?=20lines?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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) Claude-Session: https://claude.ai/code/session_0194EHFR4NamCpTHiqy7B74V --- .../scala/dev/constructive/eo/ContainerOpticsSpec.scala | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/tests/src/test/scala/dev/constructive/eo/ContainerOpticsSpec.scala b/tests/src/test/scala/dev/constructive/eo/ContainerOpticsSpec.scala index ca8ab178..f53f997b 100644 --- a/tests/src/test/scala/dev/constructive/eo/ContainerOpticsSpec.scala +++ b/tests/src/test/scala/dev/constructive/eo/ContainerOpticsSpec.scala @@ -51,12 +51,21 @@ class ContainerOpticsSpec extends Specification with CheckAllHelpers: .and(second.replace(9)(Vector(1, 2, 3)) === Vector(1, 9, 3)) .and(second.replace(9)(Vector(1)) === Vector(1)) .and(second.getOption(Vector.empty[Int]) === None) + // covers: Index.scala:37 — the setter's own `s.isDefinedAt(i)` guard. The + // `.replace` above cannot reach it: Optional#modify returns getOrModify's + // `Left(t)` directly and never calls the stored reverseGet. Forced to `true` + // this line throws IndexOutOfBoundsException. + .and(second.reverseGet(Vector(1), 9) === Vector(1)) } "Index (map): hit writes existing key; missing key passes through — no insert" >> { val port = Index[String, Int]("port") (port.replace(8080)(Map("port" -> 80)) === Map("port" -> 8080)) .and(port.replace(8080)(Map("host" -> 1)) === Map("host" -> 1)) + // covers: Index.scala:55 — the map setter's own `m.contains(k)` guard, same + // unreachable-from-`.replace` argument. Forced to `true` this line INSERTS, + // which is exactly what the scaladoc promises it will not do. + .and(port.reverseGet(Map("host" -> 1), 8080) === Map("host" -> 1)) } "At: Some upserts (insert AND update), None deletes; get is total" >> { From bee989743a468b2bca86a203ac1e65dcf5f099b3 Mon Sep 17 00:00:00 2001 From: Rodolfo Hansen Date: Fri, 18 Sep 2026 14:56:17 +0200 Subject: [PATCH 6/8] =?UTF-8?q?test(core):=20fold-surface=20agreement=20pr?= =?UTF-8?q?operty=20=E2=80=94=20kills=202=20mutants,=201=20example=20out?= =?UTF-8?q?=20/=201=20property=20in?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) Claude-Session: https://claude.ai/code/session_0194EHFR4NamCpTHiqy7B74V --- .../dev/constructive/eo/CapsMatrixSpec.scala | 3 - .../constructive/eo/OpticsBehaviorSpec.scala | 85 ++++++++++++------- 2 files changed, 53 insertions(+), 35 deletions(-) diff --git a/tests/src/test/scala/dev/constructive/eo/CapsMatrixSpec.scala b/tests/src/test/scala/dev/constructive/eo/CapsMatrixSpec.scala index 7988ab6d..bffb4471 100644 --- a/tests/src/test/scala/dev/constructive/eo/CapsMatrixSpec.scala +++ b/tests/src/test/scala/dev/constructive/eo/CapsMatrixSpec.scala @@ -227,9 +227,6 @@ class CapsMatrixSpec extends Specification: (somePrism: CanFold[Option[Int], Int]).foldMap(identity)(None) === 0 (pickPrism: CanFold[Option[Int], Int]).headOption(None) === None (zipOptional: CanFold[Address, Int]).exists(_ > 9999)(addr) === true - // covers: CanFold.exists FALSE case (CanFold.scala:27) — `false` seed of - // Monoid.instance(false, _||_) weakened to `true` would report a match that never occurs. - (zipOptional: CanFold[Address, Int]).exists(_ > 999999)(addr) === false (zipAffineFold: CanFold[Address, Int]).headOption(addr) === Some(12345) (listFold: CanFold[List[Int], Int]).foldMap(identity)(List(1, 2, 3)) === 6 (listFold: CanFold[List[Int], Int]).foci(List(1, 2, 3)) === List(1, 2, 3) diff --git a/tests/src/test/scala/dev/constructive/eo/OpticsBehaviorSpec.scala b/tests/src/test/scala/dev/constructive/eo/OpticsBehaviorSpec.scala index a8c6b5fd..d53f8e61 100644 --- a/tests/src/test/scala/dev/constructive/eo/OpticsBehaviorSpec.scala +++ b/tests/src/test/scala/dev/constructive/eo/OpticsBehaviorSpec.scala @@ -8,6 +8,7 @@ import cats.instances.option.given import dev.constructive.eo.compose.* import dev.constructive.eo.forgetful.* import org.scalacheck.Prop.forAll +import org.scalacheck.{Gen, Prop} import org.specs2.ScalaCheck import org.specs2.mutable.Specification @@ -1041,41 +1042,61 @@ class OpticsBehaviorSpec extends Specification with ScalaCheck: optOk.and(lensOk).and(prismOk).and(isoOk) } - // ----- ForgetfulFold extension methods: headOption / length / exists ----- + // ----- ForgetfulFold surface: foldMap / headOption / length / exists ----- // - // covers: .headOption (first focus), .length (focus count), .exists (predicate - // over foci) on three carrier shapes — Lens (Tuple2 = always 1 focus), Prism - // (Either = 0 or 1 focus), Traversal (MultiFocus[PSVec] = 0..n foci) — and the - // Fold-carrier read shape (Forget[List]). - "ForgetfulFold extensions: headOption / length / exists across Lens / Prism / Traversal / Fold" >> { + // ONE property, TWO receiver shapes, one independent oracle (the expected focus + // List supplied per row). `extAgrees` holds the optic as the BARE `Optic[…]` — the + // only way to reach the Optic.scala EXTENSION, since `Traversal` and `ForgetFold` + // (via `CanFold`) both declare member twins that win by precedence. `cfAgrees` + // holds the same fold as a `CanFold`, reaching the trait's default bodies. + // No higher rung is reachable: `laws/` ships no fold-surface ruleset to register + // against, and promoting `exists(p) === foci.exists(p)` to a law is user-gated. + // + // covers: optics/Optic.scala:443 — the `||` of the inline disjunction monoid. Needs + // a MULTI-FOCUS carrier (Tuple2's foldMap is `f(a)`, Affine/Either's is + // `fold(_ => empty, f)`; neither ever evaluates `combine`) AND an input where some + // focus satisfies `p` — k in {-1, 4} with n >= 1. + // covers: CanFold.scala:27 — the `false` identity of the same monoid. Needs the + // no-focus-satisfies class: k = 10 (elements are 0..9), or the empty fold n = 0. + // Carrier rows carried over from the example block this replaces: Tuple2 (Lens, one + // focus), Either (Prism, hit and miss), MultiFocus[PSVec], Forget[List]. + "ForgetfulFold surface: foldMap / headOption / length / exists agree with the foci" >> { + def extAgrees[S, T, A, B, F[_, _]](o: Optic[S, T, A, B, F])( + s: S, + expected: List[A], + p: A => Boolean, + )(using ForgetfulFold[F]): Boolean = + val foci: List[A] = o.foldMap[List[A]](a => List(a))(s) + foci == expected && o.headOption(s) == expected.headOption && + o.length(s) == expected.length && o.exists(p)(s) == expected.exists(p) + + def cfAgrees[S, A](cf: CanFold[S, A])(s: S, expected: List[A], p: A => Boolean): Boolean = + cf.foci(s) == expected && cf.headOption(s) == expected.headOption && + cf.length(s) == expected.length && cf.exists(p)(s) == expected.exists(p) + val ageL: Optic[(String, Int), (String, Int), Int, Int, Tuple2] = Lens(_._2, (s, a) => (s._1, a)) - val lensOk = (ageL.headOption(("Alice", 30)) === Some(30)) - .and(ageL.length(("Alice", 30)) === 1) - .and(ageL.exists((_: Int) > 18)(("Alice", 30)) === true) - .and(ageL.exists((_: Int) > 100)(("Alice", 30)) === false) - val posIntPrism: Optic[Int, Int, Int, Int, Either] = Prism.optional[Int, Int](n => if n >= 0 then Some(n) else None, identity) - val prismOk = (posIntPrism.headOption(5) === Some(5)) - .and(posIntPrism.headOption(-1) === None) - .and(posIntPrism.length(5) === 1) - .and(posIntPrism.length(-1) === 0) - .and(posIntPrism.exists((n: Int) => n > 3)(5) === true) - .and(posIntPrism.exists((n: Int) => n > 3)(-1) === false) - - val each = Traversal.each[List, Int] - val travOk = (each.headOption(List(7, 8, 9)) === Some(7)) - .and(each.headOption(List.empty[Int]) === None) - .and(each.length(List(1, 2, 3, 4, 5)) === 5) - .and(each.length(List.empty[Int]) === 0) - .and(each.exists((n: Int) => n > 3)(List(1, 2, 3, 4)) === true) - .and(each.exists((n: Int) => n > 99)(List(1, 2, 3, 4)) === false) - - val listFold = Fold[List, Int] - val foldOk = (listFold.headOption(List(10, 20, 30)) === Some(10)) - .and(listFold.length(List(10, 20, 30)) === 3) - .and(listFold.exists((n: Int) => n == 20)(List(10, 20, 30)) === true) - - lensOk.and(prismOk).and(travOk).and(foldOk) + val eachOptic: Optic[List[Int], List[Int], Int, Int, MultiFocus[PSVec]] = + Traversal.each[List, Int] + val foldOptic: Optic[List[Int], Unit, Int, Unit, Forget[List]] = Fold[List, Int] + val listCanFold: CanFold[List[Int], Int] = Fold[List, Int] + + val rows: Gen[(List[Int], Int)] = for + n <- Gen.oneOf(0, 1, 2, 5, 17) + xs <- Gen.listOfN(n, Gen.choose(0, 9)) + k <- Gen.oneOf(-1, 4, 10) + yield (xs, k) + + Prop.forAll(rows) { + case (xs, k) => + val p = (n: Int) => n > k + extAgrees(ageL)(("Alice", 30), List(30), p) && + extAgrees(posIntPrism)(5, List(5), p) && + extAgrees(posIntPrism)(-1, List.empty[Int], p) && + extAgrees(eachOptic)(xs, xs, p) && + extAgrees(foldOptic)(xs, xs, p) && + cfAgrees(listCanFold)(xs, xs, p) + } } From 33c36b5c73b9eea496e64768b168c837a46f6966 Mon Sep 17 00:00:00 2001 From: Rodolfo Hansen Date: Fri, 18 Sep 2026 15:10:29 +0200 Subject: [PATCH 7/8] test(circe): fold the IndexOutOfRange fire scenario into the bounds property MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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) Claude-Session: https://claude.ai/code/session_0194EHFR4NamCpTHiqy7B74V --- .../eo/circe/JsonFailureSpec.scala | 20 ++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/circe/src/test/scala/dev/constructive/eo/circe/JsonFailureSpec.scala b/circe/src/test/scala/dev/constructive/eo/circe/JsonFailureSpec.scala index e8f49778..47372f12 100644 --- a/circe/src/test/scala/dev/constructive/eo/circe/JsonFailureSpec.scala +++ b/circe/src/test/scala/dev/constructive/eo/circe/JsonFailureSpec.scala @@ -14,6 +14,11 @@ import org.specs2.mutable.Specification * * '''2026-04-29 consolidation.''' 5 → 1 named block. Each case's fire + message-projection is * still witnessed; the spec frame collapses. + * + * '''2026-09-18 consolidation.''' The `IndexOutOfRange` fire scenario is dropped: `at(5)` on a + * one-element array is one point of what [[JsonIndexBoundsSpec]]'s oracle property now asserts + * over the whole index range, against an expectation derived from the backing `Vector`. Verified + * by a mutation re-run — no mutant flipped Killed → Survived. */ class JsonFailureSpec extends Specification: @@ -25,8 +30,8 @@ class JsonFailureSpec extends Specification: // NotAnObject.message contains "expected JSON object"; // NotAnArray fires when parent is not a JSON array for an Index step, // NotAnArray.message contains "expected JSON array"; - // IndexOutOfRange fires when index past end of array (Ior.Both with size in chain), - // IndexOutOfRange.message contains "size=N"; + // IndexOutOfRange.message contains "size=N" (its FIRE scenario is subsumed by + // JsonIndexBoundsSpec's oracle property — every out-of-range index class, not one constant); // DecodeFailed fires when leaf Json doesn't decode (Ior.Left with DecodeFailed in chain) "JsonFailure: every case fires from a triggering input + message-projection holds" >> { // ---- PathMissing ---- @@ -62,12 +67,10 @@ class JsonFailureSpec extends Specification: val naMessageOk = naFailure.message must contain("expected JSON array") // ---- IndexOutOfRange ---- - val basket = Basket("Alice", Vector(Order("X"))) - val iorResult = codecPrism[Basket].items.at(5).modify(identity)(basket.asJson) - val iorFireOk = iorResult match - case Ior.Both(chain, _) => - chain.headOption.get === JsonFailure.IndexOutOfRange(PathStep.Index(5), 1) - case _ => ko(s"expected Ior.Both, got $iorResult") + // The FIRE scenario moved to JsonIndexBoundsSpec's oracle property, which asserts the exact + // `Ior.Both(Chain.one(IndexOutOfRange(Index(i), size)), json)` for every out-of-range index + // class (negative, == length, > length) instead of the single hard-coded `at(5)` here. Only + // the message projection — which that property does not look at — stays. val iorFailure: JsonFailure = JsonFailure.IndexOutOfRange(PathStep.Index(7), 3) val iorMessageOk = iorFailure.message must contain("size=3") @@ -91,7 +94,6 @@ class JsonFailureSpec extends Specification: .and(noMessageOk) .and(naFireOk) .and(naMessageOk) - .and(iorFireOk) .and(iorMessageOk) .and(dfFireOk) } From f8bc7964a8a2fef5fe332a96317e54eb9eec184d Mon Sep 17 00:00:00 2001 From: Rodolfo Hansen Date: Fri, 18 Sep 2026 15:13:55 +0200 Subject: [PATCH 8/8] docs(qa): document the equivalent mutants, retire two disproven caveats MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) Claude-Session: https://claude.ai/code/session_0194EHFR4NamCpTHiqy7B74V --- site/docs/quality-assurance.md | 165 ++++++++++++++++++++++++++++++--- site/tools/gen-qa-report.py | 11 ++- 2 files changed, 158 insertions(+), 18 deletions(-) diff --git a/site/docs/quality-assurance.md b/site/docs/quality-assurance.md index 34b31a42..e5e9c5d6 100644 --- a/site/docs/quality-assurance.md +++ b/site/docs/quality-assurance.md @@ -142,20 +142,32 @@ Caveats that keep the table honest: deliberately unlawful instances pinned to fail the suite — which is the concrete follow-up this table surfaces. -Two modules can't currently be scored, for reasons worth recording: +Two "can't be scored" caveats that used to sit here are **retired** — both were +re-tested on 2026-09-18 and neither reproduces: -- **`jsoniter`** — instrumenting `PathParser.parseField` (one large byte-cursor - method) with its mutants overflows the JVM's 64 KB per-method bytecode limit, - so the sandbox won't compile. It's un-mutatable in place without splitting the - method or excluding it. -- **`avro`** — stryker's forked test-runner fails to initialise in the sandbox - (the initial test run dies in well under a second, emitting no test output), - even though the same specs pass under plain `sbt test`. Reproduced under both - JDK 21 and JDK 25 with the default and legacy runners. +- **`jsoniter`** was recorded as un-mutatable because instrumenting + `PathParser.parseField` supposedly overflowed the JVM's 64 KB per-method + bytecode limit. It does not: the module mutates and runs clean end to end + (420 mutants, **0 compile errors**), and `PathParser.scala` itself yields 24 + mutants. Nothing needs splitting or excluding. +- **`avro`** was recorded as unscoreable because stryker's forked test-runner + failed to initialise in the sandbox. It now completes in about two minutes + and produces a full report. The row's numbers are real. -The high-signal rows are therefore `core` and `laws` (via the borrowed suite), -`schemes`, and `circe` — modules whose mutated code is genuinely exercised at -run time by the suite stryker runs. +What *is* structural and permanent — do not spend a test-writing budget on it: + +- **`generics`** — 86 `NoCoverage` mutants, every one inside a quoted macro + (`LensMacro`, `MacroSelectors`, `PlateMacro`, `PrismMacro`). Macro bodies run + inside the *compiler*, so stryker's runtime coverage probe never observes + them. The only tests that can reach them are `must not compile` negative + specs, which register no coverage. The module's score is 0 % by construction. +- **`avro`'s `AvroPrismMacro`** — 17 `NoCoverage` mutants, identical cause. +- **`kyo`'s `RecordIsoMacro`** — 28 `NoCoverage` mutants, identical cause; 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 +exercised at run time by the suite stryker runs. @@ -167,11 +179,136 @@ run time by the suite stryker runs. | `schemes` | 70 | 0 | 12 | 0 | 5 | 85.4% | 85.4% | | | `schemes-laws` | 1 | 0 | 0 | 0 | 0 | 100.0% | 100.0% | Recursion-scheme laws (hylo fusion so far; more expected). Like `laws`, mutating it probes whether the law spec notices a corrupted law. | | `circe` | 35 | 0 | 3 | 15 | 0 | 66.0% | 92.1% | | -| `avro` | 283 | 0 | 57 | 34 | 6 | 75.7% | 83.2% | Not scored: stryker's forked test-runner fails to initialise in the sandbox (the specs pass under plain `sbt test`). | -| `jsoniter` | 321 | 4 | 77 | 0 | 0 | 80.8% | 80.8% | Not scored: instrumenting `PathParser.parseField` blows the JVM 64 KB method-size limit — one giant byte-cursor method, un-mutatable in place. | +| `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. | +The table is regenerated only on release tags, so between releases it lags the +tree. The most recent full sweep (**2026-09-18**, JDK 25, `project ; stryker` +per module), after the survivor-killing pass of the same day, measured: +`core` 193 K / 4 T / 19 S, `laws` 85 K / 0 S, `schemes` 48 K / 8 S, +`circe` 44 K / 6 S, `jsoniter` 328 K / 4 T / 58 S, `avro` 391 K / 78 S, +`generics` 0 K / 86 NC. + +Two modules in the `mutationAll` alias have never had a row here: + +- **`zio`** — **0 mutants exist**. The module is pure optic construction: no + conditional, no arithmetic, no boolean literal for stryker to mutate. Its + score is `n/a`, not 0 % — there is no pool. +- **`kyo`** — scores 83.3 % *covered* (20/24 on `schema/StructureOptics.scala`; + everything else is `RecordIsoMacro`, compile-time only), but only once the + single-method `extension` block in that file is **braced**: re-printed by + stryker4s, a significant-indentation `extension` clause loses its method to + column 0 and the whole file stops compiling, aborting the module. + + +### Known equivalent mutants + +A mutation score is not a coverage target: some mutants are **equivalent** — +the mutated program computes the same observable value as the original, so *no* +test can kill them. Chasing them is how a suite grows LOC without gaining +kill capacity. The 40 survivors below were each read against the source and +carry a checkable reason. **Re-triaging them is wasted work; if one of them is +ever killed, the equivalence claim was wrong and this table is the bug report.** + +Keyed `(file, line:column, mutator → replacement)` so a key survives +renumbering only as far as the next edit to the file — re-verify a row whose +line has moved rather than trusting it. + +#### `core` — 19 (all of core's survivors) + +Every one is a perf fast path whose general path computes the same value. + +| file | line:col | mutation | why it cannot be killed | +|---|---|---|---| +| `data/IntArrBuilder.scala` | 17:12 | `len == arr.length` → `!=` (`append`) | With `!=` the builder grows on every *non-full* append, so `len` never reaches `arr.length` and the would-be overflow is unreachable; `freeze` trims to `len`, so the returned array is identical — only extra allocation. | +| `data/IntArrBuilder.scala` | 28:10 | `cap < minCap` → `false` (`doubleTo`) | Returns `arr.length * 2` at once. `grow` is only ever called as `grow(len + 1)` with `len == arr.length ≥ 1`, and `2L ≥ L + 1` for every `L ≥ 1`, so capacity still suffices. | +| `data/IntArrBuilder.scala` | 28:14 | `<` → `<=` | Doubles one step further than needed; capacity still `≥ minCap`. | +| `data/IntArrBuilder.scala` | 28:14 | `<` → `==` | The loop body runs only while `cap == minCap` (reachable only at `L = 1`), and every exit value is still `≥ minCap`. | +| `data/IntArrBuilder.scala` | 37:8 | `len == arr.length` → `false` (`freeze`) | Always trim-copies instead of aliasing; same elements, one extra array. | +| `data/ObjArrBuilder.scala` | 17:8 | guard → `true` (`append`) | Grows before every append; `grow(len + 1)` guarantees the slot. | +| `data/ObjArrBuilder.scala` | 34:12 | guard → `true` (`appendAllFromPSVec`) | Grows unconditionally; capacity is `≥ len + n` either way. | +| `data/ObjArrBuilder.scala` | 34:20 | `>` → `>=` | Grows one element early. | +| `data/ObjArrBuilder.scala` | 40:14 | `<` → `<=` (`doubleTo`) | As `IntArrBuilder` 28:14. | +| `data/ObjArrBuilder.scala` | 56:8 | `len == arr.length` → `false` (`freezeArr`) | Always trim-copies. | +| `data/PSVec.scala` | 75:16 | `i >= length` → `==` (`equals` loop) | `i` starts at 0 and advances by exactly 1, so `>=` first holds precisely where `==` does. | +| `data/PSVec.scala` | 114:10 | `n == 0` → `false` (`Functor.map`) | The general path allocates a 0-length array and `PSVec.unsafeWrap` normalises length 0 back to `Empty`. | +| `data/PSVec.scala` | 143:14 | `i >= n` → `==` (`foldRight` loop) | `i` advances by one. | +| `data/PSVec.scala` | 160:10 | `n == 0` → `false` (`Traverse.traverse`) | `loop` exits immediately and `G.pure(new Array(0))` wraps back to `Empty`. | +| `optics/Plated.scala` | 127:15 | `depth >= transformRecursionLimit` → `true` | Routes every internal node to `transformMachine`, which computes the same value — only the on-stack → heap switch point moves. | +| `optics/Plated.scala` | 127:21 | `>=` → `<` | Same: everything goes to the heap machine. | +| `optics/Plated.scala` | 127:21 | `>=` → `>` | Switches one level deeper. | +| `optics/Plated.scala` | 127:21 | `>=` → `==` | `depth` advances by one, so `==` fires at the same node `>=` would. | +| `data/MultiFocus.scala` | 430:39 | `math.max(n, 16)` → `math.min` | A capacity *floor*: `ObjArrBuilder` grows on demand and floors its own capacity at 1. Deliberate perf tuning, invisible to any value assertion. | + +A deep-`transform` test is still worth having as a **stack-safety** test — but +it will not kill the four `Plated:127` mutants, so it must be justified on its +own terms. + +#### `circe` — 6 (all of circe's survivors) + +| file | line:col | mutation | why it cannot be killed | +|---|---|---|---| +| `JsonFocus.scala` | 77:10 | `path.length == 0` → `false` (`navigateForWrite`) | Falls through to `readPath(json, [])`, which returns `Right(json)` at `i = 0`; the deferred writer then evaluates to `encoder(b)`, exactly the shortcut's value. | +| `JsonFocus.scala` | 100:10 | `path.length == 0` → `false` (`modifyImpl`) | `modifyPath(json, [])(f)` applies `f` at `i = 0`; a decode failure still aborts via `miss` and `getOrElse(json)` returns the input, as the shortcut does. | +| `JsonFocus.scala` | 114:10 | `path.length == 0` → `false` (`placeImpl`) | `modifyPath(json, [])(_ => encoder(a))` is `encoder(a)`. | +| `JsonFocus.scala` | 135:10 | `path.length == 0` → `false` (`placeIor`) | Same, wrapped as `Ior.Right`. | +| `JsonWalk.scala` | 42:12 | `i >= path.length` → `==` (`readPath` loop) | `i` starts at 0 and the only recursive call is `i + 1` from inside the `i < path.length` arm. | +| `JsonWalk.scala` | 67:12 | `i >= path.length` → `==` (`modifyPath` loop) | Same. | + +The *other* copy of the array-bounds check in this file (`JsonWalk.scala:56`, +`readPath`) is **not** equivalent — it survived for years because +`JsonIndexBoundsSpec` drove only the write twin, and a differential oracle +killed all six of its variants in 2026-09. + +#### `schemes` — 8 (all of schemes' survivors) + +| file | line:col | mutation | why it cannot be killed | +|---|---|---|---| +| `Schemes.scala` | 70:14 | `depth >= OnStackLimit` → `==` (`unfoldCoalgRec`) | `depth` advances by one, so the switch happens at the same node. | +| `Schemes.scala` | 70:14 | `depth >= OnStackLimit` → `>` | Switch-point shift by one frame; `unfoldCoalgHeap` computes the same value (the `<` variant *is* killed, which is the evidence that the two engines agree). | +| `Schemes.scala` | 74:10 | `k == 0` → `false` | Leaf fast path: the general path builds a 0-length array and `combine(PSVec.unsafeWrap(empty)) == combine(PSVec.empty)`. | +| `Schemes.scala` | 99:10 | `kids.isEmpty` → `false` (`unfoldCoalgHeap.enter`) | Pushes a frame with a 0-length `out`; the driver immediately takes the `else` arm and calls the same `combine(Empty)`. | +| `Schemes.scala` | 137:14 | `depth >= OnStackLimit` → `==` (`foldInPlaceRec`) | As 70:14. | +| `Schemes.scala` | 137:14 | `depth >= OnStackLimit` → `>` | As 70:14. | +| `Schemes.scala` | 141:10 | `k == 0` → `false` | As 74:10, with `alg` in place of `combine`. | +| `Schemes.scala` | 166:10 | `arr.length == 0` → `false` (`foldInPlaceHeap.enter`) | As 99:10. | + +#### `jsoniter` — 7 of 58 + +| file | line:col | mutation | why it cannot be killed | +|---|---|---|---| +| `JsoniterTraversal.scala` | 56:8 | `spans.isEmpty` → `false` (`to`) | `decodeSpans` on an empty span list returns `(Nil, PSVec.unsafeWrap(new Array(0)))` = `(Nil, Empty)` — the same `MultiFocus`. | +| `JsoniterTraversal.scala` | 170:10 | `written == n` → `false` | Falls to the tight-copy branch, which copies `written == n` elements: same contents. | +| `JsoniterTraversal.scala` | 171:15 | `written == 0` → `false` | Tight-copies a 0-length array; `unsafeWrap` normalises it to `Empty`. | +| `PathParser.scala` | 37:12 | `pos >= s.length` → `==` (`parseSteps`) | Every call site passes `pos ≤ s.length` (`1` on a non-empty input, `end` from a scanner that stops at `s.length`, `pos + 2` under `pos + 1 < s.length`, `end + 1` under `s.charAt(end) == ']'`), so `>=` and `==` coincide. | +| `PathParser.scala` | 49:12 | `pos >= s.length` → `==` (`parseField`) | Called only as `parseField(s, pos + 1, …)` under `pos < s.length`, so `pos ≤ s.length`; the `charAt` in the right disjunct is still guarded at the only dangerous value. | +| `PathParser.scala` | 65:18 | `pos + 1 >= s.length` → `==` (`parseIndex`) | Guarded by `pos < s.length`, so `pos + 1 ≤ s.length`. | +| `PathParser.scala` | 74:19 | `end >= s.length` → `==` (`parseIndex`) | `scanEnd` stops at `s.length`, so `end ≤ s.length`. | + +**Not certified here:** the remaining 51 `JsonPathScanner.scala` survivors and +`JsoniterPrism.scala:130:8`. They were triaged into classes (end-of-input +`>=` → `==` on cursors that advance by one; empty-container fast paths), but +one class was measured *wrong* in 2026-09: `254:13 >=` → `>` was called +equivalent and is in fact killable, because with `>` the left disjunct of +`kpos >= bytes.length || bytes(kpos) != '"'` is dead-false and the right +disjunct then indexes at `kpos == bytes.length`. **Any guard whose right-hand +side indexes the array needs the reachability of `pos == length` checked +individually** before it may be called equivalent — which is why those rows are +absent from the table above. + +#### Timeout oscillation + +A handful of the builder mutants (`IntArrBuilder:17:8`, `ObjArrBuilder:17:12`, +`IntArrBuilder:28:10 → true`, `ObjArrBuilder:40:10 → true`) alternate between +`Timeout` and `Survived` between runs: forcing a grow on every append makes the +builder quadratic, which sometimes trips the per-mutant timeout and sometimes +does not. Both statuses are equivalent-mutant outcomes, but only `Timeout` +counts as *detected*, so a module's survivor count can move by one or two with +no change to the suite. Do not read such a delta as a regression. + + ## Regenerating these numbers ```sh diff --git a/site/tools/gen-qa-report.py b/site/tools/gen-qa-report.py index 4df70150..85ea0021 100644 --- a/site/tools/gen-qa-report.py +++ b/site/tools/gen-qa-report.py @@ -48,9 +48,12 @@ ] # Modules stryker mutates. value = (on-disk module dir, human label, note). -# The note is the Notes-column caveat; for modules that can't be scored it +# 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. +# appears, its numbers take over and the note still annotates the row — so a +# note that claims a module is unscoreable MUST be retired here once numbers +# land, or the page contradicts its own table (that is how the avro/jsoniter +# "not scored" caveats survived months of real reports). MUTATION_MODULES = [ ("core", "core", "Scored against the cross-module suite in `tests/`, task-borrowed into core's Test scope by `mutationAll`."), ("laws", "laws", "Borrowed `tests/` suite; the negative fixtures in `UnlawfulFixturesSpec` keep the law-weakening mutants dead — see prose."), @@ -58,8 +61,8 @@ ("schemes", "schemes", ""), ("schemes-laws", "schemes-laws", "Recursion-scheme laws (hylo fusion so far; more expected). Like `laws`, mutating it probes whether the law spec notices a corrupted law."), ("circe", "circe", ""), - ("avro", "avro", "Not scored: stryker's forked test-runner fails to initialise in the sandbox (the specs pass under plain `sbt test`)."), - ("jsoniter", "jsoniter", "Not scored: instrumenting `PathParser.parseField` blows the JVM 64 KB method-size limit — one giant byte-cursor method, un-mutatable in place."), + ("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."), ]