dsh-logicprobe 0.5.5 → 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/lib/engine.js CHANGED
@@ -1,1699 +1,2449 @@
1
- import { createHash } from 'node:crypto';
2
- export const ENGINE_SCHEMA_VERSION = 1;
3
- const DEFAULT_MAX_STATES = 10_000;
4
- const DEFAULT_MAX_PERMUTATION_EVENTS = 5;
5
- function stableStringify(value) {
6
- if (value === null || typeof value !== 'object')
7
- return JSON.stringify(value);
8
- if (Array.isArray(value))
9
- return '[' + value.map((item) => stableStringify(item)).join(',') + ']';
10
- const record = value;
11
- return '{' + Object.keys(record).sort().map((key) => JSON.stringify(key) + ':' + stableStringify(record[key])).join(',') + '}';
12
- }
13
- export function modelHash(model) {
14
- return createHash('sha256').update(stableStringify(model)).digest('hex');
15
- }
16
- export function validateModel(input) {
17
- const errors = [];
18
- const bad = (path, message) => {
19
- errors.push(path + ': ' + message);
20
- };
21
- if (typeof input !== 'object' || input === null || Array.isArray(input)) {
22
- return { ok: false, errors: ['model: must be an object'] };
23
- }
24
- const root = input;
25
- if (root.schemaVersion !== 1)
26
- bad('schemaVersion', 'must be 1');
27
- if (typeof root.init !== 'string' || root.init.length === 0)
28
- bad('init', 'must be a non-empty string');
29
- if (!Array.isArray(root.states) || root.states.length === 0) {
30
- bad('states', 'must be a non-empty array');
31
- }
32
- else {
33
- const seen = new Set();
34
- root.states.forEach((entry, index) => {
35
- if (typeof entry !== 'object' || entry === null || Array.isArray(entry)) {
36
- bad('states[' + index + ']', 'must be an object');
37
- return;
38
- }
39
- const state = entry;
40
- if (typeof state.id !== 'string' || state.id.length === 0)
41
- bad('states[' + index + '].id', 'must be a non-empty string');
42
- else if (seen.has(state.id))
43
- bad('states[' + index + '].id', 'duplicate state id ' + state.id);
44
- else
45
- seen.add(state.id);
46
- if (state.terminal !== undefined && typeof state.terminal !== 'boolean')
47
- bad('states[' + index + '].terminal', 'must be a boolean');
48
- });
49
- if (typeof root.init === 'string' && root.init.length > 0 && !seen.has(root.init))
50
- bad('init', 'must name a declared state');
51
- }
52
- if (!Array.isArray(root.transitions)) {
53
- bad('transitions', 'must be an array');
54
- }
55
- else {
56
- const stateIds = new Set(Array.isArray(root.states) ? root.states.map((state) => state.id) : []);
57
- root.transitions.forEach((entry, index) => {
58
- const path = 'transitions[' + index + ']';
59
- if (typeof entry !== 'object' || entry === null || Array.isArray(entry)) {
60
- bad(path, 'must be an object');
61
- return;
62
- }
63
- const transition = entry;
64
- if (typeof transition.from !== 'string' || transition.from.length === 0)
65
- bad(path + '.from', 'must be a non-empty string');
66
- else if (!stateIds.has(transition.from))
67
- bad(path + '.from', 'unknown state ' + transition.from);
68
- if (typeof transition.event !== 'string' || transition.event.length === 0)
69
- bad(path + '.event', 'must be a non-empty string');
70
- if (typeof transition.to !== 'string' || transition.to.length === 0)
71
- bad(path + '.to', 'must be a non-empty string');
72
- else if (!stateIds.has(transition.to))
73
- bad(path + '.to', 'unknown state ' + transition.to);
74
- if (transition.guard !== undefined)
75
- validateGuard(transition.guard, path + '.guard', errors, bad);
76
- if (transition.updates !== undefined) {
77
- if (!Array.isArray(transition.updates))
78
- bad(path + '.updates', 'must be an array');
79
- else
80
- transition.updates.forEach((update, updateIndex) => {
81
- const updatePath = path + '.updates[' + updateIndex + ']';
82
- if (typeof update !== 'object' || update === null || Array.isArray(update)) {
83
- bad(updatePath, 'must be an object');
84
- return;
85
- }
86
- const record = update;
87
- if (typeof record.variable !== 'string' || record.variable.length === 0)
88
- bad(updatePath + '.variable', 'must be a non-empty string');
89
- if (record.op !== 'set' && record.op !== 'inc' && record.op !== 'dec')
90
- bad(updatePath + '.op', "must be 'set', 'inc', or 'dec'");
91
- if (record.value !== undefined && typeof record.value !== 'number')
92
- bad(updatePath + '.value', 'must be a number');
93
- });
94
- }
95
- });
96
- }
97
- const variableNames = new Set();
98
- if (root.variables !== undefined) {
99
- if (!Array.isArray(root.variables))
100
- bad('variables', 'must be an array');
101
- else
102
- root.variables.forEach((entry, index) => {
103
- const path = 'variables[' + index + ']';
104
- if (typeof entry !== 'object' || entry === null || Array.isArray(entry)) {
105
- bad(path, 'must be an object');
106
- return;
107
- }
108
- const variable = entry;
109
- if (typeof variable.name !== 'string' || variable.name.length === 0)
110
- bad(path + '.name', 'must be a non-empty string');
111
- else if (variableNames.has(variable.name))
112
- bad(path + '.name', 'duplicate variable ' + variable.name);
113
- else
114
- variableNames.add(variable.name);
115
- if (variable.kind !== 'integer' && variable.kind !== 'boolean')
116
- bad(path + '.kind', "must be 'integer' or 'boolean'");
117
- const expected = variable.kind === 'boolean' ? 'boolean' : 'number';
118
- if (typeof variable.init !== expected)
119
- bad(path + '.init', 'must be a ' + expected);
120
- if (variable.min !== undefined && typeof variable.min !== 'number')
121
- bad(path + '.min', 'must be a number');
122
- if (variable.max !== undefined && typeof variable.max !== 'number')
123
- bad(path + '.max', 'must be a number');
124
- if (typeof variable.min === 'number' && typeof variable.max === 'number' && variable.min > variable.max)
125
- bad(path + '.max', 'must be >= min');
126
- if (variable.monotonic !== undefined && variable.monotonic !== 'inc' && variable.monotonic !== 'dec')
127
- bad(path + '.monotonic', "must be 'inc' or 'dec'");
128
- if (variable.kind === 'integer' && typeof variable.init === 'number') {
129
- if (typeof variable.min === 'number' && variable.init < variable.min)
130
- bad(path + '.init', 'must be >= min');
131
- if (typeof variable.max === 'number' && variable.init > variable.max)
132
- bad(path + '.init', 'must be <= max');
133
- }
134
- });
135
- }
136
- const validateVariableRef = (name, path) => {
137
- if (typeof name !== 'string' || name.length === 0)
138
- bad(path, 'must be a non-empty string');
139
- else if (!variableNames.has(name))
140
- bad(path, 'references unknown variable ' + name);
141
- };
142
- if (root.invariants !== undefined) {
143
- if (!Array.isArray(root.invariants))
144
- bad('invariants', 'must be an array');
145
- else
146
- root.invariants.forEach((entry, index) => {
147
- const path = 'invariants[' + index + ']';
148
- if (typeof entry !== 'object' || entry === null || Array.isArray(entry)) {
149
- bad(path, 'must be an object');
150
- return;
151
- }
152
- const invariant = entry;
153
- if (typeof invariant.id !== 'string' || invariant.id.length === 0)
154
- bad(path + '.id', 'must be a non-empty string');
155
- if (typeof invariant.description !== 'string')
156
- bad(path + '.description', 'must be a string');
157
- if (invariant.kind === 'never-states') {
158
- if (!Array.isArray(invariant.states) || invariant.states.length === 0)
159
- bad(path + '.states', 'must be a non-empty array');
160
- else
161
- invariant.states.forEach((state, stateIndex) => {
162
- if (typeof state !== 'string' || state.length === 0)
163
- bad(path + '.states[' + stateIndex + ']', 'must be a non-empty string');
164
- });
165
- }
166
- else if (invariant.kind === 'var-in-range') {
167
- validateVariableRef(invariant.variable, path + '.variable');
168
- if (invariant.min !== undefined && typeof invariant.min !== 'number')
169
- bad(path + '.min', 'must be a number');
170
- if (invariant.max !== undefined && typeof invariant.max !== 'number')
171
- bad(path + '.max', 'must be a number');
172
- }
173
- else if (invariant.kind === 'event-before-state') {
174
- if (typeof invariant.event !== 'string' || invariant.event.length === 0)
175
- bad(path + '.event', 'must be a non-empty string');
176
- if (typeof invariant.state !== 'string' || invariant.state.length === 0)
177
- bad(path + '.state', 'must be a non-empty string');
178
- }
179
- else if (invariant.kind === 'leads-to') {
180
- if (typeof invariant.from !== 'string' || invariant.from.length === 0)
181
- bad(path + '.from', 'must be a non-empty string');
182
- if (typeof invariant.to !== 'string' || invariant.to.length === 0)
183
- bad(path + '.to', 'must be a non-empty string');
184
- }
185
- else if (invariant.kind === 'sequence') {
186
- if (!Array.isArray(invariant.events) || invariant.events.length === 0)
187
- bad(path + '.events', 'must be a non-empty array');
188
- else
189
- invariant.events.forEach((event, eventIndex) => {
190
- if (typeof event !== 'string' || event.length === 0)
191
- bad(path + '.events[' + eventIndex + ']', 'must be a non-empty string');
192
- });
193
- }
194
- else if (invariant.kind === 'atomicity') {
195
- if (!Array.isArray(invariant.events) || invariant.events.length === 0)
196
- bad(path + '.events', 'must be a non-empty array');
197
- else
198
- invariant.events.forEach((event, eventIndex) => {
199
- if (typeof event !== 'string' || event.length === 0)
200
- bad(path + '.events[' + eventIndex + ']', 'must be a non-empty string');
201
- });
202
- if (typeof invariant.commit !== 'string' || invariant.commit.length === 0)
203
- bad(path + '.commit', 'must be a non-empty string');
204
- if (invariant.rollback !== undefined && typeof invariant.rollback !== 'string')
205
- bad(path + '.rollback', 'must be a string');
206
- }
207
- else {
208
- bad(path + '.kind', 'unknown invariant kind');
209
- }
210
- });
211
- }
212
- if (root.concurrentPairs !== undefined) {
213
- if (!Array.isArray(root.concurrentPairs))
214
- bad('concurrentPairs', 'must be an array');
215
- else
216
- root.concurrentPairs.forEach((entry, index) => {
217
- const path = 'concurrentPairs[' + index + ']';
218
- if (!Array.isArray(entry) || entry.length !== 2 || typeof entry[0] !== 'string' || typeof entry[1] !== 'string') {
219
- bad(path, 'must be a [event, event] string pair');
220
- }
221
- });
222
- }
223
- if (root.boundaryChecks !== undefined) {
224
- if (!Array.isArray(root.boundaryChecks))
225
- bad('boundaryChecks', 'must be an array');
226
- else
227
- root.boundaryChecks.forEach((entry, index) => {
228
- const path = 'boundaryChecks[' + index + ']';
229
- if (typeof entry !== 'object' || entry === null || Array.isArray(entry)) {
230
- bad(path, 'must be an object');
231
- return;
232
- }
233
- const check = entry;
234
- validateVariableRef(check.variable, path + '.variable');
235
- if (!Array.isArray(check.values) || check.values.some((value) => typeof value !== 'number'))
236
- bad(path + '.values', 'must be an array of numbers');
237
- });
238
- }
239
- if (root.idempotentEvents !== undefined) {
240
- if (!Array.isArray(root.idempotentEvents))
241
- bad('idempotentEvents', 'must be an array');
242
- else
243
- root.idempotentEvents.forEach((entry, index) => {
244
- if (typeof entry !== 'string' || entry.length === 0)
245
- bad('idempotentEvents[' + index + ']', 'must be a non-empty string');
246
- });
247
- }
248
- if (root.resourcePairs !== undefined) {
249
- if (!Array.isArray(root.resourcePairs))
250
- bad('resourcePairs', 'must be an array');
251
- else
252
- root.resourcePairs.forEach((entry, index) => {
253
- const path = 'resourcePairs[' + index + ']';
254
- if (typeof entry !== 'object' || entry === null || Array.isArray(entry)) {
255
- bad(path, 'must be an object');
256
- return;
257
- }
258
- const pair = entry;
259
- if (typeof pair.resource !== 'string' || pair.resource.length === 0)
260
- bad(path + '.resource', 'must be a non-empty string');
261
- if (typeof pair.acquireEvent !== 'string' || pair.acquireEvent.length === 0)
262
- bad(path + '.acquireEvent', 'must be a non-empty string');
263
- if (typeof pair.releaseEvent !== 'string' || pair.releaseEvent.length === 0)
264
- bad(path + '.releaseEvent', 'must be a non-empty string');
265
- if (pair.failEvent !== undefined && typeof pair.failEvent !== 'string')
266
- bad(path + '.failEvent', 'must be a string');
267
- });
268
- }
269
- if (root.narrative !== undefined) {
270
- const narrativePath = 'narrative';
271
- if (typeof root.narrative !== 'object' || root.narrative === null || Array.isArray(root.narrative)) {
272
- bad(narrativePath, 'must be an object');
273
- }
274
- else {
275
- const narrative = root.narrative;
276
- const stateIds = new Set(Array.isArray(root.states) ? root.states.map((state) => state.id) : []);
277
- const eventIds = new Set(Array.isArray(root.transitions) ? root.transitions.map((transition) => transition.event) : []);
278
- const fromEventGroups = new Set(Array.isArray(root.transitions) ? root.transitions.map((transition) => transition.from + '|' + transition.event) : []);
279
- if (typeof narrative.states !== 'object' || narrative.states === null || Array.isArray(narrative.states)) {
280
- bad(narrativePath + '.states', 'must be an object mapping state id -> natural-language description');
281
- }
282
- else {
283
- for (const [id, description] of Object.entries(narrative.states)) {
284
- if (!stateIds.has(id))
285
- bad(narrativePath + '.states', 'references unknown state ' + id);
286
- if (typeof description !== 'string' || description.length === 0)
287
- bad(narrativePath + '.states.' + id, 'must be a non-empty string');
288
- }
289
- for (const id of stateIds) {
290
- if (typeof narrative.states[id] !== 'string')
291
- bad(narrativePath + '.states', 'missing description for state ' + id);
292
- }
293
- }
294
- if (typeof narrative.events !== 'object' || narrative.events === null || Array.isArray(narrative.events)) {
295
- bad(narrativePath + '.events', 'must be an object mapping event id -> natural-language description');
296
- }
297
- else {
298
- for (const [id, description] of Object.entries(narrative.events)) {
299
- if (!eventIds.has(id))
300
- bad(narrativePath + '.events', 'references unknown event ' + id);
301
- if (typeof description !== 'string' || description.length === 0)
302
- bad(narrativePath + '.events.' + id, 'must be a non-empty string');
303
- }
304
- for (const id of eventIds) {
305
- if (typeof narrative.events[id] !== 'string')
306
- bad(narrativePath + '.events', 'missing description for event ' + id);
307
- }
308
- }
309
- if (!Array.isArray(narrative.scenarios)) {
310
- bad(narrativePath + '.scenarios', 'must be an array of { from, event, scenario }');
311
- }
312
- else {
313
- const seen = new Set();
314
- narrative.scenarios.forEach((entry, index) => {
315
- const scenarioPath = narrativePath + '.scenarios[' + index + ']';
316
- if (typeof entry !== 'object' || entry === null || Array.isArray(entry)) {
317
- bad(scenarioPath, 'must be an object');
318
- return;
319
- }
320
- const scenario = entry;
321
- if (typeof scenario.from !== 'string' || scenario.from.length === 0)
322
- bad(scenarioPath + '.from', 'must be a non-empty string');
323
- else if (!stateIds.has(scenario.from))
324
- bad(scenarioPath + '.from', 'unknown state ' + scenario.from);
325
- if (typeof scenario.event !== 'string' || scenario.event.length === 0)
326
- bad(scenarioPath + '.event', 'must be a non-empty string');
327
- else if (!eventIds.has(scenario.event))
328
- bad(scenarioPath + '.event', 'unknown event ' + scenario.event);
329
- if (typeof scenario.scenario !== 'string' || scenario.scenario.length === 0)
330
- bad(scenarioPath + '.scenario', 'must be a non-empty string');
331
- const key = String(scenario.from) + '|' + String(scenario.event);
332
- if (seen.has(key))
333
- bad(scenarioPath, 'duplicate scenario for (' + scenario.from + ', ' + scenario.event + ')');
334
- seen.add(key);
335
- });
336
- for (const key of fromEventGroups) {
337
- if (!seen.has(key)) {
338
- const sep = key.indexOf('|');
339
- bad(narrativePath + '.scenarios', 'missing scenario for (' + key.slice(0, sep) + ', ' + key.slice(sep + 1) + ')');
340
- }
341
- }
342
- }
343
- }
344
- }
345
- // Guard/update variable references were validated structurally; re-walk for references.
346
- if (Array.isArray(root.transitions)) {
347
- for (const entry of root.transitions) {
348
- walkGuardReferences(entry.guard, variableNames, errors, bad);
349
- for (const update of entry.updates ?? []) {
350
- if (!variableNames.has(update.variable))
351
- bad('transitions.updates', 'references unknown variable ' + update.variable);
352
- if (update.op !== 'set' && update.value !== undefined && update.value === 0) {
353
- // zero delta is harmless; no finding
354
- }
355
- }
356
- }
357
- }
358
- if (errors.length > 0)
359
- return { ok: false, errors };
360
- return { ok: true, model: root };
361
- }
362
- function validateGuard(input, path, errors, bad) {
363
- if (typeof input !== 'object' || input === null || Array.isArray(input)) {
364
- bad(path, 'must be an object');
365
- return;
366
- }
367
- const guard = input;
368
- if (guard.variable !== undefined) {
369
- if (typeof guard.variable !== 'string' || guard.variable.length === 0)
370
- bad(path + '.variable', 'must be a non-empty string');
371
- if (guard.op !== '==' && guard.op !== '!=' && guard.op !== '<' && guard.op !== '<=' && guard.op !== '>' && guard.op !== '>=')
372
- bad(path + '.op', 'must be a comparison operator');
373
- if (typeof guard.value !== 'number' && typeof guard.value !== 'boolean')
374
- bad(path + '.value', 'must be a number or boolean');
375
- return;
376
- }
377
- if (guard.all !== undefined) {
378
- if (!Array.isArray(guard.all))
379
- bad(path + '.all', 'must be an array');
380
- else
381
- guard.all.forEach((child, index) => validateGuard(child, path + '.all[' + index + ']', errors, bad));
382
- return;
383
- }
384
- if (guard.any !== undefined) {
385
- if (!Array.isArray(guard.any))
386
- bad(path + '.any', 'must be an array');
387
- else
388
- guard.any.forEach((child, index) => validateGuard(child, path + '.any[' + index + ']', errors, bad));
389
- return;
390
- }
391
- if (guard.not !== undefined) {
392
- validateGuard(guard.not, path + '.not', errors, bad);
393
- return;
394
- }
395
- bad(path, 'must be a leaf ({ variable, op, value }), { all }, { any }, or { not }');
396
- }
397
- function walkGuardReferences(guard, variableNames, errors, bad) {
398
- if (guard === undefined)
399
- return;
400
- if ('variable' in guard) {
401
- if (!variableNames.has(guard.variable))
402
- bad('guard', 'references unknown variable ' + guard.variable);
403
- return;
404
- }
405
- if ('all' in guard) {
406
- for (const child of guard.all)
407
- walkGuardReferences(child, variableNames, errors, bad);
408
- return;
409
- }
410
- if ('any' in guard) {
411
- for (const child of guard.any)
412
- walkGuardReferences(child, variableNames, errors, bad);
413
- return;
414
- }
415
- if ('not' in guard)
416
- walkGuardReferences(guard.not, variableNames, errors, bad);
417
- }
418
- // ---------------------------------------------------------------------------
419
- // execution
420
- // ---------------------------------------------------------------------------
421
- function isLeafGuard(guard) {
422
- return 'variable' in guard;
423
- }
424
- export function guardVariables(guard) {
425
- if (guard === undefined)
426
- return [];
427
- if (isLeafGuard(guard))
428
- return [guard.variable];
429
- if ('all' in guard)
430
- return guard.all.flatMap((child) => guardVariables(child));
431
- if ('any' in guard)
432
- return guard.any.flatMap((child) => guardVariables(child));
433
- if ('not' in guard)
434
- return guardVariables(guard.not);
435
- return [];
436
- }
437
- function evalGuard(guard, vars) {
438
- if (isLeafGuard(guard)) {
439
- const actual = vars[guard.variable];
440
- switch (guard.op) {
441
- case '==': return actual === guard.value;
442
- case '!=': return actual !== guard.value;
443
- case '<': return actual < guard.value;
444
- case '<=': return actual <= guard.value;
445
- case '>': return actual > guard.value;
446
- case '>=': return actual >= guard.value;
447
- }
448
- }
449
- if ('all' in guard)
450
- return guard.all.every((child) => evalGuard(child, vars));
451
- if ('any' in guard)
452
- return guard.any.some((child) => evalGuard(child, vars));
453
- if ('not' in guard)
454
- return !evalGuard(guard.not, vars);
455
- return false;
456
- }
457
- function initialState(model) {
458
- const vars = {};
459
- for (const variable of model.variables ?? [])
460
- vars[variable.name] = variable.init;
461
- return { state: model.init, vars };
462
- }
463
- function runtimeKey(runtime) {
464
- return runtime.state + '|' + stableStringify(runtime.vars);
465
- }
466
- function applyUpdates(model, transition, runtime) {
467
- const vars = { ...runtime.vars };
468
- for (const update of transition.updates ?? []) {
469
- const current = vars[update.variable];
470
- if (update.op === 'set')
471
- vars[update.variable] = update.value ?? 0;
472
- else if (update.op === 'inc')
473
- vars[update.variable] = ((typeof current === 'number' ? current : 0) + (update.value ?? 1));
474
- else
475
- vars[update.variable] = ((typeof current === 'number' ? current : 0) - (update.value ?? 1));
476
- }
477
- return { state: transition.to, vars };
478
- }
479
- function groupTransitions(model) {
480
- const groups = new Map();
481
- for (const transition of model.transitions) {
482
- const key = transition.from + '|' + transition.event;
483
- const list = groups.get(key) ?? [];
484
- list.push(transition);
485
- groups.set(key, list);
486
- }
487
- return groups;
488
- }
489
- function applicableTransitions(group, runtime) {
490
- const guarded = [];
491
- const unguarded = [];
492
- for (const transition of group) {
493
- if (transition.guard === undefined)
494
- unguarded.push(transition);
495
- else
496
- guarded.push(transition);
497
- }
498
- const matched = guarded.filter((transition) => evalGuard(transition.guard, runtime.vars));
499
- if (matched.length > 0)
500
- return matched;
501
- return unguarded;
502
- }
503
- function stepRuntime(model, runtime, event) {
504
- const group = groupTransitions(model).get(runtime.state + '|' + event);
505
- if (group === undefined || group.length === 0)
506
- return [];
507
- const seen = new Set();
508
- const outcomes = [];
509
- for (const transition of applicableTransitions(group, runtime)) {
510
- const next = applyUpdates(model, transition, runtime);
511
- const key = runtimeKey(next);
512
- if (!seen.has(key)) {
513
- seen.add(key);
514
- outcomes.push(next);
515
- }
516
- }
517
- return outcomes;
518
- }
519
- function explore(model, options) {
520
- const init = initialState(model);
521
- const reachable = [];
522
- const reachableKeys = new Set();
523
- const queue = [init];
524
- let truncated = false;
525
- while (queue.length > 0) {
526
- const runtime = queue.shift();
527
- const key = runtimeKey(runtime);
528
- if (reachableKeys.has(key))
529
- continue;
530
- reachableKeys.add(key);
531
- reachable.push(runtime);
532
- if (reachable.length > options.maxStates) {
533
- truncated = true;
534
- break;
535
- }
536
- for (const event of allEvents(model)) {
537
- for (const next of stepRuntime(model, runtime, event)) {
538
- if (!reachableKeys.has(runtimeKey(next)))
539
- queue.push(next);
540
- }
541
- }
542
- }
543
- return { reachable, reachableKeys, truncated, initialState: init };
544
- }
545
- function allEvents(model) {
546
- return [...new Set(model.transitions.map((transition) => transition.event))].sort();
547
- }
548
- function stateById(model, id) {
549
- return model.states.find((state) => state.id === id);
550
- }
551
- function isTerminal(model, state) {
552
- return stateById(model, state)?.terminal === true;
553
- }
554
- function uniqueTargets(transitions) {
555
- return [...new Set(transitions.map((transition) => transition.to))];
556
- }
557
- function checkResult(id, name, findings, detail) {
558
- const errors = findings.filter((finding) => finding.severity === 'error').length;
559
- const warnings = findings.filter((finding) => finding.severity === 'warning').length;
560
- return {
561
- id,
562
- name,
563
- status: findings.length === 0 ? 'pass' : 'fail',
564
- detail: detail + (errors > 0 ? ' (' + errors + ' errors' + (warnings > 0 ? ', ' + warnings + ' warnings' : '') + ')' : warnings > 0 ? ' (' + warnings + ' warnings)' : ''),
565
- findings,
566
- };
567
- }
568
- // ---------------------------------------------------------------------------
569
- // S1-S7
570
- // ---------------------------------------------------------------------------
571
- function S1_reachability(model, exploration) {
572
- const reached = new Set(exploration.reachable.map((runtime) => runtime.state));
573
- const unreachable = model.states.filter((state) => !reached.has(state.id)).map((state) => state.id);
574
- const findings = unreachable.map((state) => ({
575
- code: 'S1_UNREACHABLE_STATE',
576
- severity: 'warning',
577
- message: 'State ' + state + ' is not reachable from init.',
578
- }));
579
- return checkResult('S1', 'Reachability', findings, unreachable.length === 0 ? 'All states reachable' : 'Unreachable states: ' + unreachable.join(', '));
580
- }
581
- function S2_deadlock(model) {
582
- const outgoing = new Set(model.transitions.map((transition) => transition.from));
583
- const findings = [];
584
- for (const state of model.states) {
585
- if (state.terminal === true)
586
- continue;
587
- if (!outgoing.has(state.id)) {
588
- findings.push({
589
- code: 'S2_NO_TRANSITIONS',
590
- severity: 'error',
591
- message: 'Non-terminal state ' + state.id + ' has no outgoing transitions.',
592
- });
593
- }
594
- }
595
- return checkResult('S2', 'Deadlock', findings, findings.length === 0 ? 'No deadlocks' : 'Deadlocks: ' + findings.map((finding) => finding.message).join('; '));
596
- }
597
- function sccs(model) {
598
- const states = model.states.map((state) => state.id);
599
- const edges = new Map();
600
- for (const state of states)
601
- edges.set(state, []);
602
- for (const transition of model.transitions) {
603
- const list = edges.get(transition.from);
604
- if (list !== undefined)
605
- list.push(transition.to);
606
- }
607
- let index = 0;
608
- const indices = new Map();
609
- const low = new Map();
610
- const onStack = new Set();
611
- const stack = [];
612
- const components = [];
613
- const visit = (node) => {
614
- indices.set(node, index);
615
- low.set(node, index);
616
- index += 1;
617
- stack.push(node);
618
- onStack.add(node);
619
- for (const next of edges.get(node) ?? []) {
620
- if (!indices.has(next)) {
621
- visit(next);
622
- low.set(node, Math.min(low.get(node), low.get(next)));
623
- }
624
- else if (onStack.has(next)) {
625
- low.set(node, Math.min(low.get(node), indices.get(next)));
626
- }
627
- }
628
- if (low.get(node) === indices.get(node)) {
629
- const component = [];
630
- while (stack.length > 0) {
631
- const member = stack.pop();
632
- onStack.delete(member);
633
- component.push(member);
634
- if (member === node)
635
- break;
636
- }
637
- components.push(component);
638
- }
639
- };
640
- for (const state of states) {
641
- if (!indices.has(state))
642
- visit(state);
643
- }
644
- return components;
645
- }
646
- function S3_liveness(model) {
647
- const components = sccs(model);
648
- const findings = [];
649
- for (const component of components) {
650
- const componentSet = new Set(component);
651
- const internalEdges = model.transitions.filter((transition) => componentSet.has(transition.from) && componentSet.has(transition.to));
652
- const escapingEdges = model.transitions.filter((transition) => componentSet.has(transition.from) && !componentSet.has(transition.to));
653
- if (internalEdges.length === 0 || escapingEdges.length > 0)
654
- continue;
655
- if (component.some((state) => isTerminal(model, state)))
656
- continue;
657
- findings.push({
658
- code: 'S3_CLOSED_SCC',
659
- severity: 'error',
660
- message: 'Closed SCC has no exit and contains no terminal state: ' + component.sort().join(', '),
661
- evidence: { states: component.sort(), transitions: internalEdges.length },
662
- });
663
- }
664
- return checkResult('S3', 'Liveness', findings, findings.length === 0 ? 'No harmful closed SCCs' : 'Closed SCCs: ' + findings.length);
665
- }
666
- function assignmentsFor(model, variables) {
667
- const specs = (model.variables ?? []).filter((variable) => variables.includes(variable.name));
668
- let combinations = [{}];
669
- for (const spec of specs) {
670
- let values;
671
- if (spec.kind === 'boolean')
672
- values = [false, true];
673
- else {
674
- const constants = collectGuardConstants(model).filter((value) => guardLeaves(model).some((leaf) => leaf.variable === spec.name && typeof leaf.value === 'number' && leaf.value === value));
675
- if (spec.min !== undefined && spec.max !== undefined) {
676
- values = [];
677
- for (let value = spec.min; value <= spec.max && value <= spec.min + 200; value++)
678
- values.push(value);
679
- }
680
- else {
681
- const set = new Set([-1, 0, 1, ...constants]);
682
- values = [...set].sort((a, b) => a - b);
683
- }
684
- }
685
- const next = [];
686
- for (const assignment of combinations) {
687
- for (const value of values) {
688
- next.push({ ...assignment, [spec.name]: value });
689
- if (next.length > 10_000)
690
- return null;
691
- }
692
- }
693
- combinations = next;
694
- }
695
- return combinations;
696
- }
697
- function guardLeaves(model) {
698
- const leaves = [];
699
- const visit = (guard) => {
700
- if ('variable' in guard)
701
- leaves.push(guard);
702
- else if ('all' in guard)
703
- guard.all.forEach(visit);
704
- else if ('any' in guard)
705
- guard.any.forEach(visit);
706
- else if ('not' in guard)
707
- visit(guard.not);
708
- };
709
- for (const transition of model.transitions)
710
- if (transition.guard !== undefined)
711
- visit(transition.guard);
712
- return leaves;
713
- }
714
- function collectGuardConstants(model) {
715
- return guardLeaves(model).filter((leaf) => typeof leaf.value === 'number').map((leaf) => leaf.value);
716
- }
717
- function analyzeGuards(model) {
718
- const determinism = [];
719
- const completeness = [];
720
- let truncated = false;
721
- const groups = groupTransitions(model);
722
- for (const [key, group] of groups) {
723
- const [from, event] = key.split('|');
724
- const unguarded = group.filter((transition) => transition.guard === undefined);
725
- const guarded = group.filter((transition) => transition.guard !== undefined);
726
- if (unguarded.length > 1) {
727
- determinism.push({
728
- code: 'S4_AMBIGUOUS_DEFAULT',
729
- severity: 'error',
730
- message: from + ' + ' + event + ' has multiple unconditional transitions: ' + uniqueTargets(unguarded).join(', '),
731
- });
732
- }
733
- if (guarded.length === 0)
734
- continue;
735
- const variables = [...new Set(group.flatMap((transition) => guardVariables(transition.guard)))];
736
- const assignments = assignmentsFor(model, variables);
737
- if (assignments === null) {
738
- completeness.push({ code: 'S6_UNBOUNDED_DOMAIN', severity: 'warning', message: from + ' + ' + event + ': guard domain too large to enumerate; exhaustive check skipped.' });
739
- truncated = true;
740
- continue;
741
- }
742
- const targets = uniqueTargets(group);
743
- for (const assignment of assignments) {
744
- const trueBranches = guarded.filter((transition) => evalGuard(transition.guard, assignment));
745
- if (trueBranches.length > 1) {
746
- determinism.push({
747
- code: 'S4_NONDETERMINISTIC_GUARDS',
748
- severity: 'error',
749
- message: from + ' + ' + event + ' has ' + trueBranches.length + ' simultaneously true guards for assignment ' + stableStringify(assignment) + ': ' + uniqueTargets(trueBranches).join(', '),
750
- evidence: { assignment },
751
- });
752
- }
753
- if (trueBranches.length === 0 && unguarded.length === 0) {
754
- completeness.push({
755
- code: 'S6_INCOMPLETE_GUARD',
756
- severity: 'error',
757
- message: from + ' + ' + event + ' has no true guard and no default branch for assignment ' + stableStringify(assignment),
758
- evidence: { assignment, branches: targets },
759
- });
760
- }
761
- if (determinism.length + completeness.length > 200) {
762
- truncated = true;
763
- break;
764
- }
765
- }
766
- if (truncated)
767
- break;
768
- }
769
- return { determinism, completeness, truncated };
770
- }
771
- function S4_determinism(model) {
772
- const analysis = analyzeGuards(model);
773
- return checkResult('S4', 'Determinism', analysis.determinism, analysis.determinism.length === 0 ? 'Deterministic' : 'Nondeterminism findings: ' + analysis.determinism.length);
774
- }
775
- function S6_guardCompleteness(model) {
776
- const analysis = analyzeGuards(model);
777
- return checkResult('S6', 'Guard Completeness', analysis.completeness, analysis.completeness.length === 0 ? 'All guard branches defined' : 'Guard findings: ' + analysis.completeness.length);
778
- }
779
- function S5_eventCompleteness(model) {
780
- const events = allEvents(model);
781
- const findings = [];
782
- for (const state of model.states) {
783
- if (state.terminal === true)
784
- continue;
785
- const handled = new Set(model.transitions.filter((transition) => transition.from === state.id).map((transition) => transition.event));
786
- for (const event of events) {
787
- if (!handled.has(event)) {
788
- findings.push({
789
- code: 'S5_UNHANDLED_EVENT',
790
- severity: 'warning',
791
- message: state.id + ' silently ignores event ' + event,
792
- });
793
- }
794
- }
795
- }
796
- return checkResult('S5', 'Event Completeness', findings, findings.length === 0 ? 'All states handle all relevant events' : findings.length + ' unhandled (state, event) pairs');
797
- }
798
- function invariantHolds(invariant, runtime) {
799
- if (invariant.kind === 'never-states')
800
- return !invariant.states.includes(runtime.state);
801
- if (invariant.kind === 'var-in-range') {
802
- const value = runtime.vars[invariant.variable];
803
- if (typeof value !== 'number')
804
- return false;
805
- if (invariant.min !== undefined && value < invariant.min)
806
- return false;
807
- if (invariant.max !== undefined && value > invariant.max)
808
- return false;
809
- return true;
810
- }
811
- return true;
812
- }
813
- function shortestViolationForInvariant(model, options, invariant) {
814
- if (invariant.kind === 'event-before-state') {
815
- return shortestEventBeforeStateViolation(model, options, invariant);
816
- }
817
- const init = initialState(model);
818
- if (!invariantHolds(invariant, init)) {
819
- return { invariant, path: [], reason: 'Initial state violates the invariant.' };
820
- }
821
- const visited = new Set([runtimeKey(init)]);
822
- const queue = [{ runtime: init, path: [] }];
823
- let steps = 0;
824
- while (queue.length > 0) {
825
- const entry = queue.shift();
826
- if (++steps > options.maxStates)
827
- break;
828
- for (const event of allEvents(model)) {
829
- for (const next of stepRuntime(model, entry.runtime, event)) {
830
- const key = runtimeKey(next);
831
- if (visited.has(key))
832
- continue;
833
- const path = [...entry.path, { from: entry.runtime.state, event, to: next.state }];
834
- if (!invariantHolds(invariant, next)) {
835
- return { invariant, path, reason: 'Invariant violated after ' + path.length + ' events.' };
836
- }
837
- visited.add(key);
838
- queue.push({ runtime: next, path });
839
- }
840
- }
841
- }
842
- return undefined;
843
- }
844
- function shortestEventBeforeStateViolation(model, options, invariant) {
845
- const init = initialState(model);
846
- const seenInitially = false;
847
- if (init.state === invariant.state && !seenInitially) {
848
- return { invariant, path: [], reason: 'Target state ' + invariant.state + ' is initial and the required event ' + invariant.event + ' has not occurred.' };
849
- }
850
- const key = (runtime, seen) => runtimeKey(runtime) + '|' + (seen ? '1' : '0');
851
- const visited = new Set([key(init, false)]);
852
- const queue = [{ runtime: init, seen: false, path: [] }];
853
- let steps = 0;
854
- while (queue.length > 0) {
855
- const entry = queue.shift();
856
- if (++steps > options.maxStates)
857
- break;
858
- for (const event of allEvents(model)) {
859
- for (const next of stepRuntime(model, entry.runtime, event)) {
860
- const seen = entry.seen || event === invariant.event;
861
- const nextKey = key(next, seen);
862
- if (visited.has(nextKey))
863
- continue;
864
- const path = [...entry.path, { from: entry.runtime.state, event, to: next.state }];
865
- if (next.state === invariant.state && !seen) {
866
- return { invariant, path, reason: 'Target state ' + invariant.state + ' is reachable without event ' + invariant.event + ' in ' + path.length + ' steps.' };
867
- }
868
- visited.add(nextKey);
869
- queue.push({ runtime: next, seen, path });
870
- }
871
- }
872
- }
873
- return undefined;
874
- }
875
- function runInvariants(model, options) {
876
- const violations = [];
877
- for (const invariant of model.invariants ?? []) {
878
- const violation = shortestViolationForInvariant(model, options, invariant);
879
- if (violation !== undefined)
880
- violations.push(violation);
881
- }
882
- return violations;
883
- }
884
- function S7_invariants(model, options) {
885
- const violations = runInvariants(model, options);
886
- const findings = violations.map((violation) => ({
887
- code: 'S7_INVARIANT_VIOLATION',
888
- severity: 'error',
889
- message: 'Invariant "' + violation.invariant.id + '" (' + violation.invariant.description + ') violated: ' + violation.reason,
890
- path: violation.path,
891
- evidence: { invariant: violation.invariant },
892
- }));
893
- return checkResult('S7', 'Invariant Validity', findings, violations.length === 0 ? 'All invariants hold' : 'Invariant violations: ' + violations.length);
894
- }
895
- // ---------------------------------------------------------------------------
896
- // A1-A7
897
- // ---------------------------------------------------------------------------
898
- function A1_unexpectedEvents(model) {
899
- const events = allEvents(model);
900
- const findings = [];
901
- for (const state of model.states) {
902
- if (state.terminal === true)
903
- continue;
904
- const handled = new Set(model.transitions.filter((transition) => transition.from === state.id).map((transition) => transition.event));
905
- for (const event of events) {
906
- if (!handled.has(event)) {
907
- findings.push({
908
- code: 'A1_UNHANDLED_EVENT',
909
- severity: 'warning',
910
- message: 'Event ' + event + ' in state ' + state.id + ' has no defined transition (silent ignore).',
911
- });
912
- }
913
- }
914
- }
915
- return checkResult('A1', 'Unexpected Event Injection', findings, findings.length === 0 ? 'All event/state combinations defined' : findings.length + ' unhandled combinations');
916
- }
917
- function firstOutcome(runtime, outcomes) {
918
- return outcomes[0];
919
- }
920
- function A2_raceInterleaving(model, exploration) {
921
- const findings = [];
922
- for (const [e1, e2] of model.concurrentPairs ?? []) {
923
- for (const runtime of exploration.reachable) {
924
- const first = firstOutcome(runtime, stepRuntime(model, runtime, e1));
925
- const second = firstOutcome(runtime, stepRuntime(model, runtime, e2));
926
- if (first === undefined && second === undefined)
927
- continue;
928
- const final12 = first === undefined ? runtime : firstOutcome(first, stepRuntime(model, first, e2)) ?? first;
929
- const final21 = second === undefined ? runtime : firstOutcome(second, stepRuntime(model, second, e1)) ?? second;
930
- if (runtimeKey(final12) !== runtimeKey(final21)) {
931
- findings.push({
932
- code: 'A2_ORDER_DEPENDENT',
933
- severity: 'warning',
934
- message: 'Events ' + e1 + ' and ' + e2 + ' produce order-dependent outcomes from state ' + runtime.state,
935
- evidence: { e1ThenE2: final12, e2ThenE1: final21 },
936
- });
937
- }
938
- }
939
- }
940
- return checkResult('A2', 'Race Interleaving', findings, findings.length === 0 ? 'No race conditions detected' : 'Order-dependent outcomes: ' + findings.length);
941
- }
942
- function stepSequence(model, init, events) {
943
- let runtime = init;
944
- for (const event of events) {
945
- const next = firstOutcome(runtime, stepRuntime(model, runtime, event));
946
- if (next === undefined)
947
- continue;
948
- runtime = next;
949
- }
950
- return runtime;
951
- }
952
- function permutations(items) {
953
- if (items.length === 0)
954
- return [[]];
955
- const result = [];
956
- for (let index = 0; index < items.length; index++) {
957
- const rest = items.slice(0, index).concat(items.slice(index + 1));
958
- for (const suffix of permutations(rest))
959
- result.push([items[index], ...suffix]);
960
- }
961
- return result;
962
- }
963
- function A3_orderPermutation(model, options) {
964
- const events = allEvents(model).slice(0, options.maxPermutationEvents);
965
- if (events.length < 2)
966
- return checkResult('A3', 'Order Permutation', [], 'Fewer than 2 events — skipped');
967
- const init = initialState(model);
968
- const outcomes = new Map();
969
- for (const permutation of permutations(events)) {
970
- const final = stepSequence(model, init, permutation);
971
- const key = runtimeKey(final);
972
- const list = outcomes.get(key) ?? [];
973
- if (list.length < 3)
974
- list.push(permutation.join(','));
975
- outcomes.set(key, list);
976
- }
977
- if (outcomes.size > 1) {
978
- const examples = [...outcomes.entries()].map(([key, sequences]) => ({ final: key.split('|')[0], example: sequences[0] }));
979
- return checkResult('A3', 'Order Permutation', [{
980
- code: 'A3_ORDER_DEPENDENT',
981
- severity: 'warning',
982
- message: 'Same event set produces ' + outcomes.size + ' different outcomes depending on order.',
983
- evidence: { examples },
984
- }], 'Order-dependent outcomes: ' + outcomes.size);
985
- }
986
- return checkResult('A3', 'Order Permutation', [], 'Order-independent (sampled first ' + events.length + ' events)');
987
- }
988
- function A4_pairSymmetry(model) {
989
- const findings = [];
990
- const transitionEvents = new Set(model.transitions.map((transition) => transition.event));
991
- for (const pair of model.resourcePairs ?? []) {
992
- const acquireTransitions = model.transitions.filter((transition) => transition.event === pair.acquireEvent);
993
- const releaseExists = transitionEvents.has(pair.releaseEvent);
994
- if (acquireTransitions.length === 0 && !releaseExists)
995
- continue;
996
- if (acquireTransitions.length > 0 && !releaseExists) {
997
- findings.push({
998
- code: 'A4_NO_RELEASE_EVENT',
999
- severity: 'error',
1000
- message: 'Resource "' + pair.resource + '": acquire event ' + pair.acquireEvent + ' exists but release event ' + pair.releaseEvent + ' is never defined.',
1001
- });
1002
- continue;
1003
- }
1004
- for (const acquire of acquireTransitions) {
1005
- // Is any release event reachable in the state graph from the acquired state?
1006
- const reachable = stateGraphReachable(model, acquire.to);
1007
- const releaseReachable = [...reachable].some((state) => model.transitions.some((transition) => transition.from === state && transition.event === pair.releaseEvent));
1008
- if (!releaseReachable) {
1009
- findings.push({
1010
- code: 'A4_NO_RELEASE_REACHABLE',
1011
- severity: 'error',
1012
- message: 'Resource "' + pair.resource + '": after ' + pair.acquireEvent + ' into ' + acquire.to + ', no ' + pair.releaseEvent + ' transition is reachable.',
1013
- evidence: { acquireTransition: acquire },
1014
- });
1015
- continue;
1016
- }
1017
- // Path-sensitive probe: track the held flag along state-graph paths.
1018
- const edges = stateGraphEdges(model);
1019
- const visited = new Set();
1020
- const queue = [{ state: acquire.to, held: true, path: [] }];
1021
- let steps = 0;
1022
- while (queue.length > 0) {
1023
- const entry = queue.shift();
1024
- if (++steps > 1000)
1025
- break;
1026
- const key = entry.state + '|' + (entry.held ? '1' : '0');
1027
- if (visited.has(key))
1028
- continue;
1029
- visited.add(key);
1030
- if (entry.held && isTerminal(model, entry.state)) {
1031
- findings.push({
1032
- code: 'A4_TERMINAL_WITH_RESOURCE',
1033
- severity: 'error',
1034
- message: 'Resource "' + pair.resource + '" is still held when entering terminal state ' + entry.state + '.',
1035
- path: entry.path,
1036
- });
1037
- continue;
1038
- }
1039
- for (const transition of edges.get(entry.state) ?? []) {
1040
- let held = entry.held;
1041
- if (transition.event === pair.releaseEvent)
1042
- held = false;
1043
- else if (transition.event === pair.acquireEvent) {
1044
- if (held) {
1045
- findings.push({
1046
- code: 'A4_REACQUIRE_WITHOUT_RELEASE',
1047
- severity: 'warning',
1048
- message: 'Resource "' + pair.resource + '" is acquired again in state ' + entry.state + ' before ' + pair.releaseEvent + '.',
1049
- path: [...entry.path, { from: entry.state, event: transition.event, to: transition.to }],
1050
- });
1051
- continue;
1052
- }
1053
- held = true;
1054
- }
1055
- queue.push({ state: transition.to, held, path: [...entry.path, { from: entry.state, event: transition.event, to: transition.to }] });
1056
- }
1057
- }
1058
- }
1059
- }
1060
- return checkResult('A4', 'Pair Symmetry', findings, findings.length === 0 ? 'All pairs balanced' : 'Asymmetric pairs: ' + findings.length);
1061
- }
1062
- function stateGraphEdges(model) {
1063
- const edges = new Map();
1064
- for (const state of model.states)
1065
- edges.set(state.id, []);
1066
- for (const transition of model.transitions)
1067
- edges.get(transition.from)?.push(transition);
1068
- return edges;
1069
- }
1070
- function stateGraphReachable(model, start) {
1071
- const edges = new Map();
1072
- for (const state of model.states)
1073
- edges.set(state.id, []);
1074
- for (const transition of model.transitions)
1075
- edges.get(transition.from)?.push(transition.to);
1076
- const visited = new Set();
1077
- const queue = [start];
1078
- while (queue.length > 0) {
1079
- const current = queue.shift();
1080
- if (visited.has(current))
1081
- continue;
1082
- visited.add(current);
1083
- for (const next of edges.get(current) ?? [])
1084
- queue.push(next);
1085
- }
1086
- return visited;
1087
- }
1088
- function A5_boundaryBlast(model) {
1089
- const findings = [];
1090
- for (const check of model.boundaryChecks ?? []) {
1091
- const variable = (model.variables ?? []).find((entry) => entry.name === check.variable);
1092
- if (variable === undefined)
1093
- continue;
1094
- for (const value of check.values) {
1095
- if (variable.min !== undefined && value < variable.min) {
1096
- findings.push({ code: 'A5_OUT_OF_DOMAIN', severity: 'warning', message: 'Boundary value ' + value + ' for ' + check.variable + ' is below min ' + variable.min });
1097
- }
1098
- if (variable.max !== undefined && value > variable.max) {
1099
- findings.push({ code: 'A5_OUT_OF_DOMAIN', severity: 'warning', message: 'Boundary value ' + value + ' for ' + check.variable + ' is above max ' + variable.max });
1100
- }
1101
- const assignment = { ...(model.variables ?? []).reduce((result, entry) => { result[entry.name] = entry.init; return result; }, {}), [check.variable]: value };
1102
- for (const [key, group] of groupTransitions(model)) {
1103
- const [from, event] = key.split('|');
1104
- if (!group.some((transition) => guardVariables(transition.guard).includes(check.variable)))
1105
- continue;
1106
- const guarded = group.filter((transition) => transition.guard !== undefined);
1107
- const unguarded = group.filter((transition) => transition.guard === undefined);
1108
- const matched = guarded.filter((transition) => evalGuard(transition.guard, assignment));
1109
- if (matched.length === 0 && unguarded.length === 0) {
1110
- findings.push({
1111
- code: 'A5_GUARD_HOLE',
1112
- severity: 'warning',
1113
- message: from + ' + ' + event + ' has no branch for ' + check.variable + '=' + value,
1114
- evidence: { assignment },
1115
- });
1116
- }
1117
- if (matched.length > 1) {
1118
- findings.push({
1119
- code: 'A5_GUARD_OVERLAP',
1120
- severity: 'warning',
1121
- message: from + ' + ' + event + ' has ' + matched.length + ' true branches for ' + check.variable + '=' + value,
1122
- evidence: { assignment },
1123
- });
1124
- }
1125
- }
1126
- }
1127
- }
1128
- return checkResult('A5', 'Boundary Blast', findings, findings.length === 0 ? 'Boundary checks passed' : 'Boundary findings: ' + findings.length);
1129
- }
1130
- function A6_resourceInjection(model) {
1131
- const findings = [];
1132
- for (const pair of model.resourcePairs ?? []) {
1133
- if (pair.failEvent === undefined)
1134
- continue;
1135
- const acquireStates = new Set(model.transitions.filter((transition) => transition.event === pair.acquireEvent).map((transition) => transition.from));
1136
- for (const state of acquireStates) {
1137
- const handled = model.transitions.some((transition) => transition.from === state && transition.event === pair.failEvent);
1138
- if (!handled) {
1139
- findings.push({
1140
- code: 'A6_NO_FAILURE_HANDLER',
1141
- severity: 'warning',
1142
- message: 'State ' + state + ' can ' + pair.acquireEvent + ' for "' + pair.resource + '" but has no ' + pair.failEvent + ' transition.',
1143
- });
1144
- }
1145
- }
1146
- }
1147
- return checkResult('A6', 'Resource Injection', findings, findings.length === 0 ? 'No resource vulnerabilities detected' : 'Resource failure paths missing: ' + findings.length);
1148
- }
1149
- function A7_shortestViolations(model, options) {
1150
- const violations = runInvariants(model, options);
1151
- const findings = violations.map((violation) => ({
1152
- code: 'A7_SHORTEST_COUNTEREXAMPLE',
1153
- severity: 'warning',
1154
- message: 'Invariant "' + violation.invariant.id + '" shortest violating path length: ' + violation.path.length + (violation.path.length === 0 ? ' (initial state)' : ''),
1155
- path: violation.path,
1156
- evidence: { invariant: violation.invariant.id },
1157
- }));
1158
- return checkResult('A7', 'Minimal Counter-Example', findings, violations.length === 0 ? 'All invariants hold for all reachable paths' : 'Violated invariants: ' + findings.length);
1159
- }
1160
- function mapStateId(mapping, state) {
1161
- return mapping[state] ?? state;
1162
- }
1163
- function D1_behavioralPreservation(before, after, mapping) {
1164
- const findings = [];
1165
- const afterStates = new Set(after.states.map((state) => state.id));
1166
- const afterByFromEvent = new Map();
1167
- for (const transition of after.transitions) {
1168
- const key = transition.from + '|' + transition.event;
1169
- const set = afterByFromEvent.get(key) ?? new Set();
1170
- set.add(transition.to);
1171
- afterByFromEvent.set(key, set);
1172
- }
1173
- const beforeInitMapped = mapStateId(mapping, before.init);
1174
- if (beforeInitMapped !== after.init) {
1175
- findings.push({
1176
- code: 'D1_INIT_MISMATCH',
1177
- severity: 'warning',
1178
- message: 'Mapped BEFORE init ' + beforeInitMapped + ' does not match AFTER init ' + after.init + '.',
1179
- evidence: { beforeInit: before.init, mappedInit: beforeInitMapped, afterInit: after.init },
1180
- });
1181
- }
1182
- for (const state of before.states) {
1183
- const mapped = mapStateId(mapping, state.id);
1184
- if (!afterStates.has(mapped)) {
1185
- findings.push({
1186
- code: 'D1_MAPPED_STATE_MISSING',
1187
- severity: 'error',
1188
- message: 'BEFORE state ' + state.id + ' maps to ' + mapped + ', which is not declared in AFTER.',
1189
- evidence: { beforeState: state.id, mappedState: mapped },
1190
- });
1191
- continue;
1192
- }
1193
- for (const transition of before.transitions.filter((entry) => entry.from === state.id)) {
1194
- const key = mapped + '|' + transition.event;
1195
- const targets = afterByFromEvent.get(key);
1196
- if (targets === undefined || targets.size === 0) {
1197
- findings.push({
1198
- code: 'D1_EVENT_DISABLED',
1199
- severity: 'error',
1200
- message: 'BEFORE can fire event ' + transition.event + ' from ' + state.id + ' (mapped to ' + mapped + '), but AFTER has no transition for that (state, event).',
1201
- path: [{ from: state.id, event: transition.event, to: transition.to }],
1202
- evidence: { beforeState: state.id, mappedState: mapped, event: transition.event },
1203
- });
1204
- }
1205
- }
1206
- }
1207
- return checkResult('D1', 'Behavioral Preservation', findings, findings.length === 0 ? 'BEFORE event behavior is preserved in AFTER' : 'Behavioral preservation findings: ' + findings.length);
1208
- }
1209
- function mapInvariantForComparison(invariant, mapping) {
1210
- if (invariant.kind === 'never-states') {
1211
- return {
1212
- ...invariant,
1213
- id: invariant.id + ':before',
1214
- description: invariant.description + ' (from BEFORE)',
1215
- states: invariant.states.map((state) => mapStateId(mapping, state)),
1216
- };
1217
- }
1218
- if (invariant.kind === 'event-before-state') {
1219
- return {
1220
- ...invariant,
1221
- id: invariant.id + ':before',
1222
- description: invariant.description + ' (from BEFORE)',
1223
- state: mapStateId(mapping, invariant.state),
1224
- };
1225
- }
1226
- return { ...invariant, id: invariant.id + ':before', description: invariant.description + ' (from BEFORE)' };
1227
- }
1228
- function D2_invariantContinuity(before, after, options, mapping) {
1229
- const findings = [];
1230
- const afterStates = new Set(after.states.map((state) => state.id));
1231
- const afterVariables = new Set((after.variables ?? []).map((variable) => variable.name));
1232
- for (const invariant of before.invariants ?? []) {
1233
- const mapped = mapInvariantForComparison(invariant, mapping);
1234
- if (mapped.kind === 'never-states') {
1235
- for (const state of mapped.states) {
1236
- if (!afterStates.has(state)) {
1237
- findings.push({
1238
- code: 'D2_MAPPED_STATE_MISSING',
1239
- severity: 'warning',
1240
- message: 'BEFORE invariant "' + invariant.id + '" maps to state ' + state + ', which is not declared in AFTER.',
1241
- evidence: { invariant: invariant.id, state },
1242
- });
1243
- }
1244
- }
1245
- }
1246
- else if (mapped.kind === 'event-before-state') {
1247
- if (!afterStates.has(mapped.state)) {
1248
- findings.push({
1249
- code: 'D2_MAPPED_STATE_MISSING',
1250
- severity: 'warning',
1251
- message: 'BEFORE invariant "' + invariant.id + '" maps to state ' + mapped.state + ', which is not declared in AFTER.',
1252
- evidence: { invariant: invariant.id, state: mapped.state },
1253
- });
1254
- }
1255
- }
1256
- else if (mapped.kind === 'var-in-range') {
1257
- if (!afterVariables.has(mapped.variable)) {
1258
- findings.push({
1259
- code: 'D2_VARIABLE_MISSING',
1260
- severity: 'warning',
1261
- message: 'BEFORE invariant "' + invariant.id + '" references variable ' + mapped.variable + ', which is not declared in AFTER.',
1262
- evidence: { invariant: invariant.id, variable: mapped.variable },
1263
- });
1264
- continue;
1265
- }
1266
- }
1267
- const violation = shortestViolationForInvariant(after, options, mapped);
1268
- if (violation !== undefined) {
1269
- findings.push({
1270
- code: 'D2_INVARIANT_REGRESSION',
1271
- severity: 'error',
1272
- message: 'BEFORE invariant "' + invariant.id + '" no longer holds in AFTER: ' + violation.reason,
1273
- path: violation.path,
1274
- evidence: { beforeInvariant: invariant, afterInvariant: mapped },
1275
- });
1276
- }
1277
- }
1278
- return checkResult('D2', 'Invariant Continuity', findings, findings.length === 0 ? 'All BEFORE invariants continue to hold' : 'Invariant continuity findings: ' + findings.length);
1279
- }
1280
- function D3_regressionDelta(before, after, mapping) {
1281
- const afterStateIds = new Set(after.states.map((state) => state.id));
1282
- const beforeMappedIds = new Set(before.states.map((state) => mapStateId(mapping, state.id)));
1283
- const addedStates = after.states.filter((state) => !beforeMappedIds.has(state.id)).map((state) => state.id).sort();
1284
- const removedStates = before.states.filter((state) => !afterStateIds.has(mapStateId(mapping, state.id))).map((state) => state.id).sort();
1285
- const beforeEvents = new Set(before.transitions.map((transition) => transition.event));
1286
- const afterEvents = new Set(after.transitions.map((transition) => transition.event));
1287
- const addedEvents = [...afterEvents].filter((event) => !beforeEvents.has(event)).sort();
1288
- const removedEvents = [...beforeEvents].filter((event) => !afterEvents.has(event)).sort();
1289
- const removedTransitions = before.transitions.filter((transition) => !after.transitions.some((candidate) => candidate.from === mapStateId(mapping, transition.from) &&
1290
- candidate.event === transition.event &&
1291
- candidate.to === mapStateId(mapping, transition.to)));
1292
- const addedTransitions = after.transitions.filter((transition) => !before.transitions.some((candidate) => mapStateId(mapping, candidate.from) === transition.from &&
1293
- candidate.event === transition.event &&
1294
- mapStateId(mapping, candidate.to) === transition.to));
1295
- const findings = [];
1296
- for (const state of removedStates) {
1297
- findings.push({ code: 'D3_REMOVED_STATE', severity: 'warning', message: 'BEFORE state ' + state + ' is not present in AFTER under the given mapping.', evidence: { state } });
1298
- }
1299
- for (const event of removedEvents) {
1300
- findings.push({ code: 'D3_REMOVED_EVENT', severity: 'warning', message: 'BEFORE event ' + event + ' is not present in AFTER.', evidence: { event } });
1301
- }
1302
- for (const transition of removedTransitions) {
1303
- findings.push({
1304
- code: 'D3_REMOVED_TRANSITION',
1305
- severity: 'warning',
1306
- message: 'BEFORE transition ' + transition.from + ' -' + transition.event + '-> ' + transition.to + ' has no exact AFTER counterpart.',
1307
- evidence: { transition },
1308
- });
1309
- }
1310
- const detail = 'Delta: +' + addedStates.length + ' states, -' + removedStates.length + ' states, +' + addedEvents.length + ' events, -' + removedEvents.length + ' events, +' + addedTransitions.length + ' transitions, -' + removedTransitions.length + ' transitions';
1311
- return checkResult('D3', 'Regression Delta', findings, detail);
1312
- }
1313
- function deadlockStateIds(model) {
1314
- const outgoing = new Set(model.transitions.map((transition) => transition.from));
1315
- return model.states.filter((state) => state.terminal !== true && !outgoing.has(state.id)).map((state) => state.id);
1316
- }
1317
- function closedSccStateSets(model) {
1318
- return sccs(model)
1319
- .filter((component) => {
1320
- const componentSet = new Set(component);
1321
- const internalEdges = model.transitions.some((transition) => componentSet.has(transition.from) && componentSet.has(transition.to));
1322
- const escapingEdges = model.transitions.some((transition) => componentSet.has(transition.from) && !componentSet.has(transition.to));
1323
- return internalEdges && !escapingEdges && !component.some((state) => isTerminal(model, state));
1324
- })
1325
- .map((component) => component.sort());
1326
- }
1327
- function D4_deadlockLivenessRegression(before, after, mapping) {
1328
- const findings = [];
1329
- const beforeDeadlock = new Set(deadlockStateIds(before).map((state) => mapStateId(mapping, state)));
1330
- const afterDeadlock = deadlockStateIds(after);
1331
- for (const state of afterDeadlock) {
1332
- if (!beforeDeadlock.has(state)) {
1333
- findings.push({
1334
- code: 'D4_DEADLOCK_REGRESSION',
1335
- severity: 'error',
1336
- message: 'AFTER introduces deadlock in state ' + state + ' that was not deadlocked in BEFORE.',
1337
- evidence: { state },
1338
- });
1339
- }
1340
- }
1341
- const beforeScc = new Set(closedSccStateSets(before).map((component) => component.map((state) => mapStateId(mapping, state)).sort().join(',')));
1342
- const afterScc = closedSccStateSets(after);
1343
- for (const component of afterScc) {
1344
- const key = component.join(',');
1345
- if (!beforeScc.has(key)) {
1346
- findings.push({
1347
- code: 'D4_LIVENESS_REGRESSION',
1348
- severity: 'error',
1349
- message: 'AFTER introduces a closed SCC with no exit and no terminal state: ' + key,
1350
- evidence: { states: component },
1351
- });
1352
- }
1353
- }
1354
- return checkResult('D4', 'Deadlock/Liveness Regression', findings, findings.length === 0 ? 'No new deadlock or liveness regressions' : 'Regression findings: ' + findings.length);
1355
- }
1356
- function buildComparisonSummary(before, after, mapping) {
1357
- const afterStateIds = new Set(after.states.map((state) => state.id));
1358
- const beforeMappedIds = new Set(before.states.map((state) => mapStateId(mapping, state.id)));
1359
- const addedStates = after.states.filter((state) => !beforeMappedIds.has(state.id)).map((state) => state.id).sort();
1360
- const removedStates = before.states.filter((state) => !afterStateIds.has(mapStateId(mapping, state.id))).map((state) => state.id).sort();
1361
- const beforeEvents = new Set(before.transitions.map((transition) => transition.event));
1362
- const afterEvents = new Set(after.transitions.map((transition) => transition.event));
1363
- const addedEvents = [...afterEvents].filter((event) => !beforeEvents.has(event)).sort();
1364
- const removedEvents = [...beforeEvents].filter((event) => !afterEvents.has(event)).sort();
1365
- const removedTransitions = before.transitions.filter((transition) => !after.transitions.some((candidate) => candidate.from === mapStateId(mapping, transition.from) &&
1366
- candidate.event === transition.event &&
1367
- candidate.to === mapStateId(mapping, transition.to)));
1368
- const addedTransitions = after.transitions.filter((transition) => !before.transitions.some((candidate) => mapStateId(mapping, candidate.from) === transition.from &&
1369
- candidate.event === transition.event &&
1370
- mapStateId(mapping, candidate.to) === transition.to));
1371
- return {
1372
- beforeModelHash: modelHash(before),
1373
- afterModelHash: modelHash(after),
1374
- stateMapping: mapping,
1375
- beforeStates: before.states.length,
1376
- beforeTransitions: before.transitions.length,
1377
- afterStates: after.states.length,
1378
- afterTransitions: after.transitions.length,
1379
- addedStates,
1380
- removedStates,
1381
- addedEvents,
1382
- removedEvents,
1383
- addedTransitions,
1384
- removedTransitions,
1385
- };
1386
- }
1387
- function A8_idempotentReplay(model, exploration) {
1388
- const events = model.idempotentEvents ?? [];
1389
- const findings = [];
1390
- for (const event of events) {
1391
- for (const runtime of exploration.reachable) {
1392
- const onceOptions = stepRuntime(model, runtime, event);
1393
- if (onceOptions.length === 0)
1394
- continue;
1395
- for (const once of onceOptions) {
1396
- const twiceOptions = stepRuntime(model, once, event);
1397
- if (twiceOptions.length === 0) {
1398
- findings.push({
1399
- code: 'A8_NOT_REPLAYABLE',
1400
- severity: 'warning',
1401
- message: 'Idempotent event ' + event + ' is not replayable after first application from ' + runtime.state + '.',
1402
- path: [{ from: runtime.state, event, to: once.state }],
1403
- evidence: { state: runtime.state, event },
1404
- });
1405
- continue;
1406
- }
1407
- for (const twice of twiceOptions) {
1408
- if (runtimeKey(twice) !== runtimeKey(once)) {
1409
- findings.push({
1410
- code: 'A8_NOT_IDEMPOTENT',
1411
- severity: 'error',
1412
- message: 'Idempotent event ' + event + ' changes state when applied twice from ' + runtime.state + '.',
1413
- path: [{ from: runtime.state, event, to: once.state }, { from: once.state, event, to: twice.state }],
1414
- evidence: { state: runtime.state, event, afterOnce: once, afterTwice: twice },
1415
- });
1416
- break;
1417
- }
1418
- }
1419
- }
1420
- }
1421
- }
1422
- return checkResult('A8', 'Idempotent Replay', findings, findings.length === 0 ? 'Idempotent events are replay-safe' : 'Idempotent replay findings: ' + findings.length);
1423
- }
1424
- function S8_monotonicVariables(model) {
1425
- const findings = [];
1426
- for (const variable of model.variables ?? []) {
1427
- if (variable.monotonic === undefined)
1428
- continue;
1429
- for (const transition of model.transitions) {
1430
- for (const update of transition.updates ?? []) {
1431
- if (update.variable !== variable.name)
1432
- continue;
1433
- if (variable.monotonic === 'inc' && update.op === 'dec') {
1434
- findings.push({ code: 'S8_MONOTONIC_DECREASE', severity: 'error', message: 'Monotonic (inc) variable ' + variable.name + ' is decreased by ' + transition.event + '.', evidence: { variable: variable.name, transition } });
1435
- }
1436
- else if (variable.monotonic === 'dec' && update.op === 'inc') {
1437
- findings.push({ code: 'S8_MONOTONIC_INCREASE', severity: 'error', message: 'Monotonic (dec) variable ' + variable.name + ' is increased by ' + transition.event + '.', evidence: { variable: variable.name, transition } });
1438
- }
1439
- else if (update.op === 'set') {
1440
- findings.push({ code: 'S8_MONOTONIC_SET_REVIEW', severity: 'warning', message: 'Monotonic variable ' + variable.name + ' uses set in ' + transition.event + '; verify it cannot move backwards.', evidence: { variable: variable.name, transition } });
1441
- }
1442
- }
1443
- }
1444
- }
1445
- return checkResult('S8', 'Monotonic Variables', findings, findings.length === 0 ? 'Monotonic variables are respected' : 'Monotonic findings: ' + findings.length);
1446
- }
1447
- function findLeadsToBadPath(model, start, target) {
1448
- if (start.state === target)
1449
- return undefined;
1450
- const visited = new Set();
1451
- const queue = [{ runtime: start, path: [] }];
1452
- while (queue.length > 0) {
1453
- const entry = queue.shift();
1454
- const key = runtimeKey(entry.runtime);
1455
- if (entry.runtime.state === target)
1456
- continue;
1457
- if (visited.has(key))
1458
- return { path: entry.path, reason: 'Cycle avoids target ' + target };
1459
- visited.add(key);
1460
- const nexts = [];
1461
- for (const event of allEvents(model)) {
1462
- for (const next of stepRuntime(model, entry.runtime, event))
1463
- nexts.push({ next, event });
1464
- }
1465
- if (nexts.length === 0)
1466
- return { path: entry.path, reason: 'Dead end before target ' + target };
1467
- for (const { next, event } of nexts) {
1468
- queue.push({ runtime: next, path: [...entry.path, { from: entry.runtime.state, event, to: next.state }] });
1469
- }
1470
- }
1471
- return { path: [], reason: 'No path reaches target ' + target };
1472
- }
1473
- function A9_leadsTo(model, exploration) {
1474
- const findings = [];
1475
- for (const invariant of model.invariants ?? []) {
1476
- if (invariant.kind !== 'leads-to')
1477
- continue;
1478
- for (const runtime of exploration.reachable) {
1479
- if (runtime.state !== invariant.from)
1480
- continue;
1481
- const bad = findLeadsToBadPath(model, runtime, invariant.to);
1482
- if (bad !== undefined) {
1483
- findings.push({
1484
- code: 'A9_LEADS_TO_VIOLATION',
1485
- severity: 'error',
1486
- message: 'Leads-to invariant "' + invariant.id + '" violated from ' + invariant.from + ': ' + bad.reason,
1487
- path: bad.path,
1488
- evidence: { invariant },
1489
- });
1490
- break;
1491
- }
1492
- }
1493
- }
1494
- return checkResult('A9', 'Leads-To', findings, findings.length === 0 ? 'All leads-to invariants hold' : 'Leads-to findings: ' + findings.length);
1495
- }
1496
- function findSequenceViolation(model, options, invariant) {
1497
- const events = invariant.events;
1498
- const init = initialState(model);
1499
- const key = (runtime, progress) => runtimeKey(runtime) + '|' + progress;
1500
- const visited = new Set([key(init, 0)]);
1501
- const queue = [{ runtime: init, progress: 0, path: [] }];
1502
- let steps = 0;
1503
- while (queue.length > 0) {
1504
- const entry = queue.shift();
1505
- if (++steps > options.maxStates)
1506
- break;
1507
- for (const event of allEvents(model)) {
1508
- for (const next of stepRuntime(model, entry.runtime, event)) {
1509
- let progress = entry.progress;
1510
- let violation = false;
1511
- if (progress < events.length && event === events[progress]) {
1512
- progress += 1;
1513
- }
1514
- else {
1515
- const index = events.indexOf(event);
1516
- if (index > progress)
1517
- violation = true;
1518
- }
1519
- const path = [...entry.path, { from: entry.runtime.state, event, to: next.state }];
1520
- if (violation) {
1521
- return { invariant, path, reason: 'Event ' + event + ' occurred before ' + events[progress] };
1522
- }
1523
- const nextKey = key(next, progress);
1524
- if (!visited.has(nextKey)) {
1525
- visited.add(nextKey);
1526
- queue.push({ runtime: next, progress, path });
1527
- }
1528
- }
1529
- }
1530
- }
1531
- return undefined;
1532
- }
1533
- function A10_sequenceOrder(model, options) {
1534
- const findings = [];
1535
- for (const invariant of model.invariants ?? []) {
1536
- if (invariant.kind !== 'sequence')
1537
- continue;
1538
- const violation = findSequenceViolation(model, options, invariant);
1539
- if (violation !== undefined) {
1540
- findings.push({
1541
- code: 'A10_SEQUENCE_VIOLATION',
1542
- severity: 'error',
1543
- message: 'Sequence invariant "' + invariant.id + '" violated: ' + violation.reason,
1544
- path: violation.path,
1545
- evidence: { invariant },
1546
- });
1547
- }
1548
- }
1549
- return checkResult('A10', 'Sequence Order', findings, findings.length === 0 ? 'All sequence invariants hold' : 'Sequence findings: ' + findings.length);
1550
- }
1551
- function findAtomicityViolation(model, options, invariant) {
1552
- const atomic = new Set(invariant.events);
1553
- const init = initialState(model);
1554
- const key = (runtime, started, closed) => runtimeKey(runtime) + '|' + (started ? '1' : '0') + '|' + (closed ? '1' : '0');
1555
- const visited = new Set([key(init, false, false)]);
1556
- const queue = [{ runtime: init, started: false, closed: false, path: [] }];
1557
- let steps = 0;
1558
- while (queue.length > 0) {
1559
- const entry = queue.shift();
1560
- if (++steps > options.maxStates)
1561
- break;
1562
- for (const event of allEvents(model)) {
1563
- for (const next of stepRuntime(model, entry.runtime, event)) {
1564
- const started = entry.started || atomic.has(event);
1565
- const closed = entry.closed || event === invariant.commit || (invariant.rollback !== undefined && event === invariant.rollback);
1566
- const path = [...entry.path, { from: entry.runtime.state, event, to: next.state }];
1567
- if (entry.started && !entry.closed && !atomic.has(event) && event !== invariant.commit && event !== invariant.rollback) {
1568
- return { invariant, path, reason: 'Left atomic scope via ' + event + ' without commit/rollback' };
1569
- }
1570
- if (started && !closed && isTerminal(model, next.state)) {
1571
- return { invariant, path, reason: 'Terminal state reached with incomplete atomic group' };
1572
- }
1573
- const nextKey = key(next, started, closed);
1574
- if (!visited.has(nextKey)) {
1575
- visited.add(nextKey);
1576
- queue.push({ runtime: next, started, closed, path });
1577
- }
1578
- }
1579
- }
1580
- }
1581
- return undefined;
1582
- }
1583
- function A11_atomicity(model, options) {
1584
- const findings = [];
1585
- for (const invariant of model.invariants ?? []) {
1586
- if (invariant.kind !== 'atomicity')
1587
- continue;
1588
- const violation = findAtomicityViolation(model, options, invariant);
1589
- if (violation !== undefined) {
1590
- findings.push({
1591
- code: 'A11_ATOMICITY_VIOLATION',
1592
- severity: 'error',
1593
- message: 'Atomicity invariant "' + invariant.id + '" violated: ' + violation.reason,
1594
- path: violation.path,
1595
- evidence: { invariant },
1596
- });
1597
- }
1598
- }
1599
- return checkResult('A11', 'Atomicity', findings, findings.length === 0 ? 'All atomicity invariants hold' : 'Atomicity findings: ' + findings.length);
1600
- }
1601
- // ---------------------------------------------------------------------------
1602
- // main entry
1603
- // ---------------------------------------------------------------------------
1604
- export function runVerification(input, options = {}) {
1605
- const normalized = {
1606
- maxStates: options.maxStates ?? DEFAULT_MAX_STATES,
1607
- maxPermutationEvents: options.maxPermutationEvents ?? DEFAULT_MAX_PERMUTATION_EVENTS,
1608
- };
1609
- const validation = validateModel(input);
1610
- if (!validation.ok) {
1611
- return {
1612
- ok: false,
1613
- schemaVersion: 1,
1614
- modelHash: '',
1615
- summary: { states: 0, transitions: 0, errors: validation.errors.length, warnings: 0, checksRun: 0 },
1616
- checks: [{
1617
- id: 'MODEL',
1618
- name: 'Model Validation',
1619
- status: 'fail',
1620
- detail: 'Model schema validation failed: ' + validation.errors.length + ' errors',
1621
- findings: validation.errors.map((message) => ({ code: 'MODEL_INVALID', severity: 'error', message })),
1622
- }],
1623
- };
1624
- }
1625
- const model = validation.model;
1626
- const exploration = explore(model, normalized);
1627
- const checks = [
1628
- S1_reachability(model, exploration),
1629
- S2_deadlock(model),
1630
- S3_liveness(model),
1631
- S4_determinism(model),
1632
- S5_eventCompleteness(model),
1633
- S6_guardCompleteness(model),
1634
- S7_invariants(model, normalized),
1635
- S8_monotonicVariables(model),
1636
- A1_unexpectedEvents(model),
1637
- A2_raceInterleaving(model, exploration),
1638
- A3_orderPermutation(model, normalized),
1639
- A4_pairSymmetry(model),
1640
- A5_boundaryBlast(model),
1641
- A6_resourceInjection(model),
1642
- A7_shortestViolations(model, normalized),
1643
- A8_idempotentReplay(model, exploration),
1644
- A9_leadsTo(model, exploration),
1645
- A10_sequenceOrder(model, normalized),
1646
- A11_atomicity(model, normalized),
1647
- ];
1648
- let comparison;
1649
- if (options.beforeModel !== undefined) {
1650
- const beforeValidation = validateModel(options.beforeModel);
1651
- if (!beforeValidation.ok) {
1652
- return {
1653
- ok: false,
1654
- schemaVersion: 1,
1655
- modelHash: modelHash(model),
1656
- summary: {
1657
- states: model.states.length,
1658
- transitions: model.transitions.length,
1659
- errors: beforeValidation.errors.length,
1660
- warnings: 0,
1661
- checksRun: checks.length + 1,
1662
- truncated: exploration.truncated,
1663
- },
1664
- checks: [
1665
- ...checks,
1666
- {
1667
- id: 'BEFORE_MODEL',
1668
- name: 'Before Model Validation',
1669
- status: 'fail',
1670
- detail: 'Before model schema validation failed: ' + beforeValidation.errors.length + ' errors',
1671
- findings: beforeValidation.errors.map((message) => ({ code: 'BEFORE_MODEL_INVALID', severity: 'error', message })),
1672
- },
1673
- ],
1674
- };
1675
- }
1676
- const before = beforeValidation.model;
1677
- const mapping = options.stateMapping ?? {};
1678
- checks.push(D1_behavioralPreservation(before, model, mapping), D2_invariantContinuity(before, model, normalized, mapping), D3_regressionDelta(before, model, mapping), D4_deadlockLivenessRegression(before, model, mapping));
1679
- comparison = buildComparisonSummary(before, model, mapping);
1680
- }
1681
- const errors = checks.reduce((sum, check) => sum + check.findings.filter((finding) => finding.severity === 'error').length, 0);
1682
- const warnings = checks.reduce((sum, check) => sum + check.findings.filter((finding) => finding.severity === 'warning').length, 0);
1683
- return {
1684
- ok: true,
1685
- schemaVersion: 1,
1686
- modelHash: modelHash(model),
1687
- summary: {
1688
- states: model.states.length,
1689
- transitions: model.transitions.length,
1690
- errors,
1691
- warnings,
1692
- checksRun: checks.length,
1693
- truncated: exploration.truncated,
1694
- },
1695
- checks,
1696
- ...(model.narrative === undefined ? {} : { narrative: model.narrative }),
1697
- ...(comparison === undefined ? {} : { comparison }),
1698
- };
1699
- }
1
+ import { createHash } from 'node:crypto';
2
+ export const ENGINE_SCHEMA_VERSION = 1;
3
+ const DEFAULT_MAX_STATES = 10_000;
4
+ const DEFAULT_MAX_PERMUTATION_EVENTS = 5;
5
+ function stableStringify(value) {
6
+ if (value === null || typeof value !== 'object')
7
+ return JSON.stringify(value);
8
+ if (Array.isArray(value))
9
+ return '[' + value.map((item) => stableStringify(item)).join(',') + ']';
10
+ const record = value;
11
+ return '{' + Object.keys(record).sort().map((key) => JSON.stringify(key) + ':' + stableStringify(record[key])).join(',') + '}';
12
+ }
13
+ export function modelHash(model) {
14
+ return createHash('sha256').update(stableStringify(model)).digest('hex');
15
+ }
16
+ export function validateModel(input) {
17
+ const errors = [];
18
+ const bad = (path, message) => {
19
+ errors.push(path + ': ' + message);
20
+ };
21
+ if (typeof input !== 'object' || input === null || Array.isArray(input)) {
22
+ return { ok: false, errors: ['model: must be an object'] };
23
+ }
24
+ const root = input;
25
+ if (root.schemaVersion !== 1)
26
+ bad('schemaVersion', 'must be 1');
27
+ if (typeof root.init !== 'string' || root.init.length === 0)
28
+ bad('init', 'must be a non-empty string');
29
+ if (!Array.isArray(root.states) || root.states.length === 0) {
30
+ bad('states', 'must be a non-empty array');
31
+ }
32
+ else {
33
+ const seen = new Set();
34
+ root.states.forEach((entry, index) => {
35
+ if (typeof entry !== 'object' || entry === null || Array.isArray(entry)) {
36
+ bad('states[' + index + ']', 'must be an object');
37
+ return;
38
+ }
39
+ const state = entry;
40
+ if (typeof state.id !== 'string' || state.id.length === 0)
41
+ bad('states[' + index + '].id', 'must be a non-empty string');
42
+ else if (seen.has(state.id))
43
+ bad('states[' + index + '].id', 'duplicate state id ' + state.id);
44
+ else
45
+ seen.add(state.id);
46
+ if (state.terminal !== undefined && typeof state.terminal !== 'boolean')
47
+ bad('states[' + index + '].terminal', 'must be a boolean');
48
+ for (const kind of ['onEntry', 'onExit']) {
49
+ const actions = state[kind];
50
+ if (actions !== undefined) {
51
+ if (!Array.isArray(actions))
52
+ bad('states[' + index + '].' + kind, 'must be an array of action names');
53
+ else if (actions.length > 64)
54
+ bad('states[' + index + '].' + kind, 'must not exceed 64 actions');
55
+ else
56
+ actions.forEach((action, actionIndex) => {
57
+ if (typeof action !== 'string' || action.length === 0)
58
+ bad('states[' + index + '].' + kind + '[' + actionIndex + ']', 'must be a non-empty string');
59
+ });
60
+ }
61
+ }
62
+ });
63
+ if (typeof root.init === 'string' && root.init.length > 0 && !seen.has(root.init))
64
+ bad('init', 'must name a declared state');
65
+ }
66
+ if (!Array.isArray(root.transitions)) {
67
+ bad('transitions', 'must be an array');
68
+ }
69
+ else {
70
+ const stateIds = new Set(Array.isArray(root.states) ? root.states.map((state) => state.id) : []);
71
+ root.transitions.forEach((entry, index) => {
72
+ const path = 'transitions[' + index + ']';
73
+ if (typeof entry !== 'object' || entry === null || Array.isArray(entry)) {
74
+ bad(path, 'must be an object');
75
+ return;
76
+ }
77
+ const transition = entry;
78
+ if (typeof transition.from !== 'string' || transition.from.length === 0)
79
+ bad(path + '.from', 'must be a non-empty string');
80
+ else if (!stateIds.has(transition.from))
81
+ bad(path + '.from', 'unknown state ' + transition.from);
82
+ if (typeof transition.event !== 'string' || transition.event.length === 0)
83
+ bad(path + '.event', 'must be a non-empty string');
84
+ if (typeof transition.to !== 'string' || transition.to.length === 0)
85
+ bad(path + '.to', 'must be a non-empty string');
86
+ else if (!stateIds.has(transition.to))
87
+ bad(path + '.to', 'unknown state ' + transition.to);
88
+ if (transition.guard !== undefined)
89
+ validateGuard(transition.guard, path + '.guard', errors, bad);
90
+ if (transition.updates !== undefined) {
91
+ if (!Array.isArray(transition.updates))
92
+ bad(path + '.updates', 'must be an array');
93
+ else
94
+ transition.updates.forEach((update, updateIndex) => {
95
+ const updatePath = path + '.updates[' + updateIndex + ']';
96
+ if (typeof update !== 'object' || update === null || Array.isArray(update)) {
97
+ bad(updatePath, 'must be an object');
98
+ return;
99
+ }
100
+ const record = update;
101
+ if (typeof record.variable !== 'string' || record.variable.length === 0)
102
+ bad(updatePath + '.variable', 'must be a non-empty string');
103
+ if (record.op !== 'set' && record.op !== 'inc' && record.op !== 'dec')
104
+ bad(updatePath + '.op', "must be 'set', 'inc', or 'dec'");
105
+ if (record.value !== undefined && typeof record.value !== 'number')
106
+ bad(updatePath + '.value', 'must be a number');
107
+ });
108
+ }
109
+ if (transition.cost !== undefined) {
110
+ if (typeof transition.cost !== 'number' || !Number.isFinite(transition.cost) || transition.cost < 0)
111
+ bad(path + '.cost', 'must be a non-negative finite number');
112
+ }
113
+ if (transition.weight !== undefined) {
114
+ if (typeof transition.weight !== 'number' || !Number.isFinite(transition.weight) || transition.weight < 0)
115
+ bad(path + '.weight', 'must be a non-negative finite number');
116
+ }
117
+ });
118
+ }
119
+ if (Array.isArray(root.states)) {
120
+ root.states.forEach((state, index) => {
121
+ if (state.maxTicks !== undefined && (!Number.isInteger(state.maxTicks) || state.maxTicks < 0)) {
122
+ bad('states[' + index + '].maxTicks', 'must be a non-negative integer');
123
+ }
124
+ });
125
+ }
126
+ const variableNames = new Set();
127
+ if (root.variables !== undefined) {
128
+ if (!Array.isArray(root.variables))
129
+ bad('variables', 'must be an array');
130
+ else
131
+ root.variables.forEach((entry, index) => {
132
+ const path = 'variables[' + index + ']';
133
+ if (typeof entry !== 'object' || entry === null || Array.isArray(entry)) {
134
+ bad(path, 'must be an object');
135
+ return;
136
+ }
137
+ const variable = entry;
138
+ if (typeof variable.name !== 'string' || variable.name.length === 0)
139
+ bad(path + '.name', 'must be a non-empty string');
140
+ else if (variableNames.has(variable.name))
141
+ bad(path + '.name', 'duplicate variable ' + variable.name);
142
+ else
143
+ variableNames.add(variable.name);
144
+ if (variable.kind !== 'integer' && variable.kind !== 'boolean')
145
+ bad(path + '.kind', "must be 'integer' or 'boolean'");
146
+ const expected = variable.kind === 'boolean' ? 'boolean' : 'number';
147
+ if (typeof variable.init !== expected)
148
+ bad(path + '.init', 'must be a ' + expected);
149
+ if (variable.min !== undefined && typeof variable.min !== 'number')
150
+ bad(path + '.min', 'must be a number');
151
+ if (variable.max !== undefined && typeof variable.max !== 'number')
152
+ bad(path + '.max', 'must be a number');
153
+ if (typeof variable.min === 'number' && typeof variable.max === 'number' && variable.min > variable.max)
154
+ bad(path + '.max', 'must be >= min');
155
+ if (variable.monotonic !== undefined && variable.monotonic !== 'inc' && variable.monotonic !== 'dec')
156
+ bad(path + '.monotonic', "must be 'inc' or 'dec'");
157
+ if (variable.kind === 'integer' && typeof variable.init === 'number') {
158
+ if (typeof variable.min === 'number' && variable.init < variable.min)
159
+ bad(path + '.init', 'must be >= min');
160
+ if (typeof variable.max === 'number' && variable.init > variable.max)
161
+ bad(path + '.init', 'must be <= max');
162
+ }
163
+ });
164
+ }
165
+ const validateVariableRef = (name, path) => {
166
+ if (typeof name !== 'string' || name.length === 0)
167
+ bad(path, 'must be a non-empty string');
168
+ else if (!variableNames.has(name))
169
+ bad(path, 'references unknown variable ' + name);
170
+ };
171
+ if (root.invariants !== undefined) {
172
+ if (!Array.isArray(root.invariants))
173
+ bad('invariants', 'must be an array');
174
+ else
175
+ root.invariants.forEach((entry, index) => {
176
+ const path = 'invariants[' + index + ']';
177
+ if (typeof entry !== 'object' || entry === null || Array.isArray(entry)) {
178
+ bad(path, 'must be an object');
179
+ return;
180
+ }
181
+ const invariant = entry;
182
+ if (typeof invariant.id !== 'string' || invariant.id.length === 0)
183
+ bad(path + '.id', 'must be a non-empty string');
184
+ if (typeof invariant.description !== 'string')
185
+ bad(path + '.description', 'must be a string');
186
+ if (invariant.kind === 'never-states') {
187
+ if (!Array.isArray(invariant.states) || invariant.states.length === 0)
188
+ bad(path + '.states', 'must be a non-empty array');
189
+ else
190
+ invariant.states.forEach((state, stateIndex) => {
191
+ if (typeof state !== 'string' || state.length === 0)
192
+ bad(path + '.states[' + stateIndex + ']', 'must be a non-empty string');
193
+ });
194
+ }
195
+ else if (invariant.kind === 'var-in-range') {
196
+ validateVariableRef(invariant.variable, path + '.variable');
197
+ if (invariant.min !== undefined && typeof invariant.min !== 'number')
198
+ bad(path + '.min', 'must be a number');
199
+ if (invariant.max !== undefined && typeof invariant.max !== 'number')
200
+ bad(path + '.max', 'must be a number');
201
+ }
202
+ else if (invariant.kind === 'event-before-state') {
203
+ if (typeof invariant.event !== 'string' || invariant.event.length === 0)
204
+ bad(path + '.event', 'must be a non-empty string');
205
+ if (typeof invariant.state !== 'string' || invariant.state.length === 0)
206
+ bad(path + '.state', 'must be a non-empty string');
207
+ }
208
+ else if (invariant.kind === 'leads-to') {
209
+ if (typeof invariant.from !== 'string' || invariant.from.length === 0)
210
+ bad(path + '.from', 'must be a non-empty string');
211
+ if (typeof invariant.to !== 'string' || invariant.to.length === 0)
212
+ bad(path + '.to', 'must be a non-empty string');
213
+ }
214
+ else if (invariant.kind === 'sequence') {
215
+ if (!Array.isArray(invariant.events) || invariant.events.length === 0)
216
+ bad(path + '.events', 'must be a non-empty array');
217
+ else
218
+ invariant.events.forEach((event, eventIndex) => {
219
+ if (typeof event !== 'string' || event.length === 0)
220
+ bad(path + '.events[' + eventIndex + ']', 'must be a non-empty string');
221
+ });
222
+ }
223
+ else if (invariant.kind === 'atomicity') {
224
+ if (!Array.isArray(invariant.events) || invariant.events.length === 0)
225
+ bad(path + '.events', 'must be a non-empty array');
226
+ else
227
+ invariant.events.forEach((event, eventIndex) => {
228
+ if (typeof event !== 'string' || event.length === 0)
229
+ bad(path + '.events[' + eventIndex + ']', 'must be a non-empty string');
230
+ });
231
+ if (typeof invariant.commit !== 'string' || invariant.commit.length === 0)
232
+ bad(path + '.commit', 'must be a non-empty string');
233
+ if (invariant.rollback !== undefined && typeof invariant.rollback !== 'string')
234
+ bad(path + '.rollback', 'must be a string');
235
+ }
236
+ else if (invariant.kind === 'budget') {
237
+ if (typeof invariant.budget !== 'number' || !Number.isFinite(invariant.budget) || invariant.budget < 0)
238
+ bad(path + '.budget', 'must be a non-negative finite number');
239
+ }
240
+ else if (invariant.kind === 'probability') {
241
+ if (typeof invariant.target !== 'string' || invariant.target.length === 0)
242
+ bad(path + '.target', 'must be a non-empty string');
243
+ if (invariant.op !== '>=' && invariant.op !== '<=' && invariant.op !== '>' && invariant.op !== '<')
244
+ bad(path + '.op', "must be one of '>=', '<=', '>', '<'");
245
+ if (typeof invariant.p !== 'number' || !Number.isFinite(invariant.p) || invariant.p < 0 || invariant.p > 1)
246
+ bad(path + '.p', 'must be a number in [0, 1]');
247
+ }
248
+ else {
249
+ bad(path + '.kind', 'unknown invariant kind');
250
+ }
251
+ });
252
+ }
253
+ if (root.concurrentPairs !== undefined) {
254
+ if (!Array.isArray(root.concurrentPairs))
255
+ bad('concurrentPairs', 'must be an array');
256
+ else
257
+ root.concurrentPairs.forEach((entry, index) => {
258
+ const path = 'concurrentPairs[' + index + ']';
259
+ if (!Array.isArray(entry) || entry.length !== 2 || typeof entry[0] !== 'string' || typeof entry[1] !== 'string') {
260
+ bad(path, 'must be a [event, event] string pair');
261
+ }
262
+ });
263
+ }
264
+ if (root.boundaryChecks !== undefined) {
265
+ if (!Array.isArray(root.boundaryChecks))
266
+ bad('boundaryChecks', 'must be an array');
267
+ else
268
+ root.boundaryChecks.forEach((entry, index) => {
269
+ const path = 'boundaryChecks[' + index + ']';
270
+ if (typeof entry !== 'object' || entry === null || Array.isArray(entry)) {
271
+ bad(path, 'must be an object');
272
+ return;
273
+ }
274
+ const check = entry;
275
+ validateVariableRef(check.variable, path + '.variable');
276
+ if (!Array.isArray(check.values) || check.values.some((value) => typeof value !== 'number'))
277
+ bad(path + '.values', 'must be an array of numbers');
278
+ });
279
+ }
280
+ if (root.idempotentEvents !== undefined) {
281
+ if (!Array.isArray(root.idempotentEvents))
282
+ bad('idempotentEvents', 'must be an array');
283
+ else
284
+ root.idempotentEvents.forEach((entry, index) => {
285
+ if (typeof entry !== 'string' || entry.length === 0)
286
+ bad('idempotentEvents[' + index + ']', 'must be a non-empty string');
287
+ });
288
+ }
289
+ if (root.tickEvents !== undefined) {
290
+ if (!Array.isArray(root.tickEvents))
291
+ bad('tickEvents', 'must be an array');
292
+ else
293
+ root.tickEvents.forEach((entry, index) => {
294
+ if (typeof entry !== 'string' || entry.length === 0)
295
+ bad('tickEvents[' + index + ']', 'must be a non-empty string');
296
+ });
297
+ }
298
+ if (root.resourcePairs !== undefined) {
299
+ if (!Array.isArray(root.resourcePairs))
300
+ bad('resourcePairs', 'must be an array');
301
+ else
302
+ root.resourcePairs.forEach((entry, index) => {
303
+ const path = 'resourcePairs[' + index + ']';
304
+ if (typeof entry !== 'object' || entry === null || Array.isArray(entry)) {
305
+ bad(path, 'must be an object');
306
+ return;
307
+ }
308
+ const pair = entry;
309
+ if (typeof pair.resource !== 'string' || pair.resource.length === 0)
310
+ bad(path + '.resource', 'must be a non-empty string');
311
+ if (typeof pair.acquireEvent !== 'string' || pair.acquireEvent.length === 0)
312
+ bad(path + '.acquireEvent', 'must be a non-empty string');
313
+ if (typeof pair.releaseEvent !== 'string' || pair.releaseEvent.length === 0)
314
+ bad(path + '.releaseEvent', 'must be a non-empty string');
315
+ if (pair.failEvent !== undefined && typeof pair.failEvent !== 'string')
316
+ bad(path + '.failEvent', 'must be a string');
317
+ });
318
+ }
319
+ if (root.narrative !== undefined) {
320
+ const narrativePath = 'narrative';
321
+ if (typeof root.narrative !== 'object' || root.narrative === null || Array.isArray(root.narrative)) {
322
+ bad(narrativePath, 'must be an object');
323
+ }
324
+ else {
325
+ const narrative = root.narrative;
326
+ const stateIds = new Set(Array.isArray(root.states) ? root.states.map((state) => state.id) : []);
327
+ const eventIds = new Set(Array.isArray(root.transitions) ? root.transitions.map((transition) => transition.event) : []);
328
+ const fromEventGroups = new Set(Array.isArray(root.transitions) ? root.transitions.map((transition) => transition.from + '|' + transition.event) : []);
329
+ if (typeof narrative.states !== 'object' || narrative.states === null || Array.isArray(narrative.states)) {
330
+ bad(narrativePath + '.states', 'must be an object mapping state id -> natural-language description');
331
+ }
332
+ else {
333
+ for (const [id, description] of Object.entries(narrative.states)) {
334
+ if (!stateIds.has(id))
335
+ bad(narrativePath + '.states', 'references unknown state ' + id);
336
+ if (typeof description !== 'string' || description.length === 0)
337
+ bad(narrativePath + '.states.' + id, 'must be a non-empty string');
338
+ }
339
+ for (const id of stateIds) {
340
+ if (typeof narrative.states[id] !== 'string')
341
+ bad(narrativePath + '.states', 'missing description for state ' + id);
342
+ }
343
+ }
344
+ if (typeof narrative.events !== 'object' || narrative.events === null || Array.isArray(narrative.events)) {
345
+ bad(narrativePath + '.events', 'must be an object mapping event id -> natural-language description');
346
+ }
347
+ else {
348
+ for (const [id, description] of Object.entries(narrative.events)) {
349
+ if (!eventIds.has(id))
350
+ bad(narrativePath + '.events', 'references unknown event ' + id);
351
+ if (typeof description !== 'string' || description.length === 0)
352
+ bad(narrativePath + '.events.' + id, 'must be a non-empty string');
353
+ }
354
+ for (const id of eventIds) {
355
+ if (typeof narrative.events[id] !== 'string')
356
+ bad(narrativePath + '.events', 'missing description for event ' + id);
357
+ }
358
+ }
359
+ if (!Array.isArray(narrative.scenarios)) {
360
+ bad(narrativePath + '.scenarios', 'must be an array of { from, event, scenario }');
361
+ }
362
+ else {
363
+ const seen = new Set();
364
+ narrative.scenarios.forEach((entry, index) => {
365
+ const scenarioPath = narrativePath + '.scenarios[' + index + ']';
366
+ if (typeof entry !== 'object' || entry === null || Array.isArray(entry)) {
367
+ bad(scenarioPath, 'must be an object');
368
+ return;
369
+ }
370
+ const scenario = entry;
371
+ if (typeof scenario.from !== 'string' || scenario.from.length === 0)
372
+ bad(scenarioPath + '.from', 'must be a non-empty string');
373
+ else if (!stateIds.has(scenario.from))
374
+ bad(scenarioPath + '.from', 'unknown state ' + scenario.from);
375
+ if (typeof scenario.event !== 'string' || scenario.event.length === 0)
376
+ bad(scenarioPath + '.event', 'must be a non-empty string');
377
+ else if (!eventIds.has(scenario.event))
378
+ bad(scenarioPath + '.event', 'unknown event ' + scenario.event);
379
+ if (typeof scenario.scenario !== 'string' || scenario.scenario.length === 0)
380
+ bad(scenarioPath + '.scenario', 'must be a non-empty string');
381
+ const key = String(scenario.from) + '|' + String(scenario.event);
382
+ if (seen.has(key))
383
+ bad(scenarioPath, 'duplicate scenario for (' + scenario.from + ', ' + scenario.event + ')');
384
+ seen.add(key);
385
+ });
386
+ for (const key of fromEventGroups) {
387
+ if (!seen.has(key)) {
388
+ const sep = key.indexOf('|');
389
+ bad(narrativePath + '.scenarios', 'missing scenario for (' + key.slice(0, sep) + ', ' + key.slice(sep + 1) + ')');
390
+ }
391
+ }
392
+ }
393
+ }
394
+ }
395
+ // Guard/update variable references were validated structurally; re-walk for references.
396
+ if (Array.isArray(root.transitions)) {
397
+ for (const entry of root.transitions) {
398
+ walkGuardReferences(entry.guard, variableNames, errors, bad);
399
+ for (const update of entry.updates ?? []) {
400
+ if (!variableNames.has(update.variable))
401
+ bad('transitions.updates', 'references unknown variable ' + update.variable);
402
+ if (update.op !== 'set' && update.value !== undefined && update.value === 0) {
403
+ // zero delta is harmless; no finding
404
+ }
405
+ }
406
+ }
407
+ }
408
+ if (errors.length > 0)
409
+ return { ok: false, errors };
410
+ return { ok: true, model: root };
411
+ }
412
+ function validateGuard(input, path, errors, bad) {
413
+ if (typeof input !== 'object' || input === null || Array.isArray(input)) {
414
+ bad(path, 'must be an object');
415
+ return;
416
+ }
417
+ const guard = input;
418
+ if (guard.variable !== undefined) {
419
+ if (typeof guard.variable !== 'string' || guard.variable.length === 0)
420
+ bad(path + '.variable', 'must be a non-empty string');
421
+ if (guard.op !== '==' && guard.op !== '!=' && guard.op !== '<' && guard.op !== '<=' && guard.op !== '>' && guard.op !== '>=')
422
+ bad(path + '.op', 'must be a comparison operator');
423
+ if (typeof guard.value !== 'number' && typeof guard.value !== 'boolean')
424
+ bad(path + '.value', 'must be a number or boolean');
425
+ return;
426
+ }
427
+ if (guard.all !== undefined) {
428
+ if (!Array.isArray(guard.all))
429
+ bad(path + '.all', 'must be an array');
430
+ else
431
+ guard.all.forEach((child, index) => validateGuard(child, path + '.all[' + index + ']', errors, bad));
432
+ return;
433
+ }
434
+ if (guard.any !== undefined) {
435
+ if (!Array.isArray(guard.any))
436
+ bad(path + '.any', 'must be an array');
437
+ else
438
+ guard.any.forEach((child, index) => validateGuard(child, path + '.any[' + index + ']', errors, bad));
439
+ return;
440
+ }
441
+ if (guard.not !== undefined) {
442
+ validateGuard(guard.not, path + '.not', errors, bad);
443
+ return;
444
+ }
445
+ bad(path, 'must be a leaf ({ variable, op, value }), { all }, { any }, or { not }');
446
+ }
447
+ function walkGuardReferences(guard, variableNames, errors, bad) {
448
+ if (guard === undefined)
449
+ return;
450
+ if ('variable' in guard) {
451
+ if (!variableNames.has(guard.variable))
452
+ bad('guard', 'references unknown variable ' + guard.variable);
453
+ return;
454
+ }
455
+ if ('all' in guard) {
456
+ for (const child of guard.all)
457
+ walkGuardReferences(child, variableNames, errors, bad);
458
+ return;
459
+ }
460
+ if ('any' in guard) {
461
+ for (const child of guard.any)
462
+ walkGuardReferences(child, variableNames, errors, bad);
463
+ return;
464
+ }
465
+ if ('not' in guard)
466
+ walkGuardReferences(guard.not, variableNames, errors, bad);
467
+ }
468
+ // ---------------------------------------------------------------------------
469
+ // execution
470
+ // ---------------------------------------------------------------------------
471
+ function isLeafGuard(guard) {
472
+ return 'variable' in guard;
473
+ }
474
+ export function guardVariables(guard) {
475
+ if (guard === undefined)
476
+ return [];
477
+ if (isLeafGuard(guard))
478
+ return [guard.variable];
479
+ if ('all' in guard)
480
+ return guard.all.flatMap((child) => guardVariables(child));
481
+ if ('any' in guard)
482
+ return guard.any.flatMap((child) => guardVariables(child));
483
+ if ('not' in guard)
484
+ return guardVariables(guard.not);
485
+ return [];
486
+ }
487
+ function evalGuard(guard, vars) {
488
+ if (isLeafGuard(guard)) {
489
+ const actual = vars[guard.variable];
490
+ switch (guard.op) {
491
+ case '==': return actual === guard.value;
492
+ case '!=': return actual !== guard.value;
493
+ case '<': return actual < guard.value;
494
+ case '<=': return actual <= guard.value;
495
+ case '>': return actual > guard.value;
496
+ case '>=': return actual >= guard.value;
497
+ }
498
+ }
499
+ if ('all' in guard)
500
+ return guard.all.every((child) => evalGuard(child, vars));
501
+ if ('any' in guard)
502
+ return guard.any.some((child) => evalGuard(child, vars));
503
+ if ('not' in guard)
504
+ return !evalGuard(guard.not, vars);
505
+ return false;
506
+ }
507
+ function initialState(model) {
508
+ const vars = {};
509
+ for (const variable of model.variables ?? [])
510
+ vars[variable.name] = variable.init;
511
+ return { state: model.init, vars };
512
+ }
513
+ function runtimeKey(runtime) {
514
+ return runtime.state + '|' + stableStringify(runtime.vars);
515
+ }
516
+ function applyUpdates(model, transition, runtime) {
517
+ const vars = { ...runtime.vars };
518
+ for (const update of transition.updates ?? []) {
519
+ const current = vars[update.variable];
520
+ if (update.op === 'set')
521
+ vars[update.variable] = update.value ?? 0;
522
+ else if (update.op === 'inc')
523
+ vars[update.variable] = ((typeof current === 'number' ? current : 0) + (update.value ?? 1));
524
+ else
525
+ vars[update.variable] = ((typeof current === 'number' ? current : 0) - (update.value ?? 1));
526
+ }
527
+ return { state: transition.to, vars };
528
+ }
529
+ function groupTransitions(model) {
530
+ const groups = new Map();
531
+ for (const transition of model.transitions) {
532
+ const key = transition.from + '|' + transition.event;
533
+ const list = groups.get(key) ?? [];
534
+ list.push(transition);
535
+ groups.set(key, list);
536
+ }
537
+ return groups;
538
+ }
539
+ function applicableTransitions(group, runtime) {
540
+ const guarded = [];
541
+ const unguarded = [];
542
+ for (const transition of group) {
543
+ if (transition.guard === undefined)
544
+ unguarded.push(transition);
545
+ else
546
+ guarded.push(transition);
547
+ }
548
+ const matched = guarded.filter((transition) => evalGuard(transition.guard, runtime.vars));
549
+ if (matched.length > 0)
550
+ return matched;
551
+ return unguarded;
552
+ }
553
+ function stepRuntime(model, runtime, event) {
554
+ const group = groupTransitions(model).get(runtime.state + '|' + event);
555
+ if (group === undefined || group.length === 0)
556
+ return [];
557
+ const seen = new Set();
558
+ const outcomes = [];
559
+ for (const transition of applicableTransitions(group, runtime)) {
560
+ const next = applyUpdates(model, transition, runtime);
561
+ const key = runtimeKey(next);
562
+ if (!seen.has(key)) {
563
+ seen.add(key);
564
+ outcomes.push(next);
565
+ }
566
+ }
567
+ return outcomes;
568
+ }
569
+ function explore(model, options) {
570
+ const init = initialState(model);
571
+ const reachable = [];
572
+ const reachableKeys = new Set();
573
+ const queue = [init];
574
+ let truncated = false;
575
+ while (queue.length > 0) {
576
+ const runtime = queue.shift();
577
+ const key = runtimeKey(runtime);
578
+ if (reachableKeys.has(key))
579
+ continue;
580
+ reachableKeys.add(key);
581
+ reachable.push(runtime);
582
+ if (reachable.length > options.maxStates) {
583
+ truncated = true;
584
+ break;
585
+ }
586
+ for (const event of allEvents(model)) {
587
+ for (const next of stepRuntime(model, runtime, event)) {
588
+ if (!reachableKeys.has(runtimeKey(next)))
589
+ queue.push(next);
590
+ }
591
+ }
592
+ }
593
+ return { reachable, reachableKeys, truncated, initialState: init };
594
+ }
595
+ function allEvents(model) {
596
+ return [...new Set(model.transitions.map((transition) => transition.event))].sort();
597
+ }
598
+ function stateById(model, id) {
599
+ return model.states.find((state) => state.id === id);
600
+ }
601
+ function isTerminal(model, state) {
602
+ return stateById(model, state)?.terminal === true;
603
+ }
604
+ function uniqueTargets(transitions) {
605
+ return [...new Set(transitions.map((transition) => transition.to))];
606
+ }
607
+ function checkResult(id, name, findings, detail) {
608
+ const errors = findings.filter((finding) => finding.severity === 'error').length;
609
+ const warnings = findings.filter((finding) => finding.severity === 'warning').length;
610
+ return {
611
+ id,
612
+ name,
613
+ status: findings.length === 0 ? 'pass' : 'fail',
614
+ detail: detail + (errors > 0 ? ' (' + errors + ' errors' + (warnings > 0 ? ', ' + warnings + ' warnings' : '') + ')' : warnings > 0 ? ' (' + warnings + ' warnings)' : ''),
615
+ findings,
616
+ };
617
+ }
618
+ // ---------------------------------------------------------------------------
619
+ // S1-S7
620
+ // ---------------------------------------------------------------------------
621
+ function S1_reachability(model, exploration) {
622
+ const reached = new Set(exploration.reachable.map((runtime) => runtime.state));
623
+ const unreachable = model.states.filter((state) => !reached.has(state.id)).map((state) => state.id);
624
+ const findings = unreachable.map((state) => ({
625
+ code: 'S1_UNREACHABLE_STATE',
626
+ severity: 'warning',
627
+ message: 'State ' + state + ' is not reachable from init.',
628
+ }));
629
+ return checkResult('S1', 'Reachability', findings, unreachable.length === 0 ? 'All states reachable' : 'Unreachable states: ' + unreachable.join(', '));
630
+ }
631
+ function S2_deadlock(model) {
632
+ const outgoing = new Set(model.transitions.map((transition) => transition.from));
633
+ const findings = [];
634
+ for (const state of model.states) {
635
+ if (state.terminal === true)
636
+ continue;
637
+ if (!outgoing.has(state.id)) {
638
+ findings.push({
639
+ code: 'S2_NO_TRANSITIONS',
640
+ severity: 'error',
641
+ message: 'Non-terminal state ' + state.id + ' has no outgoing transitions.',
642
+ });
643
+ }
644
+ }
645
+ return checkResult('S2', 'Deadlock', findings, findings.length === 0 ? 'No deadlocks' : 'Deadlocks: ' + findings.map((finding) => finding.message).join('; '));
646
+ }
647
+ function sccs(model) {
648
+ const states = model.states.map((state) => state.id);
649
+ const edges = new Map();
650
+ for (const state of states)
651
+ edges.set(state, []);
652
+ for (const transition of model.transitions) {
653
+ const list = edges.get(transition.from);
654
+ if (list !== undefined)
655
+ list.push(transition.to);
656
+ }
657
+ let index = 0;
658
+ const indices = new Map();
659
+ const low = new Map();
660
+ const onStack = new Set();
661
+ const stack = [];
662
+ const components = [];
663
+ const visit = (node) => {
664
+ indices.set(node, index);
665
+ low.set(node, index);
666
+ index += 1;
667
+ stack.push(node);
668
+ onStack.add(node);
669
+ for (const next of edges.get(node) ?? []) {
670
+ if (!indices.has(next)) {
671
+ visit(next);
672
+ low.set(node, Math.min(low.get(node), low.get(next)));
673
+ }
674
+ else if (onStack.has(next)) {
675
+ low.set(node, Math.min(low.get(node), indices.get(next)));
676
+ }
677
+ }
678
+ if (low.get(node) === indices.get(node)) {
679
+ const component = [];
680
+ while (stack.length > 0) {
681
+ const member = stack.pop();
682
+ onStack.delete(member);
683
+ component.push(member);
684
+ if (member === node)
685
+ break;
686
+ }
687
+ components.push(component);
688
+ }
689
+ };
690
+ for (const state of states) {
691
+ if (!indices.has(state))
692
+ visit(state);
693
+ }
694
+ return components;
695
+ }
696
+ function S3_liveness(model) {
697
+ const components = sccs(model);
698
+ const findings = [];
699
+ for (const component of components) {
700
+ const componentSet = new Set(component);
701
+ const internalEdges = model.transitions.filter((transition) => componentSet.has(transition.from) && componentSet.has(transition.to));
702
+ const escapingEdges = model.transitions.filter((transition) => componentSet.has(transition.from) && !componentSet.has(transition.to));
703
+ if (internalEdges.length === 0 || escapingEdges.length > 0)
704
+ continue;
705
+ if (component.some((state) => isTerminal(model, state)))
706
+ continue;
707
+ findings.push({
708
+ code: 'S3_CLOSED_SCC',
709
+ severity: 'error',
710
+ message: 'Closed SCC has no exit and contains no terminal state: ' + component.sort().join(', '),
711
+ evidence: { states: component.sort(), transitions: internalEdges.length },
712
+ });
713
+ }
714
+ return checkResult('S3', 'Liveness', findings, findings.length === 0 ? 'No harmful closed SCCs' : 'Closed SCCs: ' + findings.length);
715
+ }
716
+ function assignmentsFor(model, variables) {
717
+ const specs = (model.variables ?? []).filter((variable) => variables.includes(variable.name));
718
+ let combinations = [{}];
719
+ for (const spec of specs) {
720
+ let values;
721
+ if (spec.kind === 'boolean')
722
+ values = [false, true];
723
+ else {
724
+ const constants = collectGuardConstants(model).filter((value) => guardLeaves(model).some((leaf) => leaf.variable === spec.name && typeof leaf.value === 'number' && leaf.value === value));
725
+ if (spec.min !== undefined && spec.max !== undefined) {
726
+ values = [];
727
+ for (let value = spec.min; value <= spec.max && value <= spec.min + 200; value++)
728
+ values.push(value);
729
+ }
730
+ else {
731
+ const set = new Set([-1, 0, 1, ...constants]);
732
+ values = [...set].sort((a, b) => a - b);
733
+ }
734
+ }
735
+ const next = [];
736
+ for (const assignment of combinations) {
737
+ for (const value of values) {
738
+ next.push({ ...assignment, [spec.name]: value });
739
+ if (next.length > 10_000)
740
+ return null;
741
+ }
742
+ }
743
+ combinations = next;
744
+ }
745
+ return combinations;
746
+ }
747
+ function guardLeaves(model) {
748
+ const leaves = [];
749
+ const visit = (guard) => {
750
+ if ('variable' in guard)
751
+ leaves.push(guard);
752
+ else if ('all' in guard)
753
+ guard.all.forEach(visit);
754
+ else if ('any' in guard)
755
+ guard.any.forEach(visit);
756
+ else if ('not' in guard)
757
+ visit(guard.not);
758
+ };
759
+ for (const transition of model.transitions)
760
+ if (transition.guard !== undefined)
761
+ visit(transition.guard);
762
+ return leaves;
763
+ }
764
+ function collectGuardConstants(model) {
765
+ return guardLeaves(model).filter((leaf) => typeof leaf.value === 'number').map((leaf) => leaf.value);
766
+ }
767
+ function analyzeGuards(model) {
768
+ const determinism = [];
769
+ const completeness = [];
770
+ let truncated = false;
771
+ const groups = groupTransitions(model);
772
+ for (const [key, group] of groups) {
773
+ const [from, event] = key.split('|');
774
+ const unguarded = group.filter((transition) => transition.guard === undefined);
775
+ const guarded = group.filter((transition) => transition.guard !== undefined);
776
+ if (unguarded.length > 1) {
777
+ determinism.push({
778
+ code: 'S4_AMBIGUOUS_DEFAULT',
779
+ severity: 'error',
780
+ message: from + ' + ' + event + ' has multiple unconditional transitions: ' + uniqueTargets(unguarded).join(', '),
781
+ });
782
+ }
783
+ if (guarded.length === 0)
784
+ continue;
785
+ const variables = [...new Set(group.flatMap((transition) => guardVariables(transition.guard)))];
786
+ const assignments = assignmentsFor(model, variables);
787
+ if (assignments === null) {
788
+ completeness.push({ code: 'S6_UNBOUNDED_DOMAIN', severity: 'warning', message: from + ' + ' + event + ': guard domain too large to enumerate; exhaustive check skipped.' });
789
+ truncated = true;
790
+ continue;
791
+ }
792
+ const targets = uniqueTargets(group);
793
+ for (const assignment of assignments) {
794
+ const trueBranches = guarded.filter((transition) => evalGuard(transition.guard, assignment));
795
+ if (trueBranches.length > 1) {
796
+ determinism.push({
797
+ code: 'S4_NONDETERMINISTIC_GUARDS',
798
+ severity: 'error',
799
+ message: from + ' + ' + event + ' has ' + trueBranches.length + ' simultaneously true guards for assignment ' + stableStringify(assignment) + ': ' + uniqueTargets(trueBranches).join(', '),
800
+ evidence: { assignment },
801
+ });
802
+ }
803
+ if (trueBranches.length === 0 && unguarded.length === 0) {
804
+ completeness.push({
805
+ code: 'S6_INCOMPLETE_GUARD',
806
+ severity: 'error',
807
+ message: from + ' + ' + event + ' has no true guard and no default branch for assignment ' + stableStringify(assignment),
808
+ evidence: { assignment, branches: targets },
809
+ });
810
+ }
811
+ if (determinism.length + completeness.length > 200) {
812
+ truncated = true;
813
+ break;
814
+ }
815
+ }
816
+ if (truncated)
817
+ break;
818
+ }
819
+ return { determinism, completeness, truncated };
820
+ }
821
+ function S4_determinism(model) {
822
+ const analysis = analyzeGuards(model);
823
+ return checkResult('S4', 'Determinism', analysis.determinism, analysis.determinism.length === 0 ? 'Deterministic' : 'Nondeterminism findings: ' + analysis.determinism.length);
824
+ }
825
+ function S6_guardCompleteness(model) {
826
+ const analysis = analyzeGuards(model);
827
+ return checkResult('S6', 'Guard Completeness', analysis.completeness, analysis.completeness.length === 0 ? 'All guard branches defined' : 'Guard findings: ' + analysis.completeness.length);
828
+ }
829
+ function S5_eventCompleteness(model) {
830
+ const events = allEvents(model);
831
+ const findings = [];
832
+ for (const state of model.states) {
833
+ if (state.terminal === true)
834
+ continue;
835
+ const handled = new Set(model.transitions.filter((transition) => transition.from === state.id).map((transition) => transition.event));
836
+ for (const event of events) {
837
+ if (!handled.has(event)) {
838
+ findings.push({
839
+ code: 'S5_UNHANDLED_EVENT',
840
+ severity: 'warning',
841
+ message: state.id + ' silently ignores event ' + event,
842
+ });
843
+ }
844
+ }
845
+ }
846
+ return checkResult('S5', 'Event Completeness', findings, findings.length === 0 ? 'All states handle all relevant events' : findings.length + ' unhandled (state, event) pairs');
847
+ }
848
+ function invariantHolds(invariant, runtime) {
849
+ if (invariant.kind === 'never-states')
850
+ return !invariant.states.includes(runtime.state);
851
+ if (invariant.kind === 'var-in-range') {
852
+ const value = runtime.vars[invariant.variable];
853
+ if (typeof value !== 'number')
854
+ return false;
855
+ if (invariant.min !== undefined && value < invariant.min)
856
+ return false;
857
+ if (invariant.max !== undefined && value > invariant.max)
858
+ return false;
859
+ return true;
860
+ }
861
+ return true;
862
+ }
863
+ function shortestViolationForInvariant(model, options, invariant) {
864
+ if (invariant.kind === 'event-before-state') {
865
+ return shortestEventBeforeStateViolation(model, options, invariant);
866
+ }
867
+ const init = initialState(model);
868
+ if (!invariantHolds(invariant, init)) {
869
+ return { invariant, path: [], reason: 'Initial state violates the invariant.' };
870
+ }
871
+ const visited = new Set([runtimeKey(init)]);
872
+ const queue = [{ runtime: init, path: [] }];
873
+ let steps = 0;
874
+ while (queue.length > 0) {
875
+ const entry = queue.shift();
876
+ if (++steps > options.maxStates)
877
+ break;
878
+ for (const event of allEvents(model)) {
879
+ for (const next of stepRuntime(model, entry.runtime, event)) {
880
+ const key = runtimeKey(next);
881
+ if (visited.has(key))
882
+ continue;
883
+ const path = [...entry.path, { from: entry.runtime.state, event, to: next.state }];
884
+ if (!invariantHolds(invariant, next)) {
885
+ return { invariant, path, reason: 'Invariant violated after ' + path.length + ' events.' };
886
+ }
887
+ visited.add(key);
888
+ queue.push({ runtime: next, path });
889
+ }
890
+ }
891
+ }
892
+ return undefined;
893
+ }
894
+ function shortestEventBeforeStateViolation(model, options, invariant) {
895
+ const init = initialState(model);
896
+ const seenInitially = false;
897
+ if (init.state === invariant.state && !seenInitially) {
898
+ return { invariant, path: [], reason: 'Target state ' + invariant.state + ' is initial and the required event ' + invariant.event + ' has not occurred.' };
899
+ }
900
+ const key = (runtime, seen) => runtimeKey(runtime) + '|' + (seen ? '1' : '0');
901
+ const visited = new Set([key(init, false)]);
902
+ const queue = [{ runtime: init, seen: false, path: [] }];
903
+ let steps = 0;
904
+ while (queue.length > 0) {
905
+ const entry = queue.shift();
906
+ if (++steps > options.maxStates)
907
+ break;
908
+ for (const event of allEvents(model)) {
909
+ for (const next of stepRuntime(model, entry.runtime, event)) {
910
+ const seen = entry.seen || event === invariant.event;
911
+ const nextKey = key(next, seen);
912
+ if (visited.has(nextKey))
913
+ continue;
914
+ const path = [...entry.path, { from: entry.runtime.state, event, to: next.state }];
915
+ if (next.state === invariant.state && !seen) {
916
+ return { invariant, path, reason: 'Target state ' + invariant.state + ' is reachable without event ' + invariant.event + ' in ' + path.length + ' steps.' };
917
+ }
918
+ visited.add(nextKey);
919
+ queue.push({ runtime: next, seen, path });
920
+ }
921
+ }
922
+ }
923
+ return undefined;
924
+ }
925
+ function runInvariants(model, options) {
926
+ const violations = [];
927
+ for (const invariant of model.invariants ?? []) {
928
+ const violation = shortestViolationForInvariant(model, options, invariant);
929
+ if (violation !== undefined)
930
+ violations.push(violation);
931
+ }
932
+ return violations;
933
+ }
934
+ function S7_invariants(model, options) {
935
+ const violations = runInvariants(model, options);
936
+ const findings = violations.map((violation) => ({
937
+ code: 'S7_INVARIANT_VIOLATION',
938
+ severity: 'error',
939
+ message: 'Invariant "' + violation.invariant.id + '" (' + violation.invariant.description + ') violated: ' + violation.reason,
940
+ path: violation.path,
941
+ evidence: { invariant: violation.invariant },
942
+ }));
943
+ return checkResult('S7', 'Invariant Validity', findings, violations.length === 0 ? 'All invariants hold' : 'Invariant violations: ' + violations.length);
944
+ }
945
+ // ---------------------------------------------------------------------------
946
+ // A1-A7
947
+ // ---------------------------------------------------------------------------
948
+ function A1_unexpectedEvents(model) {
949
+ const events = allEvents(model);
950
+ const findings = [];
951
+ for (const state of model.states) {
952
+ if (state.terminal === true)
953
+ continue;
954
+ const handled = new Set(model.transitions.filter((transition) => transition.from === state.id).map((transition) => transition.event));
955
+ for (const event of events) {
956
+ if (!handled.has(event)) {
957
+ findings.push({
958
+ code: 'A1_UNHANDLED_EVENT',
959
+ severity: 'warning',
960
+ message: 'Event ' + event + ' in state ' + state.id + ' has no defined transition (silent ignore).',
961
+ });
962
+ }
963
+ }
964
+ }
965
+ return checkResult('A1', 'Unexpected Event Injection', findings, findings.length === 0 ? 'All event/state combinations defined' : findings.length + ' unhandled combinations');
966
+ }
967
+ function firstOutcome(runtime, outcomes) {
968
+ return outcomes[0];
969
+ }
970
+ function A2_raceInterleaving(model, exploration) {
971
+ const findings = [];
972
+ for (const [e1, e2] of model.concurrentPairs ?? []) {
973
+ for (const runtime of exploration.reachable) {
974
+ const first = firstOutcome(runtime, stepRuntime(model, runtime, e1));
975
+ const second = firstOutcome(runtime, stepRuntime(model, runtime, e2));
976
+ if (first === undefined && second === undefined)
977
+ continue;
978
+ const final12 = first === undefined ? runtime : firstOutcome(first, stepRuntime(model, first, e2)) ?? first;
979
+ const final21 = second === undefined ? runtime : firstOutcome(second, stepRuntime(model, second, e1)) ?? second;
980
+ if (runtimeKey(final12) !== runtimeKey(final21)) {
981
+ findings.push({
982
+ code: 'A2_ORDER_DEPENDENT',
983
+ severity: 'warning',
984
+ message: 'Events ' + e1 + ' and ' + e2 + ' produce order-dependent outcomes from state ' + runtime.state,
985
+ evidence: { e1ThenE2: final12, e2ThenE1: final21 },
986
+ });
987
+ }
988
+ }
989
+ }
990
+ return checkResult('A2', 'Race Interleaving', findings, findings.length === 0 ? 'No race conditions detected' : 'Order-dependent outcomes: ' + findings.length);
991
+ }
992
+ function stepSequence(model, init, events) {
993
+ let runtime = init;
994
+ for (const event of events) {
995
+ const next = firstOutcome(runtime, stepRuntime(model, runtime, event));
996
+ if (next === undefined)
997
+ continue;
998
+ runtime = next;
999
+ }
1000
+ return runtime;
1001
+ }
1002
+ function permutations(items) {
1003
+ if (items.length === 0)
1004
+ return [[]];
1005
+ const result = [];
1006
+ for (let index = 0; index < items.length; index++) {
1007
+ const rest = items.slice(0, index).concat(items.slice(index + 1));
1008
+ for (const suffix of permutations(rest))
1009
+ result.push([items[index], ...suffix]);
1010
+ }
1011
+ return result;
1012
+ }
1013
+ function A3_orderPermutation(model, options) {
1014
+ const events = allEvents(model).slice(0, options.maxPermutationEvents);
1015
+ if (events.length < 2)
1016
+ return checkResult('A3', 'Order Permutation', [], 'Fewer than 2 events — skipped');
1017
+ const init = initialState(model);
1018
+ const outcomes = new Map();
1019
+ for (const permutation of permutations(events)) {
1020
+ const final = stepSequence(model, init, permutation);
1021
+ const key = runtimeKey(final);
1022
+ const list = outcomes.get(key) ?? [];
1023
+ if (list.length < 3)
1024
+ list.push(permutation.join(','));
1025
+ outcomes.set(key, list);
1026
+ }
1027
+ if (outcomes.size > 1) {
1028
+ const examples = [...outcomes.entries()].map(([key, sequences]) => ({ final: key.split('|')[0], example: sequences[0] }));
1029
+ return checkResult('A3', 'Order Permutation', [{
1030
+ code: 'A3_ORDER_DEPENDENT',
1031
+ severity: 'warning',
1032
+ message: 'Same event set produces ' + outcomes.size + ' different outcomes depending on order.',
1033
+ evidence: { examples },
1034
+ }], 'Order-dependent outcomes: ' + outcomes.size);
1035
+ }
1036
+ return checkResult('A3', 'Order Permutation', [], 'Order-independent (sampled first ' + events.length + ' events)');
1037
+ }
1038
+ function stateActionList(model, stateId, kind) {
1039
+ const state = model.states.find((entry) => entry.id === stateId);
1040
+ const list = state === undefined ? undefined : state[kind];
1041
+ return list ?? [];
1042
+ }
1043
+ function actionsDeclared(model) {
1044
+ return model.states.some((state) => (state.onEntry ?? []).length > 0 || (state.onExit ?? []).length > 0);
1045
+ }
1046
+ /** Apply an ordered action list to the held flag. Actions never change state or variables; they only matter to pairing checks. */
1047
+ function applyActionList(list, acquireEvent, releaseEvent, held) {
1048
+ let next = held;
1049
+ let reacquired = false;
1050
+ for (const action of list) {
1051
+ if (action === acquireEvent) {
1052
+ if (next)
1053
+ reacquired = true;
1054
+ next = true;
1055
+ }
1056
+ else if (action === releaseEvent) {
1057
+ next = false;
1058
+ }
1059
+ }
1060
+ return { held: next, reacquired };
1061
+ }
1062
+ function A4_pairSymmetry(model) {
1063
+ const findings = [];
1064
+ const hasActions = actionsDeclared(model);
1065
+ const transitionEvents = new Set(model.transitions.map((transition) => transition.event));
1066
+ const actionEvents = new Set();
1067
+ if (hasActions) {
1068
+ for (const state of model.states) {
1069
+ for (const action of stateActionList(model, state.id, 'onEntry'))
1070
+ actionEvents.add(action);
1071
+ for (const action of stateActionList(model, state.id, 'onExit'))
1072
+ actionEvents.add(action);
1073
+ }
1074
+ }
1075
+ const pairHasEvent = (event) => transitionEvents.has(event) || actionEvents.has(event);
1076
+ const releaseReachableFrom = (stateId, releaseEvent) => {
1077
+ const reachable = stateGraphReachable(model, stateId);
1078
+ return [...reachable].some((state) => model.transitions.some((transition) => transition.from === state && transition.event === releaseEvent)
1079
+ || (hasActions && (stateActionList(model, state, 'onEntry').includes(releaseEvent) || stateActionList(model, state, 'onExit').includes(releaseEvent))));
1080
+ };
1081
+ const edges = stateGraphEdges(model);
1082
+ for (const pair of model.resourcePairs ?? []) {
1083
+ const acquireTransitions = model.transitions.filter((transition) => transition.event === pair.acquireEvent);
1084
+ const actionAcquire = hasActions && model.states.some((state) => stateActionList(model, state.id, 'onEntry').includes(pair.acquireEvent) || stateActionList(model, state.id, 'onExit').includes(pair.acquireEvent));
1085
+ const releaseExists = pairHasEvent(pair.releaseEvent);
1086
+ if (acquireTransitions.length === 0 && !actionAcquire && !releaseExists)
1087
+ continue;
1088
+ if ((acquireTransitions.length > 0 || actionAcquire) && !releaseExists) {
1089
+ findings.push({
1090
+ code: 'A4_NO_RELEASE_EVENT',
1091
+ severity: 'error',
1092
+ message: 'Resource "' + pair.resource + '": acquire event ' + pair.acquireEvent + ' exists but release event ' + pair.releaseEvent + ' is never defined.',
1093
+ });
1094
+ continue;
1095
+ }
1096
+ // Collect acquire sites: transitions whose event acquires, and state entry/exit actions that end holding the resource.
1097
+ const seeds = [];
1098
+ for (const acquire of acquireTransitions) {
1099
+ if (!releaseReachableFrom(acquire.to, pair.releaseEvent)) {
1100
+ findings.push({
1101
+ code: 'A4_NO_RELEASE_REACHABLE',
1102
+ severity: 'error',
1103
+ message: 'Resource "' + pair.resource + '": after ' + pair.acquireEvent + ' into ' + acquire.to + ', no ' + pair.releaseEvent + ' is reachable.',
1104
+ evidence: { acquireTransition: acquire },
1105
+ });
1106
+ continue;
1107
+ }
1108
+ seeds.push({ state: acquire.to, held: true, path: [] });
1109
+ }
1110
+ if (hasActions) {
1111
+ for (const state of model.states) {
1112
+ // acquire fired by onEntry: resource is held for the whole stay in the state
1113
+ const entry = stateActionList(model, state.id, 'onEntry');
1114
+ if (entry.includes(pair.acquireEvent)) {
1115
+ const sim = applyActionList(entry, pair.acquireEvent, pair.releaseEvent, false);
1116
+ if (sim.reacquired) {
1117
+ findings.push({
1118
+ code: 'A4_REACQUIRE_WITHOUT_RELEASE',
1119
+ severity: 'warning',
1120
+ message: 'Resource "' + pair.resource + '" is acquired more than once inside onEntry of ' + state.id + ' before ' + pair.releaseEvent + '.',
1121
+ });
1122
+ }
1123
+ if (sim.held)
1124
+ seeds.push({ state: state.id, held: true, path: [] });
1125
+ }
1126
+ // acquire fired by onExit: happens on every outgoing edge, never on a terminal state
1127
+ const exit = stateActionList(model, state.id, 'onExit');
1128
+ if (exit.includes(pair.acquireEvent) && !state.terminal) {
1129
+ const sim = applyActionList(exit, pair.acquireEvent, pair.releaseEvent, false);
1130
+ if (sim.reacquired) {
1131
+ findings.push({
1132
+ code: 'A4_REACQUIRE_WITHOUT_RELEASE',
1133
+ severity: 'warning',
1134
+ message: 'Resource "' + pair.resource + '" is acquired more than once inside onExit of ' + state.id + ' before ' + pair.releaseEvent + '.',
1135
+ });
1136
+ }
1137
+ if (sim.held)
1138
+ seeds.push({ state: state.id, held: false, path: [] });
1139
+ }
1140
+ }
1141
+ }
1142
+ // Path-sensitive probe: track the held flag along state-graph paths, firing
1143
+ // onExit of the source state before each transition and onEntry of the target after it.
1144
+ for (const seed of seeds) {
1145
+ const visited = new Set();
1146
+ const queue = [seed];
1147
+ let steps = 0;
1148
+ while (queue.length > 0) {
1149
+ const entry = queue.shift();
1150
+ if (++steps > 1000)
1151
+ break;
1152
+ const key = entry.state + '|' + (entry.held ? '1' : '0');
1153
+ if (visited.has(key))
1154
+ continue;
1155
+ visited.add(key);
1156
+ if (entry.held && isTerminal(model, entry.state)) {
1157
+ findings.push({
1158
+ code: 'A4_TERMINAL_WITH_RESOURCE',
1159
+ severity: 'error',
1160
+ message: 'Resource "' + pair.resource + '" is still held when entering terminal state ' + entry.state + '.',
1161
+ path: entry.path,
1162
+ });
1163
+ continue;
1164
+ }
1165
+ for (const transition of edges.get(entry.state) ?? []) {
1166
+ let held = entry.held;
1167
+ if (hasActions) {
1168
+ const sim = applyActionList(stateActionList(model, entry.state, 'onExit'), pair.acquireEvent, pair.releaseEvent, held);
1169
+ if (sim.reacquired) {
1170
+ findings.push({
1171
+ code: 'A4_REACQUIRE_WITHOUT_RELEASE',
1172
+ severity: 'warning',
1173
+ message: 'Resource "' + pair.resource + '" is acquired again in onExit of ' + entry.state + ' before ' + pair.releaseEvent + '.',
1174
+ path: entry.path,
1175
+ });
1176
+ continue;
1177
+ }
1178
+ held = sim.held;
1179
+ }
1180
+ if (transition.event === pair.releaseEvent)
1181
+ held = false;
1182
+ else if (transition.event === pair.acquireEvent) {
1183
+ if (held) {
1184
+ findings.push({
1185
+ code: 'A4_REACQUIRE_WITHOUT_RELEASE',
1186
+ severity: 'warning',
1187
+ message: 'Resource "' + pair.resource + '" is acquired again in state ' + entry.state + ' before ' + pair.releaseEvent + '.',
1188
+ path: [...entry.path, { from: entry.state, event: transition.event, to: transition.to }],
1189
+ });
1190
+ continue;
1191
+ }
1192
+ held = true;
1193
+ }
1194
+ if (hasActions) {
1195
+ const sim = applyActionList(stateActionList(model, transition.to, 'onEntry'), pair.acquireEvent, pair.releaseEvent, held);
1196
+ if (sim.reacquired) {
1197
+ findings.push({
1198
+ code: 'A4_REACQUIRE_WITHOUT_RELEASE',
1199
+ severity: 'warning',
1200
+ message: 'Resource "' + pair.resource + '" is acquired again inside onEntry of ' + transition.to + ' before ' + pair.releaseEvent + '.',
1201
+ path: [...entry.path, { from: entry.state, event: transition.event, to: transition.to }],
1202
+ });
1203
+ continue;
1204
+ }
1205
+ held = sim.held;
1206
+ }
1207
+ queue.push({ state: transition.to, held, path: [...entry.path, { from: entry.state, event: transition.event, to: transition.to }] });
1208
+ }
1209
+ }
1210
+ }
1211
+ }
1212
+ return checkResult('A4', 'Pair Symmetry', findings, findings.length === 0 ? 'All pairs balanced' : 'Asymmetric pairs: ' + findings.length);
1213
+ }
1214
+ function stateGraphEdges(model) {
1215
+ const edges = new Map();
1216
+ for (const state of model.states)
1217
+ edges.set(state.id, []);
1218
+ for (const transition of model.transitions)
1219
+ edges.get(transition.from)?.push(transition);
1220
+ return edges;
1221
+ }
1222
+ function stateGraphReachable(model, start) {
1223
+ const edges = new Map();
1224
+ for (const state of model.states)
1225
+ edges.set(state.id, []);
1226
+ for (const transition of model.transitions)
1227
+ edges.get(transition.from)?.push(transition.to);
1228
+ const visited = new Set();
1229
+ const queue = [start];
1230
+ while (queue.length > 0) {
1231
+ const current = queue.shift();
1232
+ if (visited.has(current))
1233
+ continue;
1234
+ visited.add(current);
1235
+ for (const next of edges.get(current) ?? [])
1236
+ queue.push(next);
1237
+ }
1238
+ return visited;
1239
+ }
1240
+ function A5_boundaryBlast(model) {
1241
+ const findings = [];
1242
+ for (const check of model.boundaryChecks ?? []) {
1243
+ const variable = (model.variables ?? []).find((entry) => entry.name === check.variable);
1244
+ if (variable === undefined)
1245
+ continue;
1246
+ for (const value of check.values) {
1247
+ if (variable.min !== undefined && value < variable.min) {
1248
+ findings.push({ code: 'A5_OUT_OF_DOMAIN', severity: 'warning', message: 'Boundary value ' + value + ' for ' + check.variable + ' is below min ' + variable.min });
1249
+ }
1250
+ if (variable.max !== undefined && value > variable.max) {
1251
+ findings.push({ code: 'A5_OUT_OF_DOMAIN', severity: 'warning', message: 'Boundary value ' + value + ' for ' + check.variable + ' is above max ' + variable.max });
1252
+ }
1253
+ const assignment = { ...(model.variables ?? []).reduce((result, entry) => { result[entry.name] = entry.init; return result; }, {}), [check.variable]: value };
1254
+ for (const [key, group] of groupTransitions(model)) {
1255
+ const [from, event] = key.split('|');
1256
+ if (!group.some((transition) => guardVariables(transition.guard).includes(check.variable)))
1257
+ continue;
1258
+ const guarded = group.filter((transition) => transition.guard !== undefined);
1259
+ const unguarded = group.filter((transition) => transition.guard === undefined);
1260
+ const matched = guarded.filter((transition) => evalGuard(transition.guard, assignment));
1261
+ if (matched.length === 0 && unguarded.length === 0) {
1262
+ findings.push({
1263
+ code: 'A5_GUARD_HOLE',
1264
+ severity: 'warning',
1265
+ message: from + ' + ' + event + ' has no branch for ' + check.variable + '=' + value,
1266
+ evidence: { assignment },
1267
+ });
1268
+ }
1269
+ if (matched.length > 1) {
1270
+ findings.push({
1271
+ code: 'A5_GUARD_OVERLAP',
1272
+ severity: 'warning',
1273
+ message: from + ' + ' + event + ' has ' + matched.length + ' true branches for ' + check.variable + '=' + value,
1274
+ evidence: { assignment },
1275
+ });
1276
+ }
1277
+ }
1278
+ }
1279
+ }
1280
+ return checkResult('A5', 'Boundary Blast', findings, findings.length === 0 ? 'Boundary checks passed' : 'Boundary findings: ' + findings.length);
1281
+ }
1282
+ function A6_resourceInjection(model) {
1283
+ const findings = [];
1284
+ for (const pair of model.resourcePairs ?? []) {
1285
+ if (pair.failEvent === undefined)
1286
+ continue;
1287
+ const acquireStates = new Set(model.transitions.filter((transition) => transition.event === pair.acquireEvent).map((transition) => transition.from));
1288
+ for (const state of acquireStates) {
1289
+ const handled = model.transitions.some((transition) => transition.from === state && transition.event === pair.failEvent);
1290
+ if (!handled) {
1291
+ findings.push({
1292
+ code: 'A6_NO_FAILURE_HANDLER',
1293
+ severity: 'warning',
1294
+ message: 'State ' + state + ' can ' + pair.acquireEvent + ' for "' + pair.resource + '" but has no ' + pair.failEvent + ' transition.',
1295
+ });
1296
+ }
1297
+ }
1298
+ }
1299
+ return checkResult('A6', 'Resource Injection', findings, findings.length === 0 ? 'No resource vulnerabilities detected' : 'Resource failure paths missing: ' + findings.length);
1300
+ }
1301
+ function A7_shortestViolations(model, options) {
1302
+ const violations = runInvariants(model, options);
1303
+ const findings = violations.map((violation) => ({
1304
+ code: 'A7_SHORTEST_COUNTEREXAMPLE',
1305
+ severity: 'warning',
1306
+ message: 'Invariant "' + violation.invariant.id + '" shortest violating path length: ' + violation.path.length + (violation.path.length === 0 ? ' (initial state)' : ''),
1307
+ path: violation.path,
1308
+ evidence: { invariant: violation.invariant.id },
1309
+ }));
1310
+ return checkResult('A7', 'Minimal Counter-Example', findings, violations.length === 0 ? 'All invariants hold for all reachable paths' : 'Violated invariants: ' + findings.length);
1311
+ }
1312
+ function mapStateId(mapping, state) {
1313
+ return mapping[state] ?? state;
1314
+ }
1315
+ function D1_behavioralPreservation(before, after, mapping) {
1316
+ const findings = [];
1317
+ const afterStates = new Set(after.states.map((state) => state.id));
1318
+ const afterByFromEvent = new Map();
1319
+ for (const transition of after.transitions) {
1320
+ const key = transition.from + '|' + transition.event;
1321
+ const set = afterByFromEvent.get(key) ?? new Set();
1322
+ set.add(transition.to);
1323
+ afterByFromEvent.set(key, set);
1324
+ }
1325
+ const beforeInitMapped = mapStateId(mapping, before.init);
1326
+ if (beforeInitMapped !== after.init) {
1327
+ findings.push({
1328
+ code: 'D1_INIT_MISMATCH',
1329
+ severity: 'warning',
1330
+ message: 'Mapped BEFORE init ' + beforeInitMapped + ' does not match AFTER init ' + after.init + '.',
1331
+ evidence: { beforeInit: before.init, mappedInit: beforeInitMapped, afterInit: after.init },
1332
+ });
1333
+ }
1334
+ for (const state of before.states) {
1335
+ const mapped = mapStateId(mapping, state.id);
1336
+ if (!afterStates.has(mapped)) {
1337
+ findings.push({
1338
+ code: 'D1_MAPPED_STATE_MISSING',
1339
+ severity: 'error',
1340
+ message: 'BEFORE state ' + state.id + ' maps to ' + mapped + ', which is not declared in AFTER.',
1341
+ evidence: { beforeState: state.id, mappedState: mapped },
1342
+ });
1343
+ continue;
1344
+ }
1345
+ for (const transition of before.transitions.filter((entry) => entry.from === state.id)) {
1346
+ const key = mapped + '|' + transition.event;
1347
+ const targets = afterByFromEvent.get(key);
1348
+ if (targets === undefined || targets.size === 0) {
1349
+ findings.push({
1350
+ code: 'D1_EVENT_DISABLED',
1351
+ severity: 'error',
1352
+ message: 'BEFORE can fire event ' + transition.event + ' from ' + state.id + ' (mapped to ' + mapped + '), but AFTER has no transition for that (state, event).',
1353
+ path: [{ from: state.id, event: transition.event, to: transition.to }],
1354
+ evidence: { beforeState: state.id, mappedState: mapped, event: transition.event },
1355
+ });
1356
+ }
1357
+ }
1358
+ }
1359
+ return checkResult('D1', 'Behavioral Preservation', findings, findings.length === 0 ? 'BEFORE event behavior is preserved in AFTER' : 'Behavioral preservation findings: ' + findings.length);
1360
+ }
1361
+ function mapInvariantForComparison(invariant, mapping) {
1362
+ if (invariant.kind === 'never-states') {
1363
+ return {
1364
+ ...invariant,
1365
+ id: invariant.id + ':before',
1366
+ description: invariant.description + ' (from BEFORE)',
1367
+ states: invariant.states.map((state) => mapStateId(mapping, state)),
1368
+ };
1369
+ }
1370
+ if (invariant.kind === 'event-before-state') {
1371
+ return {
1372
+ ...invariant,
1373
+ id: invariant.id + ':before',
1374
+ description: invariant.description + ' (from BEFORE)',
1375
+ state: mapStateId(mapping, invariant.state),
1376
+ };
1377
+ }
1378
+ return { ...invariant, id: invariant.id + ':before', description: invariant.description + ' (from BEFORE)' };
1379
+ }
1380
+ function D2_invariantContinuity(before, after, options, mapping) {
1381
+ const findings = [];
1382
+ const afterStates = new Set(after.states.map((state) => state.id));
1383
+ const afterVariables = new Set((after.variables ?? []).map((variable) => variable.name));
1384
+ for (const invariant of before.invariants ?? []) {
1385
+ const mapped = mapInvariantForComparison(invariant, mapping);
1386
+ if (mapped.kind === 'never-states') {
1387
+ for (const state of mapped.states) {
1388
+ if (!afterStates.has(state)) {
1389
+ findings.push({
1390
+ code: 'D2_MAPPED_STATE_MISSING',
1391
+ severity: 'warning',
1392
+ message: 'BEFORE invariant "' + invariant.id + '" maps to state ' + state + ', which is not declared in AFTER.',
1393
+ evidence: { invariant: invariant.id, state },
1394
+ });
1395
+ }
1396
+ }
1397
+ }
1398
+ else if (mapped.kind === 'event-before-state') {
1399
+ if (!afterStates.has(mapped.state)) {
1400
+ findings.push({
1401
+ code: 'D2_MAPPED_STATE_MISSING',
1402
+ severity: 'warning',
1403
+ message: 'BEFORE invariant "' + invariant.id + '" maps to state ' + mapped.state + ', which is not declared in AFTER.',
1404
+ evidence: { invariant: invariant.id, state: mapped.state },
1405
+ });
1406
+ }
1407
+ }
1408
+ else if (mapped.kind === 'var-in-range') {
1409
+ if (!afterVariables.has(mapped.variable)) {
1410
+ findings.push({
1411
+ code: 'D2_VARIABLE_MISSING',
1412
+ severity: 'warning',
1413
+ message: 'BEFORE invariant "' + invariant.id + '" references variable ' + mapped.variable + ', which is not declared in AFTER.',
1414
+ evidence: { invariant: invariant.id, variable: mapped.variable },
1415
+ });
1416
+ continue;
1417
+ }
1418
+ }
1419
+ const violation = shortestViolationForInvariant(after, options, mapped);
1420
+ if (violation !== undefined) {
1421
+ findings.push({
1422
+ code: 'D2_INVARIANT_REGRESSION',
1423
+ severity: 'error',
1424
+ message: 'BEFORE invariant "' + invariant.id + '" no longer holds in AFTER: ' + violation.reason,
1425
+ path: violation.path,
1426
+ evidence: { beforeInvariant: invariant, afterInvariant: mapped },
1427
+ });
1428
+ }
1429
+ }
1430
+ return checkResult('D2', 'Invariant Continuity', findings, findings.length === 0 ? 'All BEFORE invariants continue to hold' : 'Invariant continuity findings: ' + findings.length);
1431
+ }
1432
+ function D3_regressionDelta(before, after, mapping) {
1433
+ const afterStateIds = new Set(after.states.map((state) => state.id));
1434
+ const beforeMappedIds = new Set(before.states.map((state) => mapStateId(mapping, state.id)));
1435
+ const addedStates = after.states.filter((state) => !beforeMappedIds.has(state.id)).map((state) => state.id).sort();
1436
+ const removedStates = before.states.filter((state) => !afterStateIds.has(mapStateId(mapping, state.id))).map((state) => state.id).sort();
1437
+ const beforeEvents = new Set(before.transitions.map((transition) => transition.event));
1438
+ const afterEvents = new Set(after.transitions.map((transition) => transition.event));
1439
+ const addedEvents = [...afterEvents].filter((event) => !beforeEvents.has(event)).sort();
1440
+ const removedEvents = [...beforeEvents].filter((event) => !afterEvents.has(event)).sort();
1441
+ const removedTransitions = before.transitions.filter((transition) => !after.transitions.some((candidate) => candidate.from === mapStateId(mapping, transition.from) &&
1442
+ candidate.event === transition.event &&
1443
+ candidate.to === mapStateId(mapping, transition.to)));
1444
+ const addedTransitions = after.transitions.filter((transition) => !before.transitions.some((candidate) => mapStateId(mapping, candidate.from) === transition.from &&
1445
+ candidate.event === transition.event &&
1446
+ mapStateId(mapping, candidate.to) === transition.to));
1447
+ const findings = [];
1448
+ for (const state of removedStates) {
1449
+ findings.push({ code: 'D3_REMOVED_STATE', severity: 'warning', message: 'BEFORE state ' + state + ' is not present in AFTER under the given mapping.', evidence: { state } });
1450
+ }
1451
+ for (const event of removedEvents) {
1452
+ findings.push({ code: 'D3_REMOVED_EVENT', severity: 'warning', message: 'BEFORE event ' + event + ' is not present in AFTER.', evidence: { event } });
1453
+ }
1454
+ for (const transition of removedTransitions) {
1455
+ findings.push({
1456
+ code: 'D3_REMOVED_TRANSITION',
1457
+ severity: 'warning',
1458
+ message: 'BEFORE transition ' + transition.from + ' -' + transition.event + '-> ' + transition.to + ' has no exact AFTER counterpart.',
1459
+ evidence: { transition },
1460
+ });
1461
+ }
1462
+ const detail = 'Delta: +' + addedStates.length + ' states, -' + removedStates.length + ' states, +' + addedEvents.length + ' events, -' + removedEvents.length + ' events, +' + addedTransitions.length + ' transitions, -' + removedTransitions.length + ' transitions';
1463
+ return checkResult('D3', 'Regression Delta', findings, detail);
1464
+ }
1465
+ function deadlockStateIds(model) {
1466
+ const outgoing = new Set(model.transitions.map((transition) => transition.from));
1467
+ return model.states.filter((state) => state.terminal !== true && !outgoing.has(state.id)).map((state) => state.id);
1468
+ }
1469
+ function closedSccStateSets(model) {
1470
+ return sccs(model)
1471
+ .filter((component) => {
1472
+ const componentSet = new Set(component);
1473
+ const internalEdges = model.transitions.some((transition) => componentSet.has(transition.from) && componentSet.has(transition.to));
1474
+ const escapingEdges = model.transitions.some((transition) => componentSet.has(transition.from) && !componentSet.has(transition.to));
1475
+ return internalEdges && !escapingEdges && !component.some((state) => isTerminal(model, state));
1476
+ })
1477
+ .map((component) => component.sort());
1478
+ }
1479
+ function D4_deadlockLivenessRegression(before, after, mapping) {
1480
+ const findings = [];
1481
+ const beforeDeadlock = new Set(deadlockStateIds(before).map((state) => mapStateId(mapping, state)));
1482
+ const afterDeadlock = deadlockStateIds(after);
1483
+ for (const state of afterDeadlock) {
1484
+ if (!beforeDeadlock.has(state)) {
1485
+ findings.push({
1486
+ code: 'D4_DEADLOCK_REGRESSION',
1487
+ severity: 'error',
1488
+ message: 'AFTER introduces deadlock in state ' + state + ' that was not deadlocked in BEFORE.',
1489
+ evidence: { state },
1490
+ });
1491
+ }
1492
+ }
1493
+ const beforeScc = new Set(closedSccStateSets(before).map((component) => component.map((state) => mapStateId(mapping, state)).sort().join(',')));
1494
+ const afterScc = closedSccStateSets(after);
1495
+ for (const component of afterScc) {
1496
+ const key = component.join(',');
1497
+ if (!beforeScc.has(key)) {
1498
+ findings.push({
1499
+ code: 'D4_LIVENESS_REGRESSION',
1500
+ severity: 'error',
1501
+ message: 'AFTER introduces a closed SCC with no exit and no terminal state: ' + key,
1502
+ evidence: { states: component },
1503
+ });
1504
+ }
1505
+ }
1506
+ return checkResult('D4', 'Deadlock/Liveness Regression', findings, findings.length === 0 ? 'No new deadlock or liveness regressions' : 'Regression findings: ' + findings.length);
1507
+ }
1508
+ function buildComparisonSummary(before, after, mapping) {
1509
+ const afterStateIds = new Set(after.states.map((state) => state.id));
1510
+ const beforeMappedIds = new Set(before.states.map((state) => mapStateId(mapping, state.id)));
1511
+ const addedStates = after.states.filter((state) => !beforeMappedIds.has(state.id)).map((state) => state.id).sort();
1512
+ const removedStates = before.states.filter((state) => !afterStateIds.has(mapStateId(mapping, state.id))).map((state) => state.id).sort();
1513
+ const beforeEvents = new Set(before.transitions.map((transition) => transition.event));
1514
+ const afterEvents = new Set(after.transitions.map((transition) => transition.event));
1515
+ const addedEvents = [...afterEvents].filter((event) => !beforeEvents.has(event)).sort();
1516
+ const removedEvents = [...beforeEvents].filter((event) => !afterEvents.has(event)).sort();
1517
+ const removedTransitions = before.transitions.filter((transition) => !after.transitions.some((candidate) => candidate.from === mapStateId(mapping, transition.from) &&
1518
+ candidate.event === transition.event &&
1519
+ candidate.to === mapStateId(mapping, transition.to)));
1520
+ const addedTransitions = after.transitions.filter((transition) => !before.transitions.some((candidate) => mapStateId(mapping, candidate.from) === transition.from &&
1521
+ candidate.event === transition.event &&
1522
+ mapStateId(mapping, candidate.to) === transition.to));
1523
+ return {
1524
+ beforeModelHash: modelHash(before),
1525
+ afterModelHash: modelHash(after),
1526
+ stateMapping: mapping,
1527
+ beforeStates: before.states.length,
1528
+ beforeTransitions: before.transitions.length,
1529
+ afterStates: after.states.length,
1530
+ afterTransitions: after.transitions.length,
1531
+ addedStates,
1532
+ removedStates,
1533
+ addedEvents,
1534
+ removedEvents,
1535
+ addedTransitions,
1536
+ removedTransitions,
1537
+ };
1538
+ }
1539
+ function A8_idempotentReplay(model, exploration) {
1540
+ const events = model.idempotentEvents ?? [];
1541
+ const findings = [];
1542
+ for (const event of events) {
1543
+ for (const runtime of exploration.reachable) {
1544
+ const onceOptions = stepRuntime(model, runtime, event);
1545
+ if (onceOptions.length === 0)
1546
+ continue;
1547
+ for (const once of onceOptions) {
1548
+ const twiceOptions = stepRuntime(model, once, event);
1549
+ if (twiceOptions.length === 0) {
1550
+ findings.push({
1551
+ code: 'A8_NOT_REPLAYABLE',
1552
+ severity: 'warning',
1553
+ message: 'Idempotent event ' + event + ' is not replayable after first application from ' + runtime.state + '.',
1554
+ path: [{ from: runtime.state, event, to: once.state }],
1555
+ evidence: { state: runtime.state, event },
1556
+ });
1557
+ continue;
1558
+ }
1559
+ for (const twice of twiceOptions) {
1560
+ if (runtimeKey(twice) !== runtimeKey(once)) {
1561
+ findings.push({
1562
+ code: 'A8_NOT_IDEMPOTENT',
1563
+ severity: 'error',
1564
+ message: 'Idempotent event ' + event + ' changes state when applied twice from ' + runtime.state + '.',
1565
+ path: [{ from: runtime.state, event, to: once.state }, { from: once.state, event, to: twice.state }],
1566
+ evidence: { state: runtime.state, event, afterOnce: once, afterTwice: twice },
1567
+ });
1568
+ break;
1569
+ }
1570
+ }
1571
+ }
1572
+ }
1573
+ }
1574
+ return checkResult('A8', 'Idempotent Replay', findings, findings.length === 0 ? 'Idempotent events are replay-safe' : 'Idempotent replay findings: ' + findings.length);
1575
+ }
1576
+ function S8_monotonicVariables(model) {
1577
+ const findings = [];
1578
+ for (const variable of model.variables ?? []) {
1579
+ if (variable.monotonic === undefined)
1580
+ continue;
1581
+ for (const transition of model.transitions) {
1582
+ for (const update of transition.updates ?? []) {
1583
+ if (update.variable !== variable.name)
1584
+ continue;
1585
+ if (variable.monotonic === 'inc' && update.op === 'dec') {
1586
+ findings.push({ code: 'S8_MONOTONIC_DECREASE', severity: 'error', message: 'Monotonic (inc) variable ' + variable.name + ' is decreased by ' + transition.event + '.', evidence: { variable: variable.name, transition } });
1587
+ }
1588
+ else if (variable.monotonic === 'dec' && update.op === 'inc') {
1589
+ findings.push({ code: 'S8_MONOTONIC_INCREASE', severity: 'error', message: 'Monotonic (dec) variable ' + variable.name + ' is increased by ' + transition.event + '.', evidence: { variable: variable.name, transition } });
1590
+ }
1591
+ else if (update.op === 'set') {
1592
+ findings.push({ code: 'S8_MONOTONIC_SET_REVIEW', severity: 'warning', message: 'Monotonic variable ' + variable.name + ' uses set in ' + transition.event + '; verify it cannot move backwards.', evidence: { variable: variable.name, transition } });
1593
+ }
1594
+ }
1595
+ }
1596
+ }
1597
+ return checkResult('S8', 'Monotonic Variables', findings, findings.length === 0 ? 'Monotonic variables are respected' : 'Monotonic findings: ' + findings.length);
1598
+ }
1599
+ function findLeadsToBadPath(model, start, target) {
1600
+ if (start.state === target)
1601
+ return undefined;
1602
+ const visited = new Set();
1603
+ const queue = [{ runtime: start, path: [] }];
1604
+ while (queue.length > 0) {
1605
+ const entry = queue.shift();
1606
+ const key = runtimeKey(entry.runtime);
1607
+ if (entry.runtime.state === target)
1608
+ continue;
1609
+ if (visited.has(key))
1610
+ return { path: entry.path, reason: 'Cycle avoids target ' + target };
1611
+ visited.add(key);
1612
+ const nexts = [];
1613
+ for (const event of allEvents(model)) {
1614
+ for (const next of stepRuntime(model, entry.runtime, event))
1615
+ nexts.push({ next, event });
1616
+ }
1617
+ if (nexts.length === 0)
1618
+ return { path: entry.path, reason: 'Dead end before target ' + target };
1619
+ for (const { next, event } of nexts) {
1620
+ queue.push({ runtime: next, path: [...entry.path, { from: entry.runtime.state, event, to: next.state }] });
1621
+ }
1622
+ }
1623
+ return { path: [], reason: 'No path reaches target ' + target };
1624
+ }
1625
+ function A9_leadsTo(model, exploration) {
1626
+ const findings = [];
1627
+ for (const invariant of model.invariants ?? []) {
1628
+ if (invariant.kind !== 'leads-to')
1629
+ continue;
1630
+ for (const runtime of exploration.reachable) {
1631
+ if (runtime.state !== invariant.from)
1632
+ continue;
1633
+ const bad = findLeadsToBadPath(model, runtime, invariant.to);
1634
+ if (bad !== undefined) {
1635
+ findings.push({
1636
+ code: 'A9_LEADS_TO_VIOLATION',
1637
+ severity: 'error',
1638
+ message: 'Leads-to invariant "' + invariant.id + '" violated from ' + invariant.from + ': ' + bad.reason,
1639
+ path: bad.path,
1640
+ evidence: { invariant },
1641
+ });
1642
+ break;
1643
+ }
1644
+ }
1645
+ }
1646
+ return checkResult('A9', 'Leads-To', findings, findings.length === 0 ? 'All leads-to invariants hold' : 'Leads-to findings: ' + findings.length);
1647
+ }
1648
+ function findSequenceViolation(model, options, invariant) {
1649
+ const events = invariant.events;
1650
+ const init = initialState(model);
1651
+ const key = (runtime, progress) => runtimeKey(runtime) + '|' + progress;
1652
+ const visited = new Set([key(init, 0)]);
1653
+ const queue = [{ runtime: init, progress: 0, path: [] }];
1654
+ let steps = 0;
1655
+ while (queue.length > 0) {
1656
+ const entry = queue.shift();
1657
+ if (++steps > options.maxStates)
1658
+ break;
1659
+ for (const event of allEvents(model)) {
1660
+ for (const next of stepRuntime(model, entry.runtime, event)) {
1661
+ let progress = entry.progress;
1662
+ let violation = false;
1663
+ if (progress < events.length && event === events[progress]) {
1664
+ progress += 1;
1665
+ }
1666
+ else {
1667
+ const index = events.indexOf(event);
1668
+ if (index > progress)
1669
+ violation = true;
1670
+ }
1671
+ const path = [...entry.path, { from: entry.runtime.state, event, to: next.state }];
1672
+ if (violation) {
1673
+ return { invariant, path, reason: 'Event ' + event + ' occurred before ' + events[progress] };
1674
+ }
1675
+ const nextKey = key(next, progress);
1676
+ if (!visited.has(nextKey)) {
1677
+ visited.add(nextKey);
1678
+ queue.push({ runtime: next, progress, path });
1679
+ }
1680
+ }
1681
+ }
1682
+ }
1683
+ return undefined;
1684
+ }
1685
+ function A10_sequenceOrder(model, options) {
1686
+ const findings = [];
1687
+ for (const invariant of model.invariants ?? []) {
1688
+ if (invariant.kind !== 'sequence')
1689
+ continue;
1690
+ const violation = findSequenceViolation(model, options, invariant);
1691
+ if (violation !== undefined) {
1692
+ findings.push({
1693
+ code: 'A10_SEQUENCE_VIOLATION',
1694
+ severity: 'error',
1695
+ message: 'Sequence invariant "' + invariant.id + '" violated: ' + violation.reason,
1696
+ path: violation.path,
1697
+ evidence: { invariant },
1698
+ });
1699
+ }
1700
+ }
1701
+ return checkResult('A10', 'Sequence Order', findings, findings.length === 0 ? 'All sequence invariants hold' : 'Sequence findings: ' + findings.length);
1702
+ }
1703
+ function findAtomicityViolation(model, options, invariant) {
1704
+ const atomic = new Set(invariant.events);
1705
+ const init = initialState(model);
1706
+ const key = (runtime, started, closed) => runtimeKey(runtime) + '|' + (started ? '1' : '0') + '|' + (closed ? '1' : '0');
1707
+ const visited = new Set([key(init, false, false)]);
1708
+ const queue = [{ runtime: init, started: false, closed: false, path: [] }];
1709
+ let steps = 0;
1710
+ while (queue.length > 0) {
1711
+ const entry = queue.shift();
1712
+ if (++steps > options.maxStates)
1713
+ break;
1714
+ for (const event of allEvents(model)) {
1715
+ for (const next of stepRuntime(model, entry.runtime, event)) {
1716
+ const started = entry.started || atomic.has(event);
1717
+ const closed = entry.closed || event === invariant.commit || (invariant.rollback !== undefined && event === invariant.rollback);
1718
+ const path = [...entry.path, { from: entry.runtime.state, event, to: next.state }];
1719
+ if (entry.started && !entry.closed && !atomic.has(event) && event !== invariant.commit && event !== invariant.rollback) {
1720
+ return { invariant, path, reason: 'Left atomic scope via ' + event + ' without commit/rollback' };
1721
+ }
1722
+ if (started && !closed && isTerminal(model, next.state)) {
1723
+ return { invariant, path, reason: 'Terminal state reached with incomplete atomic group' };
1724
+ }
1725
+ const nextKey = key(next, started, closed);
1726
+ if (!visited.has(nextKey)) {
1727
+ visited.add(nextKey);
1728
+ queue.push({ runtime: next, started, closed, path });
1729
+ }
1730
+ }
1731
+ }
1732
+ }
1733
+ return undefined;
1734
+ }
1735
+ function A11_atomicity(model, options) {
1736
+ const findings = [];
1737
+ for (const invariant of model.invariants ?? []) {
1738
+ if (invariant.kind !== 'atomicity')
1739
+ continue;
1740
+ const violation = findAtomicityViolation(model, options, invariant);
1741
+ if (violation !== undefined) {
1742
+ findings.push({
1743
+ code: 'A11_ATOMICITY_VIOLATION',
1744
+ severity: 'error',
1745
+ message: 'Atomicity invariant "' + invariant.id + '" violated: ' + violation.reason,
1746
+ path: violation.path,
1747
+ evidence: { invariant },
1748
+ });
1749
+ }
1750
+ }
1751
+ return checkResult('A11', 'Atomicity', findings, findings.length === 0 ? 'All atomicity invariants hold' : 'Atomicity findings: ' + findings.length);
1752
+ }
1753
+ // ---------------------------------------------------------------------------
1754
+ // A12 — Budget (worst-case path cost)
1755
+ // ---------------------------------------------------------------------------
1756
+ function transitionCost(transition) {
1757
+ return transition.cost ?? 1;
1758
+ }
1759
+ function runtimeGraph(model, options) {
1760
+ const groupMap = groupTransitions(model);
1761
+ const exploration = explore(model, options);
1762
+ const edges = new Map();
1763
+ for (const runtime of exploration.reachable) {
1764
+ const out = [];
1765
+ for (const event of allEvents(model)) {
1766
+ const group = groupMap.get(runtime.state + '|' + event);
1767
+ if (group === undefined || group.length === 0)
1768
+ continue;
1769
+ for (const transition of applicableTransitions(group, runtime)) {
1770
+ const next = applyUpdates(model, transition, runtime);
1771
+ out.push({
1772
+ to: runtimeKey(next),
1773
+ step: { from: runtime.state, event: transition.event, to: next.state },
1774
+ cost: transitionCost(transition),
1775
+ });
1776
+ }
1777
+ }
1778
+ edges.set(runtimeKey(runtime), out);
1779
+ }
1780
+ return {
1781
+ edges,
1782
+ keys: exploration.reachable.map((runtime) => runtimeKey(runtime)),
1783
+ initKey: runtimeKey(exploration.initialState),
1784
+ };
1785
+ }
1786
+ /** Shortest path from init to targetKey over the runtime graph, or undefined when unreachable. */
1787
+ function runtimePathToKey(graph, targetKey) {
1788
+ const parent = new Map();
1789
+ const visited = new Set([graph.initKey]);
1790
+ const queue = [graph.initKey];
1791
+ while (queue.length > 0) {
1792
+ const current = queue.shift();
1793
+ if (current === targetKey) {
1794
+ const steps = [];
1795
+ let key = targetKey;
1796
+ while (key !== graph.initKey) {
1797
+ const step = parent.get(key);
1798
+ if (step === undefined)
1799
+ break;
1800
+ steps.unshift(step);
1801
+ key = step.from === undefined ? graph.initKey : keyOfFromStep(graph, step, key);
1802
+ }
1803
+ return steps;
1804
+ }
1805
+ for (const edge of graph.edges.get(current) ?? []) {
1806
+ if (visited.has(edge.to))
1807
+ continue;
1808
+ visited.add(edge.to);
1809
+ parent.set(edge.to, edge.step);
1810
+ queue.push(edge.to);
1811
+ }
1812
+ }
1813
+ return undefined;
1814
+ }
1815
+ function keyOfFromStep(graph, step, targetKey) {
1816
+ // Reconstruct the source key by scanning predecessors of targetKey whose step matches.
1817
+ for (const [from, edges] of graph.edges) {
1818
+ for (const edge of edges) {
1819
+ if (edge.to === targetKey && edge.step.event === step.event && edge.step.from === step.from && edge.step.to === step.to) {
1820
+ return from;
1821
+ }
1822
+ }
1823
+ }
1824
+ return graph.initKey;
1825
+ }
1826
+ /** Find a reachable cycle whose total cost is positive; return a witness path init -> one full round. */
1827
+ function findUnboundedCycle(model, options) {
1828
+ const graph = runtimeGraph(model, options);
1829
+ const color = new Map();
1830
+ for (const start of graph.keys) {
1831
+ if (color.has(start))
1832
+ continue;
1833
+ const nodeStack = [start];
1834
+ const idxStack = [0];
1835
+ const depthOf = new Map([[start, 0]]);
1836
+ const costAt = new Map([[start, 0]]);
1837
+ const prevKey = new Map();
1838
+ const prevStep = new Map();
1839
+ color.set(start, 1);
1840
+ while (nodeStack.length > 0) {
1841
+ const node = nodeStack[nodeStack.length - 1];
1842
+ const outs = graph.edges.get(node) ?? [];
1843
+ if (idxStack[idxStack.length - 1] < outs.length) {
1844
+ const edge = outs[idxStack[idxStack.length - 1]++];
1845
+ const seen = color.get(edge.to);
1846
+ if (seen === undefined) {
1847
+ color.set(edge.to, 1);
1848
+ depthOf.set(edge.to, depthOf.get(node) + 1);
1849
+ costAt.set(edge.to, costAt.get(node) + edge.cost);
1850
+ prevKey.set(edge.to, node);
1851
+ prevStep.set(edge.to, edge.step);
1852
+ nodeStack.push(edge.to);
1853
+ idxStack.push(0);
1854
+ }
1855
+ else if (seen === 1) {
1856
+ // Back edge on the current path: this is a true cycle.
1857
+ const cycleCost = costAt.get(node) + edge.cost - costAt.get(edge.to);
1858
+ if (cycleCost > 0) {
1859
+ const entry = runtimePathToKey(graph, edge.to) ?? [];
1860
+ const round = [];
1861
+ let cursor = node;
1862
+ while (cursor !== edge.to && prevStep.has(cursor)) {
1863
+ round.unshift(prevStep.get(cursor));
1864
+ cursor = prevKey.get(cursor);
1865
+ }
1866
+ round.push(edge.step);
1867
+ return [...entry, ...round];
1868
+ }
1869
+ }
1870
+ }
1871
+ else {
1872
+ color.set(node, 2);
1873
+ nodeStack.pop();
1874
+ idxStack.pop();
1875
+ depthOf.delete(node);
1876
+ costAt.delete(node);
1877
+ prevKey.delete(node);
1878
+ prevStep.delete(node);
1879
+ }
1880
+ }
1881
+ }
1882
+ return undefined;
1883
+ }
1884
+ function findBudgetViolation(model, options, invariant) {
1885
+ const groupMap = groupTransitions(model);
1886
+ const init = initialState(model);
1887
+ const bestCost = new Map([[runtimeKey(init), 0]]);
1888
+ const queue = [{ runtime: init, cost: 0, path: [] }];
1889
+ let steps = 0;
1890
+ while (queue.length > 0) {
1891
+ const entry = queue.shift();
1892
+ if (++steps > options.maxStates)
1893
+ break;
1894
+ for (const event of allEvents(model)) {
1895
+ const group = groupMap.get(entry.runtime.state + '|' + event);
1896
+ if (group === undefined || group.length === 0)
1897
+ continue;
1898
+ for (const transition of applicableTransitions(group, entry.runtime)) {
1899
+ const next = applyUpdates(model, transition, entry.runtime);
1900
+ const cost = entry.cost + transitionCost(transition);
1901
+ const path = [...entry.path, { from: entry.runtime.state, event: transition.event, to: next.state }];
1902
+ // Costs are non-negative, so the first step that pushes the accumulated cost
1903
+ // over the budget is already a witness.
1904
+ if (cost > invariant.budget)
1905
+ return { path, cost, unbounded: false };
1906
+ const key = runtimeKey(next);
1907
+ const best = bestCost.get(key);
1908
+ if (best === undefined || cost < best) {
1909
+ bestCost.set(key, cost);
1910
+ queue.push({ runtime: next, cost, path });
1911
+ }
1912
+ }
1913
+ }
1914
+ }
1915
+ // No finite path exceeds the budget within reachable space; a reachable positive-cost
1916
+ // cycle still makes the worst-case unbounded. Plain DAG merges are not cycles.
1917
+ const cycle = findUnboundedCycle(model, options);
1918
+ if (cycle !== undefined)
1919
+ return { path: cycle, unbounded: true };
1920
+ return undefined;
1921
+ }
1922
+ function A12_budget(model, options) {
1923
+ const budgets = (model.invariants ?? []).filter((invariant) => invariant.kind === 'budget');
1924
+ const usesCost = model.transitions.some((transition) => transition.cost !== undefined);
1925
+ const findings = [];
1926
+ if (usesCost && budgets.length === 0) {
1927
+ findings.push({
1928
+ code: 'A12_COST_WITHOUT_BUDGET',
1929
+ severity: 'warning',
1930
+ message: 'Transitions declare cost, but no budget invariant is declared, so worst-case path cost is not verified. Add an invariant of kind budget to check it.',
1931
+ });
1932
+ }
1933
+ for (const invariant of budgets) {
1934
+ const violation = findBudgetViolation(model, options, invariant);
1935
+ if (violation !== undefined) {
1936
+ findings.push({
1937
+ code: 'A12_BUDGET_OVER',
1938
+ severity: 'error',
1939
+ message: violation.unbounded
1940
+ ? 'Budget invariant "' + invariant.id + '" (' + invariant.description + ') exceeded: a reachable positive-cost cycle lets path cost grow without bound, so no finite budget ' + invariant.budget + ' holds.'
1941
+ : 'Budget invariant "' + invariant.id + '" (' + invariant.description + ') exceeded: worst-case path cost ' + violation.cost + ' is over budget ' + invariant.budget + '.',
1942
+ path: violation.path,
1943
+ evidence: violation.unbounded ? { invariant, unbounded: true } : { invariant, totalCost: violation.cost, unbounded: false },
1944
+ });
1945
+ }
1946
+ }
1947
+ return checkResult('A12', 'Budget', findings, findings.length === 0 ? (budgets.length === 0 ? 'No budget invariants declared' : 'All budget invariants hold') : 'Budget findings: ' + findings.length);
1948
+ }
1949
+ // ---------------------------------------------------------------------------
1950
+ // A13 — Probability reachability (DTMC)
1951
+ // ---------------------------------------------------------------------------
1952
+ function probabilityOutcomes(model, runtime) {
1953
+ const groupMap = groupTransitions(model);
1954
+ const outcomes = new Map();
1955
+ for (const event of allEvents(model)) {
1956
+ const group = groupMap.get(runtime.state + '|' + event);
1957
+ if (group === undefined || group.length === 0)
1958
+ continue;
1959
+ for (const transition of applicableTransitions(group, runtime)) {
1960
+ const weight = transition.weight ?? 1;
1961
+ if (!(weight > 0))
1962
+ continue;
1963
+ const next = applyUpdates(model, transition, runtime);
1964
+ const key = runtimeKey(next);
1965
+ const current = outcomes.get(key);
1966
+ if (current === undefined)
1967
+ outcomes.set(key, { state: next.state, weight });
1968
+ else
1969
+ current.weight += weight;
1970
+ }
1971
+ }
1972
+ return outcomes;
1973
+ }
1974
+ /**
1975
+ * Probability of ever reaching targetState from the initial state under the DTMC
1976
+ * interpretation: at each runtime state every applicable transition with weight > 0 is an
1977
+ * outcome chosen proportionally to its weight. Solved by value iteration (least fixed
1978
+ * point of the absorbing chain).
1979
+ */
1980
+ function computeHitProbability(model, options, targetState) {
1981
+ const exploration = explore(model, options);
1982
+ const reachable = exploration.reachable;
1983
+ const n = reachable.length;
1984
+ const keyIndex = new Map();
1985
+ reachable.forEach((runtime, i) => keyIndex.set(runtimeKey(runtime), i));
1986
+ const fixedValue = new Array(n).fill(null);
1987
+ const chains = [];
1988
+ for (let i = 0; i < n; i++) {
1989
+ const runtime = reachable[i];
1990
+ if (runtime.state === targetState) {
1991
+ fixedValue[i] = 1;
1992
+ chains.push([]);
1993
+ continue;
1994
+ }
1995
+ const outs = probabilityOutcomes(model, runtime);
1996
+ let sum = 0;
1997
+ const list = [];
1998
+ for (const [key, entry] of outs) {
1999
+ const j = keyIndex.get(key);
2000
+ if (j === undefined)
2001
+ continue;
2002
+ list.push({ j, w: entry.weight });
2003
+ sum += entry.weight;
2004
+ }
2005
+ if (sum <= 0) {
2006
+ fixedValue[i] = 0;
2007
+ chains.push([]);
2008
+ continue;
2009
+ }
2010
+ chains.push(list.map((item) => ({ j: item.j, w: item.w / sum })));
2011
+ }
2012
+ const p = new Array(n).fill(0);
2013
+ let converged = false;
2014
+ for (let iter = 0; iter < 20000; iter++) {
2015
+ let maxDelta = 0;
2016
+ for (let i = 0; i < n; i++) {
2017
+ if (fixedValue[i] !== null)
2018
+ continue;
2019
+ const list = chains[i];
2020
+ let acc = 0;
2021
+ for (const item of list) {
2022
+ const fixed = fixedValue[item.j];
2023
+ acc += item.w * (fixed === null ? p[item.j] : fixed);
2024
+ }
2025
+ const delta = Math.abs(acc - p[i]);
2026
+ if (delta > maxDelta)
2027
+ maxDelta = delta;
2028
+ p[i] = acc;
2029
+ }
2030
+ if (maxDelta < 1e-9) {
2031
+ converged = true;
2032
+ break;
2033
+ }
2034
+ }
2035
+ const initIndex = keyIndex.get(runtimeKey(exploration.initialState)) ?? 0;
2036
+ const fixed = fixedValue[initIndex];
2037
+ return { probability: fixed === null ? p[initIndex] : fixed, converged };
2038
+ }
2039
+ function A13_probability(model, options) {
2040
+ const findings = [];
2041
+ const eps = 1e-9;
2042
+ for (const invariant of model.invariants ?? []) {
2043
+ if (invariant.kind !== 'probability')
2044
+ continue;
2045
+ const { probability, converged } = computeHitProbability(model, options, invariant.target);
2046
+ let violated = false;
2047
+ if (invariant.op === '>=')
2048
+ violated = probability < invariant.p - eps;
2049
+ else if (invariant.op === '>')
2050
+ violated = probability <= invariant.p + eps;
2051
+ else if (invariant.op === '<=')
2052
+ violated = probability > invariant.p + eps;
2053
+ else
2054
+ violated = probability >= invariant.p - eps;
2055
+ if (violated) {
2056
+ findings.push({
2057
+ code: 'A13_PROBABILITY_VIOLATION',
2058
+ severity: 'error',
2059
+ message: 'Probability invariant "' + invariant.id + '" (' + invariant.description + ') violated: P(hit ' + invariant.target + ') = ' + probability.toFixed(6) + ' does not satisfy ' + invariant.op + ' ' + invariant.p + '.',
2060
+ evidence: { invariant, computed: probability, converged },
2061
+ });
2062
+ }
2063
+ else if (!converged) {
2064
+ findings.push({
2065
+ code: 'A13_NO_CONVERGENCE',
2066
+ severity: 'warning',
2067
+ message: 'Probability invariant "' + invariant.id + '" passes, but value iteration did not fully converge within the iteration cap.',
2068
+ evidence: { invariant, computed: probability, converged },
2069
+ });
2070
+ }
2071
+ }
2072
+ return checkResult('A13', 'Probability Reachability', findings, findings.length === 0 ? 'No probability invariants declared or all hold' : 'Probability findings: ' + findings.length);
2073
+ }
2074
+ // ---------------------------------------------------------------------------
2075
+ // A14 — Deadline (discrete tick clock)
2076
+ // ---------------------------------------------------------------------------
2077
+ function A14_deadline(model, options) {
2078
+ const findings = [];
2079
+ const tickEvents = new Set(model.tickEvents ?? []);
2080
+ const limits = new Map();
2081
+ let hasLimit = false;
2082
+ for (const state of model.states) {
2083
+ if (state.maxTicks !== undefined) {
2084
+ limits.set(state.id, state.maxTicks);
2085
+ hasLimit = true;
2086
+ }
2087
+ }
2088
+ if (!hasLimit) {
2089
+ return checkResult('A14', 'Deadline', [], 'No state declares maxTicks');
2090
+ }
2091
+ if (tickEvents.size === 0) {
2092
+ return checkResult('A14', 'Deadline', [{
2093
+ code: 'A14_NO_TICK_EVENTS',
2094
+ severity: 'warning',
2095
+ message: 'States declare maxTicks, but no tickEvents are declared, so deadline compliance cannot be verified.',
2096
+ }], 'Missing tick events');
2097
+ }
2098
+ const groupMap = groupTransitions(model);
2099
+ const init = initialState(model);
2100
+ const limitOf = (stateId) => limits.get(stateId);
2101
+ const queue = [{ runtime: init, ticks: 0, path: [] }];
2102
+ const seen = new Set();
2103
+ let steps = 0;
2104
+ while (queue.length > 0) {
2105
+ const entry = queue.shift();
2106
+ if (++steps > options.maxStates)
2107
+ break;
2108
+ const key = runtimeKey(entry.runtime) + '|' + entry.ticks;
2109
+ if (seen.has(key))
2110
+ continue;
2111
+ seen.add(key);
2112
+ for (const event of allEvents(model)) {
2113
+ const group = groupMap.get(entry.runtime.state + '|' + event);
2114
+ if (group === undefined || group.length === 0)
2115
+ continue;
2116
+ for (const transition of applicableTransitions(group, entry.runtime)) {
2117
+ const next = applyUpdates(model, transition, entry.runtime);
2118
+ const isTick = tickEvents.has(event);
2119
+ const stays = next.state === entry.runtime.state;
2120
+ const path = [...entry.path, { from: entry.runtime.state, event: transition.event, to: next.state }];
2121
+ let ticks;
2122
+ if (isTick && stays) {
2123
+ const lim = limitOf(next.state);
2124
+ if (lim !== undefined && entry.ticks + 1 > lim) {
2125
+ findings.push({
2126
+ code: 'A14_DEADLINE_MISS',
2127
+ severity: 'error',
2128
+ message: 'Deadline missed: state ' + next.state + ' can remain resident for more than ' + lim + ' tick(s).',
2129
+ path,
2130
+ evidence: { state: next.state, maxTicks: lim, ticks: entry.ticks + 1 },
2131
+ });
2132
+ continue;
2133
+ }
2134
+ ticks = entry.ticks + 1;
2135
+ }
2136
+ else if (isTick && !stays) {
2137
+ ticks = 0;
2138
+ }
2139
+ else if (!isTick && stays) {
2140
+ ticks = entry.ticks;
2141
+ }
2142
+ else {
2143
+ ticks = 0;
2144
+ }
2145
+ const nextKey = runtimeKey(next) + '|' + ticks;
2146
+ if (!seen.has(nextKey))
2147
+ queue.push({ runtime: next, ticks, path });
2148
+ }
2149
+ }
2150
+ }
2151
+ return checkResult('A14', 'Deadline', findings, findings.length === 0 ? 'All deadlines respected' : 'Deadline findings: ' + findings.length);
2152
+ }
2153
+ // ---------------------------------------------------------------------------
2154
+ // Coverage notes (informational gap notices)
2155
+ // ---------------------------------------------------------------------------
2156
+ // Underscore-separated identifiers like watchdog_timeout or isr_cmd are common in
2157
+ // state machines, so boundaries must treat '_' as a separator rather than a word char.
2158
+ const TIMING_VOCABULARY = /(?<![a-z0-9])(timeout|watchdog|timer|tick|deadline|period|delay|elapsed|latency)(?![a-z0-9])/i;
2159
+ const PREEMPTION_VOCABULARY = /(?<![a-z0-9])(isr|irq|interrupt|task|thread|preempt|rtos)(?![a-z0-9])/i;
2160
+ const HYBRID_VOCABULARY = /(?<![a-z0-9])(pid|plant|feedback|control.?loop|stability|stable|settling|damping|oscillat|chatter|kalman|foc|field.?weaken|motor|torque)(?![a-z0-9])/i;
2161
+ const PROBABILISTIC_VOCABULARY = /(?<![a-z0-9])(mtbf|mttf|failure.?rate|reliability|probability|probabilistic|markov|stochastic|fault.?tree|fmea)(?![a-z0-9])/i;
2162
+ function computeCoverageNotes(model) {
2163
+ const names = [];
2164
+ for (const state of model.states) {
2165
+ names.push(state.id);
2166
+ for (const action of state.onEntry ?? [])
2167
+ names.push(action);
2168
+ for (const action of state.onExit ?? [])
2169
+ names.push(action);
2170
+ }
2171
+ for (const transition of model.transitions)
2172
+ names.push(transition.event);
2173
+ const corpus = names.join(' ');
2174
+ const notes = [];
2175
+ if (TIMING_VOCABULARY.test(corpus)) {
2176
+ notes.push('The model references time-like vocabulary (timeout/watchdog/timer/deadline...). logicprobe verifies ordering, counts, and path budgets (A12) but not hard real-time semantics: deadlines, periods, and clock invariants need a timed model checker (e.g. UPPAAL).');
2177
+ }
2178
+ if (PREEMPTION_VOCABULARY.test(corpus)) {
2179
+ notes.push('The model references preemption/concurrency vocabulary (ISR/IRQ/task/interrupt...). logicprobe models event-order interleavings (A2/A3) but not preemptive concurrency; absolute claims such as thread-safe or interrupt-safe need dedicated verification (TSan, CBMC, or a model checker such as TLA+).');
2180
+ }
2181
+ if (HYBRID_VOCABULARY.test(corpus)) {
2182
+ notes.push('The model references control/hybrid vocabulary (pid/plant/feedback/stability/motor...). logicprobe verifies discrete transitions only; stability, settling time, and mode-switch dynamics over a continuous plant need hybrid verification (SpaceEx, Flow*, or Simulink/Stateflow analysis).');
2183
+ }
2184
+ if (PROBABILISTIC_VOCABULARY.test(corpus)) {
2185
+ notes.push('The model references probabilistic/reliability vocabulary (mtbf/failure rate/probability...). logicprobe is a qualitative model checker; reliability or probability claims need a stochastic model checker (PRISM, Storm) or fault-tree analysis.');
2186
+ }
2187
+ return notes;
2188
+ }
2189
+ // ---------------------------------------------------------------------------
2190
+ // main entry
2191
+ // ---------------------------------------------------------------------------
2192
+ export function runVerification(input, options = {}) {
2193
+ const normalized = {
2194
+ maxStates: options.maxStates ?? DEFAULT_MAX_STATES,
2195
+ maxPermutationEvents: options.maxPermutationEvents ?? DEFAULT_MAX_PERMUTATION_EVENTS,
2196
+ };
2197
+ const validation = validateModel(input);
2198
+ if (!validation.ok) {
2199
+ return {
2200
+ ok: false,
2201
+ schemaVersion: 1,
2202
+ modelHash: '',
2203
+ summary: { states: 0, transitions: 0, errors: validation.errors.length, warnings: 0, checksRun: 0 },
2204
+ checks: [{
2205
+ id: 'MODEL',
2206
+ name: 'Model Validation',
2207
+ status: 'fail',
2208
+ detail: 'Model schema validation failed: ' + validation.errors.length + ' errors',
2209
+ findings: validation.errors.map((message) => ({ code: 'MODEL_INVALID', severity: 'error', message })),
2210
+ }],
2211
+ };
2212
+ }
2213
+ const model = validation.model;
2214
+ const exploration = explore(model, normalized);
2215
+ const checks = [
2216
+ S1_reachability(model, exploration),
2217
+ S2_deadlock(model),
2218
+ S3_liveness(model),
2219
+ S4_determinism(model),
2220
+ S5_eventCompleteness(model),
2221
+ S6_guardCompleteness(model),
2222
+ S7_invariants(model, normalized),
2223
+ S8_monotonicVariables(model),
2224
+ A1_unexpectedEvents(model),
2225
+ A2_raceInterleaving(model, exploration),
2226
+ A3_orderPermutation(model, normalized),
2227
+ A4_pairSymmetry(model),
2228
+ A5_boundaryBlast(model),
2229
+ A6_resourceInjection(model),
2230
+ A7_shortestViolations(model, normalized),
2231
+ A8_idempotentReplay(model, exploration),
2232
+ A9_leadsTo(model, exploration),
2233
+ A10_sequenceOrder(model, normalized),
2234
+ A11_atomicity(model, normalized),
2235
+ A12_budget(model, normalized),
2236
+ A13_probability(model, normalized),
2237
+ A14_deadline(model, normalized),
2238
+ ];
2239
+ let comparison;
2240
+ if (options.beforeModel !== undefined) {
2241
+ const beforeValidation = validateModel(options.beforeModel);
2242
+ if (!beforeValidation.ok) {
2243
+ return {
2244
+ ok: false,
2245
+ schemaVersion: 1,
2246
+ modelHash: modelHash(model),
2247
+ summary: {
2248
+ states: model.states.length,
2249
+ transitions: model.transitions.length,
2250
+ errors: beforeValidation.errors.length,
2251
+ warnings: 0,
2252
+ checksRun: checks.length + 1,
2253
+ truncated: exploration.truncated,
2254
+ },
2255
+ checks: [
2256
+ ...checks,
2257
+ {
2258
+ id: 'BEFORE_MODEL',
2259
+ name: 'Before Model Validation',
2260
+ status: 'fail',
2261
+ detail: 'Before model schema validation failed: ' + beforeValidation.errors.length + ' errors',
2262
+ findings: beforeValidation.errors.map((message) => ({ code: 'BEFORE_MODEL_INVALID', severity: 'error', message })),
2263
+ },
2264
+ ],
2265
+ };
2266
+ }
2267
+ const before = beforeValidation.model;
2268
+ const mapping = options.stateMapping ?? {};
2269
+ checks.push(D1_behavioralPreservation(before, model, mapping), D2_invariantContinuity(before, model, normalized, mapping), D3_regressionDelta(before, model, mapping), D4_deadlockLivenessRegression(before, model, mapping));
2270
+ comparison = buildComparisonSummary(before, model, mapping);
2271
+ }
2272
+ const errors = checks.reduce((sum, check) => sum + check.findings.filter((finding) => finding.severity === 'error').length, 0);
2273
+ const warnings = checks.reduce((sum, check) => sum + check.findings.filter((finding) => finding.severity === 'warning').length, 0);
2274
+ const coverageNotes = computeCoverageNotes(model);
2275
+ return {
2276
+ ok: true,
2277
+ schemaVersion: 1,
2278
+ modelHash: modelHash(model),
2279
+ summary: {
2280
+ states: model.states.length,
2281
+ transitions: model.transitions.length,
2282
+ errors,
2283
+ warnings,
2284
+ checksRun: checks.length,
2285
+ truncated: exploration.truncated,
2286
+ },
2287
+ checks,
2288
+ ...(model.narrative === undefined ? {} : { narrative: model.narrative }),
2289
+ ...(comparison === undefined ? {} : { comparison }),
2290
+ ...(coverageNotes.length === 0 ? {} : { coverageNotes }),
2291
+ };
2292
+ }
2293
+ /**
2294
+ * N-machine composition semantics (documented in dsh-model-schema.md):
2295
+ * - a non-rendezvous event advances exactly ONE non-terminal machine that fires it;
2296
+ * - a rendezvous event (handshake) fires only when AT LEAST TWO machines declare it in
2297
+ * their alphabet and EVERY such non-terminal machine has it enabled (guards held);
2298
+ * all participants then advance simultaneously. A machine that is terminal, or whose
2299
+ * alphabet does not include the event, does not participate;
2300
+ * - a terminal machine is stopped and takes no further part.
2301
+ * Checks: C1 composition deadlock (reachable node where no machine can advance while at
2302
+ * least one is not terminal) and C2 rendezvous that can never fire.
2303
+ */
2304
+ export function runCompositionVerification(machinesInput, options = {}) {
2305
+ const models = [];
2306
+ const hashes = [];
2307
+ const modelFindings = [];
2308
+ machinesInput.forEach((input, index) => {
2309
+ const validation = validateModel(input);
2310
+ if (!validation.ok) {
2311
+ modelFindings.push({ code: 'MODEL_INVALID', severity: 'error', message: 'machine ' + index + ' invalid: ' + validation.errors.join('; ') });
2312
+ }
2313
+ else {
2314
+ models.push(validation.model);
2315
+ hashes.push(modelHash(validation.model));
2316
+ }
2317
+ });
2318
+ const machineSummary = models.map((m, i) => ({ modelHash: hashes[i] ?? '', states: m.states.length, transitions: m.transitions.length }));
2319
+ if (modelFindings.length > 0 || models.length < 2) {
2320
+ if (models.length < 2 && modelFindings.length === 0) {
2321
+ modelFindings.push({ code: 'MODEL_INVALID', severity: 'error', message: 'composition requires at least two machines' });
2322
+ }
2323
+ return {
2324
+ ok: false,
2325
+ summary: { machineCount: models.length, machines: machineSummary, compositeStates: 0, errors: modelFindings.length, warnings: 0, truncated: false },
2326
+ checks: [{ id: 'MODEL', name: 'Machine Validation', status: 'fail', detail: 'composition input validation failed', findings: modelFindings }],
2327
+ };
2328
+ }
2329
+ const rendezvous = new Set(options.rendezvous ?? []);
2330
+ const maxStates = options.maxStates ?? DEFAULT_MAX_STATES;
2331
+ const machineEventSets = models.map((model) => new Set(model.transitions.map((transition) => transition.event)));
2332
+ const compositionMoves = (node) => {
2333
+ const moves = [];
2334
+ // single-machine (non-rendezvous) steps
2335
+ for (let i = 0; i < models.length; i++) {
2336
+ if (isTerminal(models[i], node.runtimes[i].state))
2337
+ continue;
2338
+ for (const event of allEvents(models[i])) {
2339
+ if (rendezvous.has(event))
2340
+ continue;
2341
+ for (const next of stepRuntime(models[i], node.runtimes[i], event)) {
2342
+ const runtimes = node.runtimes.slice();
2343
+ runtimes[i] = next;
2344
+ moves.push({ next: { runtimes, path: [] }, event, machines: [i] });
2345
+ }
2346
+ }
2347
+ }
2348
+ // synchronized rendezvous steps (>= 2 participants, all enabled)
2349
+ for (const event of rendezvous) {
2350
+ const participants = [];
2351
+ for (let i = 0; i < models.length; i++) {
2352
+ if (isTerminal(models[i], node.runtimes[i].state))
2353
+ continue;
2354
+ if (!machineEventSets[i].has(event))
2355
+ continue;
2356
+ participants.push(i);
2357
+ }
2358
+ if (participants.length < 2)
2359
+ continue; // handshake needs a partner
2360
+ const outcomes = participants.map((i) => stepRuntime(models[i], node.runtimes[i], event));
2361
+ if (outcomes.some((list) => list.length === 0))
2362
+ continue; // not all participants ready
2363
+ let combos = [node.runtimes.slice()];
2364
+ participants.forEach((i, slot) => {
2365
+ const nextCombos = [];
2366
+ for (const combo of combos) {
2367
+ for (const outcome of outcomes[slot]) {
2368
+ const copy = combo.slice();
2369
+ copy[i] = outcome;
2370
+ nextCombos.push(copy);
2371
+ }
2372
+ }
2373
+ combos = nextCombos;
2374
+ });
2375
+ for (const runtimes of combos) {
2376
+ moves.push({ next: { runtimes, path: [] }, event, machines: participants });
2377
+ }
2378
+ }
2379
+ return moves;
2380
+ };
2381
+ const keyOf = (node) => node.runtimes.map((runtime) => runtimeKey(runtime)).join('|');
2382
+ const init = { runtimes: models.map((model) => initialState(model)), path: [] };
2383
+ const visited = new Set();
2384
+ const queue = [init];
2385
+ let compositeStates = 0;
2386
+ let truncated = false;
2387
+ const firedCount = new Map();
2388
+ for (const event of rendezvous)
2389
+ firedCount.set(event, 0);
2390
+ const c1Findings = [];
2391
+ while (queue.length > 0) {
2392
+ const node = queue.shift();
2393
+ const key = keyOf(node);
2394
+ if (visited.has(key))
2395
+ continue;
2396
+ visited.add(key);
2397
+ compositeStates += 1;
2398
+ if (compositeStates > maxStates) {
2399
+ truncated = true;
2400
+ break;
2401
+ }
2402
+ const moves = compositionMoves(node);
2403
+ const allTerminal = node.runtimes.every((runtime, i) => isTerminal(models[i], runtime.state));
2404
+ if (moves.length === 0 && !allTerminal) {
2405
+ c1Findings.push({
2406
+ code: 'C1_COMPOSITION_DEADLOCK',
2407
+ severity: 'error',
2408
+ message: 'Composition deadlock: no machine can advance from (' + node.runtimes.map((runtime) => runtime.state).join(', ') + ') while at least one is not terminal.',
2409
+ evidence: { steps: node.path, states: node.runtimes.map((runtime) => runtime.state) },
2410
+ });
2411
+ continue;
2412
+ }
2413
+ for (const move of moves) {
2414
+ if (move.machines.length > 1)
2415
+ firedCount.set(move.event, (firedCount.get(move.event) ?? 0) + 1);
2416
+ const nextKey = keyOf(move.next);
2417
+ if (visited.has(nextKey))
2418
+ continue;
2419
+ queue.push({ runtimes: move.next.runtimes, path: [...node.path, { event: move.event, machines: move.machines }] });
2420
+ }
2421
+ }
2422
+ const c2Findings = [];
2423
+ const allEventsSet = new Set();
2424
+ for (const set of machineEventSets)
2425
+ for (const event of set)
2426
+ allEventsSet.add(event);
2427
+ for (const event of rendezvous) {
2428
+ if (!allEventsSet.has(event))
2429
+ continue;
2430
+ if ((firedCount.get(event) ?? 0) === 0) {
2431
+ c2Findings.push({
2432
+ code: 'C2_RENDEZVOUS_NEVER_FIRES',
2433
+ severity: 'warning',
2434
+ message: 'Rendezvous event ' + event + ' can never fire: fewer than two machines ever jointly enable it.',
2435
+ });
2436
+ }
2437
+ }
2438
+ const errors = c1Findings.length;
2439
+ const warnings = c2Findings.length;
2440
+ const checks = [
2441
+ checkResult('C1', 'Composition Deadlock', c1Findings, c1Findings.length === 0 ? 'No composition deadlock reachable' : 'Composition deadlocks: ' + c1Findings.length),
2442
+ checkResult('C2', 'Rendezvous Sync', c2Findings, c2Findings.length === 0 ? 'All rendezvous events can fire' : 'Rendezvous warnings: ' + c2Findings.length),
2443
+ ];
2444
+ return {
2445
+ ok: errors === 0,
2446
+ summary: { machineCount: models.length, machines: machineSummary, compositeStates, errors, warnings, truncated },
2447
+ checks,
2448
+ };
2449
+ }