Skip to content

test(byte-codec): eliminate every mutant by restructuring rather than suppressing - #1252

Merged
Mearman merged 11 commits into
mainfrom
fix/no-disable-comments-byte-codec
Sep 12, 2026
Merged

Mearman merged 11 commits into
mainfrom
fix/no-disable-comments-byte-codec

Conversation

@Mearman

@Mearman Mearman commented Sep 12, 2026

Copy link
Copy Markdown
Member

byte-codec previously reached a 100% Stryker mutation score using // Stryker disable next-line <Mutator>: <reason> suppression comments to exclude genuinely unobservable mutants. That mechanism is now banned workspace-wide: every mutant must be killed by a real test, or the code restructured so the mutation opportunity doesn't exist as an AST node at all.

This removes every remaining disable comment from byte-codec (writer.ts, flate.ts, jpeg-info.ts, png-decode.ts, png-encode.ts, png-filter.ts) by restructuring each equivalence class rather than suppressing it:

  • Redundant guards deleted outright where the guarded branch was provably a no-op (ByteWriter.writeBytes's empty-chunk early return, inflateTolerant's offset > 0 retry guard).
  • Manually bounded loops rebuilt as data-terminated scans (while (bytes[offset] !== undefined)) or exact-length Array.from iteration, removing the separate length comparison an off-by-one mutant could hide behind.
  • Algebraic-identity comparisons restated as their own direct computation instead of a chain of pairwise comparisons: paethPredictor's tie-breaking via Math.min, sumOfAbsSigned's signed-magnitude fold via Math.min(byte, 256 - byte).
  • The palette-detection Map key repacked as a 32-bit bitfield (r | g << 8 | b << 16 | a << 24) instead of a sum of scaled terms, removing the arithmetic-identity mutation surface entirely.

Mirrors the same approach already landed for excel-number-format in this workspace.

Typecheck, lint, and the full unit suite (208 tests) are green. grep -rn "Stryker disable" packages/byte-codec/src returns nothing.

…h no EOI

readJpegInfo's marker-scanning loop must stop once it runs off the end
of the buffer even when the trailing bytes contain neither a marker
lead-in (0xff) nor an EOI marker. Adds a case exercising exactly that:
trailing non-marker bytes with nothing after them, expecting the
"no SOF marker found" error rather than an out-of-bounds read.
…unk guard

Pushing an empty chunk is a no-op either way: toBytes() and length are
identical whether or not the guard runs, since an empty chunk
contributes zero bytes to both the running length and the
concatenated output. The early return existed only as an allocation
avoidance, not a behavioural branch, so there is no mutation
opportunity left for it to hide.
…he data itself

Two changes to inflateTolerant's recovery ladder, each removing a
comparison that no input could ever distinguish:

- The whitespace-skip loop no longer pairs its scan with a separate
  offset < data.length bound. isAsciiWhitespace(undefined) is
  explicitly false and Uint8Array indexing past the end always
  returns undefined, so the scan already stops the moment it runs off
  the buffer without needing its own length check.
- The offset > 0 guard before retrying inflate() on the
  whitespace-stripped subarray is gone. inflate() is a deterministic
  pure function, so retrying it at offset 0 (the identical bytes the
  first attempt already threw on) fails the same way and falls
  through to the next recovery tier regardless.
readJpegInfo's marker-scanning loop no longer pairs its scan with a
separate offset < bytes.length bound. Uint8Array indexing past the
end always returns undefined, so while (bytes[offset] !== undefined)
already stops the scan the moment it runs off the buffer, with no
separate length comparison for a boundary mutation to hide behind.
… PNG decoding

Four changes to png-decode.ts, each removing a comparison or branch
that no input could ever observe:

- unpackRow's dedicated bitDepth === 8 fast path is gone. With
  bitDepth === 8, the generic bit-packed formula already reduces to
  exactly the fast path's own computation (mask = 255, byteIndex = i,
  shift = 0), so the branch existed purely to skip redundant
  shift/mask arithmetic, never to produce a different result.
- unpackRow's two remaining sample loops (bitDepth 16 and the generic
  bit-packed case) are built via an exact-length Array.from instead of
  a manually bounded for loop, so there is no separate loop-bound
  comparison whose own off-by-one could ever be observed through the
  returned array.
- decodePng's palette lookup runs unconditionally instead of being
  gated on colorType === 3. buildRawImage only ever reads it inside
  its own colorType === 3 branch, so finding a PLTE chunk for any
  other colour type is simply an unused value.
- buildRawImage's row and column loops are likewise driven by an
  exact-length Array.from: data/alpha are allocated to exactly
  width*height*outChannels/width*height elements, so there is no
  separate loop-bound comparison for an off-by-one to hide behind.
… without pairwise comparisons

Four changes to png-filter.ts, each removing a comparison that no
input could ever distinguish:

- paethPredictor now picks whichever of a, b, c has the smallest
  distance directly via Math.min, rather than a chain of pairwise
  comparisons (pa <= pb, then pb <= pc). The chain's own tie boundary
  (pa <= pb vs pa < pb) was unobservable: pa === pb algebraically
  forces pc === 0, which the second comparison already resolves
  independently, so no input could tell the two apart.
- sumOfAbsSigned computes each byte's signed-interpretation magnitude
  via Math.min(byte, 256 - byte) instead of a byte < 128 branch. Both
  formulations agree everywhere, including at the branch's own
  boundary (byte === 128, where both magnitudes are already 128), so
  there is no comparison left to mutate at all.
- unfilterScanlines' and filterRowInto's per-row byte loops are built
  via an exact-length Array.from instead of a manually bounded for
  loop, so there is no separate loop-bound comparison whose own
  off-by-one could ever be observed through their output arrays.
…d drop redundant loop bounds

