Skip to content

test(rtf-codec): reach a 100% mutation score - #1261

Draft
Mearman wants to merge 103 commits into
mainfrom
feat/100-percent-mutation-rtf-codec
Draft

Mearman wants to merge 103 commits into
mainfrom
feat/100-percent-mutation-rtf-codec

Conversation

@Mearman

@Mearman Mearman commented Sep 12, 2026

Copy link
Copy Markdown
Member

Works through rtf-codec's survived/no-coverage Stryker mutants toward a genuine 100% mutation score, restructuring code to remove unreachable or redundant defensive branches where that's the right fix and adding isolating tests otherwise. No Stryker disable comments anywhere in src/ (verified by grep).

Measured baseline: 70.65% of 3275 valid mutants, timeout share 2.1%.

Current overall score: 81.70% (up from the 70.65% baseline), across 3275+ valid mutants.

At or genuinely 100%: cell-format.ts, list-id.ts, group.ts, bytes.ts (both the shared one and test-support's own), codepage.ts, test-support/brace-balance.ts, constructs.ts, tokenize.ts, embedded-object.ts. Several of these needed real restructuring, not just more tests, to remove genuinely equivalent mutants a test could never observe -- e.g. a DIB-writer's zero-value fields already covered by a freshly zero-initialized buffer, a brace-counting helper's escape-detection rewritten around a regex matchAll so no placeholder string is left for Stryker to mutate harmlessly, and several nameStart-tracking offsets in header.ts proven fully redundant by collectPlainText's own tolerant per-token skipping.

header.ts: 83.66% (up from 75.48%), 66 gaps remaining across the list table, list override table, revision table, and info-group parsers -- the font table, colour table, and style sheet parsers are now either 100% or down to the file's own remaining structural gaps.

Still fully open: read.ts (71.65%, 336 gaps -- survived + no-coverage) and write.ts (78.38%, 181 gaps), the two largest files in the package, not yet started this round.

Left as draft until the full suite is verified at 100%. pnpm typecheck/lint/test are all green on every commit in this branch.

@Mearman
Mearman force-pushed the feat/100-percent-mutation-rtf-codec branch 5 times, most recently from 1a5029d to 9168d90 Compare September 14, 2026 12:20
… utilities

base64.ts, bytes.ts, group.ts, list-id.ts, and diagnostics.ts each had no
dedicated test file of their own and were only exercised indirectly
through the read/write pipeline, leaving boundary conditions (base64
padding for every length-mod-3 case, the base64/hex invalid-character
paths, chunked ASCII conversion across its 8192-byte boundary,
rtfBytesFromLatin1's exact error message, group-matching at an
unbalanced or sibling-adjacent close, groupHead's \* ignorable marker
against an unrelated control symbol, every numId mint/parse edge, and
every diagnostic error class's own name/code/message/fields) untested
at the unit level.
… codepage decode

Every SINGLE_BYTE_PAGES entry is exactly 128 characters covering
0x80..0xFF in full, so table[byte - 0x80] can never be undefined for a
byte in that range and the "?? �" fallback the bracket read's
own string | undefined type demanded was dead code. Switching to
table.charAt(byte - 0x80) (a total string method with no undefined
case to guard) removes the fallback outright rather than papering over
an index the noUncheckedIndexedAccess type checker cannot itself prove
is always in bounds -- the identical charAt-over-bracket-read
convention bytesToBase64 already documents in base64.ts.

Also covers the read side directly: an empty input, \ansicpg65001's
UTF-8 path, the exact unsupported-codepage diagnostic message, and the
DBCS decode's own undefined-trail-byte and no-single-byte-extras-table
fallbacks, none of which had a dedicated assertion before.
…okenize.ts

isAsciiLetter's 'A'/'Z'/'a'/'z' range edges and hexDigitValue's
uppercase hex-letter branch had no direct assertion: every existing
control-word fixture used only lowercase names, and every \'hh fixture
used only lowercase hex digits, so a boundary shifted by one letter in
either direction produced no observable difference in any existing
test.
… side-to-word lookup

CellBorderSide is an exhaustive four-member union, so the control word for a
given side can never fail to resolve -- the prior find() over
CELL_BORDER_SIDES plus its "not found" branch returning "" was dead code that
only existed to satisfy the Map's own possibly-undefined return type.
CELL_BORDER_SIDE_WORDS is CELL_BORDER_SIDES' own inverse, typed as a total
Record over CellBorderSide, so the lookup is direct and the unreachable
branch is gone.
…ing brace

The five-byte "{\rtf1" magic check needs every byte to match, not merely the
first -- this covers a candidate that opens with the same brace and then
diverges immediately, which the existing cases (no brace at all, or a ZIP
signature) didn't isolate.
…ed functions

applyCellDefinitionControlWord, resolveBorder, resolveCellFill,
borderControlWords, and cellFillControlWords were previously exercised only
indirectly through full RTF round-trips in read.test.ts/write.test.ts, which
left most of their individual branches -- each merge flag, each border
descriptor keyword, the shading boundary cases, the pattern-fill colour
omissions -- with no isolating assertion of their own. This calls each
function directly with the specific inputs its own branches need,
including the tie-break case (shading exactly halfway between two
percentN steps) and the brdr/brsp prefix-vs-suffix distinction the
control-word fallback depends on.
…s already make redundant

readLengthPrefixedAnsiString, readObjectHeader, skipPresentationObjectHeader,
and skipClipboardFormatHeader each threw a custom "ends before its X field"
error ahead of a DataView.getUint32 read that already throws a RangeError
for the identical out-of-bounds condition -- readEmbeddedObjectData's shared
catch treats both identically, so the explicit checks added nothing beyond a
different error message nobody reads. readEmbeddedObjectData's own
NativeDataSize check was the same shape. Removed all of them; kept the two
checks that are genuinely load-bearing (NativeData and PresentationData's own
subarray bounds, since Uint8Array.subarray silently clamps rather than
throwing).

Also drops readLengthPrefixedAnsiString's decoded string value entirely:
every caller (readObjectHeader, skipPresentationObjectHeader) only ever used
the returned offset to keep walking the structure, never the decoded
ClassName/TopicName/ItemName text itself, so decoding it was pure dead work.
The function now returns just the next offset, and the now-unused ANSI
decoder and per-character ASCII validation in writeLengthPrefixedAnsiString
(every caller passes one of this module's own fixed ASCII constants, never
caller-supplied text) are gone with it.
…remaining boundary checks

Adds byte-level assertions the existing round-trip tests never made: the
full ObjectHeader ClassName content ("Package", not merely its length),
every field of writeMinimalDib's own DeviceIndependentBitmap Object, and
off-by-one boundary cases for NativeDataSize and PresentationDataSize
(exactly one byte more than actually remains, rather than a wildly oversized
value). Also covers the optional envelope fields (anchorRow/anchorColumn/
offsetXPt/offsetYPt/source) being omitted entirely rather than merely
undefined when the source embed carries none of them, and a NativeData
payload that decodes to a non-object JSON value (null, an array, a bare
string, a bare number) instead of the record ContentEmbeddedObjectSchema
expects.
…l, not decorative

NUMID_PATTERN's (\d+) capture group is required, so a genuine regex match
can never actually leave match[1] undefined -- but TypeScript still types
every numeric index into a match array as possibly undefined, since it has
no way to encode "always present for a required group". The existing guard
had no test that could ever exercise it, since no real input reaches that
branch. Adds a test that mocks RegExp.prototype.exec for one call to force
the otherwise-impossible case, confirming the guard actually returns
undefined rather than only existing to satisfy the type checker.
…t8Array already makes redundant

asciiStringFromBytes and rtfBytesFromLatin1 both looped with an explicit
start/index < length bound whose off-by-one mutation (<= in place of <) is
unobservable: Uint8Array.subarray already clamps a past-the-end range to
empty on its own, and writing past a Uint8Array's own length is already a
silent no-op, so one extra iteration at the boundary produced no output
either way.

rtfBytesFromLatin1's index increments by exactly 1 per iteration, so its
bound is now index !== source.length -- exactly equivalent to < here, but an
off-by-one mutation of !== (=== in its place) stops the loop from running at
all instead of surviving unobserved. asciiStringFromBytes advances by
ASCII_CHUNK_SIZE instead, so the same trick isn't safe there (a chunk size
that doesn't evenly divide the input could skip past an exact !== match);
it now loops until subarray hands back an empty chunk, the one condition
that actually distinguishes "more bytes remain" from "done".
…roupEnd

index < tokens.length's own off-by-one mutation (<= in place of <) was
unobservable: index increments by exactly 1 per iteration and the optional
chain on tokens[index]?.kind already treats a past-the-end read as neither
a groupStart nor a groupEnd, so one extra boundary iteration changed
nothing. index !== tokens.length is exactly equivalent here, but an
off-by-one mutation of !== stops the loop from running at all instead of
surviving unobserved.
…d impossible fallbacks

decodeCodepageBytes's leading input.length === 0 check was dead weight: the
UTF-8 TextDecoder, the DBCS state machine, and the single-byte for-of loop
below all already produce "" on their own for zero bytes, with no codepage
lookup or diagnostic ever triggered along the way, so the dedicated early
return changed nothing any test could observe.

decodeDbcsBytes's own trailTable[trail] and singleByteExtras[byte - 0x80]
bracket reads each carried a `?? "diamond question mark"` fallback for a
`string | undefined` type that can never actually be undefined: both tables
are dense, fixed-length strings per their own generation header comment (256
and 128 characters), so any in-range index always returns a real character.
Switched both to charAt, which returns a plain string even past the end,
removing the unreachable fallback the same way decodeCodepageBytes's own
single-byte loop already does for SINGLE_BYTE_PAGES.

Also drops the now-redundant while (i < input.length) bound on
decodeDbcsBytes's own loop: i's step varies between 1 and 2 bytes, but a
Uint8Array read at or past its own length already returns undefined rather
than throwing, so the existing byte === undefined check is already the one
real stopping condition.
…UTF-8 and DBCS paths too

The existing empty-input test only exercised code page 1252 (the
single-byte path); the UTF-8 TextDecoder and the DBCS state machine each
have their own independent route to producing "" for zero bytes, worth
proving separately now that decodeCodepageBytes no longer special-cases
length 0 up front. The DBCS case also confirms no diagnostic fires for it.
…tead of re-looking them up

close() took a key and re-fetched its entry from the open map, guarding
against a "not found" case that could never actually occur: both call sites
already derived key from iterating open itself, so the entry was always
present. Passing the entry through directly removes the dead guard along
with the Map.get call it existed only to check.
…d functions

bookmarkAnchorDescriptor, bookmarkResidueControlWords, isBookmarkAnchor,
hasRevision, provenanceDescriptors, isoFromDttm, dttmFromIso,
formFieldControlType, formFieldContentControl, and coalesceRunConstructs
were previously exercised only indirectly through full RTF round-trips in
read.test.ts/write.test.ts. This calls each function directly with the
specific inputs its own branches need: every DTTM boundary (day/month/year
at their exact limits either side), the FFDataBits undefined-result sentinel
falling through to a default, a dropdown's own bounds-checked selection
index including a genuine sparse-array hole, and coalesceRunConstructs' own
sort tie-break when two extents open on the same run but close at different
ones.
index < rtf.length's own off-by-one mutation was unobservable: index's
step varies (1 or 2 characters per iteration, since an escape sequence
consumes two), but a string index at or past its own length already reads
back undefined rather than throwing, and undefined matches none of the
character branches below -- so one extra boundary iteration always changed
nothing. Replaced with the same character === undefined stopping condition
decodeDbcsBytes already states for the identical shape of loop in
../codepage.ts.
The brace-counting helper itself had no test of its own, only indirect
exercise through write.test.ts's use of it to check writer output. Covers
the escape-recognition rules directly: a literal \{/\} pair not miscounted
as a real delimiter, an escaped backslash (\\) not swallowing the real
brace that follows it, an unrelated control-word backslash (\b) not
absorbing the brace after it, and a lone trailing backslash with nothing
after it.
… that never change the result

readLengthPrefixedAnsiString's length === 0 early return produced the exact
same value as its own fallthrough (offset + 0 is offset), so the dedicated
case was dead weight. writeObjectHeader wrote TopicName and ItemName as
explicit zero bytes even though they are always empty and `out`'s own
zero-initialization already supplies those bytes -- only their length
needed accounting for, not the write itself. writeLengthPrefixedAnsiString's
own copy loop now bounds on index !== value.length: it increments by
exactly 1 per iteration, so this is equivalent to < but, unlike <, an
off-by-one mutation of it stops the loop rather than surviving unobserved.

isRecord drops its Array.isArray exclusion: it is only ever called on a
JSON.parse result within withoutInvalidSource, and a JSON array can never
carry a "source" own-property to strip (JSON.stringify only serialises an
array's numeric indices), so treating an array as record-shaped here
changes nothing -- ContentEmbeddedObjectSchema.safeParse still rejects it
for not being an object.
… missing Package stream

Adds the FormatID/ClipboardFormat wrong-value cases the existing
truncation tests didn't isolate (a field with an incorrect value but
otherwise well-formed framing, not one that never arrives at all), a
direct check that the written Package stream's own sourcePath/tempPath
decode to empty strings rather than arbitrary filler, and a compound file
that parses fine but carries no stream literally named "Package".
… and exact border output

Two gaps the existing assertions didn't isolate: the clvertalc/clvertalb
cases only checked the resulting verticalAlign, never the function's own
return value, so a mutant forcing that return to false went unnoticed;
and the colour-control-word test only checked for the substring's
absence/presence, which still passes even if the "no colour index" branch
returned arbitrary text instead of the empty string it's actually meant
to produce. Both now assert the complete output.
…when trail is undefined

trail is only ever undefined when i + 1 is already past input's own end,
meaning i was already the last valid index -- advancing by 1 or by 2 from
there both land past input.length either way, so there is no real pair
left to skip over by always advancing the full 2. The conditional step
only existed to look correct; it never changed which iteration the loop
stops at.
…onstructs' sort test

The existing tie-break test only covered two extents sharing the same
startRun, which a corrupted comparator can satisfy by accident once
startRun differs (the first comparison term already short-circuits before
the tie-break term is ever evaluated). Adds an overlapping-extent case
where the shorter extent closes -- and is pushed onto the result array --
before the longer one, so raw insertion order is the reverse of the
required startRun order and only a comparator that actually compares
startRun correctly produces the expected sort.
…nd vestigial textStart tracking

Each of the four cursor < input.length checks in scanControlWord guarded a
comparison that already handles running off the end safely on its own:
isAsciiLetter/isAsciiDigit against input[cursor] ?? 0 (0 matches neither),
and input[cursor] === MINUS / === SPACE against a genuinely undefined
value (never equal). One extra out-of-range check at each site changed
nothing a test could observe.

pushTextByte's own textStart === -1 guard was similarly dead: textStart's
value is only ever compared against -1 (flushText's "is a run pending"
check), never read as an actual byte offset back into input, so
unconditionally overwriting it on every call is exactly equivalent to
only setting it the first time.
…n0, and boundary fallbacks

Adds the cases the existing suite left untested: a bare LF (no CR) and a
bare CR (no LF) each collapsing into \par on their own, an LF-then-CR pair
NOT collapsing (only CR-then-LF does), a lone minus sign with no digits
after it reverting to text, a trailing backslash with nothing following
it at all, a byte just past hexDigitValue's own a-f/A-F ranges falling
back to an ordinary control symbol, \bin0 not being treated as a binary
run (N must be strictly positive), and a parameterless control word
carrying no param property at all rather than an explicit undefined one.
… partial colour entry

subject, keywords, and operator (creator) were never exercised at all --
only title/author had a test. Adds them, plus a keywords list with an
empty entry from a doubled delimiter, an entirely absent field staying
absent rather than an empty string, and a colour table entry stating only
one of red/green/blue (still a real, defaulted-to-0 colour, not the auto
entry, which requires all three to be genuinely absent).
…and bookmark deletion

The extent-sort comparator's own first clause compares startIndex by subtraction; a mutant
summing the two values instead survived every existing fixture because sectionBlockExtents is
always pushed in ascending start order for those cases, so the pre-sort array already matched the
correct order and a symmetric, always-positive comparator never triggered a swap. Adds a fixture
where a shorter, later-starting extent closes (and is therefore pushed) before a longer,
earlier-starting one that encloses it, so the array arrives pre-sorted backwards and only a
genuine subtraction-based comparator restores the correct nesting.

endBookmark's own openBookmarks.delete(name) call survived because the existing "genuinely
deletes" test only counted BOOKMARK_UNPAIRED diagnostics mentioning the bookmark's name, and a
missing delete produces exactly one such diagnostic too -- from reportUnclosedBookmarks at the
document's own end, since the never-deleted entry is still open there. Asserting the diagnostic's
exact message (the bkmkend-with-no-bkmkstart wording, not the still-open-at-end wording) is what
actually distinguishes a real delete from a no-op.
…edundant surrogate offset

Each of applyPictureControlWord's, applyFormFieldControlWord's, and
applyStructureControlWord's own switch ended in a bare default: break;
clause. Falling out of a switch with no matching case, and falling out
of an explicit but otherwise-empty default clause, land in the
identical place -- these three functions' own end -- so the clause was
pure no-op ceremony a mutation tester could never distinguish from its
own removal. Dropped rather than documented as equivalent.

The \uN reader's own code < 0 ? code + 0x1_00_00 : code ternary was
undoing RTF's own "subtract 65536 for a negative code point" encoding
by hand, but String.fromCharCode's argument coercion (ToUint16) already
reduces any integer modulo 2**16 before treating it as a UTF-16 code
unit -- fromCharCode(-4064) and fromCharCode(-4064 + 65536) are the
same call. The conditional never changed fromCharCode's own output for
any input, so it is replaced with a bare fromCharCode(code).
… own three clauses

The main token loop's own unconditional trailing flushBytes() call --
reached once the token stream is exhausted, past every other event
that could flush pending ANSI bytes on its own -- had no fixture where
input ends with buffered text and no closing brace to flush it first.
Without that call the trailing text is silently dropped: it never
reaches appendText, so the paragraph endSection force-closes comes out
empty instead of holding it.

assertRtfHeaderPresent's own three-clause OR chain had only one
fixture ("not rtf at all"), which trips its first clause alone -- the
second and third clauses independently evaluate true on that same
input regardless of the first, so neither was actually exercised on
its own. Adds a malformed-but-braceless "\rtf1\rtf1" fixture (isolates
the first clause: two bare control-word tokens with no leading brace at
all) and a properly-braced-but-wrong-name "{\ansi not rtf}" fixture
(isolates the third clause: brace and control-word shape both correct,
only the name is wrong).
closeTable's sole call site always derives columnCount as
Math.max(this.tableColumnRights.length, ...), so columnCount can never
be smaller than tableColumnRights.length itself. A slice bounded by
columnCount can therefore never truncate the array it iterates,
making rights.slice(0, columnCount) equivalent to iterating rights
bare. Dropped rather than documented as equivalent.
endParagraph's own resolveBookmarkPositions call already flushes
closingBookmarks with the correct inTable value whenever it actually
runs -- but its own force=false early return skips that call entirely
for an otherwise-empty trailing paragraph (zero runs). A \bkmkend that
is the ONLY thing in such a paragraph, immediately before \cell, is
therefore left pending until endCell's own explicit
flushClosingBookmarks(true, ...) call two lines later -- the one
remaining place that still resolves it. The existing "splices a
bookmark closed inside a cell" fixture never reaches that path (its
own bookmark closes in a paragraph that also flushes via
resolveBookmarkPositions first, leaving closingBookmarks already empty
by the time endCell's explicit call runs), so a wrong hardcoded
argument there went unnoticed. Adds a fixture where \bkmkend is the
paragraph's only content.
…lter

Every entry in pendingRunConstructs is pushed with endRun set to
this.runs.length at that exact moment (endBookmark's own same-paragraph
branch, endFormField), and runs.length only ever grows between then
and this call -- more text can follow within the same paragraph, but
nothing ever shortens it -- so a stale, too-large endRun can never
occur by construction. coalesceRunConstructs draws its own endRun
values from indices into runProvenance, which is pushed in lockstep
with runs (flushRun always pushes both together), so the identical
bound holds for it too. The filter could never remove anything a real
call actually produces, so it is dropped rather than documented as
equivalent.
… reversed push order

The existing "sorts by start position" fixture builds its two extents
entirely from coalesceRunConstructs, which already emits them in run
order -- so the pre-sort array there is already correctly ordered and
never exercises the sort's own comparator logic; a broken or removed
sort would pass it unnoticed.

Adds two fixtures that force a genuinely reversed pre-sort array, the
same technique already used for the block-extent sort: a nested
bookmark pair, where the inner one's own endBookmark call (and so its
own push into pendingRunConstructs) fires before the outer one's, despite
the inner having the LATER startRun; and a bookmark plus a coalesced
revision extent sharing one startRun, where pendingRunConstructs is
always spread into the array before coalesceRunConstructs' own output
regardless of which one's numeric range is actually smaller. Both
catch a comparator whose clauses summed instead of subtracted (the
result is then order-independent and never triggers the needed swap)
and a bare sort() call being dropped outright.
…own calls

The existing "flushes a run still pending" fixture opens \objdata
before \result and gives it valid, decodable bytes -- \objdata's own
successful decode calls addBlocks, whose own flushRun call already
pushes the pending run before \result ever opens, leaving
beginResultScratch's own flushRun call with nothing left to do.
Restoring captured accumulator state round-trips pendingRunText
unchanged either way, so the missing call was invisible to a plain
"is the text still there" check. Adds a fixture with an empty \result
and no \objdata at all, where no other call ever flushes the pending
run, so the two are only ever kept apart by beginResultScratch's own
call -- observable as run count/merging once identically-formatted
text follows \object's own close.

The existing "closes an open table" fixture ends \result's own table
with an explicit \par outside it; that \par's own endParagraph call
already closes the table via its own internal, para.inTable-gated
closeTable() call, leaving endResultScratch's own trailing call
redundant for that shape. Adds a fixture where \result's content ends
on \row itself, with para.inTable still true and no \par to trigger
that internal call -- endResultScratch's own explicit closeTable() is
the only thing left that can still turn tableRows into a real block.
paragraphSerial's own += and resolveRows' own column += are each
provably equivalent under -=: every reader of either value compares it
for equality alone (same-paragraph, matching column) against a value
captured earlier from the identical monotonic-in-one-direction
sequence, never for ordering or against a literal -- a uniform sign
flip preserves every such equality and inequality for any input.

The root GroupState's own pictureOwner/objectDataOwner/objectOwner/
isFieldGroup all default to false, and each is only ever read paired
with a sibling field (picture/objectData/object/field) that stays
undefined on root and can only become defined on a freshly cloned
child in the same branch that also sets its own paired flag true --
making the flag's own value unreachable through root for any input.

Each is explained in place rather than left for a mutation tester to
flag as unkillable, per the campaign's own rule that a documented,
proven-irreducible equivalent mutant is a legitimate last resort.
…Rows' own scan loop

The scan loop's own upper bound (next < rows.length) is equivalent
under <=: a JavaScript array read past its own length is undefined
rather than a thrown error, and the very next line's own ?./?? -1
already turns that undefined into the same matchIndex -1 an ordinary
out-of-range lookup produces, immediately breaking the loop either
way.

columnIndices[next]'s own ?? -1 fallback is unreachable, not a
defensive guard against a real case: columnIndices is built by
rows.map(...) directly above, so columnIndices.length === rows.length
always, and the loop's own bound already guarantees a defined entry
for every next this line sees. The fallback exists only to satisfy
noUncheckedIndexedAccess's own typing, never to handle a case that can
occur.
Four gaps in the existing rowSpan/continuation fixtures:

The verticalMergeContinuation early return was only ever exercised
against an already-empty continuation cell (no text typed in the
source), so a fallthrough that kept cell.blocks instead of discarding
it produced the identical [] result by coincidence. Adds a "stray"
run inside the continuation cell so a genuine discard is observable.

The scan loop's own verticalMergeFirst guard was never tested against
a plain, unmerged cell sitting directly above a real (if malformed)
\clvmrg continuation at the same column -- an unconditional scan would
wrongly extend that ordinary cell's rowSpan to 2.

The existing "exactly two, not three" fixture has only one real
continuation row, so a scan loop stepping backward instead of forward
(reaching the anchor's own row again, which is never itself a
continuation, and breaking there) produces the same rowSpan by
coincidence. Adds a second continuation row so a reversed direction
undercounts.

matchIndex's own -1 sentinel check was only ever exercised with a real
match at array position 0, so mistaking a genuine matchIndex of 1 for
"not found" went unnoticed. Adds a fixture where the anchor is the
second cell of its own row, placing a genuine match at position 1.
…call

The existing "never appends an empty block list" fixture uses a
picture that fails to decode, but buildPicture returning undefined is
guarded by its OWN `if (image !== undefined)` check at the call site
-- addBlocks is never even called there, so its own guard and flushRun
call were both entirely unexercised by it. objectState.resultBlocks is
the one real call site that can pass a genuinely empty array (an
\object whose \result had no content). Adds a fixture using \shppict
(a "body"-kind destination, not a fresh \result scratch) to type text
directly into the outer paragraph's own pendingRunText AFTER \result
has already closed and restored state, so it is still genuinely
pending at the exact moment \object's own close calls addBlocks with
that empty array -- a guard-less call would flush it regardless.

Adds a second fixture for the same function's non-empty path: a
successfully-decoded picture between two runs, proving flushRun's own
call keeps them separate rather than letting the trailing text merge
across the spliced-in image block.
endSection's own drop condition (blocks.length === 0 && this.sections.length > 0)
can only ever skip pushing when sections.length is already at least 1 -- its
second operand is false whenever sections.length is 0. finish() calls
endSection exactly once, so that single call is unconditionally
guaranteed to leave sections.length at least 1 regardless of what it
was beforehand: the fallback was guarding against a state this call
can never actually produce. Dropped rather than documented as
equivalent.
…ype key omission

The existing "keeps the document's only section" fixture only checks
blocks, never the boundary that actually matters: endSection's own
drop condition's second clause (sections.length > 0) is what makes an
empty-but-FIRST section still get pushed here rather than silently
skipped, and a ">= 0" in its place is always true regardless of count
-- indistinguishable from "> 0" on a fixture that never states a
breakType. Adds a fixture whose only, empty section states \sbknone,
checking breakType survives -- finish()'s own fallback (now removed)
never carried it, so this also proves the push came from endSection
itself.

Adds a fixture for the breakType conditional spread's own omitted-key
case: a plain toEqual cannot tell an absent key apart from one present
with value undefined, so Object.hasOwn is what actually distinguishes
a real conditional spread from an unconditional { breakType:
section.breakType } that would leave the key present but undefined.
…ir exact message text

Neither an \object with two \result children nor one with two \objdata
children had any coverage at all -- the isDuplicateResult and
isDuplicateObjectData guards, and their own EMBEDDED_OBJECT_UNREADABLE
diagnostics, were entirely unexercised. Adds one fixture per case,
checking both the diagnostic's exact message and, for the duplicate
\result case, that only the first one's own fallback content survives
into the document.

UNKNOWN_DESTINATION_SKIPPED and CONTENT_DESTINATION_SKIPPED were only
ever asserted by diagnostic code, never by message text, so a
template-literal mutant that emptied either message string went
unnoticed. Adds exact-message assertions to a new fixture and to the
existing footnote fixture.
…d guard

isResultDestination's own definition already asserts objectState !== undefined
(head.destination === "result" && objectState !== undefined), so a
second, separate objectState !== undefined check ahead of it can never
be false when isResultDestination is true -- equivalent to bare
isResultDestination alone, exactly like the sibling isDuplicateResult
check two lines above whose own comment already states the identical
reasoning. TypeScript's aliased-condition narrowing carries the
non-undefined fact through to the block's own objectState reads the
same way it already does for isDuplicateResult.
…cture recognised destination

The existing "never initialises picture/embedded-object state" fixtures
both use {\b bold}, which has no recognised destination of its own at
all (known === undefined) -- they never reach the `if (known !== undefined)
{ ... if (kind === "picture") ... }` branch this is meant to guard,
exercising an entirely different, earlier guard instead. Adds a sibling
fixture per case using \*\bkmkstart, a real, known, non-picture/
non-objectData destination that DOES reach the branch, so a `true` in
either kind check's place is now actually observable as a spurious
UNSUPPORTED_PICTURE_FORMAT/EMBEDDED_OBJECT_UNREADABLE diagnostic.
…n destination guard

state.bookmark.name.trim() had no fixture with actual leading/trailing
whitespace in the name -- the space right after \bkmkstart itself is
consumed as the control word's own terminating delimiter, but a SECOND
space before the name (or one before the group's own closing brace) is
ordinary #PCDATA and survives verbatim unless trimmed. Adds a padded
name, matched against an unpadded \bkmkend so a missing trim breaks
the name-keyed match entirely rather than merely leaving whitespace in
the output.

startFormField's own destination === "fieldInstruction" guard looked
structurally redundant against every fixture that nests only
same-destination groups inside \*\fldinst (the real reason the guard
exists, per its own comment) -- but \*\ud is a real, different, known
destination that can genuinely nest there while still sharing
state.field by reference. Appending more instruction text after such a
nested group closes is what makes the guard's absence observable: a
premature trigger at \*\ud's own close locks in a still-partial
instruction, so the field's own later, complete (and no-longer-
matching) instruction is read back and dropped with a
FORM_FIELD_KEYWORD_LOST diagnostic that correct code -- which never
opens the extent in the first place -- never produces.
…de any \field group

\*\ffname/\*\ffhelptext/\*\ffl are each recognised by DESTINATION_KINDS
regardless of what encloses them, so a hostile or truncated producer's
own stray occurrence outside \field reaches emitText with state.field
inherited from the root -- genuinely undefined, never set by anything
else. Each of the three branches' own field?.formField !== undefined
guard is what keeps that case a silent discard (matching the trailing
comment on the whole if-chain, "every other unhandled destination
already does") rather than a thrown TypeError; tsc confirms the gap
directly (removing the guard leaves six "possibly undefined" errors
across the three branches), and the mutation itself reproduces as a
real runtime crash, not just a type-checker complaint.
…ng it

Every other table fixture in this file follows its own \row with an
explicit \pard, which resets para.inTable to false on its own via
defaultParagraphState() -- masking whether \row's own case doing the
identical reset accomplishes anything. Adds a fixture with text typed
directly after \row and no \pard in between, which depends entirely on
\row's own reset: without it, that text stays routed into the
now-closed table's own cellBlocks instead of the section's real ones.
…llthrough as equivalent

Each of applyCharacterControlWord, applyCellDefinition,
applyParagraphControlWord and applySectionControlWord's own early
return, if removed, would let a name the dispatcher already fully
handled fall through to the next one down. Every one of the five
dispatchers' own recognised name sets (character, cell-definition,
paragraph, section, structure) is disjoint from all the others -- no
control-word name this reader recognises appears in more than one --
so a name any earlier dispatcher already matched can never also match
a later one, making every such fallthrough a genuine no-op for any
input regardless of whether its own early return fires.
code !== undefined had no fixture for a malformed \u with no digits at
all. String.fromCharCode(undefined) coerces via NaN's own ToUint16
result to U+0000, so calling it unconditionally would silently insert
a stray NUL character into the run rather than emitting nothing.
skipUnicodeFallback still runs either way, consuming the one ANSI
fallback character \uN's own grammar always requires -- proving the
fallback text itself was never the thing standing between correct and
mutated behaviour here.
indexOf's own "not found" sentinel (-1) is not a real property on any
plain array, so definitions[-1] already evaluates to undefined on its
own -- the exact same result the explicit "matchIndex === -1 ?
undefined : ..." ternary special-cased by hand. Indexing by matchIndex
directly produces the identical outcome for both the found and
not-found cases, so the special case is dropped rather than documented
as equivalent.
…rmField state by reference

Both a bookmark name and a formField dropdown entry are accumulated
through state shared by reference across a nested group, keyed only
on that state's own definedness rather than on the current group's
own destination. A \listtext group nested inside \*\bkmkstart or the
first \*\ffl entry inherits that shared state without genuinely being
a bookmarkStart/bookmarkEnd or formFieldListItem destination, so
these fixtures prove the destination check on each guard is
load-bearing rather than redundant.
…ent mutants

The formFieldListItem text-accumulation guard's own current !==
undefined check, and the main token loop's own index < tokens.length
bound, both survive their own forced-true/<= mutation because the
surrounding code already guarantees the branch they guard can never
actually be taken: a *\ffl group's own open always pushes an empty
listItems entry in the identical branch that sets this destination,
and tokens[index] reads as undefined past the array's own end, which
the loop's very next line already treats as its own break condition.
@Mearman
Mearman force-pushed the feat/100-percent-mutation-rtf-codec branch from 143a714 to 0c60868 Compare September 14, 2026 22:31
…n opportunities in read.ts

Replaces paragraphSerial's incrementing number with a fresh symbol per
paragraph, since every reader only ever compares it for identity and
never for ordering -- removing the AssignmentOperator mutation
(+= vs -=) that a numeric counter left behind with no real behaviour
to distinguish.

Rebuilds resolveRows' own column-position tracking from a running
width total (vulnerable to the identical += vs -= equivalence) into a
count of pushed placeholder slots, and rewrites its vertical-merge
scan's own loop bound as `next <= rows.length - 1` instead of
`next < rows.length` -- algebraically identical, but this phrasing's
own single mutation (<= to <) is a genuine one-row-short bug an
existing three-row test already catches, rather than a harmless extra
pass past the array's own end.

Extracts closingBookmarkExtent, appendToLastListItem, and
verticalMergeRowSpan out of ContentBuilder as standalone, exported
functions, each keeping the same fail-loud invariant check the inlined
code had, but now directly unit-testable with a deliberately
invariant-violating input instead of leaving an unreachable throw
branch with no test able to reach it.

Deletes the redundant `head.destination === "objdata"` conjunct next
to `known === "objectData"` (DESTINATION_KINDS maps exactly one key to
that kind, so the second clause alone already states it), and drops
the `kind === "bookmarkStart" || kind === "bookmarkEnd"` guard around
assigning a fresh bookmark onto a newly opened child group -- every
reader of state.bookmark already pairs it with its own destination
check, so assigning it unconditionally changes nothing observable and
also makes the group-close handler's own bookmarkStart/bookmarkEnd
branch pair a real, killable check instead of a no-op.

Replaces the token-processing loop's own `index < tokens.length`
condition with `while (true)`, since the very next line's
`if (token === undefined) break;` already terminates it on exactly
that condition.

Makes pictureOwner/objectDataOwner/objectOwner/isFieldGroup optional
on GroupState and omits them from the root group's own literal instead
of stating a `false` every reader already reads as paired with the
sibling field (picture/objectData/object/field) also being undefined
on root -- so there is no real `false` bit for a mutation to flip, only
a field a mutation can't meaningfully touch because it isn't there.
…ly, drop their boolean returns

applyControlWord's own final five-way dispatch (character, cell
definition, paragraph, section, structure) gated each call behind an
`if (dispatcher(...)) return;`, but the five dispatchers' own
recognised control-word names share not one name across all five
sets -- so a name any one of them recognises can never also be one a
sibling would act on, making the early-return gating a no-op chain
regardless of order. Calling all five unconditionally, in the same
fixed order, produces the identical result without threading a
boolean nothing outside this one call site ever reads.

