From 96eedd45049f2ee5d4bed94e6b9205fe10017538 Mon Sep 17 00:00:00 2001 From: Ryan Lee Date: Fri, 9 Oct 2026 12:30:05 +0000 Subject: [PATCH 1/3] fix(repo): kill the graph traversal's surviving mutants main's release gate (run 37922084024 on 374239296) 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. --- .changeset/graph-traversal-dead-branches.md | 5 + packages/xstate/src/graph/adjacency.ts | 75 ++-------- packages/xstate/src/graph/alterPath.ts | 10 +- packages/xstate/src/graph/graph.ts | 21 +-- packages/xstate/src/graph/shortestPaths.ts | 120 +++++++--------- packages/xstate/src/graph/simplePaths.ts | 76 +++++----- .../xstate/tests/graph.conformance.test.ts | 134 +++++++++++++++++- 7 files changed, 240 insertions(+), 201 deletions(-) create mode 100644 .changeset/graph-traversal-dead-branches.md diff --git a/.changeset/graph-traversal-dead-branches.md b/.changeset/graph-traversal-dead-branches.md new file mode 100644 index 0000000..f43a574 --- /dev/null +++ b/.changeset/graph-traversal-dead-branches.md @@ -0,0 +1,5 @@ +--- +"@systemfsoftware/xstate": none +--- + +The graph traversals in `@systemfsoftware/xstate/graph` drop code that changed no result: a queue compaction shortcut, a shortest-path weight improvement that breadth-first order never reaches, unreachable missing-entry errors, and a serializer fallback that the options spread always replaced. Every path, step and adjacency entry comes out the same as before. diff --git a/packages/xstate/src/graph/adjacency.ts b/packages/xstate/src/graph/adjacency.ts index 3f012df..396704d 100644 --- a/packages/xstate/src/graph/adjacency.ts +++ b/packages/xstate/src/graph/adjacency.ts @@ -239,43 +239,6 @@ const expand = < enqueueTransitions(value, queue, queued, traversal) } -const pastCompactThreshold = (head: number): boolean => head > 4096 - -const beyondHalf = (head: number, length: number): boolean => head * 2 > length - -const shouldCompact = (head: number, length: number): boolean => pastCompactThreshold(head) && beyondHalf(head, length) - -const compactFrontier = ( - queue: Array>, - head: number, -): number => { - if (!shouldCompact(head, queue.length)) { - return head - } - queue.splice(0, head + 1) - return -1 -} - -const advance = < - TSnapshot extends AnySnapshot, - TEvent extends EventObject, - TInput, - TSystem extends AnyActorSystem, ->( - adj: AdjacencyMap, - queue: Array>, - head: number, - traversal: Traversal, -): number => { - const queued = queue[head] - if (queued === undefined) { - return head + 1 - } - const nextHead = compactFrontier(queue, head) - expand(adj, queue, queued, traversal) - return nextHead + 1 -} - const drain = < TSnapshot extends AnySnapshot, TEvent extends EventObject, @@ -286,9 +249,8 @@ const drain = < queue: Array>, traversal: Traversal, ): void => { - let head = 0 - while (head < queue.length) { - head = advance(adj, queue, head, traversal) + for (const queued of queue) { + expand(adj, queue, queued, traversal) } } @@ -367,35 +329,18 @@ export function adjacencyMapToArray( event: TEvent nextState: TSnapshot }> { - return Object.keys(adjMap).flatMap((key) => adjacencyRow(adjMap, toSerializedSnapshot(key))) -} - -const requiredValue = ( - dict: Record, - key: TKey, -): TValue => { - const value = dict[key] - if (value === undefined) { - throw new Error('Missing adjacency entry') - } - return value + return Object.values({ ...adjMap }).flatMap((adjValue) => adjacencyRow(adjValue)) } const adjacencyRow = ( - adjMap: AdjacencyMap, - key: SerializedSnapshot, + adjValue: AdjacencyValue, ): Array<{ state: TSnapshot event: TEvent nextState: TSnapshot -}> => { - const adjValue = requiredValue(adjMap, key) - return Object.keys(adjValue.transitions).map((eventKey) => { - const transition = requiredValue(adjValue.transitions, toSerializedEvent(eventKey)) - return { - state: adjValue.state, - event: transition.event, - nextState: transition.state, - } - }) -} +}> => + Object.values({ ...adjValue.transitions }).map((transition) => ({ + state: adjValue.state, + event: transition.event, + nextState: transition.state, + })) diff --git a/packages/xstate/src/graph/alterPath.ts b/packages/xstate/src/graph/alterPath.ts index 67363b1..c71f9ad 100644 --- a/packages/xstate/src/graph/alterPath.ts +++ b/packages/xstate/src/graph/alterPath.ts @@ -11,8 +11,7 @@ const initStepOf = (state: AnySnapshot): ErasedStep => ({ event: { type: XSTATE_INIT }, }) -const previousStepOf = (steps: ErasedSteps, index: number): ErasedStep | undefined => - index === 0 ? undefined : steps[index - 1] +const previousStepOf = (steps: ErasedSteps, index: number): ErasedStep | undefined => steps[index - 1] const eventForStep = (steps: ErasedSteps, index: number): EventObject => { const previous = previousStepOf(steps, index) @@ -30,18 +29,15 @@ const finalStepOf = (path: ErasedStatePath): ErasedStep | undefined => { const appendedSteps = (path: ErasedStatePath): ErasedSteps => { const steps = replaySteps(path) const finalStep = finalStepOf(path) - if (finalStep !== undefined) { - steps.push(finalStep) - } + steps.push(finalStep === undefined ? initStepOf(path.state) : finalStep) return steps } // TODO: rewrite parts of the algorithm leading to this to make this function obsolete export function alterPath(path: T): T export function alterPath(path: T): ErasedStatePath { - const steps = path.steps.length > 0 ? appendedSteps(path) : [initStepOf(path.state)] return { ...path, - steps, + steps: appendedSteps(path), } } diff --git a/packages/xstate/src/graph/graph.ts b/packages/xstate/src/graph/graph.ts index cc142b6..7644394 100644 --- a/packages/xstate/src/graph/graph.ts +++ b/packages/xstate/src/graph/graph.ts @@ -138,19 +138,6 @@ function defaultSerializeState( return JSON.stringify(state) } -function firstDefined( - candidates: readonly (T | undefined)[], - fallback: T, -): T { - return candidates.find((candidate) => candidate !== undefined) ?? fallback -} - -function optionSerializeState( - options: OptionsOf | undefined, -): SerializeState | undefined { - return options?.serializeState -} - function optionFromState( options: OptionsOf | undefined, ): SnapshotFrom | undefined { @@ -278,13 +265,7 @@ export function resolveTraversalOptions( }, ): TraversalConfig, EventFromLogic> { const resolvedDefaultOptions = resolveDefaultOptions(logic, traversalOptions) - const serializeState: SerializeState = firstDefined( - [ - optionSerializeState(traversalOptions), - optionSerializeState(resolvedDefaultOptions), - ], - defaultSerializeState, - ) + const serializeState: SerializeState = defaultSerializeState const fromState = optionFromState(traversalOptions) ?? optionFromState(resolvedDefaultOptions) const traversalConfig: TraversalConfig< diff --git a/packages/xstate/src/graph/shortestPaths.ts b/packages/xstate/src/graph/shortestPaths.ts index 2f21cf1..14f2d19 100644 --- a/packages/xstate/src/graph/shortestPaths.ts +++ b/packages/xstate/src/graph/shortestPaths.ts @@ -31,9 +31,10 @@ import type { TraversalOptions, } from './types.js' -interface WeightEntry { +interface WeightEntry { weight: number - state: SerializedSnapshot | undefined + snapshot: TSnapshot + origin: SerializedSnapshot | undefined event: TEvent | undefined } @@ -45,10 +46,9 @@ interface PathPlan { interface ShortestContext { adjacency: AdjacencyMap serializeState: TraversalConfig['serializeState'] - stateMap: Map - weightMap: Map> + weightMap: Map> visited: Set - unvisited: Set + unvisited: Map> } const createNullDict = (): Record => { @@ -66,53 +66,34 @@ const initialSnapshotOf = ( actorScope: AnyActorScope, ): TSnapshot => logic.getInitialSnapshot(actorScope, input) -const mustGet = (map: Map, key: TKey): TValue => { - const value = map.get(key) - if (value === undefined) { - throw new Error('Missing traversal entry') - } - return value -} - -const improveExisting = ( +const discover = ( context: ShortestContext, - fromSerialized: SerializedSnapshot, - weight: number, nextSerialized: SerializedSnapshot, - event: TEvent, + entry: WeightEntry, ): void => { - const { weight: nextWeight } = mustGet(context.weightMap, nextSerialized) - if (nextWeight > weight + 1) { - context.weightMap.set(nextSerialized, { weight: weight + 1, state: fromSerialized, event }) + if (!context.visited.has(nextSerialized)) { + context.unvisited.set(nextSerialized, entry) } } -const improveWeight = ( +const recordReach = ( context: ShortestContext, - fromSerialized: SerializedSnapshot, - weight: number, nextSerialized: SerializedSnapshot, + weight: number, + origin: SerializedSnapshot, + snapshot: TSnapshot, event: TEvent, ): void => { - if (context.weightMap.has(nextSerialized)) { - improveExisting(context, fromSerialized, weight, nextSerialized, event) - } else { - context.weightMap.set(nextSerialized, { weight: weight + 1, state: fromSerialized, event }) - } -} - -const discover = ( - context: ShortestContext, - nextSerialized: SerializedSnapshot, -): void => { - if (!context.visited.has(nextSerialized)) { - context.unvisited.add(nextSerialized) - } + const existing = context.weightMap.get(nextSerialized) + const entry = existing === undefined ? { weight, snapshot, origin, event } : { ...existing, snapshot } + context.weightMap.set(nextSerialized, entry) + discover(context, nextSerialized, entry) } const relaxTransition = ( context: ShortestContext, fromSerialized: SerializedSnapshot, + fromSnapshot: TSnapshot, weight: number, transitions: AdjacencyMap[SerializedSnapshot]['transitions'], serializedEvent: SerializedEvent, @@ -122,34 +103,33 @@ const relaxTransition = ( context: ShortestContext, fromSerialized: SerializedSnapshot, + fromSnapshot: TSnapshot, weight: number, adjacencyValue: AdjacencyMap[SerializedSnapshot], ): void => { for (const serializedEvent of Object.keys(adjacencyValue.transitions).map(toSerializedEvent)) { - relaxTransition(context, fromSerialized, weight, adjacencyValue.transitions, serializedEvent) + relaxTransition(context, fromSerialized, fromSnapshot, weight, adjacencyValue.transitions, serializedEvent) } } const relaxState = ( context: ShortestContext, fromSerialized: SerializedSnapshot, + entry: WeightEntry, ): void => { - const weightEntry = mustGet(context.weightMap, fromSerialized) const adjacencyValue = context.adjacency[fromSerialized] if (adjacencyValue === undefined) { return } - relaxTransitions(context, fromSerialized, weightEntry.weight, adjacencyValue) + relaxTransitions(context, fromSerialized, entry.snapshot, entry.weight, adjacencyValue) context.visited.add(fromSerialized) context.unvisited.delete(fromSerialized) } @@ -157,13 +137,12 @@ const relaxState = ( const runTraversal = ( context: ShortestContext, ): void => { - for (const fromSerialized of context.unvisited) { - relaxState(context, fromSerialized) + for (const [fromSerialized, entry] of context.unvisited) { + relaxState(context, fromSerialized, entry) } } const firstPlanPath = ( - stateMap: Map, statePlanMap: StatePlanMap, fromSerialized: SerializedSnapshot, ): StatePath | undefined => { @@ -172,42 +151,38 @@ const firstPlanPath = ( - stateMap: Map, - fromSerialized: SerializedSnapshot, + fromState: TSnapshot, event: TEvent | undefined, -): Array> => event === undefined ? [] : [{ state: mustGet(stateMap, fromSerialized), event }] +): Array> => event === undefined ? [] : [{ state: fromState, event }] const stepsToward = ( - stateMap: Map, statePlanMap: StatePlanMap, fromSerialized: SerializedSnapshot, event: TEvent | undefined, ): Steps => { - const path = firstPlanPath(stateMap, statePlanMap, fromSerialized) + const path = firstPlanPath(statePlanMap, fromSerialized) return path === undefined ? [] - : path.steps.concat(stepTo(stateMap, fromSerialized, event)) + : path.steps.concat(stepTo(path.state, event)) } const predecessorSteps = ( - stateMap: Map, statePlanMap: StatePlanMap, - entry: WeightEntry, + entry: WeightEntry, ): Steps => { - const { state: fromSerialized, event } = entry - return fromSerialized === undefined + const origin = entry.origin + return origin === undefined ? [] - : stepsToward(stateMap, statePlanMap, fromSerialized, event) + : stepsToward(statePlanMap, origin, entry.event) } const buildPath = ( - stateMap: Map, statePlanMap: StatePlanMap, - entry: WeightEntry, + entry: WeightEntry, stateSerial: SerializedSnapshot, ): StatePath => { - const state = mustGet(stateMap, stateSerial) - const steps = predecessorSteps(stateMap, statePlanMap, entry) + const state = entry.snapshot + const steps = predecessorSteps(statePlanMap, entry) statePlanMap[stateSerial] = { state, paths: [{ state, steps, weight: entry.weight }], @@ -216,13 +191,12 @@ const buildPath = ( } const collectPaths = ( - stateMap: Map, - weightMap: Map>, + weightMap: Map>, ): Array> => { const statePlanMap: StatePlanMap = createNullDict>() const paths: Array> = [] weightMap.forEach((entry, stateSerial) => { - paths.push(buildPath(stateMap, statePlanMap, entry, stateSerial)) + paths.push(buildPath(statePlanMap, entry, stateSerial)) }) return paths } @@ -243,9 +217,14 @@ const seed = ( fromState: TSnapshot, ): void => { const serialized = toSerializedSnapshot(context.serializeState(fromState, undefined, undefined)) - context.stateMap.set(serialized, fromState) - context.weightMap.set(serialized, { weight: 0, state: undefined, event: undefined }) - context.unvisited.add(serialized) + const entry: WeightEntry = { + weight: 0, + snapshot: fromState, + origin: undefined, + event: undefined, + } + context.weightMap.set(serialized, entry) + context.unvisited.set(serialized, entry) } function computeShortestPaths( @@ -266,14 +245,13 @@ function computeShortestPaths( const context: ShortestContext = { adjacency, serializeState: resolvedOptions.serializeState, - stateMap: new Map(), weightMap: new Map(), visited: new Set(), - unvisited: new Set(), + unvisited: new Map(), } seed(context, fromState) runTraversal(context) - return applyTarget(collectPaths(context.stateMap, context.weightMap), resolvedOptions.toState) + return applyTarget(collectPaths(context.weightMap), resolvedOptions.toState) } export function getShortestPaths( diff --git a/packages/xstate/src/graph/simplePaths.ts b/packages/xstate/src/graph/simplePaths.ts index a2c9a99..5efd586 100644 --- a/packages/xstate/src/graph/simplePaths.ts +++ b/packages/xstate/src/graph/simplePaths.ts @@ -41,10 +41,14 @@ interface Transition { state: TSnapshot } +interface SnapshotBox { + snapshot: TSnapshot +} + interface SimpleContext { adjacency: AdjacencyMap serializeState: TraversalConfig['serializeState'] - stateMap: Map + stateMap: Map> visitCtx: VisitedContext steps: Steps pathMap: StatePlanMap @@ -65,31 +69,35 @@ const initialSnapshotOf = ( actorScope: AnyActorScope, ): TSnapshot => logic.getInitialSnapshot(actorScope, input) -const mustGet = (map: Map, key: TKey): TValue => { - const value = map.get(key) - if (value === undefined) { - throw new Error('Missing traversal entry') - } - return value +const boxFor = ( + context: SimpleContext, + serialized: SerializedSnapshot, + snapshot: TSnapshot, +): SnapshotBox => { + const existing = context.stateMap.get(serialized) + const box = existing === undefined ? { snapshot } : existing + box.snapshot = snapshot + context.stateMap.set(serialized, box) + return box } const createPlan = ( pathMap: StatePlanMap, - stateMap: Map, serialized: SerializedSnapshot, + state: TSnapshot, ): PathPlan => { - const plan: PathPlan = { state: mustGet(stateMap, serialized), paths: [] } + const plan: PathPlan = { state, paths: [] } pathMap[serialized] = plan return plan } const planAt = ( pathMap: StatePlanMap, - stateMap: Map, serialized: SerializedSnapshot, + state: TSnapshot, ): PathPlan => { const existing = pathMap[serialized] - return existing === undefined ? createPlan(pathMap, stateMap, serialized) : existing + return existing === undefined ? createPlan(pathMap, serialized, state) : existing } const recordPath = ( @@ -97,7 +105,7 @@ const recordPath = ( toSerialized: SerializedSnapshot, fromState: TSnapshot, ): void => { - const plan = planAt(context.pathMap, context.stateMap, toSerialized) + const plan = planAt(context.pathMap, toSerialized, fromState) plan.paths.push({ state: fromState, weight: context.steps.length, @@ -107,36 +115,37 @@ const recordPath = ( function expandInto( context: SimpleContext, - fromSerialized: SerializedSnapshot, + box: SnapshotBox, toSerialized: SerializedSnapshot, serializedEvent: SerializedEvent, event: TEvent, nextSerialized: SerializedSnapshot, + nextBox: SnapshotBox, ): void { context.visitCtx.edges.add(serializedEvent) - context.steps.push({ state: mustGet(context.stateMap, fromSerialized), event }) - visit(context, nextSerialized, toSerialized) + context.steps.push({ state: box.snapshot, event }) + visit(context, nextSerialized, nextBox, toSerialized) } const descendInto = ( context: SimpleContext, - fromSerialized: SerializedSnapshot, + box: SnapshotBox, toSerialized: SerializedSnapshot, serializedEvent: SerializedEvent, transition: Transition, ): void => { const nextSerialized = toSerializedSnapshot( - context.serializeState(transition.state, transition.event, context.stateMap.get(fromSerialized)), + context.serializeState(transition.state, transition.event, box.snapshot), ) - context.stateMap.set(nextSerialized, transition.state) + const nextBox = boxFor(context, nextSerialized, transition.state) if (!context.visitCtx.vertices.has(nextSerialized)) { - expandInto(context, fromSerialized, toSerialized, serializedEvent, transition.event, nextSerialized) + expandInto(context, box, toSerialized, serializedEvent, transition.event, nextSerialized, nextBox) } } const descendTransition = ( context: SimpleContext, - fromSerialized: SerializedSnapshot, + box: SnapshotBox, toSerialized: SerializedSnapshot, transitions: AdjacencyMap[SerializedSnapshot]['transitions'], serializedEvent: SerializedEvent, @@ -145,43 +154,44 @@ const descendTransition = ( context: SimpleContext, - fromSerialized: SerializedSnapshot, + box: SnapshotBox, toSerialized: SerializedSnapshot, adjacencyValue: AdjacencyMap[SerializedSnapshot], ): void => { for (const serializedEvent of Object.keys(adjacencyValue.transitions).map(toSerializedEvent)) { - descendTransition(context, fromSerialized, toSerialized, adjacencyValue.transitions, serializedEvent) + descendTransition(context, box, toSerialized, adjacencyValue.transitions, serializedEvent) } } const descend = ( context: SimpleContext, fromSerialized: SerializedSnapshot, + box: SnapshotBox, toSerialized: SerializedSnapshot, ): void => { const adjacencyValue = context.adjacency[fromSerialized] if (adjacencyValue === undefined) { return } - descendTransitions(context, fromSerialized, toSerialized, adjacencyValue) + descendTransitions(context, box, toSerialized, adjacencyValue) } function visit( context: SimpleContext, fromSerialized: SerializedSnapshot, + box: SnapshotBox, toSerialized: SerializedSnapshot, ): void { - const fromState = mustGet(context.stateMap, fromSerialized) context.visitCtx.vertices.add(fromSerialized) if (fromSerialized === toSerialized) { - recordPath(context, toSerialized, fromState) + recordPath(context, toSerialized, box.snapshot) } else { - descend(context, fromSerialized, toSerialized) + descend(context, fromSerialized, box, toSerialized) } context.steps.pop() context.visitCtx.vertices.delete(fromSerialized) @@ -190,18 +200,18 @@ function visit( const seed = ( context: SimpleContext, fromState: TSnapshot, -): SerializedSnapshot => { +): { serialized: SerializedSnapshot; box: SnapshotBox } => { const serialized = toSerializedSnapshot(context.serializeState(fromState, undefined)) - context.stateMap.set(serialized, fromState) - return serialized + return { serialized, box: boxFor(context, serialized, fromState) } } const visitEachStart = ( context: SimpleContext, fromSerialized: SerializedSnapshot, + box: SnapshotBox, ): void => { for (const nextSerialized of Object.keys(context.adjacency).map(toSerializedSnapshot)) { - visit(context, fromSerialized, nextSerialized) + visit(context, fromSerialized, box, nextSerialized) } } @@ -240,8 +250,8 @@ function computeSimplePaths( steps: [], pathMap: createNullDict>(), } - const fromSerialized = seed(context, fromState) - visitEachStart(context, fromSerialized) + const { serialized: fromSerialized, box } = seed(context, fromState) + visitEachStart(context, fromSerialized, box) const simplePaths = Object.values(context.pathMap).flatMap((plan) => plan.paths) return applyTarget(simplePaths, resolvedOptions.toState) } diff --git a/packages/xstate/tests/graph.conformance.test.ts b/packages/xstate/tests/graph.conformance.test.ts index c995283..f949f20 100644 --- a/packages/xstate/tests/graph.conformance.test.ts +++ b/packages/xstate/tests/graph.conformance.test.ts @@ -1,6 +1,7 @@ import { Conformance } from '@systemfsoftware/conformance-spec' import { And, Gherkin, Given, it, makeFeature, Then, When } from '@systemfsoftware/effect-gherkin-spec' import { + type AnyActorScope, createActor, createLogic, createMachine, @@ -330,6 +331,25 @@ const rootedLogicAdjacencyKeys = (): ReadonlyArray => { return Object.keys(getAdjacencyMap(logic, { serializeState: (state) => `s${state.context}` })) } +const machineShapedMembers = { + root: lightMachine.root, + getStateNodeById: () => undefined, + resolveState: () => undefined, + getTransitionData: () => undefined, +} + +const callableLogicAdjacencyKeys = (): ReadonlyArray => { + const logic = Object.assign( + () => undefined, + createLogic({ context: 0, run: () => undefined }), + machineShapedMembers, + ) + return Object.keys(getAdjacencyMap(logic, { + fromState: { status: 'active', output: undefined, error: undefined, context: 0, input: undefined }, + events: [], + })) +} + const countedCustomLogic = () => { let calls = 0 const logic = createLogic({ @@ -457,6 +477,56 @@ const expectedEntrypointNames = [ 'toDirectedGraph', ] +const mockScopeIdentity = (): object => { + const logic = Object.assign( + createLogic({ context: {}, run: () => undefined }), + { + getInitialSnapshot: (actorScope: AnyActorScope) => ({ + context: { id: actorScope.id, sessionId: actorScope.sessionId }, + }), + }, + ) + const path = getPathsFromEvents(logic, [], {})[0] + return path === undefined ? {} : path.state.context +} + +const candidateReplayMachine = createMachine({ + initial: 'a', + states: { + a: { on: { NEXT: { target: 'b' }, BACK: { target: 'c' } } }, + b: {}, + c: {}, + }, +}) + +const candidateReplay = (): object => { + const run = (event: EventFromLogic): string => { + try { + const path = getPathsFromEvents(candidateReplayMachine, [event], { + events: [{ type: 'NEXT' }, { type: 'BACK' }], + filterEvents: ( + _state: SnapshotFrom, + candidate: EventFromLogic, + ) => candidate.type !== 'BACK', + })[0] + return path === undefined ? 'none' : String(path.state.value) + } catch (error) { + return error instanceof Error ? error.message : 'unrecognized' + } + } + return { next: run({ type: 'NEXT' }), back: run({ type: 'BACK' }) } +} + +const subIntegerLimitReplay = (): string => { + const machine = createMachine({ initial: 'a', states: { a: { on: { GO: { target: 'a' } } } } }) + try { + const paths = getPathsFromEvents(machine, [{ type: 'GO' }, { type: 'GO' }], { limit: 0.5 }) + return `paths:${paths.length}` + } catch (error) { + return error instanceof Error ? error.message : 'unrecognized' + } +} + Feature('Judging the published graph walk against a model of its traversal', { timeout: 0 }) .withLayer(Layer.empty) .live('each scenario drives the simulation kernel itself, and a conformance check cannot run inside a kernel run') @@ -650,16 +720,25 @@ Feature('Judging the published graph walk against a model of its traversal', { t ) scenario( - 'A logic carrying a non-machine root member is traversed as plain logic, not as a machine', + 'A logic the structural machine check rejects is traversed as plain logic, not as a machine', Gherkin.Do.pipe( - Given('the adjacency keys of a custom logic whose root member is not a machine root')( + Given( + 'the adjacency keys of a logic whose root member is not a machine root and of a callable logic carrying machine-shaped members', + )( 'keys', - () => Effect.succeed(rootedLogicAdjacencyKeys()), + () => Effect.succeed({ rooted: rootedLogicAdjacencyKeys(), callable: callableLogicAdjacencyKeys() }), ), - Then('the traversal starts from the plain initial state and derives no event from snapshot nodes')(( + Then( + 'both walk as plain logic: the rooted one from its serialized start, the callable under the plain default serializer', + )(( s, expect, - ) => expect(s.keys, JSON.stringify(s.keys)).toEqual(['s0'])), + ) => + expect(s.keys, JSON.stringify(s.keys)).toEqual({ + rooted: ['s0'], + callable: ['{"status":"active","context":0}'], + }) + ), ), ) @@ -729,4 +808,49 @@ Feature('Judging the published graph walk against a model of its traversal', { t ), ), ) + + scenario( + 'A traversal hands the logic it walks an actor scope identified as the graph mock', + Gherkin.Do.pipe( + Given('the identity a custom logic reads off the actor scope of its initial snapshot')( + 'identity', + () => Effect.succeed(mockScopeIdentity()), + ), + Then('the scope is the graph mock: an empty id and the mock session id')((s, expect) => + expect(s.identity, JSON.stringify(s.identity)).toEqual({ id: '', sessionId: 'mock-actor-scope' }) + ), + ), + ) + + scenario( + 'A replayed event is replaced by the last override candidate whose filter and serial both match it', + Gherkin.Do.pipe( + Given('a one-event replay over two candidate events, the second of which the filter rejects')( + 'observed', + () => Effect.succeed(candidateReplay()), + ), + Then('an accepted event advances to its target, and an event no candidate serial names is refused')(( + s, + expect, + ) => + expect(s.observed, JSON.stringify(s.observed)).toEqual({ + next: 'b', + back: 'Invalid transition from {"value":"a"} with {"type":"BACK"}', + }) + ), + ), + ) + + scenario( + 'A replay refuses the step after the step count reaches a limit below one', + Gherkin.Do.pipe( + Given('two events replayed against a machine with the limit set to one half')( + 'outcome', + () => Effect.succeed(subIntegerLimitReplay()), + ), + Then('the traversal is refused rather than returning the completed path')((s, expect) => + expect(s.outcome, JSON.stringify(s.outcome)).toEqual('Traversal limit exceeded') + ), + ), + ) }) From afcedd6da459c47d6d50f2998a7e1e31d11c141e Mon Sep 17 00:00:00 2001 From: Ryan Lee Date: Fri, 9 Oct 2026 12:57:44 +0000 Subject: [PATCH 2/3] fix(graph): drain the adjacency traversal one level at a time 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. --- packages/xstate/src/graph/adjacency.ts | 29 +++++++++++++++++++------- 1 file changed, 22 insertions(+), 7 deletions(-) diff --git a/packages/xstate/src/graph/adjacency.ts b/packages/xstate/src/graph/adjacency.ts index 396704d..a91b8fd 100644 --- a/packages/xstate/src/graph/adjacency.ts +++ b/packages/xstate/src/graph/adjacency.ts @@ -239,6 +239,23 @@ const expand = < enqueueTransitions(value, queue, queued, traversal) } +const expandLevel = < + TSnapshot extends AnySnapshot, + TEvent extends EventObject, + TInput, + TSystem extends AnyActorSystem, +>( + adj: AdjacencyMap, + level: ReadonlyArray>, + traversal: Traversal, +): Array> => { + const nextLevel: Array> = [] + for (const queued of level) { + expand(adj, nextLevel, queued, traversal) + } + return nextLevel +} + const drain = < TSnapshot extends AnySnapshot, TEvent extends EventObject, @@ -246,11 +263,12 @@ const drain = < TSystem extends AnyActorSystem, >( adj: AdjacencyMap, - queue: Array>, + start: QueueEntry, traversal: Traversal, ): void => { - for (const queued of queue) { - expand(adj, queue, queued, traversal) + let level = [start] + while (level.length > 0) { + level = expandLevel(adj, level, traversal) } } @@ -274,10 +292,7 @@ function computeAdjacencyMap< } const fromState = config.fromState ?? initialSnapshotOf(logic, options.input, actorScope) const adj: AdjacencyMap = createNullDict>() - const queue: Array> = [ - { nextState: fromState, event: undefined, prevState: undefined }, - ] - drain(adj, queue, traversal) + drain(adj, { nextState: fromState, event: undefined, prevState: undefined }, traversal) return adj } From 39a4780a8ce53ca725084d80b829cf712421ba80 Mon Sep 17 00:00:00 2001 From: Ryan Lee Date: Fri, 9 Oct 2026 14:07:36 +0000 Subject: [PATCH 3/3] fix(graph): end the adjacency drain on an empty level without an ordering The gate run 37935776856 on 78b5c8bc7 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. --- packages/xstate/src/graph/adjacency.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/xstate/src/graph/adjacency.ts b/packages/xstate/src/graph/adjacency.ts index a91b8fd..f58b166 100644 --- a/packages/xstate/src/graph/adjacency.ts +++ b/packages/xstate/src/graph/adjacency.ts @@ -267,7 +267,7 @@ const drain = < traversal: Traversal, ): void => { let level = [start] - while (level.length > 0) { + while (level.length !== 0) { level = expandLevel(adj, level, traversal) } }