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

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,66 @@ 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.6] - 2026-05-28
8
+
9
+ Sixth v7 pre-release. Lands the debugger step controls ([#102](https://github.com/mellonis/turing-machine-js/issues/102), [PR #214](https://github.com/mellonis/turing-machine-js/pull/214)) and, in doing so, **reshapes the entire debug surface** into three clearly-separated entry points: `run()` is now pure synchronous execution (no callbacks), `runStepByStep` is the minimal pure-iteration primitive (no breakpoint detection), and a new **`DebugSession`** class owns all interactive debugging — events, step controls, throttle, and pause coordination. With #102 the **v7 milestone is feature-complete**; the stable v7.0.0 cut is the only remaining step. 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.6`.
12
+
13
+ ### Added
14
+
15
+ - **`DebugSession`** ([#102](https://github.com/mellonis/turing-machine-js/issues/102)) — `new DebugSession(machine, { initialState })`. The sole interactive-debugging surface (there is no `debugRun()` factory on `TuringMachine`). Emits `pause` / `step` / `iter` / `halt` events via `session.on(event, listener)`; `iter` listeners are **awaited** (the per-iter throttle / coordination point), `pause` / `step` / `halt` are fire-and-forget. Drive controls: `continue()`, `stepIn()`, `stepOver()`, `stepOut()`, external `pause()`, `stop()`, and `setRunInterval(ms)` for a per-iter throttle. Breakpoint detection (`state.debug` filters, `haltState.debug`) lives entirely here — `runStepByStep` no longer evaluates it. Concurrent sessions on one machine are rejected with a clear error (the underlying `TapeBlock` lock is single-active-run).
16
+
17
+ - **`PauseInfo` / `PausedMachineState`** ([#102](https://github.com/mellonis/turing-machine-js/issues/102)) — the `pause` event payload is a `MachineState & { pause: PauseInfo }` where `PauseInfo = { side: 'before' | 'after'; cause: 'breakpoint' | 'step' | 'manual' }`. Exactly one side per descriptor — `DebugSession` dispatches the two timings as separate `pause` events, so the v6 "both timings on one yield" shape no longer exists. `cause` precedence when an iter satisfies more than one trigger: `breakpoint > step > manual`; `'step'` / `'manual'` only ever fire on the `'before'` side.
18
+
19
+ - **Depth-based step controls mirroring Chrome DevTools** ([#102](https://github.com/mellonis/turing-machine-js/issues/102)) — `stepIn` = next iter at any depth; `stepOver` = next iter at `depth ≤ click-time depth` (nested `.withOverriddenHaltState(...)` subroutines run to completion, pause back at the starting level); `stepOut` = next iter at `depth < click-time depth` (current subroutine frame popped; throws at depth 0, mirroring DevTools' top-frame Step Out).
20
+
21
+ - **`matchFilter` export** ([#102](https://github.com/mellonis/turing-machine-js/issues/102), `@internal`) — predicate evaluating a `DebugConfig` side-filter against a matched symbol. Exported for sibling-module use in `DebugSession`; NOT re-exported from the package root.
22
+
23
+ ### Changed
24
+
25
+ - **`run()` is synchronous and callback-free** ([#102](https://github.com/mellonis/turing-machine-js/issues/102)) — signature is now `run({ initialState, stepsLimit? }): void` (was `async … : Promise<void>` with `onPause` / `onStep` / `onIter` callbacks). Pure execution, no debug overhead. Reverses v4's `run` → `async run` change now that all callback machinery has moved to `DebugSession`. **Breaking** for callers awaiting `run()` or passing callbacks — construct a `DebugSession` instead.
26
+
27
+ - **`runStepByStep` is the minimal pure-iteration primitive** ([#102](https://github.com/mellonis/turing-machine-js/issues/102)) — it advances the machine and yields a plain `MachineState`; it evaluates no `state.debug` filters and stamps no pause field. All breakpoint detection moved to `DebugSession`. **Breaking** for consumers that read a pause / breakpoint field off raw yields.
28
+
29
+ - **`haltState.debug` pause is delivered via `DebugSession`** ([#102](https://github.com/mellonis/turing-machine-js/issues/102)) — still fires on the AFTER side of the halt-triggering iter (alpha.5 semantics: `m.state` is the triggering state, not `haltState`), but now arrives as a `pause` event with `{ side: 'after', cause: 'breakpoint' }` instead of a `debugBreak` field on the yield.
30
+
31
+ ### Removed
32
+
33
+ - **`MachineState.debugBreak`** ([#102](https://github.com/mellonis/turing-machine-js/issues/102)) — the per-yield `{ before?, after?, cause }` descriptor from the v4–alpha.5 debugger is **removed**. Its replacement is the one-sided `m.pause: { side, cause }` carried ONLY on a `DebugSession` `pause` event (`PausedMachineState`); raw `runStepByStep` yields carry no pause field. **Breaking from alpha.5** — consumers reading `m.debugBreak.before` / `.after` / `.cause` must move to a `DebugSession` and read `m.pause.side` / `m.pause.cause`.
34
+
35
+ ## [7.0.0-alpha.5] - 2026-05-25
36
+
37
+ 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`.
38
+
39
+ **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`.
40
+
41
+ ### Added
42
+
43
+ - **`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.
44
+
45
+ - **`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.
46
+
47
+ - **`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.
48
+
49
+ - **`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`.
50
+
51
+ ### Changed
52
+
53
+ - **`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.
54
+
55
+ - **`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`.
56
+
57
+ ⚠️ **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`).
58
+
59
+ - **`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).
60
+
61
+ - **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.
62
+
63
+ ### Docs
64
+
65
+ - ⚠️ **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.
66
+
7
67
  ## [7.0.0-alpha.4] - 2026-05-23
8
68
 
9
69
  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
@@ -307,13 +307,16 @@ The runtime. Owns one `TapeBlock` and drives a state graph until it reaches `hal
307
307
  ```javascript
308
308
  const machine = new TuringMachine({ tapeBlock });
309
309
 
310
- // Run to halt — `run()` returns a Promise<void>:
311
- await machine.run({ initialState, stepsLimit: 1e5 });
310
+ // Run to halt — `run()` is synchronous, returns void:
311
+ machine.run({ initialState, stepsLimit: 1e5 });
312
312
 
313
- // Or step-by-step (useful for visualization / debugging):
313
+ // Or step-by-step (useful for tracing / observation):
314
314
  for (const step of machine.runStepByStep({ initialState })) {
315
315
  console.log(step.state.name, step.currentSymbols, '→', step.nextSymbols, step.movements);
316
316
  }
317
+
318
+ // For interactive debugging (breakpoints, step-in / step-over / step-out,
319
+ // throttle, click-pause), construct a DebugSession — see "Debugging" below.
317
320
  ```
318
321
 
319
322
  Each yielded `step` (`MachineState`) has these fields:
@@ -326,25 +329,52 @@ Each yielded `step` (`MachineState`) has these fields:
326
329
  | `nextSymbols` | `string[]` | per-tape symbols that will be written |
327
330
  | `movements` | `symbol[]` | per-tape head moves (`movements.left/right/stay`) |
328
331
  | `nextState` | `State` | the state that will execute next |
329
- | `debugBreak?` | `{ before?: true, after?: true }` | only set when `state.debug` matched on this iter — see *Debugging breakpoints* below |
332
+ | `matchedTransition` | `{ id: string, matchKinds: ('wildcard'\|'literal')[] }` | the transition the engine picked for this iter — see *Matched transition* below |
330
333
 
331
334
  `stepsLimit` (default `1e5`) guards against runaway loops — exceeding it throws.
332
335
 
333
- #### Choosing between `run()` and `runStepByStep()`
336
+ #### Choosing between `run()`, `runStepByStep()`, and `DebugSession`
334
337
 
335
- Both APIs are first-class `run()` is built on top of `runStepByStep()` (see [TuringMachine.ts](src/classes/TuringMachine.ts)), and both stay supported. They model different consumer needs:
338
+ Three non-overlapping entry points, picked by the consumer's actual need:
336
339
 
337
- | | `run()` | `runStepByStep()` |
338
- |---|---|---|
339
- | Shape | async, returns `Promise<void>` | synchronous generator |
340
- | Iteration timing | owned by the engine | owned by the consumer (`.next()` per step) |
341
- | Lifecycle hooks | dispatches `onStep`, `onPause` (gated by the `debug` master switch) | none yields raw `MachineState` |
342
- | How `state.debug` reaches the consumer | the `onPause` callback (when `debug: true`) | the optional `debugBreak` field on each yield (always populated; consumer decides what to do) |
343
- | Best for | run-to-halt with optional breakpoint UI; anything wanting the v6 per-iter `before step after` callbacks | synchronous test harnesses, visualizers that need tight control over step timing, custom batching |
340
+ | | `run()` | `runStepByStep()` | `new DebugSession(machine, ...)` |
341
+ |---|---|---|---|
342
+ | Shape | sync, returns `void` | sync generator | async session with events |
343
+ | Observation | none | per-iter via `.next()` | event-based (`pause`, `step`, `iter`, `halt`) |
344
+ | Step controls | | | `continue / stepIn / stepOver / stepOut / pause / stop` |
345
+ | Throttle | | | `setRunInterval(ms)` |
346
+ | Breakpoint handling | not consulted runs straight to halt | not consulted yields every iter; `state.debug` is ignored | the only mode that *reacts* fires a `pause` event |
347
+ | Best for | run-to-halt with no observation overhead | tracing, snapshots, test harnesses, custom batching | UI debuggers, IDE extensions, educational demos |
348
+
349
+ **Rule of thumb.** No observation → `run()`. Sync per-iter observation → iterate `runStepByStep()`. Anything interactive (breakpoints, step controls, throttle, click-pause) → construct a `DebugSession`.
350
+
351
+ **Breakpoint detection lives only in `DebugSession`.** `runStepByStep` is the pure-iteration primitive — it advances the machine and reports a minimal `MachineState` with no pause/debug field, ignoring `state.debug` entirely. `DebugSession` is what evaluates the filters and turns a match into a `pause` event. (A consumer that wants its own breakpoint behavior on the raw generator can read `state.debug` itself.)
352
+
353
+ ### Matched transition
354
+
355
+ 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`.
356
+
357
+ ```ts
358
+ matchedTransition: {
359
+ id: string; // resolvable in toGraph
360
+ matchKinds: ('wildcard' | 'literal')[]; // per-tape, length = tape count
361
+ }
362
+ ```
363
+
364
+ - **`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.
365
+
366
+ - **`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.
344
367
 
345
- **Rule of thumb.** If your consumer reads `state.debug` and expects the engine to act on it (pause, fire callbacks), use `run()`. If you want pull-based iteration with full control over timing, use `runStepByStep()` — the `debugBreak` field is still on every yield, so you can inspect breakpoint metadata yourself.
368
+ Example use:
346
369
 
347
- **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).
370
+ ```javascript
371
+ for (const m of machine.runStepByStep({initialState})) {
372
+ const wildcardPositions = m.matchedTransition.matchKinds // per-tape, e.g. ['wildcard', 'literal']
373
+ .map((k, i) => k === 'wildcard' ? i : -1)
374
+ .filter((i) => i >= 0);
375
+ console.log(`step ${m.step}: fired transition ${m.matchedTransition.id} (wildcards at tapes: ${wildcardPositions.join(',') || 'none'})`);
376
+ }
377
+ ```
348
378
 
349
379
  ## Subroutine composition with `withOverriddenHaltState`
350
380
 
@@ -448,9 +478,29 @@ s.tags; // readonly ['subroutine-entry']
448
478
 
449
479
  See [§Diagram conventions § Tags](#tags) for the full emit shape.
450
480
 
451
- ## Debugging breakpoints
481
+ ## Debugging
482
+
483
+ The library exposes interactive debugging through the `DebugSession` class, constructed directly with a `TuringMachine`. Breakpoints are configured on the engine (`state.debug` / `haltState.debug`); the session dispatches them as events plus offers step-in / step-over / step-out controls, an external `pause()`, and a per-iter throttle.
452
484
 
453
- Any `State` can carry a runtime-mutable `debug` config that pauses execution at chosen points.
485
+ ```ts
486
+ import { DebugSession } from '@turing-machine-js/machine';
487
+
488
+ const session = new DebugSession(machine, { initialState });
489
+
490
+ session.on('pause', (m) => {
491
+ console.log(`paused at ${m.state.name}, ${m.pause.side} side, cause: ${m.pause.cause}`);
492
+ session.stepIn(); // or stepOver(), stepOut(), continue(), stop()
493
+ });
494
+ session.on('step', (m) => { /* fires once per iter, mid-iter */ });
495
+ session.on('iter', (m) => { /* fires once per iter, end-of-iter */ });
496
+ session.on('halt', () => { /* fires on natural halt (not on stop()) */ });
497
+
498
+ await session.start(); // resolves on halt or stop()
499
+ ```
500
+
501
+ ### Configuring breakpoints
502
+
503
+ Breakpoints live on the engine, not on the session. The session reads `state.debug` / `haltState.debug` and fires a `pause` event when a filter matches.
454
504
 
455
505
  ```ts
456
506
  import { State, haltState, ifOtherSymbol } from '@turing-machine-js/machine';
@@ -467,38 +517,73 @@ myState.debug = { before: true };
467
517
  myState.debug = { before: [symA] };
468
518
  myState.debug = { before: [symA], after: [symA] };
469
519
 
470
- // Pause when the engine is about to enter halt (program exit OR subroutine pop):
471
- haltState.debug = { before: true };
520
+ // Pause when the engine is about to enter halt (program exit OR subroutine pop).
521
+ // haltState.debug is a `boolean` (#207) — halt is terminal, so there's only
522
+ // one meaningful pause moment (post-triggering-iter, before halt processing).
523
+ haltState.debug = true;
524
+ haltState.debug = false; // turn off
525
+ haltState.debug = null; // alias of false (reset)
472
526
 
473
- // Reset filters later — next read returns a fresh empty DebugConfig:
527
+ // Reset filters later on a regular state — next read returns a fresh empty DebugConfig:
474
528
  myState.debug = null;
475
529
  ```
476
530
 
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.
531
+ > ⚠️ **`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.pause.side === 'after'`.
478
532
 
479
- The `debug` field is mutabletoggle 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`.
533
+ > ⚠️ **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`). **Always use the whole-object form: `haltState.debug = true` / `= false` / `= null`.**
480
534
 
481
- `run()` is async and accepts an `onPause` hook:
535
+ 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 is wrapped automatically. The instance is `Object.seal`-ed — typos like `state.debug.bofore = true` throw `TypeError` instead of silently creating a useless property.
482
536
 
483
- ```ts
484
- await machine.run({
485
- initialState,
486
- onStep: (m) => { /* logger sees every step */ },
487
- onPause: async (m) => {
488
- // Awaited at every break — hold execution until you resolve.
489
- if (m.debugBreak?.before) console.log('before:', m.state.name);
490
- if (m.debugBreak?.after) console.log('after:', m.state.name);
491
- },
492
- });
493
- ```
537
+ **Filter semantics:** `true` is a wildcard (match any symbol). `[ifOtherSymbol]` is NOT a wildcard — it matches only the catch-all resolution case (same meaning as in transition keys).
538
+
539
+ **Caveat:** `haltState` is a module-level singleton. Setting `haltState.debug` affects every machine in the process; clear in `afterEach` / `finally` for test isolation.
494
540
 
495
- Both `before` and `after` for the same iteration fire on the iteration's own yield, in the order **before → step → after**. `m.state` is always the iteration's own state; the `m.debugBreak` flag (`{before: true}` or `{after: true}`) tells the consumer which timing fired.
541
+ ### DebugSession API
496
542
 
497
- If `onPause` is not provided, breaks fire-and-resume invisibly — the trajectory is identical to running without `debug` set.
543
+ | Method | Description |
544
+ |---|---|
545
+ | `start(): Promise<void>` | Begin execution. Resolves on natural halt or after `stop()`. Single-use — a second call throws. |
546
+ | `continue()` | Resume from a pause and run to the next breakpoint or halt. |
547
+ | `stepIn()` | Resume and pause at the very next iter, regardless of depth — descends into any subroutine the current iter enters. Mirrors DevTools **Step Into**. |
548
+ | `stepOver()` | Resume and pause at the next iter back at (or above) the click-time halt-stack depth (`depth ≤ clickTimeDepth`) — subroutines the stepped-over iter enters run to completion without pausing inside. Mirrors DevTools **Step Over**. |
549
+ | `stepOut()` | Resume and pause at the next iter strictly shallower than the click-time depth (`depth < clickTimeDepth`) — i.e. once the current frame has been popped. Throws if the click-time depth is 0 (no enclosing frame to exit). Mirrors DevTools **Step Out**. |
550
+ | `pause()` | Request a pause from outside the loop. Fires on the next iter with `cause: 'manual'`. If a breakpoint matches that same iter, the breakpoint wins (single pause, `cause: 'breakpoint'`). |
551
+ | `stop()` | Terminate immediately. `halt` event does NOT fire. |
552
+ | `setRunInterval(ms)` | Insert an awaited `setTimeout(ms)` at the end of each iter. `0` disables. Useful for visualization UIs. |
553
+ | `on(event, listener) / off(event, listener)` | Register / unregister listeners. Multiple listeners per event are supported. Listener dispatch differs by event — see *Events* below. |
498
554
 
499
- **Filter semantics:** `true` is a wildcard (match any symbol). `[ifOtherSymbol]` is NOT a wildcardit matches only the catch-all resolution case (same meaning as in transition keys).
555
+ The three step controls are depth-based to mirror DevTools: from a pause at halt-stack depth `D`, `stepIn` pauses at the next iter (any depth), `stepOver` at the next iter with `depth ≤ D`, `stepOut` at the next iter with `depth < D`. For a *plain* iter (no subroutine entry) all of Into/Over coincide they differ only under genuine nesting (a bare that itself enters a `withOverriddenHaltState` wrapper). One engine-specific nuance: composition is continuation-passing (`A.wohs(B)` = "run A, then B" — sequential, where `wohs` is `withOverriddenHaltState`), so a flat `.wohs()` chain has no real nesting and Over/Out behave the same there; the distinction appears only when a subroutine's body enters another wrapper.
500
556
 
501
- **Caveat:** `haltState` is a module-level singleton. Setting `haltState.debug` affects every machine in the process; clear in `afterEach` / `finally` for test isolation.
557
+ ### Events
558
+
559
+ | Event | Argument | Dispatch | Fires |
560
+ |---|---|---|---|
561
+ | `pause` | `PausedMachineState` (`MachineState` + `pause: {side, cause}`) | Implicitly awaited via internal pause-promise — engine blocks until `continue / stepIn / stepOver / stepOut / stop` is called. Listener Promise itself is fire-and-forget. | A breakpoint matched, a step-mode endpoint was reached, or `session.pause()` was requested. |
562
+ | `step` | `MachineState` | Fire-and-forget (sync hot-loop tracing). | Once per iter, between any before-pause and after-pause. |
563
+ | `iter` | `MachineState` | **Awaited** (sequenced, blocks the engine). Use for throttle / per-iter coordination / step-boundary synthesis. | Once per iter, at end. After any after-pause. |
564
+ | `halt` | (none) | Fire-and-forget. | Once, on natural halt. Does NOT fire when `stop()` was called. |
565
+
566
+ ### The pause descriptor: `m.pause`
567
+
568
+ `pause` listeners receive a `PausedMachineState` — a plain `MachineState` plus a `pause: { side, cause }` descriptor (raw `runStepByStep` yields have no such field).
569
+
570
+ - **`side`** — `'before'` or `'after'`. Exactly one: DebugSession dispatches the two timings as separate `pause` events, so a descriptor is always one-sided. (`'step'` / `'manual'` causes only ever fire on the `'before'` side.)
571
+ - **`cause`** — distinguishes the origin:
572
+ - `'breakpoint'` — a `state.debug` filter matched, or `haltState.debug === true` triggered.
573
+ - `'step'` — a `stepIn` / `stepOver` / `stepOut` directive's natural endpoint was reached.
574
+ - `'manual'` — `session.pause()` was called from outside.
575
+
576
+ **When an iter satisfies more than one trigger**, exactly **one** `pause` event fires — never two at the same iter — and `cause` is chosen by precedence:
577
+
578
+ ```
579
+ breakpoint > step > manual
580
+ ```
581
+
582
+ So a step-mode endpoint (or a pending manual pause) that lands exactly on a breakpoint reports `cause: 'breakpoint'`; a manual pause that coincides with a step endpoint reports `cause: 'step'`. Rationale: a breakpoint is the user's explicit, persistent intent ("always stop here"), so it's the most informative attribution; a step directive is a specific computed endpoint, more specific than the vaguer "pause soon" of a manual request. The step-mode is still consumed (one-shot rule below) regardless of which cause is reported — if the iter was the step's endpoint, the step is satisfied. Matches IDE convention (a breakpoint on the line you step onto reports as a breakpoint stop).
583
+
584
+ ### One-shot step-mode rule
585
+
586
+ Every active step-mode (`stepIn` / `stepOver` / `stepOut`) is dropped on the next `pause` dispatch — whether that dispatch is the step's own endpoint, an inner breakpoint that fired sooner, or a manual pause. To keep stepping, call `stepIn` / `stepOver` / `stepOut` again from the new pause. Matches IDE convention.
502
587
 
503
588
  ### Setting breakpoints by graph id
504
589
 
@@ -532,25 +617,62 @@ if (e && sym) {
532
617
 
533
618
  ### Throttle pattern
534
619
 
535
- For per-iter throttle / animation / "wait between steps" UIs, use the **`onIter`** hook — an awaited callback that fires once at the end of every iter, after both `onPause` dispatches on the same yield. It's the engine-native shape for per-iter coordination:
620
+ For per-iter throttle / animation / "wait between steps" UIs, use `session.setRunInterval(ms)`:
621
+
622
+ ```ts
623
+ const session = new DebugSession(machine, { initialState });
624
+ session.setRunInterval(50); // 50ms between iters
625
+ session.on('iter', (m) => { /* update UI with iter snapshot */ });
626
+ await session.start();
627
+ ```
628
+
629
+ The throttle inserts an awaited `setTimeout(ms)` at the end of every iter, after both `pause` dispatches (if any) and the `iter` event. Updates take effect on the next iter. `0` disables.
630
+
631
+ ### v7 migration from `run({onPause, onStep, onIter, debug})`
632
+
633
+ v7 splits the v6 mixed-mode `run()` into three non-overlapping entry points. Old shape → new shape:
536
634
 
537
635
  ```ts
636
+ // v6 — async run() with optional callbacks
538
637
  await machine.run({
539
638
  initialState,
540
- onIter: async (m) => {
541
- // Fires after before(m.state) / step / after(m.state) on iter m.step.
542
- await new Promise((r) => setTimeout(r, intervalMs));
543
- },
639
+ onStep: (m) => { ... },
640
+ onPause: async (m) => { ... },
641
+ onIter: async (m) => { ... },
642
+ debug: true,
544
643
  });
644
+
645
+ // v7 — DebugSession for interactive use
646
+ const session = new DebugSession(machine, { initialState });
647
+ session.on('step', (m) => { ... });
648
+ session.on('pause', (m) => { ...; session.continue(); });
649
+ session.on('iter', (m) => { ... });
650
+ await session.start();
651
+ ```
652
+
653
+ Or, if you only used `run({ initialState })` with no callbacks, just drop the `await` — `run()` is now synchronous:
654
+
655
+ ```ts
656
+ // v6
657
+ await machine.run({ initialState });
658
+
659
+ // v7
660
+ machine.run({ initialState });
545
661
  ```
546
662
 
547
- `onIter` is unaffected by the `debug` master switch and unrelated to `state.debug` — it fires on every iter regardless of whether any breakpoints are armed. It coexists cleanly with user-authored `state.debug` breakpoints: on an iter with both `.before` and `.after` armed, the consumer sees `onPause(before)` → `onStep` `onPause(after)` → `onIter`, in that order, on the same yield.
663
+ For sync per-iter tracing without breakpoint-driven flow, iterate the `runStepByStep` generator directly `onStep` no longer exists:
548
664
 
549
- A few details:
665
+ ```ts
666
+ // v6
667
+ await machine.run({ initialState, onStep: (m) => trace(m) });
668
+
669
+ // v7
670
+ for (const m of machine.runStepByStep({ initialState })) {
671
+ trace(m);
672
+ }
673
+ ```
550
674
 
551
- - **Halting iter**: `onIter` still fires on the iter whose `m.nextState === haltState`, after any halt-time `onPause` dispatches. Engine returns cleanly after that. Use this to land "halted" UI state in interactive consumers.
552
- - **Click-pause / external interruption**: keep a flag set from the outside; check it inside `onIter` and `await` a resolvable Promise the UI controls (instead of the bare `setTimeout`). The engine just sees a longer awaited `onIter` — no engine surface needed for the pause.
553
- - **Sync consumers should keep using `onStep`**: it's microtask-free; `onIter` adds one awaited boundary per iter. Use the right hook for the right verb (logging/tracing → `onStep`, throttle/coordination → `onIter`, user breakpoints → `onPause`).
675
+ The `debug: false` master switch is gone — in v7 the session is the only consumer of breakpoints; if you don't register a `pause` listener, breakpoints fire-and-resume invisibly (the session's `start()` still resolves cleanly), or you can use `runStepByStep` directly, which ignores `state.debug` altogether (its yields carry no pause field).
554
676
 
555
677
  (History: v6.2.0 briefly widened `onStep` to `void | Promise<void>` and added an inline `await`, motivated by this same throttle use case. That was a mistake — restored to sync in v6.3.0. v6.3.0 documented a workaround using `onPause` self-rearm on `state.debug.after = true`; that workaround is superseded by `onIter` in v6.4.0+.)
556
678
 
@@ -708,8 +830,8 @@ Reading `['0',*] → [K,'0']/[R,R]`:
708
830
  API surface changes since v3, in past tense so the timing of each piece is explicit:
709
831
 
710
832
  - **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).
712
- - **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.
833
+ - **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.*
834
+ - **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 held through v6; **v7 removes it** — breakpoint detection moved into `DebugSession`, and the pause descriptor is now `m.pause: {side, cause}` on the `pause` event only.)
713
835
  - **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
836
  - **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.
715
837
  - **v6.3** — `onStep` reverted to its v6.0–v6.1 sync contract — `(m) => void`, called synchronously inside the run loop. The Throttle pattern section documents the engine-native shape for per-iter throttle / "wait between iters" UIs. No other API changes.
@@ -0,0 +1,119 @@
1
+ import State from './State';
2
+ import TuringMachine, { type MachineState, type PausedMachineState } from './TuringMachine';
3
+ /**
4
+ * Parameters mirror `TuringMachine.runStepByStep` / `run`. DebugSession passes
5
+ * them straight through to the engine generator.
6
+ */
7
+ export type DebugSessionParameter = {
8
+ initialState: State;
9
+ stepsLimit?: number;
10
+ };
11
+ export type DebugSessionEvent = 'pause' | 'step' | 'iter' | 'halt';
12
+ /**
13
+ * Listener signatures and dispatch contract by event:
14
+ *
15
+ * - `step` — fire-and-forget. Sync hot-loop tracing; listener Promise (if any)
16
+ * is not awaited. Matches the v6 `onStep` contract.
17
+ * - `iter` — **AWAITED** (sequenced, blocks the engine). For per-iter
18
+ * throttle / coordination / step-boundary synthesis where the engine
19
+ * genuinely needs to wait for the listener's work before advancing.
20
+ * Matches the v6 `onIter` contract.
21
+ * - `pause` — implicitly awaited via the session's internal `#pauseResolver`:
22
+ * the engine pauses on the pause-promise, listeners fire (their Promises
23
+ * are NOT awaited individually), and resume is signaled by an explicit
24
+ * call to `session.continue()` / `stepIn` / `stepOver` / `stepOut` /
25
+ * `stop` — fundamentally external to the listener's call site (UI click,
26
+ * postMessage, timer, etc.).
27
+ * - `halt` — fire-and-forget. Terminal notification.
28
+ *
29
+ * `pause` listeners receive a `PausedMachineState` (a `MachineState` plus the
30
+ * one-sided `pause: {side, cause}` descriptor). `step` / `iter` listeners
31
+ * receive a plain `MachineState` — raw yields carry no pause info.
32
+ */
33
+ export type DebugSessionListener<E extends DebugSessionEvent> = E extends 'halt' ? () => void | Promise<void> : E extends 'pause' ? (machineState: PausedMachineState) => void | Promise<void> : (machineState: MachineState) => void | Promise<void>;
34
+ /**
35
+ * Interactive debugger session for `TuringMachine`. Owns the coordination
36
+ * layer that every UI debugger / IDE extension / educational demo would
37
+ * otherwise reimplement: breakpoint dispatch, step-in / step-over / step-out,
38
+ * click-pause from outside, per-iter throttle, pause/resume promise plumbing.
39
+ *
40
+ * Construction is direct — the engine class stays minimal and doesn't expose a
41
+ * `debugRun()` factory; consumers import both classes and write
42
+ * `new DebugSession(machine, {initialState})`.
43
+ *
44
+ * Lifecycle:
45
+ * const session = new DebugSession(machine, {initialState});
46
+ * session.on('pause', (m) => { ...; session.continue(); });
47
+ * session.on('halt', () => { ... });
48
+ * await session.start(); // resolves on natural halt or stop()
49
+ *
50
+ * Each session is single-use: `start()` may only be called once. Construct a
51
+ * fresh session to re-run.
52
+ */
53
+ export default class DebugSession {
54
+ #private;
55
+ constructor(machine: TuringMachine, parameter: DebugSessionParameter);
56
+ on<E extends DebugSessionEvent>(event: E, listener: DebugSessionListener<E>): this;
57
+ off<E extends DebugSessionEvent>(event: E, listener: DebugSessionListener<E>): this;
58
+ stop(): void;
59
+ /**
60
+ * Request a pause from outside the run loop. The pause fires on the next
61
+ * iter's before-side with `cause: 'manual'`. If a breakpoint matches that
62
+ * same iter, the breakpoint takes precedence and the request is consumed
63
+ * silently (one pause, cause: 'breakpoint').
64
+ *
65
+ * No-op if the session is already paused — the next `continue` / step call
66
+ * resumes normal execution, then the flag fires on the iter AFTER that.
67
+ * Equivalent to a debouncing one-shot.
68
+ */
69
+ pause(): void;
70
+ /**
71
+ * Set the per-iter throttle delay in milliseconds. After each iter (including
72
+ * any pause + step + iter listeners on that iter), the loop awaits
73
+ * `setTimeout(ms)` before proceeding to the next iter. `0` disables the
74
+ * throttle.
75
+ *
76
+ * Useful for visualization UIs that want to animate execution at a fixed
77
+ * pace. Updates take effect on the next iter.
78
+ */
79
+ setRunInterval(ms: number): void;
80
+ /**
81
+ * Resume from the current pause, returning to normal execution until the
82
+ * next breakpoint or natural halt. No-op when called outside of a paused
83
+ * state.
84
+ */
85
+ continue(): void;
86
+ /**
87
+ * Resume and force a pause on the next iter regardless of whether that
88
+ * iter's `state.debug` filter matches. Step-mode is one-shot: any
89
+ * subsequent pause dispatch (this step-in's endpoint, an inner
90
+ * breakpoint, or a manual pause) drops it. To keep stepping, call
91
+ * stepIn() again from the new pause.
92
+ */
93
+ stepIn(): void;
94
+ /**
95
+ * Resume and pause at the next iter back at (or above) the click-time depth
96
+ * — i.e. `depth <= clickTimeDepth`. Frames the stepped-over iter pushes are
97
+ * run to completion without pausing inside (the engine's continuation-passing
98
+ * `withOverriddenHaltState` "calls"). Mirrors DevTools Step Over.
99
+ *
100
+ * For a plain iter (no frame push) this coincides with stepIn (next iter is
101
+ * already at the same depth). The Over-vs-In / Over-vs-Out distinction only
102
+ * appears under genuine nesting (a bare that itself enters a wrapper).
103
+ *
104
+ * One-shot: an inner breakpoint or any other pause drops the step-over
105
+ * intent. The endpoint pause carries `cause: 'step'`.
106
+ */
107
+ stepOver(): void;
108
+ /**
109
+ * Resume and pause at the next iter STRICTLY shallower than the click-time
110
+ * depth — `depth < clickTimeDepth` — i.e. once the current frame itself has
111
+ * been popped. Mirrors DevTools Step Out.
112
+ *
113
+ * Throws when the click-time depth is 0: there's no enclosing frame to exit
114
+ * (IDE convention — "step out of nothing" is a programming error, not a
115
+ * silent no-op).
116
+ */
117
+ stepOut(): void;
118
+ start(): Promise<void>;
119
+ }
@@ -73,11 +73,34 @@ 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
+ * haltState's `debug` setter rejects object writes before reaching
78
+ * DebugConfig, so this validator only sees non-halt states. */
77
79
  [validateDebugFilter](fieldName: 'before' | 'after', filter: readonly symbol[] | true | undefined): void;
78
80
  getSymbol(tapeBlock: TapeBlock): symbol;
79
81
  getCommand(symbol: symbol): Command;
80
82
  getNextState(symbol: symbol): State | Reference;
83
+ /**
84
+ * Like `getNextState`, but also returns the matched Symbol and its index
85
+ * in this State's transition declaration order (= the `K` in `toGraph`'s
86
+ * `${stateId}.${K}` transition ids). Used by `TuringMachine.runStepByStep`
87
+ * to populate `MachineState.matchedTransition` for #205 — exposes which
88
+ * transition fired so consumers (UIs, log tools, coverage maps) can
89
+ * resolve the firing edge without re-deriving from `(source, nextState)`,
90
+ * which is ambiguous when multiple transitions on the same source go to
91
+ * the same destination.
92
+ *
93
+ * Throws (matching `getNextState` / `getCommand`) when no entry exists for
94
+ * the symbol. For wrappers (states produced by `withOverriddenHaltState`):
95
+ * the symbol-to-data map is shared with the bare via `bareState`, so the
96
+ * returned `ix` is a valid position into BOTH the wrapper's and the
97
+ * bare's transition iteration order — they're the same map.
98
+ */
99
+ getMatchedTransition(symbol: symbol): {
100
+ nextState: State | Reference;
101
+ matchedSymbol: symbol;
102
+ ix: number;
103
+ };
81
104
  withOverriddenHaltState(overriddenHaltState: State): State;
82
105
  /**
83
106
  * @internal
@@ -164,5 +187,26 @@ export default class State {
164
187
  */
165
188
  static collectStates(initialState: State, tapeBlock: TapeBlock): StateMap;
166
189
  }
167
- export declare const haltState: State;
190
+ /**
191
+ * Typed alias for the haltState singleton (#207). Narrows `debug` from
192
+ * the generic-State `DebugConfig | boolean` union to plain `boolean`,
193
+ * giving compile-time type-safety at the singleton's call sites:
194
+ *
195
+ * ```ts
196
+ * haltState.debug = true; // ok
197
+ * haltState.debug = false; // ok
198
+ * haltState.debug = { before: true } // TS error
199
+ * const isOn = haltState.debug; // typed `boolean`
200
+ * ```
201
+ *
202
+ * Anyone holding a `State` reference that happens to BE the singleton (e.g.
203
+ * via `state.getNextState(sym).ref === haltState`) sees the wider `State`
204
+ * type; runtime throws guide them to the right shape. The singleton export
205
+ * is the canonical access path.
206
+ */
207
+ export type HaltState = State & {
208
+ get debug(): boolean;
209
+ set debug(value: boolean | null);
210
+ };
211
+ export declare const haltState: HaltState;
168
212
  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 {};