From 5f673dc4dc067a5a3728d0ff105b5dda7c66d7cd Mon Sep 17 00:00:00 2001 From: Ryan Lee Date: Fri, 9 Oct 2026 11:58:34 +0000 Subject: [PATCH 01/10] refactor(repo): move xstate's guard evaluation into transitionGuards.ts (WIP, parked) Capability 6 (guards) work in progress, parked by conductor ruling 217 for the release-gate work. src/transitionGuards.ts owns checkStateIn (now with a data-last form), candidate admission and the transition-function selection rule over a tagged outcome; stateUtils.ts and StateNode.ts keep calling user code and delegate the decisions. The module is enrolled in mutate, the lint script, tsconfig.tsgo.json and XS1. The guards conformance spec is not finished and the old guard tests are still in place. --- packages/xstate/src/StateNode.ts | 31 +++-- packages/xstate/src/index.ts | 3 +- packages/xstate/src/stateUtils.ts | 152 ++++++++++++++-------- packages/xstate/src/transitionGuards.ts | 164 ++++++++++++++++++++++++ packages/xstate/src/utils.ts | 22 ---- packages/xstate/stryker.config.ts | 1 + 6 files changed, 280 insertions(+), 93 deletions(-) create mode 100644 packages/xstate/src/transitionGuards.ts diff --git a/packages/xstate/src/StateNode.ts b/packages/xstate/src/StateNode.ts index bc48839..b697c4e 100644 --- a/packages/xstate/src/StateNode.ts +++ b/packages/xstate/src/StateNode.ts @@ -4,7 +4,7 @@ import { createInvokeTimeoutEventId } from './eventUtils.js' import { memo } from './memo.js' import type { SetupStateSchemas } from './schema.types.js' import { - evaluateCandidate, + admitTransitionCandidate, formatTransition, getCandidates, getDelayedTransitions, @@ -12,6 +12,7 @@ import { matchesActorSession, type TransitionSelectionResults, } from './stateUtils.js' +import { firstAdmittedCandidate } from './transitionGuards.js' import type { AnyAction, AnyActorLogic, @@ -332,22 +333,20 @@ export class StateNode< ;(this._candidateCache ??= new Map()).set(descriptorKey, candidates) } - for (const candidate of candidates) { - const guardPassed = evaluateCandidate( - candidate, - event, - snapshot, - this, - actorScope, - selectionResults, - ) - - if (guardPassed) { - return [candidate] - } - } + const admitted = firstAdmittedCandidate({ + candidates, + admit: (candidate) => + admitTransitionCandidate( + candidate, + event, + snapshot, + this, + actorScope, + selectionResults, + ), + }) - return undefined + return admitted === undefined ? undefined : [admitted.candidate] } } diff --git a/packages/xstate/src/index.ts b/packages/xstate/src/index.ts index 807adb9..278dc9d 100644 --- a/packages/xstate/src/index.ts +++ b/packages/xstate/src/index.ts @@ -181,6 +181,7 @@ export { transition, } from './transition.js' export { executeEffects, isBuiltInExecutableAction } from './transitionActions.js' +export { checkStateIn } from './transitionGuards.js' export type * from './types.js' export { SpecialTargets } from './types.js' export type { @@ -194,5 +195,5 @@ export type { Sources, WidenLiterals, } from './types.v6.js' -export { checkStateIn, getAllOwnEventDescriptors as __unsafe_getAllOwnEventDescriptors, toObserver } from './utils.js' +export { getAllOwnEventDescriptors as __unsafe_getAllOwnEventDescriptors, toObserver } from './utils.js' export { waitFor } from './waitFor.js' diff --git a/packages/xstate/src/stateUtils.ts b/packages/xstate/src/stateUtils.ts index 6d0de57..027e47d 100644 --- a/packages/xstate/src/stateUtils.ts +++ b/packages/xstate/src/stateUtils.ts @@ -1,7 +1,7 @@ import isDevelopment from '#is-development' import { builtInActions } from './actions.js' import { getActorScopeParent, isLazyActorScope, withActorScope, withActorSelfAndParent } from './actorScope.js' -import { STATE_DELIMITER, STATE_IDENTIFIER, XSTATE_INIT, XSTATE_STOP, XSTATE_TIMER } from './constants.js' +import { STATE_DELIMITER, XSTATE_INIT, XSTATE_STOP, XSTATE_TIMER } from './constants.js' import { parseDurationToMilliseconds } from './delay.js' import { createAfterEvent, @@ -30,6 +30,16 @@ import { mergeContextPatch, resolveActionsWithContext, } from './transitionActions.js' +import { + admitCandidate, + type CandidateDecision, + firstAdmittedCandidate, + isAdmitted, + isStateId, + selectTransition, + type TransitionFunctionOutcome, + type TransitionSelection, +} from './transitionGuards.js' import type { AnyAction, AnyActor, @@ -49,7 +59,7 @@ import type { StateValue, TransitionDefinition, } from './types.js' -import { getEventOutput, isErrorEvent, matchesEvent, matchesEventDescriptor } from './utils.js' +import { getEventOutput, isErrorEvent, matchesEventDescriptor } from './utils.js' import { normalizeTarget, resolveOutput, resolveReferencedActor, toArray, toTransitionConfigArray } from './utils.js' type AnyStateNodeIterable = Iterable @@ -251,7 +261,7 @@ export function isInFinalState( return stateNode.type === 'final' } -export const isStateId = (str: string) => str[0] === STATE_IDENTIFIER +export { isStateId } function getLegacyEventType(event: EventObject): string | undefined { switch (event.type) { @@ -954,6 +964,15 @@ export type TransitionSelectionResult = { reusable: boolean } +const selectionResultOf = ( + selection: TransitionSelection, +): TransitionSelectionResult => + selection.type === 'value' + ? { enabled: true, result: selection.result, reusable: true } + : selection.type === 'effect' + ? { enabled: true, result: undefined, reusable: false } + : { enabled: false, result: undefined, reusable: true } + export type TransitionSelectionResults = Map< AnyTransitionDefinition, TransitionSelectionResult @@ -2725,15 +2744,17 @@ export function hasEffect( actorScope: AnyActorScope, ): boolean { if (transition.to) { - return evaluateTransitionFunction( - transition.to, - context, - event, - snapshot, - actorScope, - snapshot.machine.sources, - transition.source.id, - ).enabled + return selectTransition( + evaluateTransitionOutcome( + transition.to, + context, + event, + snapshot, + actorScope, + snapshot.machine.sources, + transition.source.id, + ), + ).type !== 'absent' } return false @@ -2788,7 +2809,7 @@ function getTransitionEffectEnqueue() { )) } -function evaluateTransitionFunction( +function evaluateTransitionOutcome( transitionTo: NonNullable, context: MachineContext, event: EventObject, @@ -2796,7 +2817,7 @@ function evaluateTransitionFunction( actorScope: AnyActorScope, sources: AnyMachineSnapshot['machine']['sources'], sourceId: string, -): TransitionSelectionResult { +): TransitionFunctionOutcome { let res const parent = getActorScopeParent(actorScope) if (parent) { @@ -2825,7 +2846,7 @@ function evaluateTransitionFunction( ) } catch (err) { if (err === transitionEffectSignal) { - return { enabled: true, result: undefined, reusable: false } + return { type: 'effectEnqueued' } } throw err } finally { @@ -2836,7 +2857,7 @@ function evaluateTransitionFunction( } assertSyncTransitionResult(res, event, sourceId) - return { enabled: res !== undefined, result: res, reusable: true } + return { type: 'returned', value: res } } function stopChildren( @@ -2914,55 +2935,78 @@ function selectEventlessTransitions( ) } -export function evaluateCandidate( +export function admitTransitionCandidate( candidate: AnyTransitionDefinition, event: EventObject, snapshot: AnyMachineSnapshot, stateNode: AnyStateNode, actorScope: AnyActorScope, selectionResults?: TransitionSelectionResults, -): boolean { - if (candidate.matches && !matchesEvent(event, candidate.matches)) { - return false - } +): CandidateDecision { + const transitionTo = candidate.to + const decision = admitCandidate({ + event, + snapshot, + matches: candidate.matches, + eventMatcher: candidate._eventMatcher, + runGuard: candidate.guard + ? () => { + const guardArgs = withActorSelfAndParent( + { + context: snapshot.context, + event, + output: getEventOutput(event), + children: snapshot.children, + actions: stateNode.machine.sources.actions, + actors: stateNode.machine.sources.actors, + guards: stateNode.machine.sources.guards, + delays: stateNode.machine.sources.delays, + _snapshot: snapshot, + }, + actorScope, + ) + return !!(candidate.guard as (args: typeof guardArgs) => boolean)( + guardArgs, + ) + } + : undefined, + runTransitionFunction: transitionTo === undefined + ? undefined + : () => + evaluateTransitionOutcome( + transitionTo, + snapshot.context, + event, + snapshot, + actorScope, + stateNode.machine.sources, + candidate.source.id, + ), + }) - if (candidate._eventMatcher && !candidate._eventMatcher(event, snapshot)) { - return false + if (decision.type === 'enabled' || decision.type === 'disabled') { + selectionResults?.set(candidate, selectionResultOf(decision.selection)) } - if (candidate.guard) { - const guardArgs = withActorSelfAndParent( - { - context: snapshot.context, - event, - output: getEventOutput(event), - children: snapshot.children, - actions: stateNode.machine.sources.actions, - actors: stateNode.machine.sources.actors, - guards: stateNode.machine.sources.guards, - delays: stateNode.machine.sources.delays, - _snapshot: snapshot, - }, - actorScope, - ) - if (!(candidate.guard as (args: typeof guardArgs) => boolean)(guardArgs)) { - return false - } - } + return decision +} - if (candidate.to) { - const evaluation = evaluateTransitionFunction( - candidate.to, - snapshot.context, +export function evaluateCandidate( + candidate: AnyTransitionDefinition, + event: EventObject, + snapshot: AnyMachineSnapshot, + stateNode: AnyStateNode, + actorScope: AnyActorScope, + selectionResults?: TransitionSelectionResults, +): boolean { + return isAdmitted( + admitTransitionCandidate( + candidate, event, snapshot, + stateNode, actorScope, - stateNode.machine.sources, - candidate.source.id, - ) - selectionResults?.set(candidate, evaluation) - return evaluation.enabled - } - - return true + selectionResults, + ), + ) } diff --git a/packages/xstate/src/transitionGuards.ts b/packages/xstate/src/transitionGuards.ts new file mode 100644 index 0000000..2011719 --- /dev/null +++ b/packages/xstate/src/transitionGuards.ts @@ -0,0 +1,164 @@ +import { STATE_IDENTIFIER } from './constants.js' +import type { AnyEventObject, AnyMachineSnapshot, EventObject, EventPayloadPattern, StateValue } from './types.js' + +export const isStateId = (str: string): boolean => str.startsWith(STATE_IDENTIFIER) + +const isStateIdValue = (stateValue: StateValue): stateValue is string => + typeof stateValue === 'string' && isStateId(stateValue) + +const stateInSnapshot = ( + snapshot: AnyMachineSnapshot, + stateValue: StateValue, +): boolean => { + if (isStateIdValue(stateValue)) { + const target = snapshot.machine.getStateNodeById(stateValue) + return snapshot.nodes.some((node) => node === target) + } + + return snapshot.matches(stateValue) +} + +/** @public */ +export function checkStateIn( + stateValue: StateValue, +): (snapshot: AnyMachineSnapshot) => boolean +export function checkStateIn( + snapshot: AnyMachineSnapshot, + stateValue: StateValue, +): boolean +export function checkStateIn( + ...args: + | readonly [stateValue: StateValue] + | readonly [snapshot: AnyMachineSnapshot, stateValue: StateValue] +): boolean | ((snapshot: AnyMachineSnapshot) => boolean) { + if (args.length === 1) { + const [stateValue] = args + return (snapshot: AnyMachineSnapshot) => stateInSnapshot(snapshot, stateValue) + } + return stateInSnapshot(args[0], args[1]) +} + +const matchesEvent = ( + event: EventObject, + pattern: EventPayloadPattern, +): boolean => Object.entries(pattern).every(([key, value]) => Object.is(Reflect.get(event, key), value)) + +export type TransitionFunctionOutcome = + | { readonly type: 'returned'; readonly value: Result | undefined } + | { readonly type: 'effectEnqueued' } + +export type TransitionSelection = + | { readonly type: 'value'; readonly result: Result } + | { readonly type: 'effect' } + | { readonly type: 'absent' } + +export const selectTransition = ( + outcome: TransitionFunctionOutcome, +): TransitionSelection => + outcome.type === 'returned' + ? selectionOfReturned(outcome.value) + : { type: 'effect' } + +const selectionOfReturned = ( + value: Result | undefined, +): TransitionSelection => value === undefined ? { type: 'absent' } : { type: 'value', result: value } + +export type SessionEventMatcher = ( + event: EventObject, + snapshot: AnyMachineSnapshot, +) => boolean + +export interface CandidateAdmissionInput { + readonly event: EventObject + readonly snapshot: AnyMachineSnapshot + readonly matches: EventPayloadPattern | undefined + readonly eventMatcher: SessionEventMatcher | undefined + readonly runGuard: (() => boolean) | undefined + readonly runTransitionFunction: + | (() => TransitionFunctionOutcome) + | undefined +} + +export type CandidateDecision = + | { readonly type: 'rejected' } + | { readonly type: 'admitted' } + | { readonly type: 'enabled'; readonly selection: TransitionSelection } + | { readonly type: 'disabled'; readonly selection: TransitionSelection } + +const admitsByDecisionType: Readonly< + Record['type'], boolean> +> = { + rejected: false, + admitted: true, + enabled: true, + disabled: false, +} + +export const isAdmitted = ( + decision: CandidateDecision, +): boolean => admitsByDecisionType[decision.type] + +const rejectedDecision: CandidateDecision = { type: 'rejected' } +const admittedDecision: CandidateDecision = { type: 'admitted' } + +const patternAccepts = ( + input: CandidateAdmissionInput, +): boolean => input.matches === undefined || matchesEvent(input.event, input.matches) + +const sessionAccepts = ( + input: CandidateAdmissionInput, +): boolean => + input.eventMatcher === undefined || + input.eventMatcher(input.event, input.snapshot) + +const guardAccepts = ( + input: CandidateAdmissionInput, +): boolean => input.runGuard === undefined || input.runGuard() + +const sessionAndGuardAccept = ( + input: CandidateAdmissionInput, +): boolean => sessionAccepts(input) && guardAccepts(input) + +const checksAccept = ( + input: CandidateAdmissionInput, +): boolean => patternAccepts(input) && sessionAndGuardAccept(input) + +const selectionDecision = ( + selection: TransitionSelection, +): CandidateDecision => + selection.type === 'absent' + ? { type: 'disabled', selection } + : { type: 'enabled', selection } + +const transitionDecision = ( + input: CandidateAdmissionInput, +): CandidateDecision => + input.runTransitionFunction === undefined + ? admittedDecision + : selectionDecision(selectTransition(input.runTransitionFunction())) + +export const admitCandidate = ( + input: CandidateAdmissionInput, +): CandidateDecision => checksAccept(input) ? transitionDecision(input) : rejectedDecision + +export interface AdmittedCandidate { + readonly candidate: TCandidate + readonly decision: CandidateDecision +} + +const admittedOf = ( + admit: (candidate: TCandidate) => CandidateDecision, + candidate: TCandidate, +): AdmittedCandidate | undefined => { + const decision = admit(candidate) + return isAdmitted(decision) ? { candidate, decision } : undefined +} + +export const firstAdmittedCandidate = (input: { + readonly candidates: readonly TCandidate[] + readonly admit: (candidate: TCandidate) => CandidateDecision +}): AdmittedCandidate | undefined => + input.candidates.reduce | undefined>( + (admitted, candidate) => admitted ?? admittedOf(input.admit, candidate), + undefined, + ) diff --git a/packages/xstate/src/utils.ts b/packages/xstate/src/utils.ts index f51f837..230dc80 100644 --- a/packages/xstate/src/utils.ts +++ b/packages/xstate/src/utils.ts @@ -1,7 +1,6 @@ import isDevelopment from '#is-development' import { TARGETLESS_KEY, WILDCARD } from './constants.js' import type { StateNode } from './StateNode.js' -import { isStateId } from './stateUtils.js' import type { AnyActor, AnyEventObject, @@ -17,24 +16,10 @@ import type { Observer, OutputArg, SingleOrArray, - StateValue, TransitionConfigTarget, } from './types.js' import { defaultWarn } from './warnSink.js' -/** @public */ -export function checkStateIn( - snapshot: AnyMachineSnapshot, - stateValue: StateValue, -) { - if (typeof stateValue === 'string' && isStateId(stateValue)) { - const target = snapshot.machine.getStateNodeById(stateValue) - return snapshot.nodes.some((sn) => sn === target) - } - - return snapshot.matches(stateValue) -} - export function mapValues>( collection: O, iteratee: (item: O[keyof O], key: keyof O, collection: O, i: number) => P, @@ -292,13 +277,6 @@ export function getAllOwnEvents(snapshot: AnyMachineSnapshot) { ) } -export function matchesEvent( - event: EventObject, - pattern: Record, -): boolean { - return Object.entries(pattern).every(([key, value]) => Object.is((event as AnyEventObject)[key], value)) -} - /** * Checks if an event type matches an event descriptor, supporting wildcards. * Event descriptors can be: diff --git a/packages/xstate/stryker.config.ts b/packages/xstate/stryker.config.ts index 80eb10a..213bf9e 100644 --- a/packages/xstate/stryker.config.ts +++ b/packages/xstate/stryker.config.ts @@ -12,4 +12,5 @@ export default packageStrykerConfig([ 'src/graph/pathFromEvents.ts', 'src/graph/shortestPaths.ts', 'src/graph/simplePaths.ts', + 'src/transitionGuards.ts', ]) From 529d193cda416ded01ba9c4a5410907df49229ba Mon Sep 17 00:00:00 2001 From: Ryan Lee Date: Fri, 9 Oct 2026 11:58:44 +0000 Subject: [PATCH 02/10] build(repo): enrol transitionGuards.ts in lint, tsgo and XS1 (WIP, parked) Adds src/transitionGuards.ts and the guards conformance spec path to the lint script, src/transitionGuards.ts to tsconfig.tsgo.json and to XS1's enrolled list, and regenerates the root API report for checkStateIn's data-last form. Parked by conductor ruling 217. --- packages/AGENTS.md | 8 ++--- packages/xstate/etc/xstate.api.md | 57 ++++++++++++++++-------------- packages/xstate/package.json | 2 +- packages/xstate/tsconfig.tsgo.json | 9 ++++- 4 files changed, 43 insertions(+), 33 deletions(-) diff --git a/packages/AGENTS.md b/packages/AGENTS.md index 323e157..edee06f 100644 --- a/packages/AGENTS.md +++ b/packages/AGENTS.md @@ -4,7 +4,7 @@ Root `AGENTS.md` governs; these are the facts no default gate reports. Forked from statelyai/xstate, branch `next` at `2146ae26ebfc7e6a624b3a1f237f9e6ddc30b9f5` (MIT). Members are owned (`REPO-O1`); upstream is a reference, never a merge source. Plan: systemfsoftware's `docs/plans/2026-10-05-2317-feat-xstate-v6-fork-plan.md`. Not forked (no consumer in this program): the Solid, Svelte, Vue, Angular and Preact bindings, `@xstate/codemod`, `@xstate/scxml`; core's `bin/xstate.js` only ran `@xstate/codemod` through `npx`, so it is not shipped. -| ID | Rule | Gate | -| ------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| **XS1** | A member gains `stryker.config.ts`, `oxlint.config.ts` (extending the root `oxlint.shared.ts`) and its `mutation` and `lint` scripts with its first capability PR, and its debt-ledger entry goes in that PR: `scripts/stryker-plan-gate.ts` (`exemption`) reads any `XS1` entry for a member as leave to schedule no mutants. Until its last capability PR, a member whose `src` tsconfig lacks `@effect/language-service` points `lint:tsgo` at `tsconfig.tsgo.json`, which turns the plugin on for the enrolled `src` files only. Each capability PR adds the `src` files it covers to `mutate`, to the `lint` script and to `tsconfig.tsgo.json`, with its test files, and fixes their diagnostics without disable comments. The last capability PR turns the plugin on for the whole `src` tsconfig and deletes `tsconfig.tsgo.json`. Remaining debt: in `@systemfsoftware/xstate`, every `src` file outside `mutate` is unmutated, unlinted and unchecked by the Effect plugin until its capability PR (enrolled: `src/assert.ts`, `src/stateMatching.ts`, `src/mapState.ts`, `src/fsm.ts`, `src/graph/`; the `lint` script and `tsconfig.tsgo.json` name the whole folder, and `mutate` names its seven files with runtime logic, leaving out `types.ts` and `index.ts`). | `review` — wrong: a PR mutates `src/assert.ts` and leaves it out of the `lint` script or `tsconfig.tsgo.json`, or keeps a ledger entry for an enrolled member; right: `mutate`, the `lint` script, `tsconfig.tsgo.json` and this row's enrolled list all name `src/assert.ts`, and `lint:tsgo` prints a nonzero `Checked N files` | -| **XS2** | The persisted snapshot wire format is upstream v6's (actor refs `{ xstate$type: 'actorRef', id }`, children by logical address, timers with `startedAt`); it changes only through a versioned machine migration. | `review` — wrong: an encoder emits `{ _tag: 'ActorRef', id }`; right: the encoder writes `{ xstate$type: 'actorRef', id }` and the tagged class exists only after decode | +| ID | Rule | Gate | +| ------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **XS1** | A member gains `stryker.config.ts`, `oxlint.config.ts` (extending the root `oxlint.shared.ts`) and its `mutation` and `lint` scripts with its first capability PR, and its debt-ledger entry goes in that PR: `scripts/stryker-plan-gate.ts` (`exemption`) reads any `XS1` entry for a member as leave to schedule no mutants. Until its last capability PR, a member whose `src` tsconfig lacks `@effect/language-service` points `lint:tsgo` at `tsconfig.tsgo.json`, which turns the plugin on for the enrolled `src` files only. Each capability PR adds the `src` files it covers to `mutate`, to the `lint` script and to `tsconfig.tsgo.json`, with its test files, and fixes their diagnostics without disable comments. The last capability PR turns the plugin on for the whole `src` tsconfig and deletes `tsconfig.tsgo.json`. Remaining debt: in `@systemfsoftware/xstate`, every `src` file outside `mutate` is unmutated, unlinted and unchecked by the Effect plugin until its capability PR (enrolled: `src/assert.ts`, `src/stateMatching.ts`, `src/mapState.ts`, `src/fsm.ts`, `src/graph/`, `src/transitionGuards.ts`; the `lint` script and `tsconfig.tsgo.json` name the whole folder, and `mutate` names its seven files with runtime logic, leaving out `types.ts` and `index.ts`). | `review` — wrong: a PR mutates `src/assert.ts` and leaves it out of the `lint` script or `tsconfig.tsgo.json`, or keeps a ledger entry for an enrolled member; right: `mutate`, the `lint` script, `tsconfig.tsgo.json` and this row's enrolled list all name `src/assert.ts`, and `lint:tsgo` prints a nonzero `Checked N files` | +| **XS2** | The persisted snapshot wire format is upstream v6's (actor refs `{ xstate$type: 'actorRef', id }`, children by logical address, timers with `startedAt`); it changes only through a versioned machine migration. | `review` — wrong: an encoder emits `{ _tag: 'ActorRef', id }`; right: the encoder writes `{ xstate$type: 'actorRef', id }` and the tagged class exists only after decode | diff --git a/packages/xstate/etc/xstate.api.md b/packages/xstate/etc/xstate.api.md index 0d46c31..03704bb 100644 --- a/packages/xstate/etc/xstate.api.md +++ b/packages/xstate/etc/xstate.api.md @@ -701,6 +701,9 @@ export interface CancelExecutableActionObject extends BaseExecutableActionObject // @public (undocumented) export type Cast = A extends B ? A : B; +// @public (undocumented) +export function checkStateIn(stateValue: StateValue): (snapshot: AnyMachineSnapshot) => boolean; + // @public (undocumented) export function checkStateIn(snapshot: AnyMachineSnapshot, stateValue: StateValue): boolean; @@ -3463,33 +3466,33 @@ export type WithRootSetupStateSchemas Date: Fri, 9 Oct 2026 16:05:27 +0000 Subject: [PATCH 03/10] refactor(repo): import isStateId and guard admission from transitionGuards.ts StateMachine.ts imports isStateId from transitionGuards.ts, so the re-export in stateUtils.ts is gone. The eventless walk calls admitTransitionCandidate through isAdmitted directly, so the evaluateCandidate pass-through is gone too. A changeset declares checkStateIn's data-last form, and the API reports are regenerated. --- .changeset/guards-capability.md | 5 + packages/xstate/etc/xstate-actors.api.md | 10 +- packages/xstate/etc/xstate-validation.api.md | 8 +- packages/xstate/src/StateMachine.ts | 2 +- packages/xstate/src/stateUtils.ts | 33 +- packages/xstate/test/guards.test.ts | 973 ------------------- packages/xstate/test/stateIn.test.ts | 552 ----------- 7 files changed, 24 insertions(+), 1559 deletions(-) create mode 100644 .changeset/guards-capability.md delete mode 100644 packages/xstate/test/guards.test.ts delete mode 100644 packages/xstate/test/stateIn.test.ts diff --git a/.changeset/guards-capability.md b/.changeset/guards-capability.md new file mode 100644 index 0000000..6fe72e3 --- /dev/null +++ b/.changeset/guards-capability.md @@ -0,0 +1,5 @@ +--- +"@systemfsoftware/xstate": minor +--- + +`checkStateIn(stateValue)` returns a function of the snapshot, for use in `pipe`; `checkStateIn(snapshot, stateValue)` is unchanged. diff --git a/packages/xstate/etc/xstate-actors.api.md b/packages/xstate/etc/xstate-actors.api.md index c1237a6..5b838c8 100644 --- a/packages/xstate/etc/xstate-actors.api.md +++ b/packages/xstate/etc/xstate-actors.api.md @@ -491,11 +491,11 @@ export class TimeoutError extends Error { // Warnings were encountered during analysis: // -// dist/index-DpYRYoBT.d.ts:36:5 - (ae-forgotten-export) The symbol "ActorSystemRuntime" needs to be exported by the entry point actors.d.ts -// dist/index-DpYRYoBT.d.ts:71:3 - (ae-forgotten-export) The symbol "LogicTransition" needs to be exported by the entry point actors.d.ts -// dist/index-DpYRYoBT.d.ts:171:3 - (ae-forgotten-export) The symbol "Receiver" needs to be exported by the entry point actors.d.ts -// dist/index-DpYRYoBT.d.ts:298:3 - (ae-forgotten-export) The symbol "Subscription" needs to be exported by the entry point actors.d.ts -// dist/index-DpYRYoBT.d.ts:362:3 - (ae-forgotten-export) The symbol "ObservableActor" needs to be exported by the entry point actors.d.ts +// dist/index-BMYfZluc.d.ts:36:5 - (ae-forgotten-export) The symbol "ActorSystemRuntime" needs to be exported by the entry point actors.d.ts +// dist/index-BMYfZluc.d.ts:71:3 - (ae-forgotten-export) The symbol "LogicTransition" needs to be exported by the entry point actors.d.ts +// dist/index-BMYfZluc.d.ts:171:3 - (ae-forgotten-export) The symbol "Receiver" needs to be exported by the entry point actors.d.ts +// dist/index-BMYfZluc.d.ts:298:3 - (ae-forgotten-export) The symbol "Subscription" needs to be exported by the entry point actors.d.ts +// dist/index-BMYfZluc.d.ts:362:3 - (ae-forgotten-export) The symbol "ObservableActor" needs to be exported by the entry point actors.d.ts // (No @packageDocumentation comment for this package) diff --git a/packages/xstate/etc/xstate-validation.api.md b/packages/xstate/etc/xstate-validation.api.md index 456e36d..5373e58 100644 --- a/packages/xstate/etc/xstate-validation.api.md +++ b/packages/xstate/etc/xstate-validation.api.md @@ -101,10 +101,10 @@ export interface StandardSchemaValidatorOptions { // Warnings were encountered during analysis: // -// dist/types-Dlyzn8C_.d.ts:873:3 - (ae-forgotten-export) The symbol "AnyActorLogic" needs to be exported by the entry point validation.d.ts -// dist/types-Dlyzn8C_.d.ts:878:3 - (ae-forgotten-export) The symbol "AnyEventObject" needs to be exported by the entry point validation.d.ts -// dist/types-Dlyzn8C_.d.ts:883:3 - (ae-forgotten-export) The symbol "Snapshot" needs to be exported by the entry point validation.d.ts -// dist/types-Dlyzn8C_.d.ts:884:3 - (ae-forgotten-export) The symbol "ExecutableActionObject" needs to be exported by the entry point validation.d.ts +// dist/types-DNQON1bi.d.ts:873:3 - (ae-forgotten-export) The symbol "AnyActorLogic" needs to be exported by the entry point validation.d.ts +// dist/types-DNQON1bi.d.ts:878:3 - (ae-forgotten-export) The symbol "AnyEventObject" needs to be exported by the entry point validation.d.ts +// dist/types-DNQON1bi.d.ts:883:3 - (ae-forgotten-export) The symbol "Snapshot" needs to be exported by the entry point validation.d.ts +// dist/types-DNQON1bi.d.ts:884:3 - (ae-forgotten-export) The symbol "ExecutableActionObject" needs to be exported by the entry point validation.d.ts // (No @packageDocumentation comment for this package) diff --git a/packages/xstate/src/StateMachine.ts b/packages/xstate/src/StateMachine.ts index e872d4c..29135e6 100644 --- a/packages/xstate/src/StateMachine.ts +++ b/packages/xstate/src/StateMachine.ts @@ -29,7 +29,6 @@ import { hasEffect, initialMicrostep, isInFinalState, - isStateId, macrostep, resolveStateValue, transitionNode, @@ -46,6 +45,7 @@ import { resolveActionsWithContext, takeSpawnAllocationCounters, } from './transitionActions.js' +import { isStateId } from './transitionGuards.js' import type { ActorLogic, ActorLogicTransitionResult, diff --git a/packages/xstate/src/stateUtils.ts b/packages/xstate/src/stateUtils.ts index 027e47d..08e10e8 100644 --- a/packages/xstate/src/stateUtils.ts +++ b/packages/xstate/src/stateUtils.ts @@ -33,7 +33,6 @@ import { import { admitCandidate, type CandidateDecision, - firstAdmittedCandidate, isAdmitted, isStateId, selectTransition, @@ -261,8 +260,6 @@ export function isInFinalState( return stateNode.type === 'final' } -export { isStateId } - function getLegacyEventType(event: EventObject): string | undefined { switch (event.type) { case 'xstate.done.actor': @@ -2918,7 +2915,15 @@ function selectEventlessTransitions( } for (const transition of stateNode.always) { if ( - evaluateCandidate(transition, event, snapshot, stateNode, actorScope) + isAdmitted( + admitTransitionCandidate( + transition, + event, + snapshot, + stateNode, + actorScope, + ), + ) ) { enabledTransitionSet.add(transition) break loop @@ -2990,23 +2995,3 @@ export function admitTransitionCandidate( return decision } - -export function evaluateCandidate( - candidate: AnyTransitionDefinition, - event: EventObject, - snapshot: AnyMachineSnapshot, - stateNode: AnyStateNode, - actorScope: AnyActorScope, - selectionResults?: TransitionSelectionResults, -): boolean { - return isAdmitted( - admitTransitionCandidate( - candidate, - event, - snapshot, - stateNode, - actorScope, - selectionResults, - ), - ) -} diff --git a/packages/xstate/test/guards.test.ts b/packages/xstate/test/guards.test.ts deleted file mode 100644 index 0e7ddea..0000000 --- a/packages/xstate/test/guards.test.ts +++ /dev/null @@ -1,973 +0,0 @@ -import { describe, expect, it } from 'vitest' -import z from 'zod' -import { createActor, createMachine, matchesState } from '../src/index.js' -import { type InferEvents } from '../src/types.v6.js' -import { trackEntries } from './utils.js' - -describe('guard conditions', () => { - function minTimeElapsed(elapsed: number) { - return elapsed >= 100 && elapsed < 200 - } - - const lightMachine = createMachine({ - schemas: { - input: z.object({ - elapsed: z.number().optional(), - }), - context: z.object({ - elapsed: z.number(), - }), - - events: { - TIMER: z.object({}), - EMERGENCY: z.object({ isEmergency: z.boolean() }), - TIMER_COND_OBJ: z.object({}), - }, - }, - context: ({ input = {} }) => ({ - elapsed: input.elapsed ?? 0, - }), - initial: 'green', - states: { - green: { - on: { - // TIMER: [ - // { - // target: 'green', - // guard: ({ context: { elapsed } }) => elapsed < 100 - // }, - // { - // target: 'yellow', - // guard: ({ context: { elapsed } }) => - // elapsed >= 100 && elapsed < 200 - // } - // ], - TIMER: ({ context: { elapsed } }) => { - if (elapsed < 100) { - return { target: 'green' } - } - if (elapsed >= 100 && elapsed < 200) { - return { target: 'yellow' } - } - return undefined - }, - // EMERGENCY: { - // target: 'red', - // guard: ({ event }) => !!event.isEmergency - // } - EMERGENCY: ({ event }) => { - if (event.isEmergency) { - return { target: 'red' } - } - return undefined - }, - }, - }, - yellow: { - on: { - // TIMER: { - // target: 'red', - // guard: 'minTimeElapsed' - // }, - TIMER: ({ context: { elapsed } }) => { - if (minTimeElapsed(elapsed)) { - return { target: 'red' } - } - return undefined - }, - // TIMER_COND_OBJ: { - // target: 'red', - // guard: { - // type: 'minTimeElapsed' - // } - // } - TIMER_COND_OBJ: ({ context: { elapsed } }) => { - if (minTimeElapsed(elapsed)) { - return { target: 'red' } - } - return undefined - }, - }, - }, - red: { - on: { - // BAD_COND: { - // target: 'red', - // guard: 'doesNotExist' - // } - }, - }, - }, - }) - - it('should transition only if condition is met', () => { - const actorRef1 = createActor(lightMachine, { - input: { elapsed: 50 }, - }).start() - actorRef1.send({ type: 'TIMER' }) - expect(actorRef1.getSnapshot().value).toEqual('green') - - const actorRef2 = createActor(lightMachine, { - input: { elapsed: 120 }, - }).start() - actorRef2.send({ type: 'TIMER' }) - expect(actorRef2.getSnapshot().value).toEqual('yellow') - }) - - it('should transition if condition based on event is met', () => { - const actorRef = createActor(lightMachine, { input: {} }).start() - actorRef.send({ - type: 'EMERGENCY', - isEmergency: true, - }) - expect(actorRef.getSnapshot().value).toEqual('red') - }) - - it('should not transition if condition based on event is not met', () => { - const actorRef = createActor(lightMachine, { input: {} }).start() - actorRef.send({ - type: 'EMERGENCY', - isEmergency: false, - }) - expect(actorRef.getSnapshot().value).toEqual('green') - }) - - it('should not transition if no condition is met', () => { - const machine = createMachine({ - schemas: { - events: { - TIMER: z.object({ elapsed: z.number() }), - }, - }, - initial: 'a', - states: { - a: { - on: { - TIMER: ({ event: { elapsed } }) => { - if (elapsed > 200) { - return { target: 'b' } - } - if (elapsed > 100) { - return { target: 'c' } - } - return undefined - }, - }, - }, - b: {}, - c: {}, - }, - }) - - const flushTracked = trackEntries(machine) - const actor = createActor(machine).start() - flushTracked() - - actor.send({ type: 'TIMER', elapsed: 10 }) - - expect(actor.getSnapshot().value).toBe('a') - expect(flushTracked()).toEqual([]) - }) - - it('should work with defined string transitions', () => { - const actorRef = createActor(lightMachine, { - input: { elapsed: 120 }, - }).start() - actorRef.send({ - type: 'TIMER', - }) - expect(actorRef.getSnapshot().value).toEqual('yellow') - actorRef.send({ - type: 'TIMER', - }) - expect(actorRef.getSnapshot().value).toEqual('red') - }) - - it('should work with guard objects', () => { - const actorRef = createActor(lightMachine, { - input: { elapsed: 150 }, - }).start() - actorRef.send({ - type: 'TIMER', - }) - expect(actorRef.getSnapshot().value).toEqual('yellow') - actorRef.send({ - type: 'TIMER_COND_OBJ', - }) - expect(actorRef.getSnapshot().value).toEqual('red') - }) - - it('should work with defined string transitions (condition not met)', () => { - const minTimeElapsed = (elapsed: number) => elapsed >= 100 && elapsed < 200 - - const machine = createMachine({ - // types: {} as { context: LightMachineCtx; events: LightMachineEvents }, - schemas: { - context: z.object({ - elapsed: z.number(), - }), - events: { - TIMER: z.object({}), - EMERGENCY: z.object({ isEmergency: z.boolean() }), - }, - }, - context: { - elapsed: 10, - }, - initial: 'yellow', - states: { - green: { - on: { - // TIMER: [ - // { - // target: 'green', - // guard: ({ context: { elapsed } }) => elapsed < 100 - // }, - // { - // target: 'yellow', - // guard: ({ context: { elapsed } }) => - // elapsed >= 100 && elapsed < 200 - // } - // ], - TIMER: ({ context: { elapsed } }) => { - if (elapsed < 100) { - return { target: 'green' } - } - if (elapsed >= 100 && elapsed < 200) { - return { target: 'yellow' } - } - return undefined - }, - // EMERGENCY: { - // target: 'red', - // guard: ({ event }) => !!event.isEmergency - // } - EMERGENCY: ({ event }) => { - if (event.isEmergency) { - return { target: 'red' } - } - return undefined - }, - }, - }, - yellow: { - on: { - // TIMER: { - // target: 'red', - // guard: 'minTimeElapsed' - // } - TIMER: ({ context: { elapsed } }) => { - if (minTimeElapsed(elapsed)) { - return { target: 'red' } - } - return undefined - }, - }, - }, - red: {}, - }, - }) - - const actorRef = createActor(machine).start() - actorRef.send({ - type: 'TIMER', - }) - - expect(actorRef.getSnapshot().value).toEqual('yellow') - }) - - it('should guard against transition', () => { - const machine = createMachine({ - type: 'parallel', - states: { - A: { - initial: 'A2', - states: { - A0: {}, - A2: {}, - }, - }, - B: { - initial: 'B0', - states: { - B0: { - // always: [ - // { - // target: 'B4', - // guard: () => false - // } - // ], - always: () => { - if (1 + 1 !== 2) { - return { target: 'B4' } - } - return undefined - }, - on: { - // T1: [ - // { - // target: 'B1', - // guard: () => false - // } - // ] - T1: () => { - if (1 + 1 !== 2) { - return { target: 'B1' } - } - return undefined - }, - }, - }, - B1: {}, - B4: {}, - }, - }, - }, - }) - - const actorRef = createActor(machine).start() - actorRef.send({ type: 'T1' }) - - expect(actorRef.getSnapshot().value).toEqual({ - A: 'A2', - B: 'B0', - }) - }) - - it('should allow a matching transition', () => { - const machine = createMachine({ - type: 'parallel', - states: { - A: { - initial: 'A2', - states: { - A0: {}, - A2: {}, - }, - }, - B: { - initial: 'B0', - states: { - B0: { - // always: [ - // { - // target: 'B4', - // guard: () => false - // } - // ], - always: () => { - if (1 + 1 !== 2) { - return { target: 'B4' } - } - return undefined - }, - on: { - // T2: [ - // { - // target: 'B2', - // guard: stateIn('A.A2') - // } - // ] - T2: ({ value }) => { - if (matchesState('A.A2', value)) { - return { target: 'B2' } - } - return undefined - }, - }, - }, - B1: {}, - B2: {}, - B4: {}, - }, - }, - }, - }) - - const actorRef = createActor(machine).start() - actorRef.send({ type: 'T2' }) - - expect(actorRef.getSnapshot().value).toEqual({ - A: 'A2', - B: 'B2', - }) - }) - - it('should check guards with interim states', () => { - const machine = createMachine({ - type: 'parallel', - states: { - A: { - initial: 'A2', - states: { - A2: { - on: { - A: { target: 'A3' }, - }, - }, - A3: { - always: { target: 'A4' }, - }, - A4: { - always: { target: 'A5' }, - }, - A5: {}, - }, - }, - B: { - initial: 'B0', - states: { - B0: { - // always: [ - // { - // target: 'B4', - // guard: stateIn('A.A4') - // } - // ] - always: ({ value }) => { - if (matchesState('A.A4', value)) { - return { target: 'B4' } - } - return undefined - }, - }, - B4: {}, - }, - }, - }, - }) - - const actorRef = createActor(machine).start() - actorRef.send({ type: 'A' }) - - expect(actorRef.getSnapshot().value).toEqual({ - A: 'A5', - B: 'B4', - }) - }) -}) - -describe('[function] guard conditions', () => { - const minTimeElapsed = (elapsed: number) => elapsed >= 100 && elapsed < 200 - - const lightMachine = createMachine({ - // types: {} as { - // input: { elapsed?: number }; - // context: LightMachineCtx; - // events: LightMachineEvents; - // }, - schemas: { - input: z.object({ - elapsed: z.number().optional(), - }), - context: z.object({ - elapsed: z.number(), - }), - events: { - TIMER: z.object({}), - TIMER_COND_OBJ: z.object({}), - EMERGENCY: z.object({ isEmergency: z.boolean() }), - }, - }, - context: ({ input = {} }) => ({ - elapsed: input.elapsed ?? 0, - }), - initial: 'green', - states: { - green: { - on: { - TIMER: ({ context }) => { - if (context.elapsed < 100) { - return { target: 'green' } - } - if (context.elapsed >= 100 && context.elapsed < 200) { - return { target: 'yellow' } - } - return undefined - }, - EMERGENCY: ({ event }) => event.isEmergency ? { target: 'red' } : undefined, - }, - }, - yellow: { - on: { - TIMER: ({ context }) => minTimeElapsed(context.elapsed) ? { target: 'red' } : undefined, - - TIMER_COND_OBJ: ({ context }) => minTimeElapsed(context.elapsed) ? { target: 'red' } : undefined, - }, - }, - red: {}, - }, - }) - - it('should transition only if condition is met', () => { - const actorRef1 = createActor(lightMachine, { - input: { elapsed: 50 }, - }).start() - actorRef1.send({ type: 'TIMER' }) - expect(actorRef1.getSnapshot().value).toEqual('green') - - const actorRef2 = createActor(lightMachine, { - input: { elapsed: 120 }, - }).start() - actorRef2.send({ type: 'TIMER' }) - expect(actorRef2.getSnapshot().value).toEqual('yellow') - }) - - it('should transition if condition based on event is met', () => { - const actorRef = createActor(lightMachine, { input: {} }).start() - actorRef.send({ - type: 'EMERGENCY', - isEmergency: true, - }) - expect(actorRef.getSnapshot().value).toEqual('red') - }) - - it('should not transition if condition based on event is not met', () => { - const actorRef = createActor(lightMachine, { input: {} }).start() - actorRef.send({ - type: 'EMERGENCY', - isEmergency: false, - }) - expect(actorRef.getSnapshot().value).toEqual('green') - }) - - it('should not transition if no condition is met', () => { - const machine = createMachine({ - schemas: { - events: { - TIMER: z.object({ elapsed: z.number() }), - }, - }, - initial: 'a', - states: { - a: { - on: { - TIMER: ({ event }) => ({ - ...(event.elapsed > 200 - ? { target: 'b' } - : event.elapsed > 100 - ? { target: 'c' } - : {}), - }), - }, - }, - b: {}, - c: {}, - }, - }) - - const flushTracked = trackEntries(machine) - const actor = createActor(machine).start() - flushTracked() - - actor.send({ type: 'TIMER', elapsed: 10 }) - - expect(actor.getSnapshot().value).toBe('a') - expect(flushTracked()).toEqual([]) - }) - - it('should work with defined string transitions', () => { - const actorRef = createActor(lightMachine, { - input: { elapsed: 120 }, - }).start() - actorRef.send({ - type: 'TIMER', - }) - expect(actorRef.getSnapshot().value).toEqual('yellow') - actorRef.send({ - type: 'TIMER', - }) - expect(actorRef.getSnapshot().value).toEqual('red') - }) - - it('should work with guard objects', () => { - const actorRef = createActor(lightMachine, { - input: { elapsed: 150 }, - }).start() - actorRef.send({ - type: 'TIMER', - }) - expect(actorRef.getSnapshot().value).toEqual('yellow') - actorRef.send({ - type: 'TIMER_COND_OBJ', - }) - expect(actorRef.getSnapshot().value).toEqual('red') - }) - - it('should work with defined string transitions (condition not met)', () => { - const machine = createMachine({ - // types: {} as { context: LightMachineCtx; events: LightMachineEvents }, - schemas: { - context: z.object({ - elapsed: z.number(), - }), - events: { - TIMER: z.object({}), - EMERGENCY: z.object({ isEmergency: z.boolean() }), - }, - }, - context: { - elapsed: 10, - }, - initial: 'yellow', - states: { - green: { - on: { - TIMER: ({ context }) => ({ - ...(context.elapsed < 100 - ? { target: 'green' } - : context.elapsed >= 100 && context.elapsed < 200 - ? { target: 'yellow' } - : {}), - }), - EMERGENCY: ({ event }) => ({ - ...(event.isEmergency ? { target: 'red' } : {}), - }), - }, - }, - yellow: { - on: { - TIMER: ({ context }) => ({ - ...(minTimeElapsed(context.elapsed) ? { target: 'red' } : {}), - }), - }, - }, - red: {}, - }, - }) - - const actorRef = createActor(machine).start() - actorRef.send({ - type: 'TIMER', - }) - - expect(actorRef.getSnapshot().value).toEqual('yellow') - }) - - it.skip('should allow a matching transition', () => { - const machine = createMachine({ - type: 'parallel', - states: { - A: { - initial: 'A2', - states: { - A0: {}, - A2: {}, - }, - }, - B: { - initial: 'B0', - states: { - B0: { - // always: [ - // { - // target: 'B4', - // guard: () => false - // } - // ], - always: () => { - if (1 + 1 !== 2) { - return { target: 'B4' } - } - return undefined - }, - on: { - // T2: [ - // { - // target: 'B2', - // guard: stateIn('A.A2') - // } - // ] - T2: ({ value }) => { - if (matchesState('A.A2', value)) { - return { target: 'B2' } - } - return undefined - }, - }, - }, - B1: {}, - B2: {}, - B4: {}, - }, - }, - }, - }) - - const actorRef = createActor(machine).start() - actorRef.send({ type: 'T2' }) - - expect(actorRef.getSnapshot().value).toEqual({ - A: 'A2', - B: 'B2', - }) - }) - - it.skip('should check guards with interim states', () => { - const machine = createMachine({ - type: 'parallel', - states: { - A: { - initial: 'A2', - states: { - A2: { - on: { - A: { target: 'A3' }, - }, - }, - A3: { - always: { target: 'A4' }, - }, - A4: { - always: { target: 'A5' }, - }, - A5: {}, - }, - }, - B: { - initial: 'B0', - states: { - B0: { - // always: [ - // { - // target: 'B4', - // guard: stateIn('A.A4') - // } - // ] - always: ({ value }) => { - if (matchesState('A.A4', value)) { - return { target: 'B4' } - } - return undefined - }, - }, - B4: {}, - }, - }, - }, - }) - - const actorRef = createActor(machine).start() - actorRef.send({ type: 'A' }) - - expect(actorRef.getSnapshot().value).toEqual({ - A: 'A5', - B: 'B4', - }) - }) -}) - -describe('custom guards', () => { - it('should evaluate custom guards', () => { - const contextSchema = z.object({ - count: z.number(), - }) - const eventSchema = { - EVENT: z.object({ value: z.number() }), - } - - function customGuard( - context: z.infer, - event: InferEvents, - params: { - prop: keyof z.infer - op: 'greaterThan' - compare: number - }, - ) { - const { prop, compare, op } = params - if (op === 'greaterThan') { - return context[prop] + event.value > compare - } - - return false - } - const machine = createMachine({ - // types: {} as { - // context: Ctx; - // events: Events; - // guards: { - // type: 'custom'; - // params: { - // prop: keyof Ctx; - // op: 'greaterThan'; - // compare: number; - // }; - // }; - // }, - schemas: { - context: contextSchema, - events: eventSchema, - }, - initial: 'inactive', - context: { - count: 0, - }, - states: { - inactive: { - on: { - // EVENT: { - // target: 'active', - // guard: { - // type: 'custom', - // params: { prop: 'count', op: 'greaterThan', compare: 3 } - // } - // } - EVENT: ({ context, event }) => { - if ( - customGuard(context, event, { - prop: 'count', - op: 'greaterThan', - compare: 3, - }) - ) { - return { target: 'active' } - } - return undefined - }, - }, - }, - active: {}, - }, - }) - - const actorRef1 = createActor(machine).start() - actorRef1.send({ type: 'EVENT', value: 4 }) - const passState = actorRef1.getSnapshot() - - expect(passState.value).toEqual('active') - - const actorRef2 = createActor(machine).start() - actorRef2.send({ type: 'EVENT', value: 3 }) - const failState = actorRef2.getSnapshot() - - expect(failState.value).toEqual('inactive') - }) -}) - -describe('guards - other', () => { - it('should allow for a fallback target to be a simple string', () => { - const machine = createMachine({ - initial: 'a', - states: { - a: { - on: { - // EVENT: [{ target: 'b', guard: () => false }, 'c'] - EVENT: () => { - if (1 + 1 !== 2) { - return { target: 'b' } - } - return { target: 'c' } - }, - }, - }, - b: {}, - c: {}, - }, - }) - - const actor = createActor(machine).start() - actor.send({ type: 'EVENT' }) - - expect(actor.getSnapshot().value).toBe('c') - }) -}) - -describe('guards - unknown references', () => { - it('should throw on a guard reference that is not implemented', () => { - const machine = createMachine({ - context: { ready: false }, - guards: { - isReady: (ready: boolean) => ready === true, - }, - initial: 'routing', - states: { - routing: { - always: ({ context, guards }) => { - if ( - guards - // @ts-expect-error - .isRedy(context.ready) - ) { - return { target: 'go' } - } - return { target: 'wait' } - }, - }, - go: {}, - wait: {}, - }, - }) - - const actor = createActor(machine) - actor.subscribe({ error: () => {} }) - actor.start() - - const snapshot = actor.getSnapshot() - expect(snapshot.status).toBe('error') - expect((snapshot as any).error.message).toMatch( - /guards.isRedy is not a function/, - ) - }) -}) - -describe('guards - plain function sources', () => { - it('passes only the caller-supplied params to the source', () => { - const received: unknown[][] = [] - const machine = createMachine({ - context: { count: 5 }, - guards: { - isAbove: (count: number, threshold: number) => { - received.push([count, threshold]) - return count > threshold - }, - }, - initial: 'a', - states: { - a: { - on: { - EV: ({ context, guards }) => { - if (guards.isAbove(context.count, 3)) { - return { target: 'b' } - } - return undefined - }, - }, - }, - b: {}, - }, - }) - - const actor = createActor(machine).start() - actor.send({ type: 'EV' }) - - expect(actor.getSnapshot().value).toBe('b') - expect(received).toEqual([[5, 3]]) - }) - - it('supports zero-param guards', () => { - const machine = createMachine({ - guards: { - isEnabled: () => true, - }, - initial: 'a', - states: { - a: { - on: { - EV: ({ guards }) => { - if (guards.isEnabled()) { - return { target: 'b' } - } - return undefined - }, - }, - }, - b: {}, - }, - }) - - const actor = createActor(machine).start() - actor.send({ type: 'EV' }) - expect(actor.getSnapshot().value).toBe('b') - }) -}) diff --git a/packages/xstate/test/stateIn.test.ts b/packages/xstate/test/stateIn.test.ts deleted file mode 100644 index 7956daa..0000000 --- a/packages/xstate/test/stateIn.test.ts +++ /dev/null @@ -1,552 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' -import { checkStateIn, createActor, createMachine, matchesState, type StateValue } from '../src/index.js' - -describe('transition "in" check', () => { - it('should transition if string state path matches current state value', () => { - const machine = createMachine({ - type: 'parallel', - states: { - a: { - initial: 'a1', - states: { - a1: { - on: { - EVENT2: ({ value }) => { - if (matchesState({ b: 'b2' }, value)) { - return { target: 'a2' } - } - return undefined - }, - }, - }, - a2: { - id: 'a_a2', - }, - }, - }, - b: { - initial: 'b2', - states: { - b1: { - on: { - // EVENT: { - // target: 'b2', - // guard: stateIn('#a_a2') - // } - }, - }, - b2: { - id: 'b_b2', - type: 'parallel', - states: { - foo: { - initial: 'foo2', - states: { - foo1: {}, - foo2: {}, - }, - }, - bar: { - initial: 'bar1', - states: { - bar1: { - id: 'bar1', - }, - bar2: {}, - }, - }, - }, - }, - }, - }, - }, - }) - const actorRef = createActor(machine).start() - actorRef.send({ type: 'EVENT2' }) - - expect(actorRef.getSnapshot().value).toEqual({ - a: 'a2', - b: { - b2: { - foo: 'foo2', - bar: 'bar1', - }, - }, - }) - }) - - it('should transition if state node ID matches current state value', () => { - const machine = createMachine({ - type: 'parallel', - states: { - a: { - initial: 'a1', - states: { - a1: { - on: { - // EVENT3: { - // target: 'a2', - // guard: stateIn('#b_b2') - // } - EVENT3: ({ self }) => { - if (checkStateIn(self.getSnapshot(), '#b_b2')) { - return { target: 'a2' } - } - return undefined - // if (matchesState('#b_b2', value)) { - // return { target: 'a2' }; - // } - }, - }, - }, - a2: { - id: 'a_a2', - }, - }, - }, - b: { - initial: 'b2', - states: { - b1: {}, - b2: { - id: 'b_b2', - type: 'parallel', - states: { - foo: { - initial: 'foo2', - states: { - foo1: {}, - foo2: {}, - }, - }, - bar: { - initial: 'bar1', - states: { - bar1: { - id: 'bar1', - }, - bar2: {}, - }, - }, - }, - }, - }, - }, - }, - }) - const actorRef = createActor(machine).start() - actorRef.send({ type: 'EVENT3' }) - - expect(actorRef.getSnapshot().value).toEqual({ - a: 'a2', - b: { - b2: { - foo: 'foo2', - bar: 'bar1', - }, - }, - }) - }) - - it('should not transition if string state path does not match current state value', () => { - const machine = createMachine({ - type: 'parallel', - states: { - a: { - initial: 'a1', - states: { - a1: { - on: { - // EVENT1: { - // target: 'a2', - // guard: stateIn('b.b2') - // } - EVENT1: ({ value }) => { - if (matchesState('b.b2', value)) { - return { target: 'a2' } - } - return undefined - // if (checkStateIn(self.getSnapshot(), 'b.b2')) { - // return { target: 'a2' }; - // } - }, - }, - }, - a2: { - id: 'a_a2', - }, - }, - }, - b: { - initial: 'b1', - states: { - b1: {}, - b2: { - id: 'b_b2', - type: 'parallel', - states: { - foo: { - initial: 'foo1', - states: { - foo1: {}, - foo2: {}, - }, - }, - bar: { - initial: 'bar1', - states: { - bar1: { - id: 'bar1', - }, - bar2: {}, - }, - }, - }, - }, - }, - }, - }, - }) - const actorRef = createActor(machine).start() - actorRef.send({ type: 'EVENT1' }) - - expect(actorRef.getSnapshot().value).toEqual({ - a: 'a1', - b: 'b1', - }) - }) - - it('should not transition if state value matches current state value', () => { - const machine = createMachine({ - type: 'parallel', - states: { - a: { - initial: 'a1', - states: { - a1: { - on: { - // EVENT2: { - // target: 'a2', - // guard: stateIn({ b: 'b2' }) - // } - EVENT2: ({ value }) => { - if (matchesState({ b: 'b2' }, value)) { - return { target: 'a2' } - } - return undefined - }, - }, - }, - a2: { - id: 'a_a2', - }, - }, - }, - b: { - initial: 'b2', - states: { - b1: {}, - b2: { - id: 'b_b2', - type: 'parallel', - states: { - foo: { - initial: 'foo2', - states: { - foo1: {}, - foo2: {}, - }, - }, - bar: { - initial: 'bar1', - states: { - bar1: { - id: 'bar1', - }, - bar2: {}, - }, - }, - }, - }, - }, - }, - }, - }) - const actorRef = createActor(machine).start() - actorRef.send({ type: 'EVENT2' }) - - expect(actorRef.getSnapshot().value).toEqual({ - a: 'a2', - b: { - b2: { - foo: 'foo2', - bar: 'bar1', - }, - }, - }) - }) - - it('matching should be relative to grandparent (match)', () => { - const machine = createMachine({ - type: 'parallel', - states: { - a: { - initial: 'a1', - states: { - a1: {}, - a2: { - id: 'a_a2', - }, - }, - }, - b: { - initial: 'b2', - states: { - b1: {}, - b2: { - id: 'b_b2', - type: 'parallel', - states: { - foo: { - initial: 'foo1', - states: { - foo1: { - on: { - EVENT_DEEP: ({ self }) => { - if (checkStateIn(self.getSnapshot(), '#bar1')) { - return { target: 'foo2' } - } - return undefined - }, - }, - }, - foo2: {}, - }, - }, - bar: { - initial: 'bar1', - states: { - bar1: { - id: 'bar1', - }, - bar2: {}, - }, - }, - }, - }, - }, - }, - }, - }) - const actorRef = createActor(machine).start() - actorRef.send({ type: 'EVENT_DEEP' }) - - expect(actorRef.getSnapshot().value).toEqual({ - a: 'a1', - b: { - b2: { - foo: 'foo2', - bar: 'bar1', - }, - }, - }) - }) - - it('matching should be relative to grandparent (no match)', () => { - const machine = createMachine({ - type: 'parallel', - states: { - a: { - initial: 'a1', - states: { - a1: {}, - a2: { - id: 'a_a2', - }, - }, - }, - b: { - initial: 'b2', - states: { - b1: {}, - b2: { - id: 'b_b2', - type: 'parallel', - states: { - foo: { - initial: 'foo1', - states: { - foo1: { - on: { - EVENT_DEEP: ({ self }) => { - if (checkStateIn(self.getSnapshot(), '#bar1')) { - return { target: 'foo2' } - } - return undefined - }, - }, - }, - foo2: {}, - }, - }, - bar: { - initial: 'bar2', - states: { - bar1: { - id: 'bar1', - }, - bar2: {}, - }, - }, - }, - }, - }, - }, - }, - }) - const actorRef = createActor(machine).start() - actorRef.send({ type: 'EVENT_DEEP' }) - - expect(actorRef.getSnapshot().value).toEqual({ - a: 'a1', - b: { - b2: { - foo: 'foo1', - bar: 'bar2', - }, - }, - }) - }) - - it('should work to forbid events', () => { - const machine = createMachine({ - initial: 'green', - states: { - green: { on: { TIMER: { target: 'yellow' } } }, - yellow: { on: { TIMER: { target: 'red' } } }, - red: { - initial: 'walk', - states: { - walk: { - on: { TIMER: { target: 'wait' } }, - }, - wait: { - on: { TIMER: { target: 'stop' } }, - }, - stop: {}, - }, - on: { - // TIMER: [ - // { - // target: 'green', - // guard: stateIn({ red: 'stop' }) - // } - // ] - TIMER: ({ value }) => { - if (matchesState({ red: 'stop' }, value)) { - return { target: 'green' } - } - return undefined - }, - }, - }, - }, - }) - - const actorRef = createActor(machine).start() - - actorRef.send({ type: 'TIMER' }) - actorRef.send({ type: 'TIMER' }) - actorRef.send({ type: 'TIMER' }) - expect(actorRef.getSnapshot().value).toEqual({ red: 'wait' }) - - actorRef.send({ type: 'TIMER' }) - expect(actorRef.getSnapshot().value).toEqual({ red: 'stop' }) - - actorRef.send({ type: 'TIMER' }) - expect(actorRef.getSnapshot().value).toEqual('green') - }) - - it('should be possible to use a referenced `stateIn` guard', () => { - const machine = createMachine({ - type: 'parallel', - guards: { - // hasSelection: stateIn('selected') - hasSelection: (value: StateValue) => { - return matchesState('selected', value) - }, - }, - // machine definition, - states: { - selected: {}, - location: { - initial: 'home', - states: { - home: { - on: { - NEXT: ({ guards, value }) => { - if (guards.hasSelection(value)) { - return { - target: 'success', - } - } - return undefined - }, - }, - }, - success: {}, - }, - }, - }, - }) - - const actor = createActor(machine).start() - actor.send({ - type: 'NEXT', - }) - expect(actor.getSnapshot().value).toEqual({ - selected: {}, - location: 'success', - }) - }) - - it.skip('should be possible to check an ID with a path', () => { - const spy = vi.fn() - const machine = createMachine({ - type: 'parallel', - states: { - A: { - initial: 'A1', - states: { - A1: { - on: { - // MY_EVENT: { - // guard: stateIn('#b.B1'), - // actions: spy - // } - MY_EVENT: ({ value }, enq) => { - if (matchesState('#b.B1', value)) { - enq(spy) - } - }, - }, - }, - }, - }, - B: { - id: 'b', - initial: 'B1', - states: { - B1: {}, - }, - }, - }, - }) - - createActor(machine).start().send({ - type: 'MY_EVENT', - }) - - expect(spy).toHaveBeenCalledTimes(1) - }) -}) From e7063e9e82ee00c291f2077ee6c260287569d379 Mon Sep 17 00:00:00 2001 From: Ryan Lee Date: Fri, 9 Oct 2026 16:05:28 +0000 Subject: [PATCH 04/10] test(repo): test xstate's guard evaluation as a conformance spec tests/guards.conformance.test.ts checks guard evaluation against a hand-written admission model. The model covers transition functions, string targets, event patterns, guards sources from createMachine, setup and provide, inStates via matchesState, effect-only outcomes, the walk up ancestors, parallel regions with a root fallback, and eventless steps that read the value one region reached earlier in the same macrostep. Each step also checks checkStateIn, data-first and data-last, against the active ids. Two planted subjects show the check rejects a rejection written as {} and an effect outcome that enqueues nothing. The fixed scenarios cover a missing guard source, a throwing transition function, the exact guard arguments, checkStateIn's truth table and the old suite's custom-guard and interim-state machines. test/guards.test.ts and test/stateIn.test.ts are deleted and leave both unguarded lists. --- packages/unguarded-tests.json | 2 - .../xstate/tests/__fixtures__/guards.model.ts | 430 ++++++++++++++++ packages/xstate/tests/__fixtures__/guards.ts | 462 +++++++++++++++++ .../xstate/tests/guards.conformance.test.ts | 472 ++++++++++++++++++ packages/xstate/vitest.config.ts | 2 - 5 files changed, 1364 insertions(+), 4 deletions(-) create mode 100644 packages/xstate/tests/__fixtures__/guards.model.ts create mode 100644 packages/xstate/tests/__fixtures__/guards.ts create mode 100644 packages/xstate/tests/guards.conformance.test.ts diff --git a/packages/unguarded-tests.json b/packages/unguarded-tests.json index 391b12b..4ba0fc4 100644 --- a/packages/unguarded-tests.json +++ b/packages/unguarded-tests.json @@ -93,7 +93,6 @@ "packages/xstate/test/examples.cd.test.ts", "packages/xstate/test/final.test.ts", "packages/xstate/test/firstTenMinutes.v6.test.ts", - "packages/xstate/test/guards.test.ts", "packages/xstate/test/history.test.ts", "packages/xstate/test/id.test.ts", "packages/xstate/test/initial.test.ts", @@ -149,7 +148,6 @@ "packages/xstate/test/spawn.types.test.ts", "packages/xstate/test/spawnChild.test.ts", "packages/xstate/test/state.test.ts", - "packages/xstate/test/stateIn.test.ts", "packages/xstate/test/stateInput.persistence.test.ts", "packages/xstate/test/stateInput.test.ts", "packages/xstate/test/strictTargets.types.test.ts", diff --git a/packages/xstate/tests/__fixtures__/guards.model.ts b/packages/xstate/tests/__fixtures__/guards.model.ts new file mode 100644 index 0000000..c188ef8 --- /dev/null +++ b/packages/xstate/tests/__fixtures__/guards.model.ts @@ -0,0 +1,430 @@ +import { Match, Schema } from 'effect' + +export const NODE_KEYS = ['root', 'L', 'R', 'p', 'c1', 'c2', 'q', 'r1', 'r2'] as const +export type NodeKey = (typeof NODE_KEYS)[number] + +const IN_PATHS = ['L.p.c1', 'L.p.c2', 'L.q', 'R.r1', 'R.r2'] as const +type InPath = (typeof IN_PATHS)[number] + +export type SlotTag = 'Absent' | 'Target' | 'Pattern' | 'Fn' + +export const GuardsValueSchema = Schema.Struct({ + L: Schema.Union([Schema.Literal('q'), Schema.Struct({ p: Schema.Literals(['c1', 'c2']) })]), + R: Schema.Literals(['r1', 'r2']), +}) +export type GuardsValue = Schema.Schema.Type + +export const GuardsContextSchema = Schema.Struct({ n: Schema.Finite }) + +export interface GuardsContext { + readonly n: number +} + +export interface GuardsEffectLike { + readonly type: string +} + +export interface GuardsEffect extends GuardsEffectLike { + readonly type: 'effect' + readonly at: NodeKey +} + +interface Sim { + readonly value: GuardsValue + readonly n: number +} + +const INITIAL_SIM: Sim = { value: { L: { p: 'c1' }, R: 'r1' }, n: 0 } + +const Bump = Schema.Literals([0, 1]) +const Zero = Schema.Literals([0]) +const K = Schema.Literals([0, 1, 2, 3]) +const T = Schema.Literals([1, 2, 3]) +const AllTargets = Schema.Literals(['#c1', '#c2', '#p', '#q', '#r1', '#r2']) +const LTargets = Schema.Literals(['#c1', '#c2', '#p', '#q']) +const RTargets = Schema.Literals(['#r1', '#r2']) +const C2Targets = Schema.Literals(['#c1', '#q']) + +const Evaluator = Schema.Union([ + Schema.TaggedStruct('Always', {}), + Schema.TaggedStruct('Never', {}), + Schema.TaggedStruct('ContextAtLeast', { t: T }), + Schema.TaggedStruct('PayloadAtLeast', { t: T }), + Schema.TaggedStruct('Named', { t: T }), + Schema.TaggedStruct('In', { path: Schema.Literals(IN_PATHS) }), +]) +export type Evaluator = Schema.Schema.Type + +const StayBump = Schema.TaggedStruct('Stay', { bump: Bump }) +const StayZero = Schema.TaggedStruct('Stay', { bump: Zero }) +const EffectOutcome = Schema.TaggedStruct('Effect', {}) +const GoAll = Schema.TaggedStruct('Go', { target: AllTargets, bump: Bump }) +const GoL = Schema.TaggedStruct('Go', { target: LTargets, bump: Bump }) +const GoR = Schema.TaggedStruct('Go', { target: RTargets, bump: Zero }) +const GoC2 = Schema.TaggedStruct('Go', { target: C2Targets, bump: Zero }) + +export const Slot = Schema.Union([ + Schema.TaggedStruct('Absent', {}), + Schema.TaggedStruct('Target', { target: AllTargets }), + Schema.TaggedStruct('Pattern', { k: K, target: AllTargets }), + Schema.TaggedStruct('Fn', { when: Evaluator, outcome: Schema.Union([GoAll, StayBump, EffectOutcome]) }), +]) +export type Slot = Schema.Schema.Type + +const Absent = Schema.TaggedStruct('Absent', {}) +const TargetL = Schema.TaggedStruct('Target', { target: LTargets }) +const PatternL = Schema.TaggedStruct('Pattern', { k: K, target: LTargets }) +const FnL = Schema.TaggedStruct('Fn', { when: Evaluator, outcome: Schema.Union([GoL, StayBump, EffectOutcome]) }) +const LGoSlot = Schema.Union([Absent, TargetL, PatternL, FnL]) +const LTickSlot = Schema.Union([Absent, TargetL, FnL]) + +const TargetR = Schema.TaggedStruct('Target', { target: RTargets }) +const PatternR = Schema.TaggedStruct('Pattern', { k: K, target: RTargets }) +const FnR = Schema.TaggedStruct('Fn', { when: Evaluator, outcome: Schema.Union([GoR, StayZero, EffectOutcome]) }) +const RGoSlot = Schema.Union([Absent, TargetR, PatternR, FnR]) +const RTickSlot = Schema.Union([Absent, TargetR, FnR]) + +const FnRoot = Schema.TaggedStruct('Fn', { when: Evaluator, outcome: Schema.Union([StayBump, EffectOutcome]) }) +const RootSlot = Schema.Union([Absent, FnRoot]) + +const LSlots = Schema.Struct({ GO: LGoSlot, TICK: LTickSlot }) +const RSlots = Schema.Struct({ GO: RGoSlot, TICK: RTickSlot }) +const RootSlots = Schema.Struct({ GO: RootSlot, TICK: RootSlot }) +const C2Slots = Schema.Struct({ + GO: LGoSlot, + TICK: LTickSlot, + always: Schema.Struct({ when: Evaluator, outcome: GoC2 }), +}) +const R2Slots = Schema.Struct({ GO: RGoSlot, TICK: RTickSlot, always: Schema.Struct({ when: Evaluator }) }) + +export const MachineSpec = Schema.Struct({ + root: RootSlots, + L: LSlots, + R: RSlots, + p: LSlots, + c1: LSlots, + c2: C2Slots, + q: LSlots, + r1: RSlots, + r2: R2Slots, +}) +export type MachineSpec = Schema.Schema.Type + +const Driver = Schema.Literals(['actor', 'pure']) +export type Driver = Schema.Schema.Type + +const Factory = Schema.Literals(['createMachine', 'setup', 'provide']) +export type Factory = Schema.Schema.Type + +const ModelEvent = Schema.Union([ + Schema.Struct({ type: Schema.Literal('GO'), k: K }), + Schema.Struct({ type: Schema.Literal('TICK') }), +]) +export type ModelEvent = Schema.Schema.Type + +export const GuardsCommand = Schema.TaggedStruct('Run', { + machine: MachineSpec, + events: Schema.Array(ModelEvent).check(Schema.isMinLength(1), Schema.isMaxLength(6)), + driver: Driver, + factory: Factory, +}) +export type GuardsCommand = Schema.Schema.Type + +export interface GuardsStep { + readonly can: boolean + readonly effects: ReadonlyArray +} + +export interface GuardsObservation { + readonly value: GuardsValue + readonly n: number + readonly steps: ReadonlyArray + readonly activeIds: ReadonlyArray + readonly activeIdsDataLast: ReadonlyArray +} + +export interface Resolution { + readonly can: boolean + readonly effects: ReadonlyArray + readonly admitted: ReadonlyArray<{ readonly node: NodeKey; readonly kind: SlotTag }> + readonly rejected: ReadonlyArray<{ readonly node: NodeKey; readonly kind: SlotTag }> + readonly namedAsked: boolean + readonly inTrue: boolean + readonly inFalse: boolean + readonly rootAdmitted: boolean + readonly rootRejected: boolean + readonly eventlessL: boolean + readonly eventlessR: boolean + readonly crossRegionInterim: boolean + readonly after: Sim +} + +const tagOf = (slot: Slot): SlotTag => + Match.value(slot).pipe( + Match.tag('Absent', (): SlotTag => 'Absent'), + Match.tag('Target', (): SlotTag => 'Target'), + Match.tag('Pattern', (): SlotTag => 'Pattern'), + Match.tag('Fn', (): SlotTag => 'Fn'), + Match.exhaustive, + ) + +const evaluatorOf = (slot: Slot): Evaluator | undefined => + Match.value(slot).pipe( + Match.tag('Fn', (fn) => fn.when), + Match.orElse(() => undefined), + ) + +const answersTag = (evaluator: Evaluator, tag: 'Named' | 'In'): boolean => + Match.value(evaluator).pipe( + Match.tag('Named', () => tag === 'Named'), + Match.tag('In', () => tag === 'In'), + Match.orElse(() => false), + ) + +const slotOf = (spec: MachineSpec, node: NodeKey, event: ModelEvent): Slot => + event.type === 'GO' ? spec[node].GO : spec[node].TICK + +const matchesOf = (path: InPath, value: GuardsValue): boolean => + Match.value(path).pipe( + Match.when('L.p.c1', () => value.L !== 'q' && value.L.p === 'c1'), + Match.when('L.p.c2', () => value.L !== 'q' && value.L.p === 'c2'), + Match.when('L.q', () => value.L === 'q'), + Match.when('R.r1', () => value.R === 'r1'), + Match.when('R.r2', () => value.R === 'r2'), + Match.exhaustive, + ) + +const holds = (when: Evaluator, sim: Sim, event: ModelEvent | undefined): boolean => + Match.value(when).pipe( + Match.when({ _tag: 'Always' }, () => true), + Match.when({ _tag: 'Never' }, () => false), + Match.when({ _tag: 'ContextAtLeast' }, ({ t }) => sim.n >= t), + Match.when({ _tag: 'PayloadAtLeast' }, ({ t }) => event !== undefined && event.type === 'GO' && event.k >= t), + Match.when({ _tag: 'Named' }, ({ t }) => sim.n >= t), + Match.when({ _tag: 'In' }, ({ path }) => matchesOf(path, sim.value)), + Match.exhaustive, + ) + +interface Admission { + readonly target: string | undefined + readonly bump: number + readonly effect: boolean +} + +const admit = (slot: Slot, sim: Sim, event: ModelEvent): Admission | undefined => + Match.value(slot).pipe( + Match.when({ _tag: 'Absent' }, () => undefined), + Match.when({ _tag: 'Target' }, ({ target }) => ({ target, bump: 0, effect: false })), + Match.when( + { _tag: 'Pattern' }, + ({ k, target }) => event.type === 'GO' && event.k === k ? { target, bump: 0, effect: false } : undefined, + ), + Match.when( + { _tag: 'Fn' }, + ({ when, outcome }) => + holds(when, sim, event) + ? Match.value(outcome).pipe( + Match.when({ _tag: 'Go' }, ({ target, bump }) => ({ target, bump, effect: false })), + Match.when({ _tag: 'Stay' }, ({ bump }) => ({ target: undefined, bump, effect: false })), + Match.when({ _tag: 'Effect' }, () => ({ target: undefined, bump: 0, effect: true })), + Match.exhaustive, + ) + : undefined, + ), + Match.exhaustive, + ) + +const withTarget = (value: GuardsValue, target: string): GuardsValue => + Match.value(target).pipe( + Match.when('#c1', () => ({ ...value, L: { p: 'c1' as const } })), + Match.when('#c2', () => ({ ...value, L: { p: 'c2' as const } })), + Match.when('#p', () => ({ ...value, L: { p: 'c1' as const } })), + Match.when('#q', () => ({ ...value, L: 'q' as const })), + Match.when('#r1', () => ({ ...value, R: 'r1' as const })), + Match.when('#r2', () => ({ ...value, R: 'r2' as const })), + Match.orElse(() => value), + ) + +const lKey = (value: GuardsValue): string => value.L === 'q' ? 'q' : value.L.p + +const lOrder = (value: GuardsValue): ReadonlyArray => value.L === 'q' ? ['q', 'L'] : [value.L.p, 'p', 'L'] + +const rOrder = (value: GuardsValue): ReadonlyArray => [value.R, 'R'] + +interface Trace { + readonly admitted: Array<{ readonly node: NodeKey; readonly kind: SlotTag }> + readonly rejected: Array<{ readonly node: NodeKey; readonly kind: SlotTag }> + namedAsked: boolean + inTrue: boolean + inFalse: boolean +} + +const ask = ( + spec: MachineSpec, + nodes: ReadonlyArray, + sim: Sim, + event: ModelEvent, + trace: Trace, +): { readonly node: NodeKey; readonly admission: Admission } | undefined => { + for (const node of nodes) { + const slot = slotOf(spec, node, event) + const evaluator = evaluatorOf(slot) + if (evaluator !== undefined) { + if (answersTag(evaluator, 'Named')) { + trace.namedAsked = true + } + if (answersTag(evaluator, 'In')) { + if (holds(evaluator, sim, event)) { + trace.inTrue = true + } else { + trace.inFalse = true + } + } + } + const admission = admit(slot, sim, event) + if (admission === undefined) { + trace.rejected.push({ node, kind: tagOf(slot) }) + continue + } + trace.admitted.push({ node, kind: tagOf(slot) }) + return { node, admission } + } + return undefined +} + +const settle = ( + spec: MachineSpec, + sim: Sim, + event: ModelEvent, +): { readonly sim: Sim; readonly L: boolean; readonly R: boolean } => { + let value = sim.value + let l = false + let r = false + let rounds = 0 + for (;;) { + const lFires = value.L !== 'q' && value.L.p === 'c2' && + holds(spec.c2.always.when, { value, n: sim.n }, event) + const rFires = value.R === 'r2' && holds(spec.r2.always.when, { value, n: sim.n }, event) + if (!lFires && !rFires) { + return { sim: { value, n: sim.n }, L: l, R: r } + } + if (lFires) { + value = withTarget(value, spec.c2.always.outcome.target) + l = true + } + if (rFires) { + value = withTarget(value, '#r1') + r = true + } + rounds += 1 + if (rounds > 8) { + throw new Error('eventless loop did not settle') + } + } +} + +const resolveEvent = (spec: MachineSpec, sim: Sim, event: ModelEvent): Resolution => { + const trace: Trace = { admitted: [], rejected: [], namedAsked: false, inTrue: false, inFalse: false } + const L = ask(spec, lOrder(sim.value), sim, event, trace) + const R = ask(spec, rOrder(sim.value), sim, event, trace) + const regionAdmitted = L !== undefined || R !== undefined + const root = regionAdmitted ? undefined : ask(spec, ['root'], sim, event, trace) + + let value = sim.value + let n = sim.n + const effects: Array = [] + const apply = (asked: { readonly node: NodeKey; readonly admission: Admission }): void => { + if (asked.admission.target !== undefined) { + value = withTarget(value, asked.admission.target) + } + if (asked.admission.bump !== 0) { + n = (n + asked.admission.bump) % 4 + } + if (asked.admission.effect) { + effects.push(asked.node) + } + } + + if (L !== undefined) { + apply(L) + } + if (R !== undefined) { + apply(R) + } + if (root !== undefined) { + apply(root) + } + + const movedL = lKey(value) !== lKey(sim.value) + const settled = settle(spec, { value, n }, event) + + return { + can: regionAdmitted || root !== undefined, + effects, + admitted: trace.admitted, + rejected: trace.rejected, + namedAsked: trace.namedAsked, + inTrue: trace.inTrue, + inFalse: trace.inFalse, + rootAdmitted: root !== undefined, + rootRejected: !regionAdmitted && root === undefined, + eventlessL: settled.L, + eventlessR: settled.R, + crossRegionInterim: settled.R && movedL, + after: settled.sim, + } +} + +export const analyseRun = (command: GuardsCommand): ReadonlyArray => { + const resolutions: Array = [] + let sim = INITIAL_SIM + for (const event of command.events) { + const resolution = resolveEvent(command.machine, sim, event) + resolutions.push(resolution) + sim = resolution.after + } + return resolutions +} + +const activeIdsOf = (value: GuardsValue): ReadonlyArray => + NODE_KEYS.filter((key) => + Match.value(key).pipe( + Match.when('root', () => true), + Match.when('L', () => true), + Match.when('R', () => true), + Match.when('p', () => value.L !== 'q'), + Match.when('c1', () => value.L !== 'q' && value.L.p === 'c1'), + Match.when('c2', () => value.L !== 'q' && value.L.p === 'c2'), + Match.when('q', () => value.L === 'q'), + Match.when('r1', () => value.R === 'r1'), + Match.when('r2', () => value.R === 'r2'), + Match.exhaustive, + ) + ) + +const GuardsModelState = Schema.Struct({}) +type GuardsModelState = Schema.Schema.Type + +export const guardsModel = { + state: GuardsModelState, + initial: {}, + precondition: (): boolean => true, + step: (state: GuardsModelState, command: GuardsCommand): readonly [GuardsModelState, GuardsObservation] => { + const run = analyseRun(command) + const final = run.at(-1)?.after ?? INITIAL_SIM + const ids = activeIdsOf(final.value) + return [ + state, + { + value: final.value, + n: final.n, + steps: run.map((step) => ({ + can: step.can, + effects: step.effects.map((at): GuardsEffect => ({ type: 'effect', at })), + })), + activeIds: ids, + activeIdsDataLast: ids, + }, + ] + }, +} diff --git a/packages/xstate/tests/__fixtures__/guards.ts b/packages/xstate/tests/__fixtures__/guards.ts new file mode 100644 index 0000000..9bfbf70 --- /dev/null +++ b/packages/xstate/tests/__fixtures__/guards.ts @@ -0,0 +1,462 @@ +import { + type AnyMachineSnapshot, + type AnyStateMachine, + checkStateIn, + createActor, + createMachine, + initialTransition, + matchesState, + setup, + type StateValue, + transition, + types, +} from '@systemfsoftware/xstate' +import { Context, Effect, Layer, Match, Result, Schema } from 'effect' +import { + analyseRun, + type Driver, + type Evaluator, + type Factory, + type GuardsCommand, + type GuardsContext, + GuardsContextSchema, + type GuardsEffect, + type GuardsEffectLike, + type GuardsObservation, + type GuardsStep, + GuardsValueSchema, + type MachineSpec, + NODE_KEYS, + type NodeKey, + type Resolution, + type Slot, + type SlotTag, +} from './guards.model.js' + +type GuardSource = (...args: ReadonlyArray) => boolean + +const atLeast: GuardSource = (x, t) => x >= t + +const neverAtLeast: GuardSource = () => false + +type SubjectEvent = { readonly type: 'GO'; readonly k: number } | { readonly type: 'TICK' } + +interface SelectionArgs { + readonly context: GuardsContext + readonly event: SubjectEvent + readonly value: StateValue + readonly guards: Readonly> +} + +interface Enqueue { + emit(event: GuardsEffect): void +} + +interface TransitionOutcome { + readonly target?: string + readonly context?: { readonly n: number } +} + +type TransitionEntry = (args: SelectionArgs, enq: Enqueue) => TransitionOutcome | undefined + +type ObjectEntry = + | { readonly target: string; readonly matches?: { readonly k: number } } + | TransitionEntry + +type Fault = 'none' | 'rejects-as-empty' | 'drops-effect' + +const count = (flag: boolean): number => flag ? 1 : 0 + +const holdsInSubject = (when: Evaluator, args: SelectionArgs): boolean => + Match.value(when).pipe( + Match.when({ _tag: 'Always' }, () => true), + Match.when({ _tag: 'Never' }, () => false), + Match.when({ _tag: 'ContextAtLeast' }, ({ t }) => args.context.n >= t), + Match.when({ _tag: 'PayloadAtLeast' }, ({ t }) => args.event.type === 'GO' && args.event.k >= t), + Match.when({ _tag: 'Named' }, ({ t }) => { + const source = args.guards['atLeast'] + return source === undefined ? false : source(args.context.n, t) + }), + Match.when({ _tag: 'In' }, ({ path }) => matchesState(path, args.value)), + Match.exhaustive, + ) + +const outcomeOf = ( + outcome: Extract['outcome'], + node: NodeKey, + args: SelectionArgs, + enq: Enqueue, +): TransitionOutcome | undefined => + Match.value(outcome).pipe( + Match.when({ _tag: 'Go' }, ({ target, bump }) => ({ + target, + ...(bump === 0 ? {} : { context: { n: (args.context.n + bump) % 4 } }), + })), + Match.when({ _tag: 'Stay' }, ({ bump }) => bump === 0 ? {} : { context: { n: (args.context.n + bump) % 4 } }), + Match.when({ _tag: 'Effect' }, () => { + enq.emit({ type: 'effect', at: node }) + return undefined + }), + Match.exhaustive, + ) + +const dropsEffect = (outcome: Extract['outcome']): boolean => + Match.value(outcome).pipe( + Match.tag('Effect', () => true), + Match.orElse(() => false), + ) + +const transitionFn = + (slot: Extract, node: NodeKey, fault: Fault): TransitionEntry => + (args: SelectionArgs, enq: Enqueue): TransitionOutcome | undefined => { + if (!holdsInSubject(slot.when, args)) { + return fault === 'rejects-as-empty' ? {} : undefined + } + if (dropsEffect(slot.outcome) && fault === 'drops-effect') { + return undefined + } + return outcomeOf(slot.outcome, node, args, enq) + } + +const entryOf = (spec: MachineSpec, node: NodeKey, slot: Slot, fault: Fault): ObjectEntry | string | undefined => + Match.value(slot).pipe( + Match.when({ _tag: 'Absent' }, () => undefined), + Match.when({ _tag: 'Target' }, ({ target }) => target), + Match.when({ _tag: 'Pattern' }, ({ k, target }) => ({ target, matches: { k } })), + Match.when({ _tag: 'Fn' }, (fn) => transitionFn(fn, node, fault)), + Match.exhaustive, + ) + +const entryObjectOf = (spec: MachineSpec, node: NodeKey, slot: Slot, fault: Fault): ObjectEntry | undefined => + Match.value(slot).pipe( + Match.when({ _tag: 'Absent' }, () => undefined), + Match.when({ _tag: 'Target' }, ({ target }) => ({ target })), + Match.when({ _tag: 'Pattern' }, ({ k, target }) => ({ target, matches: { k } })), + Match.when({ _tag: 'Fn' }, (fn) => transitionFn(fn, node, fault)), + Match.exhaustive, + ) + +const onOf = (spec: MachineSpec, node: NodeKey, fault: Fault) => { + const go = entryOf(spec, node, spec[node].GO, fault) + const tick = entryOf(spec, node, spec[node].TICK, fault) + return { ...(go === undefined ? {} : { GO: go }), ...(tick === undefined ? {} : { TICK: tick }) } +} + +const onObjectOf = (spec: MachineSpec, node: NodeKey, fault: Fault) => { + const go = entryObjectOf(spec, node, spec[node].GO, fault) + const tick = entryObjectOf(spec, node, spec[node].TICK, fault) + return { ...(go === undefined ? {} : { GO: go }), ...(tick === undefined ? {} : { TICK: tick }) } +} + +const alwaysOf = (spec: MachineSpec): TransitionEntry => (args: SelectionArgs): TransitionOutcome | undefined => + holdsInSubject(spec.c2.always.when, args) ? { target: spec.c2.always.outcome.target } : undefined + +const alwaysOfR2 = (spec: MachineSpec): TransitionEntry => (args: SelectionArgs): TransitionOutcome | undefined => + holdsInSubject(spec.r2.always.when, args) ? { target: '#r1' } : undefined + +const configWith = ( + spec: MachineSpec, + onOfNode: (node: NodeKey) => Readonly>, +) => ({ + id: 'root', + type: 'parallel' as const, + context: { n: 0 }, + on: onOfNode('root'), + states: { + L: { + id: 'L', + type: 'compound' as const, + initial: 'p', + on: onOfNode('L'), + states: { + p: { + id: 'p', + type: 'compound' as const, + initial: 'c1', + on: onOfNode('p'), + states: { + c1: { id: 'c1', type: 'atomic' as const, on: onOfNode('c1') }, + c2: { id: 'c2', type: 'atomic' as const, on: onOfNode('c2'), always: alwaysOf(spec) }, + }, + }, + q: { id: 'q', type: 'atomic' as const, on: onOfNode('q') }, + }, + }, + R: { + id: 'R', + type: 'compound' as const, + initial: 'r1', + on: onOfNode('R'), + states: { + r1: { id: 'r1', type: 'atomic' as const, on: onOfNode('r1') }, + r2: { id: 'r2', type: 'atomic' as const, on: onOfNode('r2'), always: alwaysOfR2(spec) }, + }, + }, + }, +}) + +const configObjectWith = ( + spec: MachineSpec, + onOfNode: (node: NodeKey) => { GO?: ObjectEntry; TICK?: ObjectEntry }, +) => ({ + id: 'root', + type: 'parallel' as const, + context: { n: 0 }, + on: onOfNode('root'), + states: { + L: { + id: 'L', + type: 'compound' as const, + initial: 'p', + on: onOfNode('L'), + states: { + p: { + id: 'p', + type: 'compound' as const, + initial: 'c1', + on: onOfNode('p'), + states: { + c1: { id: 'c1', type: 'atomic' as const, on: onOfNode('c1') }, + c2: { id: 'c2', type: 'atomic' as const, on: onOfNode('c2'), always: alwaysOf(spec) }, + }, + }, + q: { id: 'q', type: 'atomic' as const, on: onOfNode('q') }, + }, + }, + R: { + id: 'R', + type: 'compound' as const, + initial: 'r1', + on: onOfNode('R'), + states: { + r1: { id: 'r1', type: 'atomic' as const, on: onOfNode('r1') }, + r2: { id: 'r2', type: 'atomic' as const, on: onOfNode('r2'), always: alwaysOfR2(spec) }, + }, + }, + }, +}) + +const configOf = (spec: MachineSpec, fault: Fault) => configWith(spec, (node) => onOf(spec, node, fault)) + +const configObjectOf = (spec: MachineSpec, fault: Fault) => + configObjectWith(spec, (node) => onObjectOf(spec, node, fault)) + +const configuredSetup = setup({ + guards: { atLeast }, + schemas: { + context: types<{ n: number }>(), + events: { GO: types<{ k: number }>(), TICK: types<{}>() }, + }, +}) + +const machineOf = (spec: MachineSpec, factory: Factory, fault: Fault): AnyStateMachine => + Match.value(factory).pipe( + Match.when('createMachine', () => createMachine({ guards: { atLeast }, ...configOf(spec, fault) })), + Match.when('setup', () => configuredSetup.createMachine(configObjectOf(spec, fault))), + Match.when('provide', () => + createMachine({ guards: { atLeast: neverAtLeast }, ...configOf(spec, fault) }).provide({ + guards: { atLeast }, + })), + Match.exhaustive, + ) + +export interface GuardsLedger { + drivers: Record + factories: Record + admitted: Record + rejected: Record + fallbackToP: number + fallbackToRegion: number + fallbackToRoot: number + rootAdmitted: number + rootRejected: number + effectsAdmitted: number + patternMatched: number + patternMismatched: number + namedAsked: Record + inTrue: number + inFalse: number + eventlessL: number + eventlessR: number + crossRegionInterim: number + canTrue: number + canFalse: number + checkStateIn: { + dataFirst: { true: number; false: number } + dataLast: { true: number; false: number } + } +} + +const emptyLedger = (): GuardsLedger => ({ + drivers: { actor: 0, pure: 0 }, + factories: { createMachine: 0, setup: 0, provide: 0 }, + admitted: { Absent: 0, Target: 0, Pattern: 0, Fn: 0 }, + rejected: { Absent: 0, Target: 0, Pattern: 0, Fn: 0 }, + fallbackToP: 0, + fallbackToRegion: 0, + fallbackToRoot: 0, + rootAdmitted: 0, + rootRejected: 0, + effectsAdmitted: 0, + patternMatched: 0, + patternMismatched: 0, + namedAsked: { createMachine: 0, setup: 0, provide: 0 }, + inTrue: 0, + inFalse: 0, + eventlessL: 0, + eventlessR: 0, + crossRegionInterim: 0, + canTrue: 0, + canFalse: 0, + checkStateIn: { dataFirst: { true: 0, false: 0 }, dataLast: { true: 0, false: 0 } }, +}) + +const noteResolution = (ledger: GuardsLedger, command: GuardsCommand, resolutions: ReadonlyArray): void => { + for (const resolution of resolutions) { + for (const entry of resolution.admitted) { + ledger.admitted[entry.kind] += 1 + ledger.patternMatched += count(entry.kind === 'Pattern') + } + for (const entry of resolution.rejected) { + ledger.rejected[entry.kind] += 1 + ledger.patternMismatched += count(entry.kind === 'Pattern') + } + ledger.fallbackToP += count(resolution.rejected.some((entry) => entry.node === 'c1' || entry.node === 'c2')) + ledger.fallbackToRegion += count(resolution.rejected.some((entry) => entry.node === 'L' || entry.node === 'R')) + ledger.fallbackToRoot += count(resolution.rootAdmitted || resolution.rootRejected) + ledger.rootAdmitted += count(resolution.rootAdmitted) + ledger.rootRejected += count(resolution.rootRejected) + ledger.namedAsked[command.factory] += count(resolution.namedAsked) + ledger.inTrue += count(resolution.inTrue) + ledger.inFalse += count(resolution.inFalse) + ledger.eventlessL += count(resolution.eventlessL) + ledger.eventlessR += count(resolution.eventlessR) + ledger.crossRegionInterim += count(resolution.crossRegionInterim) + } +} + +const activeIdsFirst = (snapshot: AnyMachineSnapshot, ledger: GuardsLedger): ReadonlyArray => + NODE_KEYS.filter((key) => { + const inside = checkStateIn(snapshot, `#${key}`) + ledger.checkStateIn.dataFirst[inside ? 'true' : 'false'] += 1 + return inside + }) + +const activeIdsDataLast = (snapshot: AnyMachineSnapshot, ledger: GuardsLedger): ReadonlyArray => + NODE_KEYS.filter((key) => { + const inside = checkStateIn(`#${key}`)(snapshot) + ledger.checkStateIn.dataLast[inside ? 'true' : 'false'] += 1 + return inside + }) + +const throwUncaptured = (): never => { + throw new Error('the published snapshot did not match the guards capture model') +} + +const observationOf = ( + snapshot: AnyMachineSnapshot, + steps: ReadonlyArray, + ledger: GuardsLedger, +): GuardsObservation => { + const value = Schema.decodeUnknownResult(GuardsValueSchema)(snapshot.value) + const context = Schema.decodeUnknownResult(GuardsContextSchema)(snapshot.context) + return { + value: Result.isSuccess(value) ? value.success : throwUncaptured(), + n: Result.isSuccess(context) ? context.success.n : throwUncaptured(), + steps, + activeIds: activeIdsFirst(snapshot, ledger), + activeIdsDataLast: activeIdsDataLast(snapshot, ledger), + } +} + +const noteStep = (ledger: GuardsLedger, can: boolean, effects: ReadonlyArray): void => { + ledger.canTrue += count(can) + ledger.canFalse += count(!can) + ledger.effectsAdmitted += count(effects.length > 0) +} + +const runPure = ( + machine: AnyStateMachine, + command: GuardsCommand, + ledger: GuardsLedger, +): { readonly steps: ReadonlyArray; readonly final: AnyMachineSnapshot } => { + const [initial] = initialTransition(machine) + let snapshot: AnyMachineSnapshot = initial + const steps: Array = [] + for (const event of command.events) { + const can = snapshot.can(event) + const [next, actions] = transition(machine, snapshot, event) + const effects: ReadonlyArray = actions + .filter((action) => action.kind === 'emit') + .map((action) => action.event) + noteStep(ledger, can, effects) + steps.push({ can, effects }) + snapshot = next + } + return { steps, final: snapshot } +} + +const runActor = ( + machine: AnyStateMachine, + command: GuardsCommand, + ledger: GuardsLedger, +): { readonly steps: ReadonlyArray; readonly final: AnyMachineSnapshot } => { + const actor = createActor(machine) + const emitted: Array = [] + actor.subscribe({ error: () => {} }) + actor.on('effect', (event) => { + emitted.push(event) + }) + actor.start() + const steps: Array = [] + for (const event of command.events) { + const can = actor.getSnapshot().can(event) + const before = emitted.length + actor.send(event) + const effects = emitted.slice(before) + noteStep(ledger, can, effects) + steps.push({ can, effects }) + } + const final = actor.getSnapshot() + actor.stop() + return { steps, final } +} + +export class GuardsSubject extends Context.Service GuardsObservation>()( + '@systemfsoftware/xstate/tests/guards/GuardsSubject', +) {} + +export interface GuardsHandle { + readonly layer: Layer.Layer + readonly observed: GuardsLedger +} + +const subjectOf = (fault: Fault): GuardsHandle => { + const observed = emptyLedger() + const run = (command: GuardsCommand): GuardsObservation => { + observed.drivers[command.driver] += 1 + observed.factories[command.factory] += 1 + noteResolution(observed, command, analyseRun(command)) + const machine = machineOf(command.machine, command.factory, fault) + const { steps, final } = command.driver === 'pure' ? runPure(machine, command, observed) : runActor( + machine, + command, + observed, + ) + const observation = observationOf(final, steps, observed) + return observation + } + return { observed, layer: Layer.succeed(GuardsSubject, run) } +} + +export const makeGuardsSubject = (): GuardsHandle => subjectOf('none') + +export const makeRejectsAsEmptySubject = (): GuardsHandle => subjectOf('rejects-as-empty') + +export const makeDropsEffectSubject = (): GuardsHandle => subjectOf('drops-effect') + +export const runGuardsCommand = (command: GuardsCommand): Effect.Effect => + Effect.gen(function*() { + const run = yield* GuardsSubject + return run(command) + }) diff --git a/packages/xstate/tests/guards.conformance.test.ts b/packages/xstate/tests/guards.conformance.test.ts new file mode 100644 index 0000000..d2caf50 --- /dev/null +++ b/packages/xstate/tests/guards.conformance.test.ts @@ -0,0 +1,472 @@ +import { Conformance } from '@systemfsoftware/conformance-spec' +import { And, Gherkin, Given, it, makeFeature, Then, When } from '@systemfsoftware/effect-gherkin-spec' +import { + checkStateIn, + createActor, + createMachine, + matchesState, + setup, + type StateValue, + types, +} from '@systemfsoftware/xstate' +import { Effect, Layer } from 'effect' +import { failReportOf, passReportOf } from './__fixtures__/checkReports.js' +import { + type GuardsHandle, + type GuardsLedger, + makeDropsEffectSubject, + makeGuardsSubject, + makeRejectsAsEmptySubject, + runGuardsCommand, +} from './__fixtures__/guards.js' +import { GuardsCommand, guardsModel } from './__fixtures__/guards.model.js' + +export const typeLevelContract = () => { + const machine = createMachine({ + type: 'parallel', + states: { a: { initial: 'a1', states: { a1: {}, a2: {} } }, b: { initial: 'b1', states: { b1: {}, b2: {} } } }, + }) + const snapshot = machine.getInitialSnapshot() + + return [ + checkStateIn(snapshot, 'a.a1') satisfies boolean, + checkStateIn('a.a1')(snapshot) satisfies boolean, + checkStateIn(snapshot, { a: 'a1' }) satisfies boolean, + checkStateIn({ a: 'a1' })(snapshot) satisfies boolean, + matchesState('a.a1', snapshot.value) satisfies boolean, + matchesState('a.a1')(snapshot.value) satisfies boolean, + ] as const +} + +const Feature = makeFeature({ it }) + +const sequences = 96 +const operations = 6 + +const liveness = (observed: GuardsLedger): boolean => + observed.drivers.actor > 0 && + observed.drivers.pure > 0 && + observed.factories.createMachine > 0 && + observed.factories.setup > 0 && + observed.factories.provide > 0 && + observed.admitted.Target > 0 && + observed.admitted.Pattern > 0 && + observed.admitted.Fn > 0 && + observed.rejected.Absent > 0 && + observed.rejected.Pattern > 0 && + observed.rejected.Fn > 0 && + observed.fallbackToP > 0 && + observed.fallbackToRegion > 0 && + observed.fallbackToRoot > 0 && + observed.rootAdmitted > 0 && + observed.rootRejected > 0 && + observed.effectsAdmitted > 0 && + observed.patternMatched > 0 && + observed.patternMismatched > 0 && + observed.namedAsked.createMachine > 0 && + observed.namedAsked.setup > 0 && + observed.namedAsked.provide > 0 && + observed.inTrue > 0 && + observed.inFalse > 0 && + observed.eventlessL > 0 && + observed.eventlessR > 0 && + observed.crossRegionInterim > 0 && + observed.canTrue > 0 && + observed.canFalse > 0 && + observed.checkStateIn.dataFirst.true > 0 && + observed.checkStateIn.dataFirst.false > 0 && + observed.checkStateIn.dataLast.true > 0 && + observed.checkStateIn.dataLast.false > 0 + +const checkOver = (handle: GuardsHandle, seed: number) => + Conformance.sequential(handle.layer, { + commands: GuardsCommand, + model: guardsModel, + run: runGuardsCommand, + sequences, + operations, + seed, + }) + +interface ActorStatus { + readonly status: string + readonly message: string +} + +Feature('Judging the published guard evaluation against a hand-written model of admission', { timeout: 0 }) + .withLayer(Layer.empty) + .live('each scenario drives the simulation kernel itself, and a conformance check cannot run inside a kernel run') + .body(({ scenario, scenarioOutline }) => { + scenarioOutline( + 'Every machine and event sequence the model draws from seed admits exactly what the published guards admit', + [{ seed: 1 }, { seed: 2 }, { seed: 3 }], + (row) => + Gherkin.Do.pipe( + Given('a subject bound to the published machine entrypoints, with a fresh ledger')( + 'subject', + () => Effect.succeed(makeGuardsSubject()), + ), + When('the sequential model check runs the generated machines and event sequences through it')( + 'report', + (s) => checkOver(s.subject, row.seed), + ), + Then('every sequence is explained by the hand-written admission model')((s, expect) => + expect(passReportOf(s.report), 'the published guards agree with the model').toMatchObject({ + _tag: 'Pass', + histories: sequences, + }) + ), + When('the steps the run produced are read off the fixture ledger')( + 'observed', + (s) => Effect.succeed(s.subject.observed), + ), + And( + 'every slot kind was admitted and rejected, every fallback depth and the root were reached, patterns matched and mismatched, an effect fired, a named source was asked under every factory, an in check answered both ways, each region stepped eventlessly with one cross-region interim step, can answered both ways, and checkStateIn answered both ways and both call forms', + )( + (s, expect) => + expect(s.observed, JSON.stringify(s.observed)).toSatisfy( + liveness, + 'the run exercised the whole admission surface across both drivers and all three factories', + ), + ), + ), + ) + + const divergesFromTheModel = (makeSubject: () => GuardsHandle) => + Gherkin.Do.pipe( + Given('a planted subject that admits differently from the published guards')( + 'subject', + () => Effect.succeed(makeSubject()), + ), + When('the same check runs the machines drawn from seed 1 through it')('report', (s) => checkOver(s.subject, 1)), + Then('the run is rejected with the model-diverged judgement at a numbered step')((s, expect) => { + const failure = failReportOf(s.report).failure + const rendered = Conformance.render(s.report) + return expect({ problem: failure.judgement.problem, step: failure.judgement.step, rendered }, rendered) + .toSatisfy( + (value) => + value.problem === 'model-diverged' && + value.step !== undefined && + value.rendered.includes(`the model diverged at step ${value.step}`), + 'the run diverged from the model at a numbered step, reported as the model-diverged judgement', + ) + }), + ) + + scenario( + 'A rejection that returns an empty object consumes the event and is caught as a model divergence', + divergesFromTheModel(makeRejectsAsEmptySubject), + ) + scenario( + 'An effect outcome that enqueues nothing falls through and is caught as a model divergence', + divergesFromTheModel(makeDropsEffectSubject), + ) + + scenario( + 'A guard source the machine does not implement leaves the actor in error status with a TypeError naming it', + Gherkin.Do.pipe( + Given('a machine whose routing step calls a guard source it does not implement')( + 'outcome', + () => + Effect.sync(() => { + const declared = { + isReady: (ready: boolean) => ready === true, + isRedy: (ready: boolean) => ready, + } + Reflect.deleteProperty(declared, 'isRedy') + const machine = createMachine({ + context: { ready: false }, + guards: declared, + initial: 'routing', + states: { + routing: { + always: ({ context, guards }) => + guards.isRedy(context.ready) ? { target: 'go' } : { target: 'wait' }, + }, + go: {}, + wait: {}, + }, + }) + const actor = createActor(machine) + const failures: Array = [] + actor.subscribe({ + error: (error) => { + failures.push( + error instanceof Error + ? error + : new Error('the missing source threw a non-Error', { cause: error }), + ) + }, + }) + actor.start() + const status = actor.getSnapshot().status + actor.stop() + const failure = failures.at(0) + return { status, message: failure === undefined ? '' : failure.message } + }), + ), + Then('the actor is in error status with a message naming the missing source')((s, expect) => + expect(s.outcome, JSON.stringify(s.outcome)).toSatisfy( + (outcome: ActorStatus) => + outcome.status === 'error' && /guards[^\s]*isRedy[^\s]* is not a function/.test(outcome.message), + 'the actor reports a TypeError naming guards.isRedy', + ) + ), + ), + ) + + scenario( + 'A transition function that throws leaves the actor in error status with that error', + Gherkin.Do.pipe( + Given('a machine whose transition function throws')('outcome', () => + Effect.sync(() => { + const machine = createMachine({ + initial: 'a', + states: { + a: { + on: { + GO: () => { + throw new Error('boom-guard') + }, + }, + }, + }, + }) + const actor = createActor(machine) + const failures: Array = [] + actor.subscribe({ + error: (error) => { + failures.push( + error instanceof Error ? error : new Error('the transition threw a non-Error', { cause: error }), + ) + }, + }) + actor.start() + actor.send({ type: 'GO' }) + const status = actor.getSnapshot().status + actor.stop() + const failure = failures.at(0) + return { status, message: failure === undefined ? '' : failure.message } + })), + Then('the actor carries the thrown error')((s, expect) => + expect(s.outcome, JSON.stringify(s.outcome)).toSatisfy( + (outcome: ActorStatus) => outcome.status === 'error' && outcome.message === 'boom-guard', + 'the actor is in error status with the thrown error as its message', + ) + ), + ), + ) + + scenario( + 'A guard source receives exactly the arguments the transition function passes', + Gherkin.Do.pipe( + Given('a machine whose guard source records what it received')('outcome', () => + Effect.sync(() => { + const received: Array> = [] + const machine = createMachine({ + context: { count: 5 }, + guards: { + isAbove: (count: number, threshold: number) => { + received.push([count, threshold]) + return count > threshold + }, + }, + initial: 'a', + states: { + a: { + on: { + GO: ({ context, guards }) => (guards.isAbove(context.count, 3) ? { target: 'b' } : undefined), + }, + }, + b: {}, + }, + }) + const actor = createActor(machine).start() + actor.send({ type: 'GO' }) + const value = actor.getSnapshot().value + actor.stop() + return { value, received } + })), + Then('the source saw the caller-supplied arguments and the transition admitted')((s, expect) => + expect(s.outcome, JSON.stringify(s.outcome)).toSatisfy( + (outcome: { readonly value: StateValue; readonly received: ReadonlyArray> }) => + outcome.value === 'b' && JSON.stringify(outcome.received) === JSON.stringify([[5, 3]]), + 'the guard source received [5, 3] and the transition admitted', + ) + ), + ), + ) + + scenario( + 'A zero-parameter guard source is called with no arguments', + Gherkin.Do.pipe( + Given('a machine whose guard source declares no parameters')('outcome', () => + Effect.sync(() => { + const machine = createMachine({ + guards: { isEnabled: (): boolean => true }, + initial: 'a', + states: { + a: { on: { GO: ({ guards }) => (guards.isEnabled() ? { target: 'b' } : undefined) } }, + b: {}, + }, + }) + const actor = createActor(machine).start() + actor.send({ type: 'GO' }) + const value = actor.getSnapshot().value + actor.stop() + return { value } + })), + Then('the transition admits')((s, expect) => + expect(s.outcome, JSON.stringify(s.outcome)).toSatisfy( + (outcome: { readonly value: StateValue }) => outcome.value === 'b', + 'a zero-parameter source is callable and admits the transition', + ) + ), + ), + ) + + scenario( + 'checkStateIn answers path strings and state values like snapshot.matches, in both forms', + Gherkin.Do.pipe( + Given('a hand-written parallel machine resting in a1 and b2')('outcome', () => + Effect.sync(() => { + const machine = createMachine({ + type: 'parallel', + states: { + a: { initial: 'a1', states: { a1: {}, a2: {} } }, + b: { initial: 'b2', states: { b1: { id: 'b_b1' }, b2: { id: 'b_b2' } } }, + }, + }) + const actor = createActor(machine).start() + const snapshot = actor.getSnapshot() + actor.stop() + const values: ReadonlyArray = [{ a: 'a1', b: 'b2' }, 'a.a1', { a: 'a2' }, '#b_b2', '#b_b1'] + return { + dataFirst: values.map((value) => checkStateIn(snapshot, value)), + dataLast: values.map((value) => checkStateIn(value)(snapshot)), + matches: values.map((value) => snapshot.matches(value)), + } + })), + Then('both call forms answer the hand-written truth table')((s, expect) => + expect(s.outcome, JSON.stringify(s.outcome)).toSatisfy( + (outcome: { + readonly dataFirst: ReadonlyArray + readonly dataLast: ReadonlyArray + }) => + JSON.stringify(outcome.dataFirst) === JSON.stringify([true, true, false, true, false]) && + JSON.stringify(outcome.dataLast) === JSON.stringify([true, true, false, true, false]), + 'checkStateIn answers a state value, a path and an inactive path, and both active ids, in both forms', + ) + ), + ), + ) + + scenario( + 'A declared target admits the event and moves the actor to it', + Gherkin.Do.pipe( + Given('a machine whose event handler names a sibling state as its target')('outcome', () => + Effect.sync(() => { + const machine = createMachine({ + initial: 'a', + states: { a: { on: { EVENT: { target: 'c' } } }, b: {}, c: {} }, + }) + const actor = createActor(machine).start() + actor.send({ type: 'EVENT' }) + const value = actor.getSnapshot().value + actor.stop() + return { value } + })), + Then('the actor reaches the declared target')((s, expect) => + expect(s.outcome, JSON.stringify(s.outcome)).toSatisfy( + (outcome: { readonly value: StateValue }) => outcome.value === 'c', + 'the declared target admits the event and moves the actor to c', + ) + ), + ), + ) + + scenario( + 'A custom guard with parameters admits only when the context and the event together clear the threshold', + Gherkin.Do.pipe( + Given('a setup machine whose guard reads the context count and the event value')( + 'outcome', + () => + Effect.sync(() => { + const guardSetup = setup({ + schemas: { + context: types<{ count: number }>(), + events: { EVENT: types<{ value: number }>() }, + }, + }) + const machine = guardSetup.createMachine({ + context: { count: 0 }, + initial: 'inactive', + states: { + inactive: { + on: { + EVENT: ({ context, event }) => context.count + event.value > 3 ? { target: 'active' } : undefined, + }, + }, + active: {}, + }, + }) + const passing = createActor(machine).start() + passing.send({ type: 'EVENT', value: 4 }) + const reached = passing.getSnapshot().value + passing.stop() + const failing = createActor(machine).start() + failing.send({ type: 'EVENT', value: 3 }) + const held = failing.getSnapshot().value + failing.stop() + return { reached, held } + }), + ), + Then('the guard admits above the threshold and refuses at it')((s, expect) => + expect(s.outcome, JSON.stringify(s.outcome)).toSatisfy( + (outcome: { readonly reached: StateValue; readonly held: StateValue }) => + outcome.reached === 'active' && outcome.held === 'inactive', + 'the parameterised guard admits value 4 and refuses value 3', + ) + ), + ), + ) + + scenario( + 'A guard in one region reads the interim value another region reached in the same macrostep', + Gherkin.Do.pipe( + Given('a parallel machine whose right region reacts to the left region being in lq')( + 'outcome', + () => + Effect.sync(() => { + const machine = createMachine({ + type: 'parallel', + states: { + L: { initial: 'c1', states: { c1: { on: { GO: { target: 'lq' } } }, lq: {} } }, + R: { + initial: 'r1', + states: { + r1: { always: ({ value }) => (matchesState('L.lq', value) ? { target: 'r2' } : undefined) }, + r2: {}, + }, + }, + }, + }) + const actor = createActor(machine).start() + const initial = actor.getSnapshot().value + actor.send({ type: 'GO' }) + const interim = actor.getSnapshot().value + actor.stop() + return { initial, interim } + }), + ), + Then('the right region moves on the left region having moved in the same macrostep')((s, expect) => + expect(s.outcome, JSON.stringify(s.outcome)).toSatisfy( + (outcome: { readonly initial: StateValue; readonly interim: StateValue }) => + JSON.stringify(outcome.initial) === JSON.stringify({ L: 'c1', R: 'r1' }) && + JSON.stringify(outcome.interim) === JSON.stringify({ L: 'lq', R: 'r2' }), + 'the eventless guard in R sees L having reached lq', + ) + ), + ), + ) + }) diff --git a/packages/xstate/vitest.config.ts b/packages/xstate/vitest.config.ts index d752ef7..b4a8a74 100644 --- a/packages/xstate/vitest.config.ts +++ b/packages/xstate/vitest.config.ts @@ -49,7 +49,6 @@ const unguarded = [ 'test/examples.cd.test.ts', 'test/final.test.ts', 'test/firstTenMinutes.v6.test.ts', - 'test/guards.test.ts', 'test/history.test.ts', 'test/id.test.ts', 'test/initial.test.ts', @@ -105,7 +104,6 @@ const unguarded = [ 'test/spawn.types.test.ts', 'test/spawnChild.test.ts', 'test/state.test.ts', - 'test/stateIn.test.ts', 'test/stateInput.persistence.test.ts', 'test/stateInput.test.ts', 'test/strictTargets.types.test.ts', From c3813456da55923890e985dc116b1b4236dc8c93 Mon Sep 17 00:00:00 2001 From: Ryan Lee Date: Fri, 9 Oct 2026 17:49:05 +0000 Subject: [PATCH 05/10] ci(repo): cap each mutation test runner isolate at a 2 GiB heap Two of three release-gate runs on #81 lost the whole ubuntu-latest VM (16 GB) mid-shard. Node's default old-space ceiling is about 4 GiB per isolate, and each test-runner child holds a main isolate plus a claimed and a spare vitest worker thread, so two runners running a mutant that allocates without bound can outgrow the VM before the 45 s mutant timeout settles them. testRunnerNodeArgs passes --max-old-space-size=2048 to every test-runner child. The flag is process-wide in V8, so each worker thread gets the same ceiling. A worker that reaches it is ended by Node; Stryker retries the mutant twice and then records it as a RuntimeError, so the run carries on. The full xstate suite passes on one thread with a 512 MiB ceiling, so 2 GiB leaves room for the coverage instrumentation. --- stryker.shared.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/stryker.shared.ts b/stryker.shared.ts index c051663..00b9b86 100644 --- a/stryker.shared.ts +++ b/stryker.shared.ts @@ -3,6 +3,7 @@ import { relative } from 'node:path' import { fileURLToPath } from 'node:url' const slackForAContendedFullSuiteRunMs = 45_000 +const heapPerTestRunnerIsolateMb = 2048 const repoRoot = fileURLToPath(new URL('.', import.meta.url)) @@ -32,6 +33,7 @@ export const packageStrykerConfig = (mutate: ReadonlyArray): StrykerConf plugin: '@systemfsoftware/stryker-js-vitest-runner', options: { configFile: 'vitest.config.ts', dir: '.', related: true }, }, + testRunnerNodeArgs: [`--max-old-space-size=${heapPerTestRunnerIsolateMb}`], timeoutMS: slackForAContendedFullSuiteRunMs, thresholds: { break: 100, high: 100, low: 100 }, }) satisfies StrykerConfig From 81c0115590cb8939e4b2e92bf47798d704abed4f Mon Sep 17 00:00:00 2001 From: Ryan Lee Date: Fri, 9 Oct 2026 18:42:06 +0000 Subject: [PATCH 06/10] ci(repo): print a failed shard's logs before its diagnostics re-run On #81 the shard ends with exit 3 about 26 s into mutation testing, and the job log keeps only the first 1024 characters of the child's stderr, which are startup lines. The diagnostics re-run then ran for 16 minutes until the VM was lost, so its stderr.log never reached an artifact. A new step prints the last 32 KiB of each package's stryker.log as soon as the shard fails, before anything else runs. The re-run is bounded by timeout 600 (kill after 30 s more) and --concurrency 1, which gives one checker and one test runner, and prints the last 32 KiB of its stderr, stdout and stryker.log into the job log before the upload step. --- .github/scripts/print-log-tails.sh | 13 +++++++++++++ .github/workflows/release-gate.yml | 10 ++++++++-- 2 files changed, 21 insertions(+), 2 deletions(-) create mode 100755 .github/scripts/print-log-tails.sh diff --git a/.github/scripts/print-log-tails.sh b/.github/scripts/print-log-tails.sh new file mode 100755 index 0000000..168d49e --- /dev/null +++ b/.github/scripts/print-log-tails.sh @@ -0,0 +1,13 @@ +#!/usr/bin/env bash +set -u +limit=32768 +for file in "$@"; do + echo "::group::last ${limit} bytes of ${file}" + if [ -f "$file" ]; then + tail -c "$limit" "$file" + echo + else + echo "(no such file)" + fi + echo "::endgroup::" +done diff --git a/.github/workflows/release-gate.yml b/.github/workflows/release-gate.yml index 5fff451..7613240 100644 --- a/.github/workflows/release-gate.yml +++ b/.github/workflows/release-gate.yml @@ -133,6 +133,9 @@ jobs: name: mutation-report-shard-${{ strategy.job-index }} path: packages/*/reports/mutation-report.* if-no-files-found: ignore + - name: Print the failed shard's Stryker logs + if: failure() + run: .github/scripts/print-log-tails.sh packages/*/stryker.log - name: Re-run a failed shard's project runs with their output kept if: failure() timeout-minutes: 20 @@ -153,12 +156,15 @@ jobs: while read -r project; do out="$root/reports/diagnostics/$project" mkdir -p "$out" - (cd "$project" && "$root/node_modules/.bin/stryker" run --plan "$root/stryker-plan.json" \ - --shard "$MUTATION_SHARD" --project "$project" \ + (cd "$project" && timeout --kill-after=30 600 "$root/node_modules/.bin/stryker" run \ + --plan "$root/stryker-plan.json" --shard "$MUTATION_SHARD" --project "$project" \ + --concurrency 1 \ --progressStreamFile "$out/mutation-stream.jsonl" \ --incremental --incrementalFile "$out/stryker-incremental.json" \ > "$out/stdout.log" 2> "$out/stderr.log") echo "$project exited $?" | tee "$out/exit.txt" + "$root/.github/scripts/print-log-tails.sh" \ + "$out/stderr.log" "$out/stdout.log" "$root/$project/stryker.log" done < .cache/shard-projects.txt - if: failure() uses: actions/upload-artifact@v6 From 930de0ae91364f16a8a81cbb7d763bfa82d81797 Mon Sep 17 00:00:00 2001 From: Ryan Lee Date: Fri, 9 Oct 2026 19:11:12 +0000 Subject: [PATCH 07/10] ci(repo): run mutation shards with one checker and one test runner At Stryker's default concurrency on the 4 vCPU hosted runner, a shard starts 2 checkers, each driving a native tsgo process, and 2 test runners. Run 37975117762 on #82 (main's code) and every gate run on #81 ended with exit 3 about two minutes in, or lost the 16 GB VM. The bounded diagnostics re-run of the same project at concurrency 1 passed its dry run and tested 276 mutants in five minutes without a failure. concurrency: 2 splits into one checker and one test runner. The 2 GiB test-runner heap cap stays as a second guard. --- stryker.shared.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/stryker.shared.ts b/stryker.shared.ts index 00b9b86..074fa73 100644 --- a/stryker.shared.ts +++ b/stryker.shared.ts @@ -17,6 +17,8 @@ const scopedMutate = (mutate: ReadonlyArray, scope: string | undefined): export const packageStrykerConfig = (mutate: ReadonlyArray): StrykerConfig => ({ checkers: [{ plugin: '@systemfsoftware/stryker-js-typescript-checker' }], + // At the default of 4 (2 checkers, each driving tsgo), shards on the 16 GB hosted runner exited 3 or lost the VM. + concurrency: 2, coverageAnalysis: 'perTest', disableBail: true, htmlReporter: { fileName: 'reports/mutation-report.html' }, From 3e1f34ffdf0aeb8f1d29e71b939109f4318f26bf Mon Sep 17 00:00:00 2001 From: Ryan Lee Date: Fri, 9 Oct 2026 19:47:48 +0000 Subject: [PATCH 08/10] ci(repo): print the failed shard's progress streams instead of stryker.log Run 37978503748 printed "(no such file)" for packages/*/stryker.log. This Stryker build accepts fileLogLevel but nothing writes the file: the option appears only in the schema, the CLI table and the fingerprint key list. The setting and every stryker.log path go. The print step now tails the shard wrapper's own progress stream (reports/mutation-stream.jsonl, the default for a run without --progressStreamFile) and each project child's stream under reports/shards. Every run writes its framed events to that file, one synced line at a time. The diagnostics artifact keeps the wrapper's stream, and the re-run prints its stderr and stdout. --- .github/workflows/release-gate.yml | 9 ++++----- stryker.shared.ts | 1 - 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/.github/workflows/release-gate.yml b/.github/workflows/release-gate.yml index 7613240..4eedc7c 100644 --- a/.github/workflows/release-gate.yml +++ b/.github/workflows/release-gate.yml @@ -133,9 +133,9 @@ jobs: name: mutation-report-shard-${{ strategy.job-index }} path: packages/*/reports/mutation-report.* if-no-files-found: ignore - - name: Print the failed shard's Stryker logs + - name: Print the failed shard's progress streams if: failure() - run: .github/scripts/print-log-tails.sh packages/*/stryker.log + run: .github/scripts/print-log-tails.sh reports/mutation-stream.jsonl reports/shards/*/*/*/mutation-stream.jsonl - name: Re-run a failed shard's project runs with their output kept if: failure() timeout-minutes: 20 @@ -163,8 +163,7 @@ jobs: --incremental --incrementalFile "$out/stryker-incremental.json" \ > "$out/stdout.log" 2> "$out/stderr.log") echo "$project exited $?" | tee "$out/exit.txt" - "$root/.github/scripts/print-log-tails.sh" \ - "$out/stderr.log" "$out/stdout.log" "$root/$project/stryker.log" + "$root/.github/scripts/print-log-tails.sh" "$out/stderr.log" "$out/stdout.log" done < .cache/shard-projects.txt - if: failure() uses: actions/upload-artifact@v6 @@ -172,7 +171,7 @@ jobs: name: mutation-diagnostics-shard-${{ strategy.job-index }} path: | reports/diagnostics/ - packages/*/stryker.log + reports/mutation-stream.jsonl if-no-files-found: ignore verdict: diff --git a/stryker.shared.ts b/stryker.shared.ts index 074fa73..be1c031 100644 --- a/stryker.shared.ts +++ b/stryker.shared.ts @@ -22,7 +22,6 @@ export const packageStrykerConfig = (mutate: ReadonlyArray): StrykerConf coverageAnalysis: 'perTest', disableBail: true, htmlReporter: { fileName: 'reports/mutation-report.html' }, - fileLogLevel: 'info', ignorePatterns: ['reports', 'coverage', 'dist'], incremental: true, incrementalFile: 'reports/stryker-incremental.json', From 7cd18c2fca9e6795baea8bceff174f3d843cd7ef Mon Sep 17 00:00:00 2001 From: Ryan Lee Date: Fri, 9 Oct 2026 20:01:13 +0000 Subject: [PATCH 09/10] ci(repo): run each shard project's stryker child with its output kept `stryker run --shard` spawns one child per planned project with stdout ignored, keeps 4096 characters of its stderr and prints 1024 of them (stryker-js dist/main.mjs 113412-113483, 114204-114206). Every exit-3 run on #81 and #82 lost the error that way. The shard step now runs the same children itself: - stryker-plan-gate.ts gains a `shard` mode. It decodes the plan with the gate's ShardPlan schema and finds the shard by its index/count label, as the wrapper does (112975-112980, 113345-113362), refusing an unknown label. It writes "\t" lines in plan order. - .github/scripts/run-shard.sh runs each project in its own directory with the wrapper's exact args (113415-113429) and seeding (113431-113437), writing stdout.log and stderr.log beside the stream under reports/shards///. Exit 1 logs the wrapper's below-threshold line and carries on (113478); any other exit, or a missing progress stream, prints both log tails and stops the shard, as the wrapper's sequential forEach does (113474-113483). The diagnostics re-run and its artifact go: the shard artifact now carries both logs. The verdict job, merge and gate are unchanged. --- .github/scripts/run-shard.sh | 37 +++++++++++++++++++ .github/workflows/release-gate.yml | 57 +++++++++--------------------- scripts/stryker-plan-gate.test.ts | 41 ++++++++++++++++++++- scripts/stryker-plan-gate.ts | 43 +++++++++++++++++++++- 4 files changed, 135 insertions(+), 43 deletions(-) create mode 100755 .github/scripts/run-shard.sh diff --git a/.github/scripts/run-shard.sh b/.github/scripts/run-shard.sh new file mode 100755 index 0000000..f405e05 --- /dev/null +++ b/.github/scripts/run-shard.sh @@ -0,0 +1,37 @@ +#!/usr/bin/env bash +set -uo pipefail + +plan=$(realpath "$1") +shard=$2 +projects=$3 +root=$(dirname "$plan") +stryker="$root/node_modules/.bin/stryker" +tails="$(realpath "$(dirname "$0")")/print-log-tails.sh" + +abort() { + echo "::error::stryker shard: $1" + "$tails" "$2/stderr.log" "$2/stdout.log" + exit "$3" +} + +while IFS=$'\t' read -r index project; do + out="$root/reports/shards/$index/$project" + mkdir -p "$out" || abort "$project: cannot create $out" "$out" 3 + cached="$root/$project/reports/stryker-incremental.json" + if [ -f "$cached" ] && [ ! -f "$out/stryker-incremental.json" ]; then + cp "$cached" "$out/stryker-incremental.json" || abort "$project: cannot seed its incremental file" "$out" 3 + fi + (cd "$root/$project" && "$stryker" run \ + --plan "$plan" --shard "$shard" --project "$project" \ + --progressStreamFile "$out/mutation-stream.jsonl" \ + --incremental --incrementalFile "$out/stryker-incremental.json" \ + < /dev/null > "$out/stdout.log" 2> "$out/stderr.log") + code=$? + case $code in + 0) ;; + 1) echo "stryker shard: $project scored below thresholds.break over this shard's mutants; the merged report carries the project verdict" ;; + *) abort "$project exited $code" "$out" "$code" ;; + esac + [ -f "$out/mutation-stream.jsonl" ] || + abort "$project completed without leaving its progress stream at $out/mutation-stream.jsonl" "$out" 3 +done < "$projects" diff --git a/.github/workflows/release-gate.yml b/.github/workflows/release-gate.yml index 4eedc7c..726eb5c 100644 --- a/.github/workflows/release-gate.yml +++ b/.github/workflows/release-gate.yml @@ -111,6 +111,21 @@ jobs: reports/shards/**/stryker-incremental.json key: stryker-shard-${{ matrix.shard }}-${{ github.sha }} restore-keys: stryker-shard-${{ matrix.shard }}- + - name: Select the shard's projects + uses: ./.github/actions/sandbox + env: + MUTATION_SHARD: ${{ matrix.shard }} + with: + hosts: | + jsr.io + registry.npmjs.org + pass-env: | + MUTATION_SHARD + command: >- + mkdir -p .cache && + deno run --config=scripts/deno.json --allow-read --allow-write + scripts/stryker-plan-gate.ts shard --plan stryker-plan.json --shard "$MUTATION_SHARD" + --out .cache/shard-projects.tsv - name: Mutation at break 100 uses: ./.github/actions/sandbox env: @@ -120,7 +135,7 @@ jobs: GITHUB_ACTIONS MUTATION_SHARD MUTATION_SCOPE - command: ./node_modules/.bin/stryker run --plan stryker-plan.json --shard "$MUTATION_SHARD" + command: .github/scripts/run-shard.sh stryker-plan.json "$MUTATION_SHARD" .cache/shard-projects.tsv - if: always() uses: actions/upload-artifact@v6 with: @@ -133,46 +148,6 @@ jobs: name: mutation-report-shard-${{ strategy.job-index }} path: packages/*/reports/mutation-report.* if-no-files-found: ignore - - name: Print the failed shard's progress streams - if: failure() - run: .github/scripts/print-log-tails.sh reports/mutation-stream.jsonl reports/shards/*/*/*/mutation-stream.jsonl - - name: Re-run a failed shard's project runs with their output kept - if: failure() - timeout-minutes: 20 - uses: ./.github/actions/sandbox - env: - MUTATION_SHARD: ${{ matrix.shard }} - with: - pass-env: | - GITHUB_ACTIONS - MUTATION_SHARD - MUTATION_SCOPE - command: | - set -uo pipefail - root="$PWD" - jq -r --arg shard "$MUTATION_SHARD" \ - '.shards[] | select("\(.index)/\(.count)" == $shard) | .projects[].project' \ - stryker-plan.json > .cache/shard-projects.txt - while read -r project; do - out="$root/reports/diagnostics/$project" - mkdir -p "$out" - (cd "$project" && timeout --kill-after=30 600 "$root/node_modules/.bin/stryker" run \ - --plan "$root/stryker-plan.json" --shard "$MUTATION_SHARD" --project "$project" \ - --concurrency 1 \ - --progressStreamFile "$out/mutation-stream.jsonl" \ - --incremental --incrementalFile "$out/stryker-incremental.json" \ - > "$out/stdout.log" 2> "$out/stderr.log") - echo "$project exited $?" | tee "$out/exit.txt" - "$root/.github/scripts/print-log-tails.sh" "$out/stderr.log" "$out/stdout.log" - done < .cache/shard-projects.txt - - if: failure() - uses: actions/upload-artifact@v6 - with: - name: mutation-diagnostics-shard-${{ strategy.job-index }} - path: | - reports/diagnostics/ - reports/mutation-stream.jsonl - if-no-files-found: ignore verdict: name: verdict · merged report diff --git a/scripts/stryker-plan-gate.test.ts b/scripts/stryker-plan-gate.test.ts index cc8f9e6..d84cc9a 100644 --- a/scripts/stryker-plan-gate.test.ts +++ b/scripts/stryker-plan-gate.test.ts @@ -1,6 +1,6 @@ import { assertEquals } from '@std/assert' import { join } from '@std/path' -import { gatePlan, type Refusal, scopeOf, type ShardPlan } from './stryker-plan-gate.ts' +import { gatePlan, type Refusal, scopeOf, selectShardProjects, type ShardPlan } from './stryker-plan-gate.ts' interface FixturePackage { readonly dir: string @@ -75,6 +75,45 @@ Deno.test('a valid plan yields the plan matrix and has-shards', async () => { }) }) +Deno.test('a shard label selects that shard index and its projects in plan order', async () => { + const plan: ShardPlan = { + ...TWO_PROJECT_PLAN, + shards: [ + TWO_PROJECT_PLAN.shards[0]!, + { + index: 2, + count: 2, + predictedSeconds: 3, + projects: [ + { project: 'packages/site', mutants: ['fedcba9876543210'] }, + { project: 'packages/core', mutants: ['00112233445566ff'] }, + ], + }, + ], + } + const { root, planFile } = await writeFixture({ plan }) + assertEquals(await selectShardProjects({ root, planFile, shard: '2/2' }), { + ok: true, + selected: { index: 2, projects: ['packages/site', 'packages/core'] }, + }) +}) + +Deno.test('a shard label the plan does not carry is refused with the labels it does carry', async () => { + const { root, planFile } = await writeFixture({ plan: TWO_PROJECT_PLAN }) + assertEquals(await selectShardProjects({ root, planFile, shard: '2/3' }), { + ok: false, + refusal: { _tag: 'ShardUnknown', shard: '2/3', labels: ['1/2', '2/2'] }, + }) +}) + +Deno.test('shard selection without a plan file is refused', async () => { + const { root, planFile } = await writeFixture({}) + assertEquals(await selectShardProjects({ root, planFile, shard: '1/1' }), { + ok: false, + refusal: { _tag: 'PlanMissing', file: join(root, planFile) }, + }) +}) + Deno.test('a planned project that resolves outside the repository is refused (C13-13)', async () => { const { root, planFile } = await writeFixture({ packages: [{ dir: 'packages/core', name: '@fixture/core', mutates: true }], diff --git a/scripts/stryker-plan-gate.ts b/scripts/stryker-plan-gate.ts index de7c2d4..9d5d521 100755 --- a/scripts/stryker-plan-gate.ts +++ b/scripts/stryker-plan-gate.ts @@ -69,6 +69,7 @@ export type Refusal = | { readonly _tag: 'PlannedProjectEscapesRoot'; readonly project: string } | { readonly _tag: 'MutationPackageWithoutMutants'; readonly package: string } | { readonly _tag: 'VacuousPlan' } + | { readonly _tag: 'ShardUnknown'; readonly shard: string; readonly labels: readonly string[] } export const renderRefusal = (refusal: Refusal): string => { switch (refusal._tag) { @@ -90,6 +91,8 @@ export const renderRefusal = (refusal: Refusal): string => { return `${refusal.package}: declares a mutation script but stryker plan scheduled no mutants for it, and no ${MUTATION_EXEMPTION_RULE} debt-ledger entry exempts it` case 'VacuousPlan': return 'stryker plan scheduled no mutants and no debt-ledger entry exempts anything; the release gate refuses a vacuous set' + case 'ShardUnknown': + return `unknown shard ${refusal.shard}; plan has ${refusal.labels.join(', ')}` } } @@ -410,9 +413,32 @@ export const gatePlan = async ({ root, planFile, changed }: GateInput): Promise< } } +export interface ShardProjects { + readonly index: number + readonly projects: readonly string[] +} + +export type ShardSelection = + | { readonly ok: true; readonly selected: ShardProjects } + | { readonly ok: false; readonly refusal: Refusal } + +export const selectShardProjects = async ( + { root, planFile, shard }: { readonly root: string; readonly planFile: string; readonly shard: string }, +): Promise => { + const resolvedPlanFile = resolve(root, planFile) + const planText = await readText(resolvedPlanFile) + if (planText === undefined) return { ok: false, refusal: { _tag: 'PlanMissing', file: resolvedPlanFile } } + const read = decodePlan(resolvedPlanFile, planText) + if (!read.ok) return read + const found = read.plan.shards.find((candidate) => labelOf(candidate) === shard) + return found === undefined + ? { ok: false, refusal: { _tag: 'ShardUnknown', shard, labels: read.plan.shards.map(labelOf) } } + : { ok: true, selected: { index: found.index, projects: found.projects.map((entry) => entry.project) } } +} + if (import.meta.main) { const args = parseArgs(Deno.args, { - string: ['root', 'plan', 'out', 'changed', 'scope-out'], + string: ['root', 'plan', 'out', 'changed', 'scope-out', 'shard'], default: { root: '.' }, }) const mode = args._[0] @@ -439,6 +465,21 @@ if (import.meta.main) { Deno.exit(0) } + if (mode === 'shard') { + if (args.plan === undefined || args.shard === undefined) { + console.error('stryker-plan-gate: shard needs --plan --shard ') + Deno.exit(2) + } + const selection = await selectShardProjects({ root: args.root, planFile: args.plan, shard: args.shard }) + if (!selection.ok) { + console.error(`stryker-plan-gate: ${renderRefusal(selection.refusal)}`) + Deno.exit(2) + } + const { index, projects } = selection.selected + await emit(projects.map((project) => `${index}\t${project}\n`).join('')) + Deno.exit(0) + } + if (args.plan === undefined) { console.error('stryker-plan-gate: gate needs --plan ') Deno.exit(2) From d7f31aab660f222ff1c2b19b1aadad51c27df016 Mon Sep 17 00:00:00 2001 From: Ryan Lee Date: Fri, 9 Oct 2026 20:57:06 +0000 Subject: [PATCH 10/10] ci(repo): take #82's release-gate commits back out of the guards branch The guards capability no longer waits on a pull-request mutation gate. Mutation runs on main only, so this branch goes back onto main without #82's gate changes. release-gate.yml, the shard scripts, stryker-plan-gate.ts with its tests, and stryker.shared.ts return to main's content. They reach main through #82 itself. --- .github/scripts/print-log-tails.sh | 13 -------- .github/scripts/run-shard.sh | 37 --------------------- .github/workflows/release-gate.yml | 52 +++++++++++++++++++++--------- scripts/stryker-plan-gate.test.ts | 41 +---------------------- scripts/stryker-plan-gate.ts | 43 +----------------------- stryker.shared.ts | 5 +-- 6 files changed, 39 insertions(+), 152 deletions(-) delete mode 100755 .github/scripts/print-log-tails.sh delete mode 100755 .github/scripts/run-shard.sh diff --git a/.github/scripts/print-log-tails.sh b/.github/scripts/print-log-tails.sh deleted file mode 100755 index 168d49e..0000000 --- a/.github/scripts/print-log-tails.sh +++ /dev/null @@ -1,13 +0,0 @@ -#!/usr/bin/env bash -set -u -limit=32768 -for file in "$@"; do - echo "::group::last ${limit} bytes of ${file}" - if [ -f "$file" ]; then - tail -c "$limit" "$file" - echo - else - echo "(no such file)" - fi - echo "::endgroup::" -done diff --git a/.github/scripts/run-shard.sh b/.github/scripts/run-shard.sh deleted file mode 100755 index f405e05..0000000 --- a/.github/scripts/run-shard.sh +++ /dev/null @@ -1,37 +0,0 @@ -#!/usr/bin/env bash -set -uo pipefail - -plan=$(realpath "$1") -shard=$2 -projects=$3 -root=$(dirname "$plan") -stryker="$root/node_modules/.bin/stryker" -tails="$(realpath "$(dirname "$0")")/print-log-tails.sh" - -abort() { - echo "::error::stryker shard: $1" - "$tails" "$2/stderr.log" "$2/stdout.log" - exit "$3" -} - -while IFS=$'\t' read -r index project; do - out="$root/reports/shards/$index/$project" - mkdir -p "$out" || abort "$project: cannot create $out" "$out" 3 - cached="$root/$project/reports/stryker-incremental.json" - if [ -f "$cached" ] && [ ! -f "$out/stryker-incremental.json" ]; then - cp "$cached" "$out/stryker-incremental.json" || abort "$project: cannot seed its incremental file" "$out" 3 - fi - (cd "$root/$project" && "$stryker" run \ - --plan "$plan" --shard "$shard" --project "$project" \ - --progressStreamFile "$out/mutation-stream.jsonl" \ - --incremental --incrementalFile "$out/stryker-incremental.json" \ - < /dev/null > "$out/stdout.log" 2> "$out/stderr.log") - code=$? - case $code in - 0) ;; - 1) echo "stryker shard: $project scored below thresholds.break over this shard's mutants; the merged report carries the project verdict" ;; - *) abort "$project exited $code" "$out" "$code" ;; - esac - [ -f "$out/mutation-stream.jsonl" ] || - abort "$project completed without leaving its progress stream at $out/mutation-stream.jsonl" "$out" 3 -done < "$projects" diff --git a/.github/workflows/release-gate.yml b/.github/workflows/release-gate.yml index 726eb5c..5fff451 100644 --- a/.github/workflows/release-gate.yml +++ b/.github/workflows/release-gate.yml @@ -111,21 +111,6 @@ jobs: reports/shards/**/stryker-incremental.json key: stryker-shard-${{ matrix.shard }}-${{ github.sha }} restore-keys: stryker-shard-${{ matrix.shard }}- - - name: Select the shard's projects - uses: ./.github/actions/sandbox - env: - MUTATION_SHARD: ${{ matrix.shard }} - with: - hosts: | - jsr.io - registry.npmjs.org - pass-env: | - MUTATION_SHARD - command: >- - mkdir -p .cache && - deno run --config=scripts/deno.json --allow-read --allow-write - scripts/stryker-plan-gate.ts shard --plan stryker-plan.json --shard "$MUTATION_SHARD" - --out .cache/shard-projects.tsv - name: Mutation at break 100 uses: ./.github/actions/sandbox env: @@ -135,7 +120,7 @@ jobs: GITHUB_ACTIONS MUTATION_SHARD MUTATION_SCOPE - command: .github/scripts/run-shard.sh stryker-plan.json "$MUTATION_SHARD" .cache/shard-projects.tsv + command: ./node_modules/.bin/stryker run --plan stryker-plan.json --shard "$MUTATION_SHARD" - if: always() uses: actions/upload-artifact@v6 with: @@ -148,6 +133,41 @@ jobs: name: mutation-report-shard-${{ strategy.job-index }} path: packages/*/reports/mutation-report.* if-no-files-found: ignore + - name: Re-run a failed shard's project runs with their output kept + if: failure() + timeout-minutes: 20 + uses: ./.github/actions/sandbox + env: + MUTATION_SHARD: ${{ matrix.shard }} + with: + pass-env: | + GITHUB_ACTIONS + MUTATION_SHARD + MUTATION_SCOPE + command: | + set -uo pipefail + root="$PWD" + jq -r --arg shard "$MUTATION_SHARD" \ + '.shards[] | select("\(.index)/\(.count)" == $shard) | .projects[].project' \ + stryker-plan.json > .cache/shard-projects.txt + while read -r project; do + out="$root/reports/diagnostics/$project" + mkdir -p "$out" + (cd "$project" && "$root/node_modules/.bin/stryker" run --plan "$root/stryker-plan.json" \ + --shard "$MUTATION_SHARD" --project "$project" \ + --progressStreamFile "$out/mutation-stream.jsonl" \ + --incremental --incrementalFile "$out/stryker-incremental.json" \ + > "$out/stdout.log" 2> "$out/stderr.log") + echo "$project exited $?" | tee "$out/exit.txt" + done < .cache/shard-projects.txt + - if: failure() + uses: actions/upload-artifact@v6 + with: + name: mutation-diagnostics-shard-${{ strategy.job-index }} + path: | + reports/diagnostics/ + packages/*/stryker.log + if-no-files-found: ignore verdict: name: verdict · merged report diff --git a/scripts/stryker-plan-gate.test.ts b/scripts/stryker-plan-gate.test.ts index d84cc9a..cc8f9e6 100644 --- a/scripts/stryker-plan-gate.test.ts +++ b/scripts/stryker-plan-gate.test.ts @@ -1,6 +1,6 @@ import { assertEquals } from '@std/assert' import { join } from '@std/path' -import { gatePlan, type Refusal, scopeOf, selectShardProjects, type ShardPlan } from './stryker-plan-gate.ts' +import { gatePlan, type Refusal, scopeOf, type ShardPlan } from './stryker-plan-gate.ts' interface FixturePackage { readonly dir: string @@ -75,45 +75,6 @@ Deno.test('a valid plan yields the plan matrix and has-shards', async () => { }) }) -Deno.test('a shard label selects that shard index and its projects in plan order', async () => { - const plan: ShardPlan = { - ...TWO_PROJECT_PLAN, - shards: [ - TWO_PROJECT_PLAN.shards[0]!, - { - index: 2, - count: 2, - predictedSeconds: 3, - projects: [ - { project: 'packages/site', mutants: ['fedcba9876543210'] }, - { project: 'packages/core', mutants: ['00112233445566ff'] }, - ], - }, - ], - } - const { root, planFile } = await writeFixture({ plan }) - assertEquals(await selectShardProjects({ root, planFile, shard: '2/2' }), { - ok: true, - selected: { index: 2, projects: ['packages/site', 'packages/core'] }, - }) -}) - -Deno.test('a shard label the plan does not carry is refused with the labels it does carry', async () => { - const { root, planFile } = await writeFixture({ plan: TWO_PROJECT_PLAN }) - assertEquals(await selectShardProjects({ root, planFile, shard: '2/3' }), { - ok: false, - refusal: { _tag: 'ShardUnknown', shard: '2/3', labels: ['1/2', '2/2'] }, - }) -}) - -Deno.test('shard selection without a plan file is refused', async () => { - const { root, planFile } = await writeFixture({}) - assertEquals(await selectShardProjects({ root, planFile, shard: '1/1' }), { - ok: false, - refusal: { _tag: 'PlanMissing', file: join(root, planFile) }, - }) -}) - Deno.test('a planned project that resolves outside the repository is refused (C13-13)', async () => { const { root, planFile } = await writeFixture({ packages: [{ dir: 'packages/core', name: '@fixture/core', mutates: true }], diff --git a/scripts/stryker-plan-gate.ts b/scripts/stryker-plan-gate.ts index 9d5d521..de7c2d4 100755 --- a/scripts/stryker-plan-gate.ts +++ b/scripts/stryker-plan-gate.ts @@ -69,7 +69,6 @@ export type Refusal = | { readonly _tag: 'PlannedProjectEscapesRoot'; readonly project: string } | { readonly _tag: 'MutationPackageWithoutMutants'; readonly package: string } | { readonly _tag: 'VacuousPlan' } - | { readonly _tag: 'ShardUnknown'; readonly shard: string; readonly labels: readonly string[] } export const renderRefusal = (refusal: Refusal): string => { switch (refusal._tag) { @@ -91,8 +90,6 @@ export const renderRefusal = (refusal: Refusal): string => { return `${refusal.package}: declares a mutation script but stryker plan scheduled no mutants for it, and no ${MUTATION_EXEMPTION_RULE} debt-ledger entry exempts it` case 'VacuousPlan': return 'stryker plan scheduled no mutants and no debt-ledger entry exempts anything; the release gate refuses a vacuous set' - case 'ShardUnknown': - return `unknown shard ${refusal.shard}; plan has ${refusal.labels.join(', ')}` } } @@ -413,32 +410,9 @@ export const gatePlan = async ({ root, planFile, changed }: GateInput): Promise< } } -export interface ShardProjects { - readonly index: number - readonly projects: readonly string[] -} - -export type ShardSelection = - | { readonly ok: true; readonly selected: ShardProjects } - | { readonly ok: false; readonly refusal: Refusal } - -export const selectShardProjects = async ( - { root, planFile, shard }: { readonly root: string; readonly planFile: string; readonly shard: string }, -): Promise => { - const resolvedPlanFile = resolve(root, planFile) - const planText = await readText(resolvedPlanFile) - if (planText === undefined) return { ok: false, refusal: { _tag: 'PlanMissing', file: resolvedPlanFile } } - const read = decodePlan(resolvedPlanFile, planText) - if (!read.ok) return read - const found = read.plan.shards.find((candidate) => labelOf(candidate) === shard) - return found === undefined - ? { ok: false, refusal: { _tag: 'ShardUnknown', shard, labels: read.plan.shards.map(labelOf) } } - : { ok: true, selected: { index: found.index, projects: found.projects.map((entry) => entry.project) } } -} - if (import.meta.main) { const args = parseArgs(Deno.args, { - string: ['root', 'plan', 'out', 'changed', 'scope-out', 'shard'], + string: ['root', 'plan', 'out', 'changed', 'scope-out'], default: { root: '.' }, }) const mode = args._[0] @@ -465,21 +439,6 @@ if (import.meta.main) { Deno.exit(0) } - if (mode === 'shard') { - if (args.plan === undefined || args.shard === undefined) { - console.error('stryker-plan-gate: shard needs --plan --shard ') - Deno.exit(2) - } - const selection = await selectShardProjects({ root: args.root, planFile: args.plan, shard: args.shard }) - if (!selection.ok) { - console.error(`stryker-plan-gate: ${renderRefusal(selection.refusal)}`) - Deno.exit(2) - } - const { index, projects } = selection.selected - await emit(projects.map((project) => `${index}\t${project}\n`).join('')) - Deno.exit(0) - } - if (args.plan === undefined) { console.error('stryker-plan-gate: gate needs --plan ') Deno.exit(2) diff --git a/stryker.shared.ts b/stryker.shared.ts index be1c031..c051663 100644 --- a/stryker.shared.ts +++ b/stryker.shared.ts @@ -3,7 +3,6 @@ import { relative } from 'node:path' import { fileURLToPath } from 'node:url' const slackForAContendedFullSuiteRunMs = 45_000 -const heapPerTestRunnerIsolateMb = 2048 const repoRoot = fileURLToPath(new URL('.', import.meta.url)) @@ -17,11 +16,10 @@ const scopedMutate = (mutate: ReadonlyArray, scope: string | undefined): export const packageStrykerConfig = (mutate: ReadonlyArray): StrykerConfig => ({ checkers: [{ plugin: '@systemfsoftware/stryker-js-typescript-checker' }], - // At the default of 4 (2 checkers, each driving tsgo), shards on the 16 GB hosted runner exited 3 or lost the VM. - concurrency: 2, coverageAnalysis: 'perTest', disableBail: true, htmlReporter: { fileName: 'reports/mutation-report.html' }, + fileLogLevel: 'info', ignorePatterns: ['reports', 'coverage', 'dist'], incremental: true, incrementalFile: 'reports/stryker-incremental.json', @@ -34,7 +32,6 @@ export const packageStrykerConfig = (mutate: ReadonlyArray): StrykerConf plugin: '@systemfsoftware/stryker-js-vitest-runner', options: { configFile: 'vitest.config.ts', dir: '.', related: true }, }, - testRunnerNodeArgs: [`--max-old-space-size=${heapPerTestRunnerIsolateMb}`], timeoutMS: slackForAContendedFullSuiteRunMs, thresholds: { break: 100, high: 100, low: 100 }, }) satisfies StrykerConfig