Skip to content

test(document-outline.js): reach 100% mutation score - #1257

Merged
Mearman merged 25 commits into
mainfrom
feat/100-percent-mutation-document-outline.js
Sep 13, 2026
Merged

test(document-outline.js): reach 100% mutation score#1257
Mearman merged 25 commits into
mainfrom
feat/100-percent-mutation-document-outline.js

Conversation

@Mearman

@Mearman Mearman commented Sep 12, 2026

Copy link
Copy Markdown
Member

Restructures document-outline.js's own source so that every mutation opportunity Stryker finds is either genuinely killed by a real test or removed as an AST node entirely (redundant guards deleted, manual min/max replaced with Math.min/Math.max, manually-bounded loops replaced with .entries() iteration), rather than suppressed with a disable comment.

Also adds a per-package Stryker concurrency override (stryker.shared.ts) so this package's own expensive exhaustive LCS-reconciliation sweep in graph.test.ts can run at concurrency 1 without slowing every other package's mutation CI.

No // Stryker disable comments anywhere in this package.

document-outline.js's graph.test.ts runs an expensive exhaustive
LCS-reconciliation sweep that several concurrent Stryker workers
running it at once push past its own timeout through contention
alone. Rather than lowering the shared default for every package,
give packageStrykerConfig a concurrency option so only the package
that needs a lower value pays for it.
…or direct testing

The high 32 bits of the SHA-256 bit-length suffix only become
nonzero once a message reaches 2^32 bits (~512 MiB), an input size
no unit test can afford to allocate and hash. Splitting the write
into its own writeBitLength function lets a test exercise that
arithmetic directly against a synthetic bit length instead.
resolveHeadingGroup, resolveListGroup, and resolveParagraphLeaf each
special-cased an empty style chain to skip resolution. resolveStyleChain
already returns {} for an empty chain rather than throwing, and
applyEntry({}, node) already returns node by the identical reference,
so the special case computed nothing a plain call wouldn't already
produce.

Also export assertResolvedHeadingAnchor and assertResolvedListAnchor
and add direct tests exercising their throw branches: both guard an
invariant (document-schema.js's fill-only style application) that no
input reachable through this module's own construction can violate,
so a real regression test needs to call them directly rather than
relying on a resolution path that can never reach the throw.
…utlineNode

Number.isFinite's own type check already returns false for any
non-number input, so the preceding typeof value.level !== "number"
check tested nothing Number.isFinite didn't already cover.
…ilter+reduce

nearestTextCell tracked a running nearest candidate through a manual
loop with two separate comparisons (position and region membership).
Filtering to the qualifying candidates first and reducing them by
position makes the same result reachable without a mutable running
best that a mutation test can flip the comparison direction of
unnoticed.
…n orderKeyBetween

Once !highExhausted holds, position is already known to be within
high's own length -- reaching position === high.length while
!highExhausted still held would require high to be a strict prefix
of low, contradicting the precondition that low sorts strictly
before high. The separate position < high.length check therefore
never changed the result.
Every parent map entry is created by union() alone, which already
guards against mapping a root to itself -- so a chain of parent.get
calls always terminates by reaching an unmapped key, never by
revisiting an already-seen node. The separate "or equals current"
termination check in root()'s two walks tested a condition that
could never occur, and the ensure() pre-population step was likewise
redundant: an unmapped key already resolves to itself as its own
root without ever being written to the map first.

Also replace computeSignals' single-row rowRegularity shortcut and
boundingRange/boundingBox's manual min/max tracking with the
unconditional formula and Math.min/Math.max respectively, since both
already reduce to the same result in their shortcut's own case.
…stable helpers

Split classifyLeaf's signal computation (computeLeafSignals) from its
scoring (classifyFromLeafSignals), and export both alongside every
other internal helper in this module (layoutItemBounds, findCut,
recursiveXYCut, groupIntoLines, and the rest) purely for direct unit
testing -- hand-picking signal values directly is far more precise
than reverse-engineering a BoundedItem layout that happens to produce
a given avgCells/cellRegularity/xStartRegularity combination.

Also replace several manual min/max/loop patterns with Math.min/
Math.max and .entries() iteration, and drop guard branches
(recursiveXYCut's degenerate-single-group check, findCut's
bands.length checks, isRowAlignedGrid's text-only filter) that
findCut/groupIntoLines/reduce already make unreachable on their own.
…oups and nested list stacks

Add cases pinning list-stack pop targeting (popping only the deepest
of several open groups), a construct group's own content and
following siblings landing under the innermost open list item, and a
construct group's self-contained heading nesting neither inheriting
from nor leaking into the surrounding stack.

Also fix a pre-existing fixture bug: a construct group nested inside
a listGroup's children must be shape-scoped (ShapeConstructGroupNode),
not section-scoped, since document-schema.js 4.1.0 narrowed ListChild
to the shape-flow vocabulary.
…rojection

addNode/addEdge guarded against overwriting an existing entry, but a
second mint producing the identical node id or edge key is always
structurally identical content (contentHashV1 is a pure function of
that content, and edgeKey's own fields are GraphEdge's whole field
set), so the guard only ever wrote back the value already there.
decideEntry's decidingEntries cycle marker likewise never needed an
explicit delete: the memo check at the top of the method already
short-circuits any later call before it could re-read a stale marker.

isGroupChild checked both `node` and `children` for presence, but
every TreeGroup carries both and every TreeLeaf carries neither, so
checking one is exactly as discriminating as checking both.
siblingInsertIndex's matches.length > 1 guard is likewise redundant:
the single-match case already satisfies the ambiguity check trivially
(a one-element Set has size 1, equal to matchedOrderKeys.length).

walkPropertyGraph's needsGuard/onPath conditional allocation collapsed
to an unconditional Set, since the cost of tracking a CONTAINS-only
walk's on-path set (which this function's own module comment proves
can never actually suppress anything) is a few unused Set operations,
not a behavioural branch worth a separate code path. pathsEqual's
separate undefined special-case is likewise redundant: JSON.stringify
of undefined already disagrees with JSON.stringify of any real path.

Also add direct tests for two previously-untested defensive throws
(recordOf's non-record refusal, decideEntry's undefined-table-entry
refusal) via deliberately schema-invalid, type-system-bypassing
fixtures, matching this file's own established pattern for exercising
an invariant no real construction path can violate.

Raises graph.test.ts's own exhaustive LCS-reconciliation sweep
timeout again (vitest.config.ts): the sweep now also runs a reference-
oracle comparison over arbitrary, not-necessarily-subsequence existing
wirings, and Stryker's own instrumentation pushes the enlarged sweep
past the previous 120s budget.
…LeafText joins

Pin outlineLeafText's own cell-block joining behaviour (multiple
blocks within one cell join by space, multiple runs within one
paragraph join with no separator) and its recursion into a nested
table's own multi-block cells, both previously reachable only through
single-block/single-run fixtures that couldn't distinguish a space
join from a no-separator join.
…ders

Every optional-field pattern in test-support/fixtures.ts is exercised
twice -- once with the field set (checking the exact key and value
land) and once without (checking the key is genuinely absent via
"key" in result, not merely undefined). Full-literal builders are
checked against their exact expected output via toStrictEqual
instead.
Uint32Array silently drops an out-of-range write and returns undefined
for an out-of-range read, so a loop bound weakened by one (i <= 64
instead of i < 64) previously wrote past w's own 64-element length
with no observable effect. Throwing on an out-of-bounds index turns
that boundary into a genuine, catchable failure.
… fixed tuple

classifyFromLeafSignals and classifyRegion each sorted a 3-element
literal array of scores and then checked scored[0]/scored[1] for
undefined before use — a branch no input can ever actually take,
since the literal is always exactly 3 entries, but noUncheckedIndexedAccess
still typed each lookup as possibly undefined. Typing the array as a
fixed 3-tuple instead makes the two destructured elements known-defined
at the type level, since Array.prototype.sort's this-typed return
preserves the tuple shape through the sort — removing the
unreachable-guard branch rather than leaving it as dead code no
mutation can distinguish from live behaviour.

Also replaces isRowAlignedGrid's equivalent defined-check on `first`
with a plain non-null assertion, for the same reason: withLines.length
>= 2 is already established immediately above, and there is no way to
encode ">= 2" as a type for a dynamically filtered array the way a
fixed-length tuple can.
…ts into testable units

Extracts the ascending orderKey comparator, duplicated at three sort
sites, into one exported orderKeyAscComparator so a single test pins
its exact -1/0/1 return values instead of three copies each needing
their own coverage.

Extracts the repeated try/catch around boundedOrderKey (bisect, then
rebalance on OrderKeyBudgetExhaustedError) in reconcileChildren and
insertEdge into a shared runOrRebalance helper, and exports it and
boundedOrderKey so their own edge cases (the tied-siblings message,
the exhaustion/rebalance branch, the unreachable rethrow) get direct
unit tests rather than relying on paths that may never exercise them
through the public write API alone.

Replaces reconcileChildren's fixed-size, pre-filled matchedIndex and
matchedByOriginal arrays with Maps keyed by index: neither is ever
read by length or iterated as a whole, only looked up by position and
checked for presence, so a Map's own has()/get() already say
everything a pre-sized array's "still at its initial fill value" did.
Replaces its dp table's bare row[index]! reads with a bounds-checked
lookup that throws on an out-of-range index, turning a boundary
mutation of the backtrack's own loop conditions into an observable
failure instead of a silently-absorbed undefined. Replaces anchorFor's
separately-bounded for loop with children.entries(), tying the scan's
end directly to the array it walks.

Extends the exhaustive LCS-reconciliation sweep from existing wirings
up to length 3 to length 4: the anti-inflation pass's own
multi-occurrence reuse (a bucket holding more than one leftover index
for the same id) only becomes observable once existing carries at
least two unmatched occurrences of a repeated id plus a third,
differently-labelled element to break the trivial full match a
homogeneous run gets for free, which needs four existing slots.

Adds direct tests for the newly exported helpers (orderKeyAscComparator,
runOrRebalance, boundedOrderKey), a project() entry-node ordering test
covering multiple policy-extracted table entries, and an insertEdge
test proving sibling-position resolution filters by the requested
kind rather than a hardcoded one.
deriveNeighbourLabels' nearestTextCell reduce had no test exercising
two candidate cells at the identical row/column: nothing in
ContentSheetCell's own shape forbids a hand-built array from carrying
one, so the reduce's tie-break direction needs a deterministic,
tested answer rather than being left to whichever comparison
direction happens to compile.
…to a testable function

The message-schedule write's own bounds guard never fires under any
correct loop bound, so mutating the guard's condition produced a
survived mutant with no way to reach it through sha256 itself.
Extracting it to a module-level writeScheduleWord, the same pattern
writeBitLength above it already follows, lets a direct unit test call
it with an out-of-range index and prove the throw fires.
…tants testable

Extracts pendingEntryNodes' ascending id comparator into an exported
entryIdAscComparator, mirroring orderKeyAscComparator, so its own tie
branch (two entries content-hashing to the identical id) gets a direct
test pinning stable-sort behaviour instead of relying on a pipeline
where genuinely tied ids never occur.

Hoists reconcileChildren's dp bounds-check closure to a module-level,
exported dpAt: the closure form could never be called with an
out-of-range index by any real caller, so the guard's own condition
had no test able to reach it; a direct unit test now does.

Replaces anchorFor's per-call forward scan (bounded by an explicit
`later <= position` comparison) with a single backward precompute over
[...children.entries()].reverse(): the position a call site ever
anchors is always absent from matchedIndex by construction, making the
old comparison's exact boundary unobservable no matter how the loop
was written. The precompute needs no such comparison at all, removing
the mutation opportunity rather than testing around it.

Adds direct tests for reconcileChildren's originalSiblings (sorted by
orderKey rather than the edges array's own creation order, and
filtered to the owner id and CONTAINS kind specifically, not a decoy
sharing only one of the two), for insertEdge's CONTAINS-cycle check
being scoped to CONTAINS edges alone (a non-CONTAINS attachment must
never run the cycle check, even when it would otherwise close one),
and for insertEdge sorting its own siblings by orderKey before
resolving a position rather than trusting the edges array's own order.
…llable mutants

Drops the redundant `index === 0` guards in findCut's band-gap loop and
cellsInLine: both already fall through the very next line's own
undefined check (bandStats[-1] and line.items[-1] are always
undefined), so the explicit check duplicated logic that was already
there, invisible to any test.

Rewrites the mixed-margin comparison as `top < second + MIXED_MARGIN`
rather than the algebraically equivalent `top - second < MIXED_MARGIN`:
with both scores held to [SIGNAL_THRESHOLD, 1], their difference always
lands on a coarser floating-point grid than MIXED_MARGIN's own stored
value needs, so no achievable score pair can ever make that subtraction
hit the margin bit-for-bit. The addition form lets a test construct the
top score as literally `second + MIXED_MARGIN`, making the two sides of
the comparison bit-identical by construction.

Adds a test proving a touching item is merged into the running band
(not started as its own), and that this changes the merged band's own
scale enough to flip the next gap's cut decision; a test for the exact
mixed-margin boundary using the construction above; and a test proving
the vertical-tie sort's own x tie-break is a genuine subtraction, not a
sum that happens to force the correct swap only when the input is
already reversed.
…le mutants

Removes DisjointCellSet's own path compression: it only ever changes
how many hops a FUTURE find() walks, never any value this class
returns, so it was unobservable through the class's own public
contract and untestable dead weight, not real functionality worth
keeping code around for.

Drops the redundant `i === 0` guards in connectedComponents' two
adjacency loops: both already fall through the very next line's own
`previous !== undefined` check (sorted[-1] is always undefined), so
the explicit check duplicated logic that was already there.

Rewrites the mixed-margin comparison as `top < second + MIXED_MARGIN`
rather than the algebraically equivalent `top - second < MIXED_MARGIN`,
the identical fix pdf-regions.ts's own classifyFromLeafSignals needed
for the same reason (see that commit): with both scores held to
[SIGNAL_THRESHOLD, 1], their difference always lands on a coarser
floating-point grid than MIXED_MARGIN's own stored value needs, so the
subtraction form can never hit the margin bit-for-bit.

Replaces the existing "exactly at the mixed margin" test with one
built from that same identity: tableScore and proseScore are each
constructed so the comparison's two sides are bit-identical, not
merely numerically close.
… survivors

Converts reconcileChildren's anchorAt from a pre-sized array to a Map
keyed by position: every value is read back exactly once via
anchorFor, never through .length or as a whole, so a fixed initial
size added nothing a Map's own get() didn't already say, and was
itself a mutation surface with no way to observe a weakened size.

Rewrites the originalSiblings decoy test to request the same child id
TWICE against a single genuine existing edge: reconciliation never
deletes an edge just because originalSiblings over-counted it, so a
single-occurrence request produces the identical final edge count
whether a decoy is wrongly included or correctly excluded. Requesting
two occurrences makes a wrongly-included decoy visible, since it lets
a phantom "already wired" match suppress the genuinely missing second
insertion.

Rewrites insertEdge's own sibling-sort test to use a before/after
position rather than "end": resolving a named sibling's position
against the raw (unsorted) array index, rather than its sorted index,
sends boundedOrderKey down its orderKeyAfter branch instead of
orderKeyBetween -- a materially different key, unlike "end", whose
insert index is siblings.length regardless of sort order and only
changes WHICH neighbour is picked, not by how much.
…rator for direct testing

recursiveXYCut's own internal per-axis sort already fixes which region
lands as `a` versus `b` by the time segmentPdfRegions reaches its final
sort, so an end-to-end test can never control which argument the tie-
break clause receives -- both a reversed and an already-ascending input
end up compared in the identical order, unable to distinguish a real
subtraction from a sum that happens to force the correct swap either
way for two positive x values. Exporting the comparator lets a test
call it directly in both argument orders, proving the tie-break is a
genuine subtraction rather than a sum.
…e insertion

A pure reordering with one occurrence per id can never distinguish a
sorted read from an unsorted one: the anti-inflation pass pairs every
unmatched existing edge against an unmatched requested occurrence of
the identical id regardless of which index either side carries, so a
scrambled-but-balanced originalSiblings still ends every position
matched (no edge minted, no edge moved) either way, and reconcileChildren
never rewrites an existing edge's own orderKey -- the output graph was
bit-for-bit identical whether or not the read was sorted first.

Replaces that test with one requesting an id with no existing edge at
all, so a genuine insertion happens: an unsorted read pairs the LCS
match differently, anchoring the new edge before the existing set's
first sibling instead of strictly between its two siblings -- a real,
observable difference in the final walked order.
Every mutant now genuinely dies, either killed by a real test or
removed by restructuring the code so the mutation opportunity no
longer exists, matching the pattern byte-codec, pdf-raster-cpu, and
excel-number-format already establish for a package at the literal
maximum.
@Mearman
Mearman marked this pull request as ready for review September 13, 2026 06:06
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Sep 13, 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-13T06:18:46.070749Z 4ca00a5 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 13, 2026 06:07
@Mearman
Mearman merged commit b51d634 into main Sep 13, 2026
26 checks passed
@Mearman
Mearman deleted the feat/100-percent-mutation-document-outline.js branch September 13, 2026 06:09
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