@nforma.ai/nforma 0.2.1
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/LICENSE +22 -0
- package/README.md +1024 -0
- package/agents/qgsd-codebase-mapper.md +764 -0
- package/agents/qgsd-debugger.md +1201 -0
- package/agents/qgsd-executor.md +472 -0
- package/agents/qgsd-integration-checker.md +443 -0
- package/agents/qgsd-phase-researcher.md +502 -0
- package/agents/qgsd-plan-checker.md +643 -0
- package/agents/qgsd-planner.md +1182 -0
- package/agents/qgsd-project-researcher.md +621 -0
- package/agents/qgsd-quorum-orchestrator.md +628 -0
- package/agents/qgsd-quorum-slot-worker.md +41 -0
- package/agents/qgsd-quorum-synthesizer.md +133 -0
- package/agents/qgsd-quorum-test-worker.md +37 -0
- package/agents/qgsd-quorum-worker.md +161 -0
- package/agents/qgsd-research-synthesizer.md +239 -0
- package/agents/qgsd-roadmapper.md +660 -0
- package/agents/qgsd-verifier.md +628 -0
- package/bin/accept-debug-invariant.cjs +165 -0
- package/bin/account-manager.cjs +719 -0
- package/bin/aggregate-requirements.cjs +466 -0
- package/bin/analyze-assumptions.cjs +757 -0
- package/bin/analyze-state-space.cjs +921 -0
- package/bin/attribute-trace-divergence.cjs +150 -0
- package/bin/auth-drivers/gh-cli.cjs +93 -0
- package/bin/auth-drivers/index.cjs +46 -0
- package/bin/auth-drivers/pool.cjs +67 -0
- package/bin/auth-drivers/simple.cjs +95 -0
- package/bin/autoClosePtoF.cjs +110 -0
- package/bin/blessed-terminal.cjs +350 -0
- package/bin/build-phase-index.cjs +472 -0
- package/bin/call-quorum-slot.cjs +541 -0
- package/bin/ccr-secure-config.cjs +99 -0
- package/bin/ccr-secure-start.cjs +83 -0
- package/bin/check-bundled-sdks.cjs +177 -0
- package/bin/check-coverage-guard.cjs +112 -0
- package/bin/check-liveness-fairness.cjs +95 -0
- package/bin/check-mcp-health.cjs +123 -0
- package/bin/check-provider-health.cjs +395 -0
- package/bin/check-results-exit.cjs +24 -0
- package/bin/check-spec-sync.cjs +360 -0
- package/bin/check-trace-redaction.cjs +271 -0
- package/bin/check-trace-schema-drift.cjs +99 -0
- package/bin/compareDrift.cjs +21 -0
- package/bin/conformance-schema.cjs +12 -0
- package/bin/count-scenarios.cjs +420 -0
- package/bin/debt-dedup.cjs +144 -0
- package/bin/debt-ledger.cjs +61 -0
- package/bin/debt-retention.cjs +76 -0
- package/bin/debt-state-machine.cjs +80 -0
- package/bin/detect-coverage-gaps.cjs +204 -0
- package/bin/detect-project-intent.cjs +362 -0
- package/bin/export-prism-constants.cjs +164 -0
- package/bin/extract-annotations.cjs +633 -0
- package/bin/extractFormalExpected.cjs +104 -0
- package/bin/fingerprint-drift.cjs +24 -0
- package/bin/fingerprint-issue.cjs +46 -0
- package/bin/formal-core.cjs +519 -0
- package/bin/formal-ref-linker.cjs +141 -0
- package/bin/formal-test-sync.cjs +788 -0
- package/bin/generate-formal-specs.cjs +588 -0
- package/bin/generate-petri-net.cjs +397 -0
- package/bin/generate-phase-spec.cjs +249 -0
- package/bin/generate-proposed-changes.cjs +194 -0
- package/bin/generate-tla-cfg.cjs +122 -0
- package/bin/generate-traceability-matrix.cjs +701 -0
- package/bin/generate-triage-bundle.cjs +300 -0
- package/bin/gh-account-rotate.cjs +34 -0
- package/bin/initialize-model-registry.cjs +105 -0
- package/bin/install-formal-tools.cjs +382 -0
- package/bin/install.js +2424 -0
- package/bin/isNumericThreshold.cjs +34 -0
- package/bin/issue-classifier.cjs +151 -0
- package/bin/levenshtein.cjs +74 -0
- package/bin/lint-formal-models.cjs +580 -0
- package/bin/load-baseline-requirements.cjs +275 -0
- package/bin/manage-agents-core.cjs +815 -0
- package/bin/migrate-formal-dir.cjs +172 -0
- package/bin/migrate-planning.cjs +206 -0
- package/bin/migrate-to-slots.cjs +255 -0
- package/bin/nForma.cjs +2726 -0
- package/bin/observe-config.cjs +353 -0
- package/bin/observe-debt-writer.cjs +140 -0
- package/bin/observe-handler-grafana.cjs +128 -0
- package/bin/observe-handler-internal.cjs +301 -0
- package/bin/observe-handler-logstash.cjs +153 -0
- package/bin/observe-handler-prometheus.cjs +185 -0
- package/bin/observe-handlers.cjs +436 -0
- package/bin/observe-registry.cjs +131 -0
- package/bin/observe-render.cjs +168 -0
- package/bin/planning-paths.cjs +167 -0
- package/bin/polyrepo.cjs +560 -0
- package/bin/prism-priority.cjs +153 -0
- package/bin/probe-quorum-slots.cjs +167 -0
- package/bin/promote-model.cjs +225 -0
- package/bin/propose-debug-invariants.cjs +165 -0
- package/bin/providers.json +392 -0
- package/bin/pty-proxy.py +129 -0
- package/bin/qgsd-solve.cjs +2477 -0
- package/bin/quorum-consensus-gate.cjs +238 -0
- package/bin/quorum-formal-context.cjs +183 -0
- package/bin/quorum-slot-dispatch.cjs +934 -0
- package/bin/read-policy.cjs +60 -0
- package/bin/requirement-map.cjs +63 -0
- package/bin/requirements-core.cjs +247 -0
- package/bin/resolve-cli.cjs +101 -0
- package/bin/review-mcp-logs.cjs +294 -0
- package/bin/run-account-manager-tlc.cjs +188 -0
- package/bin/run-account-pool-alloy.cjs +158 -0
- package/bin/run-alloy.cjs +153 -0
- package/bin/run-audit-alloy.cjs +187 -0
- package/bin/run-breaker-tlc.cjs +181 -0
- package/bin/run-formal-check.cjs +395 -0
- package/bin/run-formal-verify.cjs +701 -0
- package/bin/run-installer-alloy.cjs +188 -0
- package/bin/run-oauth-rotation-prism.cjs +132 -0
- package/bin/run-oscillation-tlc.cjs +202 -0
- package/bin/run-phase-tlc.cjs +228 -0
- package/bin/run-prism.cjs +446 -0
- package/bin/run-protocol-tlc.cjs +201 -0
- package/bin/run-quorum-composition-alloy.cjs +155 -0
- package/bin/run-sensitivity-sweep.cjs +231 -0
- package/bin/run-stop-hook-tlc.cjs +188 -0
- package/bin/run-tlc.cjs +467 -0
- package/bin/run-transcript-alloy.cjs +173 -0
- package/bin/run-uppaal.cjs +264 -0
- package/bin/secrets.cjs +134 -0
- package/bin/sensitivity-report.cjs +219 -0
- package/bin/sensitivity-sweep-feedback.cjs +194 -0
- package/bin/set-secret.cjs +29 -0
- package/bin/setup-telemetry-cron.sh +36 -0
- package/bin/sweepPtoF.cjs +63 -0
- package/bin/sync-baseline-requirements.cjs +290 -0
- package/bin/task-envelope.cjs +360 -0
- package/bin/telemetry-collector.cjs +229 -0
- package/bin/unified-mcp-server.mjs +735 -0
- package/bin/update-agents.cjs +369 -0
- package/bin/update-scoreboard.cjs +1134 -0
- package/bin/validate-debt-entry.cjs +207 -0
- package/bin/validate-invariant.cjs +419 -0
- package/bin/validate-memory.cjs +389 -0
- package/bin/validate-requirements-haiku.cjs +435 -0
- package/bin/validate-traces.cjs +438 -0
- package/bin/verify-formal-results.cjs +124 -0
- package/bin/verify-quorum-health.cjs +273 -0
- package/bin/write-check-result.cjs +106 -0
- package/bin/xstate-to-tla.cjs +483 -0
- package/bin/xstate-trace-walker.cjs +205 -0
- package/commands/qgsd/add-phase.md +43 -0
- package/commands/qgsd/add-requirement.md +24 -0
- package/commands/qgsd/add-todo.md +47 -0
- package/commands/qgsd/audit-milestone.md +37 -0
- package/commands/qgsd/check-todos.md +45 -0
- package/commands/qgsd/cleanup.md +18 -0
- package/commands/qgsd/close-formal-gaps.md +33 -0
- package/commands/qgsd/complete-milestone.md +136 -0
- package/commands/qgsd/debug.md +166 -0
- package/commands/qgsd/discuss-phase.md +83 -0
- package/commands/qgsd/execute-phase.md +117 -0
- package/commands/qgsd/fix-tests.md +27 -0
- package/commands/qgsd/formal-test-sync.md +32 -0
- package/commands/qgsd/health.md +22 -0
- package/commands/qgsd/help.md +22 -0
- package/commands/qgsd/insert-phase.md +32 -0
- package/commands/qgsd/join-discord.md +18 -0
- package/commands/qgsd/list-phase-assumptions.md +46 -0
- package/commands/qgsd/map-codebase.md +71 -0
- package/commands/qgsd/map-requirements.md +20 -0
- package/commands/qgsd/mcp-restart.md +176 -0
- package/commands/qgsd/mcp-set-model.md +134 -0
- package/commands/qgsd/mcp-setup.md +1371 -0
- package/commands/qgsd/mcp-status.md +274 -0
- package/commands/qgsd/mcp-update.md +238 -0
- package/commands/qgsd/new-milestone.md +44 -0
- package/commands/qgsd/new-project.md +42 -0
- package/commands/qgsd/observe.md +260 -0
- package/commands/qgsd/pause-work.md +38 -0
- package/commands/qgsd/plan-milestone-gaps.md +34 -0
- package/commands/qgsd/plan-phase.md +44 -0
- package/commands/qgsd/polyrepo.md +50 -0
- package/commands/qgsd/progress.md +24 -0
- package/commands/qgsd/queue.md +54 -0
- package/commands/qgsd/quick.md +133 -0
- package/commands/qgsd/quorum-test.md +275 -0
- package/commands/qgsd/quorum.md +707 -0
- package/commands/qgsd/reapply-patches.md +110 -0
- package/commands/qgsd/remove-phase.md +31 -0
- package/commands/qgsd/research-phase.md +189 -0
- package/commands/qgsd/resume-work.md +40 -0
- package/commands/qgsd/set-profile.md +34 -0
- package/commands/qgsd/settings.md +39 -0
- package/commands/qgsd/solve.md +565 -0
- package/commands/qgsd/sync-baselines.md +119 -0
- package/commands/qgsd/triage.md +233 -0
- package/commands/qgsd/update.md +37 -0
- package/commands/qgsd/verify-work.md +38 -0
- package/hooks/dist/config-loader.js +297 -0
- package/hooks/dist/conformance-schema.cjs +12 -0
- package/hooks/dist/gsd-context-monitor.js +64 -0
- package/hooks/dist/qgsd-check-update.js +62 -0
- package/hooks/dist/qgsd-circuit-breaker.js +682 -0
- package/hooks/dist/qgsd-precompact.js +156 -0
- package/hooks/dist/qgsd-prompt.js +653 -0
- package/hooks/dist/qgsd-session-start.js +122 -0
- package/hooks/dist/qgsd-slot-correlator.js +58 -0
- package/hooks/dist/qgsd-spec-regen.js +86 -0
- package/hooks/dist/qgsd-statusline.js +91 -0
- package/hooks/dist/qgsd-stop.js +553 -0
- package/hooks/dist/qgsd-token-collector.js +133 -0
- package/hooks/dist/unified-mcp-server.mjs +669 -0
- package/package.json +95 -0
- package/scripts/build-hooks.js +46 -0
- package/scripts/postinstall.js +48 -0
- package/scripts/secret-audit.sh +45 -0
- package/templates/qgsd.json +49 -0
|
@@ -0,0 +1,483 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
// bin/xstate-to-tla.cjs
|
|
4
|
+
// Transpiles an XState v5 machine definition (.ts) to TLA+ spec + TLC model config.
|
|
5
|
+
//
|
|
6
|
+
// Strategy:
|
|
7
|
+
// 1. Compile the TypeScript machine file to a temp CJS bundle via esbuild.
|
|
8
|
+
// 2. require() the bundle and find the exported XState machine object.
|
|
9
|
+
// 3. Walk machine.config to extract states, transitions, guard names, and
|
|
10
|
+
// which context variables each transition assigns.
|
|
11
|
+
// 4. Emit .planning/formal/tla/<ModuleName>_xstate.tla and .planning/formal/tla/MC<modulename>.cfg.
|
|
12
|
+
//
|
|
13
|
+
// Usage:
|
|
14
|
+
// node bin/xstate-to-tla.cjs <machine-file.ts>
|
|
15
|
+
// node bin/xstate-to-tla.cjs src/machines/qgsd-workflow.machine.ts \
|
|
16
|
+
// --module=QGSDQuorum \
|
|
17
|
+
// --config=.planning/formal/tla/guards/qgsd-workflow.json
|
|
18
|
+
// node bin/xstate-to-tla.cjs src/machines/account-manager.machine.ts \
|
|
19
|
+
// --module=QGSDAccountManager \
|
|
20
|
+
// --config=.planning/formal/tla/guards/account-manager.json \
|
|
21
|
+
// --dry
|
|
22
|
+
//
|
|
23
|
+
// Config file format (JSON):
|
|
24
|
+
// {
|
|
25
|
+
// "guards": {
|
|
26
|
+
// "minQuorumMet": "successCount * 2 >= N",
|
|
27
|
+
// "noInfiniteDeliberation": "deliberationRounds < MaxDeliberation"
|
|
28
|
+
// },
|
|
29
|
+
// "vars": {
|
|
30
|
+
// "currentPhase": "skip", // omit — redundant with 'state'
|
|
31
|
+
// "maxDeliberation": "const", // never changes, always UNCHANGED
|
|
32
|
+
// "successCount": "event", // value comes from event — add as action param
|
|
33
|
+
// "slotsAvailable": "event",
|
|
34
|
+
// "deliberationRounds": "deliberationRounds + 1" // literal TLA+ expression
|
|
35
|
+
// }
|
|
36
|
+
// }
|
|
37
|
+
//
|
|
38
|
+
// Var annotation meanings:
|
|
39
|
+
// "skip" — omit from VARIABLES, UNCHANGED, and assignments
|
|
40
|
+
// "const" — never changes in any transition; put in UNCHANGED
|
|
41
|
+
// "event" — value provided by the triggering event; becomes an action parameter
|
|
42
|
+
// <tla-expr> — literal TLA+ expression to use on the RHS of var' = <expr>
|
|
43
|
+
// (absent) — generate: var' = var \* FIXME: provide TLA+ expression
|
|
44
|
+
//
|
|
45
|
+
// Prerequisites: esbuild (devDependency)
|
|
46
|
+
|
|
47
|
+
const { buildSync } = require('esbuild');
|
|
48
|
+
const fs = require('fs');
|
|
49
|
+
const os = require('os');
|
|
50
|
+
const path = require('path');
|
|
51
|
+
|
|
52
|
+
const TAG = '[xstate-to-tla]';
|
|
53
|
+
|
|
54
|
+
// ── CLI ───────────────────────────────────────────────────────────────────────
|
|
55
|
+
const argv = process.argv.slice(2);
|
|
56
|
+
const inputFile = argv.find(a => !a.startsWith('-'));
|
|
57
|
+
const moduleArg = (argv.find(a => a.startsWith('--module=')) || '').slice('--module='.length);
|
|
58
|
+
const configArg = (argv.find(a => a.startsWith('--config=')) || '').slice('--config='.length);
|
|
59
|
+
const outDirArg = (argv.find(a => a.startsWith('--out-dir=')) || '').slice('--out-dir='.length);
|
|
60
|
+
const dry = argv.includes('--dry');
|
|
61
|
+
|
|
62
|
+
if (!inputFile) {
|
|
63
|
+
process.stderr.write(
|
|
64
|
+
'Usage: node bin/xstate-to-tla.cjs <machine-file.ts>\n' +
|
|
65
|
+
' [--module=ModuleName]\n' +
|
|
66
|
+
' [--config=guards-and-vars.json]\n' +
|
|
67
|
+
' [--out-dir=.planning/formal/tla]\n' +
|
|
68
|
+
' [--dry]\n'
|
|
69
|
+
);
|
|
70
|
+
process.exit(1);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const absInput = path.resolve(inputFile);
|
|
74
|
+
if (!fs.existsSync(absInput)) {
|
|
75
|
+
process.stderr.write(TAG + ' File not found: ' + absInput + '\n');
|
|
76
|
+
process.exit(1);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// ── Load user config ──────────────────────────────────────────────────────────
|
|
80
|
+
let userGuards = {}; // guardName → TLA+ expression
|
|
81
|
+
let userVars = {}; // varName → 'skip' | 'const' | 'event' | tla-expr
|
|
82
|
+
|
|
83
|
+
if (configArg) {
|
|
84
|
+
try {
|
|
85
|
+
const raw = JSON.parse(fs.readFileSync(path.resolve(configArg), 'utf8'));
|
|
86
|
+
userGuards = raw.guards || {};
|
|
87
|
+
userVars = raw.vars || {};
|
|
88
|
+
process.stdout.write(TAG + ' Config: ' + configArg + '\n');
|
|
89
|
+
process.stdout.write(TAG + ' guards: ' + Object.keys(userGuards).join(', ') + '\n');
|
|
90
|
+
process.stdout.write(TAG + ' vars: ' + Object.keys(userVars).join(', ') + '\n');
|
|
91
|
+
} catch (e) {
|
|
92
|
+
process.stderr.write(TAG + ' Failed to load config: ' + e.message + '\n');
|
|
93
|
+
process.exit(1);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// ── Compile TypeScript → temp CJS ────────────────────────────────────────────
|
|
98
|
+
const tmpBundle = path.join(os.tmpdir(), 'xstate-to-tla-' + Date.now() + '.cjs');
|
|
99
|
+
try {
|
|
100
|
+
buildSync({
|
|
101
|
+
entryPoints: [absInput],
|
|
102
|
+
bundle: true,
|
|
103
|
+
format: 'cjs',
|
|
104
|
+
outfile: tmpBundle,
|
|
105
|
+
platform: 'node',
|
|
106
|
+
logLevel: 'silent',
|
|
107
|
+
});
|
|
108
|
+
} catch (e) {
|
|
109
|
+
process.stderr.write(TAG + ' esbuild compilation failed: ' + e.message + '\n');
|
|
110
|
+
process.exit(1);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// ── Load compiled module ──────────────────────────────────────────────────────
|
|
114
|
+
let mod;
|
|
115
|
+
try {
|
|
116
|
+
mod = require(tmpBundle);
|
|
117
|
+
} catch (e) {
|
|
118
|
+
process.stderr.write(TAG + ' Failed to load compiled module: ' + e.message + '\n');
|
|
119
|
+
fs.unlinkSync(tmpBundle);
|
|
120
|
+
process.exit(1);
|
|
121
|
+
} finally {
|
|
122
|
+
try { fs.unlinkSync(tmpBundle); } catch (_) {}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Find the XState machine export: an object with .config.states
|
|
126
|
+
const machine = Object.values(mod).find(v =>
|
|
127
|
+
v && typeof v === 'object' && v.config && v.config.states
|
|
128
|
+
);
|
|
129
|
+
if (!machine) {
|
|
130
|
+
process.stderr.write(TAG + ' No XState machine export found in: ' + inputFile + '\n');
|
|
131
|
+
process.stderr.write(TAG + ' Exports: ' + Object.keys(mod).join(', ') + '\n');
|
|
132
|
+
process.exit(1);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const cfg = machine.config;
|
|
136
|
+
|
|
137
|
+
// ── Extract machine structure ─────────────────────────────────────────────────
|
|
138
|
+
const machineId = cfg.id || path.basename(inputFile, '.ts').replace('.machine', '');
|
|
139
|
+
const initial = String(cfg.initial);
|
|
140
|
+
const ctxDefaults = cfg.context || {};
|
|
141
|
+
|
|
142
|
+
// Derive module name
|
|
143
|
+
const moduleName = moduleArg || machineId
|
|
144
|
+
.split(/[-_\s]+/)
|
|
145
|
+
.map(w => w.charAt(0).toUpperCase() + w.slice(1))
|
|
146
|
+
.join('');
|
|
147
|
+
|
|
148
|
+
// Context variables (excluding 'skip' ones)
|
|
149
|
+
const allCtxKeys = Object.keys(ctxDefaults);
|
|
150
|
+
const ctxVars = allCtxKeys.filter(k => userVars[k] !== 'skip');
|
|
151
|
+
|
|
152
|
+
// State list
|
|
153
|
+
const stateNames = Object.keys(cfg.states);
|
|
154
|
+
const finalStates = stateNames.filter(s => cfg.states[s].type === 'final');
|
|
155
|
+
|
|
156
|
+
// ── Parse transitions ─────────────────────────────────────────────────────────
|
|
157
|
+
// Returns array of { fromState, event, guard, target, assignedKeys }
|
|
158
|
+
function parseTransitions() {
|
|
159
|
+
const result = [];
|
|
160
|
+
for (const stateName of stateNames) {
|
|
161
|
+
const stateDef = cfg.states[stateName];
|
|
162
|
+
if (!stateDef.on) continue;
|
|
163
|
+
|
|
164
|
+
for (const [eventName, transVal] of Object.entries(stateDef.on)) {
|
|
165
|
+
const branches = Array.isArray(transVal) ? transVal : [transVal];
|
|
166
|
+
|
|
167
|
+
for (const branch of branches) {
|
|
168
|
+
if (!branch) continue;
|
|
169
|
+
const guard = typeof branch.guard === 'string' ? branch.guard : null;
|
|
170
|
+
const target = branch.target ? String(branch.target) : null;
|
|
171
|
+
|
|
172
|
+
// Collect assign keys from actions
|
|
173
|
+
const assignedKeys = [];
|
|
174
|
+
const actions = branch.actions
|
|
175
|
+
? (Array.isArray(branch.actions) ? branch.actions : [branch.actions])
|
|
176
|
+
: [];
|
|
177
|
+
for (const act of actions) {
|
|
178
|
+
if (act && act.type === 'xstate.assign' && act.assignment) {
|
|
179
|
+
for (const k of Object.keys(act.assignment)) {
|
|
180
|
+
if (!assignedKeys.includes(k)) assignedKeys.push(k);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
result.push({ fromState: stateName, event: eventName, guard, target, assignedKeys });
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
return result;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const allTransitions = parseTransitions();
|
|
193
|
+
|
|
194
|
+
// ── Action name derivation ────────────────────────────────────────────────────
|
|
195
|
+
function toCamel(s) {
|
|
196
|
+
return s.toLowerCase()
|
|
197
|
+
.split(/[_\s]+/)
|
|
198
|
+
.map(w => w.charAt(0).toUpperCase() + w.slice(1))
|
|
199
|
+
.join('');
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// Count branches per (fromState, event) pair — for multi-branch disambiguation
|
|
203
|
+
const branchCount = {};
|
|
204
|
+
for (const t of allTransitions) {
|
|
205
|
+
const k = t.fromState + '::' + t.event;
|
|
206
|
+
branchCount[k] = (branchCount[k] || 0) + 1;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// Which events appear in more than one state? → need state prefix to stay unique
|
|
210
|
+
const eventStateSet = {};
|
|
211
|
+
for (const t of allTransitions) {
|
|
212
|
+
if (!eventStateSet[t.event]) eventStateSet[t.event] = new Set();
|
|
213
|
+
eventStateSet[t.event].add(t.fromState);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
for (const t of allTransitions) {
|
|
217
|
+
const cc = toCamel(t.event);
|
|
218
|
+
const multiState = eventStateSet[t.event].size > 1;
|
|
219
|
+
const statePrefix = multiState ? toCamel(t.fromState) : '';
|
|
220
|
+
const k = t.fromState + '::' + t.event;
|
|
221
|
+
const multiBranch = branchCount[k] > 1;
|
|
222
|
+
|
|
223
|
+
if (multiBranch) {
|
|
224
|
+
t.actionName = statePrefix + cc + 'To' + (t.target || 'Unknown');
|
|
225
|
+
} else {
|
|
226
|
+
t.actionName = statePrefix + cc;
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// ── TLA+ generation helpers ───────────────────────────────────────────────────
|
|
231
|
+
const ts_date = new Date().toISOString().split('T')[0];
|
|
232
|
+
const outDir = outDirArg
|
|
233
|
+
? path.resolve(outDirArg)
|
|
234
|
+
: path.join(__dirname, '..', '.planning', 'formal', 'tla');
|
|
235
|
+
|
|
236
|
+
// Variables that appear in UNCHANGED (excludes state, skip-vars, and vars whose annotation is const/event/expr)
|
|
237
|
+
// We need UNCHANGED for: ctxVars that are NOT in assignedKeys for this transition
|
|
238
|
+
function genUnchanged(assignedInThisTrans) {
|
|
239
|
+
// 'const' vars + ctxVars not assigned (and not 'event' either — event vars that aren't assigned stay unchanged)
|
|
240
|
+
const unchanged = ctxVars.filter(v => !assignedInThisTrans.includes(v));
|
|
241
|
+
if (unchanged.length === 0) return null;
|
|
242
|
+
if (unchanged.length === 1) return unchanged[0];
|
|
243
|
+
return '<<' + unchanged.join(', ') + '>>';
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// Generate the TLA+ assignment line for one variable in a transition
|
|
247
|
+
function genAssignLine(varName, isParam) {
|
|
248
|
+
if (isParam) return " /\\ " + varName + "' = " + varName + ' \\* param from event';
|
|
249
|
+
const ann = userVars[varName];
|
|
250
|
+
if (ann && ann !== 'const' && ann !== 'event' && ann !== 'skip') {
|
|
251
|
+
return " /\\ " + varName + "' = " + ann;
|
|
252
|
+
}
|
|
253
|
+
return " /\\ " + varName + "' = " + varName + ' \\* FIXME: XState assign for ' + varName;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// Generate one TLA+ action block
|
|
257
|
+
function genAction(t) {
|
|
258
|
+
const lines = [];
|
|
259
|
+
const cc = t.actionName;
|
|
260
|
+
|
|
261
|
+
// Which assigned vars are "event" type → become parameters
|
|
262
|
+
const params = t.assignedKeys.filter(k => userVars[k] === 'event');
|
|
263
|
+
const nonParamAssigned = t.assignedKeys.filter(k => userVars[k] !== 'event' && userVars[k] !== 'skip');
|
|
264
|
+
|
|
265
|
+
const paramStr = params.length > 0 ? '(' + params.join(', ') + ')' : '';
|
|
266
|
+
|
|
267
|
+
lines.push('\\* ' + t.fromState + ' -[' + t.event + (t.guard ? ' / ' + t.guard : '') + ']-> ' + (t.target || '?'));
|
|
268
|
+
lines.push(cc + paramStr + ' ==');
|
|
269
|
+
lines.push(' /\\ state = "' + t.fromState + '"');
|
|
270
|
+
|
|
271
|
+
// Guard
|
|
272
|
+
if (t.guard) {
|
|
273
|
+
const tlaGuard = userGuards[t.guard];
|
|
274
|
+
if (tlaGuard) {
|
|
275
|
+
lines.push(' /\\ ' + tlaGuard);
|
|
276
|
+
} else {
|
|
277
|
+
lines.push(' /\\ TRUE \\* FIXME: guard ' + t.guard + ' — add to config guards');
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// State transition
|
|
282
|
+
if (t.target) {
|
|
283
|
+
lines.push(" /\\ state' = \"" + t.target + '"');
|
|
284
|
+
} else {
|
|
285
|
+
lines.push(" /\\ state' = state \\* FIXME: unknown target");
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// Variable assignments
|
|
289
|
+
for (const v of params) {
|
|
290
|
+
lines.push(genAssignLine(v, true));
|
|
291
|
+
}
|
|
292
|
+
for (const v of nonParamAssigned) {
|
|
293
|
+
lines.push(genAssignLine(v, false));
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// UNCHANGED
|
|
297
|
+
const unch = genUnchanged(t.assignedKeys);
|
|
298
|
+
if (unch) lines.push(' /\\ UNCHANGED ' + unch);
|
|
299
|
+
|
|
300
|
+
return lines.join('\n');
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// Self-loop for final (absorbing) states
|
|
304
|
+
function genFinalSelfLoop(stateName) {
|
|
305
|
+
const lines = [
|
|
306
|
+
'\\* ' + stateName + ' is a final (absorbing) state',
|
|
307
|
+
'Stay' + stateName + ' ==',
|
|
308
|
+
' /\\ state = "' + stateName + '"',
|
|
309
|
+
" /\\ state' = \"" + stateName + '"',
|
|
310
|
+
];
|
|
311
|
+
if (ctxVars.length > 0) {
|
|
312
|
+
lines.push(' /\\ UNCHANGED <<' + ctxVars.join(', ') + '>>');
|
|
313
|
+
}
|
|
314
|
+
return lines.join('\n');
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
// All unique action names (for Next and WF)
|
|
318
|
+
const actionNames = [];
|
|
319
|
+
for (const t of allTransitions) {
|
|
320
|
+
if (!actionNames.includes(t.actionName)) actionNames.push(t.actionName);
|
|
321
|
+
}
|
|
322
|
+
for (const s of finalStates) {
|
|
323
|
+
const aName = 'Stay' + s;
|
|
324
|
+
if (!actionNames.includes(aName)) actionNames.push(aName);
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
// ── Assemble TLA+ file ────────────────────────────────────────────────────────
|
|
328
|
+
const varsTuple = ['state', ...ctxVars].length === 1
|
|
329
|
+
? 'state'
|
|
330
|
+
: '<<state, ' + ctxVars.join(', ') + '>>';
|
|
331
|
+
|
|
332
|
+
const lines = [
|
|
333
|
+
'---- MODULE ' + moduleName + '_xstate ----',
|
|
334
|
+
'(*',
|
|
335
|
+
' * .planning/formal/tla/' + moduleName + '_xstate.tla',
|
|
336
|
+
' * GENERATED by bin/xstate-to-tla.cjs',
|
|
337
|
+
' * Source: ' + path.relative(path.join(__dirname, '..'), absInput),
|
|
338
|
+
' * Regenerate: node bin/xstate-to-tla.cjs ' + path.relative(path.join(__dirname, '..'), absInput) +
|
|
339
|
+
(configArg ? ' --config=' + configArg : '') +
|
|
340
|
+
' --module=' + moduleName,
|
|
341
|
+
' * Generated: ' + ts_date,
|
|
342
|
+
' *',
|
|
343
|
+
' * XState machine id: ' + machineId,
|
|
344
|
+
' * Initial state: ' + initial,
|
|
345
|
+
' * States (' + stateNames.length + '): ' + stateNames.join(', '),
|
|
346
|
+
' * Final states: ' + (finalStates.length ? finalStates.join(', ') : '(none)'),
|
|
347
|
+
'*)',
|
|
348
|
+
'EXTENDS Naturals, FiniteSets, TLC',
|
|
349
|
+
'',
|
|
350
|
+
'\\* ── Variables ────────────────────────────────────────────────────────────────',
|
|
351
|
+
'VARIABLES',
|
|
352
|
+
' state' + (ctxVars.length > 0 ? ',' : '') + ' \\* FSM state',
|
|
353
|
+
...ctxVars.map((v, i) => {
|
|
354
|
+
const ann = userVars[v] || '(no annotation)';
|
|
355
|
+
const dflt = ctxDefaults[v];
|
|
356
|
+
return ' ' + v + (i < ctxVars.length - 1 ? ',' : '') +
|
|
357
|
+
' \\* default: ' + JSON.stringify(dflt) + ' annotation: ' + ann;
|
|
358
|
+
}),
|
|
359
|
+
'',
|
|
360
|
+
'vars == ' + varsTuple,
|
|
361
|
+
'',
|
|
362
|
+
'\\* ── Type invariant ────────────────────────────────────────────────────────────',
|
|
363
|
+
'\\* @requirement QUORUM-01',
|
|
364
|
+
'TypeOK ==',
|
|
365
|
+
' /\\ state \\in {' + stateNames.map(s => '"' + s + '"').join(', ') + '}',
|
|
366
|
+
...ctxVars.map(v => {
|
|
367
|
+
const dflt = ctxDefaults[v];
|
|
368
|
+
if (typeof dflt === 'number') return ' /\\ ' + v + ' \\in Nat \\* FIXME: tighten bound if needed';
|
|
369
|
+
if (typeof dflt === 'string') return ' /\\ ' + v + ' \\in STRING';
|
|
370
|
+
if (typeof dflt === 'boolean') return ' /\\ ' + v + ' \\in BOOLEAN';
|
|
371
|
+
return ' /\\ TRUE \\* FIXME: type for ' + v;
|
|
372
|
+
}),
|
|
373
|
+
'',
|
|
374
|
+
'\\* ── Initial state ─────────────────────────────────────────────────────────────',
|
|
375
|
+
'Init ==',
|
|
376
|
+
' /\\ state = "' + initial + '"',
|
|
377
|
+
...ctxVars.map(v => {
|
|
378
|
+
const dflt = ctxDefaults[v];
|
|
379
|
+
if (typeof dflt === 'string') return ' /\\ ' + v + " = \"" + dflt + '"';
|
|
380
|
+
if (typeof dflt === 'number') return ' /\\ ' + v + ' = ' + dflt;
|
|
381
|
+
if (typeof dflt === 'boolean') return ' /\\ ' + v + ' = ' + (dflt ? 'TRUE' : 'FALSE');
|
|
382
|
+
return ' /\\ ' + v + ' = 0 \\* FIXME: initial value';
|
|
383
|
+
}),
|
|
384
|
+
'',
|
|
385
|
+
'\\* ── Actions ────────────────────────────────────────────────────────────────────',
|
|
386
|
+
];
|
|
387
|
+
|
|
388
|
+
// Non-final state transitions
|
|
389
|
+
for (const stateName of stateNames) {
|
|
390
|
+
if (!finalStates.includes(stateName)) {
|
|
391
|
+
const stateTrans = allTransitions.filter(t => t.fromState === stateName);
|
|
392
|
+
for (const t of stateTrans) {
|
|
393
|
+
lines.push('');
|
|
394
|
+
lines.push(genAction(t));
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
// Final state self-loops
|
|
400
|
+
for (const stateName of finalStates) {
|
|
401
|
+
lines.push('');
|
|
402
|
+
lines.push(genFinalSelfLoop(stateName));
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
// Next
|
|
406
|
+
lines.push('');
|
|
407
|
+
lines.push('\\* ── Next ──────────────────────────────────────────────────────────────────────');
|
|
408
|
+
lines.push('Next ==');
|
|
409
|
+
for (const t of allTransitions) {
|
|
410
|
+
const params = t.assignedKeys.filter(k => userVars[k] === 'event');
|
|
411
|
+
const paramStr = params.length > 0 ? '(\\E ' + params.map(p => p + ' \\in Nat').join(', ') + ' : ' : '';
|
|
412
|
+
const closeParen = params.length > 0 ? ')' : '';
|
|
413
|
+
if (params.length > 0) {
|
|
414
|
+
lines.push(' \\/ \\E ' + params.map(p => p + ' \\in Nat').join(', ') + ' : ' + t.actionName + '(' + params.join(', ') + ')');
|
|
415
|
+
} else {
|
|
416
|
+
lines.push(' \\/ ' + t.actionName);
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
for (const s of finalStates) {
|
|
420
|
+
lines.push(' \\/ Stay' + s);
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
// Spec
|
|
424
|
+
lines.push('');
|
|
425
|
+
lines.push('\\* ── Specification ─────────────────────────────────────────────────────────────');
|
|
426
|
+
lines.push('Spec == Init /\\ [][Next]_vars');
|
|
427
|
+
for (const a of actionNames) {
|
|
428
|
+
lines.push(' /\\ WF_vars(' + a + ')');
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
// Invariant/liveness placeholders
|
|
432
|
+
lines.push('');
|
|
433
|
+
lines.push('\\* ── Invariants (add domain-specific properties here) ──────────────────────────');
|
|
434
|
+
lines.push('\\* TypeOK is the structural baseline. Add semantic invariants below.');
|
|
435
|
+
lines.push('');
|
|
436
|
+
lines.push('====');
|
|
437
|
+
lines.push('');
|
|
438
|
+
|
|
439
|
+
const tlaContent = lines.join('\n');
|
|
440
|
+
|
|
441
|
+
// ── Generate .cfg ─────────────────────────────────────────────────────────────
|
|
442
|
+
const cfgName = 'MC' + moduleName;
|
|
443
|
+
const cfgContent = [
|
|
444
|
+
'\\* .planning/formal/tla/' + cfgName + '.cfg',
|
|
445
|
+
'\\* GENERATED by bin/xstate-to-tla.cjs',
|
|
446
|
+
'\\* Regenerate: node bin/xstate-to-tla.cjs ' + path.relative(path.join(__dirname, '..'), absInput) +
|
|
447
|
+
(configArg ? ' --config=' + configArg : '') + ' --module=' + moduleName,
|
|
448
|
+
'\\* Generated: ' + ts_date,
|
|
449
|
+
'',
|
|
450
|
+
'SPECIFICATION Spec',
|
|
451
|
+
'INVARIANT TypeOK',
|
|
452
|
+
'CHECK_DEADLOCK FALSE',
|
|
453
|
+
'',
|
|
454
|
+
].join('\n');
|
|
455
|
+
|
|
456
|
+
// ── Write or dry-run ──────────────────────────────────────────────────────────
|
|
457
|
+
const tlaOutPath = path.join(outDir, moduleName + '_xstate.tla');
|
|
458
|
+
const cfgOutPath = path.join(outDir, cfgName + '.cfg');
|
|
459
|
+
|
|
460
|
+
if (dry) {
|
|
461
|
+
process.stdout.write('\n--- ' + path.relative(process.cwd(), tlaOutPath) + ' ---\n');
|
|
462
|
+
process.stdout.write(tlaContent);
|
|
463
|
+
process.stdout.write('\n--- ' + path.relative(process.cwd(), cfgOutPath) + ' ---\n');
|
|
464
|
+
process.stdout.write(cfgContent + '\n');
|
|
465
|
+
} else {
|
|
466
|
+
fs.mkdirSync(outDir, { recursive: true });
|
|
467
|
+
fs.writeFileSync(tlaOutPath, tlaContent, 'utf8');
|
|
468
|
+
fs.writeFileSync(cfgOutPath, cfgContent, 'utf8');
|
|
469
|
+
process.stdout.write(TAG + ' TLA+: ' + path.relative(process.cwd(), tlaOutPath) + '\n');
|
|
470
|
+
process.stdout.write(TAG + ' CFG: ' + path.relative(process.cwd(), cfgOutPath) + '\n');
|
|
471
|
+
process.stdout.write(TAG + ' States: ' + stateNames.join(', ') + '\n');
|
|
472
|
+
process.stdout.write(TAG + ' Actions: ' + actionNames.join(', ') + '\n');
|
|
473
|
+
|
|
474
|
+
// Report unresolved guards
|
|
475
|
+
const allGuardNames = [...new Set(allTransitions.filter(t => t.guard).map(t => t.guard))];
|
|
476
|
+
const unresolved = allGuardNames.filter(g => !userGuards[g]);
|
|
477
|
+
if (unresolved.length > 0) {
|
|
478
|
+
process.stdout.write(TAG + ' WARN: guards without TLA+ mapping — search for FIXME:\n');
|
|
479
|
+
for (const g of unresolved) {
|
|
480
|
+
process.stdout.write(TAG + ' ' + g + '\n');
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
}
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// bin/xstate-trace-walker.cjs
|
|
3
|
+
// Reusable XState trace replay library used by validate-traces.cjs and attribute-trace-divergence.cjs.
|
|
4
|
+
// Evaluates XState transitions and guard conditions given a conformance event and current snapshot.
|
|
5
|
+
//
|
|
6
|
+
// Key design principle: replayTrace creates ONE actor for the full sequence (not fresh per event).
|
|
7
|
+
// This enables multi-event interaction bug detection — guards in event N see context accumulated
|
|
8
|
+
// from events 1..N-1, rather than seeing the uninitialized defaults from a fresh IDLE snapshot.
|
|
9
|
+
// (Pitfall 1: "fresh-actor validation blindspot" documented in v0.21-02-RESEARCH.md)
|
|
10
|
+
//
|
|
11
|
+
// Exports: evaluateTransitions, replayTrace, evaluateGuard
|
|
12
|
+
|
|
13
|
+
const fs = require('fs');
|
|
14
|
+
const path = require('path');
|
|
15
|
+
|
|
16
|
+
// ── Machine loader (cached) ────────────────────────────────────────────────────
|
|
17
|
+
|
|
18
|
+
let _cachedMachineModule = null;
|
|
19
|
+
|
|
20
|
+
function loadMachineModule() {
|
|
21
|
+
if (_cachedMachineModule) return _cachedMachineModule;
|
|
22
|
+
const repoDist = path.join(__dirname, '..', 'dist', 'machines', 'qgsd-workflow.machine.cjs');
|
|
23
|
+
const installDist = path.join(__dirname, 'dist', 'machines', 'qgsd-workflow.machine.cjs');
|
|
24
|
+
const machinePath = fs.existsSync(repoDist) ? repoDist : installDist;
|
|
25
|
+
if (!fs.existsSync(machinePath)) {
|
|
26
|
+
throw new Error('Cannot find qgsd-workflow.machine.cjs at ' + repoDist + ' or ' + installDist);
|
|
27
|
+
}
|
|
28
|
+
_cachedMachineModule = require(machinePath);
|
|
29
|
+
return _cachedMachineModule;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// ── Guard evaluation ───────────────────────────────────────────────────────────
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* evaluateGuard: evaluate a named guard function against snapshot context + event.
|
|
36
|
+
*
|
|
37
|
+
* @param {string|null} guardName - The guard name string (from transition config)
|
|
38
|
+
* @param {object} context - The XState snapshot context at evaluation time
|
|
39
|
+
* @param {object} event - The event being evaluated
|
|
40
|
+
* @param {object} machine - The XState machine (used to resolve guard fn)
|
|
41
|
+
* @returns {{ guardName: string|null, guardPassed: boolean, guardContext: object }}
|
|
42
|
+
*/
|
|
43
|
+
function evaluateGuard(guardName, context, event, machine) {
|
|
44
|
+
if (!guardName) {
|
|
45
|
+
// No guard → transition is always taken (unconditional)
|
|
46
|
+
return { guardName: null, guardPassed: true, guardContext: Object.assign({}, context) };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Resolve guard function from machine implementations (XState v5) or options (XState v4)
|
|
50
|
+
const guards = (machine && (machine.implementations?.guards || machine.options?.guards || machine.config?.guards)) || {};
|
|
51
|
+
const guardFn = guards[guardName];
|
|
52
|
+
|
|
53
|
+
if (!guardFn || typeof guardFn !== 'function') {
|
|
54
|
+
// Guard function not found → fail-open (return true to avoid false negatives)
|
|
55
|
+
return { guardName, guardPassed: true, guardContext: Object.assign({}, context) };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
let guardPassed = true;
|
|
59
|
+
try {
|
|
60
|
+
// XState v5 guard signature: ({ context, event }) => boolean
|
|
61
|
+
// XState v4 guard signature: (context, event) => boolean
|
|
62
|
+
// Try v5 first (object destructuring), fall back to v4
|
|
63
|
+
guardPassed = !!guardFn({ context, event }, event);
|
|
64
|
+
} catch (_) {
|
|
65
|
+
// Guard threw — fail-open
|
|
66
|
+
guardPassed = true;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return { guardName, guardPassed, guardContext: Object.assign({}, context) };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// ── Core transition evaluator ──────────────────────────────────────────────────
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* evaluateTransitions: given a snapshot and event, determine which transitions would be
|
|
76
|
+
* taken and evaluate all applicable guards.
|
|
77
|
+
*
|
|
78
|
+
* Uses the actor approach: clones the snapshot via a fresh actor seeded to that state,
|
|
79
|
+
* sends the event, and captures before/after. Guard results are computed by evaluating
|
|
80
|
+
* the guard functions against the pre-send context.
|
|
81
|
+
*
|
|
82
|
+
* @param {object} snapshot - XState snapshot (from actor.getSnapshot())
|
|
83
|
+
* @param {object} event - Event object (must have `type` field)
|
|
84
|
+
* @param {object} machine - The XState machine definition
|
|
85
|
+
* @returns {{
|
|
86
|
+
* currentState: string,
|
|
87
|
+
* expectedNextState: string|null,
|
|
88
|
+
* emptyTransitions: boolean,
|
|
89
|
+
* possibleTransitions: Array<{ guardName: string|null, guardPassed: boolean, guardContext: object }>
|
|
90
|
+
* }}
|
|
91
|
+
*/
|
|
92
|
+
function evaluateTransitions(snapshot, event, machine) {
|
|
93
|
+
// Extract current state name (handles string or object shape from XState v4/v5)
|
|
94
|
+
const currentState = typeof snapshot.value === 'string'
|
|
95
|
+
? snapshot.value
|
|
96
|
+
: Object.keys(snapshot.value)[0];
|
|
97
|
+
|
|
98
|
+
const context = snapshot.context || {};
|
|
99
|
+
|
|
100
|
+
// Look up transitions for this state + event from machine config
|
|
101
|
+
const machineConfig = machine.config || {};
|
|
102
|
+
const states = machineConfig.states || {};
|
|
103
|
+
const stateConfig = states[currentState] || {};
|
|
104
|
+
const eventTransitions = stateConfig.on ? (stateConfig.on[event.type] || stateConfig.on[event.type]) : null;
|
|
105
|
+
|
|
106
|
+
if (!eventTransitions) {
|
|
107
|
+
// No transitions defined for this event in this state
|
|
108
|
+
return {
|
|
109
|
+
currentState,
|
|
110
|
+
expectedNextState: null,
|
|
111
|
+
emptyTransitions: true,
|
|
112
|
+
possibleTransitions: [],
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const transArray = Array.isArray(eventTransitions) ? eventTransitions : [eventTransitions];
|
|
117
|
+
|
|
118
|
+
if (transArray.length === 0) {
|
|
119
|
+
return {
|
|
120
|
+
currentState,
|
|
121
|
+
expectedNextState: null,
|
|
122
|
+
emptyTransitions: true,
|
|
123
|
+
possibleTransitions: [],
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Evaluate each transition's guard
|
|
128
|
+
const possibleTransitions = transArray.map(trans => {
|
|
129
|
+
const guardName = trans.guard || trans.cond || null;
|
|
130
|
+
return evaluateGuard(guardName, context, event, machine);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
// Determine expected next state: first transition whose guard passes
|
|
134
|
+
let expectedNextState = null;
|
|
135
|
+
for (let i = 0; i < transArray.length; i++) {
|
|
136
|
+
if (possibleTransitions[i].guardPassed) {
|
|
137
|
+
expectedNextState = transArray[i].target || currentState;
|
|
138
|
+
break;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
return {
|
|
143
|
+
currentState,
|
|
144
|
+
expectedNextState,
|
|
145
|
+
emptyTransitions: false,
|
|
146
|
+
possibleTransitions,
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// ── Trace replayer ─────────────────────────────────────────────────────────────
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* replayTrace: replay a sequence of events through a SINGLE XState actor.
|
|
154
|
+
*
|
|
155
|
+
* This is the key difference from fresh-actor-per-event validation:
|
|
156
|
+
* - Creates ONE actor for the full event sequence
|
|
157
|
+
* - Guards evaluated for event N see context accumulated from events 1..N-1
|
|
158
|
+
* - Enables detection of cross-event interaction bugs (Pitfall 1 prevention)
|
|
159
|
+
*
|
|
160
|
+
* @param {Array<object>} events - Array of event objects (each must have `type` field)
|
|
161
|
+
* @param {object} machine - The XState machine definition
|
|
162
|
+
* @returns {Array<{
|
|
163
|
+
* event: object,
|
|
164
|
+
* snapshotBefore: object,
|
|
165
|
+
* snapshotAfter: object,
|
|
166
|
+
* walkerResult: object (result from evaluateTransitions)
|
|
167
|
+
* }>}
|
|
168
|
+
*/
|
|
169
|
+
function replayTrace(events, machine) {
|
|
170
|
+
const { createActor } = loadMachineModule();
|
|
171
|
+
const actor = createActor(machine);
|
|
172
|
+
actor.start();
|
|
173
|
+
|
|
174
|
+
const results = [];
|
|
175
|
+
|
|
176
|
+
for (const event of events) {
|
|
177
|
+
const snapshotBefore = actor.getSnapshot();
|
|
178
|
+
|
|
179
|
+
// Evaluate transitions BEFORE sending (captures pre-send context for guard evaluation)
|
|
180
|
+
const walkerResult = evaluateTransitions(snapshotBefore, event, machine);
|
|
181
|
+
|
|
182
|
+
// Send the event to advance the actor state
|
|
183
|
+
try {
|
|
184
|
+
actor.send(event);
|
|
185
|
+
} catch (_) {
|
|
186
|
+
// Fail-open: if actor errors on this event, continue with remaining events
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const snapshotAfter = actor.getSnapshot();
|
|
190
|
+
|
|
191
|
+
results.push({
|
|
192
|
+
event,
|
|
193
|
+
snapshotBefore,
|
|
194
|
+
snapshotAfter,
|
|
195
|
+
walkerResult,
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
actor.stop();
|
|
200
|
+
return results;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// ── Exports ───────────────────────────────────────────────────────────────────
|
|
204
|
+
|
|
205
|
+
module.exports = { evaluateTransitions, replayTrace, evaluateGuard };
|