Skip to content

test(epub-codec): raise the package's mutation score toward 100% - #1269

Merged
Mearman merged 47 commits into
mainfrom
feat/100-percent-mutation-epub-codec
Sep 14, 2026
Merged

Mearman merged 47 commits into
mainfrom
feat/100-percent-mutation-epub-codec

Conversation

@Mearman

@Mearman Mearman commented Sep 13, 2026

Copy link
Copy Markdown
Member

Works through epub-codec's survived and no-coverage Stryker mutants file by file, adding targeted tests that exercise the branches the existing suite left unreached, so real bugs in this hand-written PNG/JPEG/XML/OPF/nav logic get caught by a failing test instead of surviving silently.

Package mutation score so far: up from a 70.01% local baseline (against the committed CI-measured breakThreshold of 64) to roughly 78-79%, with the survived+no-coverage count down from 627 to 428.

Files brought close to or at 100% (remaining survivors in these are genuine equivalent mutants -- bounds-check guards subsumed by JavaScript's out-of-bounds-index-is-undefined semantics, confirmed by hand for each one): image/dimensions.ts, diagnostics.ts, path.ts, xhtml/link-target.ts, package-io/read.ts, xml/node.ts, util/base64.ts, ocf/container.ts, codec.ts, nav/reconcile.ts, nav/ncx.ts, xml/entities.ts, xml/query.ts, xhtml/context.ts.

Meaningfully improved but not yet exhausted: xml/build.ts, xml/parse.ts, xhtml/list-id.ts, nav/nav3.ts, xhtml/footnote.ts, opf/parse.ts.

Not yet started: the package's largest and most complex modules -- xhtml/read.ts and xhtml/write.ts (the XHTML-to-ContentDocument mapping engine, 103 and 57 survived/no-coverage mutants respectively), write.ts, opf/metadata.ts, xhtml/inline.ts, test-support/zip.ts, read.ts, opf/write.ts, nav/write.ts.

Draft until the package as a whole is done.

@Mearman
Mearman force-pushed the feat/100-percent-mutation-epub-codec branch from 9259946 to d8dfdf6 Compare September 13, 2026 21:00
…g edge cases

readImageDimensions and detectImageFormat only had tests for the happy path
and one generic truncation, leaving the PNG signature/IHDR boundary checks
and most of the JPEG marker walk (fill-byte runs, restart/TEM/EOI markers,
the DHT/JPG/DAC frame-marker exclusions, SOS-stops-the-scan, and the exact
byte-count boundaries for a marker's length field and an SOF0 payload)
unexercised. Add targeted byte-level fixtures for each of these branches so
a regression in the marker classification or the segment-skipping arithmetic
fails a test instead of silently misreading a real-world PNG/JPEG header.
…message

Every EpubParseError/EpubWriteError subclass's constructor sets this.name and
a default or derived message, but the existing tests only ever checked code
(and, for a couple of classes, message content loosely via toContain). The
base classes themselves were never constructed directly either. Assert name
and the exact default/derived message on every class, construct the two base
classes directly, and check the unmatchedEnd/unclosedStart ternary picks its
own description rather than merely asserting a substring both branches share.
…segments

resolvePackagePath's relative-vs-scheme classification and its "./", "", and
".." segment handling had no test past the two segment kinds the fixture
paths in other tests happened to exercise incidentally. Add a scheme
covering every character class the regex allows, a leading-digit case that
must NOT be classified as a scheme (a URI scheme starts with a letter), an
explicit "./" segment, and a doubled-slash empty segment.
resolveHrefTarget had no test file of its own -- only whatever incidental
exercise it got through src/xhtml/read.ts and write.ts's own round-trip
tests, which left the scheme/empty-href/no-fragment/empty-fragment guards
and the hash-index arithmetic unverified in isolation. Cover the empty href,
scheme-carrying href, hashless href, empty-fragment href, same-document
fragment, cross-document fragment, subdirectory resolution, and a hash at
index 1 specifically (to distinguish "no hash found" from "hash found early").
buildXml had no test of its own, only whatever incidental exercise it got
through every other module's own round-trip tests -- which never happened
to build a bare comment, cdata, processing-instruction, or declaration node
in isolation, and never checked the no-attributes case renders without a
stray attribute object. Cover text, comment, cdata, pi, declaration,
attribute-bearing and attribute-free elements, and nesting.
…ssification

looksLikeXml's BOM detection and its per-byte whitespace-then-'<' scan had no
test at all -- packageFromEntries was only ever exercised end-to-end through
real EPUB fixtures, none of which happen to carry a BOM, leading whitespace,
or a malformed BOM prefix. Add byte-level fixtures for a real BOM, each of
its three bytes individually wrong, a too-short BOM-like prefix, each
whitespace byte the scan recognises, a non-whitespace non-'<' byte, an
empty part, and a part that is only whitespace with no '<' at all.
isXmlNode had no test of its own -- only whatever incidental exercise it
got via Zod's z.custom() call sites elsewhere, which never happened to feed
it a declaration or pi node, a malformed attribute, or a recursively
malformed element child. Cover every node kind's own field-shape check, the
non-object/array/primitive rejections, and recursive element/attribute
validation, plus isTextLikeNode's own four-way discrimination.
parseXml's element/text/declaration paths were covered but its comment,
cdata, and pi branches (parseNode's tagKey === "__comment"/"__cdata" checks
and the tagKey.startsWith("?") pi fallback) had no test exercising real XML
syntax for any of them.
…e stripping

Only the c1-position invalid-padding case ("A===") was tested; add the
c0-position case ("=AAA") and a whitespace-stripping equivalence check so
the sanitising regex's own effect is asserted rather than merely present.
Every failure path already had a class-based toThrow assertion but never
checked the message text, leaving each one free to say anything at all.
…oss nine small modules

Each of these had one or a handful of untested branches: hasZipHeader's own
partial-header-match case, navMatchesSpine's partial-match case,
decodeTextLikeNode (never tested directly at all), elementsWithTag (never
tested directly at all), mintListNumId/parseListNumId's bullet-with-suffix
and ordered-with-no-suffix cases, readNcxHrefs' missing-src content element,
readNav3TocHrefs' no-epub:type nav / non-nav-tag-with-toc-type / href-less
anchor cases, and reportInertElementSkip (no test file existed for
src/xhtml/context.ts at all).
sameDocumentFragment, isFootnoteReference, and isFootnoteAside had no test
of their own -- only whatever incidental exercise they got through
src/xhtml/read.ts's own end-to-end fixtures. Cover the length-2 fragment
boundary, the structured epub:type noteref/footnote/rearnote signal, the
EPUB 2 class-name idiom on both the anchor and the target, case-
insensitivity, the non-aside tag gate, and the some()-not-every() semantics
for a multi-value epub:type where only one token matches.
…ring gates

Every parseOpf failure path had a class-only toThrow assertion; add message
text for each. Also cover the filter() predicates that drop a malformed
manifest item or a spine itemref with no idref (both previously only ever
exercised with well-formed input), and a manifest item's multi-valued
properties splitting on more than a single whitespace character.
…d guards

readBlockElementInner's own case "p"/"figure"/"figcaption" each called
readContainerChildren identically to the default passthrough case, so they
were dead duplicates rather than distinct behaviour; folded them into
default and moved their rationale into that case's own comment.

headingLevelOf used a /^h([1-6])$/ regex even though the only tags it is
ever asked to classify come from the fixed BLOCK_LEVEL_TAGS set, none of
which (besides h1-h6 itself) can accidentally satisfy a mis-anchored
version of that pattern; replaced it with a literal tag->level lookup
table, which also removes the now-unneeded clampHeadingLevel call.

Removed three `X.length === 0` guards (readContainerChildren's own
segment flush, flushListStrayContent, flushDefinitionListStrayContent,
readTable's stray-block computation, and a table row's own stray-cell
flush) that only ever short-circuited to a state the following
non-empty-result check already reaches on its own, since
readContainerChildren/buildInlineRuns are themselves no-ops on an empty
input array.

Simplified readPreRuns' own flat-text branch condition from
`footnoteName === undefined && !containsFootnoteReference([node], ...)`
to just the second half: footnoteName can only be defined when node is a
footnote-reference <a>, which is exactly what containsFootnoteReference's
own first check already tests on that same single-element array, so the
first half never adds information the second half didn't already carry.
…le edge cases

Adds direct scanXhtmlAnchors coverage for a deeply-nested id-bearing
element and for filtering anchors to real, href-bearing <a> elements
only. Adds a style-residue test distinguishing a <link> with a
non-stylesheet rel from an unrelated element that merely carries a
rel=stylesheet attribute, plus an exact-message assertion for the
style-residue diagnostic.

Adds a direction-key-absence check (toEqual alone can't tell a missing
key from an explicit undefined one), a combined blockquote+dd indent
test proving the two indent sources are summed rather than subtracted,
and a nested-heading-inside-blockquote test proving containsHeading
recurses rather than only checking direct children.

Covers several <pre>/<code> edge cases: no codeLanguage key when there
is no language- class, a genuinely empty <pre> producing no runs, a
non-<a> element's href never being mistaken for a footnote reference
even when it resolves to a real footnote target, an <img> with no src
producing the generic "<img>" label versus one with a src naming it, and
the language- class requiring a leading boundary rather than matching
anywhere in the string.

Covers table stray-content routing: a <ul> sitting directly inside a
<table> reads as a real nested list rather than being mistaken for a row
group, a stray <img> inside a <colgroup> becomes a real image block
rather than degrading to alt text, and the noscript-skip diagnostics
inside a <table>/<colgroup>/<tbody> fire exactly once even alongside
other stray content that itself triggers the unrecognized-content
diagnostic, plus exact list-content-outside-item message assertions for
both the before-first-item and continuation wordings.
Every stray-node accumulator collected while reading a list, definition
list, or table eventually flows through readContainerChildren's own
buildInlineRuns call, which already skips any node that is neither
text-like nor an element (comments, declarations, processing
instructions) and already carries its own isInertElement check for
script/template/style/noscript. The per-container pre-filters
duplicating both checks before pushing into strayNodes/segment were
dead weight: removing them changes nothing observable, since the
deeper call already produces the same recovered blocks and fires the
same noscript diagnostic from its own appendElement guard.
…des in xhtml/read

Adds assertions distinguishing a genuinely absent colSpan/rowSpan key
from one present with an undefined value, a zero-column-count table's
width falling back to the full content width instead of dividing by
zero, which of two duplicate captions actually triggers the
duplicate-caption diagnostic, stray content ordering around a <dl>'s
own <div> wrapper, a table row's own trailing stray cell, positiveIntAttr's
zero/negative rejection, empty-alt image dropping on both the
unresolved and unsupported-format degrade paths, a plain non-footnote
<aside> and a footnote <aside> with no id, and a non-<a> element whose
own href happens to share a fragment with a real footnote reference
elsewhere in the document. Also pins the exact diagnostic message text
for the table-caption, table-row, and footnote-target-unresolved
diagnostics.
…a table cell loop

The table row's own td/th loop duplicated the same isInertElement
pre-filter already removed from the sibling stray-content loops:
flushStrayCell's readContainerChildren call already carries the
identical check via buildInlineRuns' appendElement, so a script,
template, style, or noscript cellNode reaches the same outcome either
way. Also covers stray text sitting directly inside a <table> outside
any row, caption, or colgroup, the one remaining shape that reached
readTable's own strayNodes collection with no test at all.
…ounds checks in xhtml/write

writeSectionChild's own ListGroupNode branch was already documented as
unreachable dead code: writeSectionChildren's own loop always groups a
run of sibling ListGroupNode entries into writeList before this
function is ever called. Narrowing the parameter type to
Exclude<SectionChild, ListGroupNode> lets TypeScript's own control-flow
analysis at the one call site prove the branch unnecessary, rather than
keeping a runtime check no test can ever reach.

Also drops the `end < children.length` bound ahead of isListGroupNode
in the list-run scan, and rewrites the outer children walk as an
unconditional loop relying on its own existing undefined check:
children[end] is undefined past the array's own end, and
isListGroupNode(undefined) is already false (its own isRecord guard
rejects a non-object outright), so both loops already terminated at
the same point either way.
writeList's own consecutive-itemId scan duplicated the same
out-of-bounds-is-undefined pattern already removed elsewhere in this
file: past the array's own end, items[end] is undefined, and its
optional-chained itemId is therefore undefined too, which can never
equal the loop's own itemId (this branch only runs when itemId is a
real, defined string), so the loop already terminated at the same
point without the separate length check.
…in xhtml/write

Adds assertions for: two adjacent list items that both carry no itemId
staying as separate <li> elements rather than merging; an ordered
list's default start carrying no explicit start attribute; a nested
bookmark construct group overwriting an inner target's own id rather
than leaving both in place; a bookmark wrapping more than one element
degrading with the exact CONSTRUCT_UNREPRESENTED message; a
block-scoped endnote/comment anchor construct group falling through to
the generic construct-unrepresented path rather than being mistaken
for a bookmark; the exact wording distinguishing a footnote reference
from an internal link, and a point anchor from a range extent, in an
unrepresented-extent diagnostic; a clean, non-overlapping point or
range footnote reference never firing that diagnostic at all; a
multi-run footnote range inside a pre element joining its own text
verbatim with no separator; no spurious empty text node between two
br elements for a run's own consecutive newlines; a wholly empty run
writing as exactly one empty text node; a table cell's own rowspan
attribute; no false-positive ELEMENT_UNMAPPED for an ordinary table
cell; a paragraph carrying only codeLanguage still writing as a pre
element; the monospace-plus-newline legacy heuristic requiring exactly
one run; a horizontal-rule styleId with real runs never becoming an hr
element; a dropped pageBreak block; and an embeddedObject block's and
an unresolved image's exact diagnostic messages.
…dges in xhtml/write

Adds a plain paragraph exercising the monospace-plus-newline <pre>
heuristic on its own, with neither preformatted nor codeLanguage set;
an internal-link construct extent writing its own <a href> to the
resolved anchor; an external-target link construct extent being left
entirely unwrapped and undiagnosed, since that target kind is
ContentRun.hyperlink's own established territory, not this construct
path's; and rewrites the nested-bookmark id-replacement test to
inspect the written element's raw attributes array directly, since a
plain object literal's duplicate-key-overwrite semantics collapse a
filter that removed nothing at all to the same final serialized
attribute a correct filter would also produce.
…ts in opf/parse

Adds assertions for: dc:contributor reported as unmapped with the
exact message, alongside the existing dc:publisher/dc:rights coverage;
a whitespace-only dc:title/dc:language/dc:date treated as absent
rather than an empty string; a whitespace-only dc:creator/dc:subject
value dropped while a real sibling value survives, and both fields
carrying no key at all when every value is whitespace-only; the EPUB
2-style <meta name="dcterms:modified" content="..."/> spelling; a
whitespace-only content attribute on that same variant leaving
modifiedIso unset; and the property="dcterms:modified" scan
continuing past an empty-text meta to find a later, real one instead
of stopping there.
…ics in opf/parse

Adds assertions distinguishing a title/language/createdIso key genuinely
absent from one present with an undefined value (toEqual alone ignores
undefined properties on both sides, so it cannot tell these apart);
a meta whose property or name attribute names something other than
dcterms:modified never contributing its own text or content to
modifiedIso; and trimming applying to both the property-based meta's
own text and the name-based meta's own content attribute.
…nav3

A split on a whitespace run then an exact "toc" token lookup produces the
identical result a split on one whitespace character would, since neither
the run-collapsing quantifier nor the ?? "" fallback default can ever
change whether "toc" appears as a token. Testing the raw attribute value
directly against a word-boundary pattern removes both indistinguishable
constructs.
…o tokens

Every caller's own pattern (noteref, footnote|rearnote) is an unanchored
substring match with no word-boundary assertion, so splitting the
attribute into whitespace-separated tokens first changes nothing a caller
could observe: a match inside one token is exactly the match testing the
whole value would find, and an empty token never matches any of these
patterns either way. Testing the raw value removes the indistinguishable
split/filter machinery entirely.
…ce char

A split on a whitespace run, followed by filtering out empty tokens,
yields the identical token set a split on one whitespace character
followed by the same filter would, since the actual non-whitespace runs
are unaffected by how many split points a run of whitespace produces.
Using a bare \s costs nothing here and carries no quantifier for a
mutation test to remove without changing observable behaviour.

Also adds a test for leading/trailing whitespace in a properties
attribute, proving the filter actually drops the empty tokens that
whitespace at either end would otherwise leave in the parsed array.
…fTarget

An empty href can never contain "#", so the hashIndex-not-found check
immediately below already returns undefined for it. The separate
href.length === 0 guard duplicated that outcome without changing it for
any input, so it never had an observable effect of its own to test.
…ed buffer

The decoded length isn't known until the final quad's own padding has
been walked, so a pre-sized Uint8Array only ever needed to be a large
enough upper bound before being trimmed with subarray() -- its own exact
size was never something a caller could observe, since any sufficiently
large size decodes identically once trimmed. Appending to a plain array
and converting once at the end removes that unobservable sizing
computation entirely.
Adds cases for a value whose typeof is not "object" but which still
carries every property a text node would need, an object whose type
matches no recognized variant even though it has full element-shaped
fields, and an attribute whose name is well-formed but whose value is
not a string.
Parses the emitted XML back into its element tree and checks every tag,
attribute, and text node down to the leaves, instead of a handful of
substring checks that couldn't tell a wrong tag name or attribute value
from a right one. Also covers the blank-heading fallback to a generated
section title, joining a multi-run heading's text with no separator.
… guards

Adds direct coverage for readUint16LE/readUint32LE's truncation errors,
localFileHeaderNames walking past an entry's own extra field and
compressed data to find the next signature, and assertMimetypeEntryLayout
actually rejecting a malformed entry rather than merely being called with
one that already satisfies it.

Also drops assertMimetypeEntryLayout's own per-assertion description
strings: vitest's second expect() argument only ever labels a failure
message and is never part of the pass/fail decision, so no test could
ever observe a difference between two such strings.
…parser drops

Confirmed empirically against fast-xml-parser's own XMLBuilder in
preserveOrder mode: a "?target"/"?xml" node's inner array content is never
written to the output regardless of what it holds -- an empty array, a
plain object, and a populated "#text" wrapper all produce byte-identical
output. The array's own presence is required by the builder's expected
node shape, but nothing it holds is ever observable, so building it from
node.content/an empty text wrapper only pretended to preserve something
this builder configuration already discards on every input.

Also drops buildXml's own runtime `typeof out !== "string"` guard:
fast-xml-parser's own type declaration (`build(jObj: any): string`)
already guarantees a string return unconditionally, and the element
case's own attrs-emptiness check before setting ":@" is dropped for the
same reason confirmed empirically: an empty ":@" object and an absent
":@" key render identically.
…ape checks

fast-xml-parser's own parse() return type is any, so nothing upstream of
parseXml can guarantee the preserveOrder shape these functions validate
at compile time, and almost none of these checks can be driven by any
syntactically valid XML string -- the shape they guard is the library's
own internal invariant, not something malformed markup can violate.
Exports parseNode, parseNodes, parseAttributes, and scalarText so each
guard can be exercised directly with an adversarial unknown value, the
same way node.ts's own isXmlNode already is.
…ons already cover

isPng, isJpeg, and readJpegDimensions's own initial signature check each
paired a bytes.length comparison with byte-value equality checks
immediately after it. Reading past the end of a Uint8Array yields
undefined, which can never equal a real signature byte, so each length
guard was already redundant with the equality checks it stood in front
of -- confirmed both analytically and by brute-forcing thousands of
byte sequences through the JPEG marker walk with and without its own
segment-length guard, finding zero differing outcomes.
…ns subsume

The BOM length check duplicated what the three byte-value comparisons
right after it already guarantee: a too-short array reads undefined at
those indices, which never equals a real BOM byte. The loop's own
b === undefined guard is equally redundant -- within i < bytes.length a
Uint8Array never holds a hole, and even if b were undefined, it fails
every whitespace comparison and the trailing b === 0x3c check the exact
same way this guard's own return false already did.
parseOpf's own round trip normalizes away several details a hand-built
OPF document actually carries: it never checks a manifest item's
properties attribute is even present when empty, it drops dc:identifier's
own id attribute, and it never looks at package/metadata's own xmlns
attributes at all. Parses the emitted XML back into its element tree to
assert on all of these directly, and adds coverage for joining more than
one manifest property with a real space and for the dcterms:modified meta
element, neither of which any existing test exercised.
…ntent

Every existing test reading these fixtures resolves paths through the
OPF manifest, so a wrong zip entry path, a mimetype entry that isn't
genuinely stored uncompressed, or an entry silently zipped with no
content at all would still let most of those tests pass. Verifies each
fixture's own real output directly: the mimetype entry's byte layout,
every declared path present with real non-empty content, and the total
entry count (so two paths colliding can't hide behind an already-present
one).

Also shrinks the hand-built cover PNG to the 24 bytes its own IHDR
header actually needs and adds a direct width/height check against it:
this package's own PNG reader never looks past that offset, so the
trailing bit-depth/colour-type bytes it used to write were never
observable by anything, and the width/height fields it does read had no
test checking them at all.
…squerade fixture

Object.assign doesn't verify a source object's properties are
assignable to the target's declared types, so it passes silently
where a direct property assignment would be rejected by the compiler.
@Mearman
Mearman force-pushed the feat/100-percent-mutation-epub-codec branch from 0dfe542 to f446dfd Compare September 14, 2026 16:59
…nd the redundant span case

styledRun's hyperlink parameter had no caller: appendAnchor's own
external-href fallback spreads hyperlink onto each nested run directly
instead of ever calling styledRun with one. Separately, appendElement's
switch listed "span" as its own case immediately above the identical
default fallthrough, so the case label named nothing default did not
already handle.

Also covers styledRun's fontFamily/verticalAlign/direction fields, each
tag alias case (<b>/<i>/<strike>/<del>), an empty CDATA section
producing no run, and appendImageFallback/appendAnchor's own edge
cases -- none of these had a dedicated test file before.
…s as dead ZipEntry objects

The entries array's only consumer strips every field but .bytes before
handing it to packageFromEntries -- packageFromEntries/serializePackage
(the Package model, #963) own the real OCF
mimetype-first/stored-uncompressed byte layout now, so the "stored"
flag this array's own mimetype entry carried had no reader left.
Reworked the array to plain [path, bytes] pairs and dropped the
now-unused ZipEntry import.

Also folds buildHead's own "no heading in this section" fallback into
sectionXhtml's stored ContentSection reference rather than re-deriving
it from document.sections[index] after the fact -- sectionTitle
already provides the identical "Section N" fallback on its own, so the
separate ternary duplicated it for a lookup that was always defined by
construction anyway.

Covers each ContentImageBlock format's own extension and media-type,
the OPF manifest's nav/section/image item shapes, registered-image
numbering, the generated dc:identifier, an unbalanced construct
marker, and writeEpub's own non-wordprocessing kind check -- none of
which src/write.ts had a dedicated test file for before.
…nostic message paths

Adds coverage for a wrong (but present) mimetype entry, a missing
container.xml, a missing OPF rootfile, an empty resolvable spine, and
a section with no quarantined CSS residue carrying no source key at
all. Adds real-message assertions (not just .code) for
SPINE_ITEMREF_UNRESOLVED, MANIFEST_ITEM_MISSING, INVENTED_PAGE_GEOMETRY,
NAV_DOCUMENT_MISSING, NAV_SPINE_ORDER_MISMATCH (both the EPUB 3 nav and
EPUB 2 NCX variants), and NCX_MISSING. Adds NCX_MISSING-does-not-fire
coverage for a spine with no toc attribute and for one whose toc
attribute resolves to a real manifest item, matching the existing
NCX_MISSING-fires case.

Adds a bespoke three-chapter fixture for
buildCrossDocumentAnchorRegistry's own two edges no existing fixture
reaches: a footnote-shaped referrer read first is never downgraded by
a later plain referrer to the same target, and an explicit full-path
self-reference degrades to a plain hyperlink rather than an internal
link construct pointing at its own document.
…ined-read termination

readJpegDimensions' own marker walk and looksLikeXml's own BOM/
whitespace scan each paired a bytes.length comparison with reads that
are already undefined-safe past the array's end -- an out-of-bounds
read can never match a real marker byte, a real whitespace byte, or
the literal '<', so the loop's own final branch already answers
correctly for that case regardless of the length check. Proved by
running each loop with and without its own bound and finding zero
differing outcomes. Restructured both as unbounded loops whose real
termination is the undefined read itself, removing the redundant
comparison entirely rather than leaving it for a mutation test to
prove equivalent.
… truncation guards

A Uint8Array holds no gaps, so its highest-read index (b1 for the
16-bit reader, b3 for the 32-bit one) reads as undefined whenever any
lower one would too, and never the other way around -- the separate
per-byte undefined checks the two guards paired together were already
redundant with the highest one alone. Adds definedByte, a narrowing
helper for the remaining lower-index reads that throws rather than
silently coercing to NaN if the invariant it relies on is ever wrong,
with its own direct unit test covering both branches.
…gnored builder value

fast-xml-parser's builder never reads a pi/declaration node's own
value at all in preserveOrder mode, confirmed against every shape
tried (an empty array, one holding real content, undefined, null, a
plain object) -- each produces the identical output. undefined states
that plainly rather than via an empty array shaped like children data
the builder would otherwise use.
…toc"

hasTocEpubType matches "toc" at a real token boundary (preceded and
followed by whitespace or a string edge); nothing previously exercised
a value like "toc-alt" that starts with the literal substring but
isn't a standalone token.
@Mearman
Mearman marked this pull request as ready for review September 14, 2026 18:42
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Sep 14, 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-14T18:57:38.265943Z 8a405ab 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 14, 2026 19:13
@Mearman
Mearman merged commit 523797c into main Sep 14, 2026
30 of 35 checks passed
@Mearman
Mearman deleted the feat/100-percent-mutation-epub-codec branch September 14, 2026 19:18
@github-actions

Copy link
Copy Markdown
Contributor

🎉 This PR is included in version 1.5.5 🎉

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