Skip to content

feat(repo): test xstate's graph as a conformance spec and give its path functions data-last forms - #77

Merged
kiro-systemf[bot] merged 20 commits into
mainfrom
xs/xstate-graph
Oct 9, 2026
Merged

kiro-systemf[bot] merged 20 commits into
mainfrom
xs/xstate-graph

Conversation

@systemfsoftware-maker

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

Copy link
Copy Markdown
Collaborator

Capability 5 of 31 (graph): @systemfsoftware/xstate/graph is 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).

  • Spec: packages/xstate/tests/graph.conformance.test.ts (20 cases: a 3-seed outline plus 17 fixed scenarios), model tests/__fixtures__/graph.model.ts, subject fixture tests/__fixtures__/graph.ts.
  • Deleted: the 8 src/graph/test files (91 cases, 3 skipped, plus a 1,232-line snapshot) and src/graph/types.test.ts.

Behaviour-to-test map (CONST-T9)

Every it in the deleted packages/xstate/src/graph test files, mapped to the
conformance spec (packages/xstate/tests/graph.conformance.test.ts), its model
(__fixtures__/graph.model.ts) and its subject fixture (__fixtures__/graph.ts).

Coverage keys:

  • outline — the seeded scenarioOutline "Every machine, sequence and query the
    model draws from seed agrees with the published graph API" (seeds 1–3); the
    bracket names the Traverse operation / query option / Structure field it exercises.
  • fixed — a hand-written scenario in the same spec.
  • types — a row of typeLevelContract (checked by tsc, no runner import).
  • uncovered — not exercised, with the reason.

src/graph/types.test.ts (type-only → typeLevelContract)

  • getEvents should be allowed to return a mutable array → types row 1 (events: [{ type: 'FOO' }]).
  • getEvents should be allowed to return a readonly array → types row 2 (as const).
  • events should allow known event → types row 3 (declared FOO accepted).
  • events should not require all event types (array literal expression) → types row 1 (a subset is accepted).
  • events should not require all event types (tuple) → types row 2.
  • events should not require all event types (function) → types row 3 (function form).
  • events should not allow unknown events → types row 4 (@ts-expect-error type: 'UNKNOWN').
  • events should only allow props of a specific event → types row 5 (@ts-expect-error other).
  • serializeEvent should be allowed to return plain string → types row 6.
  • serializeState should be allowed to return plain string → types row 7.

src/graph/test/adjacency.test.ts

  • model generates an adjacency map (converted to an array) → outline: adjacency (target/limit/serialize/input), via getAdjacencyMap + adjacencyMapToArray.
  • function generates an adjacency map (converted to an array) → outline: adjacency + customEvents (array and function forms of options.events).

src/graph/test/dieHard.test.ts (bounded counter+guard machine)

  • shortest paths › should generate the right number of paths (2) → outline: shortest + counter machine; covered generically, not the literal 2.
  • shortest paths › replays <path> (×2, generated) → outline: shortest paths are replayed and their step events compared.
  • simple paths › should generate the right number of paths (14) → outline: simple + counter machine.
  • simple paths › replays <path> (×14, generated) → outline: simple paths replayed and compared.
  • paths from events › replays the path → outline: Replay (sequence + candidate) + fixed "Replaying a finite sequence on plain logic…".
  • paths from events › should return no paths if the target does not match the last entered state → outline: Replay target/stop with toState.
  • simple paths with a narrower target › should generate the right number of paths (6) → outline: simple + target.
  • simple paths with a narrower target › replays <path> (×6, generated) → outline: simple + target, paths replayed.

src/graph/test/graph.test.ts

  • getDescendantStateNodes() › should return an array of all nodes → outline: Structure descendantIds (pre-order).
  • getDescendantStateNodes() › should return an array of all nodes (parallel) → outline: parallel + Structure descendantIds (a fixed parallel machine's descendant order and per-region targets).
  • getShortestPaths() › should return a mapping of shortest paths to all states → outline: shortest.
  • getShortestPaths() › should return a mapping of shortest paths to all states (parallel) → outline: parallel (a fixed parallel machine's shortest state sequences, compared order-insensitively).
  • getShortestPaths() › the initial state should have a single-length path → outline: shortest (init-only path) + fixed replay scenario (weight 1).
  • getShortestPaths() › it.skip 'should not throw when a condition is present' → uncovered: skipped upstream; guard conditions are exercised via the counter guard, not a guard throwing.
  • getShortestPaths() › it.skip 'should represent conditional paths based on context' → uncovered: skipped upstream; conditional/targeted paths via target are covered.
  • getSimplePaths() › should return a mapping of arrays of simple paths to all states → outline: simple.
  • getSimplePaths() › should return a mapping of simple paths to all states (parallel) → outline: parallel (a fixed parallel machine's simple state sequences, compared order-insensitively).
  • getSimplePaths() › should return multiple paths for equivalent transitions → outline: simple (dedup by (state,event) with distinct targets collapse to one edge — one path per distinct transition target).
  • getSimplePaths() › should return a single-length path for the initial state → outline: simple init-only path.
  • getSimplePaths() › should return value-based paths → outline: serialize: 'value'.
  • getSimplePaths() › should support filtering disabled events → outline: filter + fixed "adjacency that ignores the event filter is caught".
  • getPathFromEvents() › should return a path to the last entered state by the event sequence → outline: Replay candidate: 'last' + fixed replay scenarios.
  • getPathFromEvents() › it.skip 'should throw when an invalid event sequence is provided' → uncovered: skipped upstream; there is no "invalid" event for the generated candidate set.
  • getPathFromEvents() › should return a path from a specified from-state → fixed "Replaying from a specified from-state begins at that state" (one TIMER from yellow lands on red).
  • getPathFromEvents() › does not treat custom logic with a getStateNodeById property as a machine → fixed "A custom logic carrying getStateNodeById is traversed as plain logic" (one INC reaches context 1).
  • it.each 'from-state can be specified' → outline ("Every machine, sequence and query the model draws from seed agrees with the published graph API"), fromSecond class (shortest, simple and replay queries start from machine.resolveState of the second state; the ledger requires it on every seed).
  • toDirectedGraph › should represent a statechart as a directed graph → fixed "toDirectedGraph renders a machine, its root state node and an identity-independent proxy alike" (hand-written node ids, children and edge source/target/label of a light statechart).
  • toDirectedGraph › does not rely on StateMachine constructor identity → the same fixed scenario: a null-prototype Proxy of the machine renders the same graph.
  • simple paths for transition functions → outline ("Every machine, sequence and query the model draws from seed agrees with the published graph API"), plainLogic class: a non-machine logic built with createLogic through getSimplePaths, data-first and data-last (plainForms ledger, required on every seed).
  • shortest paths for transition functions → the same plainLogic class through getShortestPaths, both forms.
  • filtering › should not traverse past filtered states → outline ("Every machine, sequence and query the model draws from seed agrees with the published graph API"), stop class on counted (context-carrying) machines: stopWhen ends traversal at the predicate's state.
  • should provide previous state for serializeState() → fixed "The serializer receives each state with its entering event and its previous state" (keys a|init|none, b|toB|a, c|toC|b, a|toA|c).
  • joinPaths() › should join two paths → outline ("Every machine, sequence and query the model draws from seed agrees with the published graph API"), join outcome joined.
  • joinPaths() › should not join two paths with mismatched source/target states → outline ("Every machine, sequence and query the model draws from seed agrees with the published graph API"), join outcome refused-mismatch (model message Paths cannot be joined).

src/graph/test/index.test.ts

  • events › should allow for dynamic generation of cases based on state → outline: default events are derived per snapshot from the active node's transitions; customEvents additionally pins the array/function override forms.
  • state limiting › should limit states with stopWhen option → outline: stop.
  • prevents infinite recursion based on a provided limit → outline: limit + fixed "Replaying a finite sequence on plain logic uses the input and the default serializer" (limit 1).
  • should traverse with input → outline: input (zero/one) on input-seeded machines.

src/graph/test/paths.test.ts

  • getSimplePaths › returns one path per reachable state → outline: simple.
  • getSimplePaths › should support filtering disabled events → outline: filter.
  • transition coverage › shortest paths reach every state → outline: shortest.
  • transition coverage › transition coverage should consider multiple transitions with the same target → fixed "Two transitions out of one state are both drawn as distinct simple paths" (exact hand-written list of simple-path event sequences); the outline's shortest/simple dedup by (state,event) also applies.
  • toState › Should find a path to a non-initial target state → outline: target.
  • toState › Should find a path to an initial target state → outline: target (fromSecond also exercised).
  • paths from paths › should join shortest paths from the end of other paths → outline: join queries (joined outcome; 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

  • finds the shortest paths to a state without continuing traversal from that state → outline: shortest + stop/target.
  • finds the shortest paths from a state to another state → outline: shortest + fromSecond.
  • handles event cases → outline: default events per snapshot (NEXT/JUMP/BACK).
  • should work for machines with delays → fixed "A machine with a delayed transition reaches its next state on the after event".

src/graph/test/traversal-regressions.test.ts

  • replays a finite sequence on unbounded logic → fixed "Replaying a finite sequence on plain logic uses the input and the default serializer"; outline: Replay on plain logic.
  • uses input when replaying machine events → fixed "Replaying a finite sequence…"; outline: input.
  • honors replay filters, stopping and target predicates → outline: Replay filter/stop/target.
  • replays the last permitted override candidate matching a serialized event → outline: Replay candidate: 'last' + fixed "A replay that takes the first matching override candidate is caught as a model divergence".
  • it.each 'initializes custom logic once in a path generator' → fixed "A custom logic initializes exactly once per traversal call, with and without an explicit from-state" (the logic's own context factory counts its calls: 1 per getShortestPaths/getSimplePaths call).
  • it.each 'initializes a machine once with explicit undefined fromState: %s' → fixed "A machine initializes exactly once per traversal call with an explicit undefined from-state" (a machine whose context factory 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's src/graph).
  • it.each 'accepts arbitrary serialized state/event keys: %s' → fixed "Serialized state and event keys that collide with Object.prototype members address real entries" (__proto__, constructor, toString, hasOwnProperty, '' through getAdjacencyMap, getShortestPaths and getSimplePaths).

Additional fixed scenarios not tied to a deleted it

  • "The published serializeSnapshot omits an empty context and keeps a non-empty one" → pins the serializeSnapshot entrypoint.
  • (removed in 959e3f7) "Joining two paths whose source and target states differ is refused" → now outline: join queries (refused-mismatch and refused-empty outcomes, model message Paths cannot be joined).
  • "The published graph entrypoint exposes only the graph API" → pins the nine exported names.

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 joinPaths forms are both judged:

  • "Joining two paths that meet head to tail yields the concatenated path" (it pinned ['@xstate.init','NEXT','TO_C'] for a path to b joined with a path from b to c).
  • "Joining two paths whose source and target states differ is refused" (it pinned the message 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) predicts joined with the concatenated steps, summed weight and tail state, or failed with Paths cannot be joined when the tail is empty or starts elsewhere. The outline's liveness row ("every operation, call form, join outcome ... occurred") requires joined, refused-mismatch and refused-empty to 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 in xstate-graph.api.md. No other etc/*.api.md file 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:

export function getAdjacencyMap<…>(options: TraversalOptions<TSnapshot, TEvent, TInput>): (logic: ActorLogic<TSnapshot, TEvent, TInput, TSystem>) => AdjacencyMap<TSnapshot, TEvent>;
export function getPathsFromEvents<…>(events: TEvent[], options?: TraversalOptions<TSnapshot, TEvent, TInput>): (logic: ActorLogic<TSnapshot, TEvent, TInput, TSystem>) => Array<StatePath<TSnapshot, TEvent>>;
export function getShortestPaths<TLogic extends AnyActorLogic>(options?: TraversalOptions<…>): (logic: TLogic) => Array<StatePath<SnapshotFrom<TLogic>, EventFromLogic<TLogic>>>;
export function getSimplePaths<TLogic extends AnyActorLogic>(options?: TraversalOptions<…>): (logic: TLogic) => Array<StatePath<SnapshotFrom<TLogic>, EventFromLogic<TLogic>>>;
export function joinPaths<TSnapshot extends AnySnapshot, TEvent extends EventObject>(tailPath: StatePath<TSnapshot, TEvent>): (headPath: StatePath<TSnapshot, TEvent>) => StatePath<TSnapshot, TEvent>;

On main, getAdjacencyMap and joinPaths had 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>) became serializeSnapshot(snapshot: AnySnapshot & { value?: StateValue; context?: MachineContext }). A type probe on a scratch copy shows the narrowing: serializeSnapshot on a value typed Snapshot<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 for StateValue and MachineContext, and the dist/graph.d.ts line numbers in the warning footer moved.

Bound and alias spellings with the same meaning (no consumer-visible change).

  • TSnapshot extends Snapshot<unknown> became TSnapshot extends AnySnapshot (= Snapshot<unknown>). This applies to getAdjacencyMap, getPathsFromEvents, joinPaths, PathGenerator, SerializationConfig, StatePath, StatePlanMap, Step, Steps, TraversalConfig and TraversalOptions.
  • TSystem extends ActorSystem<any> = ActorSystem<any> became TSystem extends AnyActorSystem = AnyActorSystem (= ActorSystem<any>, src/system.ts). This applies to getAdjacencyMap and getPathsFromEvents.
  • DirectedGraphEdge.transition: TransitionDefinition<any, any> became AnyTransitionDefinition (= TransitionDefinition<any, any>, src/types.ts).
  • AnyStateNode: StateNode<any, any> became StateNode. The bare form means StateNode<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) to AnyStateNode and passes the machine, its root and a child to toDirectedGraph. That compiles on this head and against main's graph/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's customEvents bracket 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 class customEvents in the fixture ledger.

  • Parallel machines — the outline's Parallel bracket 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) and getDescendantStateNodes. Fixed class parallel.

  • adjacencyMapToArray order — the adjacencyArray class pins the row order ((state,event,nextState) insertion order over Object.values), projecting each entry through the caller's serializeState/serializeEvent.

  • Machine classification (restored). cbd321867 restores main's structural machine check in graph.ts: a machine needs a root with a string id, a states object and a transitions.values function, plus getStateNodeById, resolveState and getTransitionData. 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: a createLogic logic carrying root: { id: 'x' } gives the single plain adjacency key s0. Run against ea37f3278's graph.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

  • No behaviour change in the events override. On main, resolveTraversalOptions spreads ...traversalOptions after the machine defaults, so a caller's events already replaced the defaults' per-snapshot merge function outright. That merge ran only when no events was given, and then returned the machine's own events unchanged. This branch deletes that unreachable merge (createDefaultMachineOptions now returns getAllOwnEvents directly), and the model traverses exactly the caller's events (customEvents) or the machine's own events otherwise, as main does.
  • Two it.skip cases in graph.test.ts and one in getPathFromEvents — skipped upstream; carried as skipped and deliberately not asserted.
  • index.test.ts's dynamic-generation it is 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.ts adds the seven src/graph files with runtime logic: actorScope.ts, adjacency.ts, alterPath.ts, graph.ts, pathFromEvents.ts, shortestPaths.ts, simplePaths.ts. types.ts (types only) and index.ts (re-exports) are not mutated. The set after this PR: assert.ts, stateMatching.ts, mapState.ts, fsm.ts and those seven files.

Ledger and lint enrolment (XS1)

  • xstate has no debt-ledger entry; capability 1 removed it, so the ledger is unchanged here.
  • XS1's enrolled list in packages/AGENTS.md adds src/graph/, which the lint script and tsconfig.tsgo.json name as a whole folder.
  • The recommended lint and lint:tsgo now cover src/graph, the spec and its fixtures. Every finding is fixed without disable comments.
  • missing-pipeable-signature is fixed by the data-last overloads below, not by a lint patch.
  • lint:tsgo checks 13/13 enrolled src files 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' in for actor logic (actor logic always has a transition member and TraversalOptions has none), and Array.isArray for the replay events of getPathsFromEvents. getShortestPaths and getSimplePaths share one logicFirstOrLast dispatch in graph.ts. The spec's typeLevelContract checks that each data-last return type equals its data-first counterpart, and @ts-expect-error rows 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)

  • dprint, tsc -b, tsc -p tsconfig.test.json --noEmit, pnpm lint and pnpm lint:tsgo (13/13 enrolled files, 0 diagnostics) all exit 0.
  • Spec on --sequence.shuffle seeds 1-3: 20 passed (20) each, with the same case names on every seed.
  • Full xstate suite on seeds 1-3: 138 files, 2348 passed, 26 skipped, 3 todo, the same on every seed. That is main's 2419, minus the 91 deleted cases, plus the spec's 20.
  • No src edit was used as proof, and no mutation of any kind was run locally (ruling 204).

Remaining unguarded

packages/unguarded-tests.json: 174 files (182 on main), 126 of them in xstate (134 on main). xstate's vitest.config.ts unguarded list: 126 (134 on main).

No local mutation run.

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
@systemfsoftware-maker systemfsoftware-maker changed the title xs/xstate graph feat(repo): test xstate's graph as a conformance spec and give its path functions data-last forms Oct 9, 2026
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.

@kiro-systemf kiro-systemf Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@kiro-systemf
kiro-systemf Bot merged commit 3742392 into main Oct 9, 2026
7 checks passed
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.
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.

1 participant