applyCharacterControlWord, applyParagraphControlWord,
applySectionControlWord, and the ContentBuilder.applyCellDefinition
wrapper (the underlying applyCellDefinitionControlWord keeps its own
boolean contract, tested directly on its own terms) all become void
accordingly, each keeping its own internal early exits where they
still gate real behaviour (e.g. applySectionControlWord's own "no
parameter, nothing to apply" check) rather than merely reporting a
match back to a caller that no longer asks.
…tes against a shared reference

Adds direct coverage for four of applyControlWord's own early gates
whose OTHER operand is a field shared by reference across every
descendant group (picture, object, bookmark, formField), each
demonstrating a forced-true destination check would misroute a
control word arriving from a sibling destination into the wrong
handler: a \bkmkcolf1 nested inside \pict, an \objw1440 nested inside
\object via a sibling bookmarkStart, an arbitrary parameterised
control word inside bookmarkStart masquerading as \bkmkcoll, and a
genuine \ffprot arriving from \*\ffname rather than \*\formfield
itself. Each was verified against a temporarily forced-true copy of
its own guard before being kept.
…er \sect's own force=true

The two existing \nosupersub tests asserted runs[1]'s verticalAlign
directly without first asserting the run count: if \nosupersub failed
to clear verticalAlign, "up" and "base" would carry identical
character state and coalesce into a single run, making runs[1]
undefined and the verticalAlign assertion pass vacuously regardless
of what actually happened. Both now assert length 2 first.

Adds a test for \sect's own force=true argument to endParagraph,
which was previously only exercised in a way indistinguishable from
force=false (every existing \sect fixture already had real paragraph
text before it): a bare \sectd\pard\sect with nothing accumulated
must still produce one empty paragraph block, exactly as \par's own
identical force=true does, rather than the section producing no
blocks at all.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant