Repository navigation
feat(repo): test xstate's graph as a conformance spec and give its path functions data-last forms - #77
Merged
Conversation
A machine traversal given an events option uses those events as given: resolveTraversalOptions spreads the caller options over the defaults, so the per-type merge in createDefaultMachineOptions only ran with no events, where it returned the defaults unchanged.
The graph traversal files (adjacency, alterPath, graph, pathFromEvents, shortestPaths, simplePaths, actorScope, types, index) now pass the recommended lint apart from the held missing-pipeable-signature findings. Published output is unchanged: the old graph tests and the graph conformance spec pass on this tree. The SerializedSnapshot and SerializedEvent brands stay. Traversal keys are minted through toSerializedSnapshot and toSerializedEvent, module-internal helpers in graph.ts that the entrypoint does not export. The api report is regenerated with the change it records (CONST-W3): the AnySnapshot alias replaces Snapshot<unknown> in constraints, AnyStateNode is the bare StateNode, DirectedGraphEdge.transition is AnyTransitionDefinition, TSystem defaults to AnyActorSystem, and serializeSnapshot takes the value and context it reads. Each accepts the same values as before. The mock actor scope uses a fixed session id instead of Math.random; no traversal output reads it.
The eight src/graph test files (91 cases, 3 skipped, and a 1,232-line snapshot) are deleted; tests/graph.conformance.test.ts covers their behaviours. The seven graph files with runtime logic join mutate, and the whole src/graph folder joins the lint script and tsconfig.tsgo.json. XS1's enrolled list names src/graph/, and both unguarded lists drop the deleted files. Lint and lint:tsgo still report missing-pipeable-signature on getAdjacencyMap, getPathsFromEvents, getShortestPaths, getSimplePaths, joinPaths and resolveTraversalOptions, held for the conductor's ruling.
Each published graph function gains a pipeable overload next to its
data-first form: getShortestPaths, getSimplePaths, getAdjacencyMap,
getPathsFromEvents and joinPaths. One implementation per function
branches on an exact runtime shape check — argument count, a transition
function for actor logic, and Array.isArray for replay events.
resolveTraversalOptions now takes a single { logic, options } argument.
Regenerated the graph API report and added the minor changeset.
…ispatch A two-argument getShortestPaths/getSimplePaths call is already routed to the data-first path by the shape check, so the length-2 fast path was unobservable. The isActorLogicLike guard now tests only `!== undefined` and `'transition' in` against the argument union the implementation actually receives, so each operand flips a published call form. The changeset names the `transition` member check.
The graph conformance spec now draws a call form per generated operation, so the five published graph functions are exercised in both their data-first and data-last shapes, the no-options forms included, and each response is judged against the hand-written model. - the model derives the options a call form implies: a call that passes no options uses the published defaults, so it must not read the query's own option fields - the fixture projects returned snapshots with the serializer the call actually applies, the default one for a no-options call - the fixture's machine starts an input-seeded machine at zero when no input is passed, so the no-options form is total - the planted first-candidate deviation applies to every call form, keeping the sabotage probe refutable - the liveness ledger requires every published form on every seed
…unctions getShortestPaths and getSimplePaths each carried their own copy of the shape check and the dispatch around it. graph.ts now holds one logicFirstOrLast helper, with the OptionsOf and PathsOf types both functions already spelled out, and each function calls it with its compute step. Published signatures and the api report are unchanged.
…ement The type-level contract called the five published graph functions while the module loaded, so a swapped runtime shape check failed the import with no scenario run at all. It is now a never-called function, as the fsm spec's is, and the typed join path and machines it names live inside it. No top-level statement in the spec or its fixtures calls the graph API any more: the only load-time call left is createMachine for the fixture's join machine, which no shape check guards. A swapped check now turns the call-form rows red instead of the module.
# Conflicts: # packages/AGENTS.md # packages/xstate/package.json # packages/xstate/stryker.config.ts # packages/xstate/tsconfig.tsgo.json
The lint enrolment had narrowed isMachineLogic to `'root' in logic`, so a custom logic carrying any `root` member was traversed as a machine: its paths were serialized with serializeSnapshot and its events read from snapshot nodes it does not have. The check again requires a root with a string id, a states object and a transitions map, plus getStateNodeById, resolveState and getTransitionData, as on main. It reads each member through Reflect.get, so it stays within the lint's complexity limit without casts.
…pshot The changeset now names the two public type changes the lint enrolment made on the graph entrypoint: serializeSnapshot accepts only snapshots whose value is a StateValue and whose context is an object (a `context: number` snapshot compiled against main and fails here), and the entrypoint exports the new AnySnapshot alias. The entry is shortened to fit the changelog-entry limits.
Contributor
There was a problem hiding this comment.
Fresh review PASS 8/8 on 3b427c1; 7/7; 0 threads; every deleted test mapped; main's machine check restored; hunt clean; no effect in src.
This was referenced Oct 9, 2026
kiro-systemf Bot
pushed a commit
that referenced
this pull request
Oct 9, 2026
…#78) * ci(repo): run the release gate on pull requests, scoped to the change Capability PRs never ran the mutation gate, so #77 merged src/graph with 45 live mutants (31 Survived, 14 NoCoverage) that main's gate then caught. The release gate now also runs on pull_request. The plan job lists the files the pull request changes (the merge commit against its first parent), and stryker-plan-gate.ts decides the scope: the declared files the change touches; a package's whole declared set when the change touches any other file in that package (a test, a fixture, a config, a source file outside the set); every package's whole set when it changes the gate (this workflow, the gate script, stryker.shared.ts, the lockfile, the Nix toolchain). The scope reaches Stryker as MUTATION_SCOPE, which stryker.shared.ts intersects with each package's declared mutate list; unset (local, main without a diff), the whole set is mutated. The gate refuses an in-scope package the plan scheduled nothing for, as before, and passes a change that touches no mutated package with no plan. The verdict job now runs whenever the workflow was not cancelled and fails unless the plan, and every shard when there are shards, finished, so a skipped mutation job can no longer read as a pass. Pull-request runs skip the incremental cache so every scoped mutant runs fresh, and a newer push cancels the older run. * ci(repo): keep a failed mutation shard's own output When a shard child fails, the shard runner prints only the first 1024 characters of the child's stderr, and those are startup INFO lines. The child's error envelope goes to its stdout, which the runner discards. #79's gate runs 37930507588 and 37933520982 both failed with exit 3 and left nothing that names the cause. Every package's Stryker config now writes stryker.log at info level. When the mutation job fails, a follow-up step re-runs each of the shard's project runs directly, with the same arguments the shard runner passes, keeping stdout, stderr and the exit code under reports/diagnostics/. The job then uploads that directory and every stryker.log as mutation-diagnostics-shard-N. A green job runs neither step.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Capability 5 of 31 (graph):
@systemfsoftware/xstate/graphis judged by one conformance spec over a hand-written model and no longer by its ported tests. The five published path functions also gain data-last forms (ruling 186).packages/xstate/tests/graph.conformance.test.ts(20 cases: a 3-seed outline plus 17 fixed scenarios), modeltests/__fixtures__/graph.model.ts, subject fixturetests/__fixtures__/graph.ts.src/graph/testfiles (91 cases, 3 skipped, plus a 1,232-line snapshot) andsrc/graph/types.test.ts.Behaviour-to-test map (CONST-T9)
Every
itin the deletedpackages/xstate/src/graphtest files, mapped to theconformance spec (
packages/xstate/tests/graph.conformance.test.ts), its model(
__fixtures__/graph.model.ts) and its subject fixture (__fixtures__/graph.ts).Coverage keys:
scenarioOutline"Every machine, sequence and query themodel draws from seed agrees with the published graph API" (seeds 1–3); the
bracket names the Traverse operation / query option / Structure field it exercises.
scenarioin the same spec.typeLevelContract(checked bytsc, no runner import).src/graph/types.test.ts (type-only →
typeLevelContract)getEventsshould be allowed to return a mutable array → types row 1 (events: [{ type: 'FOO' }]).getEventsshould be allowed to return a readonly array → types row 2 (as const).eventsshould allow known event → types row 3 (declaredFOOaccepted).eventsshould not require all event types (array literal expression) → types row 1 (a subset is accepted).eventsshould not require all event types (tuple) → types row 2.eventsshould not require all event types (function) → types row 3 (function form).eventsshould not allow unknown events → types row 4 (@ts-expect-error type: 'UNKNOWN').eventsshould only allow props of a specific event → types row 5 (@ts-expect-error other).serializeEventshould be allowed to return plain string → types row 6.serializeStateshould be allowed to return plain string → types row 7.src/graph/test/adjacency.test.ts
adjacency(target/limit/serialize/input), viagetAdjacencyMap+adjacencyMapToArray.adjacency+customEvents(array and function forms ofoptions.events).src/graph/test/dieHard.test.ts (bounded counter+guard machine)
shortest+ counter machine; covered generically, not the literal 2.<path>(×2, generated) → outline:shortestpaths are replayed and their step events compared.simple+ counter machine.<path>(×14, generated) → outline:simplepaths replayed and compared.target/stopwithtoState.simple+target.<path>(×6, generated) → outline:simple+target, paths replayed.src/graph/test/graph.test.ts
descendantIds(pre-order).parallel+ StructuredescendantIds(a fixed parallel machine's descendant order and per-region targets).shortest.parallel(a fixed parallel machine's shortest state sequences, compared order-insensitively).shortest(init-only path) + fixed replay scenario (weight 1).targetare covered.simple.parallel(a fixed parallel machine's simple state sequences, compared order-insensitively).simple(dedup by (state,event) with distinct targets collapse to one edge — one path per distinct transition target).simpleinit-only path.serialize: 'value'.filter+ fixed "adjacency that ignores the event filter is caught".candidate: 'last'+ fixed replay scenarios.fromSecondclass (shortest, simple and replay queries start frommachine.resolveStateof the second state; the ledger requires it on every seed).Proxyof the machine renders the same graph.plainLogicclass: a non-machine logic built withcreateLogicthroughgetSimplePaths, data-first and data-last (plainFormsledger, required on every seed).plainLogicclass throughgetShortestPaths, both forms.stopclass on counted (context-carrying) machines:stopWhenends traversal at the predicate's state.a|init|none,b|toB|a,c|toC|b,a|toA|c).joined.refused-mismatch(model messagePaths cannot be joined).src/graph/test/index.test.ts
customEventsadditionally pins the array/function override forms.stop.limit+ fixed "Replaying a finite sequence on plain logic uses the input and the default serializer" (limit 1).input(zero/one) on input-seeded machines.src/graph/test/paths.test.ts
simple.filter.shortest.shortest/simplededup by (state,event) also applies.target.target(fromSecondalso exercised).joinedoutcome; the model concatenates head steps with the tail's steps after its first, sums the weights, ends at the tail's state).src/graph/test/shortestPaths.test.ts
shortest+stop/target.shortest+fromSecond.src/graph/test/traversal-regressions.test.ts
input.filter/stop/target.candidate: 'last'+ fixed "A replay that takes the first matching override candidate is caught as a model divergence".getShortestPaths/getSimplePathscall).contextfactory counts its calls; shortest, simple and replay with{ fromState: undefined, events: [] }each give 1 path and 1 call; the same row also passes against main'ssrc/graph).__proto__,constructor,toString,hasOwnProperty,''throughgetAdjacencyMap,getShortestPathsandgetSimplePaths).Additional fixed scenarios not tied to a deleted
itserializeSnapshotentrypoint.refused-mismatchandrefused-emptyoutcomes, model messagePaths cannot be joined).Spec 13 -> 11 cases: the two join scenarios moved into the outline
959e3f7 ("judge every published call form against the model") deleted two fixed scenarios and generated join queries from the seeded model instead, so the data-first and data-last
joinPathsforms are both judged:['@xstate.init','NEXT','TO_C']for a path to b joined with a path from b to c).Paths cannot be joined).Both are now covered by the outline "Every machine, sequence and query the model draws from seed agrees with the published graph API" (seeds 1-3). The fixture model (
tests/__fixtures__/graph.model.ts, the join response) predictsjoinedwith the concatenated steps, summed weight and tail state, orfailedwithPaths cannot be joinedwhen the tail is empty or starts elsewhere. The outline's liveness row ("every operation, call form, join outcome ... occurred") requiresjoined,refused-mismatchandrefused-emptyto occur on every seed. No other case was removed.Cycle 207 then added 9 fixed scenarios for the deleted groups the map had left out (rows above), so the spec now has 20 cases.
API report
git diff origin/main...HEAD -- packages/xstate/etc/xstate-graph.api.md: 42 insertions, 18 deletions, all inxstate-graph.api.md. No otheretc/*.api.mdfile changes. Every change, grouped by kind:New overloads (data-last forms; declared in the changeset). Each is added under
// @public (undocumented)next to the existing data-first signature:On main,
getAdjacencyMapandjoinPathshad their data-first overload listed first. The report now lists the data-last overload first for these two, so the order of overloads in the declarations changed as well.New export (declared in the changeset).
export type AnySnapshot<TOutput = unknown> = Snapshot<TOutput>.Narrowed parameter (declared in the changeset).
serializeSnapshot(snapshot: Snapshot<any>)becameserializeSnapshot(snapshot: AnySnapshot & { value?: StateValue; context?: MachineContext }). A type probe on a scratch copy shows the narrowing:serializeSnapshoton a value typedSnapshot<undefined> & { context: number }, or& { value: number[] }, compiles on main and fails on this head (TS2345). Machine snapshots still compile. The report also gains forgotten-export warnings forStateValueandMachineContext, and thedist/graph.d.tsline numbers in the warning footer moved.Bound and alias spellings with the same meaning (no consumer-visible change).
TSnapshot extends Snapshot<unknown>becameTSnapshot extends AnySnapshot(= Snapshot<unknown>). This applies togetAdjacencyMap,getPathsFromEvents,joinPaths,PathGenerator,SerializationConfig,StatePath,StatePlanMap,Step,Steps,TraversalConfigandTraversalOptions.TSystem extends ActorSystem<any> = ActorSystem<any>becameTSystem extends AnyActorSystem = AnyActorSystem(= ActorSystem<any>,src/system.ts). This applies togetAdjacencyMapandgetPathsFromEvents.DirectedGraphEdge.transition:TransitionDefinition<any, any>becameAnyTransitionDefinition(= TransitionDefinition<any, any>,src/types.ts).AnyStateNode:StateNode<any, any>becameStateNode. The bare form meansStateNode<MachineContext, EventObject>, so this is the one rewrite that is not alias-identical. The same type probe assigns a typed machine's root and a child node (context{ count: number }, two typed events) toAnyStateNodeand passes the machine, its root and a child totoDirectedGraph. That compiles on this head and against main'sgraph/types.ts, so no consumer-visible difference was found. It is not in the changeset.Behaviours beyond the deleted tests (added coverage)
options.events(array and function forms) — the outline'scustomEventsbracket calls each Traverse/adjacency query with a hand-picked event list and with a per-state function, for both counted and plain machines; the model traverses exactly those events. Fixed classcustomEventsin the fixture ledger.Parallel machines — the outline's
Parallelbracket builds a fixed two-region machine (left/right, each region a 2-node machine) and compares adjacency (canonicalised row order), shortest/simple state sequences (canonicalised order) andgetDescendantStateNodes. Fixed classparallel.adjacencyMapToArrayorder — theadjacencyArrayclass pins the row order ((state,event,nextState)insertion order overObject.values), projecting each entry through the caller'sserializeState/serializeEvent.Machine classification (restored).
cbd321867restores main's structural machine check ingraph.ts: a machine needs arootwith a stringid, astatesobject and atransitions.valuesfunction, plusgetStateNodeById,resolveStateandgetTransitionData. The lint enrolment had loosened it to'root' in logic. Fixed scenario "A logic carrying a non-machine root member is traversed as plain logic, not as a machine" pins it: acreateLogiclogic carryingroot: { id: 'x' }gives the single plain adjacency keys0. Run againstea37f3278'sgraph.ts(the loose check) in a scratch copy, that row is the only failure (1 failed | 19 passed (20), a TypeError in the machine-events path). It passes on this head on seeds 1-3.Uncovered behaviour and why
eventsoverride. On main,resolveTraversalOptionsspreads...traversalOptionsafter the machine defaults, so a caller'seventsalready replaced the defaults' per-snapshot merge function outright. That merge ran only when noeventswas given, and then returned the machine's own events unchanged. This branch deletes that unreachable merge (createDefaultMachineOptionsnow returnsgetAllOwnEventsdirectly), and the model traverses exactly the caller's events (customEvents) or the machine's own events otherwise, as main does.it.skipcases in graph.test.ts and one in getPathFromEvents — skipped upstream; carried as skipped and deliberately not asserted.index.test.ts's dynamic-generationitis covered only as the width-1 case — the outline's shuffle forces width 1 at seed 1 and exercises the width-2/3 branches at seeds 2–3; the inline snapshot's exact case list is not reproduced.Mutated set
packages/xstate/stryker.config.tsadds the sevensrc/graphfiles with runtime logic:actorScope.ts,adjacency.ts,alterPath.ts,graph.ts,pathFromEvents.ts,shortestPaths.ts,simplePaths.ts.types.ts(types only) andindex.ts(re-exports) are not mutated. The set after this PR:assert.ts,stateMatching.ts,mapState.ts,fsm.tsand those seven files.Ledger and lint enrolment (XS1)
packages/AGENTS.mdaddssrc/graph/, which thelintscript andtsconfig.tsgo.jsonname as a whole folder.lint:tsgonow coversrc/graph, the spec and its fixtures. Every finding is fixed without disable comments.missing-pipeable-signatureis fixed by the data-last overloads below, not by a lint patch.lint:tsgochecks 13/13 enrolledsrcfiles with 0 diagnostics.Data-last forms and changeset
.changeset/graph-data-last-forms.md(minor). One implementation per function branches on an exact runtime shape check: argument count,'transition' infor actor logic (actor logic always has atransitionmember andTraversalOptionshas none), andArray.isArrayfor the replay events ofgetPathsFromEvents.getShortestPathsandgetSimplePathsshare onelogicFirstOrLastdispatch ingraph.ts. The spec'stypeLevelContractchecks that each data-last return type equals its data-first counterpart, and@ts-expect-errorrows check the refusals. The outline calls every function in both forms on every seed, and the liveness row requires each form to occur.Gates on 3b427c1 (main d6cc27e merged)
tsc -b,tsc -p tsconfig.test.json --noEmit,pnpm lintandpnpm lint:tsgo(13/13 enrolled files, 0 diagnostics) all exit 0.--sequence.shuffleseeds 1-3: 20 passed (20) each, with the same case names on every seed.Remaining unguarded
packages/unguarded-tests.json: 174 files (182 on main), 126 of them in xstate (134 on main). xstate'svitest.config.tsunguarded list: 126 (134 on main).No local mutation run.