@turing-machine-js/machine 7.0.0-alpha.1 → 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 +90 -0
- package/README.md +65 -24
- package/dist/classes/State.d.ts +18 -0
- package/dist/index.cjs +705 -312
- package/dist/index.mjs +705 -312
- package/dist/utilities/graph.d.ts +4 -1
- package/package.json +2 -2
package/dist/index.mjs
CHANGED
|
@@ -347,7 +347,7 @@ var __classPrivateFieldGet$2 = (undefined && undefined.__classPrivateFieldGet) |
|
|
|
347
347
|
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");
|
|
348
348
|
return kind === "m" ? f : kind === "a" ? f.call(receiver) : f ? f.value : state.get(receiver);
|
|
349
349
|
};
|
|
350
|
-
var _a, _TapeBlock_symbolToPatternListMap, _TapeBlock_lock, _TapeBlock_tapes, _TapeBlock_generateSymbolHint, _TapeBlock_buildPatternList, _TapeBlock_getSymbolForPatternList, _TapeBlock_symbol;
|
|
350
|
+
var _a$1, _TapeBlock_symbolToPatternListMap, _TapeBlock_lock, _TapeBlock_tapes, _TapeBlock_generateSymbolHint, _TapeBlock_buildPatternList, _TapeBlock_getSymbolForPatternList, _TapeBlock_symbol;
|
|
351
351
|
const symbolToPatternListMapSymbol = Symbol('symbol for symbolToPatternListMap setter');
|
|
352
352
|
const lockSymbol = Symbol('capture symbol');
|
|
353
353
|
class TapeBlock {
|
|
@@ -386,7 +386,7 @@ class TapeBlock {
|
|
|
386
386
|
symbol = storedPatternListSymbol;
|
|
387
387
|
}
|
|
388
388
|
else {
|
|
389
|
-
symbol = Symbol(__classPrivateFieldGet$2(_a, _a, "f", _TapeBlock_generateSymbolHint).call(_a, patternList));
|
|
389
|
+
symbol = Symbol(__classPrivateFieldGet$2(_a$1, _a$1, "f", _TapeBlock_generateSymbolHint).call(_a$1, patternList));
|
|
390
390
|
__classPrivateFieldGet$2(this, _TapeBlock_symbolToPatternListMap, "f").set(symbol, patternList);
|
|
391
391
|
}
|
|
392
392
|
return symbol;
|
|
@@ -486,10 +486,10 @@ class TapeBlock {
|
|
|
486
486
|
clone(cloneTapes = false) {
|
|
487
487
|
let tapeBlock;
|
|
488
488
|
if (cloneTapes) {
|
|
489
|
-
tapeBlock = _a.fromTapes(this.tapes.map((tape) => new Tape(tape)));
|
|
489
|
+
tapeBlock = _a$1.fromTapes(this.tapes.map((tape) => new Tape(tape)));
|
|
490
490
|
}
|
|
491
491
|
else {
|
|
492
|
-
tapeBlock = _a.fromAlphabets(this.alphabets);
|
|
492
|
+
tapeBlock = _a$1.fromAlphabets(this.alphabets);
|
|
493
493
|
}
|
|
494
494
|
tapeBlock[symbolToPatternListMapSymbol] = __classPrivateFieldGet$2(this, _TapeBlock_symbolToPatternListMap, "f");
|
|
495
495
|
return tapeBlock;
|
|
@@ -518,12 +518,12 @@ class TapeBlock {
|
|
|
518
518
|
}
|
|
519
519
|
}
|
|
520
520
|
}
|
|
521
|
-
_a = TapeBlock;
|
|
521
|
+
_a$1 = TapeBlock;
|
|
522
522
|
TapeBlock.fromAlphabets = (alphabets) => {
|
|
523
|
-
return new _a({ alphabets });
|
|
523
|
+
return new _a$1({ alphabets });
|
|
524
524
|
};
|
|
525
525
|
TapeBlock.fromTapes = (tapes) => {
|
|
526
|
-
return new _a({ tapes });
|
|
526
|
+
return new _a$1({ tapes });
|
|
527
527
|
};
|
|
528
528
|
_TapeBlock_generateSymbolHint = { value: (patternList) => JSON.stringify(patternList
|
|
529
529
|
.map((pattern) => pattern
|
|
@@ -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, _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) {
|
|
@@ -760,7 +768,7 @@ class State {
|
|
|
760
768
|
symbols.forEach((symbol) => {
|
|
761
769
|
const { nextState } = stateDefinition[symbol];
|
|
762
770
|
const nextStateLocal = nextState ?? this;
|
|
763
|
-
if (!(nextStateLocal instanceof
|
|
771
|
+
if (!(nextStateLocal instanceof _a) && !(nextStateLocal instanceof Reference)) {
|
|
764
772
|
throw new Error('invalid nextState');
|
|
765
773
|
}
|
|
766
774
|
let { command } = stateDefinition[symbol];
|
|
@@ -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
|
|
@@ -880,16 +918,40 @@ class State {
|
|
|
880
918
|
throw new Error(`No nextState for symbol at state named ${__classPrivateFieldGet$1(this, _State_id, "f")}`);
|
|
881
919
|
}
|
|
882
920
|
withOverriddenHaltState(overriddenHaltState) {
|
|
883
|
-
//
|
|
884
|
-
//
|
|
885
|
-
//
|
|
886
|
-
//
|
|
887
|
-
const
|
|
888
|
-
|
|
889
|
-
|
|
921
|
+
// Unwrap `this` if it's itself a wrapper — the chain's inner overrides
|
|
922
|
+
// are dead at runtime anyway (only the outermost `.wohs()`'s override is
|
|
923
|
+
// pushed onto the halt-stack on entry; verified empirically). Composite
|
|
924
|
+
// name reflects runtime behavior, not construction history. See #176.
|
|
925
|
+
const bare = __classPrivateFieldGet$1(this, _State_bareState, "f") ?? this;
|
|
926
|
+
// Memoize by (bare, override) so identical args return the same instance
|
|
927
|
+
// (#175). The cache uses WeakMaps + WeakRefs so cached wrappers can be
|
|
928
|
+
// GC'd when nothing else holds them. Compounds with the chain-collapse
|
|
929
|
+
// above: `A.wohs(t1).wohs(t2)` keys as (A, t2) after the unwrap, hitting
|
|
930
|
+
// the same cache slot as a direct `A.wohs(t2)`.
|
|
931
|
+
let innerCache = __classPrivateFieldGet$1(_a, _a, "f", _State_wrapperCache).get(bare);
|
|
932
|
+
if (innerCache !== undefined) {
|
|
933
|
+
const ref = innerCache.get(overriddenHaltState);
|
|
934
|
+
if (ref !== undefined) {
|
|
935
|
+
const cached = ref.deref();
|
|
936
|
+
if (cached !== undefined) {
|
|
937
|
+
return cached;
|
|
938
|
+
}
|
|
939
|
+
}
|
|
940
|
+
}
|
|
941
|
+
else {
|
|
942
|
+
innerCache = new WeakMap();
|
|
943
|
+
__classPrivateFieldGet$1(_a, _a, "f", _State_wrapperCache).set(bare, innerCache);
|
|
944
|
+
}
|
|
945
|
+
// Cache miss — construct with no name, then overwrite #name directly
|
|
946
|
+
// (composed names contain `(` and `)` which the constructor's user-facing
|
|
947
|
+
// validation would reject; private-field access bypasses that).
|
|
948
|
+
const state = new _a();
|
|
949
|
+
__classPrivateFieldSet$1(state, _State_name, `${bare.name}(${overriddenHaltState.name})`, "f");
|
|
950
|
+
__classPrivateFieldSet$1(state, _State_symbolToDataMap, __classPrivateFieldGet$1(bare, _State_symbolToDataMap, "f"), "f");
|
|
890
951
|
__classPrivateFieldSet$1(state, _State_overriddenHaltState, overriddenHaltState, "f");
|
|
891
|
-
__classPrivateFieldSet$1(state, _State_debugRef, __classPrivateFieldGet$1(
|
|
892
|
-
__classPrivateFieldSet$1(state, _State_bareState,
|
|
952
|
+
__classPrivateFieldSet$1(state, _State_debugRef, __classPrivateFieldGet$1(bare, _State_debugRef, "f"), "f");
|
|
953
|
+
__classPrivateFieldSet$1(state, _State_bareState, bare, "f");
|
|
954
|
+
innerCache.set(overriddenHaltState, new WeakRef(state));
|
|
893
955
|
return state;
|
|
894
956
|
}
|
|
895
957
|
// Single-state introspection — no traversal, no tapeBlock required.
|
|
@@ -902,7 +964,7 @@ class State {
|
|
|
902
964
|
for (const [sym, { command, nextState }] of __classPrivateFieldGet$1(state, _State_symbolToDataMap, "f")) {
|
|
903
965
|
let target = null;
|
|
904
966
|
try {
|
|
905
|
-
target = nextState instanceof
|
|
967
|
+
target = nextState instanceof _a ? nextState : nextState.ref;
|
|
906
968
|
}
|
|
907
969
|
catch {
|
|
908
970
|
target = null; // unbound Reference
|
|
@@ -926,204 +988,278 @@ class State {
|
|
|
926
988
|
transitions,
|
|
927
989
|
};
|
|
928
990
|
}
|
|
929
|
-
// Walks the State graph and emits a `Graph` data structure. v7
|
|
930
|
-
//
|
|
931
|
-
//
|
|
932
|
-
//
|
|
933
|
-
//
|
|
934
|
-
//
|
|
935
|
-
//
|
|
936
|
-
//
|
|
991
|
+
// Walks the State graph and emits a `Graph` data structure. v7 callable-
|
|
992
|
+
// subtree emit shape (#174):
|
|
993
|
+
//
|
|
994
|
+
// Each `withOverriddenHaltState` wrapper produces TWO graph nodes:
|
|
995
|
+
// - A wrapper node (`isWrapper: true`, `[[composite-name]]` shape) — the
|
|
996
|
+
// call site. No transitions of its own. `bareStateId` points to the
|
|
997
|
+
// bare's GraphNode; `overriddenHaltStateId` points to the override
|
|
998
|
+
// target's GraphNode.
|
|
999
|
+
// - A bare node (`isWrapper: false`, regular shape) — the callable body.
|
|
1000
|
+
// Has the bare's transitions. Shared across all wrappers that wrap
|
|
1001
|
+
// this bare (no per-context duplication).
|
|
937
1002
|
//
|
|
938
|
-
//
|
|
939
|
-
//
|
|
940
|
-
// halt
|
|
1003
|
+
// Frames are computed via union-find on bare reachability: two bares whose
|
|
1004
|
+
// forward-reachable sets overlap merge into one frame. Each frame contains
|
|
1005
|
+
// its bares + body states + a single halt marker (id = `-frameId`). The
|
|
1006
|
+
// canonical `frameId` is the smallest bare-id in the component.
|
|
1007
|
+
//
|
|
1008
|
+
// Halt-bound transitions of any in-frame state are retargeted to the
|
|
1009
|
+
// frame's halt marker. The frame's `subtree -. return .-> wrapper` and
|
|
1010
|
+
// `subtree -. halt .-> s0` arrows are demand-emitted by `toMermaid` from
|
|
1011
|
+
// the frame structure; they're not stored as graph edges.
|
|
941
1012
|
static toGraph(initialState, tapeBlock) {
|
|
942
1013
|
const nodes = {};
|
|
943
1014
|
const alphabets = tapeBlock.alphabets.map((alphabet) => alphabet.symbols);
|
|
944
|
-
//
|
|
945
|
-
//
|
|
946
|
-
|
|
947
|
-
const
|
|
948
|
-
const
|
|
949
|
-
// The `initialId` is the user-passed start. If it's a wrapper, the
|
|
950
|
-
// collapsed graph node uses its `#id`; otherwise its own `#id`.
|
|
951
|
-
const initialId = __classPrivateFieldGet$1(initialState, _State_id, "f");
|
|
952
|
-
const queue = [];
|
|
953
|
-
// Decide how to enqueue the start: if it's a wrapper, enqueue its bare with
|
|
954
|
-
// the wrapper as context; otherwise enqueue the state itself.
|
|
955
|
-
if (__classPrivateFieldGet$1(initialState, _State_overriddenHaltState, "f") && __classPrivateFieldGet$1(initialState, _State_bareState, "f")) {
|
|
956
|
-
queue.push({ state: __classPrivateFieldGet$1(initialState, _State_bareState, "f"), wrapperContext: initialState });
|
|
957
|
-
}
|
|
958
|
-
else {
|
|
959
|
-
queue.push({ state: initialState, wrapperContext: null });
|
|
960
|
-
}
|
|
1015
|
+
// Pass 1: BFS-discover all reachable States; emit one GraphNode per State
|
|
1016
|
+
// (wrapper or bare/regular). Wrappers and bares are separate nodes.
|
|
1017
|
+
const visited = new Set();
|
|
1018
|
+
const queue = [initialState];
|
|
1019
|
+
const bareIds = new Set(); // ids referenced as a wrapper's bareStateId
|
|
961
1020
|
while (queue.length > 0) {
|
|
962
|
-
const
|
|
1021
|
+
const state = queue.shift();
|
|
1022
|
+
if (visited.has(__classPrivateFieldGet$1(state, _State_id, "f"))) {
|
|
1023
|
+
continue;
|
|
1024
|
+
}
|
|
1025
|
+
visited.add(__classPrivateFieldGet$1(state, _State_id, "f"));
|
|
963
1026
|
if (state.isHalt) {
|
|
964
|
-
// Real halt — always id 0, single node.
|
|
965
1027
|
if (!(0 in nodes)) {
|
|
966
1028
|
nodes[0] = {
|
|
967
1029
|
id: 0,
|
|
968
1030
|
name: __classPrivateFieldGet$1(state, _State_name, "f"),
|
|
969
1031
|
isHalt: true,
|
|
970
1032
|
isHaltMarker: false,
|
|
971
|
-
|
|
1033
|
+
isWrapper: false,
|
|
1034
|
+
bareStateId: null,
|
|
1035
|
+
frameId: null,
|
|
972
1036
|
transitions: [],
|
|
973
1037
|
overriddenHaltStateId: null,
|
|
1038
|
+
tags: [...__classPrivateFieldGet$1(state, _State_tags, "f")],
|
|
974
1039
|
};
|
|
975
1040
|
}
|
|
976
1041
|
continue;
|
|
977
1042
|
}
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
const
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
const haltMarkerId = haltMarkerIdFor(wrapperContext);
|
|
986
|
-
const overrideTarget = __classPrivateFieldGet$1(wrapperContext, _State_overriddenHaltState, "f");
|
|
987
|
-
// The override target's collapsed id: if the override is itself a
|
|
988
|
-
// wrapper, its graph node id is `overrideTarget.#id` (its own wrapper
|
|
989
|
-
// id); otherwise its own bare id.
|
|
990
|
-
const overrideGraphId = __classPrivateFieldGet$1(overrideTarget, _State_overriddenHaltState, "f")
|
|
991
|
-
? wrapperGraphId(overrideTarget)
|
|
992
|
-
: __classPrivateFieldGet$1(overrideTarget, _State_id, "f");
|
|
993
|
-
// Emit the halt-marker node if not already present (one per wrapper).
|
|
994
|
-
if (!(haltMarkerId in nodes)) {
|
|
995
|
-
nodes[haltMarkerId] = {
|
|
996
|
-
id: haltMarkerId,
|
|
997
|
-
name: 'halt',
|
|
998
|
-
isHalt: true,
|
|
999
|
-
isHaltMarker: true,
|
|
1000
|
-
isWrapped: false,
|
|
1001
|
-
transitions: [],
|
|
1002
|
-
overriddenHaltStateId: null,
|
|
1003
|
-
};
|
|
1004
|
-
}
|
|
1005
|
-
// Build the collapsed node.
|
|
1006
|
-
const collapsedNode = {
|
|
1007
|
-
id: collapsedId,
|
|
1008
|
-
name: __classPrivateFieldGet$1(state, _State_name, "f"),
|
|
1043
|
+
// Wrapper? Emit wrapper node + queue bare and override target.
|
|
1044
|
+
if (__classPrivateFieldGet$1(state, _State_overriddenHaltState, "f") !== null && __classPrivateFieldGet$1(state, _State_bareState, "f") !== null) {
|
|
1045
|
+
const bareState = __classPrivateFieldGet$1(state, _State_bareState, "f");
|
|
1046
|
+
const overrideTarget = __classPrivateFieldGet$1(state, _State_overriddenHaltState, "f");
|
|
1047
|
+
nodes[__classPrivateFieldGet$1(state, _State_id, "f")] = {
|
|
1048
|
+
id: __classPrivateFieldGet$1(state, _State_id, "f"),
|
|
1049
|
+
name: __classPrivateFieldGet$1(state, _State_name, "f"), // composite name like "A(target)"
|
|
1009
1050
|
isHalt: false,
|
|
1010
1051
|
isHaltMarker: false,
|
|
1011
|
-
|
|
1052
|
+
isWrapper: true,
|
|
1053
|
+
bareStateId: __classPrivateFieldGet$1(bareState, _State_id, "f"),
|
|
1054
|
+
frameId: null,
|
|
1012
1055
|
transitions: [],
|
|
1013
|
-
overriddenHaltStateId:
|
|
1056
|
+
overriddenHaltStateId: __classPrivateFieldGet$1(overrideTarget, _State_id, "f"),
|
|
1057
|
+
tags: [...__classPrivateFieldGet$1(state, _State_tags, "f")],
|
|
1014
1058
|
};
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
let target;
|
|
1019
|
-
try {
|
|
1020
|
-
target = nextState instanceof State ? nextState : nextState.ref;
|
|
1021
|
-
}
|
|
1022
|
-
catch {
|
|
1023
|
-
patternIx += 1;
|
|
1024
|
-
continue;
|
|
1025
|
-
}
|
|
1026
|
-
// Retarget transitions per Variant X conventions:
|
|
1027
|
-
// - target == haltState → halt marker (stays inside the subgraph)
|
|
1028
|
-
// - target == bare (self-loop) → the collapsed wrapper id
|
|
1029
|
-
// - target is itself a wrapper → that wrapper's collapsed id
|
|
1030
|
-
// - else → target's own id
|
|
1031
|
-
let nextStateId;
|
|
1032
|
-
if (target.isHalt) {
|
|
1033
|
-
nextStateId = haltMarkerId;
|
|
1034
|
-
}
|
|
1035
|
-
else if (target === state) {
|
|
1036
|
-
nextStateId = collapsedId;
|
|
1037
|
-
}
|
|
1038
|
-
else if (__classPrivateFieldGet$1(target, _State_overriddenHaltState, "f") && __classPrivateFieldGet$1(target, _State_bareState, "f")) {
|
|
1039
|
-
nextStateId = wrapperGraphId(target);
|
|
1040
|
-
queue.push({ state: __classPrivateFieldGet$1(target, _State_bareState, "f"), wrapperContext: target });
|
|
1041
|
-
}
|
|
1042
|
-
else {
|
|
1043
|
-
nextStateId = __classPrivateFieldGet$1(target, _State_id, "f");
|
|
1044
|
-
queue.push({ state: target, wrapperContext: null });
|
|
1045
|
-
}
|
|
1046
|
-
collapsedNode.transitions.push({
|
|
1047
|
-
pattern: decodePatternDescription(sym.description, alphabets),
|
|
1048
|
-
command: command.tapesCommands.map((tc) => ({
|
|
1049
|
-
symbol: decodeWriteSymbol(tc.symbol),
|
|
1050
|
-
movement: decodeMovement(tc.movement.description),
|
|
1051
|
-
})),
|
|
1052
|
-
nextStateId,
|
|
1053
|
-
id: `${collapsedId}-${patternIx}`,
|
|
1054
|
-
});
|
|
1055
|
-
patternIx += 1;
|
|
1056
|
-
}
|
|
1057
|
-
// Enqueue the override target so its own node is emitted.
|
|
1058
|
-
if (__classPrivateFieldGet$1(overrideTarget, _State_overriddenHaltState, "f") && __classPrivateFieldGet$1(overrideTarget, _State_bareState, "f")) {
|
|
1059
|
-
queue.push({ state: __classPrivateFieldGet$1(overrideTarget, _State_bareState, "f"), wrapperContext: overrideTarget });
|
|
1060
|
-
}
|
|
1061
|
-
else {
|
|
1062
|
-
queue.push({ state: overrideTarget, wrapperContext: null });
|
|
1063
|
-
}
|
|
1064
|
-
continue;
|
|
1065
|
-
}
|
|
1066
|
-
// Non-wrapper context: emit `state` as a regular node.
|
|
1067
|
-
if (__classPrivateFieldGet$1(state, _State_id, "f") in nodes) {
|
|
1059
|
+
bareIds.add(__classPrivateFieldGet$1(bareState, _State_id, "f"));
|
|
1060
|
+
queue.push(bareState);
|
|
1061
|
+
queue.push(overrideTarget);
|
|
1068
1062
|
continue;
|
|
1069
1063
|
}
|
|
1064
|
+
// Regular (or bare) state — build node with transitions.
|
|
1070
1065
|
const node = {
|
|
1071
1066
|
id: __classPrivateFieldGet$1(state, _State_id, "f"),
|
|
1072
1067
|
name: __classPrivateFieldGet$1(state, _State_name, "f"),
|
|
1073
1068
|
isHalt: false,
|
|
1074
1069
|
isHaltMarker: false,
|
|
1075
|
-
|
|
1070
|
+
isWrapper: false,
|
|
1071
|
+
bareStateId: null,
|
|
1072
|
+
frameId: null,
|
|
1076
1073
|
transitions: [],
|
|
1077
1074
|
overriddenHaltStateId: null,
|
|
1075
|
+
tags: [...__classPrivateFieldGet$1(state, _State_tags, "f")],
|
|
1078
1076
|
};
|
|
1079
1077
|
nodes[__classPrivateFieldGet$1(state, _State_id, "f")] = node;
|
|
1080
1078
|
let patternIx = 0;
|
|
1081
1079
|
for (const [sym, { command, nextState }] of __classPrivateFieldGet$1(state, _State_symbolToDataMap, "f")) {
|
|
1082
1080
|
let target;
|
|
1083
1081
|
try {
|
|
1084
|
-
target = nextState instanceof
|
|
1082
|
+
target = nextState instanceof _a ? nextState : nextState.ref;
|
|
1085
1083
|
}
|
|
1086
1084
|
catch {
|
|
1087
1085
|
patternIx += 1;
|
|
1088
1086
|
continue;
|
|
1089
1087
|
}
|
|
1090
|
-
let nextStateId;
|
|
1091
|
-
if (__classPrivateFieldGet$1(target, _State_overriddenHaltState, "f") && __classPrivateFieldGet$1(target, _State_bareState, "f")) {
|
|
1092
|
-
// Transition into a wrapper — use its collapsed id.
|
|
1093
|
-
nextStateId = wrapperGraphId(target);
|
|
1094
|
-
queue.push({ state: __classPrivateFieldGet$1(target, _State_bareState, "f"), wrapperContext: target });
|
|
1095
|
-
}
|
|
1096
|
-
else {
|
|
1097
|
-
nextStateId = __classPrivateFieldGet$1(target, _State_id, "f");
|
|
1098
|
-
queue.push({ state: target, wrapperContext: null });
|
|
1099
|
-
}
|
|
1100
1088
|
node.transitions.push({
|
|
1101
1089
|
pattern: decodePatternDescription(sym.description, alphabets),
|
|
1102
1090
|
command: command.tapesCommands.map((tc) => ({
|
|
1103
1091
|
symbol: decodeWriteSymbol(tc.symbol),
|
|
1104
1092
|
movement: decodeMovement(tc.movement.description),
|
|
1105
1093
|
})),
|
|
1106
|
-
nextStateId,
|
|
1094
|
+
nextStateId: __classPrivateFieldGet$1(target, _State_id, "f"),
|
|
1107
1095
|
id: `${__classPrivateFieldGet$1(state, _State_id, "f")}-${patternIx}`,
|
|
1108
1096
|
});
|
|
1097
|
+
queue.push(target);
|
|
1109
1098
|
patternIx += 1;
|
|
1110
1099
|
}
|
|
1111
1100
|
}
|
|
1112
|
-
|
|
1101
|
+
// Always emit real halt as a sentinel, even if no transition targets it.
|
|
1102
|
+
// It anchors the `subtree -. halt .-> s0` frame-level arrow whenever a
|
|
1103
|
+
// frame demand-emits one, and it's the canonical machine-halt singleton.
|
|
1104
|
+
if (!(0 in nodes)) {
|
|
1105
|
+
nodes[0] = {
|
|
1106
|
+
id: 0,
|
|
1107
|
+
name: 'halt',
|
|
1108
|
+
isHalt: true,
|
|
1109
|
+
isHaltMarker: false,
|
|
1110
|
+
isWrapper: false,
|
|
1111
|
+
bareStateId: null,
|
|
1112
|
+
frameId: null,
|
|
1113
|
+
transitions: [],
|
|
1114
|
+
overriddenHaltStateId: null,
|
|
1115
|
+
tags: [...__classPrivateFieldGet$1(haltState, _State_tags, "f")],
|
|
1116
|
+
};
|
|
1117
|
+
}
|
|
1118
|
+
// Pass 2: For each bare, compute its forward-reachable set (following
|
|
1119
|
+
// transitions; stopping at halt and at wrappers — both are frame
|
|
1120
|
+
// boundaries).
|
|
1121
|
+
const computeReach = (startId) => {
|
|
1122
|
+
const reach = new Set();
|
|
1123
|
+
const stack = [startId];
|
|
1124
|
+
while (stack.length > 0) {
|
|
1125
|
+
const id = stack.pop();
|
|
1126
|
+
if (reach.has(id)) {
|
|
1127
|
+
continue;
|
|
1128
|
+
}
|
|
1129
|
+
const node = nodes[id];
|
|
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) {
|
|
1134
|
+
continue;
|
|
1135
|
+
}
|
|
1136
|
+
reach.add(id);
|
|
1137
|
+
for (const t of node.transitions) {
|
|
1138
|
+
const target = nodes[t.nextStateId];
|
|
1139
|
+
if (!target || target.isHalt || target.isWrapper) {
|
|
1140
|
+
continue;
|
|
1141
|
+
}
|
|
1142
|
+
stack.push(t.nextStateId);
|
|
1143
|
+
}
|
|
1144
|
+
}
|
|
1145
|
+
return reach;
|
|
1146
|
+
};
|
|
1147
|
+
const reachByBare = new Map();
|
|
1148
|
+
for (const bareId of bareIds) {
|
|
1149
|
+
reachByBare.set(bareId, computeReach(bareId));
|
|
1150
|
+
}
|
|
1151
|
+
// Pass 3: Union-find on bare overlaps. Two bares merge if their reach
|
|
1152
|
+
// sets share any state. Canonical representative = smallest bare-id in
|
|
1153
|
+
// the component.
|
|
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.
|
|
1159
|
+
const ufFind = (id) => {
|
|
1160
|
+
if (!ufParent.has(id)) {
|
|
1161
|
+
ufParent.set(id, id);
|
|
1162
|
+
}
|
|
1163
|
+
let root = id;
|
|
1164
|
+
while (ufParent.get(root) !== root) {
|
|
1165
|
+
root = ufParent.get(root);
|
|
1166
|
+
}
|
|
1167
|
+
return root;
|
|
1168
|
+
};
|
|
1169
|
+
const ufUnion = (a, b) => {
|
|
1170
|
+
const ra = ufFind(a);
|
|
1171
|
+
const rb = ufFind(b);
|
|
1172
|
+
if (ra === rb)
|
|
1173
|
+
return;
|
|
1174
|
+
if (ra < rb) {
|
|
1175
|
+
ufParent.set(rb, ra);
|
|
1176
|
+
}
|
|
1177
|
+
else {
|
|
1178
|
+
ufParent.set(ra, rb);
|
|
1179
|
+
}
|
|
1180
|
+
};
|
|
1181
|
+
for (const bareId of bareIds) {
|
|
1182
|
+
ufFind(bareId);
|
|
1183
|
+
}
|
|
1184
|
+
// For each state, collect the bares that reach it; union all bares that
|
|
1185
|
+
// share a state.
|
|
1186
|
+
const stateToReachingBares = new Map();
|
|
1187
|
+
for (const [bareId, reachSet] of reachByBare) {
|
|
1188
|
+
for (const stateId of reachSet) {
|
|
1189
|
+
let bares = stateToReachingBares.get(stateId);
|
|
1190
|
+
if (!bares) {
|
|
1191
|
+
bares = [];
|
|
1192
|
+
stateToReachingBares.set(stateId, bares);
|
|
1193
|
+
}
|
|
1194
|
+
bares.push(bareId);
|
|
1195
|
+
}
|
|
1196
|
+
}
|
|
1197
|
+
for (const bares of stateToReachingBares.values()) {
|
|
1198
|
+
for (let i = 1; i < bares.length; i += 1) {
|
|
1199
|
+
ufUnion(bares[0], bares[i]);
|
|
1200
|
+
}
|
|
1201
|
+
}
|
|
1202
|
+
// Assign frameId to each in-reach state.
|
|
1203
|
+
const frameIds = new Set();
|
|
1204
|
+
for (const [stateId, bares] of stateToReachingBares) {
|
|
1205
|
+
const frameId = ufFind(bares[0]);
|
|
1206
|
+
nodes[stateId].frameId = frameId;
|
|
1207
|
+
frameIds.add(frameId);
|
|
1208
|
+
}
|
|
1209
|
+
// Pass 4: Retarget halt-bound transitions for in-frame states to the
|
|
1210
|
+
// frame's halt marker. Out-of-frame states (top-level dispatcher, override
|
|
1211
|
+
// targets, etc.) keep their halt-bound transitions pointing at real halt.
|
|
1212
|
+
for (const node of Object.values(nodes)) {
|
|
1213
|
+
if (node.frameId === null) {
|
|
1214
|
+
continue;
|
|
1215
|
+
}
|
|
1216
|
+
const haltMarkerId = -node.frameId;
|
|
1217
|
+
for (const t of node.transitions) {
|
|
1218
|
+
const target = nodes[t.nextStateId];
|
|
1219
|
+
if (target && target.isHalt && !target.isHaltMarker) {
|
|
1220
|
+
t.nextStateId = haltMarkerId;
|
|
1221
|
+
}
|
|
1222
|
+
}
|
|
1223
|
+
}
|
|
1224
|
+
// Pass 5: Emit one halt marker per frame.
|
|
1225
|
+
for (const frameId of frameIds) {
|
|
1226
|
+
const haltMarkerId = -frameId;
|
|
1227
|
+
nodes[haltMarkerId] = {
|
|
1228
|
+
id: haltMarkerId,
|
|
1229
|
+
name: 'halt',
|
|
1230
|
+
isHalt: true,
|
|
1231
|
+
isHaltMarker: true,
|
|
1232
|
+
isWrapper: false,
|
|
1233
|
+
bareStateId: null,
|
|
1234
|
+
frameId,
|
|
1235
|
+
transitions: [],
|
|
1236
|
+
overriddenHaltStateId: null,
|
|
1237
|
+
tags: [],
|
|
1238
|
+
};
|
|
1239
|
+
}
|
|
1240
|
+
return { initialId: __classPrivateFieldGet$1(initialState, _State_id, "f"), alphabets, nodes };
|
|
1113
1241
|
}
|
|
1114
1242
|
// Inverse of toGraph: rebuilds a State graph (and a fresh TapeBlock with the
|
|
1115
1243
|
// graph's alphabets) from a serialized Graph. Round-trips with toGraph in
|
|
1116
1244
|
// the sense that running the rebuilt machine on the same input gives the
|
|
1117
1245
|
// same output, but the rebuilt State instances have *new* internal IDs.
|
|
1246
|
+
//
|
|
1247
|
+
// Under the v7 callable-subtree model (#174), graph nodes split into:
|
|
1248
|
+
// - Wrapper nodes (`isWrapper: true`, no transitions) — reconstructed via
|
|
1249
|
+
// `bareStates[bareStateId].withOverriddenHaltState(finalStates[overriddenHaltStateId])`.
|
|
1250
|
+
// - Bare/regular nodes — constructed as normal States with transitions.
|
|
1251
|
+
// - Halt + halt-marker nodes — collapse to the singleton `haltState`.
|
|
1118
1252
|
static fromGraph(graph) {
|
|
1119
1253
|
const alphabetObjs = graph.alphabets.map((syms) => new Alphabet(syms));
|
|
1120
1254
|
const tapeBlock = TapeBlock.fromAlphabets(alphabetObjs);
|
|
1121
1255
|
const ids = Object.keys(graph.nodes).map(Number);
|
|
1122
|
-
// Pass 1: pre-create a Reference for each non-halt node
|
|
1123
|
-
//
|
|
1256
|
+
// Pass 1: pre-create a Reference for each non-halt non-halt-marker node
|
|
1257
|
+
// (both wrappers and regulars). Halt and halt-marker nodes collapse to the
|
|
1258
|
+
// singleton `haltState` and need no ref.
|
|
1124
1259
|
const refs = {};
|
|
1125
1260
|
for (const nodeId of ids) {
|
|
1126
|
-
|
|
1261
|
+
const node = graph.nodes[nodeId];
|
|
1262
|
+
if (!node.isHalt) {
|
|
1127
1263
|
refs[nodeId] = new Reference();
|
|
1128
1264
|
}
|
|
1129
1265
|
}
|
|
@@ -1140,19 +1276,22 @@ class State {
|
|
|
1140
1276
|
}
|
|
1141
1277
|
return tapeBlock.symbol(flat);
|
|
1142
1278
|
};
|
|
1143
|
-
// Pass 2: build a
|
|
1144
|
-
//
|
|
1279
|
+
// Pass 2: build a State for each non-wrapper non-halt non-halt-marker
|
|
1280
|
+
// node. Transitions point at refs so cycles work; haltState (and halt
|
|
1281
|
+
// markers, which collapse to haltState) are used directly.
|
|
1145
1282
|
const bareStates = {};
|
|
1146
1283
|
for (const nodeId of ids) {
|
|
1147
1284
|
const node = graph.nodes[nodeId];
|
|
1148
|
-
if (node.isHalt) {
|
|
1285
|
+
if (node.isHalt || node.isWrapper) {
|
|
1149
1286
|
continue;
|
|
1150
1287
|
}
|
|
1151
1288
|
const stateDefinition = {};
|
|
1152
1289
|
for (const t of node.transitions) {
|
|
1153
1290
|
const key = patternToKey(parsePatternString(t.pattern, graph.alphabets));
|
|
1154
1291
|
const target = graph.nodes[t.nextStateId];
|
|
1155
|
-
const nextState = target
|
|
1292
|
+
const nextState = !target || target.isHalt
|
|
1293
|
+
? haltState
|
|
1294
|
+
: refs[t.nextStateId];
|
|
1156
1295
|
stateDefinition[key] = {
|
|
1157
1296
|
command: t.command.map((c) => ({
|
|
1158
1297
|
symbol: parseWriteSymbolLabel(c.symbol),
|
|
@@ -1161,14 +1300,20 @@ class State {
|
|
|
1161
1300
|
nextState,
|
|
1162
1301
|
};
|
|
1163
1302
|
}
|
|
1164
|
-
// Graph-sourced names may contain `(` and `)` (composite wrapper names
|
|
1165
|
-
//
|
|
1166
|
-
//
|
|
1167
|
-
|
|
1303
|
+
// Graph-sourced names may contain `(` and `)` (composite wrapper names —
|
|
1304
|
+
// although wrappers go through a separate path below, defensive
|
|
1305
|
+
// construction here keeps the bypass uniform). Construct without a name
|
|
1306
|
+
// and assign `#name` directly to skip user-facing name validation.
|
|
1307
|
+
const bare = new _a(stateDefinition);
|
|
1168
1308
|
__classPrivateFieldSet$1(bare, _State_name, node.name, "f");
|
|
1309
|
+
if (node.tags.length > 0) {
|
|
1310
|
+
bare.tag(...node.tags);
|
|
1311
|
+
}
|
|
1169
1312
|
bareStates[nodeId] = bare;
|
|
1170
1313
|
}
|
|
1171
|
-
// Pass 3:
|
|
1314
|
+
// Pass 3: resolve every node to its final State (memoized + cycle-safe).
|
|
1315
|
+
// Wrappers compose lazily via `withOverriddenHaltState` once their bare
|
|
1316
|
+
// and override are resolved.
|
|
1172
1317
|
const finalStates = {};
|
|
1173
1318
|
const inProgress = new Set();
|
|
1174
1319
|
const getFinal = (nodeId) => {
|
|
@@ -1176,7 +1321,7 @@ class State {
|
|
|
1176
1321
|
return finalStates[nodeId];
|
|
1177
1322
|
}
|
|
1178
1323
|
const node = graph.nodes[nodeId];
|
|
1179
|
-
if (node.isHalt) {
|
|
1324
|
+
if (!node || node.isHalt) {
|
|
1180
1325
|
finalStates[nodeId] = haltState;
|
|
1181
1326
|
return haltState;
|
|
1182
1327
|
}
|
|
@@ -1184,9 +1329,21 @@ class State {
|
|
|
1184
1329
|
throw new Error(`override-halt cycle at state #${nodeId}`);
|
|
1185
1330
|
}
|
|
1186
1331
|
inProgress.add(nodeId);
|
|
1187
|
-
let state
|
|
1188
|
-
if (node.
|
|
1189
|
-
|
|
1332
|
+
let state;
|
|
1333
|
+
if (node.isWrapper) {
|
|
1334
|
+
const bare = getFinal(node.bareStateId);
|
|
1335
|
+
const override = getFinal(node.overriddenHaltStateId);
|
|
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
|
+
}
|
|
1344
|
+
}
|
|
1345
|
+
else {
|
|
1346
|
+
state = bareStates[nodeId];
|
|
1190
1347
|
}
|
|
1191
1348
|
inProgress.delete(nodeId);
|
|
1192
1349
|
finalStates[nodeId] = state;
|
|
@@ -1195,8 +1352,8 @@ class State {
|
|
|
1195
1352
|
for (const nodeId of ids) {
|
|
1196
1353
|
getFinal(nodeId);
|
|
1197
1354
|
}
|
|
1198
|
-
// Pass 4: bind each ref to the
|
|
1199
|
-
//
|
|
1355
|
+
// Pass 4: bind each ref to the resolved final State so cross-node
|
|
1356
|
+
// transitions land on the right instance.
|
|
1200
1357
|
for (const nodeId of ids) {
|
|
1201
1358
|
if (!graph.nodes[nodeId].isHalt) {
|
|
1202
1359
|
refs[nodeId].bind(finalStates[nodeId]);
|
|
@@ -1209,6 +1366,13 @@ class State {
|
|
|
1209
1366
|
};
|
|
1210
1367
|
}
|
|
1211
1368
|
}
|
|
1369
|
+
_a = State;
|
|
1370
|
+
// Memoization cache for `withOverriddenHaltState`. Keyed by
|
|
1371
|
+
// (bare, override) — same args return the same wrapper instance (#175).
|
|
1372
|
+
// Two-level WeakMap so the outer entry is GC'd when the bare is collected;
|
|
1373
|
+
// WeakRef values let wrappers themselves be GC'd when nothing else holds
|
|
1374
|
+
// them, with cache misses simply reconstructing fresh wrappers.
|
|
1375
|
+
_State_wrapperCache = { value: new WeakMap() };
|
|
1212
1376
|
const haltState = new State(null);
|
|
1213
1377
|
|
|
1214
1378
|
var __classPrivateFieldSet = (undefined && undefined.__classPrivateFieldSet) || function (receiver, state, value, kind, f) {
|
|
@@ -1352,19 +1516,25 @@ _TuringMachine_tapeBlock = new WeakMap(), _TuringMachine_stack = new WeakMap();
|
|
|
1352
1516
|
// Currently only Mermaid flowchart syntax is supported. Future formats
|
|
1353
1517
|
// (Graphviz, JSON-LD, custom DSL) belong here too.
|
|
1354
1518
|
//
|
|
1355
|
-
// v7 emit
|
|
1356
|
-
// - Each wrapper
|
|
1357
|
-
//
|
|
1358
|
-
// (
|
|
1359
|
-
//
|
|
1360
|
-
//
|
|
1361
|
-
//
|
|
1362
|
-
//
|
|
1363
|
-
//
|
|
1364
|
-
// -
|
|
1365
|
-
//
|
|
1366
|
-
//
|
|
1367
|
-
//
|
|
1519
|
+
// v7 callable-subtree emit (#174):
|
|
1520
|
+
// - Each `withOverriddenHaltState` wrapper produces TWO graph nodes — a
|
|
1521
|
+
// wrapper node (`[[composite-name]]`, OUTSIDE any subgraph) and a bare
|
|
1522
|
+
// node (regular shape, INSIDE its callable subtree subgraph).
|
|
1523
|
+
// - Subgraphs (one per frame): `subgraph w_${frameId}["callable subtree
|
|
1524
|
+
// of NAME"]` (single bare) or `["callable scope: A ∪ B"]` (union).
|
|
1525
|
+
// - Each frame has exactly one halt marker `c${frameId}(((halt)))` inside
|
|
1526
|
+
// its subgraph; halt-bound transitions from in-frame states retarget to
|
|
1527
|
+
// it. Always emitted (orphan signals dead wrapper).
|
|
1528
|
+
// - Arrow conventions:
|
|
1529
|
+
// solid `-->` regular transitions, including wrapper-to-override.
|
|
1530
|
+
// bold `==>` RESERVED for the wrapper-to-bare `call` arrow.
|
|
1531
|
+
// `&` ribbon collapses multi-wrapper-shares-bare.
|
|
1532
|
+
// dotted `-.->` frame-level dispatch (`return`, `halt`, `enter`).
|
|
1533
|
+
// - The `return` arrow (subgraph → wrapper) is demand-emitted iff the
|
|
1534
|
+
// frame's halt marker has at least one incoming edge AND the wrapper
|
|
1535
|
+
// calls into the frame. The `halt` arrow (subgraph → s0) is emitted
|
|
1536
|
+
// iff the halt marker has incoming AND there's at least one non-wrapper
|
|
1537
|
+
// entry into the frame (cross-subgraph solid arrow from outside).
|
|
1368
1538
|
// Maps a graph node id to its Mermaid id.
|
|
1369
1539
|
// - non-negative id N → "sN"
|
|
1370
1540
|
// - negative id -N (halt marker) → "cN"
|
|
@@ -1378,108 +1548,256 @@ function parseMermaidId(s) {
|
|
|
1378
1548
|
}
|
|
1379
1549
|
return Number(s.slice(1));
|
|
1380
1550
|
}
|
|
1551
|
+
function frameSubgraphId(frameId) {
|
|
1552
|
+
return `w_${frameId}`;
|
|
1553
|
+
}
|
|
1381
1554
|
function toMermaid(graph) {
|
|
1382
1555
|
const lines = [
|
|
1383
1556
|
'flowchart TD',
|
|
1384
1557
|
`%% alphabets: ${JSON.stringify(graph.alphabets)}`,
|
|
1385
1558
|
];
|
|
1386
|
-
// Sort nodes by id
|
|
1387
|
-
//
|
|
1388
|
-
//
|
|
1559
|
+
// Sort nodes by id ascending — real halt (0) first, then regulars by their
|
|
1560
|
+
// ids, then halt markers (negative) at the end. Deterministic emit lets
|
|
1561
|
+
// toMermaid → fromMermaid → toMermaid round-trip stably (#139).
|
|
1389
1562
|
const nodes = Object.values(graph.nodes).slice().sort((a, b) => a.id - b.id);
|
|
1390
|
-
|
|
1391
|
-
|
|
1392
|
-
const
|
|
1393
|
-
//
|
|
1394
|
-
const
|
|
1395
|
-
|
|
1396
|
-
|
|
1397
|
-
|
|
1398
|
-
haltMarkerIds.add(haltMarkerId);
|
|
1399
|
-
}
|
|
1400
|
-
}
|
|
1401
|
-
// Emit non-subgraph nodes first: real halt + regular non-wrapped nodes.
|
|
1402
|
-
// No special round-shape `((…))` for the initial — the `idle -. enter .->`
|
|
1403
|
-
// arrow emitted below is the sole "start here" signal.
|
|
1563
|
+
// Bucket nodes for emit order.
|
|
1564
|
+
const topLevelNodes = nodes.filter((n) => n.frameId === null && !n.isWrapper);
|
|
1565
|
+
const wrapperNodes = nodes.filter((n) => n.isWrapper);
|
|
1566
|
+
// Bares-and-bodies inside frames, grouped by frameId.
|
|
1567
|
+
const nodesByFrame = new Map();
|
|
1568
|
+
// Halt-marker per frame (kept separate so it always emits LAST inside the
|
|
1569
|
+
// subgraph for deterministic shape).
|
|
1570
|
+
const haltMarkerByFrame = new Map();
|
|
1404
1571
|
for (const node of nodes) {
|
|
1405
|
-
if (node.
|
|
1572
|
+
if (node.frameId === null || node.isWrapper)
|
|
1406
1573
|
continue;
|
|
1574
|
+
if (node.isHaltMarker) {
|
|
1575
|
+
haltMarkerByFrame.set(node.frameId, node);
|
|
1407
1576
|
}
|
|
1408
|
-
|
|
1577
|
+
else {
|
|
1578
|
+
let bucket = nodesByFrame.get(node.frameId);
|
|
1579
|
+
if (!bucket) {
|
|
1580
|
+
bucket = [];
|
|
1581
|
+
nodesByFrame.set(node.frameId, bucket);
|
|
1582
|
+
}
|
|
1583
|
+
bucket.push(node);
|
|
1584
|
+
}
|
|
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
|
+
};
|
|
1596
|
+
// 1. Emit top-level nodes (real halt, non-wrapper regulars outside any frame).
|
|
1597
|
+
for (const node of topLevelNodes) {
|
|
1598
|
+
const mid = mermaidIdFor(node.id);
|
|
1409
1599
|
if (node.isHalt) {
|
|
1410
|
-
lines.push(` ${
|
|
1600
|
+
lines.push(` ${mid}(((halt)))`);
|
|
1411
1601
|
}
|
|
1412
1602
|
else {
|
|
1413
|
-
lines.push(` ${
|
|
1603
|
+
lines.push(` ${mid}["${labelOf(node)}"]`);
|
|
1414
1604
|
}
|
|
1415
1605
|
}
|
|
1416
|
-
//
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
|
|
1606
|
+
// 2. Emit wrappers at top level.
|
|
1607
|
+
for (const wrapper of wrapperNodes) {
|
|
1608
|
+
lines.push(` ${mermaidIdFor(wrapper.id)}[["${labelOf(wrapper)}"]]`);
|
|
1609
|
+
}
|
|
1610
|
+
// 3. `idle` sentinel.
|
|
1420
1611
|
lines.push(' idle([idle])');
|
|
1421
|
-
//
|
|
1422
|
-
|
|
1423
|
-
|
|
1424
|
-
const
|
|
1425
|
-
const
|
|
1426
|
-
|
|
1427
|
-
|
|
1428
|
-
|
|
1429
|
-
|
|
1430
|
-
|
|
1612
|
+
// 4. Subgraph per frame.
|
|
1613
|
+
const frameIds = [...nodesByFrame.keys()].sort((a, b) => a - b);
|
|
1614
|
+
for (const frameId of frameIds) {
|
|
1615
|
+
const frameBares = (nodesByFrame.get(frameId) ?? []).filter((n) => isFrameBare(n, graph));
|
|
1616
|
+
const frameBareNames = frameBares
|
|
1617
|
+
.slice()
|
|
1618
|
+
.sort((a, b) => a.id - b.id)
|
|
1619
|
+
.map((n) => n.name);
|
|
1620
|
+
const label = frameBareNames.length > 1
|
|
1621
|
+
? `callable scope: ${frameBareNames.join(' ∪ ')}`
|
|
1622
|
+
: `callable subtree of ${frameBareNames[0] ?? frameId}`;
|
|
1623
|
+
lines.push(` subgraph ${frameSubgraphId(frameId)}["${label}"]`);
|
|
1624
|
+
// Inner nodes — sort by id for determinism.
|
|
1625
|
+
for (const node of (nodesByFrame.get(frameId) ?? []).slice().sort((a, b) => a.id - b.id)) {
|
|
1626
|
+
lines.push(` ${mermaidIdFor(node.id)}["${labelOf(node)}"]`);
|
|
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.
|
|
1631
|
+
const haltMarker = haltMarkerByFrame.get(frameId);
|
|
1632
|
+
lines.push(` ${mermaidIdFor(haltMarker.id)}(((halt)))`);
|
|
1431
1633
|
lines.push(' end');
|
|
1432
1634
|
}
|
|
1433
|
-
// Enter arrow
|
|
1434
|
-
// node (whether plain `[…]` or wrapped `[[…]]` inside a subgraph).
|
|
1635
|
+
// 5. Enter arrow.
|
|
1435
1636
|
lines.push(` idle -. enter .-> ${mermaidIdFor(graph.initialId)}`);
|
|
1436
|
-
//
|
|
1437
|
-
//
|
|
1438
|
-
|
|
1637
|
+
// 6. `call` arrows — grouped by bare (multi-wrapper-shares-bare collapses
|
|
1638
|
+
// into a single `&` ribbon).
|
|
1639
|
+
const wrappersByBare = new Map();
|
|
1640
|
+
for (const wrapper of wrapperNodes) {
|
|
1641
|
+
if (wrapper.bareStateId === null)
|
|
1642
|
+
continue;
|
|
1643
|
+
let group = wrappersByBare.get(wrapper.bareStateId);
|
|
1644
|
+
if (!group) {
|
|
1645
|
+
group = [];
|
|
1646
|
+
wrappersByBare.set(wrapper.bareStateId, group);
|
|
1647
|
+
}
|
|
1648
|
+
group.push(wrapper);
|
|
1649
|
+
}
|
|
1650
|
+
const sortedBares = [...wrappersByBare.keys()].sort((a, b) => a - b);
|
|
1651
|
+
for (const bareId of sortedBares) {
|
|
1652
|
+
const wrappers = wrappersByBare.get(bareId).slice().sort((a, b) => a.id - b.id);
|
|
1653
|
+
const sources = wrappers.map((w) => mermaidIdFor(w.id)).join(' & ');
|
|
1654
|
+
lines.push(` ${sources} == "call" ==> ${mermaidIdFor(bareId)}`);
|
|
1655
|
+
}
|
|
1656
|
+
// 7. Demand-emit `return` and `halt` arrows per frame.
|
|
1657
|
+
// For each frame: check if its halt marker has incoming transitions.
|
|
1658
|
+
const haltMarkerHasIncoming = new Map();
|
|
1439
1659
|
for (const node of nodes) {
|
|
1440
|
-
|
|
1660
|
+
for (const t of node.transitions) {
|
|
1661
|
+
const target = graph.nodes[t.nextStateId];
|
|
1662
|
+
if (target && target.isHaltMarker && target.frameId !== null) {
|
|
1663
|
+
haltMarkerHasIncoming.set(target.frameId, true);
|
|
1664
|
+
}
|
|
1665
|
+
}
|
|
1666
|
+
}
|
|
1667
|
+
// For each frame: check if there's at least one non-wrapper entry (a solid
|
|
1668
|
+
// `-->` from OUTSIDE the frame into any node INSIDE).
|
|
1669
|
+
const hasNonWrapperEntry = new Map();
|
|
1670
|
+
for (const node of nodes) {
|
|
1671
|
+
if (node.isWrapper)
|
|
1672
|
+
continue;
|
|
1673
|
+
for (const t of node.transitions) {
|
|
1674
|
+
const target = graph.nodes[t.nextStateId];
|
|
1675
|
+
if (target
|
|
1676
|
+
&& target.frameId !== null
|
|
1677
|
+
&& node.frameId !== target.frameId) {
|
|
1678
|
+
hasNonWrapperEntry.set(target.frameId, true);
|
|
1679
|
+
}
|
|
1680
|
+
}
|
|
1681
|
+
}
|
|
1682
|
+
for (const frameId of frameIds) {
|
|
1683
|
+
if (!haltMarkerHasIncoming.get(frameId))
|
|
1441
1684
|
continue;
|
|
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.
|
|
1689
|
+
const callingWrappers = wrapperNodes.filter((w) => {
|
|
1690
|
+
const bare = graph.nodes[w.bareStateId];
|
|
1691
|
+
return bare.frameId === frameId;
|
|
1692
|
+
});
|
|
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}`);
|
|
1699
|
+
if (hasNonWrapperEntry.get(frameId)) {
|
|
1700
|
+
lines.push(` ${frameSubgraphId(frameId)} -. "halt" .-> s0`);
|
|
1442
1701
|
}
|
|
1702
|
+
}
|
|
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.
|
|
1709
|
+
for (const wrapper of wrapperNodes) {
|
|
1710
|
+
lines.push(` ${mermaidIdFor(wrapper.id)} --> ${mermaidIdFor(wrapper.overriddenHaltStateId)}`);
|
|
1711
|
+
}
|
|
1712
|
+
// 9. Regular transitions for non-wrapper non-halt-marker non-halt nodes.
|
|
1713
|
+
for (const node of nodes) {
|
|
1714
|
+
if (node.isHalt || node.isHaltMarker || node.isWrapper)
|
|
1715
|
+
continue;
|
|
1443
1716
|
for (const t of node.transitions) {
|
|
1444
|
-
// Bracketed-tape-block format (v7): each role-list — read alternatives,
|
|
1445
|
-
// writes, movements — wraps in `[…]` to mark "this is a tape-block
|
|
1446
|
-
// reading". Brackets stay even for single-tape machines; the `[…]` is
|
|
1447
|
-
// the tape-block concept indicator.
|
|
1448
|
-
//
|
|
1449
|
-
// Single-tape: ['X'] → [K]/[R]
|
|
1450
|
-
// Single-tape + alternation: ['^']|['1']|['0'] → [K]/[S]
|
|
1451
|
-
// Two-tape: ['0','a'] → [K,'1']/[R,S]
|
|
1452
|
-
// Two-tape + alternation: ['0','a']|['1','b'] → [K,K]/[R,L]
|
|
1453
|
-
//
|
|
1454
|
-
// Alternation is ALWAYS per-pattern-bracket — one full bracketed list
|
|
1455
|
-
// per alternative — regardless of tape count. Pedagogically each
|
|
1456
|
-
// alternative is its own drawn transition; a compact in-bracket form
|
|
1457
|
-
// (`['^'|'1']`) would read as cross-product semantics in multi-tape
|
|
1458
|
-
// (`['0'|'1','a'|'b']` = 4 combos, not 2 paired alternatives), so we
|
|
1459
|
-
// avoid introducing it for the single-tape case too.
|
|
1460
1717
|
const alternatives = t.pattern.split('|');
|
|
1461
1718
|
const reads = alternatives.map((alt) => `[${alt}]`).join('|');
|
|
1462
1719
|
const writes = `[${t.command.map((c) => c.symbol).join(',')}]`;
|
|
1463
1720
|
const moves = `[${t.command.map((c) => c.movement).join(',')}]`;
|
|
1464
1721
|
const label = `${reads} → ${writes}/${moves}`;
|
|
1465
|
-
|
|
1466
|
-
// signals "this transition pushes that wrapper's override onto the
|
|
1467
|
-
// runtime stack" (per `TuringMachine.run` line ~220's
|
|
1468
|
-
// `if (state !== nextState && nextState.overriddenHaltState) push(...)`).
|
|
1469
|
-
// Self-loops (state === nextState) don't push at runtime — keep the
|
|
1470
|
-
// regular `-->` for those even when the target is wrapped.
|
|
1471
|
-
const targetNode = graph.nodes[t.nextStateId];
|
|
1472
|
-
const isEnteringWrapper = targetNode && targetNode.isWrapped && t.nextStateId !== node.id;
|
|
1473
|
-
const lineSegment = isEnteringWrapper ? '==' : '--';
|
|
1474
|
-
const arrowTip = isEnteringWrapper ? '==>' : '-->';
|
|
1475
|
-
lines.push(` ${mermaidIdFor(node.id)} ${lineSegment} "${label}" ${arrowTip} ${mermaidIdFor(t.nextStateId)}`);
|
|
1476
|
-
}
|
|
1477
|
-
if (node.overriddenHaltStateId !== null) {
|
|
1478
|
-
lines.push(` ${mermaidIdFor(node.id)} -. onHalt .-> ${mermaidIdFor(node.overriddenHaltStateId)}`);
|
|
1722
|
+
lines.push(` ${mermaidIdFor(node.id)} -- "${label}" --> ${mermaidIdFor(t.nextStateId)}`);
|
|
1479
1723
|
}
|
|
1480
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);
|
|
1481
1733
|
return lines.join('\n');
|
|
1482
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
|
+
}
|
|
1786
|
+
// Helper: identify "the bare states" that anchor a frame's name. A bare is a
|
|
1787
|
+
// node referenced as some wrapper's `bareStateId`. Body states (also in-frame
|
|
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.
|
|
1793
|
+
function isFrameBare(node, graph) {
|
|
1794
|
+
for (const other of Object.values(graph.nodes)) {
|
|
1795
|
+
if (other.isWrapper && other.bareStateId === node.id) {
|
|
1796
|
+
return true;
|
|
1797
|
+
}
|
|
1798
|
+
}
|
|
1799
|
+
return false;
|
|
1800
|
+
}
|
|
1483
1801
|
// Inverse of toMermaid: parses the Mermaid output produced by toMermaid back
|
|
1484
1802
|
// into a Graph. The parser is strict to the dialect toMermaid emits — it
|
|
1485
1803
|
// recognises the specific node/edge shapes and the leading
|
|
@@ -1494,26 +1812,59 @@ function toMermaid(graph) {
|
|
|
1494
1812
|
const haltNodeRegex = /^([sc]\d+)\(\(\(halt\)\)\)$/;
|
|
1495
1813
|
const regularNodeRegex = /^(s\d+)\["([^"]*)"\]$/;
|
|
1496
1814
|
const wrappedNodeRegex = /^(s\d+)\[\["([^"]*)"\]\]$/;
|
|
1497
|
-
const subgraphStartRegex = /^subgraph\s+w_\d
|
|
1815
|
+
const subgraphStartRegex = /^subgraph\s+w_(\d+)\["([^"]*)"\]$/;
|
|
1498
1816
|
const subgraphEndRegex = /^end$/;
|
|
1499
1817
|
const idleNodeRegex = /^idle\(\[idle\]\)$/;
|
|
1500
1818
|
const enterArrowRegex = /^idle\s+-\.\s+enter\s+\.->\s+(s\d+)$/;
|
|
1501
|
-
|
|
1502
|
-
const
|
|
1503
|
-
|
|
1819
|
+
// Regular labeled transition (solid `-->`).
|
|
1820
|
+
const labeledTransitionRegex = /^([sc]\d+)\s+--\s+"(.*)"\s+-->\s+([sc]\d+)$/;
|
|
1821
|
+
// Wrapper → override (unlabeled solid `-->`).
|
|
1822
|
+
const wrapperOverrideRegex = /^(s\d+)\s+-->\s+([sc]\d+)$/;
|
|
1823
|
+
// Call arrow (bold `==>`), with optional `&`-joined source ribbon.
|
|
1824
|
+
// Ribbon separator is fixed at " & " (single spaces around &) — toMermaid
|
|
1825
|
+
// emits exactly that form, so the parser is strict to it. The literal-space
|
|
1826
|
+
// form avoids CodeQL's polynomial-ReDoS flag on a `\s+&\s+` shape.
|
|
1827
|
+
const callArrowRegex = /^(s\d+(?: & s\d+)*)\s+==\s+"call"\s+==>\s+(s\d+)$/;
|
|
1828
|
+
// Return arrow (`w_N -. return .-> s_W` with optional `&` target ribbon).
|
|
1829
|
+
const returnArrowRegex = /^w_(\d+)\s+-\.\s+"return"\s+\.->\s+(s\d+(?: & s\d+)*)$/;
|
|
1830
|
+
// Halt arrow (`w_N -. halt .-> s0`).
|
|
1831
|
+
const haltArrowRegex = /^w_(\d+)\s+-\.\s+"halt"\s+\.->\s+s0$/;
|
|
1504
1832
|
// First capture char anchored as \S to avoid polynomial backtracking between
|
|
1505
1833
|
// the preceding \s* and a permissive (.+); see CodeQL js/polynomial-redos.
|
|
1506
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
|
+
}
|
|
1507
1862
|
function fromMermaid(text) {
|
|
1508
1863
|
const lines = text.split('\n').map((l) => l.trim()).filter(Boolean);
|
|
1509
1864
|
let alphabets = [];
|
|
1510
1865
|
let initialId = null;
|
|
1511
1866
|
const nodes = {};
|
|
1512
|
-
|
|
1513
|
-
// marked `isHaltMarker: true` even though they share the `(((halt)))` shape
|
|
1514
|
-
// with the real halt at the top level.
|
|
1515
|
-
const haltMarkerIds = new Set();
|
|
1516
|
-
let inSubgraph = false;
|
|
1867
|
+
let currentFrameId = null;
|
|
1517
1868
|
const ensureNode = (id, opts = {}) => {
|
|
1518
1869
|
if (!nodes[id]) {
|
|
1519
1870
|
nodes[id] = {
|
|
@@ -1521,9 +1872,12 @@ function fromMermaid(text) {
|
|
|
1521
1872
|
name: opts.name ?? mermaidIdFor(id),
|
|
1522
1873
|
isHalt: opts.isHalt ?? false,
|
|
1523
1874
|
isHaltMarker: opts.isHaltMarker ?? false,
|
|
1524
|
-
|
|
1875
|
+
isWrapper: opts.isWrapper ?? false,
|
|
1876
|
+
bareStateId: opts.bareStateId ?? null,
|
|
1877
|
+
frameId: opts.frameId ?? null,
|
|
1525
1878
|
transitions: [],
|
|
1526
1879
|
overriddenHaltStateId: null,
|
|
1880
|
+
tags: opts.tags ? [...opts.tags] : [],
|
|
1527
1881
|
};
|
|
1528
1882
|
}
|
|
1529
1883
|
else {
|
|
@@ -1533,72 +1887,133 @@ function fromMermaid(text) {
|
|
|
1533
1887
|
nodes[id].isHalt = opts.isHalt;
|
|
1534
1888
|
if (opts.isHaltMarker !== undefined)
|
|
1535
1889
|
nodes[id].isHaltMarker = opts.isHaltMarker;
|
|
1536
|
-
if (opts.
|
|
1537
|
-
nodes[id].
|
|
1890
|
+
if (opts.isWrapper !== undefined)
|
|
1891
|
+
nodes[id].isWrapper = opts.isWrapper;
|
|
1892
|
+
if (opts.bareStateId !== undefined)
|
|
1893
|
+
nodes[id].bareStateId = opts.bareStateId;
|
|
1894
|
+
if (opts.frameId !== undefined)
|
|
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
|
+
}
|
|
1538
1902
|
}
|
|
1539
1903
|
return nodes[id];
|
|
1540
1904
|
};
|
|
1541
|
-
// First pass:
|
|
1905
|
+
// First pass: nodes + alphabets (track subgraph context for frameId).
|
|
1542
1906
|
for (const line of lines) {
|
|
1543
|
-
if (line === 'flowchart TD')
|
|
1907
|
+
if (line === 'flowchart TD')
|
|
1544
1908
|
continue;
|
|
1545
|
-
}
|
|
1546
1909
|
const am = line.match(alphabetsRegex);
|
|
1547
1910
|
if (am) {
|
|
1548
1911
|
alphabets = JSON.parse(am[1]);
|
|
1549
1912
|
continue;
|
|
1550
1913
|
}
|
|
1551
|
-
|
|
1552
|
-
|
|
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;
|
|
1919
|
+
const sgStart = line.match(subgraphStartRegex);
|
|
1920
|
+
if (sgStart) {
|
|
1921
|
+
currentFrameId = Number(sgStart[1]);
|
|
1553
1922
|
continue;
|
|
1554
1923
|
}
|
|
1555
1924
|
if (subgraphEndRegex.test(line)) {
|
|
1556
|
-
|
|
1925
|
+
currentFrameId = null;
|
|
1557
1926
|
continue;
|
|
1558
1927
|
}
|
|
1559
|
-
|
|
1560
|
-
// node — skip declaration, parse the `idle -. enter .-> sN` arrow in the
|
|
1561
|
-
// edge pass to set initialId.
|
|
1562
|
-
if (idleNodeRegex.test(line)) {
|
|
1928
|
+
if (idleNodeRegex.test(line))
|
|
1563
1929
|
continue;
|
|
1564
|
-
}
|
|
1565
1930
|
const hm = line.match(haltNodeRegex);
|
|
1566
1931
|
if (hm) {
|
|
1567
1932
|
const id = parseMermaidId(hm[1]);
|
|
1568
|
-
const isHaltMarker =
|
|
1569
|
-
ensureNode(id, {
|
|
1570
|
-
|
|
1571
|
-
|
|
1572
|
-
|
|
1933
|
+
const isHaltMarker = currentFrameId !== null;
|
|
1934
|
+
ensureNode(id, {
|
|
1935
|
+
name: 'halt',
|
|
1936
|
+
isHalt: true,
|
|
1937
|
+
isHaltMarker,
|
|
1938
|
+
frameId: isHaltMarker ? currentFrameId : null,
|
|
1939
|
+
});
|
|
1573
1940
|
continue;
|
|
1574
1941
|
}
|
|
1575
1942
|
const wm = line.match(wrappedNodeRegex);
|
|
1576
1943
|
if (wm) {
|
|
1577
|
-
|
|
1944
|
+
const { name, tags } = splitLabelTags(wm[2]);
|
|
1945
|
+
ensureNode(parseMermaidId(wm[1]), {
|
|
1946
|
+
name,
|
|
1947
|
+
isWrapper: true,
|
|
1948
|
+
tags,
|
|
1949
|
+
});
|
|
1578
1950
|
continue;
|
|
1579
1951
|
}
|
|
1580
1952
|
const rm = line.match(regularNodeRegex);
|
|
1581
1953
|
if (rm) {
|
|
1582
|
-
|
|
1954
|
+
const { name, tags } = splitLabelTags(rm[2]);
|
|
1955
|
+
ensureNode(parseMermaidId(rm[1]), {
|
|
1956
|
+
name,
|
|
1957
|
+
frameId: currentFrameId,
|
|
1958
|
+
tags,
|
|
1959
|
+
});
|
|
1583
1960
|
continue;
|
|
1584
1961
|
}
|
|
1585
1962
|
}
|
|
1586
1963
|
// Second pass: edges.
|
|
1587
1964
|
for (const line of lines) {
|
|
1588
|
-
// `idle -. enter .-> sN`: the sole source of initialId.
|
|
1589
1965
|
const em = line.match(enterArrowRegex);
|
|
1590
1966
|
if (em) {
|
|
1591
1967
|
initialId = parseMermaidId(em[1]);
|
|
1592
1968
|
continue;
|
|
1593
1969
|
}
|
|
1594
|
-
|
|
1595
|
-
|
|
1596
|
-
|
|
1970
|
+
// Return/halt arrows are derivable from frame structure at the next
|
|
1971
|
+
// toMermaid emit; consume but don't persist as graph data.
|
|
1972
|
+
if (returnArrowRegex.test(line) || haltArrowRegex.test(line)) {
|
|
1597
1973
|
continue;
|
|
1598
1974
|
}
|
|
1599
|
-
//
|
|
1600
|
-
//
|
|
1601
|
-
|
|
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
|
+
}
|
|
1988
|
+
// `call` arrow — sets bareStateId on each source wrapper.
|
|
1989
|
+
const cm = line.match(callArrowRegex);
|
|
1990
|
+
if (cm) {
|
|
1991
|
+
const sources = cm[1].split(' & ');
|
|
1992
|
+
const bareId = parseMermaidId(cm[2]);
|
|
1993
|
+
for (const src of sources) {
|
|
1994
|
+
ensureNode(parseMermaidId(src), { isWrapper: true, bareStateId: bareId });
|
|
1995
|
+
}
|
|
1996
|
+
continue;
|
|
1997
|
+
}
|
|
1998
|
+
// Wrapper → override (unlabeled solid `-->`). Only fires if the source
|
|
1999
|
+
// node is a known wrapper (declared as `[[…]]`).
|
|
2000
|
+
const wo = line.match(wrapperOverrideRegex);
|
|
2001
|
+
if (wo) {
|
|
2002
|
+
const fromId = parseMermaidId(wo[1]);
|
|
2003
|
+
const toId = parseMermaidId(wo[2]);
|
|
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) {
|
|
2010
|
+
nodes[fromId].overriddenHaltStateId = toId;
|
|
2011
|
+
continue;
|
|
2012
|
+
}
|
|
2013
|
+
// Fall through — unlabeled solid from a non-wrapper is unexpected;
|
|
2014
|
+
// treated as a malformed line and ignored by the labeled-regex below.
|
|
2015
|
+
}
|
|
2016
|
+
const tm = line.match(labeledTransitionRegex);
|
|
1602
2017
|
if (tm) {
|
|
1603
2018
|
const fromId = parseMermaidId(tm[1]);
|
|
1604
2019
|
const label = tm[2];
|
|
@@ -1607,31 +2022,13 @@ function fromMermaid(text) {
|
|
|
1607
2022
|
if (arrowIx === -1) {
|
|
1608
2023
|
throw new Error(`fromMermaid: malformed edge label: "${label}"`);
|
|
1609
2024
|
}
|
|
1610
|
-
// Bracketed-tape-block format (v7):
|
|
1611
|
-
// [<read-cells>]|[<read-cells>]... → [<write-cells>]/[<move-cells>]
|
|
1612
|
-
// Each bracketed list is a tape-block reading; the outer `|` separates
|
|
1613
|
-
// alternative read patterns. For single-tape machines with alternation,
|
|
1614
|
-
// the compact form `[<alt1>|<alt2>|...]` (one bracket, alternatives
|
|
1615
|
-
// inside) is also accepted; both forms decode to the same pattern
|
|
1616
|
-
// string.
|
|
1617
2025
|
const readLabel = label.slice(0, arrowIx);
|
|
1618
2026
|
const cmdLabel = label.slice(arrowIx + ' → '.length);
|
|
1619
|
-
// Strict per-pattern bracket form: `|` only between bracketed lists,
|
|
1620
|
-
// never inside. The compact `['^'|'1']` form is rejected by design —
|
|
1621
|
-
// every alternative must be its own bracketed pattern (`['^']|['1']`).
|
|
1622
|
-
// Pedagogically: each transition is drawn explicitly; the compact form
|
|
1623
|
-
// would read as cross-product semantics in multi-tape and confuse
|
|
1624
|
-
// readers (`['0'|'1','a'|'b']` could mean 4 combos, not 2 paired alts).
|
|
1625
|
-
// The rule applies to all bracketed lists — read alternatives, writes,
|
|
1626
|
-
// and movements — because commands and movements have no alternation
|
|
1627
|
-
// semantic either.
|
|
1628
2027
|
const stripBrackets = (s) => {
|
|
1629
2028
|
if (!s.startsWith('[') || !s.endsWith(']')) {
|
|
1630
2029
|
throw new Error(`fromMermaid: malformed bracketed list: "${s}"`);
|
|
1631
2030
|
}
|
|
1632
2031
|
const inner = s.slice(1, -1);
|
|
1633
|
-
// Walk the inner content; backslash escapes the next char (so `\|`
|
|
1634
|
-
// inside a cell is a literal pipe, not the alternation separator).
|
|
1635
2032
|
let i = 0;
|
|
1636
2033
|
while (i < inner.length) {
|
|
1637
2034
|
if (inner[i] === '\\' && i + 1 < inner.length) {
|
|
@@ -1646,10 +2043,6 @@ function fromMermaid(text) {
|
|
|
1646
2043
|
}
|
|
1647
2044
|
return inner;
|
|
1648
2045
|
};
|
|
1649
|
-
// Match `[…]` blocks in the read label. Inner content is a tape-block
|
|
1650
|
-
// reading (possibly with `|` for compact single-tape alternation).
|
|
1651
|
-
// `[^\]]*` is the simple non-greedy match — works because cell content
|
|
1652
|
-
// doesn't typically contain literal `]`.
|
|
1653
2046
|
const blockMatches = readLabel.match(/\[[^\]]*\]/g);
|
|
1654
2047
|
if (!blockMatches || blockMatches.length === 0) {
|
|
1655
2048
|
throw new Error(`fromMermaid: no bracketed read-list in label: "${label}"`);
|