Skip to content

feat(repo): test xstate's fsm as a conformance spec - #75

Merged
ryanleecode merged 8 commits into
mainfrom
xs/xstate-fsm
Oct 9, 2026
Merged

ryanleecode merged 8 commits into
mainfrom
xs/xstate-fsm

Conversation

@systemfsoftware-maker

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

Copy link
Copy Markdown
Collaborator

Summary

@systemfsoftware/xstate's fsm capability (@systemfsoftware/xstate/fsm: createFSM, setup, types) is now judged by a conformance spec over a hand-written model of a flat event table, in place of 20 hand-picked cases across six files. src/fsm.ts joins the mutated set and the recommended lint.

Behaviour change

Clearing the 12 cell-architecture(ban-unknown) findings in src/fsm.ts narrowed two published signatures (conductor ruling, cycle 178, item 2: minor, break stated):

  • createFSM, and the createFSM that setup returns, take the machine's state names as their state type parameter ('idle' | 'done'), not a record keyed by state name ({ idle: unknown; done: unknown }).
  • FSM.initialTransition() and FSM.getInitialSnapshot() take no arguments. They never read the ones they accepted.

.changeset/fsm-state-name-type-parameter.md (minor) states the break and the migration step, and its validator exits 0. Inferred states, createActor, initialTransition and transition are unchanged. A transition function that returns null, which the published type does not admit but main's runtime absorbed, is still a no-op that keeps the snapshot (probe on bfd7a3c9: value a, context { n: 1 }, effects [], same object; the reviewer's main run gave the same).

Deleted tests -> new coverage (CONST-T9)

The spec is tests/fsm.conformance.test.ts. Its model, tests/__fixtures__/fsm.model.ts, imports only effect and derives every expected observation from the generated machine and events alone. The fixture tests/__fixtures__/fsm.ts drives the published API through four drivers: fsm.transition, the initialTransition/transition entry points, createActor, and a JSON-persisted then restored actor. It builds each machine through one of two factories, createFSM or setup().createFSM. A ledger row requires every transition kind, a transition function that returns nothing, a state without an on table, inherited event names, inherited patch keys, a machine without context, actor emissions, every driver and both factories to occur on each seed.

