From 0c0ba8e6483e3711a61a4f366492917f6ac142eb Mon Sep 17 00:00:00 2001 From: Rodolfo Hansen Date: Fri, 18 Sep 2026 14:54:00 +0200 Subject: [PATCH] =?UTF-8?q?test(circe):=20differential=20index-bounds=20or?= =?UTF-8?q?acle=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)