jssm 5.143.35 → 5.144.1

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/README.md CHANGED
@@ -18,10 +18,10 @@ Please edit the file it's derived from, instead: `./src/md/readme_base.md`
18
18
 
19
19
 
20
20
 
21
- * Generated for version 5.143.35 at 6/17/2026, 9:46:56 PM
21
+ * Generated for version 5.144.0 at 6/21/2026, 3:13:56 PM
22
22
 
23
23
  -->
24
- # jssm 5.143.35
24
+ # jssm 5.144.0
25
25
 
26
26
  [**Try the live editor**](https://stonecypher.github.io/jssm-viz-demo/graph_explorer.html) ·
27
27
  [Documentation](https://stonecypher.github.io/jssm/docs/) ·
@@ -312,7 +312,7 @@ That decision shows up everywhere downstream:
312
312
  or run `npm run benny` against your own machine.
313
313
 
314
314
  - **More thoroughly tested than any other JavaScript state-machine
315
- library.** 7,232 tests at 100.0% line coverage
315
+ library.** 7,305 tests at 100.0% line coverage
316
316
  ([report](https://coveralls.io/github/StoneCypher/jssm)), plus
317
317
  fuzz testing via `fast-check`, with parser test data across ten natural
318
318
  languages and Emoji.
@@ -445,11 +445,11 @@ If your contribution is missing here, please open an issue.
445
445
 
446
446
  <br/>
447
447
 
448
- ***7,232 tests***, run 82,274 times.
448
+ ***7,305 tests***, run 82,347 times.
449
449
 
450
- - 6,474 specs with 100.0% coverage
451
- - 758 fuzz tests with 76.8% coverage
452
- - 6,605 TypeScript lines - 1.1 tests per line, 12.5 generated tests per line
450
+ - 6,547 specs with 100.0% coverage
451
+ - 758 fuzz tests with 74.4% coverage
452
+ - 6,801 TypeScript lines - 1.1 tests per line, 12.1 generated tests per line
453
453
 
454
454
  [![Actions Status](https://github.com/StoneCypher/jssm/workflows/Node%20CI/badge.svg)](https://github.com/StoneCypher/jssm/actions)
455
455
  [![NPM version](https://img.shields.io/npm/v/jssm.svg)](https://www.npmjs.com/package/jssm)
@@ -22922,6 +22922,9 @@ const base_active_state_style$5 = {
22922
22922
  textColor: 'white',
22923
22923
  backgroundColor: 'dodgerblue4'
22924
22924
  };
22925
+ const base_hooked_state_style$5 = {
22926
+ shape: 'component'
22927
+ };
22925
22928
  const base_terminal_state_style$5 = {
22926
22929
  textColor: 'white',
22927
22930
  backgroundColor: 'crimson'
@@ -22938,6 +22941,7 @@ const base_theme = {
22938
22941
  start: base_start_state_style$5,
22939
22942
  end: base_end_state_style$5,
22940
22943
  terminal: base_terminal_state_style$5,
22944
+ hooked: base_hooked_state_style$5,
22941
22945
  active: base_active_state_style$5};
22942
22946
 
22943
22947
  const base_state_style$4 = {
@@ -23370,6 +23374,30 @@ function pair_key(a, b) {
23370
23374
  ? (a * a) + a + b
23371
23375
  : (b * b) + a;
23372
23376
  }
23377
+ /**
23378
+ * Inverse of {@link pair_key}: recovers the ordered pair `(a, b)` that was
23379
+ * packed into a Szudzik key. Exact for any key produced by `pair_key` over
23380
+ * non-negative integer inputs, so `un_pair_key(pair_key(a, b))` round-trips
23381
+ * to `[a, b]`. Used to walk interned, pair-keyed maps (e.g. the hook tables)
23382
+ * back to their original `(from_id, to_id)` ids for {@link Interner.name_of}.
23383
+ *
23384
+ * Behavior is only defined for keys `pair_key` actually emits; a NaN key (the
23385
+ * unknown-name sentinel) yields `[NaN, NaN]`, never a spurious real pair.
23386
+ *
23387
+ * @param z - A key produced by `pair_key`.
23388
+ * @returns The ordered pair `[a, b]` such that `pair_key(a, b) === z`.
23389
+ *
23390
+ * @example
23391
+ * un_pair_key(27); // [2, 5]
23392
+ * un_pair_key(32); // [5, 2] — order preserved
23393
+ *
23394
+ * @see pair_key
23395
+ */
23396
+ function un_pair_key(z) {
23397
+ const s = Math.floor(Math.sqrt(z));
23398
+ const l = z - (s * s);
23399
+ return (l < s) ? [l, s] : [s, l - s];
23400
+ }
23373
23401
 
23374
23402
  /*******
23375
23403
  *
@@ -23486,7 +23514,7 @@ var constants = /*#__PURE__*/Object.freeze({
23486
23514
  * Useful for runtime diagnostics and for embedding in serialized machine
23487
23515
  * snapshots so that deserializers can detect version-skew.
23488
23516
  */
23489
- const version = "5.143.35";
23517
+ const version = "5.144.1";
23490
23518
 
23491
23519
  // whargarbl lots of these return arrays could/should be sets
23492
23520
  const { state_name_chars, state_name_first_chars, action_label_chars } = constants;
@@ -25889,6 +25917,12 @@ class Machine {
25889
25917
  default:
25890
25918
  throw new JssmError(this, `Unknown hook type ${HookDesc.kind}, should be impossible`);
25891
25919
  }
25920
+ // The hooked-state styling layer (tier 2.5 of resolve_state_config) depends
25921
+ // on which states carry hooks, so registering a hook can change the composed
25922
+ // style of a state. The static config cache assumes tiers 1–5 are fixed
25923
+ // after construction; invalidate it so styling stays correct when a hook is
25924
+ // added after a style has already been computed and memoized.
25925
+ this._static_state_config_cache.clear();
25892
25926
  // fire the registration event for inspector tools (#638)
25893
25927
  this._fire('hook-registration', { description: HookDesc });
25894
25928
  }
@@ -26061,6 +26095,9 @@ class Machine {
26061
26095
  throw new JssmError(this, `Unknown hook type ${HookDesc.kind}, should be impossible`);
26062
26096
  }
26063
26097
  if (removed) {
26098
+ // See set_hook: the hooked-state styling layer depends on which states
26099
+ // carry hooks, so removing one can change a state's composed style.
26100
+ this._static_state_config_cache.clear();
26064
26101
  this._fire('hook-removal', { description: HookDesc });
26065
26102
  }
26066
26103
  return removed;
@@ -27387,12 +27424,319 @@ class Machine {
27387
27424
  get active_state_style() {
27388
27425
  return this._active_state_style;
27389
27426
  }
27390
- /*
27427
+ /********
27428
+ *
27429
+ * Generate the uniform observational-hook registry — every currently
27430
+ * registered hook projected onto a normalized `(kind, target, phase)` row
27431
+ * (megaspec §12, → #1357). The registry is *generated* on demand by
27432
+ * walking the concrete per-kind storage tables rather than maintained as a
27433
+ * second copy, so it can never drift from the tables {@link Machine.set_hook}
27434
+ * actually dispatches into. It is the single source of truth behind the
27435
+ * introspection accessors ({@link Machine.has_hook}, {@link Machine.hooks_on})
27436
+ * and the `hooked_state` viz styling.
27437
+ *
27438
+ * Targets are normalized: edge hooks become `{ scope: 'edge', from, to }`
27439
+ * (named hooks add `action`), entry/exit/after become `{ scope: 'state' }`,
27440
+ * global-action hooks become `{ scope: 'action' }`, and the `any-*`,
27441
+ * transition-class, and `everything` observers become `{ scope: 'global' }`.
27442
+ *
27443
+ * ```typescript
27444
+ * const m = sm`a 'go' -> b;`;
27445
+ * m.hook_entry('b', () => true);
27446
+ * m.hook_registry();
27447
+ * // => [ { kind: 'entry', phase: 'pre', target: { scope: 'state', state: 'b' } } ]
27448
+ * ```
27449
+ *
27450
+ * @returns Every registered hook as a {@link HookRegistryEntry}, in a stable
27451
+ * table-walk order (pre-phase tables first, then post-phase).
27452
+ *
27391
27453
  */
27392
- // TODO COMEBACK IMPLEMENTME FIXME
27393
- // has_hooks(state: StateType): false {
27394
- // return false;
27395
- // }
27454
+ hook_registry() {
27455
+ const entries = [];
27456
+ // The hot-path hook tables are keyed by interned integer ids (states and
27457
+ // actions) and, for edges, by `pair_key(from_id, to_id)`. Decode each key
27458
+ // back to its original name so the registry speaks states/actions, never
27459
+ // ids. The lone exception is `_after_hooks`, deliberately string-keyed.
27460
+ const state_name = (id) => this._state_interner.name_of(id);
27461
+ const action_name = (id) => this._action_interner.name_of(id);
27462
+ // edge tables: pair_key(from_id, to_id) -> handler
27463
+ const push_edges = (table, kind, phase) => {
27464
+ table.forEach((_handler, pk) => {
27465
+ const [fid, tid] = un_pair_key(pk);
27466
+ entries.push({ kind, phase, target: { scope: 'edge', from: state_name(fid), to: state_name(tid) } });
27467
+ });
27468
+ };
27469
+ // named-edge tables: pair_key(from_id, to_id) -> action_id -> handler
27470
+ const push_named = (table, kind, phase) => {
27471
+ table.forEach((byAction, pk) => {
27472
+ const [fid, tid] = un_pair_key(pk);
27473
+ const from = state_name(fid), to = state_name(tid);
27474
+ byAction.forEach((_handler, aid) => {
27475
+ entries.push({ kind, phase, target: { scope: 'edge', from, to, action: action_name(aid) } });
27476
+ });
27477
+ });
27478
+ };
27479
+ // entry/exit tables: interned state_id -> handler
27480
+ const push_states = (table, kind, phase) => {
27481
+ table.forEach((_handler, sid) => {
27482
+ entries.push({ kind, phase, target: { scope: 'state', state: state_name(sid) } });
27483
+ });
27484
+ };
27485
+ // the `after` table is the lone string-keyed exception: state name -> handler
27486
+ const push_states_by_name = (table, kind, phase) => {
27487
+ table.forEach((_handler, state) => {
27488
+ entries.push({ kind, phase, target: { scope: 'state', state: state } });
27489
+ });
27490
+ };
27491
+ // global-action tables: interned action_id -> handler
27492
+ const push_actions = (table, kind, phase) => {
27493
+ table.forEach((_handler, aid) => {
27494
+ entries.push({ kind, phase, target: { scope: 'action', action: action_name(aid) } });
27495
+ });
27496
+ };
27497
+ const push_global = (handler, kind, phase) => {
27498
+ if (handler !== undefined) {
27499
+ entries.push({ kind, phase, target: { scope: 'global' } });
27500
+ }
27501
+ };
27502
+ // FSL boundary hooks: subject name -> { onEnter?, onExit? }, fired post-
27503
+ // commit. Each present direction becomes its own row, all phase 'post'.
27504
+ const push_boundary = (table, enterKind, exitKind, target_of) => {
27505
+ table.forEach((bh, subject) => {
27506
+ if (bh.onEnter !== undefined) {
27507
+ entries.push({ kind: enterKind, phase: 'post', target: target_of(subject) });
27508
+ }
27509
+ if (bh.onExit !== undefined) {
27510
+ entries.push({ kind: exitKind, phase: 'post', target: target_of(subject) });
27511
+ }
27512
+ });
27513
+ };
27514
+ // pre-phase, edge- and state-keyed tables
27515
+ push_edges(this._hooks, 'hook', 'pre');
27516
+ push_named(this._named_hooks, 'named', 'pre');
27517
+ push_states(this._entry_hooks, 'entry', 'pre');
27518
+ push_states(this._exit_hooks, 'exit', 'pre');
27519
+ push_states_by_name(this._after_hooks, 'after', 'pre');
27520
+ push_actions(this._global_action_hooks, 'global action', 'pre');
27521
+ // pre-phase, global singletons
27522
+ push_global(this._any_action_hook, 'any action', 'pre');
27523
+ push_global(this._standard_transition_hook, 'standard transition', 'pre');
27524
+ push_global(this._main_transition_hook, 'main transition', 'pre');
27525
+ push_global(this._forced_transition_hook, 'forced transition', 'pre');
27526
+ push_global(this._any_transition_hook, 'any transition', 'pre');
27527
+ push_global(this._pre_everything_hook, 'pre everything', 'pre');
27528
+ push_global(this._everything_hook, 'everything', 'pre');
27529
+ // post-phase, edge- and state-keyed tables
27530
+ push_edges(this._post_hooks, 'post hook', 'post');
27531
+ push_named(this._post_named_hooks, 'post named', 'post');
27532
+ push_states(this._post_entry_hooks, 'post entry', 'post');
27533
+ push_states(this._post_exit_hooks, 'post exit', 'post');
27534
+ push_actions(this._post_global_action_hooks, 'post global action', 'post');
27535
+ // post-phase, global singletons
27536
+ push_global(this._post_any_action_hook, 'post any action', 'post');
27537
+ push_global(this._post_standard_transition_hook, 'post standard transition', 'post');
27538
+ push_global(this._post_main_transition_hook, 'post main transition', 'post');
27539
+ push_global(this._post_forced_transition_hook, 'post forced transition', 'post');
27540
+ push_global(this._post_any_transition_hook, 'post any transition', 'post');
27541
+ push_global(this._pre_post_everything_hook, 'pre post everything', 'post');
27542
+ push_global(this._post_everything_hook, 'post everything', 'post');
27543
+ // FSL boundary hooks (post-commit): group and plain-state subjects
27544
+ push_boundary(this._group_hooks, 'group enter', 'group exit', (group) => ({ scope: 'group', group }));
27545
+ push_boundary(this._state_hooks, 'state enter', 'state exit', (state) => ({ scope: 'state', state: state }));
27546
+ return entries;
27547
+ }
27548
+ /********
27549
+ *
27550
+ * Does a single registry entry reference the state `state`? An entry
27551
+ * references a state when it is a `'state'`-scoped hook on that state, or an
27552
+ * `'edge'`-scoped hook whose `from` or `to` is that state. `'action'`- and
27553
+ * `'global'`-scoped entries reference no particular state. This is the
27554
+ * predicate behind both per-state introspection and the `hooked_state`
27555
+ * styling layer.
27556
+ *
27557
+ * @param entry The registry entry to test.
27558
+ * @param state The state name to test membership of.
27559
+ * @returns `true` when the entry observes that state.
27560
+ *
27561
+ */
27562
+ static _entry_touches_state(entry, state) {
27563
+ const t = entry.target;
27564
+ if (t.scope === 'state') {
27565
+ return t.state === state;
27566
+ }
27567
+ if (t.scope === 'edge') {
27568
+ return t.from === state || t.to === state;
27569
+ }
27570
+ return false;
27571
+ }
27572
+ /********
27573
+ *
27574
+ * Does a single registry entry match a `{ from, to, action? }` edge query?
27575
+ * Only `'edge'`-scoped entries can match. When the query omits `action`
27576
+ * the entry's action (if any) is ignored; when the query supplies `action`
27577
+ * it must match exactly.
27578
+ *
27579
+ * @param entry The registry entry to test.
27580
+ * @param from The edge origin to match.
27581
+ * @param to The edge destination to match.
27582
+ * @param action Optional named action to match exactly.
27583
+ * @returns `true` when the entry observes that edge.
27584
+ *
27585
+ */
27586
+ static _entry_matches_edge(entry, from, to, action) {
27587
+ const t = entry.target;
27588
+ if (t.scope !== 'edge') {
27589
+ return false;
27590
+ }
27591
+ if (t.from !== from || t.to !== to) {
27592
+ return false;
27593
+ }
27594
+ if (action !== undefined) {
27595
+ return t.action === action;
27596
+ }
27597
+ return true;
27598
+ }
27599
+ /********
27600
+ *
27601
+ * Does a single registry entry match an action name? Both `'action'`-scoped
27602
+ * hooks (global-action hooks) and named-edge hooks carrying that action
27603
+ * count as matches.
27604
+ *
27605
+ * @param entry The registry entry to test.
27606
+ * @param action The action name to match.
27607
+ * @returns `true` when the entry observes that action.
27608
+ *
27609
+ */
27610
+ static _entry_matches_action(entry, action) {
27611
+ const t = entry.target;
27612
+ if (t.scope === 'action') {
27613
+ return t.action === action;
27614
+ }
27615
+ if (t.scope === 'edge') {
27616
+ return t.action === action;
27617
+ }
27618
+ return false;
27619
+ }
27620
+ /********
27621
+ *
27622
+ * Does a single registry entry match a named state group? Only
27623
+ * `'group'`-scoped entries (FSL group-boundary hooks) match. Group hooks
27624
+ * are matched by group name only — they deliberately do not propagate to
27625
+ * member states, so a member-state query never returns them.
27626
+ *
27627
+ * @param entry The registry entry to test.
27628
+ * @param group The group name to match.
27629
+ * @returns `true` when the entry observes that group's boundary.
27630
+ *
27631
+ */
27632
+ static _entry_matches_group(entry, group) {
27633
+ const t = entry.target;
27634
+ if (t.scope === 'group') {
27635
+ return t.group === group;
27636
+ }
27637
+ return false;
27638
+ }
27639
+ /********
27640
+ *
27641
+ * Return every registry entry observing the given target (megaspec §12).
27642
+ * The `query` selects the target shape:
27643
+ *
27644
+ * - a bare **state name** matches entry/exit/after hooks on that state, its
27645
+ * state-boundary hooks, and every edge hook touching it (`from` or `to`),
27646
+ * - a `{ from, to, action? }` **edge** matches edge hooks on that
27647
+ * transition (optionally narrowed to the named action),
27648
+ * - a `{ action }` **action** matches global-action and named-edge hooks
27649
+ * carrying that action,
27650
+ * - a `{ group }` **group** matches that group's boundary hooks (group hooks
27651
+ * are matched by name only and do not propagate to member states).
27652
+ *
27653
+ * ```typescript
27654
+ * const m = sm`a 'go' -> b;`;
27655
+ * m.hook_entry('b', () => true);
27656
+ * m.hooks_on('b').length; // 1
27657
+ * m.hooks_on({ from: 'a', to: 'b' }); // [] (no edge hook registered)
27658
+ * ```
27659
+ *
27660
+ * @param query The {@link HookQuery} naming the target to inspect.
27661
+ * @returns The matching {@link HookRegistryEntry} rows (possibly empty).
27662
+ *
27663
+ */
27664
+ hooks_on(query) {
27665
+ const registry = this.hook_registry();
27666
+ if (typeof query === 'string') {
27667
+ return registry.filter(e => Machine._entry_touches_state(e, query));
27668
+ }
27669
+ // An edge query is distinguished by carrying `from` (it may *also* carry
27670
+ // `action`, which narrows the edge — so this must be tested before the
27671
+ // action-only case, whose discriminator `action` an edge query can share).
27672
+ if ('from' in query) {
27673
+ return registry.filter(e => Machine._entry_matches_edge(e, query.from, query.to, query.action));
27674
+ }
27675
+ if ('group' in query) {
27676
+ return registry.filter(e => Machine._entry_matches_group(e, query.group));
27677
+ }
27678
+ return registry.filter(e => Machine._entry_matches_action(e, query.action));
27679
+ }
27680
+ /********
27681
+ *
27682
+ * Is at least one observational hook bound to the given target (megaspec
27683
+ * §12)? The `query` is read exactly as in {@link Machine.hooks_on}. An
27684
+ * optional `phase` narrows the test to pre- or post-transition hooks only;
27685
+ * omitted, either phase satisfies it.
27686
+ *
27687
+ * ```typescript
27688
+ * const m = sm`a -> b;`;
27689
+ * m.has_hook('b'); // false
27690
+ * m.hook_entry('b', () => true);
27691
+ * m.has_hook('b'); // true
27692
+ * m.has_hook('b', 'post'); // false (the entry hook is pre-phase)
27693
+ * ```
27694
+ *
27695
+ * @param query The {@link HookQuery} naming the target to inspect.
27696
+ * @param phase Optional {@link HookPhase} to restrict the test to.
27697
+ * @returns `true` when a matching hook exists.
27698
+ *
27699
+ */
27700
+ has_hook(query, phase) {
27701
+ const matches = this.hooks_on(query);
27702
+ if (phase === undefined) {
27703
+ return matches.length > 0;
27704
+ }
27705
+ return matches.some(e => e.phase === phase);
27706
+ }
27707
+ /********
27708
+ *
27709
+ * Does the given state carry any observational hook — i.e. should it receive
27710
+ * the `hooked_state` viz styling? True when an entry/exit/after hook is
27711
+ * bound to the state, any edge hook touches it, or the state has its own
27712
+ * boundary hook. Group-boundary hooks do *not* count here — they are
27713
+ * matched by group only and never propagate to member states. Powers the
27714
+ * `hooked` styling layer in {@link Machine.resolve_state_config}; replaces
27715
+ * the long-stubbed `has_hooks` placeholder (megaspec §12).
27716
+ *
27717
+ * ```typescript
27718
+ * const m = sm`a -> b;`;
27719
+ * m.state_has_hooks('a'); // false
27720
+ * m.hook_exit('a', () => true);
27721
+ * m.state_has_hooks('a'); // true
27722
+ * ```
27723
+ *
27724
+ * @param state The state to test.
27725
+ * @returns `true` when the state is observed by at least one hook.
27726
+ *
27727
+ */
27728
+ state_has_hooks(state) {
27729
+ // Boundary hooks are a separate mechanism that sets neither _has_hooks nor
27730
+ // _has_post_hooks, so the fast-out must also consult the boundary tables —
27731
+ // otherwise a state whose only hook is a boundary hook reports unhooked.
27732
+ if (!this._has_hooks
27733
+ && !this._has_post_hooks
27734
+ && (this._state_hooks.size === 0)
27735
+ && (this._group_hooks.size === 0)) {
27736
+ return false;
27737
+ }
27738
+ return this.hook_registry().some(e => Machine._entry_touches_state(e, state));
27739
+ }
27396
27740
  /********
27397
27741
  *
27398
27742
  * Returns the list of resolved theme implementations for this machine, in
@@ -27530,6 +27874,17 @@ class Machine {
27530
27874
  });
27531
27875
  // tier 2 — default_state_config (implicit root over all states)
27532
27876
  acc = merge_state_config(acc, this._state_style);
27877
+ // tier 2.5 — hooked-state styling, applied when the state carries any
27878
+ // observational or boundary hook. Sits above the root default and below
27879
+ // the per-kind/group/per-state tiers, preserving the historical layer
27880
+ // order the pre-cascade `style_for` used. See {@link state_has_hooks}.
27881
+ if (this.state_has_hooks(state)) {
27882
+ acc = merge_state_config(acc, base_theme.hooked);
27883
+ themes.forEach(theme => { if (theme.hooked) {
27884
+ acc = merge_state_config(acc, theme.hooked);
27885
+ } });
27886
+ acc = merge_state_config(acc, this._hooked_state_style);
27887
+ }
27533
27888
  // tier 3 — static per-kind defaults, selected by structural kind
27534
27889
  if (this.state_is_terminal(state)) {
27535
27890
  acc = merge_state_config(acc, base_theme.terminal);