dsh-logicprobe 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/engine.ts ADDED
@@ -0,0 +1,1164 @@
1
+ import { createHash } from 'node:crypto'
2
+
3
+ export const ENGINE_SCHEMA_VERSION = 1
4
+
5
+ export type VarValue = number | boolean
6
+
7
+ export type GuardOp = '==' | '!=' | '<' | '<=' | '>' | '>='
8
+
9
+ export interface LeafGuard {
10
+ variable: string
11
+ op: GuardOp
12
+ value: VarValue
13
+ }
14
+
15
+ export interface AllGuard {
16
+ all: GuardNode[]
17
+ }
18
+
19
+ export interface AnyGuard {
20
+ any: GuardNode[]
21
+ }
22
+
23
+ export interface NotGuard {
24
+ not: GuardNode
25
+ }
26
+
27
+ export type GuardNode = LeafGuard | AllGuard | AnyGuard | NotGuard
28
+
29
+ export interface StateSpec {
30
+ id: string
31
+ terminal?: boolean
32
+ }
33
+
34
+ export interface UpdateSpec {
35
+ variable: string
36
+ op: 'set' | 'inc' | 'dec'
37
+ value?: number
38
+ }
39
+
40
+ export interface TransitionSpec {
41
+ from: string
42
+ event: string
43
+ to: string
44
+ /** Absent guard is the else/default branch for the same (from, event) group. */
45
+ guard?: GuardNode
46
+ updates?: UpdateSpec[]
47
+ }
48
+
49
+ export interface VariableSpec {
50
+ name: string
51
+ kind: 'integer' | 'boolean'
52
+ init: number | boolean
53
+ min?: number
54
+ max?: number
55
+ }
56
+
57
+ export type InvariantSpec =
58
+ | { id: string; description: string; kind: 'never-states'; states: string[] }
59
+ | { id: string; description: string; kind: 'var-in-range'; variable: string; min?: number; max?: number }
60
+ | { id: string; description: string; kind: 'event-before-state'; event: string; state: string }
61
+
62
+ export interface ResourcePairSpec {
63
+ resource: string
64
+ acquireEvent: string
65
+ releaseEvent: string
66
+ failEvent?: string
67
+ }
68
+
69
+ export interface BoundaryCheckSpec {
70
+ variable: string
71
+ values: number[]
72
+ }
73
+
74
+ export interface LogicModelV1 {
75
+ schemaVersion: 1
76
+ init: string
77
+ states: StateSpec[]
78
+ transitions: TransitionSpec[]
79
+ variables?: VariableSpec[]
80
+ invariants?: InvariantSpec[]
81
+ concurrentPairs?: [string, string][]
82
+ boundaryChecks?: BoundaryCheckSpec[]
83
+ resourcePairs?: ResourcePairSpec[]
84
+ }
85
+
86
+ export interface VerificationOptions {
87
+ maxStates?: number
88
+ maxPermutationEvents?: number
89
+ }
90
+
91
+ export interface PathStep {
92
+ from: string
93
+ event: string
94
+ to: string
95
+ }
96
+
97
+ export interface Finding {
98
+ code: string
99
+ severity: 'error' | 'warning'
100
+ message: string
101
+ path?: PathStep[]
102
+ evidence?: Record<string, unknown>
103
+ }
104
+
105
+ export interface CheckResult {
106
+ id: string
107
+ name: string
108
+ status: 'pass' | 'fail' | 'skip'
109
+ detail: string
110
+ findings: Finding[]
111
+ }
112
+
113
+ export interface VerificationReport {
114
+ ok: boolean
115
+ schemaVersion: 1
116
+ modelHash: string
117
+ summary: {
118
+ states: number
119
+ transitions: number
120
+ errors: number
121
+ warnings: number
122
+ checksRun: number
123
+ truncated?: boolean
124
+ }
125
+ checks: CheckResult[]
126
+ }
127
+
128
+ export interface RuntimeState {
129
+ state: string
130
+ vars: Record<string, VarValue>
131
+ }
132
+
133
+ type NormalizedOptions = Required<Pick<VerificationOptions, 'maxStates' | 'maxPermutationEvents'>>
134
+
135
+ const DEFAULT_MAX_STATES = 10_000
136
+ const DEFAULT_MAX_PERMUTATION_EVENTS = 5
137
+
138
+ function stableStringify(value: unknown): string {
139
+ if (value === null || typeof value !== 'object') return JSON.stringify(value)
140
+ if (Array.isArray(value)) return '[' + value.map((item) => stableStringify(item)).join(',') + ']'
141
+ const record = value as Record<string, unknown>
142
+ return '{' + Object.keys(record).sort().map((key) => JSON.stringify(key) + ':' + stableStringify(record[key])).join(',') + '}'
143
+ }
144
+
145
+ export function modelHash(model: LogicModelV1): string {
146
+ return createHash('sha256').update(stableStringify(model)).digest('hex')
147
+ }
148
+
149
+ export function validateModel(input: unknown): { ok: true; model: LogicModelV1 } | { ok: false; errors: string[] } {
150
+ const errors: string[] = []
151
+ const bad = (path: string, message: string): void => {
152
+ errors.push(path + ': ' + message)
153
+ }
154
+ if (typeof input !== 'object' || input === null || Array.isArray(input)) {
155
+ return { ok: false, errors: ['model: must be an object'] }
156
+ }
157
+ const root = input as Record<string, unknown>
158
+ if (root.schemaVersion !== 1) bad('schemaVersion', 'must be 1')
159
+ if (typeof root.init !== 'string' || root.init.length === 0) bad('init', 'must be a non-empty string')
160
+ if (!Array.isArray(root.states) || root.states.length === 0) {
161
+ bad('states', 'must be a non-empty array')
162
+ } else {
163
+ const seen = new Set<string>()
164
+ root.states.forEach((entry, index) => {
165
+ if (typeof entry !== 'object' || entry === null || Array.isArray(entry)) {
166
+ bad('states[' + index + ']', 'must be an object')
167
+ return
168
+ }
169
+ const state = entry as Record<string, unknown>
170
+ if (typeof state.id !== 'string' || state.id.length === 0) bad('states[' + index + '].id', 'must be a non-empty string')
171
+ else if (seen.has(state.id)) bad('states[' + index + '].id', 'duplicate state id ' + state.id)
172
+ else seen.add(state.id)
173
+ if (state.terminal !== undefined && typeof state.terminal !== 'boolean') bad('states[' + index + '].terminal', 'must be a boolean')
174
+ })
175
+ if (typeof root.init === 'string' && root.init.length > 0 && !seen.has(root.init)) bad('init', 'must name a declared state')
176
+ }
177
+ if (!Array.isArray(root.transitions)) {
178
+ bad('transitions', 'must be an array')
179
+ } else {
180
+ const stateIds = new Set<string>(Array.isArray(root.states) ? (root.states as StateSpec[]).map((state) => state.id) : [])
181
+ root.transitions.forEach((entry, index) => {
182
+ const path = 'transitions[' + index + ']'
183
+ if (typeof entry !== 'object' || entry === null || Array.isArray(entry)) {
184
+ bad(path, 'must be an object')
185
+ return
186
+ }
187
+ const transition = entry as Record<string, unknown>
188
+ if (typeof transition.from !== 'string' || transition.from.length === 0) bad(path + '.from', 'must be a non-empty string')
189
+ else if (!stateIds.has(transition.from)) bad(path + '.from', 'unknown state ' + transition.from)
190
+ if (typeof transition.event !== 'string' || transition.event.length === 0) bad(path + '.event', 'must be a non-empty string')
191
+ if (typeof transition.to !== 'string' || transition.to.length === 0) bad(path + '.to', 'must be a non-empty string')
192
+ else if (!stateIds.has(transition.to)) bad(path + '.to', 'unknown state ' + transition.to)
193
+ if (transition.guard !== undefined) validateGuard(transition.guard, path + '.guard', errors, bad)
194
+ if (transition.updates !== undefined) {
195
+ if (!Array.isArray(transition.updates)) bad(path + '.updates', 'must be an array')
196
+ else transition.updates.forEach((update, updateIndex) => {
197
+ const updatePath = path + '.updates[' + updateIndex + ']'
198
+ if (typeof update !== 'object' || update === null || Array.isArray(update)) {
199
+ bad(updatePath, 'must be an object')
200
+ return
201
+ }
202
+ const record = update as Record<string, unknown>
203
+ if (typeof record.variable !== 'string' || record.variable.length === 0) bad(updatePath + '.variable', 'must be a non-empty string')
204
+ if (record.op !== 'set' && record.op !== 'inc' && record.op !== 'dec') bad(updatePath + '.op', "must be 'set', 'inc', or 'dec'")
205
+ if (record.value !== undefined && typeof record.value !== 'number') bad(updatePath + '.value', 'must be a number')
206
+ })
207
+ }
208
+ })
209
+ }
210
+ const variableNames = new Set<string>()
211
+ if (root.variables !== undefined) {
212
+ if (!Array.isArray(root.variables)) bad('variables', 'must be an array')
213
+ else root.variables.forEach((entry, index) => {
214
+ const path = 'variables[' + index + ']'
215
+ if (typeof entry !== 'object' || entry === null || Array.isArray(entry)) {
216
+ bad(path, 'must be an object')
217
+ return
218
+ }
219
+ const variable = entry as Record<string, unknown>
220
+ if (typeof variable.name !== 'string' || variable.name.length === 0) bad(path + '.name', 'must be a non-empty string')
221
+ else if (variableNames.has(variable.name)) bad(path + '.name', 'duplicate variable ' + variable.name)
222
+ else variableNames.add(variable.name)
223
+ if (variable.kind !== 'integer' && variable.kind !== 'boolean') bad(path + '.kind', "must be 'integer' or 'boolean'")
224
+ const expected = variable.kind === 'boolean' ? 'boolean' : 'number'
225
+ if (typeof variable.init !== expected) bad(path + '.init', 'must be a ' + expected)
226
+ if (variable.min !== undefined && typeof variable.min !== 'number') bad(path + '.min', 'must be a number')
227
+ if (variable.max !== undefined && typeof variable.max !== 'number') bad(path + '.max', 'must be a number')
228
+ if (typeof variable.min === 'number' && typeof variable.max === 'number' && variable.min > variable.max) bad(path + '.max', 'must be >= min')
229
+ if (variable.kind === 'integer' && typeof variable.init === 'number') {
230
+ if (typeof variable.min === 'number' && variable.init < variable.min) bad(path + '.init', 'must be >= min')
231
+ if (typeof variable.max === 'number' && variable.init > variable.max) bad(path + '.init', 'must be <= max')
232
+ }
233
+ })
234
+ }
235
+ const validateVariableRef = (name: unknown, path: string): void => {
236
+ if (typeof name !== 'string' || name.length === 0) bad(path, 'must be a non-empty string')
237
+ else if (!variableNames.has(name)) bad(path, 'references unknown variable ' + name)
238
+ }
239
+ if (root.invariants !== undefined) {
240
+ if (!Array.isArray(root.invariants)) bad('invariants', 'must be an array')
241
+ else root.invariants.forEach((entry, index) => {
242
+ const path = 'invariants[' + index + ']'
243
+ if (typeof entry !== 'object' || entry === null || Array.isArray(entry)) {
244
+ bad(path, 'must be an object')
245
+ return
246
+ }
247
+ const invariant = entry as Record<string, unknown>
248
+ if (typeof invariant.id !== 'string' || invariant.id.length === 0) bad(path + '.id', 'must be a non-empty string')
249
+ if (typeof invariant.description !== 'string') bad(path + '.description', 'must be a string')
250
+ if (invariant.kind === 'never-states') {
251
+ if (!Array.isArray(invariant.states) || invariant.states.length === 0) bad(path + '.states', 'must be a non-empty array')
252
+ else invariant.states.forEach((state, stateIndex) => {
253
+ if (typeof state !== 'string' || state.length === 0) bad(path + '.states[' + stateIndex + ']', 'must be a non-empty string')
254
+ })
255
+ } else if (invariant.kind === 'var-in-range') {
256
+ validateVariableRef(invariant.variable, path + '.variable')
257
+ if (invariant.min !== undefined && typeof invariant.min !== 'number') bad(path + '.min', 'must be a number')
258
+ if (invariant.max !== undefined && typeof invariant.max !== 'number') bad(path + '.max', 'must be a number')
259
+ } else if (invariant.kind === 'event-before-state') {
260
+ if (typeof invariant.event !== 'string' || invariant.event.length === 0) bad(path + '.event', 'must be a non-empty string')
261
+ if (typeof invariant.state !== 'string' || invariant.state.length === 0) bad(path + '.state', 'must be a non-empty string')
262
+ } else {
263
+ bad(path + '.kind', 'unknown invariant kind')
264
+ }
265
+ })
266
+ }
267
+ if (root.concurrentPairs !== undefined) {
268
+ if (!Array.isArray(root.concurrentPairs)) bad('concurrentPairs', 'must be an array')
269
+ else root.concurrentPairs.forEach((entry, index) => {
270
+ const path = 'concurrentPairs[' + index + ']'
271
+ if (!Array.isArray(entry) || entry.length !== 2 || typeof entry[0] !== 'string' || typeof entry[1] !== 'string') {
272
+ bad(path, 'must be a [event, event] string pair')
273
+ }
274
+ })
275
+ }
276
+ if (root.boundaryChecks !== undefined) {
277
+ if (!Array.isArray(root.boundaryChecks)) bad('boundaryChecks', 'must be an array')
278
+ else root.boundaryChecks.forEach((entry, index) => {
279
+ const path = 'boundaryChecks[' + index + ']'
280
+ if (typeof entry !== 'object' || entry === null || Array.isArray(entry)) {
281
+ bad(path, 'must be an object')
282
+ return
283
+ }
284
+ const check = entry as Record<string, unknown>
285
+ validateVariableRef(check.variable, path + '.variable')
286
+ if (!Array.isArray(check.values) || check.values.some((value) => typeof value !== 'number')) bad(path + '.values', 'must be an array of numbers')
287
+ })
288
+ }
289
+ if (root.resourcePairs !== undefined) {
290
+ if (!Array.isArray(root.resourcePairs)) bad('resourcePairs', 'must be an array')
291
+ else root.resourcePairs.forEach((entry, index) => {
292
+ const path = 'resourcePairs[' + index + ']'
293
+ if (typeof entry !== 'object' || entry === null || Array.isArray(entry)) {
294
+ bad(path, 'must be an object')
295
+ return
296
+ }
297
+ const pair = entry as Record<string, unknown>
298
+ if (typeof pair.resource !== 'string' || pair.resource.length === 0) bad(path + '.resource', 'must be a non-empty string')
299
+ if (typeof pair.acquireEvent !== 'string' || pair.acquireEvent.length === 0) bad(path + '.acquireEvent', 'must be a non-empty string')
300
+ if (typeof pair.releaseEvent !== 'string' || pair.releaseEvent.length === 0) bad(path + '.releaseEvent', 'must be a non-empty string')
301
+ if (pair.failEvent !== undefined && typeof pair.failEvent !== 'string') bad(path + '.failEvent', 'must be a string')
302
+ })
303
+ }
304
+ // Guard/update variable references were validated structurally; re-walk for references.
305
+ if (Array.isArray(root.transitions)) {
306
+ for (const entry of root.transitions as TransitionSpec[]) {
307
+ walkGuardReferences(entry.guard, variableNames, errors, bad)
308
+ for (const update of entry.updates ?? []) {
309
+ if (!variableNames.has(update.variable)) bad('transitions.updates', 'references unknown variable ' + update.variable)
310
+ if (update.op !== 'set' && update.value !== undefined && update.value === 0) {
311
+ // zero delta is harmless; no finding
312
+ }
313
+ }
314
+ }
315
+ }
316
+ if (errors.length > 0) return { ok: false, errors }
317
+ return { ok: true, model: root as unknown as LogicModelV1 }
318
+ }
319
+
320
+ function validateGuard(input: unknown, path: string, errors: string[], bad: (path: string, message: string) => void): void {
321
+ if (typeof input !== 'object' || input === null || Array.isArray(input)) {
322
+ bad(path, 'must be an object')
323
+ return
324
+ }
325
+ const guard = input as Record<string, unknown>
326
+ if (guard.variable !== undefined) {
327
+ if (typeof guard.variable !== 'string' || guard.variable.length === 0) bad(path + '.variable', 'must be a non-empty string')
328
+ if (guard.op !== '==' && guard.op !== '!=' && guard.op !== '<' && guard.op !== '<=' && guard.op !== '>' && guard.op !== '>=') bad(path + '.op', 'must be a comparison operator')
329
+ if (typeof guard.value !== 'number' && typeof guard.value !== 'boolean') bad(path + '.value', 'must be a number or boolean')
330
+ return
331
+ }
332
+ if (guard.all !== undefined) {
333
+ if (!Array.isArray(guard.all)) bad(path + '.all', 'must be an array')
334
+ else guard.all.forEach((child, index) => validateGuard(child, path + '.all[' + index + ']', errors, bad))
335
+ return
336
+ }
337
+ if (guard.any !== undefined) {
338
+ if (!Array.isArray(guard.any)) bad(path + '.any', 'must be an array')
339
+ else guard.any.forEach((child, index) => validateGuard(child, path + '.any[' + index + ']', errors, bad))
340
+ return
341
+ }
342
+ if (guard.not !== undefined) {
343
+ validateGuard(guard.not, path + '.not', errors, bad)
344
+ return
345
+ }
346
+ bad(path, 'must be a leaf ({ variable, op, value }), { all }, { any }, or { not }')
347
+ }
348
+
349
+ function walkGuardReferences(guard: GuardNode | undefined, variableNames: Set<string>, errors: string[], bad: (path: string, message: string) => void): void {
350
+ if (guard === undefined) return
351
+ if ('variable' in guard) {
352
+ if (!variableNames.has(guard.variable)) bad('guard', 'references unknown variable ' + guard.variable)
353
+ return
354
+ }
355
+ if ('all' in guard) {
356
+ for (const child of guard.all) walkGuardReferences(child, variableNames, errors, bad)
357
+ return
358
+ }
359
+ if ('any' in guard) {
360
+ for (const child of guard.any) walkGuardReferences(child, variableNames, errors, bad)
361
+ return
362
+ }
363
+ if ('not' in guard) walkGuardReferences(guard.not, variableNames, errors, bad)
364
+ }
365
+
366
+ // ---------------------------------------------------------------------------
367
+ // execution
368
+ // ---------------------------------------------------------------------------
369
+
370
+ function isLeafGuard(guard: GuardNode): guard is LeafGuard {
371
+ return 'variable' in guard
372
+ }
373
+
374
+ export function guardVariables(guard: GuardNode | undefined): string[] {
375
+ if (guard === undefined) return []
376
+ if (isLeafGuard(guard)) return [guard.variable]
377
+ if ('all' in guard) return guard.all.flatMap((child) => guardVariables(child))
378
+ if ('any' in guard) return guard.any.flatMap((child) => guardVariables(child))
379
+ if ('not' in guard) return guardVariables(guard.not)
380
+ return []
381
+ }
382
+
383
+ function evalGuard(guard: GuardNode, vars: Record<string, VarValue>): boolean {
384
+ if (isLeafGuard(guard)) {
385
+ const actual = vars[guard.variable]
386
+ switch (guard.op) {
387
+ case '==': return actual === guard.value
388
+ case '!=': return actual !== guard.value
389
+ case '<': return (actual as number) < (guard.value as number)
390
+ case '<=': return (actual as number) <= (guard.value as number)
391
+ case '>': return (actual as number) > (guard.value as number)
392
+ case '>=': return (actual as number) >= (guard.value as number)
393
+ }
394
+ }
395
+ if ('all' in guard) return guard.all.every((child) => evalGuard(child, vars))
396
+ if ('any' in guard) return guard.any.some((child) => evalGuard(child, vars))
397
+ if ('not' in guard) return !evalGuard(guard.not, vars)
398
+ return false
399
+ }
400
+
401
+ function initialState(model: LogicModelV1): RuntimeState {
402
+ const vars: Record<string, VarValue> = {}
403
+ for (const variable of model.variables ?? []) vars[variable.name] = variable.init
404
+ return { state: model.init, vars }
405
+ }
406
+
407
+ function runtimeKey(runtime: RuntimeState): string {
408
+ return runtime.state + '|' + stableStringify(runtime.vars)
409
+ }
410
+
411
+ function applyUpdates(model: LogicModelV1, transition: TransitionSpec, runtime: RuntimeState): RuntimeState {
412
+ const vars = { ...runtime.vars }
413
+ for (const update of transition.updates ?? []) {
414
+ const current = vars[update.variable]
415
+ if (update.op === 'set') vars[update.variable] = update.value ?? 0
416
+ else if (update.op === 'inc') vars[update.variable] = ((typeof current === 'number' ? current : 0) + (update.value ?? 1))
417
+ else vars[update.variable] = ((typeof current === 'number' ? current : 0) - (update.value ?? 1))
418
+ }
419
+ return { state: transition.to, vars }
420
+ }
421
+
422
+ function groupTransitions(model: LogicModelV1): Map<string, TransitionSpec[]> {
423
+ const groups = new Map<string, TransitionSpec[]>()
424
+ for (const transition of model.transitions) {
425
+ const key = transition.from + '|' + transition.event
426
+ const list = groups.get(key) ?? []
427
+ list.push(transition)
428
+ groups.set(key, list)
429
+ }
430
+ return groups
431
+ }
432
+
433
+ function applicableTransitions(group: TransitionSpec[], runtime: RuntimeState): TransitionSpec[] {
434
+ const guarded: TransitionSpec[] = []
435
+ const unguarded: TransitionSpec[] = []
436
+ for (const transition of group) {
437
+ if (transition.guard === undefined) unguarded.push(transition)
438
+ else guarded.push(transition)
439
+ }
440
+ const matched = guarded.filter((transition) => evalGuard(transition.guard!, runtime.vars))
441
+ if (matched.length > 0) return matched
442
+ return unguarded
443
+ }
444
+
445
+ function stepRuntime(model: LogicModelV1, runtime: RuntimeState, event: string): RuntimeState[] {
446
+ const group = groupTransitions(model).get(runtime.state + '|' + event)
447
+ if (group === undefined || group.length === 0) return []
448
+ const seen = new Set<string>()
449
+ const outcomes: RuntimeState[] = []
450
+ for (const transition of applicableTransitions(group, runtime)) {
451
+ const next = applyUpdates(model, transition, runtime)
452
+ const key = runtimeKey(next)
453
+ if (!seen.has(key)) {
454
+ seen.add(key)
455
+ outcomes.push(next)
456
+ }
457
+ }
458
+ return outcomes
459
+ }
460
+
461
+ interface Exploration {
462
+ reachable: RuntimeState[]
463
+ reachableKeys: Set<string>
464
+ truncated: boolean
465
+ initialState: RuntimeState
466
+ }
467
+
468
+ function explore(model: LogicModelV1, options: NormalizedOptions): Exploration {
469
+ const init = initialState(model)
470
+ const reachable: RuntimeState[] = []
471
+ const reachableKeys = new Set<string>()
472
+ const queue: RuntimeState[] = [init]
473
+ let truncated = false
474
+ while (queue.length > 0) {
475
+ const runtime = queue.shift()!
476
+ const key = runtimeKey(runtime)
477
+ if (reachableKeys.has(key)) continue
478
+ reachableKeys.add(key)
479
+ reachable.push(runtime)
480
+ if (reachable.length > options.maxStates) {
481
+ truncated = true
482
+ break
483
+ }
484
+ for (const event of allEvents(model)) {
485
+ for (const next of stepRuntime(model, runtime, event)) {
486
+ if (!reachableKeys.has(runtimeKey(next))) queue.push(next)
487
+ }
488
+ }
489
+ }
490
+ return { reachable, reachableKeys, truncated, initialState: init }
491
+ }
492
+
493
+ function allEvents(model: LogicModelV1): string[] {
494
+ return [...new Set(model.transitions.map((transition) => transition.event))].sort()
495
+ }
496
+
497
+ function stateById(model: LogicModelV1, id: string): StateSpec | undefined {
498
+ return model.states.find((state) => state.id === id)
499
+ }
500
+
501
+ function isTerminal(model: LogicModelV1, state: string): boolean {
502
+ return stateById(model, state)?.terminal === true
503
+ }
504
+
505
+ function uniqueTargets(transitions: TransitionSpec[]): string[] {
506
+ return [...new Set(transitions.map((transition) => transition.to))]
507
+ }
508
+
509
+ function checkResult(id: string, name: string, findings: Finding[], detail: string): CheckResult {
510
+ const errors = findings.filter((finding) => finding.severity === 'error').length
511
+ const warnings = findings.filter((finding) => finding.severity === 'warning').length
512
+ return {
513
+ id,
514
+ name,
515
+ status: findings.length === 0 ? 'pass' : 'fail',
516
+ detail: detail + (errors > 0 ? ' (' + errors + ' errors' + (warnings > 0 ? ', ' + warnings + ' warnings' : '') + ')' : warnings > 0 ? ' (' + warnings + ' warnings)' : ''),
517
+ findings,
518
+ }
519
+ }
520
+
521
+ // ---------------------------------------------------------------------------
522
+ // S1-S7
523
+ // ---------------------------------------------------------------------------
524
+
525
+ function S1_reachability(model: LogicModelV1, exploration: Exploration): CheckResult {
526
+ const reached = new Set(exploration.reachable.map((runtime) => runtime.state))
527
+ const unreachable = model.states.filter((state) => !reached.has(state.id)).map((state) => state.id)
528
+ const findings: Finding[] = unreachable.map((state) => ({
529
+ code: 'S1_UNREACHABLE_STATE',
530
+ severity: 'warning',
531
+ message: 'State ' + state + ' is not reachable from init.',
532
+ }))
533
+ return checkResult('S1', 'Reachability', findings, unreachable.length === 0 ? 'All states reachable' : 'Unreachable states: ' + unreachable.join(', '))
534
+ }
535
+
536
+ function S2_deadlock(model: LogicModelV1): CheckResult {
537
+ const outgoing = new Set(model.transitions.map((transition) => transition.from))
538
+ const findings: Finding[] = []
539
+ for (const state of model.states) {
540
+ if (state.terminal === true) continue
541
+ if (!outgoing.has(state.id)) {
542
+ findings.push({
543
+ code: 'S2_NO_TRANSITIONS',
544
+ severity: 'error',
545
+ message: 'Non-terminal state ' + state.id + ' has no outgoing transitions.',
546
+ })
547
+ }
548
+ }
549
+ return checkResult('S2', 'Deadlock', findings, findings.length === 0 ? 'No deadlocks' : 'Deadlocks: ' + findings.map((finding) => finding.message).join('; '))
550
+ }
551
+
552
+ function sccs(model: LogicModelV1): string[][] {
553
+ const states = model.states.map((state) => state.id)
554
+ const edges = new Map<string, string[]>()
555
+ for (const state of states) edges.set(state, [])
556
+ for (const transition of model.transitions) {
557
+ const list = edges.get(transition.from)
558
+ if (list !== undefined) list.push(transition.to)
559
+ }
560
+ let index = 0
561
+ const indices = new Map<string, number>()
562
+ const low = new Map<string, number>()
563
+ const onStack = new Set<string>()
564
+ const stack: string[] = []
565
+ const components: string[][] = []
566
+ const visit = (node: string): void => {
567
+ indices.set(node, index)
568
+ low.set(node, index)
569
+ index += 1
570
+ stack.push(node)
571
+ onStack.add(node)
572
+ for (const next of edges.get(node) ?? []) {
573
+ if (!indices.has(next)) {
574
+ visit(next)
575
+ low.set(node, Math.min(low.get(node)!, low.get(next)!))
576
+ } else if (onStack.has(next)) {
577
+ low.set(node, Math.min(low.get(node)!, indices.get(next)!))
578
+ }
579
+ }
580
+ if (low.get(node) === indices.get(node)) {
581
+ const component: string[] = []
582
+ while (stack.length > 0) {
583
+ const member = stack.pop()!
584
+ onStack.delete(member)
585
+ component.push(member)
586
+ if (member === node) break
587
+ }
588
+ components.push(component)
589
+ }
590
+ }
591
+ for (const state of states) {
592
+ if (!indices.has(state)) visit(state)
593
+ }
594
+ return components
595
+ }
596
+
597
+ function S3_liveness(model: LogicModelV1): CheckResult {
598
+ const components = sccs(model)
599
+ const findings: Finding[] = []
600
+ for (const component of components) {
601
+ const componentSet = new Set(component)
602
+ const internalEdges = model.transitions.filter((transition) => componentSet.has(transition.from) && componentSet.has(transition.to))
603
+ const escapingEdges = model.transitions.filter((transition) => componentSet.has(transition.from) && !componentSet.has(transition.to))
604
+ if (internalEdges.length === 0 || escapingEdges.length > 0) continue
605
+ if (component.some((state) => isTerminal(model, state))) continue
606
+ findings.push({
607
+ code: 'S3_CLOSED_SCC',
608
+ severity: 'error',
609
+ message: 'Closed SCC has no exit and contains no terminal state: ' + component.sort().join(', '),
610
+ evidence: { states: component.sort(), transitions: internalEdges.length },
611
+ })
612
+ }
613
+ return checkResult('S3', 'Liveness', findings, findings.length === 0 ? 'No harmful closed SCCs' : 'Closed SCCs: ' + findings.length)
614
+ }
615
+
616
+ function assignmentsFor(model: LogicModelV1, variables: string[]): Array<Record<string, VarValue>> | null {
617
+ const specs = (model.variables ?? []).filter((variable) => variables.includes(variable.name))
618
+ let combinations: Array<Record<string, VarValue>> = [{}]
619
+ for (const spec of specs) {
620
+ let values: VarValue[]
621
+ if (spec.kind === 'boolean') values = [false, true]
622
+ else {
623
+ const constants = collectGuardConstants(model).filter((value) => guardLeaves(model).some((leaf) => leaf.variable === spec.name && typeof leaf.value === 'number' && leaf.value === value))
624
+ if (spec.min !== undefined && spec.max !== undefined) {
625
+ values = []
626
+ for (let value = spec.min; value <= spec.max && value <= spec.min + 200; value++) values.push(value)
627
+ } else {
628
+ const set = new Set<number>([-1, 0, 1, ...constants])
629
+ values = [...set].sort((a, b) => a - b)
630
+ }
631
+ }
632
+ const next: Array<Record<string, VarValue>> = []
633
+ for (const assignment of combinations) {
634
+ for (const value of values) {
635
+ next.push({ ...assignment, [spec.name]: value })
636
+ if (next.length > 10_000) return null
637
+ }
638
+ }
639
+ combinations = next
640
+ }
641
+ return combinations
642
+ }
643
+
644
+ function guardLeaves(model: LogicModelV1): LeafGuard[] {
645
+ const leaves: LeafGuard[] = []
646
+ const visit = (guard: GuardNode): void => {
647
+ if ('variable' in guard) leaves.push(guard)
648
+ else if ('all' in guard) guard.all.forEach(visit)
649
+ else if ('any' in guard) guard.any.forEach(visit)
650
+ else if ('not' in guard) visit(guard.not)
651
+ }
652
+ for (const transition of model.transitions) if (transition.guard !== undefined) visit(transition.guard)
653
+ return leaves
654
+ }
655
+
656
+ function collectGuardConstants(model: LogicModelV1): number[] {
657
+ return guardLeaves(model).filter((leaf) => typeof leaf.value === 'number').map((leaf) => leaf.value as number)
658
+ }
659
+
660
+ interface GuardAnalysis {
661
+ determinism: Finding[]
662
+ completeness: Finding[]
663
+ truncated: boolean
664
+ }
665
+
666
+ function analyzeGuards(model: LogicModelV1): GuardAnalysis {
667
+ const determinism: Finding[] = []
668
+ const completeness: Finding[] = []
669
+ let truncated = false
670
+ const groups = groupTransitions(model)
671
+ for (const [key, group] of groups) {
672
+ const [from, event] = key.split('|')
673
+ const unguarded = group.filter((transition) => transition.guard === undefined)
674
+ const guarded = group.filter((transition) => transition.guard !== undefined)
675
+ if (unguarded.length > 1) {
676
+ determinism.push({
677
+ code: 'S4_AMBIGUOUS_DEFAULT',
678
+ severity: 'error',
679
+ message: from + ' + ' + event + ' has multiple unconditional transitions: ' + uniqueTargets(unguarded).join(', '),
680
+ })
681
+ }
682
+ if (guarded.length === 0) continue
683
+ const variables = [...new Set(group.flatMap((transition) => guardVariables(transition.guard)))]
684
+ const assignments = assignmentsFor(model, variables)
685
+ if (assignments === null) {
686
+ completeness.push({ code: 'S6_UNBOUNDED_DOMAIN', severity: 'warning', message: from + ' + ' + event + ': guard domain too large to enumerate; exhaustive check skipped.' })
687
+ truncated = true
688
+ continue
689
+ }
690
+ const targets = uniqueTargets(group)
691
+ for (const assignment of assignments) {
692
+ const trueBranches = guarded.filter((transition) => evalGuard(transition.guard!, assignment))
693
+ if (trueBranches.length > 1) {
694
+ determinism.push({
695
+ code: 'S4_NONDETERMINISTIC_GUARDS',
696
+ severity: 'error',
697
+ message: from + ' + ' + event + ' has ' + trueBranches.length + ' simultaneously true guards for assignment ' + stableStringify(assignment) + ': ' + uniqueTargets(trueBranches).join(', '),
698
+ evidence: { assignment },
699
+ })
700
+ }
701
+ if (trueBranches.length === 0 && unguarded.length === 0) {
702
+ completeness.push({
703
+ code: 'S6_INCOMPLETE_GUARD',
704
+ severity: 'error',
705
+ message: from + ' + ' + event + ' has no true guard and no default branch for assignment ' + stableStringify(assignment),
706
+ evidence: { assignment, branches: targets },
707
+ })
708
+ }
709
+ if (determinism.length + completeness.length > 200) {
710
+ truncated = true
711
+ break
712
+ }
713
+ }
714
+ if (truncated) break
715
+ }
716
+ return { determinism, completeness, truncated }
717
+ }
718
+
719
+ function S4_determinism(model: LogicModelV1): CheckResult {
720
+ const analysis = analyzeGuards(model)
721
+ return checkResult('S4', 'Determinism', analysis.determinism, analysis.determinism.length === 0 ? 'Deterministic' : 'Nondeterminism findings: ' + analysis.determinism.length)
722
+ }
723
+
724
+ function S6_guardCompleteness(model: LogicModelV1): CheckResult {
725
+ const analysis = analyzeGuards(model)
726
+ return checkResult('S6', 'Guard Completeness', analysis.completeness, analysis.completeness.length === 0 ? 'All guard branches defined' : 'Guard findings: ' + analysis.completeness.length)
727
+ }
728
+
729
+ function S5_eventCompleteness(model: LogicModelV1): CheckResult {
730
+ const events = allEvents(model)
731
+ const findings: Finding[] = []
732
+ for (const state of model.states) {
733
+ if (state.terminal === true) continue
734
+ const handled = new Set(model.transitions.filter((transition) => transition.from === state.id).map((transition) => transition.event))
735
+ for (const event of events) {
736
+ if (!handled.has(event)) {
737
+ findings.push({
738
+ code: 'S5_UNHANDLED_EVENT',
739
+ severity: 'warning',
740
+ message: state.id + ' silently ignores event ' + event,
741
+ })
742
+ }
743
+ }
744
+ }
745
+ return checkResult('S5', 'Event Completeness', findings, findings.length === 0 ? 'All states handle all relevant events' : findings.length + ' unhandled (state, event) pairs')
746
+ }
747
+
748
+ interface InvariantViolation {
749
+ invariant: InvariantSpec
750
+ path: PathStep[]
751
+ reason: string
752
+ }
753
+
754
+ function invariantHolds(invariant: InvariantSpec, runtime: RuntimeState): boolean {
755
+ if (invariant.kind === 'never-states') return !invariant.states.includes(runtime.state)
756
+ if (invariant.kind === 'var-in-range') {
757
+ const value = runtime.vars[invariant.variable]
758
+ if (typeof value !== 'number') return false
759
+ if (invariant.min !== undefined && value < invariant.min) return false
760
+ if (invariant.max !== undefined && value > invariant.max) return false
761
+ return true
762
+ }
763
+ return true
764
+ }
765
+
766
+ function shortestViolationForInvariant(model: LogicModelV1, options: NormalizedOptions, invariant: InvariantSpec): InvariantViolation | undefined {
767
+ if (invariant.kind === 'event-before-state') {
768
+ return shortestEventBeforeStateViolation(model, options, invariant)
769
+ }
770
+ const init = initialState(model)
771
+ if (!invariantHolds(invariant, init)) {
772
+ return { invariant, path: [], reason: 'Initial state violates the invariant.' }
773
+ }
774
+ const visited = new Set<string>([runtimeKey(init)])
775
+ const queue: Array<{ runtime: RuntimeState; path: PathStep[] }> = [{ runtime: init, path: [] }]
776
+ let steps = 0
777
+ while (queue.length > 0) {
778
+ const entry = queue.shift()!
779
+ if (++steps > options.maxStates) break
780
+ for (const event of allEvents(model)) {
781
+ for (const next of stepRuntime(model, entry.runtime, event)) {
782
+ const key = runtimeKey(next)
783
+ if (visited.has(key)) continue
784
+ const path = [...entry.path, { from: entry.runtime.state, event, to: next.state }]
785
+ if (!invariantHolds(invariant, next)) {
786
+ return { invariant, path, reason: 'Invariant violated after ' + path.length + ' events.' }
787
+ }
788
+ visited.add(key)
789
+ queue.push({ runtime: next, path })
790
+ }
791
+ }
792
+ }
793
+ return undefined
794
+ }
795
+
796
+ function shortestEventBeforeStateViolation(model: LogicModelV1, options: NormalizedOptions, invariant: Extract<InvariantSpec, { kind: 'event-before-state' }>): InvariantViolation | undefined {
797
+ const init = initialState(model)
798
+ const seenInitially = false
799
+ if (init.state === invariant.state && !seenInitially) {
800
+ return { invariant, path: [], reason: 'Target state ' + invariant.state + ' is initial and the required event ' + invariant.event + ' has not occurred.' }
801
+ }
802
+ const key = (runtime: RuntimeState, seen: boolean): string => runtimeKey(runtime) + '|' + (seen ? '1' : '0')
803
+ const visited = new Set<string>([key(init, false)])
804
+ const queue: Array<{ runtime: RuntimeState; seen: boolean; path: PathStep[] }> = [{ runtime: init, seen: false, path: [] }]
805
+ let steps = 0
806
+ while (queue.length > 0) {
807
+ const entry = queue.shift()!
808
+ if (++steps > options.maxStates) break
809
+ for (const event of allEvents(model)) {
810
+ for (const next of stepRuntime(model, entry.runtime, event)) {
811
+ const seen = entry.seen || event === invariant.event
812
+ const nextKey = key(next, seen)
813
+ if (visited.has(nextKey)) continue
814
+ const path = [...entry.path, { from: entry.runtime.state, event, to: next.state }]
815
+ if (next.state === invariant.state && !seen) {
816
+ return { invariant, path, reason: 'Target state ' + invariant.state + ' is reachable without event ' + invariant.event + ' in ' + path.length + ' steps.' }
817
+ }
818
+ visited.add(nextKey)
819
+ queue.push({ runtime: next, seen, path })
820
+ }
821
+ }
822
+ }
823
+ return undefined
824
+ }
825
+
826
+ function runInvariants(model: LogicModelV1, options: NormalizedOptions): InvariantViolation[] {
827
+ const violations: InvariantViolation[] = []
828
+ for (const invariant of model.invariants ?? []) {
829
+ const violation = shortestViolationForInvariant(model, options, invariant)
830
+ if (violation !== undefined) violations.push(violation)
831
+ }
832
+ return violations
833
+ }
834
+
835
+ function S7_invariants(model: LogicModelV1, options: NormalizedOptions): CheckResult {
836
+ const violations = runInvariants(model, options)
837
+ const findings: Finding[] = violations.map((violation) => ({
838
+ code: 'S7_INVARIANT_VIOLATION',
839
+ severity: 'error',
840
+ message: 'Invariant "' + violation.invariant.id + '" (' + violation.invariant.description + ') violated: ' + violation.reason,
841
+ path: violation.path,
842
+ evidence: { invariant: violation.invariant },
843
+ }))
844
+ return checkResult('S7', 'Invariant Validity', findings, violations.length === 0 ? 'All invariants hold' : 'Invariant violations: ' + violations.length)
845
+ }
846
+
847
+ // ---------------------------------------------------------------------------
848
+ // A1-A7
849
+ // ---------------------------------------------------------------------------
850
+
851
+ function A1_unexpectedEvents(model: LogicModelV1): CheckResult {
852
+ const events = allEvents(model)
853
+ const findings: Finding[] = []
854
+ for (const state of model.states) {
855
+ if (state.terminal === true) continue
856
+ const handled = new Set(model.transitions.filter((transition) => transition.from === state.id).map((transition) => transition.event))
857
+ for (const event of events) {
858
+ if (!handled.has(event)) {
859
+ findings.push({
860
+ code: 'A1_UNHANDLED_EVENT',
861
+ severity: 'warning',
862
+ message: 'Event ' + event + ' in state ' + state.id + ' has no defined transition (silent ignore).',
863
+ })
864
+ }
865
+ }
866
+ }
867
+ return checkResult('A1', 'Unexpected Event Injection', findings, findings.length === 0 ? 'All event/state combinations defined' : findings.length + ' unhandled combinations')
868
+ }
869
+
870
+ function firstOutcome(runtime: RuntimeState, outcomes: RuntimeState[]): RuntimeState | undefined {
871
+ return outcomes[0]
872
+ }
873
+
874
+ function A2_raceInterleaving(model: LogicModelV1, exploration: Exploration): CheckResult {
875
+ const findings: Finding[] = []
876
+ for (const [e1, e2] of model.concurrentPairs ?? []) {
877
+ for (const runtime of exploration.reachable) {
878
+ const first = firstOutcome(runtime, stepRuntime(model, runtime, e1))
879
+ const second = firstOutcome(runtime, stepRuntime(model, runtime, e2))
880
+ if (first === undefined && second === undefined) continue
881
+ const final12 = first === undefined ? runtime : firstOutcome(first, stepRuntime(model, first, e2)) ?? first
882
+ const final21 = second === undefined ? runtime : firstOutcome(second, stepRuntime(model, second, e1)) ?? second
883
+ if (runtimeKey(final12) !== runtimeKey(final21)) {
884
+ findings.push({
885
+ code: 'A2_ORDER_DEPENDENT',
886
+ severity: 'warning',
887
+ message: 'Events ' + e1 + ' and ' + e2 + ' produce order-dependent outcomes from state ' + runtime.state,
888
+ evidence: { e1ThenE2: final12, e2ThenE1: final21 },
889
+ })
890
+ }
891
+ }
892
+ }
893
+ return checkResult('A2', 'Race Interleaving', findings, findings.length === 0 ? 'No race conditions detected' : 'Order-dependent outcomes: ' + findings.length)
894
+ }
895
+
896
+ function stepSequence(model: LogicModelV1, init: RuntimeState, events: string[]): RuntimeState {
897
+ let runtime = init
898
+ for (const event of events) {
899
+ const next = firstOutcome(runtime, stepRuntime(model, runtime, event))
900
+ if (next === undefined) continue
901
+ runtime = next
902
+ }
903
+ return runtime
904
+ }
905
+
906
+ function permutations<T>(items: T[]): T[][] {
907
+ if (items.length === 0) return [[]]
908
+ const result: T[][] = []
909
+ for (let index = 0; index < items.length; index++) {
910
+ const rest = items.slice(0, index).concat(items.slice(index + 1))
911
+ for (const suffix of permutations(rest)) result.push([items[index], ...suffix])
912
+ }
913
+ return result
914
+ }
915
+
916
+ function A3_orderPermutation(model: LogicModelV1, options: NormalizedOptions): CheckResult {
917
+ const events = allEvents(model).slice(0, options.maxPermutationEvents)
918
+ if (events.length < 2) return checkResult('A3', 'Order Permutation', [], 'Fewer than 2 events — skipped')
919
+ const init = initialState(model)
920
+ const outcomes = new Map<string, string[]>()
921
+ for (const permutation of permutations(events)) {
922
+ const final = stepSequence(model, init, permutation)
923
+ const key = runtimeKey(final)
924
+ const list = outcomes.get(key) ?? []
925
+ if (list.length < 3) list.push(permutation.join(','))
926
+ outcomes.set(key, list)
927
+ }
928
+ if (outcomes.size > 1) {
929
+ const examples = [...outcomes.entries()].map(([key, sequences]) => ({ final: key.split('|')[0], example: sequences[0] }))
930
+ return checkResult('A3', 'Order Permutation', [{
931
+ code: 'A3_ORDER_DEPENDENT',
932
+ severity: 'warning',
933
+ message: 'Same event set produces ' + outcomes.size + ' different outcomes depending on order.',
934
+ evidence: { examples },
935
+ }], 'Order-dependent outcomes: ' + outcomes.size)
936
+ }
937
+ return checkResult('A3', 'Order Permutation', [], 'Order-independent (sampled first ' + events.length + ' events)')
938
+ }
939
+
940
+ function A4_pairSymmetry(model: LogicModelV1): CheckResult {
941
+ const findings: Finding[] = []
942
+ const transitionEvents = new Set(model.transitions.map((transition) => transition.event))
943
+ for (const pair of model.resourcePairs ?? []) {
944
+ const acquireTransitions = model.transitions.filter((transition) => transition.event === pair.acquireEvent)
945
+ const releaseExists = transitionEvents.has(pair.releaseEvent)
946
+ if (acquireTransitions.length === 0 && !releaseExists) continue
947
+ if (acquireTransitions.length > 0 && !releaseExists) {
948
+ findings.push({
949
+ code: 'A4_NO_RELEASE_EVENT',
950
+ severity: 'error',
951
+ message: 'Resource "' + pair.resource + '": acquire event ' + pair.acquireEvent + ' exists but release event ' + pair.releaseEvent + ' is never defined.',
952
+ })
953
+ continue
954
+ }
955
+ for (const acquire of acquireTransitions) {
956
+ // Is any release event reachable in the state graph from the acquired state?
957
+ const reachable = stateGraphReachable(model, acquire.to)
958
+ const releaseReachable = [...reachable].some((state) => model.transitions.some((transition) => transition.from === state && transition.event === pair.releaseEvent))
959
+ if (!releaseReachable) {
960
+ findings.push({
961
+ code: 'A4_NO_RELEASE_REACHABLE',
962
+ severity: 'error',
963
+ message: 'Resource "' + pair.resource + '": after ' + pair.acquireEvent + ' into ' + acquire.to + ', no ' + pair.releaseEvent + ' transition is reachable.',
964
+ evidence: { acquireTransition: acquire },
965
+ })
966
+ continue
967
+ }
968
+ // Path-sensitive probe: track the held flag along state-graph paths.
969
+ const edges = stateGraphEdges(model)
970
+ const visited = new Set<string>()
971
+ const queue: Array<{ state: string; held: boolean; path: PathStep[] }> = [{ state: acquire.to, held: true, path: [] }]
972
+ let steps = 0
973
+ while (queue.length > 0) {
974
+ const entry = queue.shift()!
975
+ if (++steps > 1000) break
976
+ const key = entry.state + '|' + (entry.held ? '1' : '0')
977
+ if (visited.has(key)) continue
978
+ visited.add(key)
979
+ if (entry.held && isTerminal(model, entry.state)) {
980
+ findings.push({
981
+ code: 'A4_TERMINAL_WITH_RESOURCE',
982
+ severity: 'error',
983
+ message: 'Resource "' + pair.resource + '" is still held when entering terminal state ' + entry.state + '.',
984
+ path: entry.path,
985
+ })
986
+ continue
987
+ }
988
+ for (const transition of edges.get(entry.state) ?? []) {
989
+ let held = entry.held
990
+ if (transition.event === pair.releaseEvent) held = false
991
+ else if (transition.event === pair.acquireEvent) {
992
+ if (held) {
993
+ findings.push({
994
+ code: 'A4_REACQUIRE_WITHOUT_RELEASE',
995
+ severity: 'warning',
996
+ message: 'Resource "' + pair.resource + '" is acquired again in state ' + entry.state + ' before ' + pair.releaseEvent + '.',
997
+ path: [...entry.path, { from: entry.state, event: transition.event, to: transition.to }],
998
+ })
999
+ continue
1000
+ }
1001
+ held = true
1002
+ }
1003
+ queue.push({ state: transition.to, held, path: [...entry.path, { from: entry.state, event: transition.event, to: transition.to }] })
1004
+ }
1005
+ }
1006
+ }
1007
+ }
1008
+ return checkResult('A4', 'Pair Symmetry', findings, findings.length === 0 ? 'All pairs balanced' : 'Asymmetric pairs: ' + findings.length)
1009
+ }
1010
+
1011
+ function stateGraphEdges(model: LogicModelV1): Map<string, TransitionSpec[]> {
1012
+ const edges = new Map<string, TransitionSpec[]>()
1013
+ for (const state of model.states) edges.set(state.id, [])
1014
+ for (const transition of model.transitions) edges.get(transition.from)?.push(transition)
1015
+ return edges
1016
+ }
1017
+
1018
+ function stateGraphReachable(model: LogicModelV1, start: string): Set<string> {
1019
+ const edges = new Map<string, string[]>()
1020
+ for (const state of model.states) edges.set(state.id, [])
1021
+ for (const transition of model.transitions) edges.get(transition.from)?.push(transition.to)
1022
+ const visited = new Set<string>()
1023
+ const queue = [start]
1024
+ while (queue.length > 0) {
1025
+ const current = queue.shift()!
1026
+ if (visited.has(current)) continue
1027
+ visited.add(current)
1028
+ for (const next of edges.get(current) ?? []) queue.push(next)
1029
+ }
1030
+ return visited
1031
+ }
1032
+
1033
+ function A5_boundaryBlast(model: LogicModelV1): CheckResult {
1034
+ const findings: Finding[] = []
1035
+ for (const check of model.boundaryChecks ?? []) {
1036
+ const variable = (model.variables ?? []).find((entry) => entry.name === check.variable)
1037
+ if (variable === undefined) continue
1038
+ for (const value of check.values) {
1039
+ if (variable.min !== undefined && value < variable.min) {
1040
+ findings.push({ code: 'A5_OUT_OF_DOMAIN', severity: 'warning', message: 'Boundary value ' + value + ' for ' + check.variable + ' is below min ' + variable.min })
1041
+ }
1042
+ if (variable.max !== undefined && value > variable.max) {
1043
+ findings.push({ code: 'A5_OUT_OF_DOMAIN', severity: 'warning', message: 'Boundary value ' + value + ' for ' + check.variable + ' is above max ' + variable.max })
1044
+ }
1045
+ const assignment = { ...(model.variables ?? []).reduce<Record<string, VarValue>>((result, entry) => { result[entry.name] = entry.init; return result }, {}), [check.variable]: value }
1046
+ for (const [key, group] of groupTransitions(model)) {
1047
+ const [from, event] = key.split('|')
1048
+ if (!group.some((transition) => guardVariables(transition.guard).includes(check.variable))) continue
1049
+ const guarded = group.filter((transition) => transition.guard !== undefined)
1050
+ const unguarded = group.filter((transition) => transition.guard === undefined)
1051
+ const matched = guarded.filter((transition) => evalGuard(transition.guard!, assignment))
1052
+ if (matched.length === 0 && unguarded.length === 0) {
1053
+ findings.push({
1054
+ code: 'A5_GUARD_HOLE',
1055
+ severity: 'warning',
1056
+ message: from + ' + ' + event + ' has no branch for ' + check.variable + '=' + value,
1057
+ evidence: { assignment },
1058
+ })
1059
+ }
1060
+ if (matched.length > 1) {
1061
+ findings.push({
1062
+ code: 'A5_GUARD_OVERLAP',
1063
+ severity: 'warning',
1064
+ message: from + ' + ' + event + ' has ' + matched.length + ' true branches for ' + check.variable + '=' + value,
1065
+ evidence: { assignment },
1066
+ })
1067
+ }
1068
+ }
1069
+ }
1070
+ }
1071
+ return checkResult('A5', 'Boundary Blast', findings, findings.length === 0 ? 'Boundary checks passed' : 'Boundary findings: ' + findings.length)
1072
+ }
1073
+
1074
+ function A6_resourceInjection(model: LogicModelV1): CheckResult {
1075
+ const findings: Finding[] = []
1076
+ for (const pair of model.resourcePairs ?? []) {
1077
+ if (pair.failEvent === undefined) continue
1078
+ const acquireStates = new Set(model.transitions.filter((transition) => transition.event === pair.acquireEvent).map((transition) => transition.from))
1079
+ for (const state of acquireStates) {
1080
+ const handled = model.transitions.some((transition) => transition.from === state && transition.event === pair.failEvent)
1081
+ if (!handled) {
1082
+ findings.push({
1083
+ code: 'A6_NO_FAILURE_HANDLER',
1084
+ severity: 'warning',
1085
+ message: 'State ' + state + ' can ' + pair.acquireEvent + ' for "' + pair.resource + '" but has no ' + pair.failEvent + ' transition.',
1086
+ })
1087
+ }
1088
+ }
1089
+ }
1090
+ return checkResult('A6', 'Resource Injection', findings, findings.length === 0 ? 'No resource vulnerabilities detected' : 'Resource failure paths missing: ' + findings.length)
1091
+ }
1092
+
1093
+ function A7_shortestViolations(model: LogicModelV1, options: NormalizedOptions): CheckResult {
1094
+ const violations = runInvariants(model, options)
1095
+ const findings: Finding[] = violations.map((violation) => ({
1096
+ code: 'A7_SHORTEST_COUNTEREXAMPLE',
1097
+ severity: 'warning',
1098
+ message: 'Invariant "' + violation.invariant.id + '" shortest violating path length: ' + violation.path.length + (violation.path.length === 0 ? ' (initial state)' : ''),
1099
+ path: violation.path,
1100
+ evidence: { invariant: violation.invariant.id },
1101
+ }))
1102
+ return checkResult('A7', 'Minimal Counter-Example', findings, violations.length === 0 ? 'All invariants hold for all reachable paths' : 'Violated invariants: ' + findings.length)
1103
+ }
1104
+
1105
+ // ---------------------------------------------------------------------------
1106
+ // main entry
1107
+ // ---------------------------------------------------------------------------
1108
+
1109
+ export function runVerification(input: unknown, options: VerificationOptions = {}): VerificationReport {
1110
+ const normalized: NormalizedOptions = {
1111
+ maxStates: options.maxStates ?? DEFAULT_MAX_STATES,
1112
+ maxPermutationEvents: options.maxPermutationEvents ?? DEFAULT_MAX_PERMUTATION_EVENTS,
1113
+ }
1114
+ const validation = validateModel(input)
1115
+ if (!validation.ok) {
1116
+ return {
1117
+ ok: false,
1118
+ schemaVersion: 1,
1119
+ modelHash: '',
1120
+ summary: { states: 0, transitions: 0, errors: validation.errors.length, warnings: 0, checksRun: 0 },
1121
+ checks: [{
1122
+ id: 'MODEL',
1123
+ name: 'Model Validation',
1124
+ status: 'fail',
1125
+ detail: 'Model schema validation failed: ' + validation.errors.length + ' errors',
1126
+ findings: validation.errors.map((message) => ({ code: 'MODEL_INVALID', severity: 'error', message })),
1127
+ }],
1128
+ }
1129
+ }
1130
+ const model = validation.model
1131
+ const exploration = explore(model, normalized)
1132
+ const checks: CheckResult[] = [
1133
+ S1_reachability(model, exploration),
1134
+ S2_deadlock(model),
1135
+ S3_liveness(model),
1136
+ S4_determinism(model),
1137
+ S5_eventCompleteness(model),
1138
+ S6_guardCompleteness(model),
1139
+ S7_invariants(model, normalized),
1140
+ A1_unexpectedEvents(model),
1141
+ A2_raceInterleaving(model, exploration),
1142
+ A3_orderPermutation(model, normalized),
1143
+ A4_pairSymmetry(model),
1144
+ A5_boundaryBlast(model),
1145
+ A6_resourceInjection(model),
1146
+ A7_shortestViolations(model, normalized),
1147
+ ]
1148
+ const errors = checks.reduce((sum, check) => sum + check.findings.filter((finding) => finding.severity === 'error').length, 0)
1149
+ const warnings = checks.reduce((sum, check) => sum + check.findings.filter((finding) => finding.severity === 'warning').length, 0)
1150
+ return {
1151
+ ok: true,
1152
+ schemaVersion: 1,
1153
+ modelHash: modelHash(model),
1154
+ summary: {
1155
+ states: model.states.length,
1156
+ transitions: model.transitions.length,
1157
+ errors,
1158
+ warnings,
1159
+ checksRun: checks.length,
1160
+ truncated: exploration.truncated,
1161
+ },
1162
+ checks,
1163
+ }
1164
+ }