@turing-machine-js/machine 7.0.0-alpha.2 → 7.0.0-alpha.3

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,46 @@ 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.3] - 2026-05-21
8
+
9
+ Third v7 pre-release. Adds first-class out-of-band tags on `State` ([#186](https://github.com/mellonis/turing-machine-js/issues/186)) — a metadata channel for visualization grouping and debugger labels that survives `toGraph` / `fromGraph` / `toMermaid` / `fromMermaid` round-trips. Driven by downstream [post-machine-js#86](https://github.com/mellonis/post-machine-js/issues/86), which will build a path-based registry and inline pseudo-command on top once this ships. 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.3`.
12
+
13
+ ### Added
14
+
15
+ - **`State.tag(...) / .untag(...) / .tags` API** ([#186](https://github.com/mellonis/turing-machine-js/issues/186)). Fluent post-construct API for attaching string tags to a State. Tags are out-of-band metadata — they don't affect runtime transition lookup, `equivalentOn` comparisons, or any structural identity. Storage lives on the State INSTANCE (not on the shared `#symbolToDataMap`), so engine [#175](https://github.com/mellonis/turing-machine-js/issues/175) memoization doesn't leak tags across wrappers that share a bare: `A.wohs(t1).tag('hot')` does NOT propagate to `A.wohs(t2)`.
16
+
17
+ ```ts
18
+ const s = new State({...}, 'foo')
19
+ .tag('hot', 'sampled')
20
+ .untag('sampled');
21
+ s.tags; // ['hot'] ← frozen snapshot, in insertion order
22
+ ```
23
+
24
+ - **`GraphNode.tags: string[]`** ([#186](https://github.com/mellonis/turing-machine-js/issues/186)). New field on the serialized graph node. Survives `State.toGraph` / `State.fromGraph` round-trip; empty array for untagged states.
25
+
26
+ - **`toMermaid` tag rendering** ([#186](https://github.com/mellonis/turing-machine-js/issues/186)). Two surfaces:
27
+ - **Visible labels via `<br>`**: tagged node labels include the tag names inline (`s5["A<br>hot, sampled"]`). Uses Mermaid's universal `<br>` line break — works across mermaid.js, the live editor, machines-demo, and any other renderer; no CSS-pseudo-element tricks.
28
+ - **Color grouping via `classDef` + `class`**: each unique tag gets a `classDef tag_<sanitized> fill:#...,stroke:#...` line (palette of 6 colors selected by tag-name hash) and a `class s5,s6 tag_<sanitized>` line listing every node carrying the tag.
29
+
30
+ - **`fromMermaid` tag parse** ([#186](https://github.com/mellonis/turing-machine-js/issues/186)). Splits the node label on `<br>` to extract tags as the source of truth; `class` lines are decorative and discarded on parse (they regenerate on the next `toMermaid` emit from the tag set).
31
+
32
+ ### Compatibility
33
+
34
+ - Peer dep `@turing-machine-js/machine` widened `^7.0.0-alpha.2` → `^7.0.0-alpha.3` on `@turing-machine-js/builder`, `@turing-machine-js/library-binary-numbers`, `@turing-machine-js/library-binary-numbers-bare`.
35
+
36
+ ### Migration from alpha.2
37
+
38
+ Purely additive — no breaking changes. Existing code that doesn't call `state.tag(...)` or read `state.tags` / `GraphNode.tags` continues to work identically. Mermaid emit for untagged states is bytewise unchanged.
39
+
40
+ If you serialize `GraphNode` JSON, note that the new `tags: string[]` field is now required by the type (always emitted; empty array if no tags).
41
+
42
+ ### Out of v7-alpha.3 (still pending for stable v7.0.0)
43
+
44
+ - **[#102](https://github.com/mellonis/turing-machine-js/issues/102)** — debugger step-in / step-out / step-over primitives.
45
+ - **[#180](https://github.com/mellonis/turing-machine-js/issues/180)** — extract `State.toGraph`/`fromGraph` to its own module.
46
+
7
47
  ## [7.0.0-alpha.2] - 2026-05-21
8
48
 
9
49
  Second v7 pre-release. Refines alpha.1's `toMermaid` wrapped-state emit into the function-call model ([#174](https://github.com/mellonis/turing-machine-js/issues/174)) and adds two construction-time improvements to `withOverriddenHaltState` ([#175](https://github.com/mellonis/turing-machine-js/issues/175), [#176](https://github.com/mellonis/turing-machine-js/issues/176)). Published to npm under the `next` dist-tag: `npm install @turing-machine-js/machine@next`.
package/README.md CHANGED
@@ -13,6 +13,7 @@ A composable Turing-machine engine for JavaScript: multi-tape, subroutine compos
13
13
  - [Building from a state table](#building-from-a-state-table)
14
14
  - [Classes](#classes) — [`Alphabet`](#alphabet) · [`Tape`](#tape) · [`TapeBlock`](#tapeblock) · [`TapeCommand`](#tapecommand) · [`Command`](#command) · [`State`](#state) · [`Reference`](#reference) · [`TuringMachine`](#turingmachine)
15
15
  - [Subroutine composition with `withOverriddenHaltState`](#subroutine-composition-with-withoverriddenhaltstate)
16
+ - [State tags](#state-tags)
16
17
  - [Debugging breakpoints](#debugging-breakpoints)
17
18
  - [Special objects](#special-objects) — [`haltState`](#haltstate) · [`ifOtherSymbol`](#ifothersymbol) · [`movements`](#movements) · [`symbolCommands`](#symbolcommands)
18
19
  - [Introspection and testing](#introspection-and-testing)
@@ -427,6 +428,25 @@ flowchart TD
427
428
 
428
429
  Wrappers nest: `inner.withOverriddenHaltState(middle).withOverriddenHaltState(outer)` chains halt-redirects through `middle → outer → halt`. `library-binary-numbers/src/index.ts`'s `minusOne` (the `~(~x + 1)` composition) uses a 4-deep nest of wrappers.
429
430
 
431
+ ## State tags
432
+
433
+ A State carries an optional set of string tags — out-of-band metadata for visualization grouping and debugger labels. Tags don't affect runtime transition lookup, `equivalentOn` comparisons, or any structural identity; they ride alongside the State.
434
+
435
+ ```ts
436
+ const s = new State({...}, 'walkToBlank::1')
437
+ .tag('hot', 'subroutine-entry');
438
+
439
+ s.tags; // readonly ['hot', 'subroutine-entry'] — frozen snapshot
440
+ s.untag('hot');
441
+ s.tags; // readonly ['subroutine-entry']
442
+ ```
443
+
444
+ **Scoped to the wrapper instance.** Under [`withOverriddenHaltState` memoization (#175)](https://github.com/mellonis/turing-machine-js/issues/175), `A.wohs(t1)` and `A.wohs(t2)` are distinct wrapper instances even though they share `A`'s `#symbolToDataMap`. Tags live on the instance, so tagging one wrapper doesn't propagate to siblings sharing the same bare. Wrappers from `withOverriddenHaltState` start with an empty tag set (do not inherit from bare); the caller tags explicitly as needed.
445
+
446
+ **Round-trip preserved.** `state.toGraph` writes the tag set to `GraphNode.tags`; `state.fromGraph` reads it back and reapplies. `toMermaid` renders tags two ways: inline in the node label (`sN["name<br>tag1, tag2"]`, universal Mermaid line break) and as `classDef tag_<sanitized>` + `class sN tag_<sanitized>` lines for color grouping. `fromMermaid` splits the label on `<br>` as source of truth; the `class` lines are decorative and discarded on parse.
447
+
448
+ See [§Diagram conventions § Tags](#tags) for the full emit shape.
449
+
430
450
  ## Debugging breakpoints
431
451
 
432
452
  Any `State` can carry a runtime-mutable `debug` config that pauses execution at chosen points.
@@ -599,6 +619,15 @@ The `&` ribbon syntax (`s_W1 & s_W2 == "call" ==> s_A`) collapses multiple wrapp
599
619
 
600
620
  `subgraph w_N["callable subtree of NAME"] … end` wraps a bare + its body + a halt marker — the callable scope of code that runs when a wrapper "calls" the bare. Multi-bare frames (union-find merged from shared body states) use the label `"callable scope: A ∪ B"`.
601
621
 
622
+ ### Tags
623
+
624
+ Tagged states (via `state.tag('hot', 'sampled')` — see [§State tags](#state-tags)) render two ways simultaneously:
625
+
626
+ - **Inline in the node label**: `sN["name<br>tag1, tag2"]` — the `<br>` is Mermaid's universal line break, so the tags display as a second line under the state name in any renderer.
627
+ - **As a color class**: `classDef tag_<sanitized> fill:#...,stroke:#...` per unique tag (6-color palette selected by tag-name hash), plus `class sN,sM tag_<sanitized>` listing all nodes carrying the tag. Lets the eye group related states by color even when their names are scattered across the diagram.
628
+
629
+ The `<br>`-embedded label is the source of truth for `fromMermaid` round-trip; the `classDef`/`class` lines are decorative and regenerate on the next `toMermaid` emit. Tag-name sanitization in `classDef` identifiers: any char outside `[A-Za-z0-9_-]` is replaced with `_`. Labels preserve the raw tag names.
630
+
602
631
  ### Edge label format
603
632
 
604
633
  `[reads] → [writes]/[moves]`. Each bracketed list is a tape-block reading — one entry per tape; brackets always present, even single-tape.
@@ -654,7 +683,13 @@ API surface changes since v3, in past tense so the timing of each piece is expli
654
683
  - **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.
655
684
  - **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.
656
685
  - **v6.4** — New **`onIter`** hook on `run()`: awaited, fires once at the end of every iter (after both `onPause` dispatches on the same yield), unaffected by the `debug` master switch. Use for per-iter throttle / animation / coordination needing a suspend point; complements the existing sync `onStep` (tracing) and conditional `onPause` (user breakpoints). Three-hook contract is now `onStep` (sync, mid-iter) / `onPause` (awaited, on `state.debug` match) / `onIter` (awaited, end-of-iter). Additive — peer-deps unchanged. The v6.3.0 README's `onPause`-rearm throttle workaround is superseded.
657
- - **v7** *(alpha 1, 2026-05-21)* — Composition-representation overhaul. **First pre-release on the `next` dist-tag:** `npm install @turing-machine-js/machine@next` (or pin `@7.0.0-alpha.1`). Stable v7.0.0 still pending [#102](https://github.com/mellonis/turing-machine-js/issues/102) (debugger step-in/over/out primitives). Landed in alpha.1:
686
+ - **v7** *(latest alpha: alpha.3, 2026-05-21)* — Composition-representation overhaul + first-class state tags. **Pre-release on the `next` dist-tag:** `npm install @turing-machine-js/machine@next` (or pin `@7.0.0-alpha.3`). Stable v7.0.0 still pending [#102](https://github.com/mellonis/turing-machine-js/issues/102) (debugger step-in/over/out primitives). Highlights across alphas:
687
+
688
+ **alpha.3** — first-class **State tags** ([#186](https://github.com/mellonis/turing-machine-js/issues/186)). `state.tag(...) / .untag(...) / .tags` API; `GraphNode.tags: string[]` round-trips through `toGraph`/`fromGraph`; `toMermaid` emits tags two ways simultaneously — inline via `<br>` in node labels (`sN["name<br>tag1, tag2"]`) and as `classDef`/`class` for color grouping. Tags live on the State instance (not on the shared `#symbolToDataMap`), so engine [#175](https://github.com/mellonis/turing-machine-js/issues/175) memoization doesn't leak tags across wrappers sharing a bare. See [§State tags](#state-tags).
689
+
690
+ **alpha.2** — callable-subtree `toMermaid` emit refinement ([#174](https://github.com/mellonis/turing-machine-js/issues/174)). The wrapper is a separate `[[composite-name]]` node OUTSIDE the subgraph; the bare's reachable subtree becomes a `subgraph w_${frameId}["callable subtree of NAME"]` block. Frames computed via union-find — shared bares dedupe with `&` ribbons on call arrows. Bold `==> "call"` reserved for wrapper-to-bare; dotted `-.->` for frame dispatch (`return` / `halt` / `enter`). Plus engine memoization ([#175](https://github.com/mellonis/turing-machine-js/issues/175)) and nested-chain collapse ([#176](https://github.com/mellonis/turing-machine-js/issues/176)) for `.wohs()`.
691
+
692
+ **alpha.1** — initial v7 composition-representation overhaul:
658
693
  - **`withOverrodeHaltState` → `withOverriddenHaltState`** ([#149](https://github.com/mellonis/turing-machine-js/issues/149)). Grammar fix on a name introduced in 2019: the past-participle `overridden` fits the "with a halt-state that has been ___" naming idiom; `overrode` (simple past) didn't. Hard cutover — no deprecated alias. The getter (`state.overrodeHaltState` → `state.overriddenHaltState`) and the serialized `Graph` data field (`node.overrodeHaltStateId` → `node.overriddenHaltStateId`) rename in lockstep. Consumer migration: global find/replace `OverrodeHaltState` → `OverriddenHaltState` and `overrodeHaltState` → `overriddenHaltState`. Persisted `State.toGraph` JSON dumps would need the same field-rename treatment, but persistence isn't a known consumer pattern.
659
694
  - **Paren-based wrapped-state naming** ([#148](https://github.com/mellonis/turing-machine-js/issues/148)). `withOverriddenHaltState`'s composite name format changed from flat `bare>override` to nested `bare(override)`. Same nesting depth reads as `A(B(A))` (bare = `A`, override = `B(A)`) versus `A(B)(A)` (bare = `A(B)`, override = `A`) — two structurally-different wrap-trees that the old `>`-flat notation collided into the single string `A>B>A`. As a consequence, **user-provided state names must not contain `(` or `)`** — `State` now throws at construction time if a user passes such a name. The `>` character stays valid in user names (no longer reserved). The `inspect()` / `toGraph` / `toMermaid` outputs carry the new format. `states.md` files in `library-binary-numbers` regenerate accordingly.
660
695
  - **`toMermaid` callable-subtree emit** ([#174](https://github.com/mellonis/turing-machine-js/issues/174), supersedes the alpha.1 collapsed-bare shape from #138/#139). `withOverriddenHaltState` is modeled as a function call: the wrapper is a `[[composite-name]]` call site OUTSIDE any subgraph, the bare's reachable subtree becomes a `subgraph w_${frameId}["callable subtree of NAME"] … end` block containing the bare + body states + a per-frame halt marker `c${frameId}(((halt)))`. Frames are computed via union-find on bare-reachability — overlapping reach sets merge into a single union frame, so shared bares (`library-binary-numbers/minusOne`'s `invertNumber`) appear ONCE with `& `-joined call arrows from each wrapper. Bold `==> "call"` arrows are reserved for the wrapper-to-bare call; dotted `-.->` is reserved for frame-level dispatch (`return` / `halt` / `enter`). The retired `-. onHalt .->` keyword is replaced by a solid `--> override` arrow (just an ordinary transition under the call/return mental model). `GraphNode` gains `isWrapper`, `bareStateId`, `frameId` fields (and drops `isWrapped`). Bytewise round-trip stability now holds for all wrapped states including shared-bare cases (no per-context duplication).
@@ -32,6 +32,24 @@ export default class State {
32
32
  before?: symbol[] | readonly symbol[] | true;
33
33
  after?: symbol[] | readonly symbol[] | true;
34
34
  } | null);
35
+ /**
36
+ * Add one or more tags to this State (#186). Tags are out-of-band metadata
37
+ * used by visualization (`toMermaid` emits `classDef`/`class` lines) and
38
+ * debugger tooling — they don't affect runtime transition lookup,
39
+ * `equivalentOn` comparisons, or any structural identity. Chainable.
40
+ */
41
+ tag(...tags: string[]): this;
42
+ /**
43
+ * Remove one or more tags from this State (#186). Untagging a tag the
44
+ * State doesn't carry is a no-op. Chainable.
45
+ */
46
+ untag(...tags: string[]): this;
47
+ /**
48
+ * Frozen snapshot of this State's current tags (#186). The returned array
49
+ * is `Object.freeze`d — mutating it throws in strict mode (which TS-emitted
50
+ * code uses). Order matches insertion order of the underlying Set.
51
+ */
52
+ get tags(): readonly string[];
35
53
  /** @internal — invoked by DebugConfig setters via module-private symbol. */
36
54
  [validateDebugFilter](fieldName: 'before' | 'after', filter: readonly symbol[] | true | undefined): void;
37
55
  getSymbol(tapeBlock: TapeBlock): symbol;
package/dist/index.cjs CHANGED
@@ -689,7 +689,7 @@ var __classPrivateFieldGet$1 = (undefined && undefined.__classPrivateFieldGet) |
689
689
  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");
690
690
  return kind === "m" ? f : kind === "a" ? f.call(receiver) : f ? f.value : state.get(receiver);
691
691
  };
692
- var _DebugConfig_ownerState, _DebugConfig_before, _DebugConfig_after, _a, _State_wrapperCache, _State_id, _State_name, _State_overriddenHaltState, _State_bareState, _State_symbolToDataMap, _State_debugRef;
692
+ var _DebugConfig_ownerState, _DebugConfig_before, _DebugConfig_after, _a, _State_wrapperCache, _State_id, _State_name, _State_overriddenHaltState, _State_bareState, _State_symbolToDataMap, _State_debugRef, _State_tags;
693
693
  const ifOtherSymbol = Symbol('other symbol');
694
694
  // Module-private symbol used by DebugConfig setters to call State's validator
695
695
  // without exposing the validator on the public surface.
@@ -750,6 +750,14 @@ class State {
750
750
  // Note: toGraph / fromGraph deliberately do not serialize debug — debug is
751
751
  // a runtime concern, not part of the structural graph.
752
752
  _State_debugRef.set(this, { current: null });
753
+ // Out-of-band tags applied to this State (#186). Tags are visualization
754
+ // and debugger-tooling metadata — they don't affect runtime transition
755
+ // lookup or `equivalentOn` comparisons. Stored as a Set for de-duplication;
756
+ // exposed via the `tags` getter as a frozen array snapshot. Lives on the
757
+ // State INSTANCE so wrappers (from `withOverriddenHaltState`) carry tags
758
+ // independently of their bare's tag set — see the #175 sharing test in
759
+ // State.spec.ts.
760
+ _State_tags.set(this, new Set());
753
761
  if (stateDefinition) {
754
762
  const keys = Object.getOwnPropertyNames(stateDefinition);
755
763
  if (keys.length) {
@@ -833,8 +841,38 @@ class State {
833
841
  }
834
842
  __classPrivateFieldGet$1(this, _State_debugRef, "f").current = new DebugConfig(this, value);
835
843
  }
844
+ /**
845
+ * Add one or more tags to this State (#186). Tags are out-of-band metadata
846
+ * used by visualization (`toMermaid` emits `classDef`/`class` lines) and
847
+ * debugger tooling — they don't affect runtime transition lookup,
848
+ * `equivalentOn` comparisons, or any structural identity. Chainable.
849
+ */
850
+ tag(...tags) {
851
+ for (const t of tags) {
852
+ __classPrivateFieldGet$1(this, _State_tags, "f").add(t);
853
+ }
854
+ return this;
855
+ }
856
+ /**
857
+ * Remove one or more tags from this State (#186). Untagging a tag the
858
+ * State doesn't carry is a no-op. Chainable.
859
+ */
860
+ untag(...tags) {
861
+ for (const t of tags) {
862
+ __classPrivateFieldGet$1(this, _State_tags, "f").delete(t);
863
+ }
864
+ return this;
865
+ }
866
+ /**
867
+ * Frozen snapshot of this State's current tags (#186). The returned array
868
+ * is `Object.freeze`d — mutating it throws in strict mode (which TS-emitted
869
+ * code uses). Order matches insertion order of the underlying Set.
870
+ */
871
+ get tags() {
872
+ return Object.freeze([...__classPrivateFieldGet$1(this, _State_tags, "f")]);
873
+ }
836
874
  /** @internal — invoked by DebugConfig setters via module-private symbol. */
837
- [(_State_id = new WeakMap(), _State_name = new WeakMap(), _State_overriddenHaltState = new WeakMap(), _State_bareState = new WeakMap(), _State_symbolToDataMap = new WeakMap(), _State_debugRef = new WeakMap(), validateDebugFilter)](fieldName, filter) {
875
+ [(_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) {
838
876
  if (filter === undefined)
839
877
  return;
840
878
  // #108 part 2: `.after` on haltState has no semantic anchor — halt is
@@ -999,6 +1037,7 @@ class State {
999
1037
  frameId: null,
1000
1038
  transitions: [],
1001
1039
  overriddenHaltStateId: null,
1040
+ tags: [...__classPrivateFieldGet$1(state, _State_tags, "f")],
1002
1041
  };
1003
1042
  }
1004
1043
  continue;
@@ -1017,6 +1056,7 @@ class State {
1017
1056
  frameId: null,
1018
1057
  transitions: [],
1019
1058
  overriddenHaltStateId: __classPrivateFieldGet$1(overrideTarget, _State_id, "f"),
1059
+ tags: [...__classPrivateFieldGet$1(state, _State_tags, "f")],
1020
1060
  };
1021
1061
  bareIds.add(__classPrivateFieldGet$1(bareState, _State_id, "f"));
1022
1062
  queue.push(bareState);
@@ -1034,6 +1074,7 @@ class State {
1034
1074
  frameId: null,
1035
1075
  transitions: [],
1036
1076
  overriddenHaltStateId: null,
1077
+ tags: [...__classPrivateFieldGet$1(state, _State_tags, "f")],
1037
1078
  };
1038
1079
  nodes[__classPrivateFieldGet$1(state, _State_id, "f")] = node;
1039
1080
  let patternIx = 0;
@@ -1073,6 +1114,7 @@ class State {
1073
1114
  frameId: null,
1074
1115
  transitions: [],
1075
1116
  overriddenHaltStateId: null,
1117
+ tags: [...__classPrivateFieldGet$1(haltState, _State_tags, "f")],
1076
1118
  };
1077
1119
  }
1078
1120
  // Pass 2: For each bare, compute its forward-reachable set (following
@@ -1087,7 +1129,10 @@ class State {
1087
1129
  continue;
1088
1130
  }
1089
1131
  const node = nodes[id];
1090
- if (!node || node.isHalt || node.isWrapper) {
1132
+ // `nodes[id]` is always populated for `id` that the BFS reached, so
1133
+ // a defensive `!node` check would be dead. `isHalt` / `isWrapper`
1134
+ // are real boundaries — both stop reach-set expansion.
1135
+ if (node.isHalt || node.isWrapper) {
1091
1136
  continue;
1092
1137
  }
1093
1138
  reach.add(id);
@@ -1109,6 +1154,10 @@ class State {
1109
1154
  // sets share any state. Canonical representative = smallest bare-id in
1110
1155
  // the component.
1111
1156
  const ufParent = new Map();
1157
+ // Note: no path compression. The union policy below ("smaller id always
1158
+ // becomes root") keeps the tree flat — every union targets bares[0] as
1159
+ // the root, so any node's parent IS the root. Walking up never exceeds
1160
+ // one step. Path compression would be dead code under this invariant.
1112
1161
  const ufFind = (id) => {
1113
1162
  if (!ufParent.has(id)) {
1114
1163
  ufParent.set(id, id);
@@ -1117,13 +1166,6 @@ class State {
1117
1166
  while (ufParent.get(root) !== root) {
1118
1167
  root = ufParent.get(root);
1119
1168
  }
1120
- // Path compression
1121
- let cur = id;
1122
- while (ufParent.get(cur) !== root) {
1123
- const next = ufParent.get(cur);
1124
- ufParent.set(cur, root);
1125
- cur = next;
1126
- }
1127
1169
  return root;
1128
1170
  };
1129
1171
  const ufUnion = (a, b) => {
@@ -1194,6 +1236,7 @@ class State {
1194
1236
  frameId,
1195
1237
  transitions: [],
1196
1238
  overriddenHaltStateId: null,
1239
+ tags: [],
1197
1240
  };
1198
1241
  }
1199
1242
  return { initialId: __classPrivateFieldGet$1(initialState, _State_id, "f"), alphabets, nodes };
@@ -1265,6 +1308,9 @@ class State {
1265
1308
  // and assign `#name` directly to skip user-facing name validation.
1266
1309
  const bare = new _a(stateDefinition);
1267
1310
  __classPrivateFieldSet$1(bare, _State_name, node.name, "f");
1311
+ if (node.tags.length > 0) {
1312
+ bare.tag(...node.tags);
1313
+ }
1268
1314
  bareStates[nodeId] = bare;
1269
1315
  }
1270
1316
  // Pass 3: resolve every node to its final State (memoized + cycle-safe).
@@ -1290,6 +1336,13 @@ class State {
1290
1336
  const bare = getFinal(node.bareStateId);
1291
1337
  const override = getFinal(node.overriddenHaltStateId);
1292
1338
  state = bare.withOverriddenHaltState(override);
1339
+ // Apply wrapper-scoped tags (#186). Tags don't leak across wrappers
1340
+ // sharing a bare — the wrapper instance owns its own tag set, and
1341
+ // engine #175 memoization returns the same instance for the same
1342
+ // (bare, override) pair, so this is idempotent across rebuilds.
1343
+ if (node.tags.length > 0) {
1344
+ state.tag(...node.tags);
1345
+ }
1293
1346
  }
1294
1347
  else {
1295
1348
  state = bareStates[nodeId];
@@ -1532,6 +1585,16 @@ function toMermaid(graph) {
1532
1585
  bucket.push(node);
1533
1586
  }
1534
1587
  }
1588
+ // Build the visible-label string for a node — name plus, if tagged, a
1589
+ // `<br>tag1, tag2, ...` suffix so the rendered Mermaid shows both. Tags
1590
+ // are the source of truth on the GraphNode; `<br>` is the universal
1591
+ // Mermaid line-break that works across renderers without `classDef`-
1592
+ // pseudo-element hacks (#186).
1593
+ const labelOf = (node) => {
1594
+ if (node.tags.length === 0)
1595
+ return node.name;
1596
+ return `${node.name}<br>${node.tags.join(', ')}`;
1597
+ };
1535
1598
  // 1. Emit top-level nodes (real halt, non-wrapper regulars outside any frame).
1536
1599
  for (const node of topLevelNodes) {
1537
1600
  const mid = mermaidIdFor(node.id);
@@ -1539,12 +1602,12 @@ function toMermaid(graph) {
1539
1602
  lines.push(` ${mid}(((halt)))`);
1540
1603
  }
1541
1604
  else {
1542
- lines.push(` ${mid}["${node.name}"]`);
1605
+ lines.push(` ${mid}["${labelOf(node)}"]`);
1543
1606
  }
1544
1607
  }
1545
1608
  // 2. Emit wrappers at top level.
1546
1609
  for (const wrapper of wrapperNodes) {
1547
- lines.push(` ${mermaidIdFor(wrapper.id)}[["${wrapper.name}"]]`);
1610
+ lines.push(` ${mermaidIdFor(wrapper.id)}[["${labelOf(wrapper)}"]]`);
1548
1611
  }
1549
1612
  // 3. `idle` sentinel.
1550
1613
  lines.push(' idle([idle])');
@@ -1562,12 +1625,13 @@ function toMermaid(graph) {
1562
1625
  lines.push(` subgraph ${frameSubgraphId(frameId)}["${label}"]`);
1563
1626
  // Inner nodes — sort by id for determinism.
1564
1627
  for (const node of (nodesByFrame.get(frameId) ?? []).slice().sort((a, b) => a.id - b.id)) {
1565
- lines.push(` ${mermaidIdFor(node.id)}["${node.name}"]`);
1628
+ lines.push(` ${mermaidIdFor(node.id)}["${labelOf(node)}"]`);
1566
1629
  }
1630
+ // Every frame has a halt marker — `State.toGraph`'s frame-emit pass
1631
+ // creates one for each frame. Non-null assertion is safe; a defensive
1632
+ // null check would be dead.
1567
1633
  const haltMarker = haltMarkerByFrame.get(frameId);
1568
- if (haltMarker) {
1569
- lines.push(` ${mermaidIdFor(haltMarker.id)}(((halt)))`);
1570
- }
1634
+ lines.push(` ${mermaidIdFor(haltMarker.id)}(((halt)))`);
1571
1635
  lines.push(' end');
1572
1636
  }
1573
1637
  // 5. Enter arrow.
@@ -1620,29 +1684,31 @@ function toMermaid(graph) {
1620
1684
  for (const frameId of frameIds) {
1621
1685
  if (!haltMarkerHasIncoming.get(frameId))
1622
1686
  continue;
1623
- // Return arrow — collapsed `&` ribbon over all wrappers calling this frame.
1687
+ // Return arrow — collapsed `&` ribbon over all wrappers calling this
1688
+ // frame. Frames only exist because at least one wrapper's bareStateId
1689
+ // points to a bare in the frame, so `callingWrappers` is always
1690
+ // non-empty for any frame that reached this code path.
1624
1691
  const callingWrappers = wrapperNodes.filter((w) => {
1625
- if (w.bareStateId === null)
1626
- return false;
1627
1692
  const bare = graph.nodes[w.bareStateId];
1628
- return !!bare && bare.frameId === frameId;
1693
+ return bare.frameId === frameId;
1629
1694
  });
1630
- if (callingWrappers.length > 0) {
1631
- const targets = callingWrappers
1632
- .slice()
1633
- .sort((a, b) => a.id - b.id)
1634
- .map((w) => mermaidIdFor(w.id))
1635
- .join(' & ');
1636
- lines.push(` ${frameSubgraphId(frameId)} -. "return" .-> ${targets}`);
1637
- }
1695
+ const targets = callingWrappers
1696
+ .slice()
1697
+ .sort((a, b) => a.id - b.id)
1698
+ .map((w) => mermaidIdFor(w.id))
1699
+ .join(' & ');
1700
+ lines.push(` ${frameSubgraphId(frameId)} -. "return" .-> ${targets}`);
1638
1701
  if (hasNonWrapperEntry.get(frameId)) {
1639
1702
  lines.push(` ${frameSubgraphId(frameId)} -. "halt" .-> s0`);
1640
1703
  }
1641
1704
  }
1642
1705
  // 8. Wrapper-to-override arrows (regular solid).
1706
+ //
1707
+ // `wrapper.overriddenHaltStateId` is always non-null on wrapper nodes
1708
+ // (set by `State.toGraph` for every `isWrapper: true` node — it's the
1709
+ // wrapper's override target, which a wrapper by definition has). The
1710
+ // non-null assertion is safe; a defensive null check would be dead.
1643
1711
  for (const wrapper of wrapperNodes) {
1644
- if (wrapper.overriddenHaltStateId === null)
1645
- continue;
1646
1712
  lines.push(` ${mermaidIdFor(wrapper.id)} --> ${mermaidIdFor(wrapper.overriddenHaltStateId)}`);
1647
1713
  }
1648
1714
  // 9. Regular transitions for non-wrapper non-halt-marker non-halt nodes.
@@ -1658,14 +1724,75 @@ function toMermaid(graph) {
1658
1724
  lines.push(` ${mermaidIdFor(node.id)} -- "${label}" --> ${mermaidIdFor(t.nextStateId)}`);
1659
1725
  }
1660
1726
  }
1727
+ // 10. Tags (#186) — emit one `classDef tag_<name> fill:#...` per unique
1728
+ // tag across all nodes, then one `class <ids> tag_<name>` line per
1729
+ // tag listing every node that carries it (comma-joined for compact
1730
+ // emit). Tag-name → CSS-class identifier sanitization replaces any
1731
+ // char outside `[A-Za-z0-9_-]` with `_`; tag-name uniqueness in the
1732
+ // emit assumes user tags are already distinct after sanitization
1733
+ // (collisions are user error).
1734
+ emitTagAnnotations(lines, nodes);
1661
1735
  return lines.join('\n');
1662
1736
  }
1737
+ // Default Mermaid `classDef` palette — 6 visually distinct fill+stroke pairs,
1738
+ // selected by tag-name hash so multi-tag diagrams look readable out of the
1739
+ // box without user configuration. Users who want different colors can edit
1740
+ // the emitted Mermaid before rendering or override post-emit.
1741
+ const TAG_PALETTE = [
1742
+ ['#fef3c7', '#92400e'], // amber
1743
+ ['#dbeafe', '#1e40af'], // blue
1744
+ ['#dcfce7', '#166534'], // green
1745
+ ['#fce7f3', '#9d174d'], // pink
1746
+ ['#ede9fe', '#5b21b6'], // violet
1747
+ ['#fee2e2', '#991b1b'], // red
1748
+ ];
1749
+ function sanitizeTagName(tag) {
1750
+ return tag.replace(/[^A-Za-z0-9_-]/g, '_');
1751
+ }
1752
+ function tagColor(tag) {
1753
+ // Cheap deterministic hash — sum of char codes mod palette length. Stable
1754
+ // across runs; same tag name always picks the same color.
1755
+ let h = 0;
1756
+ for (let i = 0; i < tag.length; i += 1) {
1757
+ h = (h + tag.charCodeAt(i)) % TAG_PALETTE.length;
1758
+ }
1759
+ return TAG_PALETTE[h];
1760
+ }
1761
+ function emitTagAnnotations(lines, nodes) {
1762
+ // Collect nodes per tag in node-id order so output is deterministic.
1763
+ const nodesByTag = new Map();
1764
+ for (const node of nodes) {
1765
+ for (const tag of node.tags) {
1766
+ let list = nodesByTag.get(tag);
1767
+ if (!list) {
1768
+ list = [];
1769
+ nodesByTag.set(tag, list);
1770
+ }
1771
+ list.push(node.id);
1772
+ }
1773
+ }
1774
+ if (nodesByTag.size === 0)
1775
+ return;
1776
+ const sortedTags = [...nodesByTag.keys()].sort();
1777
+ for (const tag of sortedTags) {
1778
+ const sanitized = sanitizeTagName(tag);
1779
+ const [fill, stroke] = tagColor(tag);
1780
+ lines.push(` classDef tag_${sanitized} fill:${fill},stroke:${stroke}`);
1781
+ }
1782
+ for (const tag of sortedTags) {
1783
+ const sanitized = sanitizeTagName(tag);
1784
+ const ids = nodesByTag.get(tag).map((id) => mermaidIdFor(id)).join(',');
1785
+ lines.push(` class ${ids} tag_${sanitized}`);
1786
+ }
1787
+ }
1663
1788
  // Helper: identify "the bare states" that anchor a frame's name. A bare is a
1664
1789
  // node referenced as some wrapper's `bareStateId`. Body states (also in-frame
1665
1790
  // but not bare) are excluded from the frame label.
1791
+ //
1792
+ // The caller in `toMermaid` only passes non-wrapper, non-halt-marker nodes
1793
+ // (wrappers go to a separate bucket; halt markers go to `haltMarkerByFrame`).
1794
+ // No defensive `isHalt` / `isWrapper` guards needed here.
1666
1795
  function isFrameBare(node, graph) {
1667
- if (node.isWrapper || node.isHalt)
1668
- return false;
1669
1796
  for (const other of Object.values(graph.nodes)) {
1670
1797
  if (other.isWrapper && other.bareStateId === node.id) {
1671
1798
  return true;
@@ -1707,6 +1834,33 @@ const haltArrowRegex = /^w_(\d+)\s+-\.\s+"halt"\s+\.->\s+s0$/;
1707
1834
  // First capture char anchored as \S to avoid polynomial backtracking between
1708
1835
  // the preceding \s* and a permissive (.+); see CodeQL js/polynomial-redos.
1709
1836
  const alphabetsRegex = /^%%\s*alphabets:\s*(\S.*)$/;
1837
+ // Tag annotation lines (#186). Matches both `classDef tag_<sanitized>` and
1838
+ // `class <id-list> tag_<sanitized>`. ClassDef declarations are decorative
1839
+ // (palette) and discarded on parse — toMermaid will regenerate them from
1840
+ // the tag set on re-emit. `class` lines carry the actual graph-node
1841
+ // assignments; we strip the `tag_` prefix and assign each tag to each
1842
+ // listed node's `tags` array.
1843
+ //
1844
+ // Inter-token gaps are fixed at single literal spaces (matching toMermaid's
1845
+ // canonical emit) rather than `\s+`. This avoids the polynomial-ReDoS
1846
+ // pattern CodeQL flags when `\s+` surrounds a content group (see also
1847
+ // `callArrowRegex` / `returnArrowRegex` tightening in PR #182).
1848
+ const classDefTagRegex = /^classDef tag_([A-Za-z0-9_-]+) .+$/;
1849
+ const classAssignTagRegex = /^class ([sc]\d+(?:,[sc]\d+)*) tag_([A-Za-z0-9_-]+)$/;
1850
+ // Splits a node label like `"A<br>hot, sampled"` into its name and tags (#186).
1851
+ // Labels without `<br>` have no tags. Tags are comma-joined; trimmed of
1852
+ // whitespace. The `<br>` is the single source of truth for tag-name parsing —
1853
+ // `class` lines are decorative-only and not consulted here.
1854
+ function splitLabelTags(label) {
1855
+ const brIx = label.indexOf('<br>');
1856
+ if (brIx < 0) {
1857
+ return { name: label, tags: [] };
1858
+ }
1859
+ const name = label.slice(0, brIx);
1860
+ const tagsStr = label.slice(brIx + '<br>'.length);
1861
+ const tags = tagsStr.split(',').map((t) => t.trim()).filter((t) => t.length > 0);
1862
+ return { name, tags };
1863
+ }
1710
1864
  function fromMermaid(text) {
1711
1865
  const lines = text.split('\n').map((l) => l.trim()).filter(Boolean);
1712
1866
  let alphabets = [];
@@ -1725,6 +1879,7 @@ function fromMermaid(text) {
1725
1879
  frameId: opts.frameId ?? null,
1726
1880
  transitions: [],
1727
1881
  overriddenHaltStateId: null,
1882
+ tags: opts.tags ? [...opts.tags] : [],
1728
1883
  };
1729
1884
  }
1730
1885
  else {
@@ -1740,6 +1895,12 @@ function fromMermaid(text) {
1740
1895
  nodes[id].bareStateId = opts.bareStateId;
1741
1896
  if (opts.frameId !== undefined)
1742
1897
  nodes[id].frameId = opts.frameId;
1898
+ if (opts.tags !== undefined) {
1899
+ for (const t of opts.tags) {
1900
+ if (!nodes[id].tags.includes(t))
1901
+ nodes[id].tags.push(t);
1902
+ }
1903
+ }
1743
1904
  }
1744
1905
  return nodes[id];
1745
1906
  };
@@ -1752,6 +1913,11 @@ function fromMermaid(text) {
1752
1913
  alphabets = JSON.parse(am[1]);
1753
1914
  continue;
1754
1915
  }
1916
+ // Tag annotations (#186) — classDef lines are decorative and skipped;
1917
+ // `class` lines are parsed in the edge pass since they reference nodes
1918
+ // by id and need those nodes already created in the first pass.
1919
+ if (classDefTagRegex.test(line))
1920
+ continue;
1755
1921
  const sgStart = line.match(subgraphStartRegex);
1756
1922
  if (sgStart) {
1757
1923
  currentFrameId = Number(sgStart[1]);
@@ -1777,17 +1943,21 @@ function fromMermaid(text) {
1777
1943
  }
1778
1944
  const wm = line.match(wrappedNodeRegex);
1779
1945
  if (wm) {
1946
+ const { name, tags } = splitLabelTags(wm[2]);
1780
1947
  ensureNode(parseMermaidId(wm[1]), {
1781
- name: wm[2],
1948
+ name,
1782
1949
  isWrapper: true,
1950
+ tags,
1783
1951
  });
1784
1952
  continue;
1785
1953
  }
1786
1954
  const rm = line.match(regularNodeRegex);
1787
1955
  if (rm) {
1956
+ const { name, tags } = splitLabelTags(rm[2]);
1788
1957
  ensureNode(parseMermaidId(rm[1]), {
1789
- name: rm[2],
1958
+ name,
1790
1959
  frameId: currentFrameId,
1960
+ tags,
1791
1961
  });
1792
1962
  continue;
1793
1963
  }
@@ -1804,6 +1974,19 @@ function fromMermaid(text) {
1804
1974
  if (returnArrowRegex.test(line) || haltArrowRegex.test(line)) {
1805
1975
  continue;
1806
1976
  }
1977
+ // Tag class-assignment line (#186): `class s1,s5 tag_hot` — adds
1978
+ // the tag to each listed node. Tag-name preserved as written
1979
+ // (sanitization on emit is lossy in principle; on parse we don't
1980
+ // un-sanitize, since the original could have any characters).
1981
+ const tagMatch = line.match(classAssignTagRegex);
1982
+ if (tagMatch) {
1983
+ const ids = tagMatch[1].split(',');
1984
+ const tagName = tagMatch[2];
1985
+ for (const idStr of ids) {
1986
+ ensureNode(parseMermaidId(idStr), { tags: [tagName] });
1987
+ }
1988
+ continue;
1989
+ }
1807
1990
  // `call` arrow — sets bareStateId on each source wrapper.
1808
1991
  const cm = line.match(callArrowRegex);
1809
1992
  if (cm) {
@@ -1820,7 +2003,12 @@ function fromMermaid(text) {
1820
2003
  if (wo) {
1821
2004
  const fromId = parseMermaidId(wo[1]);
1822
2005
  const toId = parseMermaidId(wo[2]);
1823
- if (nodes[fromId] && nodes[fromId].isWrapper) {
2006
+ // The wrapper-override regex only matches `sN --> sM` (unlabeled);
2007
+ // since `toMermaid` only emits this shape from wrappers, the source
2008
+ // is guaranteed to be a wrapper if `fromMermaid`'s input came from
2009
+ // `toMermaid`. `nodes[fromId]` is always populated (first pass emits
2010
+ // node declarations before any edge parsing).
2011
+ if (nodes[fromId].isWrapper) {
1824
2012
  nodes[fromId].overriddenHaltStateId = toId;
1825
2013
  continue;
1826
2014
  }
package/dist/index.mjs CHANGED
@@ -687,7 +687,7 @@ var __classPrivateFieldGet$1 = (undefined && undefined.__classPrivateFieldGet) |
687
687
  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");
688
688
  return kind === "m" ? f : kind === "a" ? f.call(receiver) : f ? f.value : state.get(receiver);
689
689
  };
690
- var _DebugConfig_ownerState, _DebugConfig_before, _DebugConfig_after, _a, _State_wrapperCache, _State_id, _State_name, _State_overriddenHaltState, _State_bareState, _State_symbolToDataMap, _State_debugRef;
690
+ var _DebugConfig_ownerState, _DebugConfig_before, _DebugConfig_after, _a, _State_wrapperCache, _State_id, _State_name, _State_overriddenHaltState, _State_bareState, _State_symbolToDataMap, _State_debugRef, _State_tags;
691
691
  const ifOtherSymbol = Symbol('other symbol');
692
692
  // Module-private symbol used by DebugConfig setters to call State's validator
693
693
  // without exposing the validator on the public surface.
@@ -748,6 +748,14 @@ class State {
748
748
  // Note: toGraph / fromGraph deliberately do not serialize debug — debug is
749
749
  // a runtime concern, not part of the structural graph.
750
750
  _State_debugRef.set(this, { current: null });
751
+ // Out-of-band tags applied to this State (#186). Tags are visualization
752
+ // and debugger-tooling metadata — they don't affect runtime transition
753
+ // lookup or `equivalentOn` comparisons. Stored as a Set for de-duplication;
754
+ // exposed via the `tags` getter as a frozen array snapshot. Lives on the
755
+ // State INSTANCE so wrappers (from `withOverriddenHaltState`) carry tags
756
+ // independently of their bare's tag set — see the #175 sharing test in
757
+ // State.spec.ts.
758
+ _State_tags.set(this, new Set());
751
759
  if (stateDefinition) {
752
760
  const keys = Object.getOwnPropertyNames(stateDefinition);
753
761
  if (keys.length) {
@@ -831,8 +839,38 @@ class State {
831
839
  }
832
840
  __classPrivateFieldGet$1(this, _State_debugRef, "f").current = new DebugConfig(this, value);
833
841
  }
842
+ /**
843
+ * Add one or more tags to this State (#186). Tags are out-of-band metadata
844
+ * used by visualization (`toMermaid` emits `classDef`/`class` lines) and
845
+ * debugger tooling — they don't affect runtime transition lookup,
846
+ * `equivalentOn` comparisons, or any structural identity. Chainable.
847
+ */
848
+ tag(...tags) {
849
+ for (const t of tags) {
850
+ __classPrivateFieldGet$1(this, _State_tags, "f").add(t);
851
+ }
852
+ return this;
853
+ }
854
+ /**
855
+ * Remove one or more tags from this State (#186). Untagging a tag the
856
+ * State doesn't carry is a no-op. Chainable.
857
+ */
858
+ untag(...tags) {
859
+ for (const t of tags) {
860
+ __classPrivateFieldGet$1(this, _State_tags, "f").delete(t);
861
+ }
862
+ return this;
863
+ }
864
+ /**
865
+ * Frozen snapshot of this State's current tags (#186). The returned array
866
+ * is `Object.freeze`d — mutating it throws in strict mode (which TS-emitted
867
+ * code uses). Order matches insertion order of the underlying Set.
868
+ */
869
+ get tags() {
870
+ return Object.freeze([...__classPrivateFieldGet$1(this, _State_tags, "f")]);
871
+ }
834
872
  /** @internal — invoked by DebugConfig setters via module-private symbol. */
835
- [(_State_id = new WeakMap(), _State_name = new WeakMap(), _State_overriddenHaltState = new WeakMap(), _State_bareState = new WeakMap(), _State_symbolToDataMap = new WeakMap(), _State_debugRef = new WeakMap(), validateDebugFilter)](fieldName, filter) {
873
+ [(_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) {
836
874
  if (filter === undefined)
837
875
  return;
838
876
  // #108 part 2: `.after` on haltState has no semantic anchor — halt is
@@ -997,6 +1035,7 @@ class State {
997
1035
  frameId: null,
998
1036
  transitions: [],
999
1037
  overriddenHaltStateId: null,
1038
+ tags: [...__classPrivateFieldGet$1(state, _State_tags, "f")],
1000
1039
  };
1001
1040
  }
1002
1041
  continue;
@@ -1015,6 +1054,7 @@ class State {
1015
1054
  frameId: null,
1016
1055
  transitions: [],
1017
1056
  overriddenHaltStateId: __classPrivateFieldGet$1(overrideTarget, _State_id, "f"),
1057
+ tags: [...__classPrivateFieldGet$1(state, _State_tags, "f")],
1018
1058
  };
1019
1059
  bareIds.add(__classPrivateFieldGet$1(bareState, _State_id, "f"));
1020
1060
  queue.push(bareState);
@@ -1032,6 +1072,7 @@ class State {
1032
1072
  frameId: null,
1033
1073
  transitions: [],
1034
1074
  overriddenHaltStateId: null,
1075
+ tags: [...__classPrivateFieldGet$1(state, _State_tags, "f")],
1035
1076
  };
1036
1077
  nodes[__classPrivateFieldGet$1(state, _State_id, "f")] = node;
1037
1078
  let patternIx = 0;
@@ -1071,6 +1112,7 @@ class State {
1071
1112
  frameId: null,
1072
1113
  transitions: [],
1073
1114
  overriddenHaltStateId: null,
1115
+ tags: [...__classPrivateFieldGet$1(haltState, _State_tags, "f")],
1074
1116
  };
1075
1117
  }
1076
1118
  // Pass 2: For each bare, compute its forward-reachable set (following
@@ -1085,7 +1127,10 @@ class State {
1085
1127
  continue;
1086
1128
  }
1087
1129
  const node = nodes[id];
1088
- if (!node || node.isHalt || node.isWrapper) {
1130
+ // `nodes[id]` is always populated for `id` that the BFS reached, so
1131
+ // a defensive `!node` check would be dead. `isHalt` / `isWrapper`
1132
+ // are real boundaries — both stop reach-set expansion.
1133
+ if (node.isHalt || node.isWrapper) {
1089
1134
  continue;
1090
1135
  }
1091
1136
  reach.add(id);
@@ -1107,6 +1152,10 @@ class State {
1107
1152
  // sets share any state. Canonical representative = smallest bare-id in
1108
1153
  // the component.
1109
1154
  const ufParent = new Map();
1155
+ // Note: no path compression. The union policy below ("smaller id always
1156
+ // becomes root") keeps the tree flat — every union targets bares[0] as
1157
+ // the root, so any node's parent IS the root. Walking up never exceeds
1158
+ // one step. Path compression would be dead code under this invariant.
1110
1159
  const ufFind = (id) => {
1111
1160
  if (!ufParent.has(id)) {
1112
1161
  ufParent.set(id, id);
@@ -1115,13 +1164,6 @@ class State {
1115
1164
  while (ufParent.get(root) !== root) {
1116
1165
  root = ufParent.get(root);
1117
1166
  }
1118
- // Path compression
1119
- let cur = id;
1120
- while (ufParent.get(cur) !== root) {
1121
- const next = ufParent.get(cur);
1122
- ufParent.set(cur, root);
1123
- cur = next;
1124
- }
1125
1167
  return root;
1126
1168
  };
1127
1169
  const ufUnion = (a, b) => {
@@ -1192,6 +1234,7 @@ class State {
1192
1234
  frameId,
1193
1235
  transitions: [],
1194
1236
  overriddenHaltStateId: null,
1237
+ tags: [],
1195
1238
  };
1196
1239
  }
1197
1240
  return { initialId: __classPrivateFieldGet$1(initialState, _State_id, "f"), alphabets, nodes };
@@ -1263,6 +1306,9 @@ class State {
1263
1306
  // and assign `#name` directly to skip user-facing name validation.
1264
1307
  const bare = new _a(stateDefinition);
1265
1308
  __classPrivateFieldSet$1(bare, _State_name, node.name, "f");
1309
+ if (node.tags.length > 0) {
1310
+ bare.tag(...node.tags);
1311
+ }
1266
1312
  bareStates[nodeId] = bare;
1267
1313
  }
1268
1314
  // Pass 3: resolve every node to its final State (memoized + cycle-safe).
@@ -1288,6 +1334,13 @@ class State {
1288
1334
  const bare = getFinal(node.bareStateId);
1289
1335
  const override = getFinal(node.overriddenHaltStateId);
1290
1336
  state = bare.withOverriddenHaltState(override);
1337
+ // Apply wrapper-scoped tags (#186). Tags don't leak across wrappers
1338
+ // sharing a bare — the wrapper instance owns its own tag set, and
1339
+ // engine #175 memoization returns the same instance for the same
1340
+ // (bare, override) pair, so this is idempotent across rebuilds.
1341
+ if (node.tags.length > 0) {
1342
+ state.tag(...node.tags);
1343
+ }
1291
1344
  }
1292
1345
  else {
1293
1346
  state = bareStates[nodeId];
@@ -1530,6 +1583,16 @@ function toMermaid(graph) {
1530
1583
  bucket.push(node);
1531
1584
  }
1532
1585
  }
1586
+ // Build the visible-label string for a node — name plus, if tagged, a
1587
+ // `<br>tag1, tag2, ...` suffix so the rendered Mermaid shows both. Tags
1588
+ // are the source of truth on the GraphNode; `<br>` is the universal
1589
+ // Mermaid line-break that works across renderers without `classDef`-
1590
+ // pseudo-element hacks (#186).
1591
+ const labelOf = (node) => {
1592
+ if (node.tags.length === 0)
1593
+ return node.name;
1594
+ return `${node.name}<br>${node.tags.join(', ')}`;
1595
+ };
1533
1596
  // 1. Emit top-level nodes (real halt, non-wrapper regulars outside any frame).
1534
1597
  for (const node of topLevelNodes) {
1535
1598
  const mid = mermaidIdFor(node.id);
@@ -1537,12 +1600,12 @@ function toMermaid(graph) {
1537
1600
  lines.push(` ${mid}(((halt)))`);
1538
1601
  }
1539
1602
  else {
1540
- lines.push(` ${mid}["${node.name}"]`);
1603
+ lines.push(` ${mid}["${labelOf(node)}"]`);
1541
1604
  }
1542
1605
  }
1543
1606
  // 2. Emit wrappers at top level.
1544
1607
  for (const wrapper of wrapperNodes) {
1545
- lines.push(` ${mermaidIdFor(wrapper.id)}[["${wrapper.name}"]]`);
1608
+ lines.push(` ${mermaidIdFor(wrapper.id)}[["${labelOf(wrapper)}"]]`);
1546
1609
  }
1547
1610
  // 3. `idle` sentinel.
1548
1611
  lines.push(' idle([idle])');
@@ -1560,12 +1623,13 @@ function toMermaid(graph) {
1560
1623
  lines.push(` subgraph ${frameSubgraphId(frameId)}["${label}"]`);
1561
1624
  // Inner nodes — sort by id for determinism.
1562
1625
  for (const node of (nodesByFrame.get(frameId) ?? []).slice().sort((a, b) => a.id - b.id)) {
1563
- lines.push(` ${mermaidIdFor(node.id)}["${node.name}"]`);
1626
+ lines.push(` ${mermaidIdFor(node.id)}["${labelOf(node)}"]`);
1564
1627
  }
1628
+ // Every frame has a halt marker — `State.toGraph`'s frame-emit pass
1629
+ // creates one for each frame. Non-null assertion is safe; a defensive
1630
+ // null check would be dead.
1565
1631
  const haltMarker = haltMarkerByFrame.get(frameId);
1566
- if (haltMarker) {
1567
- lines.push(` ${mermaidIdFor(haltMarker.id)}(((halt)))`);
1568
- }
1632
+ lines.push(` ${mermaidIdFor(haltMarker.id)}(((halt)))`);
1569
1633
  lines.push(' end');
1570
1634
  }
1571
1635
  // 5. Enter arrow.
@@ -1618,29 +1682,31 @@ function toMermaid(graph) {
1618
1682
  for (const frameId of frameIds) {
1619
1683
  if (!haltMarkerHasIncoming.get(frameId))
1620
1684
  continue;
1621
- // Return arrow — collapsed `&` ribbon over all wrappers calling this frame.
1685
+ // Return arrow — collapsed `&` ribbon over all wrappers calling this
1686
+ // frame. Frames only exist because at least one wrapper's bareStateId
1687
+ // points to a bare in the frame, so `callingWrappers` is always
1688
+ // non-empty for any frame that reached this code path.
1622
1689
  const callingWrappers = wrapperNodes.filter((w) => {
1623
- if (w.bareStateId === null)
1624
- return false;
1625
1690
  const bare = graph.nodes[w.bareStateId];
1626
- return !!bare && bare.frameId === frameId;
1691
+ return bare.frameId === frameId;
1627
1692
  });
1628
- if (callingWrappers.length > 0) {
1629
- const targets = callingWrappers
1630
- .slice()
1631
- .sort((a, b) => a.id - b.id)
1632
- .map((w) => mermaidIdFor(w.id))
1633
- .join(' & ');
1634
- lines.push(` ${frameSubgraphId(frameId)} -. "return" .-> ${targets}`);
1635
- }
1693
+ const targets = callingWrappers
1694
+ .slice()
1695
+ .sort((a, b) => a.id - b.id)
1696
+ .map((w) => mermaidIdFor(w.id))
1697
+ .join(' & ');
1698
+ lines.push(` ${frameSubgraphId(frameId)} -. "return" .-> ${targets}`);
1636
1699
  if (hasNonWrapperEntry.get(frameId)) {
1637
1700
  lines.push(` ${frameSubgraphId(frameId)} -. "halt" .-> s0`);
1638
1701
  }
1639
1702
  }
1640
1703
  // 8. Wrapper-to-override arrows (regular solid).
1704
+ //
1705
+ // `wrapper.overriddenHaltStateId` is always non-null on wrapper nodes
1706
+ // (set by `State.toGraph` for every `isWrapper: true` node — it's the
1707
+ // wrapper's override target, which a wrapper by definition has). The
1708
+ // non-null assertion is safe; a defensive null check would be dead.
1641
1709
  for (const wrapper of wrapperNodes) {
1642
- if (wrapper.overriddenHaltStateId === null)
1643
- continue;
1644
1710
  lines.push(` ${mermaidIdFor(wrapper.id)} --> ${mermaidIdFor(wrapper.overriddenHaltStateId)}`);
1645
1711
  }
1646
1712
  // 9. Regular transitions for non-wrapper non-halt-marker non-halt nodes.
@@ -1656,14 +1722,75 @@ function toMermaid(graph) {
1656
1722
  lines.push(` ${mermaidIdFor(node.id)} -- "${label}" --> ${mermaidIdFor(t.nextStateId)}`);
1657
1723
  }
1658
1724
  }
1725
+ // 10. Tags (#186) — emit one `classDef tag_<name> fill:#...` per unique
1726
+ // tag across all nodes, then one `class <ids> tag_<name>` line per
1727
+ // tag listing every node that carries it (comma-joined for compact
1728
+ // emit). Tag-name → CSS-class identifier sanitization replaces any
1729
+ // char outside `[A-Za-z0-9_-]` with `_`; tag-name uniqueness in the
1730
+ // emit assumes user tags are already distinct after sanitization
1731
+ // (collisions are user error).
1732
+ emitTagAnnotations(lines, nodes);
1659
1733
  return lines.join('\n');
1660
1734
  }
1735
+ // Default Mermaid `classDef` palette — 6 visually distinct fill+stroke pairs,
1736
+ // selected by tag-name hash so multi-tag diagrams look readable out of the
1737
+ // box without user configuration. Users who want different colors can edit
1738
+ // the emitted Mermaid before rendering or override post-emit.
1739
+ const TAG_PALETTE = [
1740
+ ['#fef3c7', '#92400e'], // amber
1741
+ ['#dbeafe', '#1e40af'], // blue
1742
+ ['#dcfce7', '#166534'], // green
1743
+ ['#fce7f3', '#9d174d'], // pink
1744
+ ['#ede9fe', '#5b21b6'], // violet
1745
+ ['#fee2e2', '#991b1b'], // red
1746
+ ];
1747
+ function sanitizeTagName(tag) {
1748
+ return tag.replace(/[^A-Za-z0-9_-]/g, '_');
1749
+ }
1750
+ function tagColor(tag) {
1751
+ // Cheap deterministic hash — sum of char codes mod palette length. Stable
1752
+ // across runs; same tag name always picks the same color.
1753
+ let h = 0;
1754
+ for (let i = 0; i < tag.length; i += 1) {
1755
+ h = (h + tag.charCodeAt(i)) % TAG_PALETTE.length;
1756
+ }
1757
+ return TAG_PALETTE[h];
1758
+ }
1759
+ function emitTagAnnotations(lines, nodes) {
1760
+ // Collect nodes per tag in node-id order so output is deterministic.
1761
+ const nodesByTag = new Map();
1762
+ for (const node of nodes) {
1763
+ for (const tag of node.tags) {
1764
+ let list = nodesByTag.get(tag);
1765
+ if (!list) {
1766
+ list = [];
1767
+ nodesByTag.set(tag, list);
1768
+ }
1769
+ list.push(node.id);
1770
+ }
1771
+ }
1772
+ if (nodesByTag.size === 0)
1773
+ return;
1774
+ const sortedTags = [...nodesByTag.keys()].sort();
1775
+ for (const tag of sortedTags) {
1776
+ const sanitized = sanitizeTagName(tag);
1777
+ const [fill, stroke] = tagColor(tag);
1778
+ lines.push(` classDef tag_${sanitized} fill:${fill},stroke:${stroke}`);
1779
+ }
1780
+ for (const tag of sortedTags) {
1781
+ const sanitized = sanitizeTagName(tag);
1782
+ const ids = nodesByTag.get(tag).map((id) => mermaidIdFor(id)).join(',');
1783
+ lines.push(` class ${ids} tag_${sanitized}`);
1784
+ }
1785
+ }
1661
1786
  // Helper: identify "the bare states" that anchor a frame's name. A bare is a
1662
1787
  // node referenced as some wrapper's `bareStateId`. Body states (also in-frame
1663
1788
  // but not bare) are excluded from the frame label.
1789
+ //
1790
+ // The caller in `toMermaid` only passes non-wrapper, non-halt-marker nodes
1791
+ // (wrappers go to a separate bucket; halt markers go to `haltMarkerByFrame`).
1792
+ // No defensive `isHalt` / `isWrapper` guards needed here.
1664
1793
  function isFrameBare(node, graph) {
1665
- if (node.isWrapper || node.isHalt)
1666
- return false;
1667
1794
  for (const other of Object.values(graph.nodes)) {
1668
1795
  if (other.isWrapper && other.bareStateId === node.id) {
1669
1796
  return true;
@@ -1705,6 +1832,33 @@ const haltArrowRegex = /^w_(\d+)\s+-\.\s+"halt"\s+\.->\s+s0$/;
1705
1832
  // First capture char anchored as \S to avoid polynomial backtracking between
1706
1833
  // the preceding \s* and a permissive (.+); see CodeQL js/polynomial-redos.
1707
1834
  const alphabetsRegex = /^%%\s*alphabets:\s*(\S.*)$/;
1835
+ // Tag annotation lines (#186). Matches both `classDef tag_<sanitized>` and
1836
+ // `class <id-list> tag_<sanitized>`. ClassDef declarations are decorative
1837
+ // (palette) and discarded on parse — toMermaid will regenerate them from
1838
+ // the tag set on re-emit. `class` lines carry the actual graph-node
1839
+ // assignments; we strip the `tag_` prefix and assign each tag to each
1840
+ // listed node's `tags` array.
1841
+ //
1842
+ // Inter-token gaps are fixed at single literal spaces (matching toMermaid's
1843
+ // canonical emit) rather than `\s+`. This avoids the polynomial-ReDoS
1844
+ // pattern CodeQL flags when `\s+` surrounds a content group (see also
1845
+ // `callArrowRegex` / `returnArrowRegex` tightening in PR #182).
1846
+ const classDefTagRegex = /^classDef tag_([A-Za-z0-9_-]+) .+$/;
1847
+ const classAssignTagRegex = /^class ([sc]\d+(?:,[sc]\d+)*) tag_([A-Za-z0-9_-]+)$/;
1848
+ // Splits a node label like `"A<br>hot, sampled"` into its name and tags (#186).
1849
+ // Labels without `<br>` have no tags. Tags are comma-joined; trimmed of
1850
+ // whitespace. The `<br>` is the single source of truth for tag-name parsing —
1851
+ // `class` lines are decorative-only and not consulted here.
1852
+ function splitLabelTags(label) {
1853
+ const brIx = label.indexOf('<br>');
1854
+ if (brIx < 0) {
1855
+ return { name: label, tags: [] };
1856
+ }
1857
+ const name = label.slice(0, brIx);
1858
+ const tagsStr = label.slice(brIx + '<br>'.length);
1859
+ const tags = tagsStr.split(',').map((t) => t.trim()).filter((t) => t.length > 0);
1860
+ return { name, tags };
1861
+ }
1708
1862
  function fromMermaid(text) {
1709
1863
  const lines = text.split('\n').map((l) => l.trim()).filter(Boolean);
1710
1864
  let alphabets = [];
@@ -1723,6 +1877,7 @@ function fromMermaid(text) {
1723
1877
  frameId: opts.frameId ?? null,
1724
1878
  transitions: [],
1725
1879
  overriddenHaltStateId: null,
1880
+ tags: opts.tags ? [...opts.tags] : [],
1726
1881
  };
1727
1882
  }
1728
1883
  else {
@@ -1738,6 +1893,12 @@ function fromMermaid(text) {
1738
1893
  nodes[id].bareStateId = opts.bareStateId;
1739
1894
  if (opts.frameId !== undefined)
1740
1895
  nodes[id].frameId = opts.frameId;
1896
+ if (opts.tags !== undefined) {
1897
+ for (const t of opts.tags) {
1898
+ if (!nodes[id].tags.includes(t))
1899
+ nodes[id].tags.push(t);
1900
+ }
1901
+ }
1741
1902
  }
1742
1903
  return nodes[id];
1743
1904
  };
@@ -1750,6 +1911,11 @@ function fromMermaid(text) {
1750
1911
  alphabets = JSON.parse(am[1]);
1751
1912
  continue;
1752
1913
  }
1914
+ // Tag annotations (#186) — classDef lines are decorative and skipped;
1915
+ // `class` lines are parsed in the edge pass since they reference nodes
1916
+ // by id and need those nodes already created in the first pass.
1917
+ if (classDefTagRegex.test(line))
1918
+ continue;
1753
1919
  const sgStart = line.match(subgraphStartRegex);
1754
1920
  if (sgStart) {
1755
1921
  currentFrameId = Number(sgStart[1]);
@@ -1775,17 +1941,21 @@ function fromMermaid(text) {
1775
1941
  }
1776
1942
  const wm = line.match(wrappedNodeRegex);
1777
1943
  if (wm) {
1944
+ const { name, tags } = splitLabelTags(wm[2]);
1778
1945
  ensureNode(parseMermaidId(wm[1]), {
1779
- name: wm[2],
1946
+ name,
1780
1947
  isWrapper: true,
1948
+ tags,
1781
1949
  });
1782
1950
  continue;
1783
1951
  }
1784
1952
  const rm = line.match(regularNodeRegex);
1785
1953
  if (rm) {
1954
+ const { name, tags } = splitLabelTags(rm[2]);
1786
1955
  ensureNode(parseMermaidId(rm[1]), {
1787
- name: rm[2],
1956
+ name,
1788
1957
  frameId: currentFrameId,
1958
+ tags,
1789
1959
  });
1790
1960
  continue;
1791
1961
  }
@@ -1802,6 +1972,19 @@ function fromMermaid(text) {
1802
1972
  if (returnArrowRegex.test(line) || haltArrowRegex.test(line)) {
1803
1973
  continue;
1804
1974
  }
1975
+ // Tag class-assignment line (#186): `class s1,s5 tag_hot` — adds
1976
+ // the tag to each listed node. Tag-name preserved as written
1977
+ // (sanitization on emit is lossy in principle; on parse we don't
1978
+ // un-sanitize, since the original could have any characters).
1979
+ const tagMatch = line.match(classAssignTagRegex);
1980
+ if (tagMatch) {
1981
+ const ids = tagMatch[1].split(',');
1982
+ const tagName = tagMatch[2];
1983
+ for (const idStr of ids) {
1984
+ ensureNode(parseMermaidId(idStr), { tags: [tagName] });
1985
+ }
1986
+ continue;
1987
+ }
1805
1988
  // `call` arrow — sets bareStateId on each source wrapper.
1806
1989
  const cm = line.match(callArrowRegex);
1807
1990
  if (cm) {
@@ -1818,7 +2001,12 @@ function fromMermaid(text) {
1818
2001
  if (wo) {
1819
2002
  const fromId = parseMermaidId(wo[1]);
1820
2003
  const toId = parseMermaidId(wo[2]);
1821
- if (nodes[fromId] && nodes[fromId].isWrapper) {
2004
+ // The wrapper-override regex only matches `sN --> sM` (unlabeled);
2005
+ // since `toMermaid` only emits this shape from wrappers, the source
2006
+ // is guaranteed to be a wrapper if `fromMermaid`'s input came from
2007
+ // `toMermaid`. `nodes[fromId]` is always populated (first pass emits
2008
+ // node declarations before any edge parsing).
2009
+ if (nodes[fromId].isWrapper) {
1822
2010
  nodes[fromId].overriddenHaltStateId = toId;
1823
2011
  continue;
1824
2012
  }
@@ -18,6 +18,7 @@ export type GraphNode = {
18
18
  bareStateId: number | null;
19
19
  frameId: number | null;
20
20
  isHaltMarker: boolean;
21
+ tags: string[];
21
22
  };
22
23
  export type Graph = {
23
24
  initialId: number;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@turing-machine-js/machine",
3
- "version": "7.0.0-alpha.2",
3
+ "version": "7.0.0-alpha.3",
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": "163c0f818365241bac2c2de5e489124056703d8b"
41
+ "gitHead": "d0c25d94e1f3caca1c68dd08072a3e21685e2428"
42
42
  }