@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 +40 -0
- package/README.md +36 -1
- package/dist/classes/State.d.ts +18 -0
- package/dist/index.cjs +223 -35
- package/dist/index.mjs +223 -35
- package/dist/utilities/graph.d.ts +1 -0
- package/package.json +2 -2
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
|
|
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).
|
package/dist/classes/State.d.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
1693
|
+
return bare.frameId === frameId;
|
|
1629
1694
|
});
|
|
1630
|
-
|
|
1631
|
-
|
|
1632
|
-
|
|
1633
|
-
|
|
1634
|
-
|
|
1635
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
1691
|
+
return bare.frameId === frameId;
|
|
1627
1692
|
});
|
|
1628
|
-
|
|
1629
|
-
|
|
1630
|
-
|
|
1631
|
-
|
|
1632
|
-
|
|
1633
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@turing-machine-js/machine",
|
|
3
|
-
"version": "7.0.0-alpha.
|
|
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": "
|
|
41
|
+
"gitHead": "d0c25d94e1f3caca1c68dd08072a3e21685e2428"
|
|
42
42
|
}
|