@vsirotin/ts-stop 0.5.2 → 0.6.0
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/README.md +2 -36
- package/lib/FiniteStateMachine.d.ts +6 -1
- package/lib/FiniteStateMachine.d.ts.map +1 -1
- package/lib/FiniteStateMachine.js +114 -6
- package/lib/FiniteStateMachine.js.map +1 -1
- package/lib/esm/FiniteStateMachine.js +114 -6
- package/lib/esm/fa/DefaultState.js +10 -0
- package/lib/esm/fa/FiniteStateMachine.js +405 -0
- package/lib/esm/fa/IStateWithActions.js +1 -0
- package/lib/esm/fa/IStateWithOutputSignal.js +1 -0
- package/lib/esm/fa/MatrixBasedStateMachine.js +127 -0
- package/lib/esm/fa/TransitionMatrix.js +98 -0
- package/lib/esm/index.js +6 -6
- package/lib/esm/sfsm/ExternalWorldHub.js +80 -0
- package/lib/esm/sfsm/FaLoader.js +30 -0
- package/lib/esm/sfsm/FaReducer.js +36 -0
- package/lib/esm/sfsm/FaResolver.js +115 -0
- package/lib/esm/sfsm/FaUpdater.js +101 -0
- package/lib/esm/sfsm/Sfsm.js +225 -0
- package/lib/esm/sfsm/index.js +6 -0
- package/lib/esm/sfsm/interfaces.js +1 -0
- package/lib/esm/sfsm/types.js +1 -0
- package/lib/fa/DefaultState.d.ts +9 -0
- package/lib/fa/DefaultState.d.ts.map +1 -0
- package/lib/fa/DefaultState.js +15 -0
- package/lib/fa/DefaultState.js.map +1 -0
- package/lib/fa/FiniteStateMachine.d.ts +227 -0
- package/lib/fa/FiniteStateMachine.d.ts.map +1 -0
- package/lib/fa/FiniteStateMachine.js +410 -0
- package/lib/fa/FiniteStateMachine.js.map +1 -0
- package/lib/fa/IStateWithActions.d.ts +18 -0
- package/lib/fa/IStateWithActions.d.ts.map +1 -0
- package/lib/fa/IStateWithActions.js +3 -0
- package/lib/fa/IStateWithActions.js.map +1 -0
- package/lib/fa/IStateWithOutputSignal.d.ts +4 -0
- package/lib/fa/IStateWithOutputSignal.d.ts.map +1 -0
- package/lib/fa/IStateWithOutputSignal.js +3 -0
- package/lib/fa/IStateWithOutputSignal.js.map +1 -0
- package/lib/fa/MatrixBasedStateMachine.d.ts +108 -0
- package/lib/fa/MatrixBasedStateMachine.d.ts.map +1 -0
- package/lib/fa/MatrixBasedStateMachine.js +132 -0
- package/lib/fa/MatrixBasedStateMachine.js.map +1 -0
- package/lib/fa/TransitionMatrix.d.ts +61 -0
- package/lib/fa/TransitionMatrix.d.ts.map +1 -0
- package/lib/fa/TransitionMatrix.js +104 -0
- package/lib/fa/TransitionMatrix.js.map +1 -0
- package/lib/index.d.ts +6 -6
- package/lib/index.d.ts.map +1 -1
- package/lib/index.js +6 -6
- package/lib/index.js.map +1 -1
- package/lib/sfsm/ExternalWorldHub.d.ts +52 -0
- package/lib/sfsm/ExternalWorldHub.d.ts.map +1 -0
- package/lib/sfsm/ExternalWorldHub.js +85 -0
- package/lib/sfsm/ExternalWorldHub.js.map +1 -0
- package/lib/sfsm/FaLoader.d.ts +22 -0
- package/lib/sfsm/FaLoader.d.ts.map +1 -0
- package/lib/sfsm/FaLoader.js +68 -0
- package/lib/sfsm/FaLoader.js.map +1 -0
- package/lib/sfsm/FaReducer.d.ts +12 -0
- package/lib/sfsm/FaReducer.d.ts.map +1 -0
- package/lib/sfsm/FaReducer.js +40 -0
- package/lib/sfsm/FaReducer.js.map +1 -0
- package/lib/sfsm/FaResolver.d.ts +37 -0
- package/lib/sfsm/FaResolver.d.ts.map +1 -0
- package/lib/sfsm/FaResolver.js +120 -0
- package/lib/sfsm/FaResolver.js.map +1 -0
- package/lib/sfsm/FaUpdater.d.ts +25 -0
- package/lib/sfsm/FaUpdater.d.ts.map +1 -0
- package/lib/sfsm/FaUpdater.js +106 -0
- package/lib/sfsm/FaUpdater.js.map +1 -0
- package/lib/sfsm/Sfsm.d.ts +60 -0
- package/lib/sfsm/Sfsm.d.ts.map +1 -0
- package/lib/sfsm/Sfsm.js +230 -0
- package/lib/sfsm/Sfsm.js.map +1 -0
- package/lib/sfsm/index.d.ts +9 -0
- package/lib/sfsm/index.d.ts.map +1 -0
- package/lib/sfsm/index.js +18 -0
- package/lib/sfsm/index.js.map +1 -0
- package/lib/sfsm/interfaces.d.ts +22 -0
- package/lib/sfsm/interfaces.d.ts.map +1 -0
- package/lib/sfsm/interfaces.js +3 -0
- package/lib/sfsm/interfaces.js.map +1 -0
- package/lib/sfsm/types.d.ts +78 -0
- package/lib/sfsm/types.d.ts.map +1 -0
- package/lib/sfsm/types.js +3 -0
- package/lib/sfsm/types.js.map +1 -0
- package/package.json +12 -2
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Wiring hub between the SFSM and the "external world".
|
|
3
|
+
*
|
|
4
|
+
* - registerSignalSender(signals, sender) — declares which signal names a sender may emit.
|
|
5
|
+
* - registerCommandReceiver(commands, receiver) — declares which commands a receiver handles.
|
|
6
|
+
* - connectTo(sfsm) — wires everything: sets itself as the SFSM command receiver and
|
|
7
|
+
* calls connectSignalTarget(sfsm) on every registered signal sender.
|
|
8
|
+
*
|
|
9
|
+
* The explicit signal/command name lists enable precise runtime error messages and
|
|
10
|
+
* provide a foundation for FA-vs-wiring validation.
|
|
11
|
+
*
|
|
12
|
+
* Fluent API:
|
|
13
|
+
* new ExternalWorldHub()
|
|
14
|
+
* .registerSignalSender(['TS.s'], service)
|
|
15
|
+
* .registerSignalSender(['TS.to', 'TS.ps'], device)
|
|
16
|
+
* .registerCommandReceiver(['TS.ut', 'TS.l'], device)
|
|
17
|
+
* .connectTo(sfsm);
|
|
18
|
+
*/
|
|
19
|
+
export class ExternalWorldHub {
|
|
20
|
+
constructor() {
|
|
21
|
+
this.commandRoutes = new Map();
|
|
22
|
+
this.signalSenders = [];
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Register a command receiver for the given list of exact command names.
|
|
26
|
+
* Throws if any command name is already registered.
|
|
27
|
+
* Fluent — returns this.
|
|
28
|
+
*/
|
|
29
|
+
registerCommandReceiver(commands, receiver) {
|
|
30
|
+
for (const cmd of commands) {
|
|
31
|
+
if (this.commandRoutes.has(cmd)) {
|
|
32
|
+
throw new Error(`ExternalWorldHub: command "${cmd}" is already registered`);
|
|
33
|
+
}
|
|
34
|
+
this.commandRoutes.set(cmd, receiver);
|
|
35
|
+
}
|
|
36
|
+
return this;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Register a signal sender for the given list of signal names.
|
|
40
|
+
* Duplicate signal names across registrations are allowed (same sender may be
|
|
41
|
+
* registered multiple times for documentation clarity), but will not cause
|
|
42
|
+
* double-wiring — connectSignalTarget is called once per registerSignalSender call.
|
|
43
|
+
* Fluent — returns this.
|
|
44
|
+
*/
|
|
45
|
+
registerSignalSender(signals, sender) {
|
|
46
|
+
this.signalSenders.push({ signals, sender });
|
|
47
|
+
return this;
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Wire this hub to the given SFSM instance:
|
|
51
|
+
* 1. Sets this hub as the SFSM's command receiver.
|
|
52
|
+
* 2. Calls connectSignalTarget(sfsm) on every registered signal sender.
|
|
53
|
+
* Fluent — returns this.
|
|
54
|
+
*/
|
|
55
|
+
connectTo(sfsm) {
|
|
56
|
+
sfsm.setCommandReceiver(this);
|
|
57
|
+
for (const { sender } of this.signalSenders) {
|
|
58
|
+
sender.connectSignalTarget(sfsm);
|
|
59
|
+
}
|
|
60
|
+
return this;
|
|
61
|
+
}
|
|
62
|
+
/** ICommandReceiver implementation — dispatches command to the registered receiver. */
|
|
63
|
+
receiveCommand(command, data) {
|
|
64
|
+
const receiver = this.commandRoutes.get(command);
|
|
65
|
+
if (!receiver) {
|
|
66
|
+
const registered = [...this.commandRoutes.keys()].join(', ');
|
|
67
|
+
throw new Error(`ExternalWorldHub: no receiver registered for command "${command}". ` +
|
|
68
|
+
`Registered commands: [${registered}]`);
|
|
69
|
+
}
|
|
70
|
+
receiver.receiveCommand(command, data);
|
|
71
|
+
}
|
|
72
|
+
/** Returns all command names that have a registered receiver (for diagnostics / validation). */
|
|
73
|
+
getRegisteredCommands() {
|
|
74
|
+
return [...this.commandRoutes.keys()];
|
|
75
|
+
}
|
|
76
|
+
/** Returns all signal names that have a registered sender (for diagnostics / validation). */
|
|
77
|
+
getRegisteredSignals() {
|
|
78
|
+
return this.signalSenders.flatMap(e => e.signals);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import * as fs from 'fs';
|
|
2
|
+
/**
|
|
3
|
+
* Reads and parses a JSON file at the given path and returns it as a FaDefinition.
|
|
4
|
+
* Works with both extended and compact formats.
|
|
5
|
+
*
|
|
6
|
+
* Node.js only — uses `fs.readFileSync` and is not available in browser environments.
|
|
7
|
+
*
|
|
8
|
+
* @param filePath - Absolute or relative path to a JSON file containing an FA definition.
|
|
9
|
+
* @returns Parsed FaDefinition ready to pass to `Sfsm.loadFA()`.
|
|
10
|
+
* @throws If the file cannot be read or the contents are not valid JSON.
|
|
11
|
+
*/
|
|
12
|
+
export function loadFAFromFile(filePath) {
|
|
13
|
+
const content = fs.readFileSync(filePath, 'utf-8');
|
|
14
|
+
return JSON.parse(content);
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Fetches and parses a JSON FA definition from a URL.
|
|
18
|
+
* Works in both browser and Node.js (≥ 18) environments — uses the standard `fetch` API.
|
|
19
|
+
*
|
|
20
|
+
* @param url - Absolute URL to a JSON file containing an FA definition.
|
|
21
|
+
* @returns Promise resolving to a FaDefinition ready to pass to `Sfsm.loadFA()`.
|
|
22
|
+
* @throws If the request fails, the server returns a non-2xx status, or the body is not valid JSON.
|
|
23
|
+
*/
|
|
24
|
+
export async function loadFAFromURL(url) {
|
|
25
|
+
const response = await fetch(url);
|
|
26
|
+
if (!response.ok) {
|
|
27
|
+
throw new Error(`loadFAFromURL: request failed with status ${response.status} ${response.statusText} for URL: ${url}`);
|
|
28
|
+
}
|
|
29
|
+
return response.json();
|
|
30
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Converts an extended FA definition (nested FaNode tree) into compact format
|
|
3
|
+
* (flat Record<string, Transition[]> with each FA's name as a top-level key).
|
|
4
|
+
*
|
|
5
|
+
* If the input is already in compact multi-FA format (all values are Transition[]),
|
|
6
|
+
* it is returned unchanged.
|
|
7
|
+
*
|
|
8
|
+
* Metadata (state names, descriptions, signal/command metadata) is discarded in the output.
|
|
9
|
+
*/
|
|
10
|
+
export function reduceFA(definition) {
|
|
11
|
+
const entries = Object.entries(definition);
|
|
12
|
+
// Already compact: all values are arrays
|
|
13
|
+
if (entries.every(([, v]) => Array.isArray(v))) {
|
|
14
|
+
return definition;
|
|
15
|
+
}
|
|
16
|
+
const result = {};
|
|
17
|
+
const [rootName, rootValue] = entries[0];
|
|
18
|
+
collectNodes(rootName, rootValue, result);
|
|
19
|
+
return result;
|
|
20
|
+
}
|
|
21
|
+
function collectNodes(name, node, result) {
|
|
22
|
+
result[name] = node.ts;
|
|
23
|
+
if (node.states) {
|
|
24
|
+
for (const [stateName, stateValue] of Object.entries(node.states)) {
|
|
25
|
+
if (isSubFaNode(stateValue)) {
|
|
26
|
+
collectNodes(stateName, stateValue, result);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
function isSubFaNode(value) {
|
|
32
|
+
return (typeof value === 'object' &&
|
|
33
|
+
value !== null &&
|
|
34
|
+
'ts' in value &&
|
|
35
|
+
Array.isArray(value.ts));
|
|
36
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Parses a raw FaDefinition and builds a flat index of all FAs by name.
|
|
3
|
+
*
|
|
4
|
+
* A state entry is considered a sub-FA if its value object contains a "ts" key.
|
|
5
|
+
* Leaf states (no "ts") are plain named states.
|
|
6
|
+
*/
|
|
7
|
+
export class FaResolver {
|
|
8
|
+
constructor(definition) {
|
|
9
|
+
this.definition = definition;
|
|
10
|
+
this.index = new Map();
|
|
11
|
+
this.buildIndex();
|
|
12
|
+
}
|
|
13
|
+
/** Returns the resolved FA for the given name, or throws if not found. */
|
|
14
|
+
get(name) {
|
|
15
|
+
const fa = this.index.get(name);
|
|
16
|
+
if (!fa) {
|
|
17
|
+
throw new Error(`SFSM: FA "${name}" not found in definition.`);
|
|
18
|
+
}
|
|
19
|
+
return fa;
|
|
20
|
+
}
|
|
21
|
+
/** Returns the name of the root FA.
|
|
22
|
+
*
|
|
23
|
+
* For single-key definitions (compact single FA or extended FA), the sole key is the root.
|
|
24
|
+
* For multi-key compact definitions (all values are Transition[]), the root is the FA whose
|
|
25
|
+
* name is never referenced as a target state (3rd element) in any other FA's transitions.
|
|
26
|
+
*/
|
|
27
|
+
getRootName() {
|
|
28
|
+
const keys = Object.keys(this.definition);
|
|
29
|
+
if (keys.length === 1) {
|
|
30
|
+
return keys[0];
|
|
31
|
+
}
|
|
32
|
+
// Multi-key: all values must be Transition[]
|
|
33
|
+
const allCompact = keys.every(k => Array.isArray(this.definition[k]));
|
|
34
|
+
if (!allCompact) {
|
|
35
|
+
throw new Error(`SFSM: Multi-key FA definition must use compact format (all values must be Transition[]).`);
|
|
36
|
+
}
|
|
37
|
+
// Collect all target state names referenced across all transitions
|
|
38
|
+
const referenced = new Set();
|
|
39
|
+
for (const key of keys) {
|
|
40
|
+
const transitions = this.definition[key];
|
|
41
|
+
for (const t of transitions) {
|
|
42
|
+
referenced.add(t[2]); // newState is at index 2
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
// Root = the FA name that is never a target in any other FA's transitions
|
|
46
|
+
const roots = keys.filter(k => !referenced.has(k));
|
|
47
|
+
if (roots.length !== 1) {
|
|
48
|
+
throw new Error(`SFSM: Cannot determine root FA. Candidates: [${roots.join(', ')}]. ` +
|
|
49
|
+
`All FA names: [${keys.join(', ')}].`);
|
|
50
|
+
}
|
|
51
|
+
return roots[0];
|
|
52
|
+
}
|
|
53
|
+
buildIndex() {
|
|
54
|
+
const entries = Object.entries(this.definition);
|
|
55
|
+
const allCompact = entries.every(([, v]) => Array.isArray(v));
|
|
56
|
+
if (allCompact && entries.length > 1) {
|
|
57
|
+
// Multi-key compact format: index all FAs, sub-FA names derived from definition keys
|
|
58
|
+
this.buildCompactMultiIndex();
|
|
59
|
+
}
|
|
60
|
+
else {
|
|
61
|
+
const rootName = this.getRootName();
|
|
62
|
+
const rootValue = this.definition[rootName];
|
|
63
|
+
if (Array.isArray(rootValue)) {
|
|
64
|
+
// Single-key compact format
|
|
65
|
+
const node = { ts: rootValue };
|
|
66
|
+
this.index.set(rootName, {
|
|
67
|
+
name: rootName,
|
|
68
|
+
transitions: rootValue,
|
|
69
|
+
subFaNames: new Set(),
|
|
70
|
+
node
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
else {
|
|
74
|
+
// Extended format
|
|
75
|
+
this.indexNode(rootName, rootValue);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
buildCompactMultiIndex() {
|
|
80
|
+
const definedFaNames = new Set(Object.keys(this.definition));
|
|
81
|
+
for (const [name, value] of Object.entries(this.definition)) {
|
|
82
|
+
const transitions = value;
|
|
83
|
+
// A state is a sub-FA if its name is a key in the definition
|
|
84
|
+
const subFaNames = new Set(transitions
|
|
85
|
+
.map(t => t[2])
|
|
86
|
+
.filter(target => definedFaNames.has(target)));
|
|
87
|
+
const node = { ts: transitions };
|
|
88
|
+
this.index.set(name, { name, transitions, subFaNames, node });
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
indexNode(name, node) {
|
|
92
|
+
const subFaNames = new Set();
|
|
93
|
+
if (node.states) {
|
|
94
|
+
for (const [stateName, stateValue] of Object.entries(node.states)) {
|
|
95
|
+
if (this.isSubFa(stateValue)) {
|
|
96
|
+
subFaNames.add(stateName);
|
|
97
|
+
this.indexNode(stateName, stateValue);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
this.index.set(name, {
|
|
102
|
+
name,
|
|
103
|
+
transitions: node.ts,
|
|
104
|
+
subFaNames,
|
|
105
|
+
node
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
/** A state value is a sub-FA if it has a "ts" array property. */
|
|
109
|
+
isSubFa(value) {
|
|
110
|
+
return (typeof value === 'object' &&
|
|
111
|
+
value !== null &&
|
|
112
|
+
'ts' in value &&
|
|
113
|
+
Array.isArray(value.ts));
|
|
114
|
+
}
|
|
115
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Apply an update to a compact FA definition (flat Record<string, Transition[]>).
|
|
3
|
+
*
|
|
4
|
+
* Steps (in order):
|
|
5
|
+
* 1. remove: delete the named top-level keys; also prune every remaining FA's
|
|
6
|
+
* transitions whose 3rd element (target state) is one of the removed names.
|
|
7
|
+
* 2. add: upsert the provided keys (existing entries are replaced, new ones are added).
|
|
8
|
+
*
|
|
9
|
+
* The source is not mutated — a deep-cloned result is returned.
|
|
10
|
+
*/
|
|
11
|
+
export function updateCompactFA(source, update) {
|
|
12
|
+
var _a;
|
|
13
|
+
const result = JSON.parse(JSON.stringify(source));
|
|
14
|
+
const toRemove = new Set((_a = update.remove) !== null && _a !== void 0 ? _a : []);
|
|
15
|
+
for (const name of toRemove) {
|
|
16
|
+
delete result[name];
|
|
17
|
+
}
|
|
18
|
+
// Prune transitions referencing removed FAs from all surviving FAs.
|
|
19
|
+
for (const transitions of Object.values(result)) {
|
|
20
|
+
const arr = transitions;
|
|
21
|
+
const filtered = arr.filter(t => !toRemove.has(t[2]));
|
|
22
|
+
arr.length = 0;
|
|
23
|
+
arr.push(...filtered);
|
|
24
|
+
}
|
|
25
|
+
if (update.add) {
|
|
26
|
+
for (const [name, value] of Object.entries(update.add)) {
|
|
27
|
+
result[name] = JSON.parse(JSON.stringify(value));
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
return result;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Apply an update to an extended (full/declaration) FA definition.
|
|
34
|
+
*
|
|
35
|
+
* Steps (in order):
|
|
36
|
+
* 1. remove: recursively locate each named FA in the tree; remove it from its
|
|
37
|
+
* parent's states map and prune the parent's ts of any transition targeting it.
|
|
38
|
+
* 2. add: for each entry, recursively locate the FA by name and replace it entirely.
|
|
39
|
+
* If the name matches the root key, the root node is replaced.
|
|
40
|
+
*
|
|
41
|
+
* The source is not mutated — a deep-cloned result is returned.
|
|
42
|
+
*/
|
|
43
|
+
export function updateFullFA(source, update) {
|
|
44
|
+
var _a;
|
|
45
|
+
const result = JSON.parse(JSON.stringify(source));
|
|
46
|
+
for (const name of ((_a = update.remove) !== null && _a !== void 0 ? _a : [])) {
|
|
47
|
+
const rootNode = Object.values(result)[0];
|
|
48
|
+
removeFromNode(rootNode, name);
|
|
49
|
+
}
|
|
50
|
+
if (update.add) {
|
|
51
|
+
const rootKey = Object.keys(result)[0];
|
|
52
|
+
for (const [name, newNode] of Object.entries(update.add)) {
|
|
53
|
+
if (name === rootKey) {
|
|
54
|
+
result[rootKey] = JSON.parse(JSON.stringify(newNode));
|
|
55
|
+
}
|
|
56
|
+
else {
|
|
57
|
+
const rootNode = Object.values(result)[0];
|
|
58
|
+
replaceInNode(rootNode, name, newNode);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return result;
|
|
63
|
+
}
|
|
64
|
+
// ---------------------------------------------------------------------------
|
|
65
|
+
// Helpers
|
|
66
|
+
// ---------------------------------------------------------------------------
|
|
67
|
+
function removeFromNode(node, targetName) {
|
|
68
|
+
if (!node.states)
|
|
69
|
+
return false;
|
|
70
|
+
if (targetName in node.states && isSubFaNode(node.states[targetName])) {
|
|
71
|
+
delete node.states[targetName];
|
|
72
|
+
node.ts = node.ts.filter(t => t[2] !== targetName);
|
|
73
|
+
return true;
|
|
74
|
+
}
|
|
75
|
+
for (const stateValue of Object.values(node.states)) {
|
|
76
|
+
if (isSubFaNode(stateValue) && removeFromNode(stateValue, targetName)) {
|
|
77
|
+
return true;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return false;
|
|
81
|
+
}
|
|
82
|
+
function replaceInNode(node, targetName, newNode) {
|
|
83
|
+
if (!node.states)
|
|
84
|
+
return false;
|
|
85
|
+
if (targetName in node.states) {
|
|
86
|
+
node.states[targetName] = JSON.parse(JSON.stringify(newNode));
|
|
87
|
+
return true;
|
|
88
|
+
}
|
|
89
|
+
for (const stateValue of Object.values(node.states)) {
|
|
90
|
+
if (isSubFaNode(stateValue) && replaceInNode(stateValue, targetName, newNode)) {
|
|
91
|
+
return true;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return false;
|
|
95
|
+
}
|
|
96
|
+
function isSubFaNode(value) {
|
|
97
|
+
return (typeof value === 'object' &&
|
|
98
|
+
value !== null &&
|
|
99
|
+
'ts' in value &&
|
|
100
|
+
Array.isArray(value.ts));
|
|
101
|
+
}
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
import { FaResolver } from './FaResolver';
|
|
2
|
+
/**
|
|
3
|
+
* Stacked Finite State Machine engine.
|
|
4
|
+
*
|
|
5
|
+
* Usage:
|
|
6
|
+
* const sfsm = new Sfsm(options);
|
|
7
|
+
* sfsm.setCommandReceiver(receiver);
|
|
8
|
+
* sfsm.loadFA(definition); // auto-activates state I of root FA
|
|
9
|
+
* sfsm.receiveSignal('TS.s');
|
|
10
|
+
*
|
|
11
|
+
* Signal re-entrancy: if receiveSignal is called while the engine is already
|
|
12
|
+
* processing a signal (e.g. from within a command receiver callback), the
|
|
13
|
+
* incoming signal is queued and processed after the current step completes.
|
|
14
|
+
* This prevents stack corruption and ensures deterministic ordering.
|
|
15
|
+
*/
|
|
16
|
+
export class Sfsm {
|
|
17
|
+
constructor(options = {}) {
|
|
18
|
+
var _a, _b;
|
|
19
|
+
this.resolver = null;
|
|
20
|
+
this.stack = [];
|
|
21
|
+
this.commandReceiver = null;
|
|
22
|
+
this.log = [];
|
|
23
|
+
this.processing = false;
|
|
24
|
+
this.signalQueue = [];
|
|
25
|
+
this.options = {
|
|
26
|
+
byMissingData: (_a = options.byMissingData) !== null && _a !== void 0 ? _a : 'error',
|
|
27
|
+
byMissingTransition: (_b = options.byMissingTransition) !== null && _b !== void 0 ? _b : 'error'
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
/** Register the single command receiver for this SFSM instance. */
|
|
31
|
+
setCommandReceiver(receiver) {
|
|
32
|
+
this.commandReceiver = receiver;
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Load a FA definition and initialise the engine.
|
|
36
|
+
* The root FA is pushed onto the stack with active state "I".
|
|
37
|
+
* No signal is sent automatically — the caller is responsible for
|
|
38
|
+
* sending the first signal (e.g. "TS.s") to drive out of state I.
|
|
39
|
+
*/
|
|
40
|
+
loadFA(definition) {
|
|
41
|
+
this.resolver = new FaResolver(definition);
|
|
42
|
+
const rootName = this.resolver.getRootName();
|
|
43
|
+
this.stack = [{ faName: rootName, currentState: 'I' }];
|
|
44
|
+
this.log = [];
|
|
45
|
+
this.processing = false;
|
|
46
|
+
this.signalQueue = [];
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Send a signal to the SFSM.
|
|
50
|
+
* If called re-entrantly (from within a command receiver), the signal
|
|
51
|
+
* is queued and will be processed after the current step finishes.
|
|
52
|
+
*/
|
|
53
|
+
receiveSignal(signal, data) {
|
|
54
|
+
if (this.processing) {
|
|
55
|
+
this.signalQueue.push({ signal, data });
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
this.processSignal(signal, data);
|
|
59
|
+
// Drain queue
|
|
60
|
+
while (this.signalQueue.length > 0) {
|
|
61
|
+
const next = this.signalQueue.shift();
|
|
62
|
+
this.processSignal(next.signal, next.data);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
/** Returns the accumulated log entries. */
|
|
66
|
+
getLog() {
|
|
67
|
+
return [...this.log];
|
|
68
|
+
}
|
|
69
|
+
/** Returns the current FA name stack (bottom = index 0, head = last). */
|
|
70
|
+
getCurrentStack() {
|
|
71
|
+
return this.stack.map(f => f.faName);
|
|
72
|
+
}
|
|
73
|
+
/** Returns the active state of the head FA. */
|
|
74
|
+
getHeadState() {
|
|
75
|
+
if (this.stack.length === 0)
|
|
76
|
+
throw new Error('SFSM: not initialised');
|
|
77
|
+
return this.stack[this.stack.length - 1].currentState;
|
|
78
|
+
}
|
|
79
|
+
// ── Core engine ─────────────────────────────────────────────────────────
|
|
80
|
+
processSignal(signal, data) {
|
|
81
|
+
if (!this.resolver || this.stack.length === 0) {
|
|
82
|
+
throw new Error('SFSM: call loadFA() before sending signals');
|
|
83
|
+
}
|
|
84
|
+
this.processing = true;
|
|
85
|
+
try {
|
|
86
|
+
this.applySignal(signal, data);
|
|
87
|
+
}
|
|
88
|
+
finally {
|
|
89
|
+
this.processing = false;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* Rule 2: find a matching transition, searching from head down the stack.
|
|
94
|
+
* Returns { frameIndex, transition } or null if not found anywhere.
|
|
95
|
+
*/
|
|
96
|
+
findTransition(signal) {
|
|
97
|
+
for (let i = this.stack.length - 1; i >= 0; i--) {
|
|
98
|
+
const frame = this.stack[i];
|
|
99
|
+
const fa = this.resolver.get(frame.faName);
|
|
100
|
+
const match = fa.transitions.find(t => t[0] === frame.currentState && t[1] === signal);
|
|
101
|
+
if (match) {
|
|
102
|
+
return { frameIndex: i, toState: match[2], command: match[3] };
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
return null;
|
|
106
|
+
}
|
|
107
|
+
applySignal(signal, data) {
|
|
108
|
+
var _a, _b, _c, _d;
|
|
109
|
+
const result = this.findTransition(signal);
|
|
110
|
+
if (!result) {
|
|
111
|
+
// Rule 2.2.1 — no transition found anywhere
|
|
112
|
+
this.handleMissingTransition(signal);
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
const { frameIndex, toState, command } = result;
|
|
116
|
+
// Capture stack state BEFORE any structural changes
|
|
117
|
+
const stackBefore = this.getCurrentStack();
|
|
118
|
+
const stateBefore = this.stack[frameIndex].currentState;
|
|
119
|
+
// Bubble-up: signal was handled by an ancestor FA, not the head
|
|
120
|
+
const bubbledUp = frameIndex < this.stack.length - 1;
|
|
121
|
+
// Rule 2.2.2.1 — pop frames above the matched FA
|
|
122
|
+
if (bubbledUp) {
|
|
123
|
+
this.stack.splice(frameIndex + 1);
|
|
124
|
+
}
|
|
125
|
+
// Update state
|
|
126
|
+
this.stack[frameIndex].currentState = toState;
|
|
127
|
+
// Rule label: 2.1 = head FA handled signal; 2.2.2.1 = ancestor handled after bubble-up
|
|
128
|
+
const rule = bubbledUp ? '2.2.2.1' : '2.1';
|
|
129
|
+
// Rule 2.1.1 — send command if present
|
|
130
|
+
let sentCommand;
|
|
131
|
+
let commandName;
|
|
132
|
+
let receiver;
|
|
133
|
+
if (command) {
|
|
134
|
+
sentCommand = command;
|
|
135
|
+
const meta = this.resolveCommandMeta(frameIndex, command);
|
|
136
|
+
commandName = meta === null || meta === void 0 ? void 0 : meta.name;
|
|
137
|
+
receiver = meta === null || meta === void 0 ? void 0 : meta.receiver;
|
|
138
|
+
const commandData = command.endsWith('$') ? data : undefined;
|
|
139
|
+
if (command.endsWith('$') && data === undefined) {
|
|
140
|
+
this.handleMissingData(signal, command);
|
|
141
|
+
}
|
|
142
|
+
(_a = this.commandReceiver) === null || _a === void 0 ? void 0 : _a.receiveCommand(command, commandData);
|
|
143
|
+
}
|
|
144
|
+
const logEntry = {
|
|
145
|
+
step: this.log.length + 1,
|
|
146
|
+
stack: stackBefore,
|
|
147
|
+
state: stateBefore,
|
|
148
|
+
stateName: (_b = this.resolveStateMeta(frameIndex, stateBefore)) === null || _b === void 0 ? void 0 : _b.name,
|
|
149
|
+
signal,
|
|
150
|
+
signalName: (_c = this.resolveSignalMeta(frameIndex, signal)) === null || _c === void 0 ? void 0 : _c.name,
|
|
151
|
+
rule,
|
|
152
|
+
newStack: this.getCurrentStack(),
|
|
153
|
+
newState: toState,
|
|
154
|
+
newStateName: (_d = this.resolveStateMeta(frameIndex, toState)) === null || _d === void 0 ? void 0 : _d.name,
|
|
155
|
+
command: sentCommand,
|
|
156
|
+
commandName,
|
|
157
|
+
receiver
|
|
158
|
+
};
|
|
159
|
+
this.log.push(logEntry);
|
|
160
|
+
// Rule 3: new state is itself a sub-FA — push it
|
|
161
|
+
const headFa = this.resolver.get(this.stack[frameIndex].faName);
|
|
162
|
+
if (headFa.subFaNames.has(toState)) {
|
|
163
|
+
this.stack.push({ faName: toState, currentState: 'I' });
|
|
164
|
+
this.applySignal(signal, data); // forward signal to sub-FA
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
// Rule 4: new state is an exit state
|
|
168
|
+
if (toState.startsWith('E_')) {
|
|
169
|
+
if (this.stack.length === 1) {
|
|
170
|
+
// Rule 4.1 — root FA resets to I
|
|
171
|
+
this.stack[0].currentState = 'I';
|
|
172
|
+
}
|
|
173
|
+
else {
|
|
174
|
+
// Rule 4.2 — pop current FA, forward signal to new head
|
|
175
|
+
this.stack.pop();
|
|
176
|
+
this.applySignal(signal, data);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
// ── Meta resolution helpers ──────────────────────────────────────────────
|
|
181
|
+
resolveStateMeta(frameIndex, state) {
|
|
182
|
+
var _a;
|
|
183
|
+
const fa = this.resolver.get(this.stack[frameIndex].faName);
|
|
184
|
+
return (_a = fa.node.states) === null || _a === void 0 ? void 0 : _a[state];
|
|
185
|
+
}
|
|
186
|
+
resolveSignalMeta(frameIndex, signal) {
|
|
187
|
+
var _a;
|
|
188
|
+
// Search from head FA downward for signal metadata
|
|
189
|
+
for (let i = this.stack.length - 1; i >= 0; i--) {
|
|
190
|
+
const fa = this.resolver.get(this.stack[i].faName);
|
|
191
|
+
if ((_a = fa.node.signals) === null || _a === void 0 ? void 0 : _a[signal])
|
|
192
|
+
return fa.node.signals[signal];
|
|
193
|
+
}
|
|
194
|
+
return undefined;
|
|
195
|
+
}
|
|
196
|
+
resolveCommandMeta(frameIndex, command) {
|
|
197
|
+
var _a;
|
|
198
|
+
for (let i = this.stack.length - 1; i >= 0; i--) {
|
|
199
|
+
const fa = this.resolver.get(this.stack[i].faName);
|
|
200
|
+
if ((_a = fa.node.commands) === null || _a === void 0 ? void 0 : _a[command])
|
|
201
|
+
return fa.node.commands[command];
|
|
202
|
+
}
|
|
203
|
+
return undefined;
|
|
204
|
+
}
|
|
205
|
+
// ── Policy handlers ──────────────────────────────────────────────────────
|
|
206
|
+
handleMissingTransition(signal) {
|
|
207
|
+
var _a;
|
|
208
|
+
const policy = (_a = this.options.byMissingTransition) !== null && _a !== void 0 ? _a : 'error';
|
|
209
|
+
const msg = `SFSM: no transition for signal "${signal}" in state "${this.getHeadState()}" (FA: ${this.getCurrentStack().join('>')})`;
|
|
210
|
+
if (policy === 'error')
|
|
211
|
+
throw new Error(msg);
|
|
212
|
+
if (policy === 'log_warning')
|
|
213
|
+
console.warn(msg);
|
|
214
|
+
// 'ignore': do nothing
|
|
215
|
+
}
|
|
216
|
+
handleMissingData(signal, command) {
|
|
217
|
+
var _a;
|
|
218
|
+
const policy = (_a = this.options.byMissingData) !== null && _a !== void 0 ? _a : 'error';
|
|
219
|
+
const msg = `SFSM: command "${command}" expects data but signal "${signal}" carries none`;
|
|
220
|
+
if (policy === 'error')
|
|
221
|
+
throw new Error(msg);
|
|
222
|
+
if (policy === 'log_warning')
|
|
223
|
+
console.warn(msg);
|
|
224
|
+
}
|
|
225
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export { ExternalWorldHub } from './ExternalWorldHub';
|
|
2
|
+
export { FaResolver } from './FaResolver';
|
|
3
|
+
export { Sfsm } from './Sfsm';
|
|
4
|
+
export { reduceFA } from './FaReducer';
|
|
5
|
+
export { updateCompactFA, updateFullFA } from './FaUpdater';
|
|
6
|
+
export { loadFAFromFile, loadFAFromURL } from './FaLoader';
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Marker base class for states that should be used processed by invalid signals and
|
|
3
|
+
* in situation, then no transitions set for pair (current signal, current state).
|
|
4
|
+
* Only one state in a state machine can have this role.
|
|
5
|
+
*/
|
|
6
|
+
export declare abstract class DefaultState {
|
|
7
|
+
isDefaultState(): boolean;
|
|
8
|
+
}
|
|
9
|
+
//# sourceMappingURL=DefaultState.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"DefaultState.d.ts","sourceRoot":"","sources":["../../src/fa/DefaultState.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AACH,8BAAsB,YAAY;IAC9B,cAAc,IAAI,OAAO;CAG5B"}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.DefaultState = void 0;
|
|
4
|
+
/**
|
|
5
|
+
* Marker base class for states that should be used processed by invalid signals and
|
|
6
|
+
* in situation, then no transitions set for pair (current signal, current state).
|
|
7
|
+
* Only one state in a state machine can have this role.
|
|
8
|
+
*/
|
|
9
|
+
class DefaultState {
|
|
10
|
+
isDefaultState() {
|
|
11
|
+
return true;
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
exports.DefaultState = DefaultState;
|
|
15
|
+
//# sourceMappingURL=DefaultState.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"DefaultState.js","sourceRoot":"","sources":["../../src/fa/DefaultState.ts"],"names":[],"mappings":";;;AAAA;;;;GAIG;AACH,MAAsB,YAAY;IAC9B,cAAc;QACV,OAAO,IAAI,CAAC;IAChB,CAAC;CACJ;AAJD,oCAIC"}
|