@turing-machine-js/machine 7.0.0-alpha.4 → 7.0.0-alpha.5

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/CHANGELOG.md CHANGED
@@ -4,6 +4,38 @@ All notable changes to this project will be documented in this file.
4
4
 
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
6
 
7
+ ## [7.0.0-alpha.5] - 2026-05-25
8
+
9
+ Fifth v7 pre-release. Adds per-iter `matchedTransition` to `MachineState` so consumers can resolve the firing transition by graph id without re-deriving from `(source, nextState)` ambiguous pairs ([#205](https://github.com/mellonis/turing-machine-js/issues/205)), collapses `haltState.debug` to a `boolean` and moves the halt-imminent pause to the AFTER side of the halt-triggering iter so the wording + diagram cursor agree with the just-fired transition ([#207](https://github.com/mellonis/turing-machine-js/issues/207)), and renames the `GraphTransition.id` separator from `-` to `.` for parser-friendly splitting. Published under the `next` dist-tag: `npm install @turing-machine-js/machine@next`.
10
+
11
+ **Pre-release — the API surface may still shift before stable v7.0.0.** Pin to a specific alpha for reproducibility: `@turing-machine-js/machine@7.0.0-alpha.5`.
12
+
13
+ ### Added
14
+
15
+ - **`MachineState.matchedTransition`** ([#205](https://github.com/mellonis/turing-machine-js/issues/205)) — every yielded `MachineState` from `run` / `runStepByStep` now carries `{ id: string, matchKinds: ('wildcard' | 'literal')[] }`. `id` is `${stateId}.${transitionIx}` and resolves directly in `toGraph`'s output via `graph.nodes[stateId].transitions.find(t => t.id === ...)`. For wrapper-entry iters, `id` references the BARE's transition (wrappers delegate via `bareState`). `matchKinds` is per-tape, length = tape count; `'wildcard'` iff the winning alternative held `ifOtherSymbol` at that position. Eliminates `(source, nextState)` resolution ambiguity for tools doing exact-edge highlighting, per-transition coverage maps, or wildcard-aware log formatting.
16
+
17
+ - **`State.getMatchedTransition(symbol)`** ([#205](https://github.com/mellonis/turing-machine-js/issues/205)) — public method returning `{ nextState, matchedSymbol, ix }` for the symbol's transition. Same lookup `getNextState` does, plus the index — exposes the K used by `MachineState.matchedTransition.id` so callers needing both don't pay for the lookup twice.
18
+
19
+ - **`TapeBlock.patternKinds(symbol, currentSymbols?)`** ([#205](https://github.com/mellonis/turing-machine-js/issues/205)) — public method returning per-tape `'wildcard' | 'literal'` for the matched alternative's selector. The engine uses it internally to populate `matchedTransition.matchKinds`; exposed for consumers wiring custom dispatch.
20
+
21
+ - **`HaltState` typed alias** ([#207](https://github.com/mellonis/turing-machine-js/issues/207)) — the exported `haltState` singleton now carries a narrower TypeScript type (`State & { debug: boolean; ... }`) so `haltState.debug = true` is type-safe at the call site. Generic `State` references still see `DebugConfig` — runtime branches on `this.isHalt`.
22
+
23
+ ### Changed
24
+
25
+ - **`GraphTransition.id` separator: `-` → `.`** ([#205](https://github.com/mellonis/turing-machine-js/issues/205)). Was `${stateId}-${patternIx}`, now `${stateId}.${patternIx}`. Aligns with `matchedTransition.id` and makes the id splittable without colliding with user-defined state names that may contain `-`. **Breaking** for any consumer parsing the old separator; alpha-channel only.
26
+
27
+ - **`haltState.debug` is `boolean`** ([#207](https://github.com/mellonis/turing-machine-js/issues/207)). Was `DebugConfig` with per-side `{ before?, after? }` filters; under v7 it's a single `boolean` flag. `haltState.debug = true` enables; `false` / `null` disables. **Object-shaped writes throw** (`{ before: true }`, `{ after: true }`, etc.). The pause fires on the AFTER side of the iter whose transition leads to halt — `m.state` is the triggering state (not haltState), `m.debugBreak.after === true`. Diagram + log narratives read naturally: the halt-bound transition has fired when the pause lands, and halt is the next thing. **Breaking** — pre-alpha.5 consumers using `haltState.debug = { before: true }` must switch to `haltState.debug = true`.
28
+
29
+ ⚠️ **Chained-form `haltState.debug.before = true` silently no-ops in non-strict mode.** The getter returns the boolean; assigning `.before` to a primitive is a JS no-op outside strict mode (`TypeError` in strict). The engine setter never sees the write. Use the whole-object form (`haltState.debug = true` / `= false` / `= null`).
30
+
31
+ - **`State` internal: single `#getEntry` helper for `getCommand` / `getNextState` / `getMatchedTransition`** ([#206](https://github.com/mellonis/turing-machine-js/pull/206)). Internal refactor — no public API change. All three accessors now share one `.get()` lookup + unified throw message (`"No transition for symbol at state named ..."` — was per-method messages).
32
+
33
+ - **Internal: dead-code typeof check on `state.debug` in `runStepByStep` removed; `patternKinds` defensive paths now have dedicated test coverage** ([#210](https://github.com/mellonis/turing-machine-js/pull/210)). Engine-internal cleanup that bumps overall coverage to 99.35% statements / 96.58% branches / 100% functions / 99.48% lines.
34
+
35
+ ### Docs
36
+
37
+ - ⚠️ **README + CLAUDE.md** ([#209](https://github.com/mellonis/turing-machine-js/pull/209)) — warn that chained-form `haltState.debug.before = true` silently no-ops in non-strict mode (`new Function(...)`, `<script>` without `"use strict"`). Engine can't intercept (the write happens on a primitive after the getter returned); doc steers users to the whole-object form.
38
+
7
39
  ## [7.0.0-alpha.4] - 2026-05-23
8
40
 
9
41
  Fourth v7 pre-release. Adds an id-keyed lookup helper for the State graph ([#195](https://github.com/mellonis/turing-machine-js/issues/195)), fixes two upstream issues surfaced while wiring the new helper into downstream tooling — a `toMermaid` label-grammar bug ([#194](https://github.com/mellonis/turing-machine-js/issues/194)) and a halt-stack lifetime bug in `runStepByStep` ([#196](https://github.com/mellonis/turing-machine-js/issues/196)) — and extracts graph serialization into its own module without API change ([#180](https://github.com/mellonis/turing-machine-js/issues/180)). Published under the `next` dist-tag: `npm install @turing-machine-js/machine@next`.
package/README.md CHANGED
@@ -327,6 +327,7 @@ Each yielded `step` (`MachineState`) has these fields:
327
327
  | `movements` | `symbol[]` | per-tape head moves (`movements.left/right/stay`) |
328
328
  | `nextState` | `State` | the state that will execute next |
329
329
  | `debugBreak?` | `{ before?: true, after?: true }` | only set when `state.debug` matched on this iter — see *Debugging breakpoints* below |
330
+ | `matchedTransition` | `{ id: string, matchKinds: ('wildcard'\|'literal')[] }` | the transition the engine picked for this iter — see *Matched transition* below |
330
331
 
331
332
  `stepsLimit` (default `1e5`) guards against runaway loops — exceeding it throws.
332
333
 
@@ -346,6 +347,35 @@ Both APIs are first-class — `run()` is built on top of `runStepByStep()` (see
346
347
 
347
348
  **Don't split one logical flow across both APIs.** A consumer that wants stepwise UI *and* hook-driven breakpoints should use `run({ onStep, onPause, debug })` exclusively. Routing some operations through `runStepByStep()` and others through `run()` means `state.debug` only flows through one of the two paths — a subtle footgun where breakpoints silently disappear on whichever code path uses the generator directly. For per-iter throttle / "wait between steps" UIs, see [Throttle pattern](#throttle-pattern).
348
349
 
350
+ ### Matched transition
351
+
352
+ Every yielded `MachineState` carries a `matchedTransition` describing which transition the engine picked for that iter. The engine already resolves this via `state.getNextState(symbol)` internally; this field exposes the resolution to consumers so visualizations, log formatters, and coverage maps don't have to re-derive an ambiguous `(source, nextState)` pair (which collides when multiple transitions on the same source share a destination) or parse pattern strings from `toGraph`.
353
+
354
+ ```ts
355
+ matchedTransition: {
356
+ id: string; // resolvable in toGraph
357
+ matchKinds: ('wildcard' | 'literal')[]; // per-tape, length = tape count
358
+ }
359
+ ```
360
+
361
+ - **`id`** — `${stateId}.${transitionIx}`. Resolvable in `toGraph`'s output: `graph.nodes[stateId].transitions` has an entry with the matching `id`. For wrapper-entry iters (source is a wrapper produced by `withOverriddenHaltState`), `id` references the **bare's** transition — the wrapper's own `transitions` array in `toGraph` is empty because wrappers delegate, and the pattern actually lives on the bare. Detect by comparing `id.split('.')[0]` against `state.id`: different → wrapper delegation.
362
+
363
+ - **`matchKinds`** — per-tape match kind for the matched alternative's selector at each tape position. `'wildcard'` if the position held `ifOtherSymbol` (catch-all) in the winning alternative; `'literal'` if it held a specific symbol or symbol-list. Length always equals tape count.
364
+
365
+ Example use:
366
+
367
+ ```javascript
368
+ await machine.run({
369
+ initialState,
370
+ onStep: (m) => {
371
+ const wildcardPositions = m.matchedTransition.matchKinds // per-tape, e.g. ['wildcard', 'literal']
372
+ .map((k, i) => k === 'wildcard' ? i : -1)
373
+ .filter((i) => i >= 0);
374
+ console.log(`step ${m.step}: fired transition ${m.matchedTransition.id} (wildcards at tapes: ${wildcardPositions.join(',') || 'none'})`);
375
+ },
376
+ });
377
+ ```
378
+
349
379
  ## Subroutine composition with `withOverriddenHaltState`
350
380
 
351
381
  `state.withOverriddenHaltState(other)` returns a copy of `state` whose would-be halt transitions fall through to `other` at run time. The original is left untouched. This is the engine's only composition primitive — bigger machines are built by stacking smaller halt-on-completion subroutines.
@@ -467,14 +497,20 @@ myState.debug = { before: true };
467
497
  myState.debug = { before: [symA] };
468
498
  myState.debug = { before: [symA], after: [symA] };
469
499
 
470
- // Pause when the engine is about to enter halt (program exit OR subroutine pop):
471
- haltState.debug = { before: true };
500
+ // Pause when the engine is about to enter halt (program exit OR subroutine pop).
501
+ // haltState.debug is a `boolean` (#207) — halt is terminal, so there's only
502
+ // one meaningful pause moment (post-triggering-iter, before halt processing).
503
+ haltState.debug = true;
504
+ haltState.debug = false; // turn off
505
+ haltState.debug = null; // alias of false (reset)
472
506
 
473
- // Reset filters later — next read returns a fresh empty DebugConfig:
507
+ // Reset filters later on a regular state — next read returns a fresh empty DebugConfig:
474
508
  myState.debug = null;
475
509
  ```
476
510
 
477
- > ⚠️ **`haltState.debug.after` throws.** Halt is terminal there is no iteration-after-halt for an after-fire to anchor on. Assigning a truthy `.after` to `haltState.debug` (including `{ before: true, after: true }`) throws at write time. Symbol-list filters on `haltState.debug.before` are silent no-ops, since halt has no head symbol; only the wildcard `true` activates.
511
+ > ⚠️ **`haltState.debug` is `boolean`-only.** Any object-shaped write (`{ before: true }`, `{ after: true }`, `{ before: true, after: true }`) throws at write time. The pause fires on the AFTER side of the iter whose transition leads to halt — `m.state` is the triggering state (not haltState), `m.debugBreak.after === true`. Diagram + log narratives read naturally: the halt-bound transition has already fired when the pause lands, and halt is the next thing.
512
+
513
+ > ⚠️ **Chained-form `haltState.debug.before = true` doesn't throw in non-strict mode** — this is a JavaScript primitive quirk, not engine behavior. The getter returns the boolean `false`; assigning `.before` to that boolean is a no-op in non-strict mode (silent), a `TypeError` in strict mode. The engine setter only sees whole-object writes (`haltState.debug = X`), so it can't intercept the chained form. **Always use the whole-object form: `haltState.debug = true` / `= false` / `= null`.** Modules built with `tsc` / ESM run in strict mode by default and surface this throw; `new Function(...)` and `<script>` without `"use strict"` run non-strict and silently no-op the chained write.
478
514
 
479
515
  The `debug` field is mutable — toggle breakpoints at runtime without rebuilding the graph. The internal cell is shared with `state.withOverriddenHaltState(...)` wrappers, so an assignment on the original is visible from every wrapper. `state.debug` is always a `DebugConfig` instance (lazy-initialized on first read); plain-object input (`state.debug = { before: true }`) is wrapped in a fresh `DebugConfig` automatically. The instance itself is `Object.seal`-ed — typos like `state.debug.bofore = true` throw `TypeError` instead of silently creating a useless property. Per-property setters validate and freeze the stored array, so `state.debug.before.push(...)` also throws `TypeError`.
480
516
 
@@ -708,7 +744,7 @@ Reading `['0',*] → [K,'0']/[R,R]`:
708
744
  API surface changes since v3, in past tense so the timing of each piece is explicit:
709
745
 
710
746
  - **v4** — `run()` became async (`Promise<void>`). Per-state runtime breakpoints landed (`state.debug.before` / `state.debug.after`); `run()` accepted an `onDebugBreak` hook. `MachineState` exposed on each yield.
711
- - **v5** — `onDebugBreak` renamed to `onPause`. New `run({ debug: boolean })` master switch suppresses all `onPause` dispatches without unsetting `state.debug` assignments. Assigning a truthy `.after` to `haltState.debug` now throws at write time (halt is terminal — no iteration-after-halt to anchor on).
747
+ - **v5** — `onDebugBreak` renamed to `onPause`. New `run({ debug: boolean })` master switch suppresses all `onPause` dispatches without unsetting `state.debug` assignments. Assigning a truthy `.after` to `haltState.debug` now throws at write time (halt is terminal — no iteration-after-halt to anchor on). *Superseded in v7 by #207: `haltState.debug` is now `boolean`, all object-shaped writes throw.*
712
748
  - **v6** — Per-iter lifecycle reordered to `before → step → after`, all firing on the same yield. Previously `after` fired on iter K+1's tick with a `prevYield` substitution dance; that substitution is gone. The `MachineState.debugBreak` field shape is unchanged across all three versions.
713
749
  - **v6.1** — `state.debug` ergonomics: the field is now always a non-null `DebugConfig` instance (lazy-initialized on first read), so chained field writes like `state.debug.before = true` work on a fresh state without a prior whole-object assignment. The `DebugConfig` instance is `Object.seal`-ed, so typos like `state.debug.bofore = true` throw `TypeError` at write time instead of silently creating a useless property. `state.debug = null` continues to work but semantically means "reset filters" — the next read returns a fresh empty `DebugConfig` (#150).
714
750
  - **v6.2** *(superseded by v6.3.0)* — widened `onStep`'s signature to `(m) => void | Promise<void>` and added an inline `await onStep(...)` in the run loop, enabling throttle-in-`onStep` patterns. This overturned the docstring-stated contract that `onStep` is sync (microtask-free); the right place for per-iter throttling is `onPause` with self-rearm (see [Throttle pattern](#throttle-pattern)). Restored in v6.3.0.
@@ -73,11 +73,35 @@ export default class State {
73
73
  * code uses). Order matches insertion order of the underlying Set.
74
74
  */
75
75
  get tags(): readonly string[];
76
- /** @internal — invoked by DebugConfig setters via module-private symbol. */
76
+ /** @internal — invoked by DebugConfig setters via module-private symbol.
77
+ * Per #207, haltState no longer flows through DebugConfig (its `debug`
78
+ * setter rejects object writes before construction), so the validator
79
+ * only sees non-halt states here. */
77
80
  [validateDebugFilter](fieldName: 'before' | 'after', filter: readonly symbol[] | true | undefined): void;
78
81
  getSymbol(tapeBlock: TapeBlock): symbol;
79
82
  getCommand(symbol: symbol): Command;
80
83
  getNextState(symbol: symbol): State | Reference;
84
+ /**
85
+ * Like `getNextState`, but also returns the matched Symbol and its index
86
+ * in this State's transition declaration order (= the `K` in `toGraph`'s
87
+ * `${stateId}.${K}` transition ids). Used by `TuringMachine.runStepByStep`
88
+ * to populate `MachineState.matchedTransition` for #205 — exposes which
89
+ * transition fired so consumers (UIs, log tools, coverage maps) can
90
+ * resolve the firing edge without re-deriving from `(source, nextState)`,
91
+ * which is ambiguous when multiple transitions on the same source go to
92
+ * the same destination.
93
+ *
94
+ * Throws (matching `getNextState` / `getCommand`) when no entry exists for
95
+ * the symbol. For wrappers (states produced by `withOverriddenHaltState`):
96
+ * the symbol-to-data map is shared with the bare via `bareState`, so the
97
+ * returned `ix` is a valid position into BOTH the wrapper's and the
98
+ * bare's transition iteration order — they're the same map.
99
+ */
100
+ getMatchedTransition(symbol: symbol): {
101
+ nextState: State | Reference;
102
+ matchedSymbol: symbol;
103
+ ix: number;
104
+ };
81
105
  withOverriddenHaltState(overriddenHaltState: State): State;
82
106
  /**
83
107
  * @internal
@@ -164,5 +188,26 @@ export default class State {
164
188
  */
165
189
  static collectStates(initialState: State, tapeBlock: TapeBlock): StateMap;
166
190
  }
167
- export declare const haltState: State;
191
+ /**
192
+ * Typed alias for the haltState singleton (#207). Narrows `debug` from
193
+ * the generic-State `DebugConfig | boolean` union to plain `boolean`,
194
+ * giving compile-time type-safety at the singleton's call sites:
195
+ *
196
+ * ```ts
197
+ * haltState.debug = true; // ok
198
+ * haltState.debug = false; // ok
199
+ * haltState.debug = { before: true } // TS error
200
+ * const isOn = haltState.debug; // typed `boolean`
201
+ * ```
202
+ *
203
+ * Anyone holding a `State` reference that happens to BE the singleton (e.g.
204
+ * via `state.getNextState(sym).ref === haltState`) sees the wider `State`
205
+ * type; runtime throws guide them to the right shape. The singleton export
206
+ * is the canonical access path.
207
+ */
208
+ export type HaltState = State & {
209
+ get debug(): boolean;
210
+ set debug(value: boolean | null);
211
+ };
212
+ export declare const haltState: HaltState;
168
213
  export {};
@@ -23,6 +23,31 @@ export default class TapeBlock {
23
23
  currentSymbols?: string[];
24
24
  symbol: symbol;
25
25
  }): boolean;
26
+ /**
27
+ * For a Symbol returned by `this.symbol([...])` (or the catch-all
28
+ * `ifOtherSymbol`), returns the per-tape match kind for the
29
+ * **alternative that actually matched** given `currentSymbols`:
30
+ * `'wildcard'` if that tape position was `ifOtherSymbol` in the winning
31
+ * alternative, `'literal'` otherwise. Length always equals the tape
32
+ * count.
33
+ *
34
+ * Used by `TuringMachine.runStepByStep` to populate
35
+ * `MachineState.matchedTransition.matchKinds` for #205. The "winning
36
+ * alternative" disambiguation matters for alternations like
37
+ * `[[ifOtherSymbol, 'c'], ['a', 'b']]` — different alternatives can
38
+ * have different per-tape kinds, and only the alternative that matched
39
+ * the current head symbols is meaningful.
40
+ *
41
+ * - `ifOtherSymbol` (the State's catch-all transition fired): all
42
+ * positions are `'wildcard'`.
43
+ * - Symbol with patternList: find the first alternative that matches
44
+ * `currentSymbols` (same predicate as `isMatched`), return its
45
+ * per-position kinds.
46
+ * - Symbol with no winning alternative under the given `currentSymbols`
47
+ * (defensive — shouldn't happen if the caller resolved the Symbol via
48
+ * the State's normal matching): fall back to all `'literal'`.
49
+ */
50
+ patternKinds(symbol: symbol, currentSymbols?: string[]): ('wildcard' | 'literal')[];
26
51
  replaceTape(tape: Tape, tapeIx?: number): void;
27
52
  }
28
53
  export {};
@@ -26,6 +26,29 @@ export type MachineState = {
26
26
  before?: true;
27
27
  after?: true;
28
28
  };
29
+ /**
30
+ * The transition the engine picked for this iter (#205). Always present
31
+ * — `runStepByStep` resolves it at the very start of every iter via
32
+ * `state.getMatchedTransition(symbol)`, well before any callback fires.
33
+ *
34
+ * - `id` — resolvable in `toGraph`'s output: `graph.nodes[…].transitions`
35
+ * contains a `GraphTransition` whose `.id` equals this value. Format is
36
+ * `${stateId}.${transitionIx}`. **For wrapper-entry iters (`state` is
37
+ * produced by `withOverriddenHaltState`): the wrapper's own
38
+ * `transitions` array in `toGraph` is empty because wrappers delegate
39
+ * to the bare; this field carries the BARE's transition id, where the
40
+ * pattern actually lives.** Consumers can detect this case by
41
+ * comparing `id.split('.')[0]` against `state.id` — different = wrapper
42
+ * delegation.
43
+ * - `matchKinds` — per-tape match kind for the picked transition's
44
+ * pattern at each tape position. `'wildcard'` if the matched
45
+ * alternative had `ifOtherSymbol` at that position, `'literal'`
46
+ * otherwise. Length equals tape count.
47
+ */
48
+ matchedTransition: {
49
+ id: string;
50
+ matchKinds: ('wildcard' | 'literal')[];
51
+ };
29
52
  };
30
53
  export default class TuringMachine {
31
54
  #private;
package/dist/index.cjs CHANGED
@@ -655,6 +655,46 @@ class TapeBlock {
655
655
  .every((everySymbol, ix) => (everySymbol === ifOtherSymbol
656
656
  || everySymbol === currentSymbols[ix])))) ?? false;
657
657
  }
658
+ /**
659
+ * For a Symbol returned by `this.symbol([...])` (or the catch-all
660
+ * `ifOtherSymbol`), returns the per-tape match kind for the
661
+ * **alternative that actually matched** given `currentSymbols`:
662
+ * `'wildcard'` if that tape position was `ifOtherSymbol` in the winning
663
+ * alternative, `'literal'` otherwise. Length always equals the tape
664
+ * count.
665
+ *
666
+ * Used by `TuringMachine.runStepByStep` to populate
667
+ * `MachineState.matchedTransition.matchKinds` for #205. The "winning
668
+ * alternative" disambiguation matters for alternations like
669
+ * `[[ifOtherSymbol, 'c'], ['a', 'b']]` — different alternatives can
670
+ * have different per-tape kinds, and only the alternative that matched
671
+ * the current head symbols is meaningful.
672
+ *
673
+ * - `ifOtherSymbol` (the State's catch-all transition fired): all
674
+ * positions are `'wildcard'`.
675
+ * - Symbol with patternList: find the first alternative that matches
676
+ * `currentSymbols` (same predicate as `isMatched`), return its
677
+ * per-position kinds.
678
+ * - Symbol with no winning alternative under the given `currentSymbols`
679
+ * (defensive — shouldn't happen if the caller resolved the Symbol via
680
+ * the State's normal matching): fall back to all `'literal'`.
681
+ */
682
+ patternKinds(symbol, currentSymbols = this.currentSymbols) {
683
+ const tapeCount = __classPrivateFieldGet$2(this, _TapeBlock_tapes, "f").length;
684
+ if (symbol === ifOtherSymbol) {
685
+ return Array.from({ length: tapeCount }, () => 'wildcard');
686
+ }
687
+ const patternList = __classPrivateFieldGet$2(this, _TapeBlock_symbolToPatternListMap, "f").get(symbol);
688
+ if (patternList === undefined) {
689
+ return Array.from({ length: tapeCount }, () => 'literal');
690
+ }
691
+ const winning = patternList.find((pattern) => (pattern.every((everySymbol, ix) => (everySymbol === ifOtherSymbol
692
+ || everySymbol === currentSymbols[ix]))));
693
+ if (winning === undefined) {
694
+ return Array.from({ length: tapeCount }, () => 'literal');
695
+ }
696
+ return winning.map((everySymbol) => (everySymbol === ifOtherSymbol ? 'wildcard' : 'literal'));
697
+ }
658
698
  replaceTape(tape, tapeIx = 0) {
659
699
  if (__classPrivateFieldGet$2(this, _TapeBlock_tapes, "f")[tapeIx] == null) {
660
700
  throw new Error('invalid tapeIx');
@@ -789,7 +829,14 @@ function toGraph(initialState, tapeBlock) {
789
829
  movement: decodeMovement(tc.movement.description),
790
830
  })),
791
831
  nextStateId: targetInternal.id,
792
- id: `${stateInternal.id}-${patternIx}`,
832
+ // Transition id format: `${stateId}.${transitionIx}` (#205).
833
+ // Matches `TuringMachine.runStepByStep`'s `MachineState.
834
+ // matchedTransition.id` so consumers can do
835
+ // `graph.nodes[stateId].transitions.find(t => t.id === id)`.
836
+ // Was `${stateId}-${ix}` pre-#205 — the `.` separator avoids
837
+ // the hyphen reading as a minus sign next to negative halt-
838
+ // marker ids in adjacent contexts.
839
+ id: `${stateInternal.id}.${patternIx}`,
793
840
  });
794
841
  queue.push(target);
795
842
  patternIx += 1;
@@ -827,6 +874,9 @@ function toGraph(initialState, tapeBlock) {
827
874
  // `nodes[id]` is always populated for `id` that the BFS reached, so
828
875
  // a defensive `!node` check would be dead. `isHalt` / `isWrapper`
829
876
  // are real boundaries — both stop reach-set expansion.
877
+ /* c8 ignore next 3 — defensive: the push site below already filters
878
+ halt/wrapper targets, and the initial push is always a bare, so
879
+ this branch is unreachable in practice. */
830
880
  if (node.isHalt || node.isWrapper) {
831
881
  continue;
832
882
  }
@@ -1071,7 +1121,8 @@ function fromGraph(graph) {
1071
1121
  * instance + per-pattern Symbol references for breakpoint setup (#195).
1072
1122
  *
1073
1123
  * **Positional alignment contract.** For any `GraphTransition` whose id
1074
- * is `${N}-${K}`, `result.get(N)!.transitionSymbols[K]` is the Symbol
1124
+ * is `${N}.${K}` (#205 changed the separator from `-` to `.`),
1125
+ * `result.get(N)!.transitionSymbols[K]` is the Symbol
1075
1126
  * the transition fires on (reference equality, not structural). The K-th
1076
1127
  * entry is the K-th key from the source State's `#symbolToDataMap` in
1077
1128
  * insertion order, including `ifOtherSymbol` when the user wrote one.
@@ -1082,7 +1133,7 @@ function fromGraph(graph) {
1082
1133
  * when a transition's `nextState` is an unresolved `Reference` (it
1083
1134
  * `continue`s without pushing the GraphTransition). In that case
1084
1135
  * `transitionSymbols[K]` is still set to the K-th Map key, but no
1085
- * `Graph.nodes[N].transitions` entry exists with id `${N}-${K}`. Sparse
1136
+ * `Graph.nodes[N].transitions` entry exists with id `${N}.${K}`. Sparse
1086
1137
  * on the Graph side, dense on the `transitionSymbols` side — same
1087
1138
  * indexing.
1088
1139
  *
@@ -1170,7 +1221,7 @@ function collectStates(initialState, tapeBlock) {
1170
1221
  }
1171
1222
  // Regular or bare State — enumerate `#symbolToDataMap.keys()` for
1172
1223
  // the patternIx alignment. The K-th key is the Symbol that
1173
- // `${id}-${K}` GraphTransition fires on (positional contract).
1224
+ // `${id}.${K}` GraphTransition fires on (positional contract).
1174
1225
  const state = stateById.get(id);
1175
1226
  const transitionSymbols = [...state[STATE_INTERNAL]().symbolToDataMap.keys()];
1176
1227
  result.set(id, { state, transitionSymbols });
@@ -1196,7 +1247,7 @@ var __classPrivateFieldGet$1 = (undefined && undefined.__classPrivateFieldGet) |
1196
1247
  if (typeof state === "function" ? receiver !== state || !f : !state.has(receiver)) throw new TypeError("Cannot read private member from an object whose class did not declare it");
1197
1248
  return kind === "m" ? f : kind === "a" ? f.call(receiver) : f ? f.value : state.get(receiver);
1198
1249
  };
1199
- var _DebugConfig_ownerState, _DebugConfig_before, _DebugConfig_after, _a, _State_wrapperCache, _State_id, _State_name, _State_overriddenHaltState, _State_bareState, _State_symbolToDataMap, _State_debugRef, _State_tags;
1250
+ var _DebugConfig_ownerState, _DebugConfig_before, _DebugConfig_after, _State_instances, _a, _State_wrapperCache, _State_id, _State_name, _State_overriddenHaltState, _State_bareState, _State_symbolToDataMap, _State_debugRef, _State_haltDebug, _State_tags, _State_getEntry;
1200
1251
  const ifOtherSymbol = Symbol('other symbol');
1201
1252
  // Module-private symbol used by DebugConfig setters to call State's validator
1202
1253
  // without exposing the validator on the public surface.
@@ -1262,6 +1313,7 @@ class DebugConfig {
1262
1313
  _DebugConfig_ownerState = new WeakMap(), _DebugConfig_before = new WeakMap(), _DebugConfig_after = new WeakMap();
1263
1314
  class State {
1264
1315
  constructor(stateDefinition = null, name) {
1316
+ _State_instances.add(this);
1265
1317
  _State_id.set(this, id(this));
1266
1318
  // Not `readonly` because `withOverriddenHaltState` and `fromGraph` set the
1267
1319
  // composed name on a no-arg `new State()` to bypass the constructor's
@@ -1279,6 +1331,14 @@ class State {
1279
1331
  // Note: toGraph / fromGraph deliberately do not serialize debug — debug is
1280
1332
  // a runtime concern, not part of the structural graph.
1281
1333
  _State_debugRef.set(this, { current: null });
1334
+ // Storage for `haltState.debug` (#207). haltState is a singleton terminal
1335
+ // state — it has no iter of its own, so the per-side `{ before, after }`
1336
+ // DebugConfig shape doesn't model anything meaningful for it. Instead the
1337
+ // halt breakpoint is a single boolean ("enabled / disabled"). The pause
1338
+ // anchors on the iter whose transition LEADS to halt, fired at end-of-iter
1339
+ // (after that iter's own after-pause if armed). Only used when `isHalt`;
1340
+ // ignored on every other State (whose `#debugRef` flow is unchanged).
1341
+ _State_haltDebug.set(this, false);
1282
1342
  // Out-of-band tags applied to this State (#186). Tags are visualization
1283
1343
  // and debugger-tooling metadata — they don't affect runtime transition
1284
1344
  // lookup or `equivalentOn` comparisons. Stored as a Set for de-duplication;
@@ -1349,6 +1409,17 @@ class State {
1349
1409
  return this;
1350
1410
  }
1351
1411
  get debug() {
1412
+ // haltState (#207): the canonical access path is the `haltState` singleton
1413
+ // export, which is typed `HaltState` — its `debug` getter is narrowed to
1414
+ // `boolean`. Generic `State` references statically see `DebugConfig` and
1415
+ // (in practice) never refer to haltState — the run loop's `state` is
1416
+ // never haltState because halt is terminal and doesn't iterate. The cast
1417
+ // below makes the runtime boolean return type-compatible with the
1418
+ // declared `DebugConfig` for any rare caller that holds a State
1419
+ // reference happening to be haltState.
1420
+ if (this.isHalt) {
1421
+ return __classPrivateFieldGet$1(this, _State_haltDebug, "f");
1422
+ }
1352
1423
  // Lazy-init: `state.debug` is never null at read time, so chained writes
1353
1424
  // like `state.debug.before = true` work on a fresh state without a prior
1354
1425
  // whole-object assignment. The setter still accepts `null` to reset the
@@ -1359,16 +1430,47 @@ class State {
1359
1430
  }
1360
1431
  return __classPrivateFieldGet$1(this, _State_debugRef, "f").current;
1361
1432
  }
1433
+ // TS signature: non-halt callers (generic `State` reference) get the
1434
+ // `DebugConfig | object | null` surface; boolean is rejected statically.
1435
+ // The `HaltState` typed alias on the singleton export overrides this to
1436
+ // `boolean | null` for the canonical halt access path. Runtime checks
1437
+ // below are defensive against type-bypass / mixed-source callers.
1362
1438
  set debug(value) {
1363
- if (value === null) {
1439
+ // Defensive runtime cast: TS signature excludes boolean for the generic
1440
+ // State surface, but haltState (via the HaltState alias) DOES accept
1441
+ // boolean, and the runtime needs to handle it for the singleton path.
1442
+ const v = value;
1443
+ // haltState (#207): only `boolean | null` is accepted. `null` aliases
1444
+ // to `false` (reset). Any object-shaped write throws at write-time so
1445
+ // misuse surfaces immediately rather than silently no-op'ing — the
1446
+ // `{before, after}` shape doesn't model anything meaningful for halt
1447
+ // (no own iter to anchor on; halt is terminal).
1448
+ if (this.isHalt) {
1449
+ if (v === null || typeof v === 'boolean') {
1450
+ __classPrivateFieldSet$1(this, _State_haltDebug, v === true, "f");
1451
+ return;
1452
+ }
1453
+ throw new Error('haltState.debug only accepts boolean (or null to reset). Use '
1454
+ + '`haltState.debug = true` to enable the halt breakpoint, false to '
1455
+ + 'disable. The pause fires after the iter whose transition leads to '
1456
+ + 'halt (post-iter, before halt processing).');
1457
+ }
1458
+ // Non-halt states: boolean writes are rejected — the per-side
1459
+ // `{before, after}` granularity is the contract. A boolean shortcut
1460
+ // would hide the asymmetry between before / after.
1461
+ if (typeof v === 'boolean') {
1462
+ throw new Error('state.debug only accepts a DebugConfig or `{ before, after }` object '
1463
+ + '(or null to reset). Boolean assignment is reserved for `haltState`.');
1464
+ }
1465
+ if (v === null) {
1364
1466
  __classPrivateFieldGet$1(this, _State_debugRef, "f").current = null;
1365
1467
  return;
1366
1468
  }
1367
- if (value instanceof DebugConfig) {
1368
- __classPrivateFieldGet$1(this, _State_debugRef, "f").current = value;
1469
+ if (v instanceof DebugConfig) {
1470
+ __classPrivateFieldGet$1(this, _State_debugRef, "f").current = v;
1369
1471
  return;
1370
1472
  }
1371
- __classPrivateFieldGet$1(this, _State_debugRef, "f").current = new DebugConfig(this, value);
1473
+ __classPrivateFieldGet$1(this, _State_debugRef, "f").current = new DebugConfig(this, v);
1372
1474
  }
1373
1475
  /**
1374
1476
  * Add one or more tags to this State (#186). Tags are out-of-band metadata
@@ -1400,25 +1502,15 @@ class State {
1400
1502
  get tags() {
1401
1503
  return Object.freeze([...__classPrivateFieldGet$1(this, _State_tags, "f")]);
1402
1504
  }
1403
- /** @internal — invoked by DebugConfig setters via module-private symbol. */
1404
- [(_State_id = new WeakMap(), _State_name = new WeakMap(), _State_overriddenHaltState = new WeakMap(), _State_bareState = new WeakMap(), _State_symbolToDataMap = new WeakMap(), _State_debugRef = new WeakMap(), _State_tags = new WeakMap(), validateDebugFilter)](fieldName, filter) {
1505
+ /** @internal — invoked by DebugConfig setters via module-private symbol.
1506
+ * Per #207, haltState no longer flows through DebugConfig (its `debug`
1507
+ * setter rejects object writes before construction), so the validator
1508
+ * only sees non-halt states here. */
1509
+ [(_State_id = new WeakMap(), _State_name = new WeakMap(), _State_overriddenHaltState = new WeakMap(), _State_bareState = new WeakMap(), _State_symbolToDataMap = new WeakMap(), _State_debugRef = new WeakMap(), _State_haltDebug = new WeakMap(), _State_tags = new WeakMap(), _State_instances = new WeakSet(), validateDebugFilter)](fieldName, filter) {
1405
1510
  if (filter === undefined)
1406
1511
  return;
1407
- // #108 part 2: `.after` on haltState has no semantic anchor — halt is
1408
- // terminal, so there is no iteration-after-halt for an after-fire to
1409
- // attach to. Reject any truthy assignment (true OR list) at write time
1410
- // so misuse surfaces immediately rather than silently no-op'ing.
1411
- if (this.isHalt && fieldName === 'after') {
1412
- throw new Error('haltState.debug.after is not supported: halt is terminal, so there is '
1413
- + 'no iteration-after-halt for an after-fire to anchor on. Use '
1414
- + '{ before: true } to pause on halt entry.');
1415
- }
1416
1512
  if (filter === true)
1417
1513
  return;
1418
- // haltState has no own transitions; symbol-list filters on `before` are
1419
- // silent no-ops at the engine level (spec §8.6), so accept any list shape.
1420
- if (this.isHalt)
1421
- return;
1422
1514
  for (const sym of filter) {
1423
1515
  if (sym !== ifOtherSymbol && !__classPrivateFieldGet$1(this, _State_symbolToDataMap, "f").has(sym)) {
1424
1516
  throw new Error(`State.debug.${fieldName}: symbol is not a transition key of this state `
@@ -1437,16 +1529,40 @@ class State {
1437
1529
  return ifOtherSymbol;
1438
1530
  }
1439
1531
  getCommand(symbol) {
1440
- if (__classPrivateFieldGet$1(this, _State_symbolToDataMap, "f").has(symbol)) {
1441
- return __classPrivateFieldGet$1(this, _State_symbolToDataMap, "f").get(symbol).command;
1442
- }
1443
- throw new Error(`No command for symbol at state named ${__classPrivateFieldGet$1(this, _State_name, "f")}`);
1532
+ return __classPrivateFieldGet$1(this, _State_instances, "m", _State_getEntry).call(this, symbol).command;
1444
1533
  }
1445
1534
  getNextState(symbol) {
1446
- if (__classPrivateFieldGet$1(this, _State_symbolToDataMap, "f").has(symbol)) {
1447
- return __classPrivateFieldGet$1(this, _State_symbolToDataMap, "f").get(symbol).nextState;
1448
- }
1449
- throw new Error(`No nextState for symbol at state named ${__classPrivateFieldGet$1(this, _State_id, "f")}`);
1535
+ return __classPrivateFieldGet$1(this, _State_instances, "m", _State_getEntry).call(this, symbol).nextState;
1536
+ }
1537
+ /**
1538
+ * Like `getNextState`, but also returns the matched Symbol and its index
1539
+ * in this State's transition declaration order (= the `K` in `toGraph`'s
1540
+ * `${stateId}.${K}` transition ids). Used by `TuringMachine.runStepByStep`
1541
+ * to populate `MachineState.matchedTransition` for #205 — exposes which
1542
+ * transition fired so consumers (UIs, log tools, coverage maps) can
1543
+ * resolve the firing edge without re-deriving from `(source, nextState)`,
1544
+ * which is ambiguous when multiple transitions on the same source go to
1545
+ * the same destination.
1546
+ *
1547
+ * Throws (matching `getNextState` / `getCommand`) when no entry exists for
1548
+ * the symbol. For wrappers (states produced by `withOverriddenHaltState`):
1549
+ * the symbol-to-data map is shared with the bare via `bareState`, so the
1550
+ * returned `ix` is a valid position into BOTH the wrapper's and the
1551
+ * bare's transition iteration order — they're the same map.
1552
+ */
1553
+ getMatchedTransition(symbol) {
1554
+ const entry = __classPrivateFieldGet$1(this, _State_instances, "m", _State_getEntry).call(this, symbol);
1555
+ // Iteration order on a Map is insertion order; index lookup is O(N),
1556
+ // acceptable since this fires at most once per iter and N (transitions
1557
+ // per state) is typically tiny. If hot-path measurement ever flags it,
1558
+ // cache as `#symbolToIxMap` mirror.
1559
+ let ix = 0;
1560
+ for (const key of __classPrivateFieldGet$1(this, _State_symbolToDataMap, "f").keys()) {
1561
+ if (key === symbol)
1562
+ break;
1563
+ ix += 1;
1564
+ }
1565
+ return { nextState: entry.nextState, matchedSymbol: symbol, ix };
1450
1566
  }
1451
1567
  withOverriddenHaltState(overriddenHaltState) {
1452
1568
  // Unwrap `this` if it's itself a wrapper — the chain's inner overrides
@@ -1509,7 +1625,13 @@ class State {
1509
1625
  * read what they need. Adding fields here is a deliberate decision —
1510
1626
  * each adds to the implicit contract sibling modules can rely on.
1511
1627
  */
1512
- [STATE_INTERNAL]() {
1628
+ [(_State_getEntry = function _State_getEntry(symbol) {
1629
+ const entry = __classPrivateFieldGet$1(this, _State_symbolToDataMap, "f").get(symbol);
1630
+ if (entry === undefined) {
1631
+ throw new Error(`No transition for symbol at state named ${__classPrivateFieldGet$1(this, _State_name, "f")}`);
1632
+ }
1633
+ return entry;
1634
+ }, STATE_INTERNAL)]() {
1513
1635
  // Aliasing `this` so the nested object-literal getters/setters below
1514
1636
  // can read/write the enclosing State's private fields — getters in an
1515
1637
  // object literal can't be arrow functions, so the standard arrow-
@@ -1678,14 +1800,43 @@ class TuringMachine {
1678
1800
  i += 1;
1679
1801
  const symbol = state.getSymbol(__classPrivateFieldGet(this, _TuringMachine_tapeBlock, "f"));
1680
1802
  const command = state.getCommand(symbol);
1681
- let nextState = state.getNextState(symbol).ref;
1803
+ const matched = state.getMatchedTransition(symbol);
1804
+ let nextState = matched.nextState.ref;
1805
+ // For wrapper-entry iters, the wrapper's transitions in `toGraph`
1806
+ // are empty (wrappers delegate to the bare via shared
1807
+ // `#symbolToDataMap`); the resolvable transition id lives under
1808
+ // the bare's stateId. `bareState` is non-null only when `state`
1809
+ // is a wrapper produced by `withOverriddenHaltState`. Accessed
1810
+ // via the STATE_INTERNAL package-private view (same pattern
1811
+ // `utilities/stateGraph.ts` uses) to avoid widening the public
1812
+ // State API for this internal need.
1813
+ const stateInternal = state[STATE_INTERNAL]();
1814
+ const resolvableStateId = stateInternal.bareState?.id ?? state.id;
1815
+ const matchedTransition = {
1816
+ id: `${resolvableStateId}.${matched.ix}`,
1817
+ matchKinds: __classPrivateFieldGet(this, _TuringMachine_tapeBlock, "f").patternKinds(matched.matchedSymbol),
1818
+ };
1682
1819
  try {
1683
1820
  // Both before and after refer to THIS iter (#119 / v6.0.0).
1684
1821
  // The halting iter's after-fire just rides along on the iter's
1685
1822
  // own yield — no post-loop drain needed.
1686
- const beforeMatch = matchFilter(state.debug?.before, symbol)
1687
- || (nextState.isHalt && nextState.debug?.before === true);
1688
- const afterMatch = matchFilter(state.debug?.after, symbol);
1823
+ //
1824
+ // #207: `haltState.debug` is now a boolean, and pauses on the
1825
+ // halt-triggering iter's AFTER side (not before). The previous
1826
+ // before-side check (`nextState.debug?.before === true`) was
1827
+ // "early-warning" timing — the user paused before the halt-bound
1828
+ // transition fired, then had to mentally re-derive what would
1829
+ // happen. Now the pause anchors post-step (after the iter's own
1830
+ // after-pause if armed), so consumers see the just-fired halt-
1831
+ // bound transition + diagram cursor still on the triggering state.
1832
+ //
1833
+ // `state` here is always non-halt (halt is terminal — the run
1834
+ // loop never iterates with state === haltState), so `state.debug`
1835
+ // is always `DebugConfig` at runtime. The public getter's return
1836
+ // type matches that.
1837
+ const beforeMatch = matchFilter(state.debug?.before, symbol);
1838
+ const afterMatch = matchFilter(state.debug?.after, symbol)
1839
+ || (nextState === haltState && haltState.debug);
1689
1840
  const nextStateForYield = nextState.isHalt && stack.length
1690
1841
  ? stack.slice(-1)[0]
1691
1842
  : nextState;
@@ -1708,6 +1859,7 @@ class TuringMachine {
1708
1859
  }),
1709
1860
  movements: command.tapesCommands.map((tapeCommand) => tapeCommand.movement),
1710
1861
  nextState: nextStateForYield,
1862
+ matchedTransition,
1711
1863
  };
1712
1864
  if (beforeMatch || afterMatch) {
1713
1865
  const dbg = {};
@@ -1851,6 +2003,8 @@ function unescapeMermaidLabel(s) {
1851
2003
  const n = Number.parseInt(hex, 16);
1852
2004
  return n <= 0xFFFF ? String.fromCharCode(n) : String.fromCodePoint(n);
1853
2005
  }
2006
+ /* c8 ignore next 2 — defensive: the regex shape guarantees one of
2007
+ named / dec / hex is always set, so this fallback is unreachable. */
1854
2008
  return match;
1855
2009
  }
1856
2010
  }
package/dist/index.mjs CHANGED
@@ -653,6 +653,46 @@ class TapeBlock {
653
653
  .every((everySymbol, ix) => (everySymbol === ifOtherSymbol
654
654
  || everySymbol === currentSymbols[ix])))) ?? false;
655
655
  }
656
+ /**
657
+ * For a Symbol returned by `this.symbol([...])` (or the catch-all
658
+ * `ifOtherSymbol`), returns the per-tape match kind for the
659
+ * **alternative that actually matched** given `currentSymbols`:
660
+ * `'wildcard'` if that tape position was `ifOtherSymbol` in the winning
661
+ * alternative, `'literal'` otherwise. Length always equals the tape
662
+ * count.
663
+ *
664
+ * Used by `TuringMachine.runStepByStep` to populate
665
+ * `MachineState.matchedTransition.matchKinds` for #205. The "winning
666
+ * alternative" disambiguation matters for alternations like
667
+ * `[[ifOtherSymbol, 'c'], ['a', 'b']]` — different alternatives can
668
+ * have different per-tape kinds, and only the alternative that matched
669
+ * the current head symbols is meaningful.
670
+ *
671
+ * - `ifOtherSymbol` (the State's catch-all transition fired): all
672
+ * positions are `'wildcard'`.
673
+ * - Symbol with patternList: find the first alternative that matches
674
+ * `currentSymbols` (same predicate as `isMatched`), return its
675
+ * per-position kinds.
676
+ * - Symbol with no winning alternative under the given `currentSymbols`
677
+ * (defensive — shouldn't happen if the caller resolved the Symbol via
678
+ * the State's normal matching): fall back to all `'literal'`.
679
+ */
680
+ patternKinds(symbol, currentSymbols = this.currentSymbols) {
681
+ const tapeCount = __classPrivateFieldGet$2(this, _TapeBlock_tapes, "f").length;
682
+ if (symbol === ifOtherSymbol) {
683
+ return Array.from({ length: tapeCount }, () => 'wildcard');
684
+ }
685
+ const patternList = __classPrivateFieldGet$2(this, _TapeBlock_symbolToPatternListMap, "f").get(symbol);
686
+ if (patternList === undefined) {
687
+ return Array.from({ length: tapeCount }, () => 'literal');
688
+ }
689
+ const winning = patternList.find((pattern) => (pattern.every((everySymbol, ix) => (everySymbol === ifOtherSymbol
690
+ || everySymbol === currentSymbols[ix]))));
691
+ if (winning === undefined) {
692
+ return Array.from({ length: tapeCount }, () => 'literal');
693
+ }
694
+ return winning.map((everySymbol) => (everySymbol === ifOtherSymbol ? 'wildcard' : 'literal'));
695
+ }
656
696
  replaceTape(tape, tapeIx = 0) {
657
697
  if (__classPrivateFieldGet$2(this, _TapeBlock_tapes, "f")[tapeIx] == null) {
658
698
  throw new Error('invalid tapeIx');
@@ -787,7 +827,14 @@ function toGraph(initialState, tapeBlock) {
787
827
  movement: decodeMovement(tc.movement.description),
788
828
  })),
789
829
  nextStateId: targetInternal.id,
790
- id: `${stateInternal.id}-${patternIx}`,
830
+ // Transition id format: `${stateId}.${transitionIx}` (#205).
831
+ // Matches `TuringMachine.runStepByStep`'s `MachineState.
832
+ // matchedTransition.id` so consumers can do
833
+ // `graph.nodes[stateId].transitions.find(t => t.id === id)`.
834
+ // Was `${stateId}-${ix}` pre-#205 — the `.` separator avoids
835
+ // the hyphen reading as a minus sign next to negative halt-
836
+ // marker ids in adjacent contexts.
837
+ id: `${stateInternal.id}.${patternIx}`,
791
838
  });
792
839
  queue.push(target);
793
840
  patternIx += 1;
@@ -825,6 +872,9 @@ function toGraph(initialState, tapeBlock) {
825
872
  // `nodes[id]` is always populated for `id` that the BFS reached, so
826
873
  // a defensive `!node` check would be dead. `isHalt` / `isWrapper`
827
874
  // are real boundaries — both stop reach-set expansion.
875
+ /* c8 ignore next 3 — defensive: the push site below already filters
876
+ halt/wrapper targets, and the initial push is always a bare, so
877
+ this branch is unreachable in practice. */
828
878
  if (node.isHalt || node.isWrapper) {
829
879
  continue;
830
880
  }
@@ -1069,7 +1119,8 @@ function fromGraph(graph) {
1069
1119
  * instance + per-pattern Symbol references for breakpoint setup (#195).
1070
1120
  *
1071
1121
  * **Positional alignment contract.** For any `GraphTransition` whose id
1072
- * is `${N}-${K}`, `result.get(N)!.transitionSymbols[K]` is the Symbol
1122
+ * is `${N}.${K}` (#205 changed the separator from `-` to `.`),
1123
+ * `result.get(N)!.transitionSymbols[K]` is the Symbol
1073
1124
  * the transition fires on (reference equality, not structural). The K-th
1074
1125
  * entry is the K-th key from the source State's `#symbolToDataMap` in
1075
1126
  * insertion order, including `ifOtherSymbol` when the user wrote one.
@@ -1080,7 +1131,7 @@ function fromGraph(graph) {
1080
1131
  * when a transition's `nextState` is an unresolved `Reference` (it
1081
1132
  * `continue`s without pushing the GraphTransition). In that case
1082
1133
  * `transitionSymbols[K]` is still set to the K-th Map key, but no
1083
- * `Graph.nodes[N].transitions` entry exists with id `${N}-${K}`. Sparse
1134
+ * `Graph.nodes[N].transitions` entry exists with id `${N}.${K}`. Sparse
1084
1135
  * on the Graph side, dense on the `transitionSymbols` side — same
1085
1136
  * indexing.
1086
1137
  *
@@ -1168,7 +1219,7 @@ function collectStates(initialState, tapeBlock) {
1168
1219
  }
1169
1220
  // Regular or bare State — enumerate `#symbolToDataMap.keys()` for
1170
1221
  // the patternIx alignment. The K-th key is the Symbol that
1171
- // `${id}-${K}` GraphTransition fires on (positional contract).
1222
+ // `${id}.${K}` GraphTransition fires on (positional contract).
1172
1223
  const state = stateById.get(id);
1173
1224
  const transitionSymbols = [...state[STATE_INTERNAL]().symbolToDataMap.keys()];
1174
1225
  result.set(id, { state, transitionSymbols });
@@ -1194,7 +1245,7 @@ var __classPrivateFieldGet$1 = (undefined && undefined.__classPrivateFieldGet) |
1194
1245
  if (typeof state === "function" ? receiver !== state || !f : !state.has(receiver)) throw new TypeError("Cannot read private member from an object whose class did not declare it");
1195
1246
  return kind === "m" ? f : kind === "a" ? f.call(receiver) : f ? f.value : state.get(receiver);
1196
1247
  };
1197
- var _DebugConfig_ownerState, _DebugConfig_before, _DebugConfig_after, _a, _State_wrapperCache, _State_id, _State_name, _State_overriddenHaltState, _State_bareState, _State_symbolToDataMap, _State_debugRef, _State_tags;
1248
+ var _DebugConfig_ownerState, _DebugConfig_before, _DebugConfig_after, _State_instances, _a, _State_wrapperCache, _State_id, _State_name, _State_overriddenHaltState, _State_bareState, _State_symbolToDataMap, _State_debugRef, _State_haltDebug, _State_tags, _State_getEntry;
1198
1249
  const ifOtherSymbol = Symbol('other symbol');
1199
1250
  // Module-private symbol used by DebugConfig setters to call State's validator
1200
1251
  // without exposing the validator on the public surface.
@@ -1260,6 +1311,7 @@ class DebugConfig {
1260
1311
  _DebugConfig_ownerState = new WeakMap(), _DebugConfig_before = new WeakMap(), _DebugConfig_after = new WeakMap();
1261
1312
  class State {
1262
1313
  constructor(stateDefinition = null, name) {
1314
+ _State_instances.add(this);
1263
1315
  _State_id.set(this, id(this));
1264
1316
  // Not `readonly` because `withOverriddenHaltState` and `fromGraph` set the
1265
1317
  // composed name on a no-arg `new State()` to bypass the constructor's
@@ -1277,6 +1329,14 @@ class State {
1277
1329
  // Note: toGraph / fromGraph deliberately do not serialize debug — debug is
1278
1330
  // a runtime concern, not part of the structural graph.
1279
1331
  _State_debugRef.set(this, { current: null });
1332
+ // Storage for `haltState.debug` (#207). haltState is a singleton terminal
1333
+ // state — it has no iter of its own, so the per-side `{ before, after }`
1334
+ // DebugConfig shape doesn't model anything meaningful for it. Instead the
1335
+ // halt breakpoint is a single boolean ("enabled / disabled"). The pause
1336
+ // anchors on the iter whose transition LEADS to halt, fired at end-of-iter
1337
+ // (after that iter's own after-pause if armed). Only used when `isHalt`;
1338
+ // ignored on every other State (whose `#debugRef` flow is unchanged).
1339
+ _State_haltDebug.set(this, false);
1280
1340
  // Out-of-band tags applied to this State (#186). Tags are visualization
1281
1341
  // and debugger-tooling metadata — they don't affect runtime transition
1282
1342
  // lookup or `equivalentOn` comparisons. Stored as a Set for de-duplication;
@@ -1347,6 +1407,17 @@ class State {
1347
1407
  return this;
1348
1408
  }
1349
1409
  get debug() {
1410
+ // haltState (#207): the canonical access path is the `haltState` singleton
1411
+ // export, which is typed `HaltState` — its `debug` getter is narrowed to
1412
+ // `boolean`. Generic `State` references statically see `DebugConfig` and
1413
+ // (in practice) never refer to haltState — the run loop's `state` is
1414
+ // never haltState because halt is terminal and doesn't iterate. The cast
1415
+ // below makes the runtime boolean return type-compatible with the
1416
+ // declared `DebugConfig` for any rare caller that holds a State
1417
+ // reference happening to be haltState.
1418
+ if (this.isHalt) {
1419
+ return __classPrivateFieldGet$1(this, _State_haltDebug, "f");
1420
+ }
1350
1421
  // Lazy-init: `state.debug` is never null at read time, so chained writes
1351
1422
  // like `state.debug.before = true` work on a fresh state without a prior
1352
1423
  // whole-object assignment. The setter still accepts `null` to reset the
@@ -1357,16 +1428,47 @@ class State {
1357
1428
  }
1358
1429
  return __classPrivateFieldGet$1(this, _State_debugRef, "f").current;
1359
1430
  }
1431
+ // TS signature: non-halt callers (generic `State` reference) get the
1432
+ // `DebugConfig | object | null` surface; boolean is rejected statically.
1433
+ // The `HaltState` typed alias on the singleton export overrides this to
1434
+ // `boolean | null` for the canonical halt access path. Runtime checks
1435
+ // below are defensive against type-bypass / mixed-source callers.
1360
1436
  set debug(value) {
1361
- if (value === null) {
1437
+ // Defensive runtime cast: TS signature excludes boolean for the generic
1438
+ // State surface, but haltState (via the HaltState alias) DOES accept
1439
+ // boolean, and the runtime needs to handle it for the singleton path.
1440
+ const v = value;
1441
+ // haltState (#207): only `boolean | null` is accepted. `null` aliases
1442
+ // to `false` (reset). Any object-shaped write throws at write-time so
1443
+ // misuse surfaces immediately rather than silently no-op'ing — the
1444
+ // `{before, after}` shape doesn't model anything meaningful for halt
1445
+ // (no own iter to anchor on; halt is terminal).
1446
+ if (this.isHalt) {
1447
+ if (v === null || typeof v === 'boolean') {
1448
+ __classPrivateFieldSet$1(this, _State_haltDebug, v === true, "f");
1449
+ return;
1450
+ }
1451
+ throw new Error('haltState.debug only accepts boolean (or null to reset). Use '
1452
+ + '`haltState.debug = true` to enable the halt breakpoint, false to '
1453
+ + 'disable. The pause fires after the iter whose transition leads to '
1454
+ + 'halt (post-iter, before halt processing).');
1455
+ }
1456
+ // Non-halt states: boolean writes are rejected — the per-side
1457
+ // `{before, after}` granularity is the contract. A boolean shortcut
1458
+ // would hide the asymmetry between before / after.
1459
+ if (typeof v === 'boolean') {
1460
+ throw new Error('state.debug only accepts a DebugConfig or `{ before, after }` object '
1461
+ + '(or null to reset). Boolean assignment is reserved for `haltState`.');
1462
+ }
1463
+ if (v === null) {
1362
1464
  __classPrivateFieldGet$1(this, _State_debugRef, "f").current = null;
1363
1465
  return;
1364
1466
  }
1365
- if (value instanceof DebugConfig) {
1366
- __classPrivateFieldGet$1(this, _State_debugRef, "f").current = value;
1467
+ if (v instanceof DebugConfig) {
1468
+ __classPrivateFieldGet$1(this, _State_debugRef, "f").current = v;
1367
1469
  return;
1368
1470
  }
1369
- __classPrivateFieldGet$1(this, _State_debugRef, "f").current = new DebugConfig(this, value);
1471
+ __classPrivateFieldGet$1(this, _State_debugRef, "f").current = new DebugConfig(this, v);
1370
1472
  }
1371
1473
  /**
1372
1474
  * Add one or more tags to this State (#186). Tags are out-of-band metadata
@@ -1398,25 +1500,15 @@ class State {
1398
1500
  get tags() {
1399
1501
  return Object.freeze([...__classPrivateFieldGet$1(this, _State_tags, "f")]);
1400
1502
  }
1401
- /** @internal — invoked by DebugConfig setters via module-private symbol. */
1402
- [(_State_id = new WeakMap(), _State_name = new WeakMap(), _State_overriddenHaltState = new WeakMap(), _State_bareState = new WeakMap(), _State_symbolToDataMap = new WeakMap(), _State_debugRef = new WeakMap(), _State_tags = new WeakMap(), validateDebugFilter)](fieldName, filter) {
1503
+ /** @internal — invoked by DebugConfig setters via module-private symbol.
1504
+ * Per #207, haltState no longer flows through DebugConfig (its `debug`
1505
+ * setter rejects object writes before construction), so the validator
1506
+ * only sees non-halt states here. */
1507
+ [(_State_id = new WeakMap(), _State_name = new WeakMap(), _State_overriddenHaltState = new WeakMap(), _State_bareState = new WeakMap(), _State_symbolToDataMap = new WeakMap(), _State_debugRef = new WeakMap(), _State_haltDebug = new WeakMap(), _State_tags = new WeakMap(), _State_instances = new WeakSet(), validateDebugFilter)](fieldName, filter) {
1403
1508
  if (filter === undefined)
1404
1509
  return;
1405
- // #108 part 2: `.after` on haltState has no semantic anchor — halt is
1406
- // terminal, so there is no iteration-after-halt for an after-fire to
1407
- // attach to. Reject any truthy assignment (true OR list) at write time
1408
- // so misuse surfaces immediately rather than silently no-op'ing.
1409
- if (this.isHalt && fieldName === 'after') {
1410
- throw new Error('haltState.debug.after is not supported: halt is terminal, so there is '
1411
- + 'no iteration-after-halt for an after-fire to anchor on. Use '
1412
- + '{ before: true } to pause on halt entry.');
1413
- }
1414
1510
  if (filter === true)
1415
1511
  return;
1416
- // haltState has no own transitions; symbol-list filters on `before` are
1417
- // silent no-ops at the engine level (spec §8.6), so accept any list shape.
1418
- if (this.isHalt)
1419
- return;
1420
1512
  for (const sym of filter) {
1421
1513
  if (sym !== ifOtherSymbol && !__classPrivateFieldGet$1(this, _State_symbolToDataMap, "f").has(sym)) {
1422
1514
  throw new Error(`State.debug.${fieldName}: symbol is not a transition key of this state `
@@ -1435,16 +1527,40 @@ class State {
1435
1527
  return ifOtherSymbol;
1436
1528
  }
1437
1529
  getCommand(symbol) {
1438
- if (__classPrivateFieldGet$1(this, _State_symbolToDataMap, "f").has(symbol)) {
1439
- return __classPrivateFieldGet$1(this, _State_symbolToDataMap, "f").get(symbol).command;
1440
- }
1441
- throw new Error(`No command for symbol at state named ${__classPrivateFieldGet$1(this, _State_name, "f")}`);
1530
+ return __classPrivateFieldGet$1(this, _State_instances, "m", _State_getEntry).call(this, symbol).command;
1442
1531
  }
1443
1532
  getNextState(symbol) {
1444
- if (__classPrivateFieldGet$1(this, _State_symbolToDataMap, "f").has(symbol)) {
1445
- return __classPrivateFieldGet$1(this, _State_symbolToDataMap, "f").get(symbol).nextState;
1446
- }
1447
- throw new Error(`No nextState for symbol at state named ${__classPrivateFieldGet$1(this, _State_id, "f")}`);
1533
+ return __classPrivateFieldGet$1(this, _State_instances, "m", _State_getEntry).call(this, symbol).nextState;
1534
+ }
1535
+ /**
1536
+ * Like `getNextState`, but also returns the matched Symbol and its index
1537
+ * in this State's transition declaration order (= the `K` in `toGraph`'s
1538
+ * `${stateId}.${K}` transition ids). Used by `TuringMachine.runStepByStep`
1539
+ * to populate `MachineState.matchedTransition` for #205 — exposes which
1540
+ * transition fired so consumers (UIs, log tools, coverage maps) can
1541
+ * resolve the firing edge without re-deriving from `(source, nextState)`,
1542
+ * which is ambiguous when multiple transitions on the same source go to
1543
+ * the same destination.
1544
+ *
1545
+ * Throws (matching `getNextState` / `getCommand`) when no entry exists for
1546
+ * the symbol. For wrappers (states produced by `withOverriddenHaltState`):
1547
+ * the symbol-to-data map is shared with the bare via `bareState`, so the
1548
+ * returned `ix` is a valid position into BOTH the wrapper's and the
1549
+ * bare's transition iteration order — they're the same map.
1550
+ */
1551
+ getMatchedTransition(symbol) {
1552
+ const entry = __classPrivateFieldGet$1(this, _State_instances, "m", _State_getEntry).call(this, symbol);
1553
+ // Iteration order on a Map is insertion order; index lookup is O(N),
1554
+ // acceptable since this fires at most once per iter and N (transitions
1555
+ // per state) is typically tiny. If hot-path measurement ever flags it,
1556
+ // cache as `#symbolToIxMap` mirror.
1557
+ let ix = 0;
1558
+ for (const key of __classPrivateFieldGet$1(this, _State_symbolToDataMap, "f").keys()) {
1559
+ if (key === symbol)
1560
+ break;
1561
+ ix += 1;
1562
+ }
1563
+ return { nextState: entry.nextState, matchedSymbol: symbol, ix };
1448
1564
  }
1449
1565
  withOverriddenHaltState(overriddenHaltState) {
1450
1566
  // Unwrap `this` if it's itself a wrapper — the chain's inner overrides
@@ -1507,7 +1623,13 @@ class State {
1507
1623
  * read what they need. Adding fields here is a deliberate decision —
1508
1624
  * each adds to the implicit contract sibling modules can rely on.
1509
1625
  */
1510
- [STATE_INTERNAL]() {
1626
+ [(_State_getEntry = function _State_getEntry(symbol) {
1627
+ const entry = __classPrivateFieldGet$1(this, _State_symbolToDataMap, "f").get(symbol);
1628
+ if (entry === undefined) {
1629
+ throw new Error(`No transition for symbol at state named ${__classPrivateFieldGet$1(this, _State_name, "f")}`);
1630
+ }
1631
+ return entry;
1632
+ }, STATE_INTERNAL)]() {
1511
1633
  // Aliasing `this` so the nested object-literal getters/setters below
1512
1634
  // can read/write the enclosing State's private fields — getters in an
1513
1635
  // object literal can't be arrow functions, so the standard arrow-
@@ -1676,14 +1798,43 @@ class TuringMachine {
1676
1798
  i += 1;
1677
1799
  const symbol = state.getSymbol(__classPrivateFieldGet(this, _TuringMachine_tapeBlock, "f"));
1678
1800
  const command = state.getCommand(symbol);
1679
- let nextState = state.getNextState(symbol).ref;
1801
+ const matched = state.getMatchedTransition(symbol);
1802
+ let nextState = matched.nextState.ref;
1803
+ // For wrapper-entry iters, the wrapper's transitions in `toGraph`
1804
+ // are empty (wrappers delegate to the bare via shared
1805
+ // `#symbolToDataMap`); the resolvable transition id lives under
1806
+ // the bare's stateId. `bareState` is non-null only when `state`
1807
+ // is a wrapper produced by `withOverriddenHaltState`. Accessed
1808
+ // via the STATE_INTERNAL package-private view (same pattern
1809
+ // `utilities/stateGraph.ts` uses) to avoid widening the public
1810
+ // State API for this internal need.
1811
+ const stateInternal = state[STATE_INTERNAL]();
1812
+ const resolvableStateId = stateInternal.bareState?.id ?? state.id;
1813
+ const matchedTransition = {
1814
+ id: `${resolvableStateId}.${matched.ix}`,
1815
+ matchKinds: __classPrivateFieldGet(this, _TuringMachine_tapeBlock, "f").patternKinds(matched.matchedSymbol),
1816
+ };
1680
1817
  try {
1681
1818
  // Both before and after refer to THIS iter (#119 / v6.0.0).
1682
1819
  // The halting iter's after-fire just rides along on the iter's
1683
1820
  // own yield — no post-loop drain needed.
1684
- const beforeMatch = matchFilter(state.debug?.before, symbol)
1685
- || (nextState.isHalt && nextState.debug?.before === true);
1686
- const afterMatch = matchFilter(state.debug?.after, symbol);
1821
+ //
1822
+ // #207: `haltState.debug` is now a boolean, and pauses on the
1823
+ // halt-triggering iter's AFTER side (not before). The previous
1824
+ // before-side check (`nextState.debug?.before === true`) was
1825
+ // "early-warning" timing — the user paused before the halt-bound
1826
+ // transition fired, then had to mentally re-derive what would
1827
+ // happen. Now the pause anchors post-step (after the iter's own
1828
+ // after-pause if armed), so consumers see the just-fired halt-
1829
+ // bound transition + diagram cursor still on the triggering state.
1830
+ //
1831
+ // `state` here is always non-halt (halt is terminal — the run
1832
+ // loop never iterates with state === haltState), so `state.debug`
1833
+ // is always `DebugConfig` at runtime. The public getter's return
1834
+ // type matches that.
1835
+ const beforeMatch = matchFilter(state.debug?.before, symbol);
1836
+ const afterMatch = matchFilter(state.debug?.after, symbol)
1837
+ || (nextState === haltState && haltState.debug);
1687
1838
  const nextStateForYield = nextState.isHalt && stack.length
1688
1839
  ? stack.slice(-1)[0]
1689
1840
  : nextState;
@@ -1706,6 +1857,7 @@ class TuringMachine {
1706
1857
  }),
1707
1858
  movements: command.tapesCommands.map((tapeCommand) => tapeCommand.movement),
1708
1859
  nextState: nextStateForYield,
1860
+ matchedTransition,
1709
1861
  };
1710
1862
  if (beforeMatch || afterMatch) {
1711
1863
  const dbg = {};
@@ -1849,6 +2001,8 @@ function unescapeMermaidLabel(s) {
1849
2001
  const n = Number.parseInt(hex, 16);
1850
2002
  return n <= 0xFFFF ? String.fromCharCode(n) : String.fromCodePoint(n);
1851
2003
  }
2004
+ /* c8 ignore next 2 — defensive: the regex shape guarantees one of
2005
+ named / dec / hex is always set, so this fallback is unreachable. */
1852
2006
  return match;
1853
2007
  }
1854
2008
  }
@@ -60,7 +60,8 @@ export type StateMap = Map<number, StateMapEntry>;
60
60
  * instance + per-pattern Symbol references for breakpoint setup (#195).
61
61
  *
62
62
  * **Positional alignment contract.** For any `GraphTransition` whose id
63
- * is `${N}-${K}`, `result.get(N)!.transitionSymbols[K]` is the Symbol
63
+ * is `${N}.${K}` (#205 changed the separator from `-` to `.`),
64
+ * `result.get(N)!.transitionSymbols[K]` is the Symbol
64
65
  * the transition fires on (reference equality, not structural). The K-th
65
66
  * entry is the K-th key from the source State's `#symbolToDataMap` in
66
67
  * insertion order, including `ifOtherSymbol` when the user wrote one.
@@ -71,7 +72,7 @@ export type StateMap = Map<number, StateMapEntry>;
71
72
  * when a transition's `nextState` is an unresolved `Reference` (it
72
73
  * `continue`s without pushing the GraphTransition). In that case
73
74
  * `transitionSymbols[K]` is still set to the K-th Map key, but no
74
- * `Graph.nodes[N].transitions` entry exists with id `${N}-${K}`. Sparse
75
+ * `Graph.nodes[N].transitions` entry exists with id `${N}.${K}`. Sparse
75
76
  * on the Graph side, dense on the `transitionSymbols` side — same
76
77
  * indexing.
77
78
  *
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@turing-machine-js/machine",
3
- "version": "7.0.0-alpha.4",
3
+ "version": "7.0.0-alpha.5",
4
4
  "description": "A convenient Turing machine",
5
5
  "engines": {
6
6
  "npm": ">=7.0.0"
@@ -38,5 +38,5 @@
38
38
  "default": "./dist/index.mjs"
39
39
  }
40
40
  },
41
- "gitHead": "0183c78ea7f7212ce983e99c846fc8e1fec5e56b"
41
+ "gitHead": "4838b823c5b93f74a783d8652746787436b431e4"
42
42
  }