Skip to content

test(document-schema.js): reach 100% mutation score - #1256

Merged
Mearman merged 21 commits into
mainfrom
feat/100-percent-mutation-document-schema.js
Sep 12, 2026
Merged

Mearman merged 21 commits into
mainfrom
feat/100-percent-mutation-document-schema.js

Conversation

@Mearman

@Mearman Mearman commented Sep 12, 2026

Copy link
Copy Markdown
Member

Works through document-schema.js's mutation survivors from its original 22% baseline: removes several redundant defensive checks that made a mutant provably unobservable (a hex-colour capture-group re-check, an isImageFormat typeof guard, isGroupWrapper's unreachable node-record guard, factor-styles' per-kind wrapper dispatch, factor-styles' assertHeadingAnchor/assertListAnchor no-op assertion functions, content-json-schema-defs' mathMlDef undefined guard), collapses the wrapper walk to one generic structural recursion shared by every MintWrapper kind, makes rebuildParagraph/stripParagraphKeys generic so a heading/list anchor's own required type survives the round trip with no re-narrowing needed, and adds targeted unit coverage across a1/canonicalise/color/content/decompose/definitions/flatten/math/mathml/package-node/schema-io/style for the boundaries that were previously reachable but untested.

Also flips ignoreStatic to false for this package specifically: its own vitest-runner activation switch is set inside a beforeAll() hook, which runs after a test file's top-level imports already evaluated, so a mutation inside a top-level z.object({...}) declaration could never be observed under the default ignoreStatic:true, independent of test quality.

A full stryker run stryker.config.ts now reports 100.00 across every file with 0 survived and 0 no-coverage mutants, and breakThreshold is locked at 100.

…nary for the unbounded half

parseRangeReference computed endRaw with the same separatorIndex-based
ternary as startRaw, but when separatorIndex is -1, ref.slice(separatorIndex + 1)
already equals ref.slice(0) (the whole string) -- the value the ternary's
other branch would have picked anyway. It was never a genuine second
condition, just a restatement of what slice's own arithmetic already produces.
…ion, not a regex capture

rgbHexToColor matched the whole (optionally hashed) string against one
regex with a capture group, then re-checked the capture for undefined
even though a successful match always populates it. Splitting the '#'
strip into a plain startsWith/slice and validating the remaining six
digits against their own pattern removes the redundant capture-group
check and needs no match-array indexing at all.
…record guard

isGroupWrapper checked isRecord(value.node) before validating the rest
of a wrapper's shape, but every one of its nine call sites only invokes
it after value.node has already passed a real Zod object schema's own
safeParse -- which can only succeed against a genuine non-null, non-array
record. The guard could never actually reject anything at any real call
site, so it was dead defensive code rather than a load-bearing check.
…hared structural walks

extentOf, childWrappers, and every rebuildXGroup function had a
dedicated arm per MintWrapper kind (shape, slide, draw page, section,
construct) even though every one of those arms did the identical
structural walk -- recurse into anything carrying its own node and
children, collect a bare paragraph leaf, skip everything else. Only
the anchor-group arm (heading/list) genuinely differs, since it alone
contributes its own paragraph. Replacing the per-kind arms with one
generic walk removes mutation-equivalent branches that could never be
observed to differ, and widens FlowChild to cover a slide's and draw
page's own child vocabularies so the same walk serves every wrapper
kind.

Also removes the ref-defined check from every rebuildXGroup's
`unchanged` computation: a wrapper only carries a ref when its own
candidate search minted an entry, which requires at least two matching
paragraph/run positions inside its own extent to actually get their
keys stripped -- so a minted ref always implies at least one
descendant actually changed, making the ref check redundant with the
children-equality check beside it.

paragraphTuple/runTuple drop their per-key presence check: both are
only ever called with a key list already proven (by
commonParagraphKeys/commonRunKeys) to be present on every paragraph or
run in scope, so the check could never fire. commonRunKeys drops its
empty-extent early return for the same reason: an empty runs array
already makes every downstream loop over it a no-op.

The plan()/mint() length and switch-arm guards removed are equivalent
for the identical reason -- an empty extent or an empty children array
already makes the code that would have been skipped behave as a no-op
on its own.
…uard

Array.prototype.includes compares by strict equality against
IMAGE_FORMATS's own string elements, so it can only return true for a
value that already is one of those strings -- a non-string value never
satisfies it either way, making the preceding typeof check redundant.

Also adds coverage for isContentBlock/isContentConstructStart/
isRunConstructExtent rejecting a partly-invalid array, a wrong-kind
discriminant, and an out-of-range run bound respectively, and for the
hand-authored content-json-schema-defs fragments' own field-level
shape.
…aries