Two changes to png-encode.ts:

- detectPalette's per-pixel Map key packs r/g/b/a into one 32-bit
  bitfield (r | g << 8 | b << 16 | a << 24) instead of summing
  scaled terms (r + g*256 + b*65536 + a*16777216). Each channel now
  occupies its own disjoint 8-bit lane, so the packing is a bijection
  by construction, with no arithmetic identity between coefficients
  for a mutation to preserve the way the scaled-sum form had.
- writeTruecolorPng's pixel and channel interleaving loops are built
  via an exact-length Array.from instead of manually bounded for
  loops, so there is no separate loop-bound comparison whose own
  off-by-one could ever be observed through the interleaved output.
…able comments

The break threshold's own comment still described the old suppression
mechanism (per-mutant Stryker disable comments with an equivalence
proof). None remain: every mutation opportunity that was genuinely
unobservable has instead been restructured out of the source, so the
comment now describes the restructuring patterns actually used
instead of pointing at comments that no longer exist.
@Mearman
Mearman marked this pull request as ready for review September 12, 2026 08:26
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Sep 12, 2026

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
🔒 Security Review ⚠️ Failed 2026-09-12T08:36:04.821947Z 8063496 Draft marked ready
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@Mearman

Mearman commented Sep 12, 2026

Copy link
Copy Markdown
Member Author

Local Stryker verification note: every Stryker disable comment is confirmed gone (grep -rn "Stryker disable" packages/byte-codec/src returns nothing), and typecheck/lint/the full unit suite (208 tests) are green, matching the required CI checks above.

I wasn't able to get a trustworthy local mutation score for this PR: the machine this was built on was running many concurrent sessions doing the identical "remove Stryker disable comments" pass across other packages in this workspace at the same time (visible in the Actions run history for fix/no-disable-comments-pdf-raster-cpu, fix/no-disable-comments-document-compute.js, fix/no-disable-comments-excel-number-format, feat/100-percent-mutation-wpd-codec), which pushed load averages into the 60-400 range on a 12-core box. Every full run either timed out near-universally or, once, reported widespread "survived" mutants that don't hold up: e.g. it reported flate.ts's MAX_INFLATE_OUTPUT_BYTES guard as survived, but flate.test.ts's own "throws once the inflated output exceeds the limit by a single byte" test (a cheap, mocked assertion) plainly kills that exact mutant. That run also reported "Ran 0.39 tests per mutant on average" and "All tests (covered 0)", which points at the perTest coverage analysis itself getting corrupted under the load rather than a real gap.

The repo's own Mutation testing result check isn't in the required-checks list yet, and its shared mutation-testing concurrency group is similarly backed up by the same concurrent PRs (my queued run there was cancelled twice without a job ever starting). Once the queue clears it should pick this branch up on its own; happy to re-verify once it does.

@Mearman

Mearman commented Sep 12, 2026

Copy link
Copy Markdown
Member Author

Update: got a genuine, clean local run after the workspace-wide stryker.shared.ts concurrency drop eased the cross-package contention described above (took several retries to catch a window where the dry run itself didn't blow its own test timeouts, but once it got past that point the actual mutation run completed cleanly).

Real result from pnpm --dir packages/byte-codec exec stryker run stryker.config.ts:

All files       | 100.00 |  100.00 |      550 |        18 |          0 |        0 |      233
 bytes          | 100.00 |  100.00 |       69 |         3 |          0 |        0 |       26
  crc32.ts      | 100.00 |  100.00 |        1 |         0 |          0 |        0 |        1
  flate.ts      | 100.00 |  100.00 |       28 |         1 |          0 |        0 |        8
  reader.ts     | 100.00 |  100.00 |       28 |         2 |          0 |        0 |       13
  writer.ts     | 100.00 |  100.00 |       12 |         0 |          0 |        0 |        4
 image          | 100.00 |  100.00 |      481 |        15 |          0 |        0 |      207
  jpeg-info.ts  | 100.00 |  100.00 |       39 |         6 |          0 |        0 |       19
  png-decode.ts | 100.00 |  100.00 |      195 |         2 |          0 |        0 |       95
  png-encode.ts | 100.00 |  100.00 |      121 |         6 |          0 |        0 |       40
  png-filter.ts | 100.00 |  100.00 |      126 |         1 |          0 |        0 |       53

0 survived, 0 no-coverage, mutation score 100.00 (>= break threshold 100). The two non-"Killed" categories are both explainable, not noise:

  • The 233 "errors" are all CompileError, caught by the typescript checker (checkers: ["typescript"] in stryker.shared.ts) before a test ever runs -- e.g. emptying a function body that must return a value (TS2355), or returning {} where InflateResult requires bytes/recovered (TS2739). Verified directly against the incremental report's per-mutant statusReason, not inferred.
  • The 18 timeouts are exactly the mutations that force a data-bounded while loop or its own advancing index into a genuine infinite loop or unbounded backward scan (while (isAsciiWhitespace(...)) -> while (true), offset++ -> offset-- on the JPEG marker scan, the PNG signature-check loop's i++ -> i--, etc.) -- the same category Stryker's own FAQ names as the canonical timeout case, and none of it maps back to a genuinely observable behaviour a test is failing to catch.

Verified against the report's raw reports/stryker-incremental.json, not just the console summary.

@Mearman
Mearman enabled auto-merge (rebase) September 12, 2026 22:12
@Mearman
Mearman merged commit 48351e2 into main Sep 12, 2026
26 checks passed
@Mearman
Mearman deleted the fix/no-disable-comments-byte-codec branch September 12, 2026 22:43
@github-actions

Copy link
Copy Markdown
Contributor

🎉 This PR is included in version 1.5.3 🎉

The release is available on:

Your semantic-release bot 📦🚀

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant