Skip to content

fix(repo): kill the graph traversal's surviving mutants - #79

Merged
kiro-systemf[bot] merged 5 commits into
xs/release-gate-on-prsfrom
xs/xstate-graph-survivors
Oct 9, 2026
Merged

kiro-systemf[bot] merged 5 commits into
xs/release-gate-on-prsfrom
xs/xstate-graph-survivors

Conversation

@systemfsoftware-maker

@systemfsoftware-maker systemfsoftware-maker commented Oct 9, 2026 •

Copy link
Copy Markdown
Collaborator

Stacked on #78, which runs the release gate on pull requests. This PR's own gate run is the proof.

Why

main's release gate (run 37922084024 on 3742392, after #77) left 45 mutants alive in packages/xstate/src/graph: 31 Survived and 14 NoCoverage. This PR kills each one. It either adds a behaviour scenario through @systemfsoftware/xstate/graph that the mutant would break, or deletes the code the mutant lived in when that code changed no published result. No Stryker disable comment, threshold change or mutate change. No local mutation of any kind: every claim below is argued from the source, and the gate run on this PR's head decides.

Every mutant

file:line status mutator id how it dies
actorScope.ts:15 S StringLiteral 558cf3b49e8e9d15 test: 'A traversal hands the logic it walks an actor scope identified as the graph mock'
actorScope.ts:16 S StringLiteral 371e04ac78884594 test: same scenario
adjacency.ts:242 S ConditionalExpression 50b26085155c9cb4 deleted: BFS queue compaction (performance-only)
adjacency.ts:242 S ConditionalExpression 19be813569cfd8de deleted: BFS queue compaction (performance-only)
adjacency.ts:242 S EqualityOperator e043b53c11268600 deleted: BFS queue compaction (performance-only)
adjacency.ts:242 S EqualityOperator b35049a7e291f727 deleted: BFS queue compaction (performance-only)
adjacency.ts:244 NC ConditionalExpression 18c84258dd622982 deleted: BFS queue compaction (performance-only)
adjacency.ts:244 NC ConditionalExpression de60faca2d09a8ab deleted: BFS queue compaction (performance-only)
adjacency.ts:244 NC EqualityOperator 7a6088a3b24cc692 deleted: BFS queue compaction (performance-only)
adjacency.ts:244 NC EqualityOperator 2132b9c4fb75b9ec deleted: BFS queue compaction (performance-only)
adjacency.ts:244 NC ArithmeticOperator 9055b8fcef2b5130 deleted: BFS queue compaction (performance-only)
adjacency.ts:246 S ConditionalExpression 2b83979899de186d deleted: BFS queue compaction (performance-only)
adjacency.ts:246 S ConditionalExpression 19f61e099e38eab1 deleted: BFS queue compaction (performance-only)
adjacency.ts:246 S LogicalOperator b51cda8c70c3bf70 deleted: BFS queue compaction (performance-only)
adjacency.ts:252 S BooleanLiteral a4a445049cca80c3 deleted: BFS queue compaction (performance-only)
adjacency.ts:252 S ConditionalExpression f38c2bc4745fe411 deleted: BFS queue compaction (performance-only)
adjacency.ts:252 S ConditionalExpression 0aec8d7f096e247e deleted: BFS queue compaction (performance-only)
adjacency.ts:252 S BlockStatement 24d8d40718a5d526 deleted: BFS queue compaction (performance-only)
adjacency.ts:255 NC ArithmeticOperator ba02984e4f9349c9 deleted: BFS queue compaction (performance-only)
adjacency.ts:256 NC UnaryOperator d1dc01a9dd4fd2ce deleted: BFS queue compaction (performance-only)
adjacency.ts:272 NC ArithmeticOperator 3f4ddc239e4380b4 deleted: BFS queue compaction (performance-only)
adjacency.ts:290 S EqualityOperator 8981f678590bfbc5 deleted: BFS queue compaction (performance-only)
adjacency.ts:290 S EqualityOperator d789d533c5d95474 deleted: BFS queue compaction (performance-only)
adjacency.ts:378 S ConditionalExpression 8a87c15aed33df15 deleted: unreachable missing-entry error (keys come from the same dict)
adjacency.ts:378 NC BlockStatement abec76b9e331d42c deleted: unreachable missing-entry error (keys come from the same dict)
adjacency.ts:379 NC StringLiteral bd761ce943840f5a deleted: unreachable missing-entry error (keys come from the same dict)
alterPath.ts:15 S ConditionalExpression 98f3c65bb0215cc0 deleted: previousStepOf is now steps[index - 1]
alterPath.ts:42 S EqualityOperator c372e6a93f727a0b deleted: the branch is gone; appendedSteps pushes finalStep === undefined ? initStepOf(path.state) : finalStep and alterPath returns { ...path, steps: appendedSteps(path) }
graph.ts:145 S ArrowFunction 01437da0a099c29d deleted: firstDefined deleted
graph.ts:145 S ConditionalExpression 320c780a09e39d28 deleted: firstDefined deleted
graph.ts:145 S ConditionalExpression 5d729f191ec19853 deleted: firstDefined deleted
graph.ts:145 S EqualityOperator 49b894cdeac7155b deleted: firstDefined deleted
graph.ts:200 S ConditionalExpression 7cbed213a61e2b83 test: 'A logic the structural machine check rejects is traversed as plain logic, not as a machine' (the existing non-machine-root scenario, extended and renamed)
graph.ts:282 S ArrayDeclaration 519479179279d176 deleted: the array and its optionSerializeState helper deleted with firstDefined
pathFromEvents.ts:75 S ConditionalExpression 6b2d1227b439edc8 test: 'A replayed event is replaced by the last override candidate whose filter and serial both match it'
pathFromEvents.ts:75 S LogicalOperator 7743da989e7e957d test: same scenario
pathFromEvents.ts:75 S ConditionalExpression ca8b74dbabcf9359 test: same scenario
pathFromEvents.ts:129 S EqualityOperator 112151831eb7e855 test: 'A replay refuses the step after the step count reaches a limit below one'. The mutant is stepCount >= limit -> stepCount == limit. With limit 0.5, 1 == 0.5 is false, so the second step is not refused. Run 37935776856 records this scenario as its killer. (> is a different mutant, bc6be1f0be34c217. It was not among main's 45, and the seed-1 outline kills it.)
shortestPaths.ts:72 NC StringLiteral 0e6d0c4ce16e5d39 deleted: unreachable missing-entry error
shortestPaths.ts:83 S BlockStatement d7ba70ebd35a86f8 deleted: weight improvement BFS order never reaches
shortestPaths.ts:85 S ConditionalExpression b8f3d2237b991686 deleted: weight improvement BFS order never reaches
shortestPaths.ts:85 NC BlockStatement 6e1dc23dd4037995 deleted: weight improvement BFS order never reaches
shortestPaths.ts:86 NC ArithmeticOperator 1fab8825ec56bfd8 deleted: weight improvement BFS order never reaches
shortestPaths.ts:97 S BlockStatement d2c283bffa0a5fd2 deleted: weight improvement BFS order never reaches
simplePaths.ts:71 NC StringLiteral 9b18f901734ec00e deleted: unreachable missing-entry error

New and changed scenarios (tests/graph.conformance.test.ts, 20 -> 23 cases)

  • A traversal hands the logic it walks an actor scope identified as the graph mock. A custom logic copies actorScope.id and sessionId into its initial context, and getPathsFromEvents(logic, [], {})[0].state.context must equal { id: '', sessionId: 'mock-actor-scope' }.
  • A replayed event is replaced by the last override candidate whose filter and serial both match it. The events are [NEXT, BACK] and the filter rejects BACK. Replaying NEXT lands on b. Replaying BACK throws Invalid transition from {"value":"a"} with {"type":"BACK"}.
  • A replay refuses the step after the step count reaches a limit below one. With limit: 0.5, replaying two events throws Traversal limit exceeded.
  • A logic the structural machine check rejects is traversed as plain logic, not as a machine. This extends the existing non-machine-root scenario and renames it. It adds a callable logic: a function object that carries every machine member. Main's check accepts only objects, so the callable is walked as plain logic, and its adjacency keys are ['{"status":"active","context":0}'], not the machine serializer's '{}'.

Every expected value is a hand-written literal.

Why each deletion leaves behaviour unchanged

  • adjacency.ts: queue compaction. pastCompactThreshold, beyondHalf, shouldCompact, compactFrontier and advance spliced the consumed head off a growing FIFO array once it passed 4096 entries. drain now expands one breadth-first level into a fresh array and drops the previous one (expandLevel). That is the same sequence of expand calls as the FIFO queue, and only the current and next levels stay alive, so no size threshold is left. (The first version iterated one growing array with for…of. Its gate run 37930507588 aborted with exit 3; see the gate note below.)
  • adjacency.ts: requiredValue's missing-entry throw. The rows read keys taken from Object.keys of the same dict. They now iterate Object.values of a spread copy, in the same order.
  • shortestPaths.ts: improveExisting. The traversal is FIFO breadth-first with unit weights, so a state's first discovery already carries its least weight, and nextWeight > weight + 1 never holds. The 'Missing traversal entry' throw went too: the snapshot moved into the weight entry, so no by-key lookup remains. Last-writer semantics are kept. As on main, the snapshot recorded for a serialized key is overwritten on every reach. That snapshot is what path.state, every step's state and the serializer's previous-state argument see, when a serializer collapses distinct snapshots. Weight and predecessor are still set at first discovery only.
  • simplePaths.ts: the missing-entry throw. Each key's snapshot lives in a box that is updated in place and threaded through the descent. Every read sees the latest value, as main's stateMap.get did, including a transition back onto a vertex already on the stack.
  • graph.ts: firstDefined and optionSerializeState. Their result was always replaced by the ...resolvedDefaultOptions and ...traversalOptions spreads that follow it in the returned literal. It was only observable when neither carried a serializer, and then it equalled defaultSerializeState, which is now the literal base. isMachineLogic is unchanged from main, including typeof logic === 'object'.
  • alterPath.ts. index === 0 ? undefined : steps[index - 1] is steps[index - 1], since steps[-1] is undefined. length > 0 and length != 0 agree on every array, so the empty-path branch now falls out of appendedSteps: an empty path still gets [initStepOf(path.state)].

Net src: -105 lines in adjacency/shortestPaths/simplePaths, -16 in graph.ts, -5 in alterPath.ts.

Gates (local, on 39a4780)

  • dprint check, tsc -b, tsc -p tsconfig.test.json --noEmit, lint and lint:tsgo all exit 0. lint:tsgo reports 0 errors, 0 warnings, 0 messages.
  • Graph spec: 23 passed on seeds 1, 2 and 3, with identical case names.
  • Full xstate suite: 138 files, 2351 passed, 26 skipped, 3 todo. Before this change it was 2348 passed; the +3 are the new scenarios.

Gate note: the two exit-3 runs

Runs 37930507588 (3ecb77e) and 37933520982 (afcedd6) ended mutation · shard 1/1 with exit 3. Run 37935776856 (78b5c8b) has the same src as afcedd6 plus only #78's diagnostics commit, and its shard finished. Its verdict failed on one survivor, which 39a4780 removes.

What the CI artifacts show:

  • The dry run passed in both failed runs. Each mutation-stream.jsonl reaches phase mutation-test (at 95.8 s and at 63.5 s), and the incremental file records the dry run's 2376 tests.
  • The abort came before the TypeScript checker's first answer. In both failed streams, the only settled mutants are the 2 or 3 Ignored loop guards that Stryker settles without checking. In every finished run (main 37922084024, ci(repo): run the release gate on pull requests, scoped to the change #78 37928589139, fix(repo): kill the graph traversal's surviving mutants #79 37935776856), the next records are the checker's CompileError verdicts for actorScope.ts and alterPath.ts. Stryker gives a mutant to a test runner only after the checker passes it, so no mutant was under test when the run ended.
  • The traversal mutants do not run out of memory. The mutants that defeat the visited check (adjacency.ts:73 adj[serialized] !== undefined -> false, and adjacency.ts:116 if (isVisited(...)) -> false) settle as Timeout after about 80 s in all three finished runs: main's FIFO queue with compaction, ci(repo): run the release gate on pull requests, scoped to the change #78, and this PR's level-by-level drain.

So the cause is not the traversal, and the for…of explanation in afcedd6's commit message is wrong. In stryker-js, exit 3 (RuntimeError) on this path means the checker failed: a CheckerFailed, which becomes StageError(mutationTest) (checkerBreachToStageError, main.mjs:108805), or a checker worker OutOfMemoryError (main.mjs:88402, 108822). The TypeScript checker builds its program through typescript/unstable/async's API, and any failure there is reported as CheckerFailed (runtime.mjs:1637-1660). The same source failed twice and then passed, so this failure is intermittent and comes from the tool and its environment, not from this diff. Which of the two it was is not in any artifact: the shard runner prints only the first 1024 characters of the child's stderr. #78's diagnostics commit (42a4aed) keeps that output on the next failure.

The level-by-level drain stays in this PR. It makes the same expand calls in the same order as main's queue and replaces the compaction survivors.

Changeset

.changeset/graph-traversal-dead-branches.md is none for @systemfsoftware/xstate. It declares the deletions and states that every path, step and adjacency entry comes out the same.

main's release gate (run 37922084024 on 3742392) left 45 mutants alive
in xstate's src/graph: 31 Survived and 14 NoCoverage.

Three new scenarios in the graph conformance spec pin what the published
API shows. A traversal hands the logic it walks an actor scope with id ''
and sessionId 'mock-actor-scope'. A replayed event takes the last
override candidate whose filter and serial both match it, and refuses
when none does. A replay refuses once the step count reaches a limit
below one. The non-machine-root scenario now also walks a callable logic
that carries every machine member as plain logic, because the
structural machine check accepts only objects.

The rest was code that changed no result, now deleted:
- adjacency.ts: the BFS queue's compaction, a performance shortcut. The
  array iterator visits appended items in the same order. Also an
  unreachable missing-entry error.
- shortestPaths.ts: the weight-improvement branch. Breadth-first order
  gives every state its least weight at first discovery. Also the
  unreachable missing-entry error. The snapshot recorded for a
  serialized key is still overwritten on every reach, as on main.
- simplePaths.ts: the unreachable missing-entry error. The per-key
  snapshot lives in a box that is updated in place, so every read sees
  the latest snapshot, as on main.
- graph.ts: a serializer fallback that the options spreads always
  replaced.
- alterPath.ts: two branches whose conditions agree on every input.
@systemfsoftware-maker
systemfsoftware-maker added this pull request to stack #80 October 9, 2026 12:30
@systemfsoftware-maker systemfsoftware-maker changed the title xs/xstate graph survivors fix(repo): kill the graph traversal's surviving mutants Oct 9, 2026
The release gate run 37930507588 on #79 aborted 25 seconds into
mutation testing with exit 3. The dry run had passed (2376 tests) and two
mutants had already settled. Exit 3 is the class Stryker gives a test
runner worker that runs out of memory, and that ends the whole run.

The previous commit replaced main's queue compaction with a `for...of`
over a single growing array. Every entry the traversal ever queued then
stayed reachable until the traversal returned. Under a mutant that stops
the visited check from stopping re-expansion, the traversal never ends,
and the array grows until the worker runs out of heap before Stryker's
45 s timeout can classify the mutant as a Timeout. Main's compaction
dropped consumed entries, so the same mutant held flat memory there and
timed out as expected.

drain now expands one breadth-first level into the next and drops the
previous one. It makes the same expand calls in the same order as the
FIFO queue, and it keeps only the current and next levels alive, with no
compaction threshold left for a mutant to survive on.
systemfsoftware-maker added a commit that referenced this pull request Oct 9, 2026
When a shard child fails, the shard runner prints only the first 1024
characters of the child's stderr, and those are startup INFO lines. The
child's error envelope goes to its stdout, which the runner discards. #79's
gate runs 37930507588 and 37933520982 both failed with exit 3 and left
nothing that names the cause.

Every package's Stryker config now writes stryker.log at info level. When
the mutation job fails, a follow-up step re-runs each of the shard's
project runs directly, with the same arguments the shard runner passes,
keeping stdout, stderr and the exit code under reports/diagnostics/. The
job then uploads that directory and every stryker.log as
mutation-diagnostics-shard-N. A green job runs neither step.
…ring

The gate run 37935776856 on 78b5c8b left one survivor,
adjacency.ts:270 `level.length > 0` -> `level.length != 0`. A length is
never negative, so the two conditions agree on every array. Testing
`level.length !== 0` gives the same loop without an ordering operator,
so no equivalent mutant is left to survive.

@kiro-systemf kiro-systemf Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verified: own gate run 37941873978 on 39a4780 green end to end (203 Killed, 10 Timeout, 0 Survived, 0 NoCoverage). Review79 clauses on the deletions (Demands 1-6) passed; its P2 is refuted by CI data (mutant 112151831eb7e855 is >= -> ==, Killed). 0 threads; hunt clean.

kiro-systemf Bot pushed a commit that referenced this pull request Oct 9, 2026
…#78)

* ci(repo): run the release gate on pull requests, scoped to the change

Capability PRs never ran the mutation gate, so #77 merged src/graph with
45 live mutants (31 Survived, 14 NoCoverage) that main's gate then
caught. The release gate now also runs on pull_request.

The plan job lists the files the pull request changes (the merge commit
against its first parent), and stryker-plan-gate.ts decides the scope:
the declared files the change touches; a package's whole declared set
when the change touches any other file in that package (a test, a
fixture, a config, a source file outside the set); every package's
whole set when it changes the gate (this workflow, the gate script,
stryker.shared.ts, the lockfile, the Nix toolchain). The scope reaches
Stryker as MUTATION_SCOPE, which stryker.shared.ts intersects with each
package's declared mutate list; unset (local, main without a diff), the
whole set is mutated. The gate refuses an in-scope package the plan
scheduled nothing for, as before, and passes a change that touches no
mutated package with no plan.

The verdict job now runs whenever the workflow was not cancelled and
fails unless the plan, and every shard when there are shards, finished,
so a skipped mutation job can no longer read as a pass. Pull-request
runs skip the incremental cache so every scoped mutant runs fresh, and
a newer push cancels the older run.

* ci(repo): keep a failed mutation shard's own output

When a shard child fails, the shard runner prints only the first 1024
characters of the child's stderr, and those are startup INFO lines. The
child's error envelope goes to its stdout, which the runner discards. #79's
gate runs 37930507588 and 37933520982 both failed with exit 3 and left
nothing that names the cause.

Every package's Stryker config now writes stryker.log at info level. When
the mutation job fails, a follow-up step re-runs each of the shard's
project runs directly, with the same arguments the shard runner passes,
keeping stdout, stderr and the exit code under reports/diagnostics/. The
job then uploads that directory and every stryker.log as
mutation-diagnostics-shard-N. A green job runs neither step.
@kiro-systemf
kiro-systemf Bot merged commit 023b47d into main Oct 9, 2026
10 checks passed
An error occurred while trying to automatically change base from xs/release-gate-on-prs to main October 9, 2026 15:13
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