Covers the stack re-check a pop loop needs when a single open
heading/list item shrinks to zero, and when two open items in a row
must both pop -- the boundary where a wrong stack-top index would stop
after only one pop instead of continuing. Also covers a non-paragraph
leaf attaching inside a single open list item's own children (both at
a section root and inside a shape), and pins ConstructMarkerImbalanceError's
own name and message text for both imbalance kinds.
…evel boundaries

Adds coverage for StyleEntry/StyleParagraphProperties/StyleRunProperties
and the ban-list fields (frames, sourcePath, styleId, list) they reject,
plus numeric-range and enum-membership boundaries on the fields each
schema accepts.
…ries

Covers the outermost-first overlay order (a nested ref's key wins over
an ancestor's), a ref that resolves to no table entry, and resolution
across every wrapper kind's own extent boundary.
Covers exact-rational and dimension-vector field validation on the
math expression grammar, and the MathML round-trip helpers' own
malformed-input rejection paths.
Covers documentFromJson's rejection of malformed input at each schema
boundary and the parse-error messages it surfaces.
canonicalise's stable-key-ordering and NaN/undefined-normalisation
behaviour was previously exercised only incidentally through callers
(factor-styles' candidate grouping); this pins the function's own
contract directly.
Pins the style-property-merging helpers' gap-fill-never-overwrite
contract directly, previously exercised only incidentally through
flatten's own resolution tests.
…ants activate

@stryker-mutator/vitest-runner activates a non-static mutant inside a
beforeAll() hook, which runs strictly after a test file's top-level
imports have already evaluated. A mutation inside a top-level
z.object({...}) or object-literal declaration therefore executes
before the activation switch is ever set, so it can never be observed
regardless of what a covering test asserts.

Confirmed directly: CONTENT_DEFS.Color's r field mutated to {}
reproducibly survived Stryker's sandbox while the identical edit,
applied by hand and run through plain vitest run, failed the exact
test Stryker itself says covers it -- proving the miss was this
activation-timing gap, not a missing test.

ignoreStatic: false routes static mutants through the synchronous
"static" activation mode instead, set before any test file's own
imports run, at the cost of re-running each static mutant's whole
related suite (measured at ~92% of this package's dry-run time, since
57% of its mutants are static by construction -- it is almost entirely
schema declarations). breakThreshold stays provisional pending a full
run under the corrected configuration.
…guard

getMathMlJsonSchemas() registers exactly the three ids mathMlDef ever looks
up before calling z.toJSONSchema(), so the registry's conversion result
always carries an entry for each — the undefined branch could never be hit
by any real input, only by a mutation flipping the lookup itself, which the
existing tests already catch via the resulting value.
…ive checks

Remove MintedEntry's firstVisit field and the visit-index bookkeeping that
fed it: entries.values() already yields ascending pre-order-visit order for
free, since each entry is inserted into the Map at the moment plan() first
mints it and Map iteration is insertion order, so the recorded index was a
duplicate of information the Map already carries.

Also strip the throwing guards in assertHeadingAnchor and assertListAnchor.
PARAGRAPH_STYLE_KEYS, the only source a strip's key list is ever drawn from,
never contains headingLevel or list, so stripping can never remove either
grouping signal and no real input could ever reach the throw.

Add a regression test pinning that a paragraph gains no style property when
only a nested descendant's style changed.
…anchor type

Replace assertHeadingAnchor/assertListAnchor, two runtime no-op assertion
functions used only to re-narrow rebuildParagraph's loosened ContentParagraph
return back to a heading/list anchor's own required type, with a generic
rebuildParagraph<P extends ContentParagraph> that preserves the input's exact
type through the round trip.

The two assertion functions carried no runtime check, since the narrowing
they performed can never actually fail per the module's own documented
invariant, so their bodies were true no-ops. A Stryker BlockStatement mutant
reducing an already-inert body to an empty block is therefore unobservable by
any test by construction, not a missing test — removing the no-op function
entirely is the fix, not trying to make it fail differently.
A clean run now kills or times out every mutant across the package with
zero survived and zero no-coverage, confirming the provisional threshold
can be replaced with the real gate.
@Mearman
Mearman marked this pull request as ready for review September 12, 2026 22:13
@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 Completed 2026-09-12T22:17:40.038763Z 5fd88c8 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 enabled auto-merge (rebase) September 12, 2026 22:14
@Mearman
Mearman merged commit 0a273cf into main Sep 12, 2026
26 checks passed
@Mearman
Mearman deleted the feat/100-percent-mutation-document-schema.js branch September 12, 2026 22:57
@github-actions

Copy link
Copy Markdown
Contributor

🎉 This PR is included in version 7.11.2 🎉

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