Skip to content

feat(repo): test xstate's state matching as a conformance spec - #68

Merged
ryanleecode merged 17 commits into
mainfrom
xs/xstate-state-matching
Oct 9, 2026
Merged

ryanleecode merged 17 commits into
mainfrom
xs/xstate-state-matching

Conversation

@systemfsoftware-maker

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

Copy link
Copy Markdown
Collaborator

Summary

@systemfsoftware/xstate's state-matching capability (matchesState, snapshot.matches) is now judged by a conformance spec over a model of active state paths instead of twelve hand-picked examples. matchesState gains a data-last form, and it now reads a state name inside a state value as a plain name even when the name holds a dot (conductor ruling, cycle 128, option a). Off main, with main merged up to #71.

Behaviour change

Only a state value that is a whole string is read as a dotted path (\. is a dot inside a name). For a state d.e nested under b, snapshot.matches({ b: 'd.e' }) is now true; on main it is false and only { b: 'd\\.e' } matched. snapshot.matches('b.d\\.e') stays true.

Three edge inputs also change, all stated in the changeset: a machine snapshot passed in place of a state value is no longer unwrapped (the published type never allowed it; no caller in the repo passes one); a region whose value is undefined answers false where main threw a TypeError; an id ending in a lone \ keeps the backslash where main produced the text undefined. A pre-PR differential sweep (40,000 generated cases against main's algorithm) found no other divergence.

Code move

matchesState, pathToStateValue and toStatePath move from src/utils.ts into src/stateMatching.ts. Internal callers (State.ts, StateMachine.ts, stateUtils.ts) import from there, which removes the State.ts / utils.ts import cycle. Public names are unchanged; pathToStateValue now takes a readonly path. The API report is regenerated.

Packaging

@systemfsoftware/xstate has no dependencies. matchesState's data-last form is written in our own code, as assertEvent's is: two overloads and an argument-count check, with no effect import in src. An earlier head (b3a8fb08) imported Effect's dual, so tsdown inlined part of effect into the dist and wrote inlinedDependencies: { effect: "4.0.1" } into package.json. Both are gone. xstate's tsdown.config.ts now sets deps: { onlyBundle: [] }, so the build, and with it gate:dist, fails if it would inline any dependency.

Deleted tests → new coverage (CONST-T9)

packages/xstate/test/match.test.ts is deleted. Every row below is covered by tests/state-matching.conformance.test.ts, whose expected answers come from tests/__fixtures__/stateValue.model.ts (imports only effect): a parent matches a child when every leaf path of the parent is a prefix of a leaf path of the child.

Deleted case Covered by
two equal states / state values / parallel values match MatchItself commands: every generated tree, compound and parallel, matched against itself in each form and call
a substate (string, value, parallel value) matches its superstate Match commands whose parent paths are prefixes of the child's, judged by the model
unequal values, a more specific parent, a non-substate do not match the same commands where the model answers false; the liveness step requires each call form to have answered false
string and object values mix parentForm / childForm draw each argument as a value or as an escaped dotted id string
matches() on a snapshot the snapshot call form: checkStateIn on a real snapshot, which calls snapshot.matches for non-#id values

Spec shape

Conformance.sequential runs 64 generated sequences of 8 match commands per seed (1, 2, 3) through the data-first, data-last and snapshot forms. A liveness step checks that each call form answered both ways, that escaped-dot ids were passed, and that state values nested a leaf name holding a dot. Two planted matchers are rejected with the model-diverged judgement: one that splits ids on escaped dots, and one that reads nested leaf names as dotted paths.

Enrolment

  • Mutated set (packages/xstate/stryker.config.ts): src/assert.ts, src/stateMatching.ts.
  • Lint: the lint script adds src/stateMatching.ts, the spec and its fixtures. 0 findings, no disable comments.
  • Effect plugin: tsconfig.tsgo.json adds src/stateMatching.ts. lint:tsgo: Checked 2 files out of 2 files. 0 errors, 0 warnings and 0 messages.
  • Ledger: no xstate ledger entry remains (removed in feat(repo): test xstate's event assertions as a conformance spec #66). XS1's enrolled list now names src/stateMatching.ts.

Evidence

  • On head 78f1a112: xstate suite --sequence.shuffle seeds 1-6 each exit 0 (2440 passed, 29 skipped, 3 todo). The spec's 5 cases pass with the same names on seeds 1-3.
  • Sabotages on head 78f1a112 (scratch tree, deleted), each 3 failed | 2 passed on seeds 1, 2 and 3:
    • nested strings read as dotted paths again (main's behaviour)
    • every → some across regions
    • escapes ignored in toStatePath
    • the data-last form swaps its arguments
  • Packaging on head 78f1a112:
    • pnpm --filter @systemfsoftware/xstate build exits 0.
    • No module region in dist/*.mjs comes from outside src/; a grep for node_modules, effect@ and non-src/ #region markers finds no matches.
    • package.json has no inlinedDependencies.
    • On b3a8fb08 with only the new tsdown.config.ts, the build exits 1: effect is located in node_modules but is not included in deps.onlyBundle option ... Imported by - .../src/stateMatching.ts.
  • On head 78f1a112: gate:dist, dprint, typecheck, lint and test:scripts (23/23) exit 0. lint:tsgo: Checked 2 files out of 2 files. 0 errors, 0 warnings and 0 messages.
  • API report vs b3a8fb08: the only change is that matchesState is now two function overloads instead of a const with two call signatures. Against main, no export is removed or renamed.
  • Merging main took two edits. The API report is regenerated from the merged source. test/match.test.ts is dropped from the xstate config's inline unguarded list, which fix(repo): let a stryker sandbox load each package's own test config #69 added; test:scripts checks that list against unguarded-tests.json.
  • Before test(repo): keep each shuffled xstate and xstate-test case to its own errors and files #71 merged, the merged tree failed shuffled seeds 1-3 in xstate's error-handling suite. That is main's report leak, which test(repo): keep each shuffled xstate and xstate-test case to its own errors and files #71 fixes. With test(repo): keep each shuffled xstate and xstate-test case to its own errors and files #71's change applied in a scratch tree, seeds 1-6 exited 0.
  • src/assert.ts at the Release gate on main 7c234dec (run 37853142378, xstate shard report): CompileError=5 Killed=16, 0 survived, 0 no coverage, thresholds 100/100/100. No assert.ts survivor needs a test.
  • Pre-PR review: a correctness reviewer's 40,000-case differential sweep against main's algorithm found divergences only on nested dotted names (the ruling) and the three edge inputs above, now in the changeset. Its sabotages of src/stateMatching.ts (parent/child swapped; escape handling dropped; nested-name ruling reverted) each turned the spec red.
  • Hook steps (commitlint, lint-staged) ran over every commit before the first push; two commits made before the hooks were repaired needed a formatting fix, which is its own commit. No local mutation runs.

Remaining

packages/unguarded-tests.json: 190 -> 189 files.

…tate-matching

# Conflicts:
#	packages/xstate/tests/__fixtures__/eventAssertion.ts
…tate-matching

# Conflicts:
#	packages/AGENTS.md
# Conflicts:
#	packages/AGENTS.md
#	packages/xstate/etc/xstate.api.md
#	packages/xstate/package.json
#	packages/xstate/stryker.config.ts
#	packages/xstate/tests/__fixtures__/eventAssertion.ts
#	packages/xstate/tests/event-assertions.conformance.test.ts
#	packages/xstate/tsconfig.tsgo.json
@systemfsoftware-maker
systemfsoftware-maker marked this pull request as draft October 8, 2026 20:28
@systemfsoftware-maker
systemfsoftware-maker marked this pull request as ready for review October 8, 2026 23:48
…fuse inlined deps

The xstate package has no dependency on effect, so importing `dual` made
tsdown inline part of effect into xstate's dist and write an
inlinedDependencies field into its package.json. matchesState now has
two overloads and checks its argument count in its own code. The
inlinedDependencies field is gone, and xstate's build now sets
deps.onlyBundle to an empty list, so any dependency the build would
inline fails the build and gate:dist.
@ryanleecode
ryanleecode merged commit 9857b0b 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