Deleted file (cases) Behaviour Covered by
fsm-entrypoint.test.ts (2) the entrypoint exports only createFSM, setup, types scenario "The published FSM entrypoint exposes only the pure FSM API" (the exact name list)
a machine without context transitions through {} seed outline: withContext: false machines start from {} (sabotage E)
fsm.actor.test.ts (4) runs in createActor: final value, context, status: 'active', one subscriber emission per sent event, including a no-op seed outline, actor driver: status, emissions (sabotage C)
a state without an on table (the counter's stopped: {}) ignores every event seed outline: bareState machines; the ledger requires events sent in that state (bareStateEvents) on every seed (sabotage I)
transition() and fsm.transition() agree; effects are [] seed outline, entry and pure drivers: same model; effects: 0
initialTransition() returns initialState itself, as does getInitialSnapshot() seed outline, entry driver: initialIdentity
a JSON-persisted snapshot restores and keeps transitioning seed outline, persisted driver, including its emissions after restore
fsm.differential.test.ts (2) agrees with an equivalent flat createMachine, including pure context updates the hand-written model replaces the second implementation as the oracle (CONST-T10)
fsm.setup.test.ts (3) setup().createFSM runs: transitions, patches, function transitions seed outline, setup factory, with and without a context schema (sabotage H)
typed events, payloads, state-correlated context narrowing typeLevelContract: userMachine rows (loaded narrows to user; refuses status satisfies 'loading'; refuses a numeric user)
entering a state with a different context schema requires context typeLevelContract: four @ts-expect-error rows (initial context, string target, missing user, function transition without context)
root context combined with a partial state schema typeLevelContract: draftMachine rows (requestId and draft both string; skip without a draft refused)
fsm.test.ts (6) transitions through a flat event table seed outline (targetChanges); an event in a restored state the machine does not declare leaves the snapshot as it was (scenario, sabotage K)
function transitions with context updates seed outline (Add slot, functionTransitions); a function that returns nothing is a no-op (Absent slot, absentReturns, sabotage J)
a no-op context patch keeps snapshot identity seed outline: contextKept, the model's per-step prediction of whether the context object is kept, read from every driver; the ledger requires a patch that changes nothing (unchangedPatches) on every seed. sentinelKeptIdentity covers the snapshot on a step with no transition. Planted subjects wrap the FSM's own transition: one copies the snapshot on a no-op, one copies the context on an unchanged patch. Sabotages B and F1
only own context patch keys apply seed outline (inherited count/tag on the patch prototype); planted subject that reads them; sabotage F
inherited event names are ignored seed outline (toString, constructor, prototype toggle); planted subject; sabotage A
output and error are own snapshot keys, also through JSON seed outline: snapshotKeys of initialState, getInitialSnapshot() and every pure/entry step, plain and JSON round-tripped, equal status,value,context,output,error (sabotage G)
fsm.types.test.ts (3) context, payloads and targets are typed; declared context cannot be omitted; undeclared targets and events are refused typeLevelContract rows
FSM satisfies ActorLogic; SnapshotFrom/EventFromLogic are exact; an actor refuses an undeclared event typeLevelContract: satisfies ActorLogic, two type-level Equal rows, actor send refusal
setup snapshot unions survive into actors typeLevelContract: SnapshotFrom<typeof loadedMachine> narrows context.id to string

typeLevelContract is a function that is type-checked and never called, so no createFSM runs while test modules load. On main, module-level construction made fsm.ts mutants static, so each one ran the whole suite at the gate. tsc -p tsconfig.test.json fails on any @ts-expect-error row that stops erroring.

Enrolment

  • Mutated set (packages/xstate/stryker.config.ts): src/assert.ts, src/stateMatching.ts, src/mapState.ts, src/fsm.ts.
  • Lint: src/fsm.ts and the spec are in the lint script, with 0 findings and no disable comments. tsconfig.tsgo.json includes src/fsm.ts. lint:tsgo: Checked 4 files out of 4 files. 0 errors, 0 warnings and 0 messages.
  • Ledger: xstate has no debt-ledger entry (capability 1 removed it). XS1's enrolled list in packages/AGENTS.md now names src/fsm.ts; dprint re-pads that table, so the diff shows every row.
  • Packaging: src/fsm.ts imports nothing from effect, and package.json has no inlinedDependencies. The build (with api:check) exits 0. etc/xstate-fsm.api.md changes only the two signatures above.

Type bridge (declared, CONST-W3)

Main bridged createFSM and setup to their generic types with as casts, and the recommended lint rejects every as (typescript(consistent-type-assertions)). Each now publishes its generic signature as an overload over one implementation typed at MachineContext, EventObject and string. There, the {} default context and the setup object type-check with no bridge. TypeScript checks overload compatibility with type parameters erased, so this is still an unchecked bridge. It replaces main's casts and doesn't present itself as a runtime check. docs/solutions/conventions/generic-signature-over-erased-implementation.md records why no-op asserts functions are the wrong fix: an earlier commit on this branch used them, they pass the lint, and they assert nothing.

Evidence

All from head 44fe5b69, based on main 84e602e7.

  • The spec passes 9/9 on --sequence.shuffle seeds 1-3, with the same case names on each seed.
  • Twelve sabotages on src/fsm.ts, each in a scratch worktree removed afterwards. On the 8-case spec (bfd7a3c9), A-H, F1 and I each give 3 failed | 5 passed on seeds 1-3 and J gives 7 failed | 1 passed; on the 9-case spec (44fe5b69), K gives 1 failed | 8 passed on seeds 1-3. The control gives all passed on both heads:
    • (A) Object.hasOwn dropped, so inherited event names match;
    • (B) an unchanged step returns a new snapshot;
    • (C) status: 'done';
    • (D) the context patch is ignored;
    • (E) a machine without context keeps undefined;
    • (F) inherited patch keys are merged;
    • (G) output is not an own key of created snapshots;
    • (H) setup().createFSM drops the context it is given;
    • (F1, the first fresh reviewer's) any patch copies the context, even one whose values already match. Before e549d691 the spec stayed green on it; the spec compared context by value;
    • (I) a state without an on table is not given the empty table, so Object.hasOwn reads undefined;
    • (J) a transition function's missing result is not given the empty transition;
    • (K) the optional read of the current state's config is dropped (config.states[value].on), so an event in an undeclared state throws.
  • The whole xstate suite exits 0 on --sequence.shuffle seeds 1-3: 145 files, 2419 passed, 29 skipped, 3 todo. That is main's 2430, minus the 20 deleted cases, plus the 9 spec cases.
  • dprint, tsc -b, tsc -p tsconfig.test.json, lint, lint:tsgo and build exit 0. Hooks ran on every commit. No local stryker run.
  • ce-simplify-code and ce-code-review: the three simplify lenses failed at once with 429 Token Plan usage limit reached (2056), as does every reviewer route, so both ran inline. They found the no-op asserts functions (fixed in 7abb5c58, above) and five fixture exports nothing imports (removed). An earlier inline pass found 5 deleted behaviours the first spec draft didn't cover: own patch keys, own output/error keys, actor emissions, setup runtime, and a machine without context. It also found the setup refusal and narrowing type rows. All are covered and listed above.
  • ce-compound: the solution doc above. Its frontmatter, claims and solution-doc validators exit 0.
  • Fresh reviewer Review75 (head 8114d830): FAIL on CONST-T9. The map claimed the no-op context patch was pinned; it was not (F1 above). Fixed in e549d691.
  • Fresh reviewer Review75b (head e549d691): PASS, 13/13 clauses, CI green on that head. Its two P2s and one P3 are fixed in bfd7a3c9: a transition function that returns null threw where main treated it as a no-op; the on === undefined guard was reachable but never drawn, so its mutant would survive the release gate (the guard is now deleted, and the state without an on table is drawn and required); the effects row checked satisfies never[] instead of an exact equality.
  • Incremental reviewer Review75c (head bfd7a3c9): PASS, 10/10 clauses, CI green on that head. Its P3: config.states[value]?.on is reachable (a snapshot restored in a state the machine does not declare) but never drawn, so dropping the ?. survived the whole suite and would have failed the release gate at break 100. 44fe5b69 adds the scenario "A snapshot restored in a state the machine does not declare ignores events": a document persisted by a machine that declares state z, restored through createActor into one that does not, stays active in z with its context after go, as on main. Sabotage K above.

Remaining

packages/unguarded-tests.json: 188 -> 182 files.

The fsm capability replaced main's `as` casts with no-op `asserts` functions,
which pass consistent-type-assertions while asserting nothing at runtime.
createFSM and setup now publish their generic signature as an overload over
one implementation typed at MachineContext, EventObject and string, where the
empty default context and the setup object need no bridge. The published API
report is unchanged. The fsm fixtures drop exports nothing imports.
…thing

The fsm spec compared context by value, so a transition that copied the
context for a patch whose values already matched passed unseen. The
model now predicts, per step, whether the context object is kept, and
the fixture reads that identity from every driver. The planted subjects
wrap the FSM's own transition instead of overriding the observation, and
a new one copies the context on an unchanged patch. The liveness ledger
requires such a patch on every seed.
…n table

A transition function that returns null threw on this branch, where main
treated it as a no-op. The fsm now resolves a missing result to an empty
transition, null included, and a state without an `on` table to an empty
table, which deletes the guard that only that state reached. The spec's
model now draws a bump transition that returns nothing and a state
without an `on` table, and its ledger requires both on every seed. The
effects row of the type contract is an exact `never[]` equality again.
A snapshot persisted by a machine version that declares a state, restored
into a machine that does not, keeps its value and context when an event
arrives, as on main. No generated machine reaches that state, so the
optional read of the state's config survived the whole suite. The spec now
restores such a document through createActor and requires the snapshot to
stay as it was.
@ryanleecode
ryanleecode merged commit 5768d49 into main Oct 9, 2026
7 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants