aifsmjs 0.5.6 → 0.5.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/llms-full.txt CHANGED
@@ -13,562 +13,89 @@ The short index lives at `llms.txt` (see https://llmstxt.org/).
13
13
 
14
14
  # aifsmjs
15
15
 
16
- [![npm version](https://img.shields.io/npm/v/aifsmjs.svg)](https://www.npmjs.com/package/aifsmjs)
17
- [![CI](https://github.com/islumina/aifsmjs/actions/workflows/ci.yml/badge.svg)](https://github.com/islumina/aifsmjs/actions/workflows/ci.yml)
18
- [![License](https://img.shields.io/badge/license-MIT-brightgreen.svg)](LICENSE)
19
- [![AI Generated](https://img.shields.io/badge/AI_Generated-Claude_Code_Opus_4.7_Max-blueviolet.svg)](https://www.anthropic.com/claude-code)
20
- [![繁體中文](https://img.shields.io/badge/lang-繁體中文-red.svg)](README_ZHTW.md)
16
+ Small deterministic FSM library for replayable TypeScript/JavaScript state machines. Definitions are plain data; guards/actions/effects are injected at runtime.
21
17
 
22
- > A small, strict FSM library for any TypeScript/JS app that needs deterministic, replayable state transitions. Lifecycle is a pure `step()` function. Chain-of-Responsibility intuition is reserved for cross-cutting concerns (observe / persist / replay), never for the transition core.
18
+ > **Status: 0.5.9 - stable 1.0-track core.** Core FSM, guards, effects, inspect, replay, PBT helpers, scheduler, and sub-machines are live.
23
19
 
24
- Part of the [ai\*js micro-runtime ecosystem](https://github.com/islumina) — see also [aibridgejs](https://github.com/islumina/aibridgejs) (cross-context RPC) and [aiecsjs](https://github.com/islumina/aiecsjs) (ECS).
25
-
26
- > **Status: 0.5.6.** Core FSM, hierarchical sub-machines, guards & effects, scheduler, replay, inspect, and PBT helpers are all live. See [CHANGELOG.md](CHANGELOG.md) for release history.
27
-
28
- **Primary audience**: developers building stateful flows — multi-step forms, checkout funnels, auth flows, tutorial sequences, document-status workflows, scene flow in interactive apps, and the same patterns in browser-based games (PixiJS / Svelte 5 / plain Canvas / WebGL). The core is **environment-neutral** (pure function + adapter boundary): browser, Node, Bun, Deno, Flutter WebView, and Web Workers all work. The Roadmap section keeps gaming-specific niceties (tick hook, ECS bridge) as opt-in subpaths, not core surface.
29
-
30
- ---
31
-
32
- ## Why aifsmjs
33
-
34
- Developers coming from C# Chain-of-Responsibility instinctively wrap FSM lifecycle in a cancellable middleware chain. In FSM territory that breaks determinism and replay. Web games in particular need replayable, serializable, worker-friendly state, so aifsmjs goes the other way:
35
-
36
- - **Lifecycle is a pure function**: `step(def, snapshot, event, impl)` runs `guards → exit → action → entry` in a fixed, uninterruptible order.
37
- - **CoR intuition is reserved for cross-cutting layers**: `inspect/` provides a Koa-style middleware pipeline, but it can only observe — **never alter the transition outcome**.
38
- - **Definition is plain data**: guards / actions / effects are referenced by string; implementations are injected only at runtime. Serializable, transferable across Web Workers, persistable to a database.
39
- - **PBT is first-class**: built-in `fast-check` `fc.commands` adapter plus 6 generic property tests. No comparable library currently ships this.
40
-
41
- In ecosystem terms: closer to Robot3's functional composition + XState v5's `and/or/not` guard combinators + `@xstate/store` v3's `enq.effect()` dual-track side effects. The core measures ~2.8KB ESM gzipped (v0.1.0); every opt-in subpath is independently tree-shakeable.
42
-
43
- ---
44
-
45
- ## Quick Start
20
+ ## Install
46
21
 
47
22
  ```bash
48
23
  pnpm add aifsmjs
49
24
  ```
50
25
 
51
- ```typescript
52
- import { setup, createRuntime, assign } from "aifsmjs";
26
+ ```ts
27
+ import { assign, createRuntime, setup } from "aifsmjs";
28
+ ```
29
+
30
+ ## Quick Start
53
31
 
32
+ ```ts
54
33
  type Ctx = { ticks: number };
55
34
  type Evt = { type: "NEXT" };
56
35
 
57
- // 1. Definition is plain data; setup<Ctx, Evt>() lets States be inferred from
58
- // the keys of `states`, so you don't have to repeat them.
59
36
  const trafficLight = setup<Ctx, Evt>().defineMachine({
60
37
  id: "trafficLight",
61
38
  initial: "red",
62
39
  context: { ticks: 0 },
63
40
  states: {
64
- red: { on: { NEXT: { target: "green", actions: ["bump"] } } },
65
- green: { on: { NEXT: { target: "yellow", actions: ["bump"] } } },
66
- yellow: { on: { NEXT: { target: "red", actions: ["bump"] } } },
41
+ red: { on: { NEXT: { target: "green", actions: ["bump"] } } },
42
+ green: { on: { NEXT: { target: "yellow", actions: ["bump"] } } },
43
+ yellow: { on: { NEXT: { target: "red", actions: ["bump"] } } },
67
44
  },
68
45
  });
69
46
 
70
- // 2. Implementations are injected only at runtime
71
47
  const runtime = createRuntime(trafficLight, {
72
48
  actions: {
73
49
  bump: assign(({ context }) => ({ ticks: context.ticks + 1 })),
74
50
  },
75
51
  });
76
52
 
77
- // 3. Interact
78
53
  runtime.send({ type: "NEXT" });
79
- console.log(runtime.getSnapshot().value); // "green"
80
- console.log(runtime.getSnapshot().context); // { ticks: 1 }
81
- ```
82
-
83
- > The bare `defineMachine<Ctx, Evt, States>({...})` form is still available as an escape hatch when you need explicit control over union event types. In normal cases prefer `setup().defineMachine()`.
84
-
85
- ---
86
-
87
- ## Mental Model
88
-
89
- ```
90
- ┌──────────────────────┐ ┌──────────────────────┐
91
- │ MachineDefinition │ │ Implementations │
92
- │ (plain data, JSON) │ + │ (guards/actions/ │
93
- │ • states │ │ effects fn map) │
94
- │ • on / target │ │ │
95
- │ • string refs │ │ │
96
- └──────────┬───────────┘ └──────────┬───────────┘
97
- │ │
98
- └──────────────┬───────────────┘
99
- ▼
100
- ┌────────────────────────┐
101
- │ step(def, snap, evt, │ ← pure function
102
- │ impl) │ fixed order, uninterruptible
103
- └───────────┬────────────┘
104
- ▼
105
- ┌────────────────────────┐
106
- │ { snapshot, │
107
- │ effects: [...] } │ caller decides when
108
- └───────────┬────────────┘ to dispatch effects
109
- ▼
110
- ┌────────────────────────┐
111
- │ createRuntime(...) │ ← thin wrapper
112
- │ state holder + send │
113
- └────────────────────────┘
114
- ```
115
-
116
- The three layers are fully decoupled: take `step()` alone for replay, take `MachineDefinition` alone for visualization, and `createRuntime` is just the convenience layer that glues them.
117
-
118
- ---
119
-
120
- ## Capabilities / Limitations
121
-
122
- | Will do (v1) | Won't do |
123
- | --------------------------------------------------- | ------------------------------------------------- |
124
- | Flat states + transitions | Parallel state regions |
125
- | Hierarchical sugar via `state.sub` (stable since 0.4.0) | Closures embedded in definition (breaks serialize) |
126
- | Guards (sync only; inline async throws `InvalidDefinitionError` at `defineMachine`; runtime throws `AsyncGuardError` on thenable return) | Async guards |
127
- | Actions (assign + enqueue effects) | Async API inside an action (use an effect) |
128
- | Fire-and-forget effects | Actor invocation / spawn |
129
- | Read-only inspect middleware | Cancellable transition middleware |
130
- | `replay(initial, log, def, impl)` pure function | Time-travel debugger (v2 candidate) |
131
- | `fast-check` `fc.commands` adapter | Custom PBT framework |
132
- | String ref + runtime injection | Single root import for everything |
133
- | Tree-shake friendly subpath exports | ECS / Pixi bridges (opt-in subpath, not core) |
134
-
135
- ---
136
-
137
- ## Design Philosophy
138
-
139
- <details>
140
- <summary>Why lifecycle cannot be middleware (click to expand)</summary>
141
-
142
- UML statecharts and SCXML both mandate `exit → transition action → entry` as an atomic sequence. The moment a middleware handler can call `next()` or throw to abort, you can land in an invalid state — "entered the new state but never exited the old one" — which destroys:
143
-
144
- 1. **Determinism**: the same event sequence no longer guarantees the same snapshot.
145
- 2. **Replay**: event logs cannot reproduce the same outcome in another environment.
146
- 3. **PBT shrinking**: fast-check's counter-example minimization presumes a deterministic machine.
147
-
148
- XState v5 removed the `predictableActionArguments` flag (actions are now always predictable) precisely because of this lesson from v4. Spring StateMachine flags its cancellable Interceptor as a "relatively deep internal feature" for the same reason.
149
-
150
- So aifsmjs splits the CoR chain instinct two ways:
151
-
152
- | Use case | How it is handled |
153
- | ------------------------------ | ------------------------------------------------------- |
154
- | Chained guard predicates | `and/or/not` higher-order combinators |
155
- | Multi-step action sequencing | `actions: [...]` array, runs in order to completion |
156
- | Cross-cutting (log/persist) | `inspect/` middleware — read-only, no cancel ability |
157
-
158
- </details>
159
-
160
- <details>
161
- <summary>Why the definition is plain data</summary>
162
-
163
- The moment definitions contain closures, you lose:
164
-
165
- - `JSON.stringify` round-trip for DB / localStorage persistence
166
- - `postMessage` transfer to a Web Worker
167
- - Static reachability analysis by a visualizer tool
168
- - Auto-generated event arbitraries from a PBT adapter
169
-
170
- aifsmjs follows the XState v5 two-phase pattern (`setup().createMachine()`): the definition uses string refs; the function map is injected at `createRuntime()`. Inline functions are still allowed but flagged as an escape hatch.
171
-
172
- </details>
173
-
174
- ---
175
-
176
- ## Core API
177
-
178
- ### `defineMachine<C, E, S>(def)`
179
-
180
- ```typescript
181
- function defineMachine<
182
- Ctx = Record<string, never>,
183
- Evt extends { type: string } = { type: string },
184
- States extends string = string,
185
- >(def: MachineConfig<Ctx, Evt, States>): MachineDef<Ctx, Evt, States>;
186
- ```
187
-
188
- Pure data builder. Validates that `initial` exists in the `states` map and returns the (normalized) definition.
189
-
190
- `context` is **optional** — omit it for stateless machines and it defaults to `{}` (the type parameter defaults to `Record<string, never>`). Existing definitions that pass `context` are unaffected.
191
-
192
- ```typescript
193
- // No context needed — defaults to {}
194
- const toggle = defineMachine({
195
- id: "toggle",
196
- initial: "off",
197
- states: {
198
- off: { on: { TOGGLE: "on" } }, // string shorthand, see below
199
- on: { on: { TOGGLE: "off" } },
200
- },
201
- });
54
+ console.log(runtime.getSnapshot().value); // "green"
202
55
  ```
203
56
 
204
- **String-shorthand transitions.** A transition value may be either the full object form or a bare target-state string (à la XState). The string is normalized to `{ target }` before processing — it carries no guard or actions:
205
-
206
- ```typescript
207
- on: { NEXT: "green" } // shorthand for { target: "green" }
208
- on: { NEXT: [{ target: "a", guard: "g" }, "b"] } // mixes with the object form
209
- ```
210
-
211
- ### `createRuntime(def, impl, opts?)`
212
-
213
- ```typescript
214
- function createRuntime<C, E, S>(
215
- def: MachineDef<C, E, S>,
216
- impl: Implementations<C, E>,
217
- opts?: { middleware?: readonly Middleware<C, E, S>[] },
218
- ): Runtime<C, E, S>;
219
-
220
- interface Runtime<C, E, S> {
221
- getSnapshot(): Snapshot<C, S>;
222
- send(event: E): Snapshot<C, S>;
223
- subscribe(listener: (snap: Snapshot<C, S>) => void): () => void;
224
- reset(event?: E): Snapshot<C, S>;
225
- dispose(): void;
226
- readonly disposed: boolean;
227
- readonly signal: AbortSignal;
228
- }
229
- ```
57
+ Prefer `setup<Ctx, Evt>().defineMachine()` for state inference. Use bare `defineMachine<Ctx, Evt, States>()` only when you need explicit generic control.
230
58
 
231
- Thin wrapper. Internally calls `step()` and dispatches effects. `dispose()` aborts the built-in `AbortController`, clears listeners, and causes subsequent `send()` / `reset()` calls to throw `RuntimeDisposedError`. `reset()` rewinds the snapshot to `initialSnapshot(def)` and notifies subscribers, but **does not run entry actions** — reset is "the runtime is reborn", not a transition.
232
-
233
- `runtime.signal` is the runtime's lifetime signal; it fires once on dispose. Every `EffectHandler` receives it via `args.signal`. External integrations (React unmount, game scene teardown) can attach `runtime.signal.addEventListener("abort", ...)` to chain their own cleanup.
234
-
235
- ### `step(def, snapshot, event, impl)`
236
-
237
- ```typescript
238
- function step<C, E, S>(
239
- def: MachineDef<C, E, S>,
240
- snapshot: Snapshot<C, S>,
241
- event: E,
242
- impl: Implementations<C, E>,
243
- ): { snapshot: Snapshot<C, S>; effects: readonly Effect[] };
244
- ```
245
-
246
- **Pure function**. The invariant keeper for the whole library. It never dispatches effects and never mutates the snapshot. A failing guard or unmapped event simply returns the original snapshot unchanged. It does throw on misuse (`UnknownGuardError`, `UnknownActionError`, `AsyncGuardError`) so guard/action wiring errors surface at development time rather than silently passing.
247
-
248
- ### `assign(updater)`
249
-
250
- ```typescript
251
- function assign<C, E>(
252
- updater: (args: { context: C; event: E }) => Partial<C>,
253
- ): Action<C, E>;
254
- ```
255
-
256
- Pure context update helper. Returns a partial that is merged into the context. No side effects.
257
-
258
- ---
259
-
260
- ## Opt-in Modules
261
-
262
- Each opt-in lives on its own subpath. If you don't import it, it is fully tree-shaken away.
263
-
264
- ### `aifsmjs/guards` — Guard combinators
265
-
266
- ```typescript
267
- import { and, or, not, stateIn } from "aifsmjs/guards";
268
-
269
- const canCheckout = and([
270
- "isAuthenticated",
271
- or(["isAdmin", "isOwner"]),
272
- not("isBanned"),
273
- ]);
274
- ```
275
-
276
- `and/or/not` short-circuit over sync guards. `stateIn(...states)` is a sugar predicate: "current state is one of these".
277
-
278
- ### `aifsmjs/effects` — Fire-and-forget effects
279
-
280
- ```typescript
281
- import { type Action } from "aifsmjs";
282
-
283
- const checkout: Action<Ctx, Evt> = ({ context, enqueue }) => {
284
- enqueue.effect("trackAnalytics", { event: "checkout", ctx: context });
285
- // Return value becomes the new context (omit to keep current context)
286
- };
287
- ```
288
-
289
- `enqueue.effect(type, payload)` queues a side-effect declaration. `step()` collects them and hands them back to the caller. Runtime dispatches after the transition; replay mode disables dispatch and keeps only the snapshot fold.
290
-
291
- ### `aifsmjs/inspect` — Read-only middleware
292
-
293
- ```typescript
294
- import { createRuntime } from "aifsmjs";
295
- import { logger, persist } from "aifsmjs/inspect";
296
-
297
- const runtime = createRuntime(def, impl, {
298
- middleware: [
299
- logger(console.log),
300
- persist({ key: "machine-state", storage: localStorage }),
301
- ],
302
- });
303
- ```
304
-
305
- Koa-style `(ctx, next) => void` pipeline. `ctx` is `{ prev, next, event, effects, changed }`, all deep-frozen. **Cannot cancel a transition** — `next()` must be called; the return value carries no meaning.
306
-
307
- Two boundaries to respect (both stable for 1.x, detailed in [STABILITY.md](STABILITY.md)): skipping `next()` is **not** enforced and silently drops every later middleware (and the `recorder` / `persist` sinks) for that event; and a synchronous throw from a middleware (e.g. `persist` on non-serialisable context) propagates to the caller but leaves the snapshot **committed yet unannounced** — `notify()` and `on('transition')` are skipped. Do **not** call `runtime.send()` from inside the pipeline: the inner event runs its full pipeline before the outer frame finishes, so the `recorder` log is reordered and `replay()` diverges.
308
-
309
- ### `aifsmjs/replay` — Pure event log replay
310
-
311
- ```typescript
312
- import { replay } from "aifsmjs/replay";
313
-
314
- const finalSnap = replay(initialSnapshot, eventLog, def, impl);
315
- // Equivalent to eventLog.reduce((s, e) => step(def, s, e, impl).snapshot, initial)
316
- ```
317
-
318
- Never dispatches effects. For PBT, time-travel debugging, and incident reproduction.
319
-
320
- ### `aifsmjs/pbt` — fast-check adapter
321
-
322
- > **Install the peer**: `pnpm add -D fast-check` (^3.20.0). aifsmjs lists fast-check as an optional peer; you only need it when importing this subpath.
323
-
324
- ```typescript
325
- import fc from "fast-check";
326
- import { createRuntime } from "aifsmjs";
327
- import { commandsFromMachine, initialModel, properties } from "aifsmjs/pbt";
328
-
329
- // Use one of the six built-in generic properties, or assertAll for all at once:
330
- properties.replayEqualsFold(def, impl, {
331
- NEXT: fc.constant({ type: "NEXT" as const }),
332
- });
333
-
334
- // Or build a custom property using commandsFromMachine:
335
- fc.assert(
336
- fc.property(
337
- commandsFromMachine(def, impl, {
338
- NEXT: fc.constant({ type: "NEXT" as const }),
339
- }),
340
- (cmds) => {
341
- const real = createRuntime(def, impl, { dispatchEffects: false });
342
- fc.modelRun(() => ({ model: initialModel(def), real }), cmds);
343
- return true;
344
- },
345
- ),
346
- );
347
- ```
348
-
349
- `properties.*` ships 6 generic properties (see [Testing Strategy](#testing-strategy)). `fast-check` is `peerDependenciesMeta.optional`; no install penalty if you don't use it.
350
-
351
- ### `aifsmjs/timer` — Cancellable delayed callbacks
352
-
353
- ```typescript
354
- import { after, createScheduler } from "aifsmjs/timer";
355
-
356
- // One-shot
357
- const handle = after(5000, () => runtime.send({ type: "TIMEOUT" }));
358
- handle.cancel(); // cancels if not yet fired
359
-
360
- // AbortSignal integration
361
- const ac = new AbortController();
362
- after(5000, () => runtime.send({ type: "TIMEOUT" }), { signal: ac.signal });
363
- ac.abort(); // also cancels
364
-
365
- // Scheduler: bundle a group of timers and cancel them together on teardown
366
- const sched = createScheduler();
367
- sched.after(1000, () => {});
368
- sched.after(2000, () => {});
369
- sched.cancelAll();
370
- ```
371
-
372
- - Thin wrapper over `setTimeout` / `clearTimeout`, with injectable timer functions (validated by vitest fake timers)
373
- - AbortSignal listener registered with `{ once: true }` to avoid leaks
374
- - Decoupled from the FSM core: you decide when to forward a fired timer as `runtime.send(...)`
375
-
376
- ---
377
-
378
- ## Lifecycle Invariants
379
-
380
- The fixed order inside `step()` (always, no escape hatch):
381
-
382
- ```
383
- 1. resolveTransitions(def, snapshot.value, event)
384
- → candidate transitions for (state, event)
385
- 2. evaluate guard on each candidate in declaration order
386
- → first passing transition is chosen; otherwise the original snapshot is returned
387
- 3. exit actions of the old state (v1 is flat, no hierarchy)
388
- 4. transition.actions[] run in declaration order
389
- → each action may call enqueue.effect()
390
- → each action's returned partial context is merged into the current context
391
- 5. entry actions of the new state
392
- 6. return { snapshot, effects } — the caller decides when to dispatch effects
393
- ```
59
+ ## Public Surface
394
60
 
395
- **Contracts**:
61
+ | Import | Purpose |
62
+ | --- | --- |
63
+ | `aifsmjs` | `setup`, `defineMachine`, `createRuntime`, `createMachine`, `step`, `assign`, snapshots, runtime/errors/types. |
64
+ | `aifsmjs/guards` | `and`, `or`, `not`, `stateIn`. Guards must be synchronous. |
65
+ | `aifsmjs/effects` | `enqueue.effect()` descriptors and `runEffects()`. |
66
+ | `aifsmjs/inspect` | Read-only middleware helpers: `logger`, `persist`, `recorder`. |
67
+ | `aifsmjs/replay` | Pure event-log replay. |
68
+ | `aifsmjs/pbt` | fast-check property helpers. |
69
+ | `aifsmjs/timer` | `after()` and `createScheduler()`. |
396
70
 
397
- Guarantees:
71
+ ## Lifecycle Rules
398
72
 
399
- - Guards are sync and pure (never mutate context)
400
- - Actions always run to completion (no cancel mechanism)
401
- - Effects are declarations (type + payload), not callbacks — serializable
402
- - Snapshot is immutable; dev mode deep-freezes for diagnostics, prod is shallow for speed
73
+ - `step(def, snapshot, event, impl)` is pure and returns `{ snapshot, effects, changed }`.
74
+ - `createRuntime()` owns mutable runtime state, dispatches effects after commit, and emits transition/error/dispose events.
75
+ - Guards and reducers are sync. Thenable guards throw `AsyncGuardError`.
76
+ - Effects are fire-and-forget descriptors. Async rejection is routed to the runtime `"error"` channel.
77
+ - `reset()` rewinds the snapshot and notifies listeners, but does not run entry actions.
78
+ - `dispose()` is idempotent; post-dispose `send()`/`reset()` throw `RuntimeDisposedError`.
403
79
 
404
- Non-goals:
80
+ ## Sharp Edges
405
81
 
406
- - No async lifecycle hook
407
- - Inspect middleware cannot alter the transition outcome
82
+ - Middleware and synchronous effect throws happen after snapshot commit. A throw can leave the committed snapshot visible without later notification.
83
+ - Sub-machine replacement can roll back on init failure, but dispose failure has already torn down the old child.
84
+ - `subRuntime()` can return a disposed child handle if external code disposed it; it is recreated only after the parent exits and re-enters the sub state.
85
+ - `setup().defineMachine()` uses `NoInfer` so states infer from `keyof states`; keep regression tests for exact optional property configurations.
86
+ - Do not perform async I/O inside guards or actions. Send events from effects instead.
408
87
 
409
- ### Sub-machine lifecycle (stable since 0.4.0)
88
+ ## AI Context
410
89
 
411
- When a state declares `sub`, the per-transition ordering is:
412
-
413
- 1. Parent `step()` runs: `exit actions → transition.actions → entry actions`.
414
- 2. Old child (if any) `dispose()` — synchronous; exceptions become
415
- `SubMachineError(phase: "dispose")`.
416
- 3. New child (if next state has `sub`) instantiation — exceptions become
417
- `SubMachineError(phase: "init")`.
418
- 4. Parent snapshot commits.
419
- 5. Middleware pipeline runs.
420
- 6. Effects dispatch.
421
- 7. `'transition'` event emits to `on()` / `onTransition()` subscribers.
422
-
423
- If step 2 or 3 throws, the parent snapshot is **not** committed (rollback
424
- to `prev`); no middleware / effects / `'transition'` runs.
425
-
426
- `runtime.dispose()` cascades to the child via `controller.signal`'s abort
427
- listener and an explicit `child.dispose()` call. Cascade swallows child
428
- exceptions to honour the never-throws dispose contract.
429
-
430
- ---
431
-
432
- ## Lifecycle Protocol
433
-
434
- aifsmjs is the first package in a "minimal AI toolchain" family. This lifecycle protocol is meant to be reused by future packages (`aitaskjs / aibridgejs / aiaudiojs` and friends):
435
-
436
- | Verb | aifsmjs equivalent | Semantics |
437
- |---|---|---|
438
- | `createX()` | `createRuntime` / `createScheduler` / `defineMachine` / `setup` | Factory function returning the instance |
439
- | `dispose()` | `runtime.dispose()` / `scheduler.cancelAll()` | Release resources; idempotent; post-dispose API throws a known error |
440
- | `reset()` | `runtime.reset()` | Zero out state without releasing resources |
441
- | `on/off` | `runtime.subscribe(fn)` returning an unsubscribe fn | Subscription pattern; explicit unsubscribe |
442
- | `AbortSignal` | `runtime.signal` / `after(_, _, { signal })` | Cancellation channel for any long-running / async work |
443
- | Pure core | `step()` | No I/O, serializable, replayable |
444
- | Explicit errors | `RuntimeDisposedError` / `UnknownGuardError` / `UnknownActionError` / `InvalidDefinitionError` | Named error classes, never bare `throw "string"` |
445
-
446
- When future ai\*js packages ask "should this have a dispose?" or "where does the signal plug in?", this table is the baseline.
447
-
448
- ---
449
-
450
- ## Design choices: divergence from common patterns
451
-
452
- aifsmjs ships a few opinionated calls that look different from the typical FSM library. The rationale below explains what we chose and why, so readers coming from XState, statecharts, or general event-emitter libraries can skip the source dive.
453
-
454
- - **`send()` is synchronous, returning `Snapshot` instead of `Promise<Snapshot>`.** The pure `step()` core is sync by construction so that `replay(initial, log)` and PBT shrinking remain trivial. Effect handlers may still be async; the runtime fires them and forwards async rejections to the `'error'` event channel. If you need to await effect completion, build a small wrapper that returns `Promise.all` over your handler results.
455
- - **Guards and reducers are sync.** A non-deterministic guard would break the PBT determinism property (#1 in the generic suite). Move async predicates into events: send `FETCH_REQUEST`, then later `FETCH_DONE` with the resolved value as payload.
456
- - **Effects are descriptors, not inline callbacks.** Actions enqueue `{ type, payload }` via `enqueue.effect(...)`; the runtime collects them and the dispatcher invokes user handlers. This keeps machine definitions serializable (JSON round-trippable when no inline functions are used), enables `replay()` to fold an event log into the same snapshot, and lets `inspect/persist` middleware capture effects for audit logs.
457
- - **Two factory paths coexist.** `setup<Ctx, Evt>().defineMachine(...)` is the type-friendly form (States inferred from `keyof states`). `createMachine(def, impl, opts?)` is the spec-style single-factory shortcut from the ai*js ecosystem review. Plain `defineMachine<Ctx, Evt, States>(def)` remains for explicit generic control. Pick whichever reads best at the call site.
458
- - **Transitions accept a string shorthand.** `on: { EVENT: "targetState" }` is sugar for `on: { EVENT: { target: "targetState" } }`, normalized in the resolver before any guard/action processing. The shorthand has no guard or actions; reach for the object form when you need them. It composes inside the array form too, so guard-fallthrough lists can mix `{ target, guard }` objects with bare target strings. The full object form is unchanged — this is purely additive.
459
- - **`context` is optional.** Omit it for stateless machines and it defaults to `{}` (`Ctx` defaults to `Record<string, never>`). Definitions that already pass `context` keep their inferred type and behave identically.
460
- - **`subscribe(listener)` and `on(type, fn, { signal, once })` both exist.** The typed `on()` matches the platform `EventTarget` semantics (signal + once) and emits `'transition'`, `'error'`, `'dispose'`. The older `subscribe()` keeps the React `useSyncExternalStore` shape — pass it directly. They are not exclusive.
461
-
462
- ---
463
-
464
- ## AI-Agent Reading Guide
465
-
466
- > This section is for LLMs and code-search agents. Invariants, types, and misuse patterns are concentrated here.
467
-
468
- ### Serializable fields
469
-
470
- The following are plain data, safe to `JSON.stringify` round-trip:
471
-
472
- - The entire `MachineDef` (provided no inline functions are used)
473
- - The entire `Snapshot` (provided `context` is plain data)
474
- - The entire `Effect` (`{ type: string; payload?: unknown }`)
475
-
476
- The following are **not serializable** and will break PBT/replay:
477
-
478
- - Every function inside `Implementations`
479
- - Middleware closures
480
-
481
- ### Invariants (do not violate)
482
-
483
- 1. `step()` is pure: identical `(def, snapshot, event, impl)` always returns identical `{ snapshot, effects }`.
484
- 2. Snapshots are frozen: in dev mode any mutation throws immediately.
485
- 3. Guards never mutate context: violators are caught by PBT property #2.
486
- 4. Effects are always fire-and-forget: the runtime never waits for an effect before updating the snapshot.
487
- 5. `dispose()` is idempotent; post-dispose `send()` / `reset()` throws `RuntimeDisposedError`.
488
- 6. `runtime.signal.aborted` is `true` for the rest of time once disposed; the effect handler's `signal` is the same one.
489
- 7. `reset()` only resets the snapshot and notifies listeners — it does **not** run entry actions. Listeners are notified only when `prev.value !== initial.value` (parity with `send()`). Middleware always observes the call (possibly with `changed: false`).
490
- 8. `MiddlewareContext.event` is typed `Evt | ResetEvent`; a `reset()` without an event injects the `RESET_EVENT_TYPE` sentinel (`"@@aifsmjs/RESET"`).
491
-
492
- ### Common misuses
493
-
494
- | Anti-pattern | Correct form |
495
- | --------------------------------------------------------- | ------------------------------------------------------------- |
496
- | Calling `fetch()` (or any async API) inside a guard | Rewrite as events: send `FETCH_REQUEST`, then `FETCH_DONE` |
497
- | `setTimeout`-and-mutate inside an action | Use `enqueue.effect("delayedThing", ...)` |
498
- | Using middleware to alter the next state | Not possible — middleware is read-only. Rewrite as a guard. |
499
- | Inline functions inside a definition (works but breaks serialize) | Pull out as string refs, inject at `createRuntime` |
500
-
501
- ### Machine-readable schema
502
-
503
- A JSON schema for `MachineDef` will ship at `dist/schema/machine.schema.json`. Not yet available in v1; types live in [src/fsm/types.ts](src/fsm/types.ts) for agents to derive from.
504
-
505
- ---
506
-
507
- ## Testing Strategy
508
-
509
- Example-first, PBT-augmented. Lesson from jssm: "3000+ tests / 100% coverage" turns out to have < 12% coverage from stochastic tests — the rest is example specs.
510
-
511
- - **Example tests** (vitest): for every src module, write happy path + edge + error-message triplets.
512
- - **PBT smoke**: each generic property runs 50 iterations as an invariant guard, not as a coverage source.
513
- - **CI-enforced thresholds**: `@vitest/coverage-v8` is wired to **100% lines / 100% functions / ≥95% statements / ≥90% branches**. The few defensive invariant-guard branches (e.g. runtime determinism mismatch) carry `/* v8 ignore */` annotations with rationale.
514
- - **Size budget**: `scripts/check-size.mjs` enforces per-subpath gzip caps in CI, measured as each entry's transitive closure (entry + shared chunks — the build code-splits so error classes keep cross-subpath identity) — core ≤6.5 KB, pbt ≤8.5 KB (transitively imports `createRuntime`), replay ≤3.3 KB, effects ≤1.7 KB, guards ≤1.5 KB, timer ≤1.2 KB, inspect ≤1 KB. Exceeding any cap fails the build.
515
-
516
- ### The 6 built-in generic properties
517
-
518
- | # | Property | One-liner |
519
- | --- | --------------------------------- | -------------------------------------------------------- |
520
- | 1 | snapshotAlwaysFrozen | After any event sequence, the snapshot remains frozen |
521
- | 2 | unknownEventNoOp | Undeclared events do not change the snapshot |
522
- | 3 | reachableStatesSubsetDeclared | Every reachable state belongs to `def.states` |
523
- | 4 | replayEqualsFold | `replay(init, log)` equals `events.reduce(step)` |
524
- | 5 | guardsFalseNoTransition | When all guards fail, the state is unchanged |
525
- | 6 | assignDoesNotMutate | `assign` never modifies the previous context |
526
-
527
- ---
528
-
529
- ## Comparison
530
-
531
- | | aifsmjs | XState v5 | Robot3 | @xstate/store | Zag.js |
532
- | -------------------------- | -------------- | ----------------- | ----------------- | ----------------- | ----------------- |
533
- | Core size (gzip) | ~2.8KB | ~15KB | ~1KB | < 1KB | per-component |
534
- | Hierarchical states | Sugar (0.3.0) | Yes | No | N/A | Yes |
535
- | Async invoke / actor | No | Yes | No | N/A | No |
536
- | Guard combinators | and/or/not | and/or/not | No | N/A | No |
537
- | Effects dual-track | enqueue | enqueueActions | reduce/action | enq.effect() | array of names |
538
- | Inspect / observe | read-only | inspect API | No | proposed | watch ctx |
539
- | Serializable definition | Yes | Yes | Partial | Partial | Yes |
540
- | fast-check adapter | built-in | No | No | No | No |
541
- | Tree-shake subpath imports | Yes | Partial | Yes | Yes | Yes |
542
-
543
- ---
544
-
545
- ## Roadmap
546
-
547
- | Version | Scope |
548
- | ------- | ------------------------------------------------------------------ |
549
- | v0.1 | core + guards + effects + inspect + replay + pbt (this release) |
550
- | v0.2 | Async-guard detection, coverage tuning, llms-full.txt verify gate |
551
- | v0.3 | Hierarchical sugar via `state.sub` (experimental) |
552
- | v0.4 | Sub-machine API promoted to stable; dependency-reduction cycle |
553
- | v0.5 | `aifsmjs-bridge-bitecs` / `aifsmjs-bridge-pixi` (separate sub-packages) |
554
- | v1.0 | API freeze and stability guarantee |
555
-
556
- **Out of scope (v1)**:
557
-
558
- - **Parallel state regions** (out of scope for v1)
559
- - **Actor invocation / spawn** (out of scope for v1)
560
- - **Tick / game-loop hook** (out of scope for v1)
561
- - **ECS / Pixi bridges** (out of scope for v1)
562
-
563
- **Future candidate**: `historyState` — remember last active sub-state on
564
- re-entry. Workaround today: snapshot via `onTransition` and restore
565
- manually.
566
-
567
- ---
90
+ - Short index: [`llms.txt`](llms.txt)
91
+ - Full generated context: [`llms-full.txt`](llms-full.txt)
92
+ - Stability contract: [`STABILITY.md`](STABILITY.md)
93
+ - Current review backlog: [`REVIEW.md`](REVIEW.md)
94
+ - Release history: [`CHANGELOG.md`](CHANGELOG.md)
568
95
 
569
96
  ## License
570
97
 
571
- [MIT](LICENSE)
98
+ MIT
572
99
 
573
100
  ---
574
101
 
@@ -576,502 +103,85 @@ manually.
576
103
 
577
104
  # Changelog
578
105
 
579
- All notable changes to this project will be documented in this file.
580
-
581
- The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
582
- and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
106
+ All notable changes to aifsmjs are summarized here.
583
107
 
584
108
  ## [Unreleased]
585
109
 
586
- ## [0.5.6] - 2026-06-10
587
-
588
- ### Fixed
589
-
590
- - **Guard combinators route through the async-guard safety net** — `and()` / `or()` / `not()` now pass each resolved guard's return value through the same `isThenable` check as top-level guards, throwing `AsyncGuardError` instead of coercing a pending thenable to `true`/`false`. The 0.2.0 async-guard protection was bypassable through any combinator. (Review wave 2026-06-10, FSM-S-01.)
591
- - **`reset()` captures the committed snapshot before notifying** (mirroring the 0.2.0 `send()` fix), so a listener that re-enters `send()` during reset can no longer make the `'transition'` payload report a state pair that never occurred. (FSM-B-01.)
592
- - **Scheduler `pending` registry no longer leaks** on any of its three escape paths: signal-abort cancellation, scheduling on an already-aborted signal, and a custom scheduler that fires synchronously during `after()`. Signal handling is owned by the scheduler layer now; fire/cancel/abort all remove the tracked handle and detach the abort listener. (FSM-R-01.)
593
- - PBT property #5 `guardsFalseNoTransition` actually asserts `changed === false` (its body was vacuous — it returned `true` unconditionally); the other five generic properties were audited and are non-vacuous. (FSM-B-02.)
594
- - Hierarchical `sub.initial` is validated as a member of `sub.states` at definition time. (FSM-S-02.)
595
- - Effect results are detected with `isThenable` instead of `instanceof Promise` at both dispatch sites, so cross-realm / userland PromiseLike values are awaited and error-routed correctly. (FSM-B-03.)
596
- - `emit()` / `notify()` snapshot their listener sets before iterating (family-canonical), pinning unsubscribe/re-subscribe-during-dispatch semantics. (FAM-S-03.)
597
-
598
- ### Changed
599
-
600
- - **Code splitting enabled (`tsup splitting: true`)** — the published 0.5.x dist inlined a private copy of the shared runtime (including every error class) into each subpath bundle, so an error raised through `aifsmjs/guards` failed `instanceof` checks against the root export. Shared chunks restore cross-subpath class identity; total dist JS shrank 50.7 KB → 31.1 KB. `check:size` now measures each entry's transitive chunk closure, with budgets recalibrated and the README size-budget bullet updated to match.
601
- - Triplicated child init/wire logic extracted into a single `initChildFor()` helper (behaviour-preserving).
602
- - Supply-chain and release hardening: CI/publish actions SHA-pinned, npm CLI pinned (`11.16.0`), `permissions: contents: read` on CI, job timeouts, `npm publish --ignore-scripts`, manual dispatch defaults to dry-run, new `verify:docs` banner gate, two-stage typecheck (tests are now type-checked), `llms-full.txt` embeds `STABILITY.md`, and a cross-subpath dist smoke (`verify:dist`).
603
-
604
- ### Docs
605
-
606
- - README coverage claim corrected to the enforced thresholds (95% statements); middleware sync-throw commit/notify boundary and `next()`-skip behaviour documented (with characterisation tests); README status banner added (EN + ZHTW).
110
+ ## [0.5.9] - 2026-06-29
607
111
 
608
- ## [0.5.5] - 2026-06-08
112
+ - Fixed: `dispose()` never throws and always completes teardown even if a `'dispose'` event listener throws (external-signal abort cleanups no longer leak); restores the never-throws / idempotency contract.
113
+ - Fixed: sub-machine definitions are now deep-validated at construction — an unknown transition target or a declared-async guard inside a `sub` is rejected by `defineMachine` (with a cycle guard) instead of surfacing only at `child.send()`.
114
+ - Fixed: PBT replay/assign oracles use structural deep-equality (`node:util` `isDeepStrictEqual`) instead of `JSON.stringify`, which mis-handled key order, `undefined` keys, `Map`/`Set`/`Date`, and `BigInt`.
115
+ - Docs: clarified that `replay()` reproduces parent value+context only (sub-machine state is not modelled) and that production snapshots are frozen at the top level only.
609
116
 
610
- ### Changed
117
+ ## [0.5.8] - 2026-06-14
611
118
 
612
- - Project home migrated to the [`islumina`](https://github.com/islumina) GitHub org; the package is now published from there via npm trusted publisher (OIDC + SLSA provenance). Family-wide version alignment at `0.5.5` — no runtime or API changes.
119
+ - Documentation-only slimming pass across README, stability notes, review backlog, and LLM context. Family version alignment at 0.5.8 — no runtime or API change. `setup().defineMachine()` inference tests and an opt-in safer mode for post-commit synchronous throws remain documented follow-ups.
613
120
 
614
- ## [0.5.3] - 2026-06-05
615
-
616
- ### Added
617
-
618
- - **String-shorthand transitions and optional `context` (both additive, non-breaking).** Transitions now accept a bare target string — `on: { EVENT: "target" }` is sugar for `{ target: "target" }`, normalized in the resolver and composable inside guard-fallthrough arrays; and `context` is now optional in `defineMachine` / `setup().defineMachine`, defaulting to `{}` (`Ctx` defaults to `Record<string, never>`). Existing object-form transitions and explicit-`context` definitions are unaffected. (`src/fsm/types.ts`, `src/fsm/resolver.ts`, `src/fsm/definition.ts`, `src/fsm/lifecycle.ts`, `src/fsm/runtime.ts`)
619
-
620
- ## [0.5.2] - 2026-06-05
621
-
622
- ### Docs
623
-
624
- - Review-driven documentation fixes (`README.md`, `README_ZHTW.md`, `llms-full.txt`): clarity and accuracy from a cross-package code review. No runtime or API change; `dist` byte-identical to 0.5.1.
625
-
626
- ## [0.5.1] - 2026-06-02
627
-
628
- ### Fixed
629
-
630
- - **Memory: `after()` and `createScheduler().after()` accumulated dead abort
631
- listeners on a reused `AbortSignal`.** `{ once: true }` only removes the
632
- listener when the signal fires — not when the timer fires normally or
633
- `cancel()` is called. Scheduling many timers on one long-lived signal
634
- therefore accumulated dead `"abort"` closures. The fix explicitly calls
635
- `signal.removeEventListener("abort", cancel)` inside the fire callback (so
636
- a fired timer detaches immediately) and at the end of `cancel()` (so a
637
- cancelled timer also detaches). Same class of leak as the [0.1.2] and
638
- [0.3.1] abort-listener fixes; the timer subpath was the remaining gap.
639
- Four regression tests added to `test/timer/scheduler.test.ts` covering
640
- both `after()` and `createScheduler().after()`, fire and cancel paths.
641
- (`src/timer/scheduler.ts`)
642
-
643
- ### Changed
644
-
645
- - **`fast-check` peer dependency range extended to `^3.20.0 || ^4.0.0`.**
646
- The ai\*js family standard is fast-check v4.8; consumers who have already
647
- upgraded to v4 no longer need to suppress a peer warning. The devDependency
648
- is pinned to `^4.8.0` so CI runs against v4. Consumers who remain on v3
649
- are fully unaffected — the `||` range keeps v3 satisfied. The `aifsmjs/pbt`
650
- subpath is the only entry point that imports fast-check; the core and all
651
- other subpaths are tree-shake-free of it.
652
-
653
- ## [0.4.1] — 2026-05-29
654
-
655
- ### Changed
656
-
657
- - **`STABILITY.md` is now repo-only** — removed from the npm `files`
658
- allowlist, aligning with the majority of the ai*js family (5 of 7
659
- packages already ship the stability contract repo-only). The file stays
660
- in the repository and remains visible on GitHub and the rendered npm
661
- package page; it is simply no longer bundled inside the published
662
- tarball. Packaging consistency patch: **no runtime API change, no
663
- signature change**; the built bundles (`dist/`) are byte-identical to
664
- 0.4.0 (core gzip 4,387 B).
665
-
666
- ## [0.4.0] — 2026-05-29
667
-
668
- ### Changed
669
-
670
- - **Sub-machine API promoted experimental → stable.** The hierarchical
671
- sub-machine surface shipped in 0.3.0 — `StateDef.sub`, `StateDef.subImpl`,
672
- `Runtime.subRuntime()`, `SubMachineError`, and the `SubMachineDef` type
673
- alias — is now stable. Signatures and runtime semantics, including the
674
- init-failure quarantine behaviour, are frozen for the 1.x line; the
675
- boundaries documented in `STABILITY.md` are intentional design trade-offs,
676
- not instability. **No signature changed from 0.3.x.**
677
-
678
- ### Dependency reduction
679
-
680
- - Part of the ai*js v0.4.0 dependency-reduction cycle. `fast-check` remains
681
- an **optional** peer dependency isolated to the `aifsmjs/pbt` subpath. A
682
- fresh build confirms the core entry and the five non-pbt subpaths
683
- (`guards` / `effects` / `inspect` / `replay` / `timer`) are tree-shake-free
684
- of `fast-check` (only `dist/pbt/index.js` references it). `pnpm audit`
685
- reports zero advisories. No `devDependency` changes.
686
-
687
- ### Compatibility
688
-
689
- This release adds **no runtime API** and changes **no signature**. The core
690
- bundle is byte-identical to 0.3.1 (gzip 4,387 B); existing 0.3.x consumer
691
- code is unaffected. The only substantive change is the documented stability
692
- tier of the sub-machine API.
693
-
694
- ## [0.3.1] — 2026-05-29
695
-
696
- ### Fixed
697
-
698
- - **Memory: `on(type, fn, { once: true, signal })` left the abort listener
699
- attached after the once-handler fired.** The `once` wrapper removed itself
700
- from the listener set but did not detach the `AbortSignal` listener or drop
701
- its entry from the internal cleanup set, so the closure lingered on the
702
- external signal until the signal aborted or `dispose()` ran. With a
703
- long-lived signal and repeated once+signal registration, dead listeners
704
- accumulated. `on()` now routes the once-wrapper, the abort handler, and the
705
- returned unsubscribe through a single `cleanup()` that always detaches the
706
- abort listener. `runtime.onTransition(fn, { once, signal })` inherits the
707
- fix (it delegates to `on`). Same class of leak as the [0.1.2] abort-listener
708
- fix; `once` + `signal` together was the remaining gap. Present since 0.1.2.
709
-
710
- This release is **non-breaking**. No API surface change; `once`-only,
711
- `signal`-only, and no-option callers are byte-for-byte unaffected at runtime.
712
- Core gzip 4,393 B → 4,387 B (the shared `cleanup` closure deduplicates).
713
-
714
- ## [0.3.0] — 2026-05-29
715
-
716
- ### Added
717
-
718
- - **Hierarchical / sub-machine sugar** (experimental). `StateDef` accepts
719
- two new optional fields: `sub` (a child `SubMachineDef`) and `subImpl`
720
- (the child's `Implementations`). When the runtime enters a state with
721
- `sub`, a child `Runtime` is lazily instantiated; when it exits, the child
722
- is disposed. Access via `runtime.subRuntime()`. See `STABILITY.md` for
723
- the experimental contract.
724
- - New error: `SubMachineError` (`{ parentState, phase, cause }`) thrown
725
- by `send()` / `reset()` on child init / dispose failure. `dispose()`
726
- cascade swallows child dispose exceptions (idempotent + never-throws
727
- contract).
728
- - New type alias: `SubMachineDef<SubCtx, SubEvt, SubStates>`.
729
- - **`runtime.onTransition(handler, opts?)`** — semantic sugar over
730
- `runtime.on('transition', handler, opts)`. One-line delegation; shares
731
- the same listener Set, so registration order across both APIs determines
732
- invocation order. Returned unsubscribe identical to `on('transition', ...)`.
733
-
734
- ### Stability
735
-
736
- - New file: `STABILITY.md`. Documents the three tiers: **stable**
737
- (everything shipped 0.1.0–0.2.1), **experimental** (`sub`, `subImpl`,
738
- `subRuntime`, `SubMachineError`, `SubMachineDef`), **draft**
739
- (`historyState`, v0.4 candidate).
740
-
741
- ### Changed (positioning)
121
+ ## [0.5.6] - 2026-06-10
742
122
 
743
- - **README "Capabilities / Limitations" table**: "Hierarchical / compound
744
- states" moved out of the "Won't do" column. Added on the "Will do" side
745
- as "Hierarchical sugar via `state.sub` (experimental since 0.3.0)".
746
- - **README Lifecycle Invariants**: documented the sub-machine ordering —
747
- parent `step()` lifecycle (exit / actions / entry) runs first, then
748
- child dispose → child init → snapshot commit → middleware → effects →
749
- `transition` emit.
123
+ - Hardened async guard rejection, reset snapshot integrity, sub-machine lifecycle cleanup, and scheduler abort cleanup.
124
+ - Clarified fire-and-forget effect semantics and post-commit ordering.
125
+ - Regenerated generated LLM context from canonical docs.
750
126
 
751
- ### Build & tooling
127
+ ## Older releases
752
128
 
753
- - **`scripts/check-size.mjs`**: core gzip budget raised 3,700 → 4,700 B
754
- to absorb sub-machine lifecycle, `SubMachineError`, and `onTransition`
755
- sugar (measured at 4,465 B). `pbt` budget raised 4,600 → 5,500 B because
756
- `pbt/properties.ts` imports `createRuntime` from `runtime.ts`; the new
757
- sub-machine code is pulled in transitively (measured at 5,228 B).
129
+ - `0.5.5` through `0.5.1` focused on release hygiene, docs accuracy, property tests, and lifecycle regressions.
130
+ - `0.4.x` stabilized sub-machine lifecycle semantics.
131
+ - `0.3.x` added inspect/replay/PBT/timer helpers and dependency reduction.
132
+ - `0.2.x` hardened definitions, guard/action resolution, and examples.
133
+ - `0.1.x` introduced `defineMachine`, `createRuntime`, `step`, `assign`, snapshots, and core error classes.
758
134
 
759
- ### Compatibility
135
+ ---
760
136
 
761
- This release is **non-breaking** for v0.2.1 callers who do not opt into
762
- the new sub-machine fields. All existing API signatures, error types, and
763
- runtime behaviour are byte-identical.
137
+ # Stability tiers (`STABILITY.md`)
764
138
 
765
- ## [0.2.1] — 2026-05-28
139
+ # Stability
766
140
 
767
- ### Security
141
+ ## Stable Surface
768
142
 
769
- - **Resolve two Dependabot moderate advisories** on the transitive dev-only graph by upgrading `vitest` 2.1.0 → 4.1.7 and `@vitest/coverage-v8` 2.1.9 → 4.1.7. Adds `vite` 8.0.14 as a direct devDependency to satisfy vitest 4's peer range (`^6 || ^7 || ^8`). These are dev-only — runtime surface unchanged. Same fix as `aibridgejs` 0.1.2.
770
- - [GHSA-67mh-4wv8-2f99](https://github.com/advisories/GHSA-67mh-4wv8-2f99) `esbuild <=0.24.2` CORS development server data leak (fixed in 0.25.0).
771
- - [GHSA-4w7w-66w2-5vf9](https://github.com/advisories/GHSA-4w7w-66w2-5vf9) `vite <=6.4.1` path traversal in optimized deps `.map` handling (fixed in 6.4.2 / 7.3.2 / 8.0.5).
143
+ | Surface | Status | Notes |
144
+ | --- | --- | --- |
145
+ | `aifsmjs` root | Stable | Definition/runtime/step/snapshot APIs and core errors. |
146
+ | `aifsmjs/guards` | Stable | Sync guard combinators. |
147
+ | `aifsmjs/effects` | Stable | Effect descriptors and dispatcher helper. |
148
+ | `aifsmjs/inspect` | Stable | Read-only middleware helpers. |
149
+ | `aifsmjs/replay` | Stable | Pure log replay. |
150
+ | `aifsmjs/pbt` | Stable | fast-check helpers. |
151
+ | `aifsmjs/timer` | Stable | Timer/scheduler helpers. |
772
152
 
773
- ### Changed
153
+ ## Behavioral Contract
774
154
 
775
- - **Coverage threshold relaxed**: statements 100 → 95 in [vitest.config.ts](vitest.config.ts). Vitest 4 with v8 coverage scores defensive race-recovery if-guards (e.g. `if (!current) return;` in timeout/abort handlers) as separate statements that are not deterministically reachable. Lines and functions stay at 100%; branches stays at 90%.
776
- - **`prepublishOnly` now includes `verify:llms`** so llms-full.txt drift is caught at publish time as well as CI.
777
- - **README opening unified across the ai*js family**: five-badge shields row, one-line tagline as blockquote, ecosystem footer.
155
+ - Definition data is serializable when using string refs instead of inline functions.
156
+ - `step()` is pure and never dispatches effects.
157
+ - Runtime commit happens before middleware, effect dispatch, and listener notification.
158
+ - Async effects are fire-and-forget; rejections emit runtime `"error"`.
159
+ - `reset()` does not run entry actions.
160
+ - `dispose()` aborts runtime signal, clears listeners, and is idempotent. A throwing `'dispose'` listener is swallowed and never aborts teardown.
778
161
 
779
- Runtime surface unchanged. Production bundles are byte-identical to 0.2.0.
162
+ ## Replay caveat
780
163
 
781
- ## [0.2.0] — 2026-05-28
164
+ `replay()` and `step()` reproduce only the **parent** machine's `value` + `context` (`aifsmjs/replay`, "Pure event-log replay" in the README). Sub-machine state is **not** modelled: the pure lifecycle has no `sub` references, so a replayed/stepped snapshot reflects the parent state alone and never re-instantiates, advances, or restores any child runtime. To capture child state for time-travel or incident reproduction, snapshot the child separately from the live runtime via `subRuntime()`.
782
165
 
783
- ### Added
166
+ ## Snapshot freezing depth
784
167
 
785
- - **Async-guard detection**: `evalGuard` and `defineMachine`'s validation pass now throw on async guards. TypeScript already prevents the typed case, but a JS caller or a cast could slip an async guard through and silently pass every check (a thenable is truthy). The new check fails loudly:
786
- - **Definition time** (inline `async` guard) → `InvalidDefinitionError` from `defineMachine`'s `validateDefinition`.
787
- - **Runtime** (string-ref or cast guard whose return value is thenable) → `AsyncGuardError` from `evalGuard`. The thenable check uses `typeof x?.then === "function"`, so cross-realm Promises (iframe / worker / vm) and user-defined PromiseLike values are also caught — not just same-realm `instanceof Promise`.
788
- - New exports from `aifsmjs`: `AsyncGuardError`, `isAsyncGuardFn`.
789
- - README's "Capabilities / Limitations" table updated to reflect the new runtime guarantee.
168
+ Snapshot freezing is depth-dependent on `NODE_ENV`:
790
169
 
791
- ### Fixed (correctness)
170
+ - **Dev** (`NODE_ENV !== "production"`): the whole snapshot tree is deep-frozen, so accidental nested mutation throws immediately.
171
+ - **Production** (`NODE_ENV === "production"`): only the **top-level** snapshot object is frozen (`Object.freeze`). Nested `context` is **caller-owned and not deeply frozen** — treat it as read-only by convention; the library does not enforce immutability of nested context in prod.
792
172
 
793
- - **`send()` transition payload AND `notify()` listeners are captured pre-reentry** ([src/fsm/runtime.ts](src/fsm/runtime.ts)): the `next` field of the emitted `'transition'` event, the effect dispatch context, and the snapshot delivered to `subscribe()` listeners are all now read from a captured local `committed` snapshot rather than the outer mutable `snapshot` variable. Closes a race where a reentrant `send()` inside an effect handler or subscriber would race ahead and the outer payload / later subscribers in the same notify pass would end up pointing at the reentry's snapshot.
794
- - **`evalGuard` falls back to `<inline>` for anonymous-arrow guards** ([src/fsm/evaluator.ts](src/fsm/evaluator.ts)): switched `??` to `||` so an empty `Function.prototype.name` falls back instead of producing `guard "" must be sync;`.
795
- - **README + README_ZHTW + llms-full.txt** now describe the two error paths separately (`InvalidDefinitionError` at definition time vs `AsyncGuardError` at runtime) instead of conflating them.
796
-
797
- ### Changed (positioning + meta)
798
-
799
- - **`package.json#description`** rewritten from «for web game development» to lead with the broader use case set (multi-step forms, checkout funnels, auth flows, tutorials, scene flow). The README's "Primary audience" paragraph already moved away from game-only framing in v0.2.0; the package metadata now matches.
800
-
801
- ### Build & tooling
802
-
803
- - **`verify:llms` is now build-agnostic** ([scripts/build-llms-full.mjs](scripts/build-llms-full.mjs)): the script accepts `--check` which builds the file in memory and compares against disk, exit 1 on diff. The previous form used `git diff --exit-code -- llms-full.txt` after running the build, which failed any time the working tree had uncommitted changes (not just llms-full.txt drift). The new form works identically pre-commit and in CI.
804
- - **Per-subpath gzip budgets raised** ([scripts/check-size.mjs](scripts/check-size.mjs)): core 3500 → 3700 B and replay 1600 → 1800 B to absorb the AsyncGuardError + thenable detection cost; pbt 4500 → 4600 B for a small symbol additions. All entries still tracked at ≥95% headroom.
805
-
806
- ### Added (examples)
807
-
808
- - `examples/03-checkout-funnel` — e-commerce checkout funnel with guarded staging, payment / analytics effects, and a `replay()` round-trip. Demonstrates that aifsmjs models classic web UX flows with no canvas / game loop involvement.
809
- - `examples/04-form-wizard` — multi-step form wizard with back / next / jump-to-step navigation, per-step validation, and draft persistence via the `persist` middleware.
810
-
811
- ### Changed (positioning)
812
-
813
- - `README.md` and `README_ZHTW.md`'s "Primary audience" paragraph now leads with stateful web flows (multi-step forms, checkout funnels, auth flows, tutorials, document workflows) and frames games as one application of the same pattern. The core remains environment-neutral; the only opt-in dependency is `fast-check` for the `aifsmjs/pbt` PBT adapter.
814
-
815
- ### Compatibility
816
-
817
- This release is **non-breaking at runtime** for users who already wrote sync guards. Async guards previously slipped through and silently passed; they now throw. If you relied on this accidental behaviour, move the async work into an effect (`enq.effect(...)`) and dispatch a follow-up event when the work completes — the pattern is documented in the README's "Common pitfalls" table.
818
-
819
- ## [0.1.2] — 2026-05-28
820
-
821
- ### Fixed
822
-
823
- - **Memory**: `runtime.on(type, fn, { signal })` previously left an
824
- abort listener attached to the external `AbortSignal` after
825
- `runtime.dispose()`. The listener (and its closure over the user's
826
- callback) was retained until the signal eventually fired or was
827
- garbage-collected. `dispose()` now removes each abort listener from
828
- the signal it was attached to, and the unsubscribe function returned
829
- by `on()` does the same on manual unsubscribe.
830
-
831
- ### Internal
832
-
833
- - Narrowed `dispatchEffects` event parameter from `Evt | ResetEvent`
834
- to `Evt`; the function is only reached via `send()`. Removed the
835
- corresponding `as Evt` cast.
836
- - Removed a redundant `snapshot.context as Ctx` cast in `step()`.
837
-
838
- No public API changes; existing 0.1.1 callers run unchanged. Core gzip
839
- 3296 B → 3401 B (97% of 3500 B budget).
840
-
841
- ## [0.1.1] — 2026-05-28
842
-
843
- ### Changed
844
-
845
- - **Release pipeline**: switched to npm OIDC trusted publisher. Releases
846
- now ship with provenance attestation generated from the GitHub Action
847
- via `id-token: write` + `--provenance`; no long-lived `NPM_TOKEN`
848
- needed. The `Publish to npm` workflow is unchanged from v0.1.0; see
849
- CONTRIBUTING for the `pnpm version patch && git push --follow-tags`
850
- flow.
851
-
852
- No code changes vs v0.1.0; runtime behaviour, API surface, and bundle
853
- sizes are identical (core gzip 3.30 KB / 3.5 KB budget).
854
-
855
- ## [0.1.0] — 2026-05-28
856
-
857
- Initial public release.
858
-
859
- ### Added
860
-
861
- - **fsm/** (source folder, internal) — `defineMachine`, `setup<Ctx, Evt>()`
862
- curried builder for inferred States, `createRuntime`, `step()` pure
863
- function with fixed `guards → exit → action → entry` lifecycle order.
864
- Runtime exposes `dispose()`, `reset(event?)`, `disposed`, `signal`
865
- (internal `AbortController` lifetime) — see the Lifecycle Protocol section
866
- of README. `RuntimeDisposedError` thrown on post-dispose calls. Snapshot
867
- is frozen (deep-frozen in dev). Implementations injected at runtime via
868
- string refs.
869
- - **`aifsmjs/guards`** — `and / or / not / stateIn` higher-order combinators
870
- with short-circuit evaluation. Both string-ref and inline `Guard` supported.
871
- - **`aifsmjs/effects`** — `Enqueuer` API (`enqueue.effect(type, payload?)`)
872
- and a standalone `runEffects()` dispatcher. Effects are descriptors, not
873
- callbacks, so they remain serializable. `EffectHandler` receives the
874
- runtime's `AbortSignal` in `args.signal`. `runEffects()` accepts
875
- `args.signal` as optional — standalone callers may omit it and the
876
- dispatcher supplies a never-aborting placeholder.
877
- - **`Runtime.reset()`** — listeners notified only when `prev.value !==
878
- initial.value` (parity with `send()`); middleware always observes the
879
- call regardless. The triggering event is exposed on
880
- `MiddlewareContext.event` as `Evt | ResetEvent`. The sentinel
881
- `RESET_EVENT_TYPE` (`"@@aifsmjs/RESET"`) is exported for discrimination.
882
- - **`aifsmjs/inspect`** — Koa-style read-only middleware pipeline. Built-in
883
- `logger`, `persist`, and `recorder` middlewares. Middleware cannot alter a
884
- transition outcome.
885
- - **`aifsmjs/replay`** — Pure event-log fold via `step()`. Never dispatches
886
- effects; suitable for PBT, time travel, and incident reproduction.
887
- - **`aifsmjs/pbt`** — `fast-check` `fc.commands` adapter
888
- (`commandsFromMachine`) plus six generic property tests
889
- (`snapshotAlwaysFrozen`, `unknownEventNoOp`, `reachableStatesSubsetDeclared`,
890
- `replayEqualsFold`, `guardsFalseNoTransition`, `assignDoesNotMutate`) and an
891
- `assertAll` convenience runner. `fast-check` listed as optional peer.
892
- - **`aifsmjs/timer`** — `after(ms, fn, { signal })` returning a cancellable
893
- handle, plus `createScheduler()` for bundled cancellation. `AbortSignal`
894
- listeners registered with `{ once: true }` to avoid leaks.
895
- - TypeScript build with `strict + noUncheckedIndexedAccess +
896
- exactOptionalPropertyTypes`; dual ESM/CJS output via tsup.
897
- - Bilingual README (Traditional Chinese canonical + English mirror) with an
898
- AI-Agent Reading Guide section, Lifecycle Invariants contract, and
899
- comparison table against XState v5 / Robot3 / @xstate/store / Zag.js.
900
- - 94 example-based tests (vitest) plus PBT smoke runs against a traffic-light
901
- fixture.
902
-
903
- ### API additions for ai*js ecosystem alignment
904
-
905
- - **`createMachine(def, impl, opts?)`** — single-factory convenience that
906
- composes `defineMachine` + `createRuntime`. Spec-style entry point from
907
- the ai*js micro-runtime review; the curried `setup().defineMachine` form
908
- remains for States inference, and explicit `defineMachine<Ctx,Evt,States>`
909
- remains as an escape hatch.
910
- - **`runtime.snapshot()`** — alias for `runtime.getSnapshot()`; documented
911
- as the preferred name going forward.
912
- - **`runtime.can(event)`** — predicate that returns `true` iff sending the
913
- event would fire a transition. Reuses `evalGuard`; guards must be pure
914
- for `can` and `send` to agree.
915
- - **`runtime.on(type, listener, { signal?, once? })`** — `EventTarget`-style
916
- typed event API. Channels: `'transition'` (after a state-changing `send`
917
- or `reset`), `'error'` (async effect handler rejections), `'dispose'`
918
- (fires once on teardown). `subscribe(listener)` is unchanged and still
919
- preferred for `useSyncExternalStore`.
920
-
921
- Core gzip grew from 2.87 KB to ~3.3 KB; the size budget script raised the
922
- core cap to 3.5 KB with rationale in `scripts/check-size.mjs`.
923
-
924
- ### Documentation restructure
925
-
926
- - `README.md` is now the canonical English README; the Traditional Chinese
927
- mirror moved to `README_ZHTW.md`.
928
- - Added `llms.txt` and `llms-full.txt` following the [llmstxt.org](https://llmstxt.org/)
929
- convention so LLM agents can ground in the project surface with one fetch.
930
- `llms-full.txt` is generated by `scripts/build-llms-full.mjs`; `pnpm
931
- verify:llms` re-runs the generator and diffs to catch drift.
932
- - New "Design choices" section in both READMEs explains why send is sync,
933
- guards are sync, effects are descriptors, why two factory forms exist,
934
- and why `on` and `subscribe` both ship.
935
-
936
- ### CI guarantees
937
-
938
- - **Coverage threshold**: 100% statements / 100% lines / 100% functions /
939
- ≥90% branches, enforced via `@vitest/coverage-v8` thresholds (actual on
940
- v0.1.0 release: 100/100/100/98.81). Defensive invariant-guard branches
941
- carry `/* v8 ignore */` annotations with rationale comments.
942
- - **Per-subpath gzip size budget** (verified by `scripts/check-size.mjs`):
943
- core ≤3 KB · replay ≤1.6 KB · pbt ≤4.5 KB · guards / effects / inspect /
944
- timer ≤1 KB each. Tarball measured at ~98 KB / 48 files.
945
-
946
- ### Out of scope (v1)
947
-
948
- Hierarchical / compound states, parallel state regions, actor invocation
949
- (async), tick/game-loop hook, ECS / Pixi bridges. See Roadmap in README.
173
+ ## Sub-machines
950
174
 
951
- ---
175
+ Sub-machines are stable but sharp:
952
176
 
953
- # Stability tiers (`STABILITY.md`)
177
+ - Entry lazily creates the child; exit disposes it.
178
+ - Init failure rolls back parent transition.
179
+ - Dispose failure happens after the old child is already torn down and surfaces as `SubMachineError`.
180
+ - External child disposal leaves a stale handle until the parent leaves/re-enters the state.
954
181
 
955
- # Stability
182
+ ## Drafts
956
183
 
957
- This document defines the stability tier of every public symbol exported by
958
- `aifsmjs`. Tiers govern what breaks may occur in future minor / major bumps.
959
-
960
- ## Stable (since 0.1.0)
961
-
962
- Fully stable. Breaking changes only at a major version bump (1.0+).
963
-
964
- - `createMachine`, `defineMachine`, `setup`, `createRuntime`, `initialSnapshot`
965
- - `step`, `resolveTransitions`, `evalGuard`, `resolveGuard`, `isAsyncGuardFn`
966
- - `assign`, `mergeContext`, `createSnapshot`, `deepFreeze`, `freezeSnapshot`
967
- - `Runtime.send`, `Runtime.reset`, `Runtime.can`, `Runtime.getSnapshot`,
968
- `Runtime.snapshot`, `Runtime.subscribe`, `Runtime.on`, `Runtime.dispose`,
969
- `Runtime.signal`, `Runtime.disposed`
970
- - All error classes from 0.1.0–0.2.1: `RuntimeDisposedError`,
971
- `InvalidDefinitionError`, `UnknownActionError`, `UnknownGuardError`,
972
- `AsyncGuardError`
973
- - Types: `MachineDef`, `StateDef` (fields `on`, `entry`, `exit`, `final`),
974
- `TransitionDef`, `Snapshot`, `Implementations`, `Guard`, `Action`,
975
- `EffectHandler`, `Effect`, `Enqueuer`, `Middleware`, `MiddlewareContext`,
976
- `RuntimeOptions`, `StepResult`, `ResetEvent`, `RESET_EVENT_TYPE`,
977
- `RuntimeTransitionEvent`, `RuntimeErrorEvent`, `RuntimeEventMap`
978
- - All subpath exports: `aifsmjs/guards`, `aifsmjs/effects`, `aifsmjs/inspect`,
979
- `aifsmjs/replay`, `aifsmjs/pbt`, `aifsmjs/timer`
980
- - `Runtime.onTransition` (added in 0.3.0) — pure sugar over the stable
981
- `on('transition', ...)` API; listed under Stable because the underlying
982
- contract is unchanged.
983
-
984
- ### Sub-machines (stable since 0.4.0)
985
-
986
- The hierarchical sub-machine surface shipped experimentally in 0.3.0 is
987
- stable as of 0.4.0. Signatures and runtime semantics — including the
988
- init-failure quarantine behaviour described below — are frozen for the 1.x
989
- line. The boundaries listed are intentional design trade-offs, not bugs or
990
- pending instability.
991
-
992
- - `StateDef.sub` (optional `SubMachineDef`) — when present, a child runtime
993
- is lazily initialised on entry and disposed on exit. Per-transition
994
- ordering is parent `step()` (exit / actions / entry) → child dispose →
995
- child init → snapshot commit.
996
- - `StateDef.subImpl` (optional `Implementations`) — paired with `sub`;
997
- passed to the child `createRuntime`. Defaults to `{}`.
998
- - `Runtime.subRuntime()` — returns the live child handle, or `undefined`.
999
- Returned generic is `Runtime<unknown, { type: string }, string>`; caller
1000
- narrows via cast if necessary.
1001
- - `SubMachineError` — thrown by `send()` / `reset()` on child init/dispose
1002
- failure. Fields: `parentState`, `phase ("init" | "dispose")`, `cause`.
1003
- - `SubMachineDef` type alias.
1004
-
1005
- #### Design boundaries
1006
-
1007
- - **Replay / PBT do not see child state.** `replay()` and
1008
- `commandsFromMachine` only inspect parent snapshots. If your business
1009
- logic lives in the parent layer, replay is still deterministic.
1010
- - **`subRuntime()` may return a disposed handle** if an external caller
1011
- disposed it. The handle is not reinitialised until the parent leaves and
1012
- re-enters the sub-bearing state. Detect with `child.disposed`.
1013
- - **Self-targeting external (`A → A`) is treated as full exit/entry**:
1014
- child is disposed and reinitialised. The dispatcher re-resolves guards to
1015
- identify the chosen transition before deciding external vs internal, so
1016
- guarded internal transitions on the same event do not trigger a reinit.
1017
- - **Init-failure mid-transition leaves the parent without a live child.**
1018
- If `applySubLifecycle` successfully disposes the old child and then the
1019
- new child's `createRuntime` throws, the parent's snapshot is rolled back
1020
- to `prev` but `subRuntime()` returns `undefined`. Callers catching
1021
- `SubMachineError(phase: "init")` should treat the runtime as quarantined
1022
- — call `runtime.dispose()` (idempotent) or `runtime.reset()` (which
1023
- attempts re-init) before sending further events. This quarantine
1024
- behaviour is part of the stable contract; a future **major** version may
1025
- switch to a two-phase "init before dispose" commit strategy (a breaking
1026
- change reserved for 1.0+), but the current semantics are frozen for the
1027
- 1.x line.
1028
-
1029
- ### Middleware, effects & notification ordering (documented boundaries)
1030
-
1031
- These are intentional ordering boundaries of the read-only middleware /
1032
- effect pipeline, not bugs. They are stable for the 1.x line.
1033
-
1034
- - **A synchronous throw from a middleware or effect handler leaves the
1035
- snapshot committed but unannounced.** Inside `send()` / `reset()` the new
1036
- snapshot is committed (so `getSnapshot()` already reflects it) *before*
1037
- `runMiddleware()` and effect dispatch run. If a middleware or effect
1038
- handler throws synchronously, the throw propagates to the `send()` /
1039
- `reset()` caller (as documented), but `notify()`, `on('transition')`
1040
- listeners, and — for a middleware throw — the collected effects are
1041
- skipped. Observers therefore desynchronise from `getSnapshot()`. The
1042
- shipped `persist` middleware throws on non-serialisable context, making
1043
- this reachable without exotic code. Wrap throwing middleware/effects in
1044
- your own `try/catch` if you need observers to fire regardless. (Contrast:
1045
- the sub-machine init/dispose failure path *rolls back* instead — see
1046
- above.) A future **major** may move the commit after the pipeline; the
1047
- current order is frozen for 1.x.
1048
- - **`next()` must be called by every middleware; skipping it is not
1049
- enforced.** Calling `next()` twice throws; calling it zero times is
1050
- silently tolerated and drops every later middleware (and the recorder /
1051
- persist sinks) for that event — no throw, no warning. The state transition
1052
- itself is unaffected (the snapshot is committed before the pipeline). Treat
1053
- `next()` as mandatory.
1054
- - **Re-entrant `send()` from inside middleware reorders recorder logs.**
1055
- Middleware is documented read-only; a middleware that re-entrantly calls
1056
- `runtime.send()` runs the inner event's full pipeline (including the
1057
- recorder push) before the outer frame reaches the recorder. The
1058
- `recorder` sink — intended to feed `replay()` — then lists `[inner,
1059
- outer]` for an application order of `[outer, inner]`, so replaying that log
1060
- diverges. Do not `send()` from within the middleware pipeline.
1061
-
1062
- ## Experimental
1063
-
1064
- No experimental APIs as of 0.4.0. The 0.3.0 sub-machine surface graduated to
1065
- Stable in 0.4.0 — see "Sub-machines" above.
1066
-
1067
- ## Draft (planned, not implemented)
1068
-
1069
- API sketched, not shipped. May change before release.
1070
-
1071
- - `historyState` (candidate for a future minor) — opt-in pseudo-state that
1072
- remembers the last active sub-state on re-entry. Workaround in 0.3.0:
1073
- snapshot the sub-runtime's value on exit via `onTransition`, restore
1074
- manually.
184
+ Parallel regions, actor spawning, async guards, and awaited effect completion are not implemented.
1075
185
 
1076
186
  ---
1077
187
 
@@ -1079,103 +189,35 @@ API sketched, not shipped. May change before release.
1079
189
 
1080
190
  # Contributing to aifsmjs
1081
191
 
1082
- Thanks for taking the time to look. aifsmjs is a deliberately small library;
1083
- contributions that keep the surface narrow are easier to accept than ones
1084
- that expand it.
192
+ Keep the deterministic core small and make lifecycle changes test-heavy.
1085
193
 
1086
- ## Quick start
194
+ ## Local workflow
1087
195
 
1088
196
  ```bash
1089
197
  pnpm install
1090
- pnpm test # vitest, ~117 example tests + PBT smoke runs
1091
- pnpm coverage # vitest with 100/100/100/90 thresholds (CI-enforced)
1092
- pnpm typecheck # tsc --noEmit on strict mode
1093
- pnpm lint # biome check
1094
- pnpm build # tsup; dual ESM/CJS + .d.ts
1095
- pnpm verify:exports # ensures package.json#exports matches dist/
1096
- pnpm check:size # gzip per subpath against the size budget
1097
- ```
1098
-
1099
- The full pre-publish gate is `pnpm prepublishOnly`, which runs typecheck,
1100
- lint, coverage (with thresholds), build, exports verification, and size
1101
- budget check — in that order.
1102
-
1103
- ## What gets in easily
1104
-
1105
- - Bug fixes with a failing test added first
1106
- - README / typing corrections
1107
- - Tests that lock down existing behaviour
1108
- - New `aifsmjs/<subpath>` opt-in modules that follow the same shape as
1109
- `guards`, `effects`, `inspect`, `replay`, `pbt`, `timer`: independent,
1110
- named exports only, no side effects, single responsibility
1111
-
1112
- ## What needs discussion first
1113
-
1114
- - Anything that changes the `step()` signature or lifecycle order
1115
- - New required fields on `MachineDef` or `Snapshot`
1116
- - A change that would push the core gzip past ~3KB
1117
- - Hierarchical / parallel / actor features (v0.2+ — open an issue with the
1118
- use case)
1119
-
1120
- ## Design principles
1121
-
1122
- aifsmjs follows a library-core priority order:
1123
-
1124
- > Security > Correctness > Simplicity > YAGNI > Performance
1125
-
1126
- In particular, `step()` must remain a pure function: identical
1127
- `(def, snapshot, event, impl)` always returns identical
1128
- `{ snapshot, effects, changed }`. Any change that breaks this invariant will
1129
- be rejected.
1130
-
1131
- ## Commit & PR style
1132
-
1133
- - Commit messages: imperative subject under 70 chars; body explains *why*.
1134
- - PRs: keep scope to one topic. Link the issue if any.
1135
- - Tests required for any behaviour change. PBT preferred for invariants;
1136
- example tests preferred for behaviour you want documented.
1137
-
1138
- ## Reporting issues
1139
-
1140
- - Minimal reproduction welcome (paste the smallest `defineMachine + step`
1141
- pair that shows the bug).
1142
- - For security issues, please email the maintainer rather than filing
1143
- publicly.
1144
-
1145
- ## Release flow
1146
-
1147
- Releases are automated via the **Publish to npm** GitHub Action
1148
- ([`.github/workflows/publish.yml`](.github/workflows/publish.yml)). The
1149
- required `NPM_TOKEN` secret is already configured at the repo level. From
1150
- a clean tree on `main`:
1151
-
1152
- ```bash
1153
- # 1. Bump version + create commit + create tag (single command)
1154
- pnpm version patch # or `minor` / `major`
1155
-
1156
- # 2. Push the commit AND the tag in one go
1157
- git push --follow-tags
198
+ pnpm typecheck
199
+ pnpm test
200
+ pnpm verify:docs
201
+ pnpm build:llms
202
+ pnpm verify:llms
203
+ pnpm verify:exports
204
+ pnpm verify:dist
205
+ pnpm check:size
1158
206
  ```
1159
207
 
1160
- The workflow triggers on `v*` tag push and runs the full gate before
1161
- publishing:
1162
-
1163
- 1. verify tag matches `package.json#version`
1164
- 2. typecheck / lint / coverage (with 100/100/100/90 thresholds)
1165
- 3. build / verify exports / check bundle sizes / verify llms-full.txt
1166
- 4. `pnpm publish --provenance --access public` (npm supply-chain
1167
- attestation is generated automatically)
208
+ Run `pnpm lint` before PRs. If docs change, regenerate `llms-full.txt`.
1168
209
 
1169
- A failed gate stops the publish; the tag stays on the repo but nothing
1170
- ships. If you need to test the gate without publishing, trigger the
1171
- workflow manually via `workflow_dispatch` with `dry-run: true`.
210
+ ## Rules
1172
211
 
1173
- For prereleases (e.g. `0.2.0-rc.1`), tag manually with `git tag v0.2.0-rc.1 && git push --tags`. npm will mark the version with the `rc` dist-tag once published — adjust the workflow if you need a different tag strategy.
212
+ - Preserve `step()` purity and replay determinism.
213
+ - Keep guards synchronous; route I/O through effects and events.
214
+ - Add tests for runtime commit ordering, sub-machine lifecycle, reset, dispose, and scheduler cancellation.
215
+ - Keep subpath imports tree-shakeable.
216
+ - Discuss public type inference changes before implementation.
1174
217
 
1175
218
  ## License
1176
219
 
1177
- By contributing, you agree your changes will be licensed under the MIT
1178
- license that covers this project.
220
+ MIT
1179
221
 
1180
222
  ---
1181
223