Repository navigation
fix(repo): kill the graph traversal's surviving mutants - #79
Merged
kiro-systemf[bot] merged 5 commits intoOct 9, 2026
Merged
Conversation
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.
…xstate-graph-survivors
systemfsoftware-maker
added this pull request to stack #80
October 9, 2026 12:30
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.
…xstate-graph-survivors
…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.
Contributor
There was a problem hiding this comment.
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.
An error occurred while trying to automatically change base from
xs/release-gate-on-prs
to
main
October 9, 2026 15:13
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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/graphthat 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 ormutatechange. 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
558cf3b49e8e9d15371e04ac7888459450b26085155c9cb419be813569cfd8dee043b53c11268600b35049a7e291f72718c84258dd622982de60faca2d09a8ab7a6088a3b24cc6922132b9c4fb75b9ec9055b8fcef2b51302b83979899de186d19f61e099e38eab1b51cda8c70c3bf70a4a445049cca80c3f38c2bc4745fe4110aec8d7f096e247e24d8d40718a5d526ba02984e4f9349c9d1dc01a9dd4fd2ce3f4ddc239e4380b48981f678590bfbc5d789d533c5d954748a87c15aed33df15abec76b9e331d42cbd761ce943840f5a98f3c65bb0215cc0steps[index - 1]c372e6a93f727a0bfinalStep === undefined ? initStepOf(path.state) : finalStepand alterPath returns{ ...path, steps: appendedSteps(path) }01437da0a099c29d320c780a09e39d285d729f191ec1985349b894cdeac7155b7cbed213a61e2b83519479179279d1766b2d1227b439edc87743da989e7e957dca8b74dbabcf9359112151831eb7e855stepCount >= limit->stepCount == limit. With limit 0.5,1 == 0.5is 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.)0e6d0c4ce16e5d39d7ba70ebd35a86f8b8f3d2237b9916866e1dc23dd40379951fab8825ec56bfd8d2c283bffa0a5fd29b18f901734ec00eNew and changed scenarios (tests/graph.conformance.test.ts, 20 -> 23 cases)
actorScope.idandsessionIdinto its initial context, andgetPathsFromEvents(logic, [], {})[0].state.contextmust equal{ id: '', sessionId: 'mock-actor-scope' }.[NEXT, BACK]and the filter rejectsBACK. ReplayingNEXTlands onb. ReplayingBACKthrowsInvalid transition from {"value":"a"} with {"type":"BACK"}.limit: 0.5, replaying two events throwsTraversal limit exceeded.['{"status":"active","context":0}'], not the machine serializer's'{}'.Every expected value is a hand-written literal.
Why each deletion leaves behaviour unchanged
pastCompactThreshold,beyondHalf,shouldCompact,compactFrontierandadvancespliced the consumed head off a growing FIFO array once it passed 4096 entries.drainnow expands one breadth-first level into a fresh array and drops the previous one (expandLevel). That is the same sequence ofexpandcalls 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 withfor…of. Its gate run 37930507588 aborted with exit 3; see the gate note below.)requiredValue's missing-entry throw. The rows read keys taken fromObject.keysof the same dict. They now iterateObject.valuesof a spread copy, in the same order.improveExisting. The traversal is FIFO breadth-first with unit weights, so a state's first discovery already carries its least weight, andnextWeight > weight + 1never 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 whatpath.state, every step'sstateand the serializer's previous-state argument see, when a serializer collapses distinct snapshots. Weight and predecessor are still set at first discovery only.stateMap.getdid, including a transition back onto a vertex already on the stack.firstDefinedandoptionSerializeState. Their result was always replaced by the...resolvedDefaultOptionsand...traversalOptionsspreads that follow it in the returned literal. It was only observable when neither carried a serializer, and then it equalleddefaultSerializeState, which is now the literal base.isMachineLogicis unchanged from main, includingtypeof logic === 'object'.index === 0 ? undefined : steps[index - 1]issteps[index - 1], sincesteps[-1]is undefined.length > 0andlength != 0agree on every array, so the empty-path branch now falls out ofappendedSteps: 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,lintandlint:tsgoall exit 0. lint:tsgo reports 0 errors, 0 warnings, 0 messages.Gate note: the two exit-3 runs
Runs 37930507588 (3ecb77e) and 37933520982 (afcedd6) ended
mutation · shard 1/1with exit 3. Run 37935776856 (78b5c8b) has the samesrcas 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:
mutation-stream.jsonlreachesphase mutation-test(at 95.8 s and at 63.5 s), and the incremental file records the dry run's 2376 tests.Ignoredloop 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'sCompileErrorverdicts foractorScope.tsandalterPath.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.adjacency.ts:73adj[serialized] !== undefined->false, andadjacency.ts:116if (isVisited(...))->false) settle asTimeoutafter 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…ofexplanation in afcedd6's commit message is wrong. In stryker-js, exit 3 (RuntimeError) on this path means the checker failed: aCheckerFailed, which becomesStageError(mutationTest)(checkerBreachToStageError, main.mjs:108805), or a checker workerOutOfMemoryError(main.mjs:88402, 108822). The TypeScript checker builds its program throughtypescript/unstable/async'sAPI, and any failure there is reported asCheckerFailed(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
expandcalls in the same order as main's queue and replaces the compaction survivors.Changeset
.changeset/graph-traversal-dead-branches.mdisnonefor@systemfsoftware/xstate. It declares the deletions and states that every path, step and adjacency entry comes out the same.