dsh-logicprobe 0.8.1 → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.en-US.md +79 -14
- package/README.md +79 -14
- package/lib/baseline.js +142 -0
- package/lib/client.js +56 -1
- package/lib/compose-tool.js +1 -1
- package/lib/concurrency-tool.js +1 -1
- package/lib/concurrency.js +14 -0
- package/lib/data-engine.js +42 -8
- package/lib/engine.js +609 -118
- package/lib/export-tool.js +27 -2
- package/lib/granularity.js +237 -0
- package/lib/index.js +63 -32
- package/lib/report-diff-tool.js +39 -0
- package/lib/structure-tool.js +64 -0
- package/lib/structure.js +648 -0
- package/lib/tool.js +3 -3
- package/lib/types/baseline.d.ts +69 -0
- package/lib/types/concurrency.d.ts +10 -0
- package/lib/types/data-engine.d.ts +13 -1
- package/lib/types/engine.d.ts +139 -2
- package/lib/types/granularity.d.ts +106 -0
- package/lib/types/index.d.ts +9 -8
- package/lib/types/report-diff-tool.d.ts +8 -0
- package/lib/types/structure-tool.d.ts +10 -0
- package/lib/types/structure.d.ts +156 -0
- package/lib/types/tool.d.ts +2 -2
- package/lib/types/uml.d.ts +89 -0
- package/lib/uml-tool.js +36 -7
- package/lib/uml.js +217 -23
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +16 -14
- package/skills/logicprobe/SKILL.md +97 -58
- package/skills/logicprobe/references/dsh-model-schema.md +87 -15
- package/skills/logicprobe/references/gap-routing-guide.md +37 -36
- package/skills/logicprobe/references/hash-spec.md +128 -0
- package/skills/logicprobe/references/logic-verification-guide.md +29 -248
- package/skills/logicprobe-concurrency/SKILL.md +43 -0
- package/skills/{logicprobe → logicprobe-concurrency}/references/concurrency-risk-guide.md +1 -1
- package/skills/logicprobe-datamodel/SKILL.md +5 -4
- package/skills/logicprobe-datamodel/references/data-model-guide.md +1 -1
- package/skills/logicprobe-datamodel/references/data-model-schema.md +1 -1
- package/skills/logicprobe-structure/SKILL.md +95 -0
- package/skills/logicprobe-structure/references/structure-review-guide.md +177 -0
- package/skills/logicprobe-uml/SKILL.md +55 -0
- package/skills/{logicprobe → logicprobe-uml}/references/uml-modeling-guide.md +120 -8
- package/src/baseline.ts +180 -0
- package/src/client.js +56 -1
- package/src/compose-tool.ts +47 -47
- package/src/concurrency-tool.ts +2 -2
- package/src/concurrency.ts +23 -0
- package/src/data-engine.ts +51 -9
- package/src/engine.ts +656 -101
- package/src/export-tool.ts +73 -48
- package/src/granularity.ts +312 -0
- package/src/index.ts +63 -32
- package/src/report-diff-tool.ts +43 -0
- package/src/structure-tool.ts +68 -0
- package/src/structure.ts +724 -0
- package/src/tool.ts +4 -4
- package/src/uml-tool.ts +38 -8
- package/src/uml.ts +275 -24
- package/skills/logicprobe/references/logicprobe-engine.py +0 -4780
- package/skills/logicprobe/references/verification-harness.py +0 -959
- package/skills/logicprobe-datamodel/references/data-model-harness.py +0 -528
|
@@ -1,4780 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env python3
|
|
2
|
-
"""logicprobe-engine.py — standalone JSON-driven mirror of the logicprobe DSH engine.
|
|
3
|
-
|
|
4
|
-
Reads the same LogicModelV1 JSON schema as the DSH `logicprobe_verify` /
|
|
5
|
-
`logicprobe_compose_verify` / `logicprobe_export` tools and runs the same
|
|
6
|
-
checks (S1-S8 structural, A1-A14 adversarial, D1-D4 before/after regression,
|
|
7
|
-
C1/C2 composition) plus the four external-tool exporters (UPPAAL / TLA+ /
|
|
8
|
-
PRISM / SPIN) and the UML front end (render / parse / review, mirroring
|
|
9
|
-
`src/uml.ts`). Pure stdlib, no third-party imports.
|
|
10
|
-
|
|
11
|
-
Usage:
|
|
12
|
-
python logicprobe-engine.py verify model.json [--before-model before.json]
|
|
13
|
-
[--state-mapping map.json] [--max-states N] [--max-permutation-events N]
|
|
14
|
-
python logicprobe-engine.py compose m1.json m2.json [m3.json ...]
|
|
15
|
-
[--rendezvous ev1,ev2] [--max-states N]
|
|
16
|
-
python logicprobe-engine.py export model.json --format uppaal|tla|prism|spin
|
|
17
|
-
python logicprobe-engine.py uml-render model.json [--notation mermaid|plantuml]
|
|
18
|
-
[--diagram state|activity|sequence] [--max-steps N]
|
|
19
|
-
python logicprobe-engine.py uml-parse diagram.txt [--notation auto|mermaid|plantuml]
|
|
20
|
-
python logicprobe-engine.py uml-review [--model model.json] [--diagram diagram.txt]
|
|
21
|
-
[--notation auto|mermaid|plantuml] [--diagram-kind state|activity|sequence]
|
|
22
|
-
[--no-round-trip] [--max-steps N]
|
|
23
|
-
|
|
24
|
-
Output: JSON verification/composition report on stdout (same shape as the DSH
|
|
25
|
-
tool result), or the export result JSON (format / primary / extras / warnings),
|
|
26
|
-
or the UML render/parse/review JSON.
|
|
27
|
-
"""
|
|
28
|
-
import argparse
|
|
29
|
-
import hashlib
|
|
30
|
-
import json
|
|
31
|
-
import math
|
|
32
|
-
import re
|
|
33
|
-
import sys
|
|
34
|
-
from collections import deque
|
|
35
|
-
|
|
36
|
-
ENGINE_SCHEMA_VERSION = 1
|
|
37
|
-
DEFAULT_MAX_STATES = 10000
|
|
38
|
-
DEFAULT_MAX_PERMUTATION_EVENTS = 5
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
# ---------------------------------------------------------------------------
|
|
42
|
-
# lossless JS-compatible helpers
|
|
43
|
-
# ---------------------------------------------------------------------------
|
|
44
|
-
|
|
45
|
-
def js_number(value):
|
|
46
|
-
"""Render a number the way JavaScript's String(number) does for the value
|
|
47
|
-
ranges logicprobe models use (small integers and finite decimals)."""
|
|
48
|
-
if isinstance(value, bool):
|
|
49
|
-
return '1' if value else '0'
|
|
50
|
-
if isinstance(value, int):
|
|
51
|
-
return str(value)
|
|
52
|
-
# float that is integral renders without a decimal point in JS
|
|
53
|
-
if value.is_integer() and abs(value) < 1e21:
|
|
54
|
-
return str(int(value))
|
|
55
|
-
return repr(value)
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
def js_stringify(value, sort_keys=False):
|
|
59
|
-
"""JSON.stringify with sorted or insertion-order keys (no spaces), kept
|
|
60
|
-
stable so model hashes match the TypeScript engine's stableStringify."""
|
|
61
|
-
if value is None:
|
|
62
|
-
return 'null'
|
|
63
|
-
if value is True:
|
|
64
|
-
return 'true'
|
|
65
|
-
if value is False:
|
|
66
|
-
return 'false'
|
|
67
|
-
if isinstance(value, (int, float)):
|
|
68
|
-
# JS JSON.stringify(1.0) -> '1'; python str(float) differs, so reuse js_number
|
|
69
|
-
return js_number(value) if isinstance(value, float) else str(value)
|
|
70
|
-
if isinstance(value, str):
|
|
71
|
-
return json.dumps(value, ensure_ascii=False)
|
|
72
|
-
if isinstance(value, (list, tuple)):
|
|
73
|
-
return '[' + ','.join(js_stringify(item, sort_keys) for item in value) + ']'
|
|
74
|
-
if isinstance(value, dict):
|
|
75
|
-
keys = sorted(value.keys()) if sort_keys else list(value.keys())
|
|
76
|
-
return '{' + ','.join(js_stringify(str(k), sort_keys) + ':' + js_stringify(value[k], sort_keys) for k in keys) + '}'
|
|
77
|
-
return 'null'
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
def stable_stringify(value):
|
|
81
|
-
return js_stringify(value, sort_keys=True)
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
def model_hash(model):
|
|
85
|
-
return hashlib.sha256(stable_stringify(model).encode('utf-8')).hexdigest()
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
# ---------------------------------------------------------------------------
|
|
89
|
-
# Validation
|
|
90
|
-
# ---------------------------------------------------------------------------
|
|
91
|
-
|
|
92
|
-
def _is_plain_object(value):
|
|
93
|
-
return isinstance(value, dict)
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
def validate_model(input_value):
|
|
97
|
-
"""Mirror of validateModel: returns (ok, model_or_errors)."""
|
|
98
|
-
errors = []
|
|
99
|
-
|
|
100
|
-
def bad(path, message):
|
|
101
|
-
errors.append(path + ': ' + message)
|
|
102
|
-
|
|
103
|
-
# Declared keys per model part. The model schema is closed: a key that is not
|
|
104
|
-
# declared is a typo or an unsupported feature, and silently ignoring it can make
|
|
105
|
-
# the engine report a property the model does not actually have (a mistyped guard
|
|
106
|
-
# variable reads as an always-false guard, which prunes real paths and can yield a
|
|
107
|
-
# false "no deadlock"). Rejecting is the only safe option for a verifier.
|
|
108
|
-
model_keys = {
|
|
109
|
-
'root': ('schemaVersion', 'init', 'states', 'transitions', 'variables', 'invariants',
|
|
110
|
-
'concurrentPairs', 'boundaryChecks', 'resourcePairs', 'idempotentEvents',
|
|
111
|
-
'tickEvents', 'narrative'),
|
|
112
|
-
'state': ('id', 'terminal', 'onEntry', 'onExit', 'maxTicks'),
|
|
113
|
-
'transition': ('from', 'event', 'to', 'guard', 'updates', 'cost', 'weight'),
|
|
114
|
-
'update': ('variable', 'op', 'value'),
|
|
115
|
-
'variable': ('name', 'kind', 'init', 'min', 'max', 'monotonic'),
|
|
116
|
-
# LogicModelV1 boundary checks are (variable, values). The data-model engine has
|
|
117
|
-
# its own (entity, field) shape and its own validator — the two must not be mixed.
|
|
118
|
-
'boundaryCheck': ('variable', 'values'),
|
|
119
|
-
'resourcePair': ('resource', 'acquireEvent', 'releaseEvent', 'failEvent'),
|
|
120
|
-
'narrative': ('states', 'events', 'scenarios'),
|
|
121
|
-
'scenario': ('from', 'event', 'scenario'),
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
def reject_unknown_keys(value, part, path):
|
|
125
|
-
allowed = model_keys[part]
|
|
126
|
-
unexpected = [k for k in value.keys() if k not in allowed]
|
|
127
|
-
if unexpected:
|
|
128
|
-
bad(path + '.' + unexpected[0], 'unknown field (allowed: ' + ', '.join(allowed) + ')')
|
|
129
|
-
|
|
130
|
-
if not _is_plain_object(input_value):
|
|
131
|
-
return (False, ['model: must be an object'])
|
|
132
|
-
root = input_value
|
|
133
|
-
reject_unknown_keys(root, 'root', 'model')
|
|
134
|
-
if root.get('schemaVersion') != 1:
|
|
135
|
-
bad('schemaVersion', 'must be 1')
|
|
136
|
-
init = root.get('init')
|
|
137
|
-
if not isinstance(init, str) or len(init) == 0:
|
|
138
|
-
bad('init', 'must be a non-empty string')
|
|
139
|
-
states = root.get('states')
|
|
140
|
-
if not isinstance(states, list) or len(states) == 0:
|
|
141
|
-
bad('states', 'must be a non-empty array')
|
|
142
|
-
else:
|
|
143
|
-
seen = set()
|
|
144
|
-
for index, entry in enumerate(states):
|
|
145
|
-
if not _is_plain_object(entry):
|
|
146
|
-
bad('states[' + str(index) + ']', 'must be an object')
|
|
147
|
-
continue
|
|
148
|
-
sid = entry.get('id')
|
|
149
|
-
reject_unknown_keys(entry, 'state', 'states[' + str(index) + ']')
|
|
150
|
-
if not isinstance(sid, str) or len(sid) == 0:
|
|
151
|
-
bad('states[' + str(index) + '].id', 'must be a non-empty string')
|
|
152
|
-
elif sid in seen:
|
|
153
|
-
bad('states[' + str(index) + '].id', 'duplicate state id ' + str(sid))
|
|
154
|
-
else:
|
|
155
|
-
seen.add(sid)
|
|
156
|
-
terminal = entry.get('terminal')
|
|
157
|
-
if terminal is not None and not isinstance(terminal, bool):
|
|
158
|
-
bad('states[' + str(index) + '].terminal', 'must be a boolean')
|
|
159
|
-
for kind in ('onEntry', 'onExit'):
|
|
160
|
-
actions = entry.get(kind)
|
|
161
|
-
if actions is None:
|
|
162
|
-
continue
|
|
163
|
-
if not isinstance(actions, list):
|
|
164
|
-
bad('states[' + str(index) + '].' + kind, 'must be an array of action names')
|
|
165
|
-
elif len(actions) > 64:
|
|
166
|
-
bad('states[' + str(index) + '].' + kind, 'must not exceed 64 actions')
|
|
167
|
-
else:
|
|
168
|
-
for action_index, action in enumerate(actions):
|
|
169
|
-
if not isinstance(action, str) or len(action) == 0:
|
|
170
|
-
bad('states[' + str(index) + '].' + kind + '[' + str(action_index) + ']', 'must be a non-empty string')
|
|
171
|
-
if isinstance(init, str) and len(init) > 0 and init not in seen:
|
|
172
|
-
bad('init', 'must name a declared state')
|
|
173
|
-
transitions = root.get('transitions')
|
|
174
|
-
if not isinstance(transitions, list):
|
|
175
|
-
bad('transitions', 'must be an array')
|
|
176
|
-
else:
|
|
177
|
-
state_ids = set()
|
|
178
|
-
if isinstance(states, list):
|
|
179
|
-
for state in states:
|
|
180
|
-
if isinstance(state, dict) and isinstance(state.get('id'), str):
|
|
181
|
-
state_ids.add(state['id'])
|
|
182
|
-
for index, entry in enumerate(transitions):
|
|
183
|
-
p = 'transitions[' + str(index) + ']'
|
|
184
|
-
if not _is_plain_object(entry):
|
|
185
|
-
bad(p, 'must be an object')
|
|
186
|
-
continue
|
|
187
|
-
reject_unknown_keys(entry, 'transition', p)
|
|
188
|
-
tfrom = entry.get('from')
|
|
189
|
-
if not isinstance(tfrom, str) or len(tfrom) == 0:
|
|
190
|
-
bad(p + '.from', 'must be a non-empty string')
|
|
191
|
-
elif tfrom not in state_ids:
|
|
192
|
-
bad(p + '.from', 'unknown state ' + tfrom)
|
|
193
|
-
tevent = entry.get('event')
|
|
194
|
-
if not isinstance(tevent, str) or len(tevent) == 0:
|
|
195
|
-
bad(p + '.event', 'must be a non-empty string')
|
|
196
|
-
tto = entry.get('to')
|
|
197
|
-
if not isinstance(tto, str) or len(tto) == 0:
|
|
198
|
-
bad(p + '.to', 'must be a non-empty string')
|
|
199
|
-
elif tto not in state_ids:
|
|
200
|
-
bad(p + '.to', 'unknown state ' + tto)
|
|
201
|
-
if entry.get('guard') is not None:
|
|
202
|
-
_validate_guard(entry['guard'], p + '.guard', errors, bad)
|
|
203
|
-
updates = entry.get('updates')
|
|
204
|
-
if updates is not None:
|
|
205
|
-
if not isinstance(updates, list):
|
|
206
|
-
bad(p + '.updates', 'must be an array')
|
|
207
|
-
else:
|
|
208
|
-
for update_index, update in enumerate(updates):
|
|
209
|
-
up = p + '.updates[' + str(update_index) + ']'
|
|
210
|
-
if not _is_plain_object(update):
|
|
211
|
-
bad(up, 'must be an object')
|
|
212
|
-
continue
|
|
213
|
-
reject_unknown_keys(update, 'update', up)
|
|
214
|
-
variable = update.get('variable')
|
|
215
|
-
if not isinstance(variable, str) or len(variable) == 0:
|
|
216
|
-
bad(up + '.variable', 'must be a non-empty string')
|
|
217
|
-
op = update.get('op')
|
|
218
|
-
if op not in ('set', 'inc', 'dec'):
|
|
219
|
-
bad(up + '.op', "must be 'set', 'inc', or 'dec'")
|
|
220
|
-
value = update.get('value')
|
|
221
|
-
if value is not None and not isinstance(value, (int, float)) or isinstance(value, bool):
|
|
222
|
-
bad(up + '.value', 'must be a number')
|
|
223
|
-
cost = entry.get('cost')
|
|
224
|
-
if cost is not None and (not isinstance(cost, (int, float)) or isinstance(cost, bool) or not math.isfinite(cost) or cost < 0):
|
|
225
|
-
bad(p + '.cost', 'must be a non-negative finite number')
|
|
226
|
-
weight = entry.get('weight')
|
|
227
|
-
if weight is not None and (not isinstance(weight, (int, float)) or isinstance(weight, bool) or not math.isfinite(weight) or weight < 0):
|
|
228
|
-
bad(p + '.weight', 'must be a non-negative finite number')
|
|
229
|
-
if isinstance(states, list):
|
|
230
|
-
for index, state in enumerate(states):
|
|
231
|
-
if not isinstance(state, dict):
|
|
232
|
-
continue
|
|
233
|
-
mt = state.get('maxTicks')
|
|
234
|
-
if mt is not None and (not isinstance(mt, int) or isinstance(mt, bool) or mt < 0):
|
|
235
|
-
bad('states[' + str(index) + '].maxTicks', 'must be a non-negative integer')
|
|
236
|
-
variable_names = set()
|
|
237
|
-
variables = root.get('variables')
|
|
238
|
-
if variables is not None:
|
|
239
|
-
if not isinstance(variables, list):
|
|
240
|
-
bad('variables', 'must be an array')
|
|
241
|
-
else:
|
|
242
|
-
for index, entry in enumerate(variables):
|
|
243
|
-
p = 'variables[' + str(index) + ']'
|
|
244
|
-
if not _is_plain_object(entry):
|
|
245
|
-
bad(p, 'must be an object')
|
|
246
|
-
continue
|
|
247
|
-
name = entry.get('name')
|
|
248
|
-
reject_unknown_keys(entry, 'variable', p)
|
|
249
|
-
if not isinstance(name, str) or len(name) == 0:
|
|
250
|
-
bad(p + '.name', 'must be a non-empty string')
|
|
251
|
-
elif name in variable_names:
|
|
252
|
-
bad(p + '.name', 'duplicate variable ' + name)
|
|
253
|
-
else:
|
|
254
|
-
variable_names.add(name)
|
|
255
|
-
kind = entry.get('kind')
|
|
256
|
-
if kind not in ('integer', 'boolean'):
|
|
257
|
-
bad(p + '.kind', "must be 'integer' or 'boolean'")
|
|
258
|
-
expected = 'bool' if kind == 'boolean' else 'number'
|
|
259
|
-
init_val = entry.get('init')
|
|
260
|
-
if expected == 'bool':
|
|
261
|
-
if not isinstance(init_val, bool):
|
|
262
|
-
bad(p + '.init', 'must be a boolean')
|
|
263
|
-
else:
|
|
264
|
-
if not isinstance(init_val, (int, float)) or isinstance(init_val, bool):
|
|
265
|
-
bad(p + '.init', 'must be a number')
|
|
266
|
-
mn = entry.get('min')
|
|
267
|
-
mx = entry.get('max')
|
|
268
|
-
if mn is not None and (not isinstance(mn, (int, float)) or isinstance(mn, bool)):
|
|
269
|
-
bad(p + '.min', 'must be a number')
|
|
270
|
-
if mx is not None and (not isinstance(mx, (int, float)) or isinstance(mx, bool)):
|
|
271
|
-
bad(p + '.max', 'must be a number')
|
|
272
|
-
if isinstance(mn, (int, float)) and not isinstance(mn, bool) and isinstance(mx, (int, float)) and not isinstance(mx, bool) and mn > mx:
|
|
273
|
-
bad(p + '.max', 'must be >= min')
|
|
274
|
-
mono = entry.get('monotonic')
|
|
275
|
-
if mono is not None and mono not in ('inc', 'dec'):
|
|
276
|
-
bad(p + '.monotonic', "must be 'inc' or 'dec'")
|
|
277
|
-
if kind == 'integer' and isinstance(init_val, (int, float)) and not isinstance(init_val, bool):
|
|
278
|
-
if isinstance(mn, (int, float)) and not isinstance(mn, bool) and init_val < mn:
|
|
279
|
-
bad(p + '.init', 'must be >= min')
|
|
280
|
-
if isinstance(mx, (int, float)) and not isinstance(mx, bool) and init_val > mx:
|
|
281
|
-
bad(p + '.init', 'must be <= max')
|
|
282
|
-
|
|
283
|
-
def validate_variable_ref(name, p):
|
|
284
|
-
if not isinstance(name, str) or len(name) == 0:
|
|
285
|
-
bad(p, 'must be a non-empty string')
|
|
286
|
-
elif name not in variable_names:
|
|
287
|
-
bad(p, 'references unknown variable ' + str(name))
|
|
288
|
-
|
|
289
|
-
invariants = root.get('invariants')
|
|
290
|
-
# Closed schema per kind: a key the selected kind does not declare is rejected
|
|
291
|
-
# rather than silently ignored, because an ignored field still appears in the
|
|
292
|
-
# echoed report and reads as if it took effect. Unknown kinds are skipped so
|
|
293
|
-
# they fail on .kind alone.
|
|
294
|
-
invariant_keys = {
|
|
295
|
-
'never-states': ('id', 'description', 'kind', 'states'),
|
|
296
|
-
'var-in-range': ('id', 'description', 'kind', 'variable', 'min', 'max', 'when'),
|
|
297
|
-
'event-before-state': ('id', 'description', 'kind', 'event', 'state'),
|
|
298
|
-
'leads-to': ('id', 'description', 'kind', 'from', 'to'),
|
|
299
|
-
'sequence': ('id', 'description', 'kind', 'events'),
|
|
300
|
-
'atomicity': ('id', 'description', 'kind', 'events', 'commit', 'rollback'),
|
|
301
|
-
'budget': ('id', 'description', 'kind', 'budget'),
|
|
302
|
-
'probability': ('id', 'description', 'kind', 'target', 'op', 'p'),
|
|
303
|
-
}
|
|
304
|
-
invariant_state_ids = set()
|
|
305
|
-
if isinstance(states, list):
|
|
306
|
-
for state in states:
|
|
307
|
-
if isinstance(state, dict) and isinstance(state.get('id'), str):
|
|
308
|
-
invariant_state_ids.add(state['id'])
|
|
309
|
-
# Reference sets for invariant targets: denormalising a kind's target into the
|
|
310
|
-
# report makes a mistyped id look authoritative, so every reference must resolve.
|
|
311
|
-
invariant_event_ids = set()
|
|
312
|
-
if isinstance(transitions, list):
|
|
313
|
-
for transition in transitions:
|
|
314
|
-
if isinstance(transition, dict) and isinstance(transition.get('event'), str):
|
|
315
|
-
invariant_event_ids.add(transition['event'])
|
|
316
|
-
if invariants is not None:
|
|
317
|
-
if not isinstance(invariants, list):
|
|
318
|
-
bad('invariants', 'must be an array')
|
|
319
|
-
else:
|
|
320
|
-
for index, entry in enumerate(invariants):
|
|
321
|
-
p = 'invariants[' + str(index) + ']'
|
|
322
|
-
if not _is_plain_object(entry):
|
|
323
|
-
bad(p, 'must be an object')
|
|
324
|
-
continue
|
|
325
|
-
iid = entry.get('id')
|
|
326
|
-
if not isinstance(iid, str) or len(iid) == 0:
|
|
327
|
-
bad(p + '.id', 'must be a non-empty string')
|
|
328
|
-
desc = entry.get('description')
|
|
329
|
-
if not isinstance(desc, str):
|
|
330
|
-
bad(p + '.description', 'must be a string')
|
|
331
|
-
kind = entry.get('kind')
|
|
332
|
-
if kind in invariant_keys:
|
|
333
|
-
allowed_keys = invariant_keys[kind]
|
|
334
|
-
unexpected = [k for k in entry.keys() if k not in allowed_keys]
|
|
335
|
-
if unexpected:
|
|
336
|
-
bad(p + '.' + unexpected[0], 'unknown field for kind ' + str(kind) + ' (allowed: ' + ', '.join(allowed_keys) + ')')
|
|
337
|
-
if kind == 'never-states':
|
|
338
|
-
s_list = entry.get('states')
|
|
339
|
-
if not isinstance(s_list, list) or len(s_list) == 0:
|
|
340
|
-
bad(p + '.states', 'must be a non-empty array')
|
|
341
|
-
else:
|
|
342
|
-
for s_index, state in enumerate(s_list):
|
|
343
|
-
if not isinstance(state, str) or len(state) == 0:
|
|
344
|
-
bad(p + '.states[' + str(s_index) + ']', 'must be a non-empty string')
|
|
345
|
-
elif state not in invariant_state_ids:
|
|
346
|
-
bad(p + '.states[' + str(s_index) + ']', 'references unknown state ' + state)
|
|
347
|
-
elif kind == 'var-in-range':
|
|
348
|
-
validate_variable_ref(entry.get('variable'), p + '.variable')
|
|
349
|
-
mn = entry.get('min')
|
|
350
|
-
mx = entry.get('max')
|
|
351
|
-
if mn is not None and (not isinstance(mn, (int, float)) or isinstance(mn, bool)):
|
|
352
|
-
bad(p + '.min', 'must be a number')
|
|
353
|
-
if mx is not None and (not isinstance(mx, (int, float)) or isinstance(mx, bool)):
|
|
354
|
-
bad(p + '.max', 'must be a number')
|
|
355
|
-
if mn is None and mx is None:
|
|
356
|
-
bad(p, 'requires min or max (a range with neither bound is vacuous)')
|
|
357
|
-
when = entry.get('when')
|
|
358
|
-
if when is not None:
|
|
359
|
-
if not _is_plain_object(when):
|
|
360
|
-
bad(p + '.when', 'must be an object')
|
|
361
|
-
elif 'state' in when:
|
|
362
|
-
for key in when.keys():
|
|
363
|
-
if key != 'state':
|
|
364
|
-
bad(p + '.when.' + key, 'unknown field for a state scope (allowed: state)')
|
|
365
|
-
scope_state = when.get('state')
|
|
366
|
-
if not isinstance(scope_state, str) or len(scope_state) == 0:
|
|
367
|
-
bad(p + '.when.state', 'must be a non-empty string')
|
|
368
|
-
elif scope_state not in invariant_state_ids:
|
|
369
|
-
bad(p + '.when.state', 'references unknown state ' + scope_state)
|
|
370
|
-
else:
|
|
371
|
-
_validate_guard(when, p + '.when', errors, bad)
|
|
372
|
-
constrained = entry.get('variable')
|
|
373
|
-
for wv in _guard_variables(when):
|
|
374
|
-
if wv == constrained:
|
|
375
|
-
bad(p + '.when', 'must not reference the constrained variable ' + str(constrained) + ' (it can mask its own violation)')
|
|
376
|
-
else:
|
|
377
|
-
validate_variable_ref(wv, p + '.when')
|
|
378
|
-
elif kind == 'event-before-state':
|
|
379
|
-
ev = entry.get('event')
|
|
380
|
-
if not isinstance(ev, str) or len(ev) == 0:
|
|
381
|
-
bad(p + '.event', 'must be a non-empty string')
|
|
382
|
-
elif ev not in invariant_event_ids:
|
|
383
|
-
bad(p + '.event', 'references unknown event ' + ev)
|
|
384
|
-
st = entry.get('state')
|
|
385
|
-
if not isinstance(st, str) or len(st) == 0:
|
|
386
|
-
bad(p + '.state', 'must be a non-empty string')
|
|
387
|
-
elif st not in invariant_state_ids:
|
|
388
|
-
bad(p + '.state', 'references unknown state ' + st)
|
|
389
|
-
elif kind == 'leads-to':
|
|
390
|
-
lfrom = entry.get('from')
|
|
391
|
-
if not isinstance(lfrom, str) or len(lfrom) == 0:
|
|
392
|
-
bad(p + '.from', 'must be a non-empty string')
|
|
393
|
-
elif lfrom not in invariant_state_ids:
|
|
394
|
-
bad(p + '.from', 'references unknown state ' + lfrom)
|
|
395
|
-
lto = entry.get('to')
|
|
396
|
-
if not isinstance(lto, str) or len(lto) == 0:
|
|
397
|
-
bad(p + '.to', 'must be a non-empty string')
|
|
398
|
-
elif lto not in invariant_state_ids:
|
|
399
|
-
bad(p + '.to', 'references unknown state ' + lto)
|
|
400
|
-
elif kind == 'sequence':
|
|
401
|
-
evs = entry.get('events')
|
|
402
|
-
if not isinstance(evs, list) or len(evs) == 0:
|
|
403
|
-
bad(p + '.events', 'must be a non-empty array')
|
|
404
|
-
else:
|
|
405
|
-
for e_index, ev in enumerate(evs):
|
|
406
|
-
if not isinstance(ev, str) or len(ev) == 0:
|
|
407
|
-
bad(p + '.events[' + str(e_index) + ']', 'must be a non-empty string')
|
|
408
|
-
elif kind == 'atomicity':
|
|
409
|
-
evs = entry.get('events')
|
|
410
|
-
if not isinstance(evs, list) or len(evs) == 0:
|
|
411
|
-
bad(p + '.events', 'must be a non-empty array')
|
|
412
|
-
else:
|
|
413
|
-
for e_index, ev in enumerate(evs):
|
|
414
|
-
if not isinstance(ev, str) or len(ev) == 0:
|
|
415
|
-
bad(p + '.events[' + str(e_index) + ']', 'must be a non-empty string')
|
|
416
|
-
cm = entry.get('commit')
|
|
417
|
-
if not isinstance(cm, str) or len(cm) == 0:
|
|
418
|
-
bad(p + '.commit', 'must be a non-empty string')
|
|
419
|
-
rb = entry.get('rollback')
|
|
420
|
-
if rb is not None and not isinstance(rb, str):
|
|
421
|
-
bad(p + '.rollback', 'must be a string')
|
|
422
|
-
elif kind == 'budget':
|
|
423
|
-
budget = entry.get('budget')
|
|
424
|
-
if budget is None or not isinstance(budget, (int, float)) or isinstance(budget, bool) or not math.isfinite(budget) or budget < 0:
|
|
425
|
-
bad(p + '.budget', 'must be a non-negative finite number')
|
|
426
|
-
elif kind == 'probability':
|
|
427
|
-
target = entry.get('target')
|
|
428
|
-
if not isinstance(target, str) or len(target) == 0:
|
|
429
|
-
bad(p + '.target', 'must be a non-empty string')
|
|
430
|
-
elif target not in invariant_state_ids:
|
|
431
|
-
bad(p + '.target', 'references unknown state ' + target)
|
|
432
|
-
op = entry.get('op')
|
|
433
|
-
if op not in ('>=', '<=', '>', '<'):
|
|
434
|
-
bad(p + '.op', "must be one of '>=', '<=', '>', '<'")
|
|
435
|
-
pv = entry.get('p')
|
|
436
|
-
if pv is None or not isinstance(pv, (int, float)) or isinstance(pv, bool) or not math.isfinite(pv) or pv < 0 or pv > 1:
|
|
437
|
-
bad(p + '.p', 'must be a number in [0, 1]')
|
|
438
|
-
else:
|
|
439
|
-
bad(p + '.kind', 'unknown invariant kind')
|
|
440
|
-
|
|
441
|
-
concurrent_pairs = root.get('concurrentPairs')
|
|
442
|
-
if concurrent_pairs is not None:
|
|
443
|
-
if not isinstance(concurrent_pairs, list):
|
|
444
|
-
bad('concurrentPairs', 'must be an array')
|
|
445
|
-
else:
|
|
446
|
-
for index, entry in enumerate(concurrent_pairs):
|
|
447
|
-
p = 'concurrentPairs[' + str(index) + ']'
|
|
448
|
-
if not (isinstance(entry, list) and len(entry) == 2 and isinstance(entry[0], str) and isinstance(entry[1], str)):
|
|
449
|
-
bad(p, 'must be a [event, event] string pair')
|
|
450
|
-
boundary_checks = root.get('boundaryChecks')
|
|
451
|
-
if boundary_checks is not None:
|
|
452
|
-
if not isinstance(boundary_checks, list):
|
|
453
|
-
bad('boundaryChecks', 'must be an array')
|
|
454
|
-
else:
|
|
455
|
-
for index, entry in enumerate(boundary_checks):
|
|
456
|
-
p = 'boundaryChecks[' + str(index) + ']'
|
|
457
|
-
if not _is_plain_object(entry):
|
|
458
|
-
bad(p, 'must be an object')
|
|
459
|
-
continue
|
|
460
|
-
validate_variable_ref(entry.get('variable'), p + '.variable')
|
|
461
|
-
reject_unknown_keys(entry, 'boundaryCheck', p)
|
|
462
|
-
values = entry.get('values')
|
|
463
|
-
if not isinstance(values, list) or any(not isinstance(v, (int, float)) or isinstance(v, bool) for v in values):
|
|
464
|
-
bad(p + '.values', 'must be an array of numbers')
|
|
465
|
-
idem_events = root.get('idempotentEvents')
|
|
466
|
-
if idem_events is not None:
|
|
467
|
-
if not isinstance(idem_events, list):
|
|
468
|
-
bad('idempotentEvents', 'must be an array')
|
|
469
|
-
else:
|
|
470
|
-
for index, entry in enumerate(idem_events):
|
|
471
|
-
if not isinstance(entry, str) or len(entry) == 0:
|
|
472
|
-
bad('idempotentEvents[' + str(index) + ']', 'must be a non-empty string')
|
|
473
|
-
tick_events = root.get('tickEvents')
|
|
474
|
-
if tick_events is not None:
|
|
475
|
-
if not isinstance(tick_events, list):
|
|
476
|
-
bad('tickEvents', 'must be an array')
|
|
477
|
-
else:
|
|
478
|
-
for index, entry in enumerate(tick_events):
|
|
479
|
-
if not isinstance(entry, str) or len(entry) == 0:
|
|
480
|
-
bad('tickEvents[' + str(index) + ']', 'must be a non-empty string')
|
|
481
|
-
resource_pairs = root.get('resourcePairs')
|
|
482
|
-
if resource_pairs is not None:
|
|
483
|
-
if not isinstance(resource_pairs, list):
|
|
484
|
-
bad('resourcePairs', 'must be an array')
|
|
485
|
-
else:
|
|
486
|
-
for index, entry in enumerate(resource_pairs):
|
|
487
|
-
p = 'resourcePairs[' + str(index) + ']'
|
|
488
|
-
if not _is_plain_object(entry):
|
|
489
|
-
bad(p, 'must be an object')
|
|
490
|
-
continue
|
|
491
|
-
resource = entry.get('resource')
|
|
492
|
-
reject_unknown_keys(entry, 'resourcePair', p)
|
|
493
|
-
if not isinstance(resource, str) or len(resource) == 0:
|
|
494
|
-
bad(p + '.resource', 'must be a non-empty string')
|
|
495
|
-
aq = entry.get('acquireEvent')
|
|
496
|
-
if not isinstance(aq, str) or len(aq) == 0:
|
|
497
|
-
bad(p + '.acquireEvent', 'must be a non-empty string')
|
|
498
|
-
rel = entry.get('releaseEvent')
|
|
499
|
-
if not isinstance(rel, str) or len(rel) == 0:
|
|
500
|
-
bad(p + '.releaseEvent', 'must be a non-empty string')
|
|
501
|
-
fail = entry.get('failEvent')
|
|
502
|
-
if fail is not None and not isinstance(fail, str):
|
|
503
|
-
bad(p + '.failEvent', 'must be a string')
|
|
504
|
-
narrative = root.get('narrative')
|
|
505
|
-
if narrative is not None:
|
|
506
|
-
np_ = 'narrative'
|
|
507
|
-
if not _is_plain_object(narrative):
|
|
508
|
-
bad(np_, 'must be an object')
|
|
509
|
-
else:
|
|
510
|
-
reject_unknown_keys(narrative, 'narrative', np_)
|
|
511
|
-
state_ids = set()
|
|
512
|
-
if isinstance(states, list):
|
|
513
|
-
for state in states:
|
|
514
|
-
if isinstance(state, dict) and isinstance(state.get('id'), str):
|
|
515
|
-
state_ids.add(state['id'])
|
|
516
|
-
event_ids = set()
|
|
517
|
-
if isinstance(transitions, list):
|
|
518
|
-
for transition in transitions:
|
|
519
|
-
if isinstance(transition, dict) and isinstance(transition.get('event'), str):
|
|
520
|
-
event_ids.add(transition['event'])
|
|
521
|
-
from_event_groups = set()
|
|
522
|
-
if isinstance(transitions, list):
|
|
523
|
-
for transition in transitions:
|
|
524
|
-
if isinstance(transition, dict) and isinstance(transition.get('from'), str) and isinstance(transition.get('event'), str):
|
|
525
|
-
from_event_groups.add(transition['from'] + '|' + transition['event'])
|
|
526
|
-
nstates = narrative.get('states')
|
|
527
|
-
if not _is_plain_object(nstates):
|
|
528
|
-
bad(np_ + '.states', 'must be an object mapping state id -> natural-language description')
|
|
529
|
-
else:
|
|
530
|
-
for sid, description in nstates.items():
|
|
531
|
-
if sid not in state_ids:
|
|
532
|
-
bad(np_ + '.states', 'references unknown state ' + str(sid))
|
|
533
|
-
if not isinstance(description, str) or len(description) == 0:
|
|
534
|
-
bad(np_ + '.states.' + str(sid), 'must be a non-empty string')
|
|
535
|
-
for sid in state_ids:
|
|
536
|
-
if not isinstance(nstates.get(sid), str):
|
|
537
|
-
bad(np_ + '.states', 'missing description for state ' + str(sid))
|
|
538
|
-
nevents = narrative.get('events')
|
|
539
|
-
if not _is_plain_object(nevents):
|
|
540
|
-
bad(np_ + '.events', 'must be an object mapping event id -> natural-language description')
|
|
541
|
-
else:
|
|
542
|
-
for eid, description in nevents.items():
|
|
543
|
-
if eid not in event_ids:
|
|
544
|
-
bad(np_ + '.events', 'references unknown event ' + str(eid))
|
|
545
|
-
if not isinstance(description, str) or len(description) == 0:
|
|
546
|
-
bad(np_ + '.events.' + str(eid), 'must be a non-empty string')
|
|
547
|
-
for eid in event_ids:
|
|
548
|
-
if not isinstance(nevents.get(eid), str):
|
|
549
|
-
bad(np_ + '.events', 'missing description for event ' + str(eid))
|
|
550
|
-
scenarios = narrative.get('scenarios')
|
|
551
|
-
if not isinstance(scenarios, list):
|
|
552
|
-
bad(np_ + '.scenarios', 'must be an array of { from, event, scenario }')
|
|
553
|
-
else:
|
|
554
|
-
seen = set()
|
|
555
|
-
for index, entry in enumerate(scenarios):
|
|
556
|
-
sp = np_ + '.scenarios[' + str(index) + ']'
|
|
557
|
-
if not _is_plain_object(entry):
|
|
558
|
-
bad(sp, 'must be an object')
|
|
559
|
-
continue
|
|
560
|
-
sfrom = entry.get('from')
|
|
561
|
-
reject_unknown_keys(entry, 'scenario', sp)
|
|
562
|
-
if not isinstance(sfrom, str) or len(sfrom) == 0:
|
|
563
|
-
bad(sp + '.from', 'must be a non-empty string')
|
|
564
|
-
elif sfrom not in state_ids:
|
|
565
|
-
bad(sp + '.from', 'unknown state ' + str(sfrom))
|
|
566
|
-
sevent = entry.get('event')
|
|
567
|
-
if not isinstance(sevent, str) or len(sevent) == 0:
|
|
568
|
-
bad(sp + '.event', 'must be a non-empty string')
|
|
569
|
-
elif sevent not in event_ids:
|
|
570
|
-
bad(sp + '.event', 'unknown event ' + str(sevent))
|
|
571
|
-
scenario = entry.get('scenario')
|
|
572
|
-
if not isinstance(scenario, str) or len(scenario) == 0:
|
|
573
|
-
bad(sp + '.scenario', 'must be a non-empty string')
|
|
574
|
-
key = str(sfrom) + '|' + str(sevent)
|
|
575
|
-
if key in seen:
|
|
576
|
-
bad(sp, 'duplicate scenario for (' + str(sfrom) + ', ' + str(sevent) + ')')
|
|
577
|
-
seen.add(key)
|
|
578
|
-
for key in from_event_groups:
|
|
579
|
-
if key not in seen:
|
|
580
|
-
sep = key.find('|')
|
|
581
|
-
bad(np_ + '.scenarios', 'missing scenario for (' + key[:sep] + ', ' + key[sep + 1:] + ')')
|
|
582
|
-
# guard/update variable reference walk
|
|
583
|
-
if isinstance(transitions, list):
|
|
584
|
-
for entry in transitions:
|
|
585
|
-
if not isinstance(entry, dict):
|
|
586
|
-
continue
|
|
587
|
-
_walk_guard_references(entry.get('guard'), variable_names, errors, bad)
|
|
588
|
-
for update in entry.get('updates') or []:
|
|
589
|
-
if not isinstance(update, dict):
|
|
590
|
-
continue
|
|
591
|
-
variable = update.get('variable')
|
|
592
|
-
if variable not in variable_names:
|
|
593
|
-
bad('transitions.updates', 'references unknown variable ' + str(variable))
|
|
594
|
-
if errors:
|
|
595
|
-
return (False, errors)
|
|
596
|
-
return (True, root)
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
def _validate_guard(input_value, p, errors, bad):
|
|
600
|
-
if not _is_plain_object(input_value):
|
|
601
|
-
bad(p, 'must be an object')
|
|
602
|
-
return
|
|
603
|
-
guard = input_value
|
|
604
|
-
if 'variable' in guard:
|
|
605
|
-
variable = guard.get('variable')
|
|
606
|
-
if not isinstance(variable, str) or len(variable) == 0:
|
|
607
|
-
bad(p + '.variable', 'must be a non-empty string')
|
|
608
|
-
op = guard.get('op')
|
|
609
|
-
if op not in ('==', '!=', '<', '<=', '>', '>='):
|
|
610
|
-
bad(p + '.op', 'must be a comparison operator')
|
|
611
|
-
value = guard.get('value')
|
|
612
|
-
if not isinstance(value, (int, float, bool)):
|
|
613
|
-
bad(p + '.value', 'must be a number or boolean')
|
|
614
|
-
return
|
|
615
|
-
if 'all' in guard:
|
|
616
|
-
all_list = guard.get('all')
|
|
617
|
-
if not isinstance(all_list, list):
|
|
618
|
-
bad(p + '.all', 'must be an array')
|
|
619
|
-
else:
|
|
620
|
-
for index, child in enumerate(all_list):
|
|
621
|
-
_validate_guard(child, p + '.all[' + str(index) + ']', errors, bad)
|
|
622
|
-
return
|
|
623
|
-
if 'any' in guard:
|
|
624
|
-
any_list = guard.get('any')
|
|
625
|
-
if not isinstance(any_list, list):
|
|
626
|
-
bad(p + '.any', 'must be an array')
|
|
627
|
-
else:
|
|
628
|
-
for index, child in enumerate(any_list):
|
|
629
|
-
_validate_guard(child, p + '.any[' + str(index) + ']', errors, bad)
|
|
630
|
-
return
|
|
631
|
-
if 'not' in guard:
|
|
632
|
-
_validate_guard(guard.get('not'), p + '.not', errors, bad)
|
|
633
|
-
return
|
|
634
|
-
bad(p, 'must be a leaf ({ variable, op, value }), { all }, { any }, or { not }')
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
def _guard_variables(guard):
|
|
638
|
-
"""Mirror of the TypeScript guardVariables(): every variable a guard references."""
|
|
639
|
-
if guard is None:
|
|
640
|
-
return []
|
|
641
|
-
if 'variable' in guard:
|
|
642
|
-
return [guard['variable']]
|
|
643
|
-
if 'all' in guard:
|
|
644
|
-
out = []
|
|
645
|
-
for child in guard['all']:
|
|
646
|
-
out.extend(_guard_variables(child))
|
|
647
|
-
return out
|
|
648
|
-
if 'any' in guard:
|
|
649
|
-
out = []
|
|
650
|
-
for child in guard['any']:
|
|
651
|
-
out.extend(_guard_variables(child))
|
|
652
|
-
return out
|
|
653
|
-
if 'not' in guard:
|
|
654
|
-
return _guard_variables(guard['not'])
|
|
655
|
-
return []
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
def _walk_guard_references(guard, variable_names, errors, bad):
|
|
659
|
-
if guard is None:
|
|
660
|
-
return
|
|
661
|
-
if 'variable' in guard:
|
|
662
|
-
variable = guard.get('variable')
|
|
663
|
-
if variable not in variable_names:
|
|
664
|
-
bad('guard', 'references unknown variable ' + str(variable))
|
|
665
|
-
return
|
|
666
|
-
if 'all' in guard:
|
|
667
|
-
for child in guard['all']:
|
|
668
|
-
_walk_guard_references(child, variable_names, errors, bad)
|
|
669
|
-
return
|
|
670
|
-
if 'any' in guard:
|
|
671
|
-
for child in guard['any']:
|
|
672
|
-
_walk_guard_references(child, variable_names, errors, bad)
|
|
673
|
-
return
|
|
674
|
-
if 'not' in guard:
|
|
675
|
-
_walk_guard_references(guard['not'], variable_names, errors, bad)
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
# ---------------------------------------------------------------------------
|
|
679
|
-
# execution
|
|
680
|
-
# ---------------------------------------------------------------------------
|
|
681
|
-
|
|
682
|
-
def _is_leaf_guard(guard):
|
|
683
|
-
return 'variable' in guard
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
def guard_variables(guard):
|
|
687
|
-
if guard is None:
|
|
688
|
-
return []
|
|
689
|
-
if _is_leaf_guard(guard):
|
|
690
|
-
return [guard['variable']]
|
|
691
|
-
if 'all' in guard:
|
|
692
|
-
out = []
|
|
693
|
-
for child in guard['all']:
|
|
694
|
-
out.extend(guard_variables(child))
|
|
695
|
-
return out
|
|
696
|
-
if 'any' in guard:
|
|
697
|
-
out = []
|
|
698
|
-
for child in guard['any']:
|
|
699
|
-
out.extend(guard_variables(child))
|
|
700
|
-
return out
|
|
701
|
-
if 'not' in guard:
|
|
702
|
-
return guard_variables(guard['not'])
|
|
703
|
-
return []
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
def _eval_guard(guard, vars_map):
|
|
707
|
-
if _is_leaf_guard(guard):
|
|
708
|
-
actual = vars_map.get(guard['variable'])
|
|
709
|
-
op = guard['op']
|
|
710
|
-
value = guard['value']
|
|
711
|
-
if op == '==':
|
|
712
|
-
return actual == value
|
|
713
|
-
if op == '!=':
|
|
714
|
-
return actual != value
|
|
715
|
-
if op == '<':
|
|
716
|
-
return actual < value
|
|
717
|
-
if op == '<=':
|
|
718
|
-
return actual <= value
|
|
719
|
-
if op == '>':
|
|
720
|
-
return actual > value
|
|
721
|
-
if op == '>=':
|
|
722
|
-
return actual >= value
|
|
723
|
-
return False
|
|
724
|
-
if 'all' in guard:
|
|
725
|
-
return all(_eval_guard(child, vars_map) for child in guard['all'])
|
|
726
|
-
if 'any' in guard:
|
|
727
|
-
return any(_eval_guard(child, vars_map) for child in guard['any'])
|
|
728
|
-
if 'not' in guard:
|
|
729
|
-
return not _eval_guard(guard['not'], vars_map)
|
|
730
|
-
return False
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
def _initial_state(model):
|
|
734
|
-
vars_map = {}
|
|
735
|
-
for variable in model.get('variables') or []:
|
|
736
|
-
vars_map[variable['name']] = variable['init']
|
|
737
|
-
return {'state': model['init'], 'vars': vars_map}
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
def _runtime_key(runtime):
|
|
741
|
-
return runtime['state'] + '|' + stable_stringify(runtime['vars'])
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
def _apply_updates(model, transition, runtime):
|
|
745
|
-
vars_map = dict(runtime['vars'])
|
|
746
|
-
for update in transition.get('updates') or []:
|
|
747
|
-
variable = update['variable']
|
|
748
|
-
current = vars_map.get(variable)
|
|
749
|
-
op = update['op']
|
|
750
|
-
if op == 'set':
|
|
751
|
-
vars_map[variable] = update.get('value', 0)
|
|
752
|
-
elif op == 'inc':
|
|
753
|
-
vars_map[variable] = (current if isinstance(current, (int, float)) and not isinstance(current, bool) else 0) + update.get('value', 1)
|
|
754
|
-
else:
|
|
755
|
-
vars_map[variable] = (current if isinstance(current, (int, float)) and not isinstance(current, bool) else 0) - update.get('value', 1)
|
|
756
|
-
return {'state': transition['to'], 'vars': vars_map}
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
def _group_transitions(model):
|
|
760
|
-
groups = {}
|
|
761
|
-
for transition in model.get('transitions') or []:
|
|
762
|
-
key = transition['from'] + '|' + transition['event']
|
|
763
|
-
groups.setdefault(key, []).append(transition)
|
|
764
|
-
return groups
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
def _applicable_transitions(group, runtime):
|
|
768
|
-
guarded = []
|
|
769
|
-
unguarded = []
|
|
770
|
-
for transition in group:
|
|
771
|
-
if transition.get('guard') is None:
|
|
772
|
-
unguarded.append(transition)
|
|
773
|
-
else:
|
|
774
|
-
guarded.append(transition)
|
|
775
|
-
matched = [t for t in guarded if _eval_guard(t['guard'], runtime['vars'])]
|
|
776
|
-
if matched:
|
|
777
|
-
return matched
|
|
778
|
-
return unguarded
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
def _step_runtime(model, runtime, event):
|
|
782
|
-
group = _group_transitions(model).get(runtime['state'] + '|' + event)
|
|
783
|
-
if group is None or len(group) == 0:
|
|
784
|
-
return []
|
|
785
|
-
seen = set()
|
|
786
|
-
outcomes = []
|
|
787
|
-
for transition in _applicable_transitions(group, runtime):
|
|
788
|
-
next_state = _apply_updates(model, transition, runtime)
|
|
789
|
-
key = _runtime_key(next_state)
|
|
790
|
-
if key not in seen:
|
|
791
|
-
seen.add(key)
|
|
792
|
-
outcomes.append(next_state)
|
|
793
|
-
return outcomes
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
def _all_events(model):
|
|
797
|
-
return sorted(set(t['event'] for t in model.get('transitions') or []))
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
def _state_by_id(model, sid):
|
|
801
|
-
for state in model.get('states') or []:
|
|
802
|
-
if state['id'] == sid:
|
|
803
|
-
return state
|
|
804
|
-
return None
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
def _is_terminal(model, sid):
|
|
808
|
-
state = _state_by_id(model, sid)
|
|
809
|
-
return state is not None and state.get('terminal') is True
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
def _unique_targets(transitions):
|
|
813
|
-
# JS [...new Set(...)] keeps first-encounter order
|
|
814
|
-
seen = set()
|
|
815
|
-
out = []
|
|
816
|
-
for t in transitions:
|
|
817
|
-
target = t['to']
|
|
818
|
-
if target not in seen:
|
|
819
|
-
seen.add(target)
|
|
820
|
-
out.append(target)
|
|
821
|
-
return out
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
def _dedupe_preserve(items):
|
|
825
|
-
seen = set()
|
|
826
|
-
out = []
|
|
827
|
-
for item in items:
|
|
828
|
-
if item not in seen:
|
|
829
|
-
seen.add(item)
|
|
830
|
-
out.append(item)
|
|
831
|
-
return out
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
def _explore(model, max_states):
|
|
835
|
-
init = _initial_state(model)
|
|
836
|
-
reachable = []
|
|
837
|
-
reachable_keys = set()
|
|
838
|
-
queue = deque([init])
|
|
839
|
-
truncated = False
|
|
840
|
-
while queue:
|
|
841
|
-
runtime = queue.popleft()
|
|
842
|
-
key = _runtime_key(runtime)
|
|
843
|
-
if key in reachable_keys:
|
|
844
|
-
continue
|
|
845
|
-
reachable_keys.add(key)
|
|
846
|
-
reachable.append(runtime)
|
|
847
|
-
if len(reachable) > max_states:
|
|
848
|
-
truncated = True
|
|
849
|
-
break
|
|
850
|
-
for event in _all_events(model):
|
|
851
|
-
for nxt in _step_runtime(model, runtime, event):
|
|
852
|
-
if _runtime_key(nxt) not in reachable_keys:
|
|
853
|
-
queue.append(nxt)
|
|
854
|
-
return {'reachable': reachable, 'reachable_keys': reachable_keys, 'truncated': truncated, 'initial_state': init}
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
def _check_result(cid, name, findings, detail):
|
|
858
|
-
errors = sum(1 for f in findings if f.get('severity') == 'error')
|
|
859
|
-
warnings = sum(1 for f in findings if f.get('severity') == 'warning')
|
|
860
|
-
suffix = ''
|
|
861
|
-
if errors > 0:
|
|
862
|
-
suffix = ' (' + str(errors) + ' errors' + (', ' + str(warnings) + ' warnings' if warnings > 0 else '') + ')'
|
|
863
|
-
elif warnings > 0:
|
|
864
|
-
suffix = ' (' + str(warnings) + ' warnings)'
|
|
865
|
-
return {'id': cid, 'name': name, 'status': 'pass' if len(findings) == 0 else 'fail', 'detail': detail + suffix, 'findings': findings}
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
# ---------------------------------------------------------------------------
|
|
869
|
-
# S1-S7
|
|
870
|
-
# ---------------------------------------------------------------------------
|
|
871
|
-
|
|
872
|
-
def S1_reachability(model, exploration):
|
|
873
|
-
reached = set(r['state'] for r in exploration['reachable'])
|
|
874
|
-
unreachable = [s['id'] for s in model.get('states') or [] if s['id'] not in reached]
|
|
875
|
-
findings = [{
|
|
876
|
-
'code': 'S1_UNREACHABLE_STATE',
|
|
877
|
-
'severity': 'warning',
|
|
878
|
-
'message': 'State ' + s + ' is not reachable from init.',
|
|
879
|
-
} for s in unreachable]
|
|
880
|
-
return _check_result('S1', 'Reachability', findings,
|
|
881
|
-
'All states reachable' if not unreachable else 'Unreachable states: ' + ', '.join(unreachable))
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
def S2_deadlock(model):
|
|
885
|
-
outgoing = set(t['from'] for t in model.get('transitions') or [])
|
|
886
|
-
findings = []
|
|
887
|
-
for state in model.get('states') or []:
|
|
888
|
-
if state.get('terminal') is True:
|
|
889
|
-
continue
|
|
890
|
-
if state['id'] not in outgoing:
|
|
891
|
-
findings.append({
|
|
892
|
-
'code': 'S2_NO_TRANSITIONS',
|
|
893
|
-
'severity': 'error',
|
|
894
|
-
'message': 'Non-terminal state ' + state['id'] + ' has no outgoing transitions.',
|
|
895
|
-
})
|
|
896
|
-
detail = 'No deadlocks' if not findings else 'Deadlocks: ' + '; '.join(f['message'] for f in findings)
|
|
897
|
-
return _check_result('S2', 'Deadlock', findings, detail)
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
def _sccs(model):
|
|
901
|
-
states = [s['id'] for s in model.get('states') or []]
|
|
902
|
-
edges = {}
|
|
903
|
-
for state in states:
|
|
904
|
-
edges[state] = []
|
|
905
|
-
for transition in model.get('transitions') or []:
|
|
906
|
-
edges.setdefault(transition['from'], []).append(transition['to'])
|
|
907
|
-
index = 0
|
|
908
|
-
indices = {}
|
|
909
|
-
low = {}
|
|
910
|
-
on_stack = set()
|
|
911
|
-
stack = []
|
|
912
|
-
components = []
|
|
913
|
-
|
|
914
|
-
def visit(node):
|
|
915
|
-
nonlocal index
|
|
916
|
-
indices[node] = index
|
|
917
|
-
low[node] = index
|
|
918
|
-
index += 1
|
|
919
|
-
stack.append(node)
|
|
920
|
-
on_stack.add(node)
|
|
921
|
-
for nxt in edges.get(node) or []:
|
|
922
|
-
if nxt not in indices:
|
|
923
|
-
visit(nxt)
|
|
924
|
-
low[node] = min(low[node], low[nxt])
|
|
925
|
-
elif nxt in on_stack:
|
|
926
|
-
low[node] = min(low[node], indices[nxt])
|
|
927
|
-
if low[node] == indices[node]:
|
|
928
|
-
component = []
|
|
929
|
-
while stack:
|
|
930
|
-
member = stack.pop()
|
|
931
|
-
on_stack.discard(member)
|
|
932
|
-
component.append(member)
|
|
933
|
-
if member == node:
|
|
934
|
-
break
|
|
935
|
-
components.append(component)
|
|
936
|
-
|
|
937
|
-
for state in states:
|
|
938
|
-
if state not in indices:
|
|
939
|
-
visit(state)
|
|
940
|
-
return components
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
def S3_liveness(model):
|
|
944
|
-
components = _sccs(model)
|
|
945
|
-
findings = []
|
|
946
|
-
for component in components:
|
|
947
|
-
component_set = set(component)
|
|
948
|
-
internal_edges = [t for t in model.get('transitions') or [] if t['from'] in component_set and t['to'] in component_set]
|
|
949
|
-
escaping_edges = [t for t in model.get('transitions') or [] if t['from'] in component_set and t['to'] not in component_set]
|
|
950
|
-
if not internal_edges or escaping_edges:
|
|
951
|
-
continue
|
|
952
|
-
if any(_is_terminal(model, s) for s in component):
|
|
953
|
-
continue
|
|
954
|
-
sorted_component = sorted(component)
|
|
955
|
-
findings.append({
|
|
956
|
-
'code': 'S3_CLOSED_SCC',
|
|
957
|
-
'severity': 'error',
|
|
958
|
-
'message': 'Closed SCC has no exit and contains no terminal state: ' + ', '.join(sorted_component),
|
|
959
|
-
'evidence': {'states': sorted_component, 'transitions': len(internal_edges)},
|
|
960
|
-
})
|
|
961
|
-
return _check_result('S3', 'Liveness', findings,
|
|
962
|
-
'No harmful closed SCCs' if not findings else 'Closed SCCs: ' + str(len(findings)))
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
def _guard_leaves(model):
|
|
966
|
-
leaves = []
|
|
967
|
-
|
|
968
|
-
def visit(guard):
|
|
969
|
-
if 'variable' in guard:
|
|
970
|
-
leaves.append(guard)
|
|
971
|
-
elif 'all' in guard:
|
|
972
|
-
for child in guard['all']:
|
|
973
|
-
visit(child)
|
|
974
|
-
elif 'any' in guard:
|
|
975
|
-
for child in guard['any']:
|
|
976
|
-
visit(child)
|
|
977
|
-
elif 'not' in guard:
|
|
978
|
-
visit(guard['not'])
|
|
979
|
-
|
|
980
|
-
for transition in model.get('transitions') or []:
|
|
981
|
-
if transition.get('guard') is not None:
|
|
982
|
-
visit(transition['guard'])
|
|
983
|
-
return leaves
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
def _collect_guard_constants(model):
|
|
987
|
-
return [leaf['value'] for leaf in _guard_leaves(model) if isinstance(leaf['value'], (int, float)) and not isinstance(leaf['value'], bool)]
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
def _assignments_for(model, variables):
|
|
991
|
-
specs = [v for v in model.get('variables') or [] if v['name'] in variables]
|
|
992
|
-
combinations = [{}]
|
|
993
|
-
for spec in specs:
|
|
994
|
-
if spec['kind'] == 'boolean':
|
|
995
|
-
values = [False, True]
|
|
996
|
-
else:
|
|
997
|
-
constants = [value for value in _collect_guard_constants(model)
|
|
998
|
-
if any(leaf['variable'] == spec['name'] and isinstance(leaf['value'], (int, float)) and not isinstance(leaf['value'], bool) and leaf['value'] == value
|
|
999
|
-
for leaf in _guard_leaves(model))]
|
|
1000
|
-
mn = spec.get('min')
|
|
1001
|
-
mx = spec.get('max')
|
|
1002
|
-
if mn is not None and mx is not None:
|
|
1003
|
-
values = []
|
|
1004
|
-
value = mn
|
|
1005
|
-
while value <= mx and value <= mn + 200:
|
|
1006
|
-
values.append(value)
|
|
1007
|
-
value += 1
|
|
1008
|
-
else:
|
|
1009
|
-
values = sorted(set([-1, 0, 1] + constants))
|
|
1010
|
-
nxt = []
|
|
1011
|
-
for assignment in combinations:
|
|
1012
|
-
for value in values:
|
|
1013
|
-
copy = dict(assignment)
|
|
1014
|
-
copy[spec['name']] = value
|
|
1015
|
-
nxt.append(copy)
|
|
1016
|
-
if len(nxt) > 10000:
|
|
1017
|
-
return None
|
|
1018
|
-
combinations = nxt
|
|
1019
|
-
return combinations
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
def _analyze_guards(model):
|
|
1023
|
-
determinism = []
|
|
1024
|
-
completeness = []
|
|
1025
|
-
truncated = False
|
|
1026
|
-
groups = _group_transitions(model)
|
|
1027
|
-
for key, group in groups.items():
|
|
1028
|
-
sep = key.find('|')
|
|
1029
|
-
frm = key[:sep]
|
|
1030
|
-
event = key[sep + 1:]
|
|
1031
|
-
unguarded = [t for t in group if t.get('guard') is None]
|
|
1032
|
-
guarded = [t for t in group if t.get('guard') is not None]
|
|
1033
|
-
if len(unguarded) > 1:
|
|
1034
|
-
determinism.append({
|
|
1035
|
-
'code': 'S4_AMBIGUOUS_DEFAULT',
|
|
1036
|
-
'severity': 'error',
|
|
1037
|
-
'message': frm + ' + ' + event + ' has multiple unconditional transitions: ' + ', '.join(_unique_targets(unguarded)),
|
|
1038
|
-
})
|
|
1039
|
-
if len(guarded) == 0:
|
|
1040
|
-
continue
|
|
1041
|
-
variables = []
|
|
1042
|
-
for t in group:
|
|
1043
|
-
for v in guard_variables(t.get('guard')):
|
|
1044
|
-
if v not in variables:
|
|
1045
|
-
variables.append(v)
|
|
1046
|
-
assignments = _assignments_for(model, variables)
|
|
1047
|
-
if assignments is None:
|
|
1048
|
-
completeness.append({'code': 'S6_UNBOUNDED_DOMAIN', 'severity': 'warning',
|
|
1049
|
-
'message': frm + ' + ' + event + ': guard domain too large to enumerate; exhaustive check skipped.'})
|
|
1050
|
-
truncated = True
|
|
1051
|
-
continue
|
|
1052
|
-
targets = _unique_targets(group)
|
|
1053
|
-
for assignment in assignments:
|
|
1054
|
-
true_branches = [t for t in guarded if _eval_guard(t['guard'], assignment)]
|
|
1055
|
-
if len(true_branches) > 1:
|
|
1056
|
-
determinism.append({
|
|
1057
|
-
'code': 'S4_NONDETERMINISTIC_GUARDS',
|
|
1058
|
-
'severity': 'error',
|
|
1059
|
-
'message': frm + ' + ' + event + ' has ' + str(len(true_branches)) + ' simultaneously true guards for assignment ' + stable_stringify(assignment) + ': ' + ', '.join(_unique_targets(true_branches)),
|
|
1060
|
-
'evidence': {'assignment': assignment},
|
|
1061
|
-
})
|
|
1062
|
-
if len(true_branches) == 0 and len(unguarded) == 0:
|
|
1063
|
-
completeness.append({
|
|
1064
|
-
'code': 'S6_INCOMPLETE_GUARD',
|
|
1065
|
-
'severity': 'error',
|
|
1066
|
-
'message': frm + ' + ' + event + ' has no true guard and no default branch for assignment ' + stable_stringify(assignment),
|
|
1067
|
-
'evidence': {'assignment': assignment, 'branches': targets},
|
|
1068
|
-
})
|
|
1069
|
-
if len(determinism) + len(completeness) > 200:
|
|
1070
|
-
truncated = True
|
|
1071
|
-
break
|
|
1072
|
-
if truncated:
|
|
1073
|
-
break
|
|
1074
|
-
return {'determinism': determinism, 'completeness': completeness, 'truncated': truncated}
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
def S4_determinism(model):
|
|
1078
|
-
analysis = _analyze_guards(model)
|
|
1079
|
-
return _check_result('S4', 'Determinism', analysis['determinism'],
|
|
1080
|
-
'Deterministic' if not analysis['determinism'] else 'Nondeterminism findings: ' + str(len(analysis['determinism'])))
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
def S6_guard_completeness(model):
|
|
1084
|
-
analysis = _analyze_guards(model)
|
|
1085
|
-
return _check_result('S6', 'Guard Completeness', analysis['completeness'],
|
|
1086
|
-
'All guard branches defined' if not analysis['completeness'] else 'Guard findings: ' + str(len(analysis['completeness'])))
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
def S5_event_completeness(model):
|
|
1090
|
-
events = _all_events(model)
|
|
1091
|
-
findings = []
|
|
1092
|
-
for state in model.get('states') or []:
|
|
1093
|
-
if state.get('terminal') is True:
|
|
1094
|
-
continue
|
|
1095
|
-
handled = set(t['event'] for t in model.get('transitions') or [] if t['from'] == state['id'])
|
|
1096
|
-
for event in events:
|
|
1097
|
-
if event not in handled:
|
|
1098
|
-
findings.append({
|
|
1099
|
-
'code': 'S5_UNHANDLED_EVENT',
|
|
1100
|
-
'severity': 'warning',
|
|
1101
|
-
'message': state['id'] + ' silently ignores event ' + event,
|
|
1102
|
-
})
|
|
1103
|
-
return _check_result('S5', 'Event Completeness', findings,
|
|
1104
|
-
'All states handle all relevant events' if not findings else str(len(findings)) + ' unhandled (state, event) pairs')
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
def _when_scope_holds(when, runtime):
|
|
1108
|
-
"""Whether a var-in-range `when` scope selects this runtime state."""
|
|
1109
|
-
if 'state' in when:
|
|
1110
|
-
return when['state'] == runtime['state']
|
|
1111
|
-
return _eval_guard(when, runtime['vars'])
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
def _invariant_holds(invariant, runtime, is_initial=False):
|
|
1115
|
-
kind = invariant['kind']
|
|
1116
|
-
if kind == 'never-states':
|
|
1117
|
-
return runtime['state'] not in invariant['states']
|
|
1118
|
-
if kind == 'var-in-range':
|
|
1119
|
-
# The initial runtime state is checked unconditionally: a machine must never
|
|
1120
|
-
# be able to escape the range simply by starting outside the scope.
|
|
1121
|
-
when = invariant.get('when')
|
|
1122
|
-
if not is_initial and when is not None and not _when_scope_holds(when, runtime):
|
|
1123
|
-
return True
|
|
1124
|
-
value = runtime['vars'].get(invariant['variable'])
|
|
1125
|
-
if not isinstance(value, (int, float)) or isinstance(value, bool):
|
|
1126
|
-
return False
|
|
1127
|
-
if invariant.get('min') is not None and value < invariant['min']:
|
|
1128
|
-
return False
|
|
1129
|
-
if invariant.get('max') is not None and value > invariant['max']:
|
|
1130
|
-
return False
|
|
1131
|
-
return True
|
|
1132
|
-
return True
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
def _shortest_event_before_state_violation(model, max_states, invariant):
|
|
1136
|
-
init = _initial_state(model)
|
|
1137
|
-
if init['state'] == invariant['state']:
|
|
1138
|
-
return {'invariant': invariant, 'path': [],
|
|
1139
|
-
'reason': 'Target state ' + invariant['state'] + ' is initial and the required event ' + invariant['event'] + ' has not occurred.'}
|
|
1140
|
-
key = lambda runtime, seen: _runtime_key(runtime) + '|' + ('1' if seen else '0')
|
|
1141
|
-
visited = set([key(init, False)])
|
|
1142
|
-
queue = deque([{'runtime': init, 'seen': False, 'path': []}])
|
|
1143
|
-
steps = 0
|
|
1144
|
-
while queue:
|
|
1145
|
-
entry = queue.popleft()
|
|
1146
|
-
steps += 1
|
|
1147
|
-
if steps > max_states:
|
|
1148
|
-
break
|
|
1149
|
-
for event in _all_events(model):
|
|
1150
|
-
for nxt in _step_runtime(model, entry['runtime'], event):
|
|
1151
|
-
seen = entry['seen'] or event == invariant['event']
|
|
1152
|
-
nk = key(nxt, seen)
|
|
1153
|
-
if nk in visited:
|
|
1154
|
-
continue
|
|
1155
|
-
p = list(entry['path'])
|
|
1156
|
-
p.append({'from': entry['runtime']['state'], 'event': event, 'to': nxt['state']})
|
|
1157
|
-
if nxt['state'] == invariant['state'] and not seen:
|
|
1158
|
-
return {'invariant': invariant, 'path': p,
|
|
1159
|
-
'reason': 'Target state ' + invariant['state'] + ' is reachable without event ' + invariant['event'] + ' in ' + str(len(p)) + ' steps.'}
|
|
1160
|
-
visited.add(nk)
|
|
1161
|
-
queue.append({'runtime': nxt, 'seen': seen, 'path': p})
|
|
1162
|
-
return None
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
def _shortest_violation_for_invariant(model, max_states, invariant):
|
|
1166
|
-
if invariant['kind'] == 'event-before-state':
|
|
1167
|
-
return _shortest_event_before_state_violation(model, max_states, invariant)
|
|
1168
|
-
init = _initial_state(model)
|
|
1169
|
-
if not _invariant_holds(invariant, init, True):
|
|
1170
|
-
return {'invariant': invariant, 'path': [], 'reason': 'Initial state violates the invariant.'}
|
|
1171
|
-
visited = set([_runtime_key(init)])
|
|
1172
|
-
queue = deque([{'runtime': init, 'path': []}])
|
|
1173
|
-
steps = 0
|
|
1174
|
-
while queue:
|
|
1175
|
-
entry = queue.popleft()
|
|
1176
|
-
steps += 1
|
|
1177
|
-
if steps > max_states:
|
|
1178
|
-
break
|
|
1179
|
-
for event in _all_events(model):
|
|
1180
|
-
for nxt in _step_runtime(model, entry['runtime'], event):
|
|
1181
|
-
k = _runtime_key(nxt)
|
|
1182
|
-
if k in visited:
|
|
1183
|
-
continue
|
|
1184
|
-
p = list(entry['path'])
|
|
1185
|
-
p.append({'from': entry['runtime']['state'], 'event': event, 'to': nxt['state']})
|
|
1186
|
-
if not _invariant_holds(invariant, nxt):
|
|
1187
|
-
return {'invariant': invariant, 'path': p, 'reason': 'Invariant violated after ' + str(len(p)) + ' events.'}
|
|
1188
|
-
visited.add(k)
|
|
1189
|
-
queue.append({'runtime': nxt, 'path': p})
|
|
1190
|
-
return None
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
def _run_invariants(model, max_states):
|
|
1194
|
-
violations = []
|
|
1195
|
-
for invariant in model.get('invariants') or []:
|
|
1196
|
-
violation = _shortest_violation_for_invariant(model, max_states, invariant)
|
|
1197
|
-
if violation is not None:
|
|
1198
|
-
violations.append(violation)
|
|
1199
|
-
return violations
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
def S7_invariants(model, max_states):
|
|
1203
|
-
violations = _run_invariants(model, max_states)
|
|
1204
|
-
findings = []
|
|
1205
|
-
for violation in violations:
|
|
1206
|
-
invariant = violation['invariant']
|
|
1207
|
-
findings.append({
|
|
1208
|
-
'code': 'S7_INVARIANT_VIOLATION',
|
|
1209
|
-
'severity': 'error',
|
|
1210
|
-
'message': 'Invariant "' + invariant['id'] + '" (' + invariant['description'] + ') violated: ' + violation['reason'],
|
|
1211
|
-
'path': violation['path'],
|
|
1212
|
-
'evidence': {'invariant': invariant},
|
|
1213
|
-
})
|
|
1214
|
-
return _check_result('S7', 'Invariant Validity', findings,
|
|
1215
|
-
'All invariants hold' if not violations else 'Invariant violations: ' + str(len(violations)))
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
# ---------------------------------------------------------------------------
|
|
1219
|
-
# A1-A7
|
|
1220
|
-
# ---------------------------------------------------------------------------
|
|
1221
|
-
|
|
1222
|
-
def A1_unexpected_events(model):
|
|
1223
|
-
events = _all_events(model)
|
|
1224
|
-
findings = []
|
|
1225
|
-
for state in model.get('states') or []:
|
|
1226
|
-
if state.get('terminal') is True:
|
|
1227
|
-
continue
|
|
1228
|
-
handled = set(t['event'] for t in model.get('transitions') or [] if t['from'] == state['id'])
|
|
1229
|
-
for event in events:
|
|
1230
|
-
if event not in handled:
|
|
1231
|
-
findings.append({
|
|
1232
|
-
'code': 'A1_UNHANDLED_EVENT',
|
|
1233
|
-
'severity': 'warning',
|
|
1234
|
-
'message': 'Event ' + event + ' in state ' + state['id'] + ' has no defined transition (silent ignore).',
|
|
1235
|
-
})
|
|
1236
|
-
return _check_result('A1', 'Unexpected Event Injection', findings,
|
|
1237
|
-
'All event/state combinations defined' if not findings else str(len(findings)) + ' unhandled combinations')
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
def _first_outcome(runtime, outcomes):
|
|
1241
|
-
return outcomes[0] if outcomes else None
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
def A2_race_interleaving(model, exploration):
|
|
1245
|
-
findings = []
|
|
1246
|
-
for pair in model.get('concurrentPairs') or []:
|
|
1247
|
-
e1, e2 = pair[0], pair[1]
|
|
1248
|
-
for runtime in exploration['reachable']:
|
|
1249
|
-
first = _first_outcome(runtime, _step_runtime(model, runtime, e1))
|
|
1250
|
-
second = _first_outcome(runtime, _step_runtime(model, runtime, e2))
|
|
1251
|
-
if first is None and second is None:
|
|
1252
|
-
continue
|
|
1253
|
-
final12 = runtime if first is None else (_first_outcome(first, _step_runtime(model, first, e2)) or first)
|
|
1254
|
-
final21 = runtime if second is None else (_first_outcome(second, _step_runtime(model, second, e1)) or second)
|
|
1255
|
-
if _runtime_key(final12) != _runtime_key(final21):
|
|
1256
|
-
findings.append({
|
|
1257
|
-
'code': 'A2_ORDER_DEPENDENT',
|
|
1258
|
-
'severity': 'warning',
|
|
1259
|
-
'message': 'Events ' + e1 + ' and ' + e2 + ' produce order-dependent outcomes from state ' + runtime['state'],
|
|
1260
|
-
'evidence': {'e1ThenE2': final12, 'e2ThenE1': final21},
|
|
1261
|
-
})
|
|
1262
|
-
return _check_result('A2', 'Race Interleaving', findings,
|
|
1263
|
-
'No race conditions detected' if not findings else 'Order-dependent outcomes: ' + str(len(findings)))
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
def _step_sequence(model, init, events):
|
|
1267
|
-
runtime = init
|
|
1268
|
-
for event in events:
|
|
1269
|
-
nxt = _first_outcome(runtime, _step_runtime(model, runtime, event))
|
|
1270
|
-
if nxt is None:
|
|
1271
|
-
continue
|
|
1272
|
-
runtime = nxt
|
|
1273
|
-
return runtime
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
def _permutations(items):
|
|
1277
|
-
if len(items) == 0:
|
|
1278
|
-
return [[]]
|
|
1279
|
-
result = []
|
|
1280
|
-
for index in range(len(items)):
|
|
1281
|
-
rest = items[:index] + items[index + 1:]
|
|
1282
|
-
for suffix in _permutations(rest):
|
|
1283
|
-
result.append([items[index]] + suffix)
|
|
1284
|
-
return result
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
def A3_order_permutation(model, max_permutation_events):
|
|
1288
|
-
events = _all_events(model)[:max_permutation_events]
|
|
1289
|
-
if len(events) < 2:
|
|
1290
|
-
return _check_result('A3', 'Order Permutation', [], 'Fewer than 2 events — skipped')
|
|
1291
|
-
init = _initial_state(model)
|
|
1292
|
-
outcomes = {}
|
|
1293
|
-
for permutation in _permutations(events):
|
|
1294
|
-
final = _step_sequence(model, init, permutation)
|
|
1295
|
-
k = _runtime_key(final)
|
|
1296
|
-
lst = outcomes.setdefault(k, [])
|
|
1297
|
-
if len(lst) < 3:
|
|
1298
|
-
lst.append(','.join(permutation))
|
|
1299
|
-
if len(outcomes) > 1:
|
|
1300
|
-
examples = [{'final': k.split('|')[0], 'example': lst[0]} for k, lst in outcomes.items()]
|
|
1301
|
-
findings = [{
|
|
1302
|
-
'code': 'A3_ORDER_DEPENDENT',
|
|
1303
|
-
'severity': 'warning',
|
|
1304
|
-
'message': 'Same event set produces ' + str(len(outcomes)) + ' different outcomes depending on order.',
|
|
1305
|
-
'evidence': {'examples': examples},
|
|
1306
|
-
}]
|
|
1307
|
-
return _check_result('A3', 'Order Permutation', findings, 'Order-dependent outcomes: ' + str(len(outcomes)))
|
|
1308
|
-
return _check_result('A3', 'Order Permutation', [], 'Order-independent (sampled first ' + str(len(events)) + ' events)')
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
def _state_action_list(model, state_id, kind):
|
|
1312
|
-
state = _state_by_id(model, state_id)
|
|
1313
|
-
if state is None:
|
|
1314
|
-
return []
|
|
1315
|
-
return list(state.get(kind) or [])
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
def _actions_declared(model):
|
|
1319
|
-
for state in model.get('states') or []:
|
|
1320
|
-
if len(state.get('onEntry') or []) > 0 or len(state.get('onExit') or []) > 0:
|
|
1321
|
-
return True
|
|
1322
|
-
return False
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
def _apply_action_list(action_list, acquire_event, release_event, held):
|
|
1326
|
-
nxt = held
|
|
1327
|
-
reacquired = False
|
|
1328
|
-
for action in action_list:
|
|
1329
|
-
if action == acquire_event:
|
|
1330
|
-
if nxt:
|
|
1331
|
-
reacquired = True
|
|
1332
|
-
nxt = True
|
|
1333
|
-
elif action == release_event:
|
|
1334
|
-
nxt = False
|
|
1335
|
-
return {'held': nxt, 'reacquired': reacquired}
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
def _state_graph_edges(model):
|
|
1339
|
-
edges = {}
|
|
1340
|
-
for state in model.get('states') or []:
|
|
1341
|
-
edges[state['id']] = []
|
|
1342
|
-
for transition in model.get('transitions') or []:
|
|
1343
|
-
edges.setdefault(transition['from'], []).append(transition)
|
|
1344
|
-
return edges
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
def _state_graph_reachable(model, start):
|
|
1348
|
-
edges = {}
|
|
1349
|
-
for state in model.get('states') or []:
|
|
1350
|
-
edges[state['id']] = []
|
|
1351
|
-
for transition in model.get('transitions') or []:
|
|
1352
|
-
edges.setdefault(transition['from'], []).append(transition['to'])
|
|
1353
|
-
visited = set()
|
|
1354
|
-
queue = deque([start])
|
|
1355
|
-
while queue:
|
|
1356
|
-
current = queue.popleft()
|
|
1357
|
-
if current in visited:
|
|
1358
|
-
continue
|
|
1359
|
-
visited.add(current)
|
|
1360
|
-
for nxt in edges.get(current) or []:
|
|
1361
|
-
queue.append(nxt)
|
|
1362
|
-
return visited
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
def A4_pair_symmetry(model):
|
|
1366
|
-
findings = []
|
|
1367
|
-
has_actions = _actions_declared(model)
|
|
1368
|
-
transition_events = set(t['event'] for t in model.get('transitions') or [])
|
|
1369
|
-
action_events = set()
|
|
1370
|
-
if has_actions:
|
|
1371
|
-
for state in model.get('states') or []:
|
|
1372
|
-
for action in _state_action_list(model, state['id'], 'onEntry'):
|
|
1373
|
-
action_events.add(action)
|
|
1374
|
-
for action in _state_action_list(model, state['id'], 'onExit'):
|
|
1375
|
-
action_events.add(action)
|
|
1376
|
-
|
|
1377
|
-
def pair_has_event(event):
|
|
1378
|
-
return event in transition_events or event in action_events
|
|
1379
|
-
|
|
1380
|
-
def release_reachable_from(state_id, release_event):
|
|
1381
|
-
reachable = _state_graph_reachable(model, state_id)
|
|
1382
|
-
for state in reachable:
|
|
1383
|
-
if any(t['from'] == state and t['event'] == release_event for t in model.get('transitions') or []):
|
|
1384
|
-
return True
|
|
1385
|
-
if has_actions:
|
|
1386
|
-
if release_event in _state_action_list(model, state, 'onEntry') or release_event in _state_action_list(model, state, 'onExit'):
|
|
1387
|
-
return True
|
|
1388
|
-
return False
|
|
1389
|
-
|
|
1390
|
-
edges = _state_graph_edges(model)
|
|
1391
|
-
for pair in model.get('resourcePairs') or []:
|
|
1392
|
-
acquire_event = pair['acquireEvent']
|
|
1393
|
-
release_event = pair['releaseEvent']
|
|
1394
|
-
acquire_transitions = [t for t in model.get('transitions') or [] if t['event'] == acquire_event]
|
|
1395
|
-
action_acquire = False
|
|
1396
|
-
if has_actions:
|
|
1397
|
-
for state in model.get('states') or []:
|
|
1398
|
-
if acquire_event in _state_action_list(model, state['id'], 'onEntry') or acquire_event in _state_action_list(model, state['id'], 'onExit'):
|
|
1399
|
-
action_acquire = True
|
|
1400
|
-
break
|
|
1401
|
-
release_exists = pair_has_event(release_event)
|
|
1402
|
-
if not acquire_transitions and not action_acquire and not release_exists:
|
|
1403
|
-
continue
|
|
1404
|
-
if (acquire_transitions or action_acquire) and not release_exists:
|
|
1405
|
-
findings.append({
|
|
1406
|
-
'code': 'A4_NO_RELEASE_EVENT',
|
|
1407
|
-
'severity': 'error',
|
|
1408
|
-
'message': 'Resource "' + pair['resource'] + '": acquire event ' + acquire_event + ' exists but release event ' + release_event + ' is never defined.',
|
|
1409
|
-
})
|
|
1410
|
-
continue
|
|
1411
|
-
seeds = []
|
|
1412
|
-
for acquire in acquire_transitions:
|
|
1413
|
-
if not release_reachable_from(acquire['to'], release_event):
|
|
1414
|
-
findings.append({
|
|
1415
|
-
'code': 'A4_NO_RELEASE_REACHABLE',
|
|
1416
|
-
'severity': 'error',
|
|
1417
|
-
'message': 'Resource "' + pair['resource'] + '": after ' + acquire_event + ' into ' + acquire['to'] + ', no ' + release_event + ' is reachable.',
|
|
1418
|
-
'evidence': {'acquireTransition': acquire},
|
|
1419
|
-
})
|
|
1420
|
-
continue
|
|
1421
|
-
seeds.append({'state': acquire['to'], 'held': True, 'path': []})
|
|
1422
|
-
if has_actions:
|
|
1423
|
-
for state in model.get('states') or []:
|
|
1424
|
-
entry = _state_action_list(model, state['id'], 'onEntry')
|
|
1425
|
-
if acquire_event in entry:
|
|
1426
|
-
sim = _apply_action_list(entry, acquire_event, release_event, False)
|
|
1427
|
-
if sim['reacquired']:
|
|
1428
|
-
findings.append({
|
|
1429
|
-
'code': 'A4_REACQUIRE_WITHOUT_RELEASE',
|
|
1430
|
-
'severity': 'warning',
|
|
1431
|
-
'message': 'Resource "' + pair['resource'] + '" is acquired more than once inside onEntry of ' + state['id'] + ' before ' + release_event + '.',
|
|
1432
|
-
})
|
|
1433
|
-
if sim['held']:
|
|
1434
|
-
seeds.append({'state': state['id'], 'held': True, 'path': []})
|
|
1435
|
-
exit_list = _state_action_list(model, state['id'], 'onExit')
|
|
1436
|
-
if acquire_event in exit_list and state.get('terminal') is not True:
|
|
1437
|
-
sim = _apply_action_list(exit_list, acquire_event, release_event, False)
|
|
1438
|
-
if sim['reacquired']:
|
|
1439
|
-
findings.append({
|
|
1440
|
-
'code': 'A4_REACQUIRE_WITHOUT_RELEASE',
|
|
1441
|
-
'severity': 'warning',
|
|
1442
|
-
'message': 'Resource "' + pair['resource'] + '" is acquired more than once inside onExit of ' + state['id'] + ' before ' + release_event + '.',
|
|
1443
|
-
})
|
|
1444
|
-
if sim['held']:
|
|
1445
|
-
seeds.append({'state': state['id'], 'held': False, 'path': []})
|
|
1446
|
-
for seed in seeds:
|
|
1447
|
-
visited = set()
|
|
1448
|
-
queue = deque([seed])
|
|
1449
|
-
steps = 0
|
|
1450
|
-
while queue:
|
|
1451
|
-
entry = queue.popleft()
|
|
1452
|
-
steps += 1
|
|
1453
|
-
if steps > 1000:
|
|
1454
|
-
break
|
|
1455
|
-
k = entry['state'] + '|' + ('1' if entry['held'] else '0')
|
|
1456
|
-
if k in visited:
|
|
1457
|
-
continue
|
|
1458
|
-
visited.add(k)
|
|
1459
|
-
if entry['held'] and _is_terminal(model, entry['state']):
|
|
1460
|
-
findings.append({
|
|
1461
|
-
'code': 'A4_TERMINAL_WITH_RESOURCE',
|
|
1462
|
-
'severity': 'error',
|
|
1463
|
-
'message': 'Resource "' + pair['resource'] + '" is still held when entering terminal state ' + entry['state'] + '.',
|
|
1464
|
-
'path': entry['path'],
|
|
1465
|
-
})
|
|
1466
|
-
continue
|
|
1467
|
-
for transition in edges.get(entry['state']) or []:
|
|
1468
|
-
held = entry['held']
|
|
1469
|
-
if has_actions:
|
|
1470
|
-
sim = _apply_action_list(_state_action_list(model, entry['state'], 'onExit'), acquire_event, release_event, held)
|
|
1471
|
-
if sim['reacquired']:
|
|
1472
|
-
findings.append({
|
|
1473
|
-
'code': 'A4_REACQUIRE_WITHOUT_RELEASE',
|
|
1474
|
-
'severity': 'warning',
|
|
1475
|
-
'message': 'Resource "' + pair['resource'] + '" is acquired again in onExit of ' + entry['state'] + ' before ' + release_event + '.',
|
|
1476
|
-
'path': entry['path'],
|
|
1477
|
-
})
|
|
1478
|
-
continue
|
|
1479
|
-
held = sim['held']
|
|
1480
|
-
if transition['event'] == release_event:
|
|
1481
|
-
held = False
|
|
1482
|
-
elif transition['event'] == acquire_event:
|
|
1483
|
-
if held:
|
|
1484
|
-
findings.append({
|
|
1485
|
-
'code': 'A4_REACQUIRE_WITHOUT_RELEASE',
|
|
1486
|
-
'severity': 'warning',
|
|
1487
|
-
'message': 'Resource "' + pair['resource'] + '" is acquired again in state ' + entry['state'] + ' before ' + release_event + '.',
|
|
1488
|
-
'path': list(entry['path']) + [{'from': entry['state'], 'event': transition['event'], 'to': transition['to']}],
|
|
1489
|
-
})
|
|
1490
|
-
continue
|
|
1491
|
-
held = True
|
|
1492
|
-
if has_actions:
|
|
1493
|
-
sim = _apply_action_list(_state_action_list(model, transition['to'], 'onEntry'), acquire_event, release_event, held)
|
|
1494
|
-
if sim['reacquired']:
|
|
1495
|
-
findings.append({
|
|
1496
|
-
'code': 'A4_REACQUIRE_WITHOUT_RELEASE',
|
|
1497
|
-
'severity': 'warning',
|
|
1498
|
-
'message': 'Resource "' + pair['resource'] + '" is acquired again inside onEntry of ' + transition['to'] + ' before ' + release_event + '.',
|
|
1499
|
-
'path': list(entry['path']) + [{'from': entry['state'], 'event': transition['event'], 'to': transition['to']}],
|
|
1500
|
-
})
|
|
1501
|
-
continue
|
|
1502
|
-
held = sim['held']
|
|
1503
|
-
queue.append({'state': transition['to'], 'held': held,
|
|
1504
|
-
'path': list(entry['path']) + [{'from': entry['state'], 'event': transition['event'], 'to': transition['to']}]})
|
|
1505
|
-
return _check_result('A4', 'Pair Symmetry', findings,
|
|
1506
|
-
'All pairs balanced' if not findings else 'Asymmetric pairs: ' + str(len(findings)))
|
|
1507
|
-
|
|
1508
|
-
|
|
1509
|
-
def A5_boundary_blast(model):
|
|
1510
|
-
findings = []
|
|
1511
|
-
for check in model.get('boundaryChecks') or []:
|
|
1512
|
-
variable = None
|
|
1513
|
-
for v in model.get('variables') or []:
|
|
1514
|
-
if v['name'] == check['variable']:
|
|
1515
|
-
variable = v
|
|
1516
|
-
break
|
|
1517
|
-
if variable is None:
|
|
1518
|
-
continue
|
|
1519
|
-
for value in check['values']:
|
|
1520
|
-
mn = variable.get('min')
|
|
1521
|
-
mx = variable.get('max')
|
|
1522
|
-
if mn is not None and value < mn:
|
|
1523
|
-
findings.append({'code': 'A5_OUT_OF_DOMAIN', 'severity': 'warning',
|
|
1524
|
-
'message': 'Boundary value ' + js_number(value) + ' for ' + check['variable'] + ' is below min ' + js_number(mn)})
|
|
1525
|
-
if mx is not None and value > mx:
|
|
1526
|
-
findings.append({'code': 'A5_OUT_OF_DOMAIN', 'severity': 'warning',
|
|
1527
|
-
'message': 'Boundary value ' + js_number(value) + ' for ' + check['variable'] + ' is above max ' + js_number(mx)})
|
|
1528
|
-
assignment = {}
|
|
1529
|
-
for v in model.get('variables') or []:
|
|
1530
|
-
assignment[v['name']] = v['init']
|
|
1531
|
-
assignment[check['variable']] = value
|
|
1532
|
-
for key, group in _group_transitions(model).items():
|
|
1533
|
-
sep = key.find('|')
|
|
1534
|
-
frm = key[:sep]
|
|
1535
|
-
event = key[sep + 1:]
|
|
1536
|
-
if not any(check['variable'] in guard_variables(t.get('guard')) for t in group):
|
|
1537
|
-
continue
|
|
1538
|
-
guarded = [t for t in group if t.get('guard') is not None]
|
|
1539
|
-
unguarded = [t for t in group if t.get('guard') is None]
|
|
1540
|
-
matched = [t for t in guarded if _eval_guard(t['guard'], assignment)]
|
|
1541
|
-
if not matched and not unguarded:
|
|
1542
|
-
findings.append({
|
|
1543
|
-
'code': 'A5_GUARD_HOLE',
|
|
1544
|
-
'severity': 'warning',
|
|
1545
|
-
'message': frm + ' + ' + event + ' has no branch for ' + check['variable'] + '=' + js_number(value),
|
|
1546
|
-
'evidence': {'assignment': assignment},
|
|
1547
|
-
})
|
|
1548
|
-
if len(matched) > 1:
|
|
1549
|
-
findings.append({
|
|
1550
|
-
'code': 'A5_GUARD_OVERLAP',
|
|
1551
|
-
'severity': 'warning',
|
|
1552
|
-
'message': frm + ' + ' + event + ' has ' + str(len(matched)) + ' true branches for ' + check['variable'] + '=' + js_number(value),
|
|
1553
|
-
'evidence': {'assignment': assignment},
|
|
1554
|
-
})
|
|
1555
|
-
return _check_result('A5', 'Boundary Blast', findings,
|
|
1556
|
-
'Boundary checks passed' if not findings else 'Boundary findings: ' + str(len(findings)))
|
|
1557
|
-
|
|
1558
|
-
|
|
1559
|
-
def A6_resource_injection(model):
|
|
1560
|
-
findings = []
|
|
1561
|
-
for pair in model.get('resourcePairs') or []:
|
|
1562
|
-
if pair.get('failEvent') is None:
|
|
1563
|
-
continue
|
|
1564
|
-
acquire_states = set(t['from'] for t in model.get('transitions') or [] if t['event'] == pair['acquireEvent'])
|
|
1565
|
-
for state in acquire_states:
|
|
1566
|
-
handled = any(t['from'] == state and t['event'] == pair['failEvent'] for t in model.get('transitions') or [])
|
|
1567
|
-
if not handled:
|
|
1568
|
-
findings.append({
|
|
1569
|
-
'code': 'A6_NO_FAILURE_HANDLER',
|
|
1570
|
-
'severity': 'warning',
|
|
1571
|
-
'message': 'State ' + state + ' can ' + pair['acquireEvent'] + ' for "' + pair['resource'] + '" but has no ' + pair['failEvent'] + ' transition.',
|
|
1572
|
-
})
|
|
1573
|
-
return _check_result('A6', 'Resource Injection', findings,
|
|
1574
|
-
'No resource vulnerabilities detected' if not findings else 'Resource failure paths missing: ' + str(len(findings)))
|
|
1575
|
-
|
|
1576
|
-
|
|
1577
|
-
def A7_shortest_violations(model, max_states):
|
|
1578
|
-
violations = _run_invariants(model, max_states)
|
|
1579
|
-
findings = []
|
|
1580
|
-
for violation in violations:
|
|
1581
|
-
invariant = violation['invariant']
|
|
1582
|
-
findings.append({
|
|
1583
|
-
'code': 'A7_SHORTEST_COUNTEREXAMPLE',
|
|
1584
|
-
'severity': 'warning',
|
|
1585
|
-
'message': 'Invariant "' + invariant['id'] + '" shortest violating path length: ' + str(len(violation['path'])) + (' (initial state)' if len(violation['path']) == 0 else ''),
|
|
1586
|
-
'path': violation['path'],
|
|
1587
|
-
'evidence': {'invariant': invariant['id']},
|
|
1588
|
-
})
|
|
1589
|
-
return _check_result('A7', 'Minimal Counter-Example', findings,
|
|
1590
|
-
'All invariants hold for all reachable paths' if not violations else 'Violated invariants: ' + str(len(findings)))
|
|
1591
|
-
|
|
1592
|
-
|
|
1593
|
-
def _map_state_id(mapping, state):
|
|
1594
|
-
return mapping.get(state, state)
|
|
1595
|
-
|
|
1596
|
-
|
|
1597
|
-
def D1_behavioral_preservation(before, after, mapping):
|
|
1598
|
-
findings = []
|
|
1599
|
-
after_states = set(s['id'] for s in after.get('states') or [])
|
|
1600
|
-
after_by_from_event = {}
|
|
1601
|
-
for transition in after.get('transitions') or []:
|
|
1602
|
-
k = transition['from'] + '|' + transition['event']
|
|
1603
|
-
after_by_from_event.setdefault(k, set()).add(transition['to'])
|
|
1604
|
-
before_init_mapped = _map_state_id(mapping, before['init'])
|
|
1605
|
-
if before_init_mapped != after['init']:
|
|
1606
|
-
findings.append({
|
|
1607
|
-
'code': 'D1_INIT_MISMATCH',
|
|
1608
|
-
'severity': 'warning',
|
|
1609
|
-
'message': 'Mapped BEFORE init ' + before_init_mapped + ' does not match AFTER init ' + after['init'] + '.',
|
|
1610
|
-
'evidence': {'beforeInit': before['init'], 'mappedInit': before_init_mapped, 'afterInit': after['init']},
|
|
1611
|
-
})
|
|
1612
|
-
for state in before.get('states') or []:
|
|
1613
|
-
mapped = _map_state_id(mapping, state['id'])
|
|
1614
|
-
if mapped not in after_states:
|
|
1615
|
-
findings.append({
|
|
1616
|
-
'code': 'D1_MAPPED_STATE_MISSING',
|
|
1617
|
-
'severity': 'error',
|
|
1618
|
-
'message': 'BEFORE state ' + state['id'] + ' maps to ' + mapped + ', which is not declared in AFTER.',
|
|
1619
|
-
'evidence': {'beforeState': state['id'], 'mappedState': mapped},
|
|
1620
|
-
})
|
|
1621
|
-
continue
|
|
1622
|
-
for transition in [t for t in before.get('transitions') or [] if t['from'] == state['id']]:
|
|
1623
|
-
k = mapped + '|' + transition['event']
|
|
1624
|
-
targets = after_by_from_event.get(k)
|
|
1625
|
-
if targets is None or len(targets) == 0:
|
|
1626
|
-
findings.append({
|
|
1627
|
-
'code': 'D1_EVENT_DISABLED',
|
|
1628
|
-
'severity': 'error',
|
|
1629
|
-
'message': 'BEFORE can fire event ' + transition['event'] + ' from ' + state['id'] + ' (mapped to ' + mapped + '), but AFTER has no transition for that (state, event).',
|
|
1630
|
-
'path': [{'from': state['id'], 'event': transition['event'], 'to': transition['to']}],
|
|
1631
|
-
'evidence': {'beforeState': state['id'], 'mappedState': mapped, 'event': transition['event']},
|
|
1632
|
-
})
|
|
1633
|
-
return _check_result('D1', 'Behavioral Preservation', findings,
|
|
1634
|
-
'BEFORE event behavior is preserved in AFTER' if not findings else 'Behavioral preservation findings: ' + str(len(findings)))
|
|
1635
|
-
|
|
1636
|
-
|
|
1637
|
-
def _map_invariant_for_comparison(invariant, mapping):
|
|
1638
|
-
out = dict(invariant)
|
|
1639
|
-
out['id'] = invariant['id'] + ':before'
|
|
1640
|
-
out['description'] = invariant['description'] + ' (from BEFORE)'
|
|
1641
|
-
if invariant['kind'] == 'never-states':
|
|
1642
|
-
out['states'] = [_map_state_id(mapping, s) for s in invariant['states']]
|
|
1643
|
-
elif invariant['kind'] == 'event-before-state':
|
|
1644
|
-
out['state'] = _map_state_id(mapping, invariant['state'])
|
|
1645
|
-
elif invariant['kind'] == 'var-in-range':
|
|
1646
|
-
# A state scope must follow the state rename, otherwise D2 reports a spurious
|
|
1647
|
-
# regression when the scope later refers to a state id that no longer exists.
|
|
1648
|
-
when = invariant.get('when')
|
|
1649
|
-
if when is not None and 'state' in when:
|
|
1650
|
-
out['when'] = {'state': _map_state_id(mapping, when['state'])}
|
|
1651
|
-
return out
|
|
1652
|
-
|
|
1653
|
-
|
|
1654
|
-
def D2_invariant_continuity(before, after, max_states, mapping):
|
|
1655
|
-
findings = []
|
|
1656
|
-
after_states = set(s['id'] for s in after.get('states') or [])
|
|
1657
|
-
after_variables = set(v['name'] for v in after.get('variables') or [])
|
|
1658
|
-
for invariant in before.get('invariants') or []:
|
|
1659
|
-
mapped = _map_invariant_for_comparison(invariant, mapping)
|
|
1660
|
-
if mapped['kind'] == 'never-states':
|
|
1661
|
-
for state in mapped['states']:
|
|
1662
|
-
if state not in after_states:
|
|
1663
|
-
findings.append({
|
|
1664
|
-
'code': 'D2_MAPPED_STATE_MISSING',
|
|
1665
|
-
'severity': 'warning',
|
|
1666
|
-
'message': 'BEFORE invariant "' + invariant['id'] + '" maps to state ' + state + ', which is not declared in AFTER.',
|
|
1667
|
-
'evidence': {'invariant': invariant['id'], 'state': state},
|
|
1668
|
-
})
|
|
1669
|
-
elif mapped['kind'] == 'event-before-state':
|
|
1670
|
-
if mapped['state'] not in after_states:
|
|
1671
|
-
findings.append({
|
|
1672
|
-
'code': 'D2_MAPPED_STATE_MISSING',
|
|
1673
|
-
'severity': 'warning',
|
|
1674
|
-
'message': 'BEFORE invariant "' + invariant['id'] + '" maps to state ' + mapped['state'] + ', which is not declared in AFTER.',
|
|
1675
|
-
'evidence': {'invariant': invariant['id'], 'state': mapped['state']},
|
|
1676
|
-
})
|
|
1677
|
-
elif mapped['kind'] == 'var-in-range':
|
|
1678
|
-
if mapped['variable'] not in after_variables:
|
|
1679
|
-
findings.append({
|
|
1680
|
-
'code': 'D2_VARIABLE_MISSING',
|
|
1681
|
-
'severity': 'warning',
|
|
1682
|
-
'message': 'BEFORE invariant "' + invariant['id'] + '" references variable ' + mapped['variable'] + ', which is not declared in AFTER.',
|
|
1683
|
-
'evidence': {'invariant': invariant['id'], 'variable': mapped['variable']},
|
|
1684
|
-
})
|
|
1685
|
-
continue
|
|
1686
|
-
violation = _shortest_violation_for_invariant(after, max_states, mapped)
|
|
1687
|
-
if violation is not None:
|
|
1688
|
-
findings.append({
|
|
1689
|
-
'code': 'D2_INVARIANT_REGRESSION',
|
|
1690
|
-
'severity': 'error',
|
|
1691
|
-
'message': 'BEFORE invariant "' + invariant['id'] + '" no longer holds in AFTER: ' + violation['reason'],
|
|
1692
|
-
'path': violation['path'],
|
|
1693
|
-
'evidence': {'beforeInvariant': invariant, 'afterInvariant': mapped},
|
|
1694
|
-
})
|
|
1695
|
-
return _check_result('D2', 'Invariant Continuity', findings,
|
|
1696
|
-
'All BEFORE invariants continue to hold' if not findings else 'Invariant continuity findings: ' + str(len(findings)))
|
|
1697
|
-
|
|
1698
|
-
|
|
1699
|
-
def D3_regression_delta(before, after, mapping):
|
|
1700
|
-
after_state_ids = set(s['id'] for s in after.get('states') or [])
|
|
1701
|
-
before_mapped_ids = set(_map_state_id(mapping, s['id']) for s in before.get('states') or [])
|
|
1702
|
-
added_states = sorted([s['id'] for s in after.get('states') or [] if s['id'] not in before_mapped_ids])
|
|
1703
|
-
removed_states = sorted([s['id'] for s in before.get('states') or [] if _map_state_id(mapping, s['id']) not in after_state_ids])
|
|
1704
|
-
before_events = set(t['event'] for t in before.get('transitions') or [])
|
|
1705
|
-
after_events = set(t['event'] for t in after.get('transitions') or [])
|
|
1706
|
-
added_events = sorted([e for e in after_events if e not in before_events])
|
|
1707
|
-
removed_events = sorted([e for e in before_events if e not in after_events])
|
|
1708
|
-
|
|
1709
|
-
def _same_after(candidate):
|
|
1710
|
-
return any(c.get('from') == _map_state_id(mapping, t.get('from')) and c.get('event') == t.get('event') and c.get('to') == _map_state_id(mapping, t.get('to'))
|
|
1711
|
-
for t in before.get('transitions') or [] for c in [candidate])
|
|
1712
|
-
|
|
1713
|
-
removed_transitions = [t for t in before.get('transitions') or [] if not any(
|
|
1714
|
-
c.get('from') == _map_state_id(mapping, t.get('from')) and c.get('event') == t.get('event') and c.get('to') == _map_state_id(mapping, t.get('to'))
|
|
1715
|
-
for c in after.get('transitions') or [])]
|
|
1716
|
-
added_transitions = [t for t in after.get('transitions') or [] if not any(
|
|
1717
|
-
_map_state_id(mapping, c.get('from')) == t.get('from') and c.get('event') == t.get('event') and _map_state_id(mapping, c.get('to')) == t.get('to')
|
|
1718
|
-
for c in before.get('transitions') or [])]
|
|
1719
|
-
findings = []
|
|
1720
|
-
for state in removed_states:
|
|
1721
|
-
findings.append({'code': 'D3_REMOVED_STATE', 'severity': 'warning',
|
|
1722
|
-
'message': 'BEFORE state ' + state + ' is not present in AFTER under the given mapping.', 'evidence': {'state': state}})
|
|
1723
|
-
for event in removed_events:
|
|
1724
|
-
findings.append({'code': 'D3_REMOVED_EVENT', 'severity': 'warning',
|
|
1725
|
-
'message': 'BEFORE event ' + event + ' is not present in AFTER.', 'evidence': {'event': event}})
|
|
1726
|
-
for transition in removed_transitions:
|
|
1727
|
-
findings.append({
|
|
1728
|
-
'code': 'D3_REMOVED_TRANSITION',
|
|
1729
|
-
'severity': 'warning',
|
|
1730
|
-
'message': 'BEFORE transition ' + transition['from'] + ' -' + transition['event'] + '-> ' + transition['to'] + ' has no exact AFTER counterpart.',
|
|
1731
|
-
'evidence': {'transition': transition},
|
|
1732
|
-
})
|
|
1733
|
-
detail = ('Delta: +' + str(len(added_states)) + ' states, -' + str(len(removed_states)) + ' states, +' + str(len(added_events)) + ' events, -' + str(len(removed_events))
|
|
1734
|
-
+ ' events, +' + str(len(added_transitions)) + ' transitions, -' + str(len(removed_transitions)) + ' transitions')
|
|
1735
|
-
return _check_result('D3', 'Regression Delta', findings, detail)
|
|
1736
|
-
|
|
1737
|
-
|
|
1738
|
-
def _deadlock_state_ids(model):
|
|
1739
|
-
outgoing = set(t['from'] for t in model.get('transitions') or [])
|
|
1740
|
-
return [s['id'] for s in model.get('states') or [] if s.get('terminal') is not True and s['id'] not in outgoing]
|
|
1741
|
-
|
|
1742
|
-
|
|
1743
|
-
def _closed_scc_state_sets(model):
|
|
1744
|
-
out = []
|
|
1745
|
-
for component in _sccs(model):
|
|
1746
|
-
component_set = set(component)
|
|
1747
|
-
internal_edges = any(t['from'] in component_set and t['to'] in component_set for t in model.get('transitions') or [])
|
|
1748
|
-
escaping_edges = any(t['from'] in component_set and t['to'] not in component_set for t in model.get('transitions') or [])
|
|
1749
|
-
if internal_edges and not escaping_edges and not any(_is_terminal(model, s) for s in component):
|
|
1750
|
-
out.append(sorted(component))
|
|
1751
|
-
return out
|
|
1752
|
-
|
|
1753
|
-
|
|
1754
|
-
def D4_deadlock_liveness_regression(before, after, mapping):
|
|
1755
|
-
findings = []
|
|
1756
|
-
before_deadlock = set(_map_state_id(mapping, s) for s in _deadlock_state_ids(before))
|
|
1757
|
-
after_deadlock = _deadlock_state_ids(after)
|
|
1758
|
-
for state in after_deadlock:
|
|
1759
|
-
if state not in before_deadlock:
|
|
1760
|
-
findings.append({
|
|
1761
|
-
'code': 'D4_DEADLOCK_REGRESSION',
|
|
1762
|
-
'severity': 'error',
|
|
1763
|
-
'message': 'AFTER introduces deadlock in state ' + state + ' that was not deadlocked in BEFORE.',
|
|
1764
|
-
'evidence': {'state': state},
|
|
1765
|
-
})
|
|
1766
|
-
before_scc = set(','.join(sorted([_map_state_id(mapping, s) for s in component])) for component in _closed_scc_state_sets(before))
|
|
1767
|
-
after_scc = _closed_scc_state_sets(after)
|
|
1768
|
-
for component in after_scc:
|
|
1769
|
-
key = ','.join(component)
|
|
1770
|
-
if key not in before_scc:
|
|
1771
|
-
findings.append({
|
|
1772
|
-
'code': 'D4_LIVENESS_REGRESSION',
|
|
1773
|
-
'severity': 'error',
|
|
1774
|
-
'message': 'AFTER introduces a closed SCC with no exit and no terminal state: ' + key,
|
|
1775
|
-
'evidence': {'states': component},
|
|
1776
|
-
})
|
|
1777
|
-
return _check_result('D4', 'Deadlock/Liveness Regression', findings,
|
|
1778
|
-
'No new deadlock or liveness regressions' if not findings else 'Regression findings: ' + str(len(findings)))
|
|
1779
|
-
|
|
1780
|
-
|
|
1781
|
-
def _build_comparison_summary(before, after, mapping):
|
|
1782
|
-
after_state_ids = set(s['id'] for s in after.get('states') or [])
|
|
1783
|
-
before_mapped_ids = set(_map_state_id(mapping, s['id']) for s in before.get('states') or [])
|
|
1784
|
-
added_states = sorted([s['id'] for s in after.get('states') or [] if s['id'] not in before_mapped_ids])
|
|
1785
|
-
removed_states = sorted([s['id'] for s in before.get('states') or [] if _map_state_id(mapping, s['id']) not in after_state_ids])
|
|
1786
|
-
before_events = set(t['event'] for t in before.get('transitions') or [])
|
|
1787
|
-
after_events = set(t['event'] for t in after.get('transitions') or [])
|
|
1788
|
-
added_events = sorted([e for e in after_events if e not in before_events])
|
|
1789
|
-
removed_events = sorted([e for e in before_events if e not in after_events])
|
|
1790
|
-
removed_transitions = [t for t in before.get('transitions') or [] if not any(
|
|
1791
|
-
c.get('from') == _map_state_id(mapping, t.get('from')) and c.get('event') == t.get('event') and c.get('to') == _map_state_id(mapping, t.get('to'))
|
|
1792
|
-
for c in after.get('transitions') or [])]
|
|
1793
|
-
added_transitions = [t for t in after.get('transitions') or [] if not any(
|
|
1794
|
-
_map_state_id(mapping, c.get('from')) == t.get('from') and c.get('event') == t.get('event') and _map_state_id(mapping, c.get('to')) == t.get('to')
|
|
1795
|
-
for c in before.get('transitions') or [])]
|
|
1796
|
-
return {
|
|
1797
|
-
'beforeModelHash': model_hash(before),
|
|
1798
|
-
'afterModelHash': model_hash(after),
|
|
1799
|
-
'stateMapping': mapping,
|
|
1800
|
-
'beforeStates': len(before.get('states') or []),
|
|
1801
|
-
'beforeTransitions': len(before.get('transitions') or []),
|
|
1802
|
-
'afterStates': len(after.get('states') or []),
|
|
1803
|
-
'afterTransitions': len(after.get('transitions') or []),
|
|
1804
|
-
'addedStates': added_states,
|
|
1805
|
-
'removedStates': removed_states,
|
|
1806
|
-
'addedEvents': added_events,
|
|
1807
|
-
'removedEvents': removed_events,
|
|
1808
|
-
'addedTransitions': added_transitions,
|
|
1809
|
-
'removedTransitions': removed_transitions,
|
|
1810
|
-
}
|
|
1811
|
-
|
|
1812
|
-
|
|
1813
|
-
def A8_idempotent_replay(model, exploration):
|
|
1814
|
-
events = model.get('idempotentEvents') or []
|
|
1815
|
-
findings = []
|
|
1816
|
-
for event in events:
|
|
1817
|
-
for runtime in exploration['reachable']:
|
|
1818
|
-
once_options = _step_runtime(model, runtime, event)
|
|
1819
|
-
if not once_options:
|
|
1820
|
-
continue
|
|
1821
|
-
for once in once_options:
|
|
1822
|
-
twice_options = _step_runtime(model, once, event)
|
|
1823
|
-
if not twice_options:
|
|
1824
|
-
findings.append({
|
|
1825
|
-
'code': 'A8_NOT_REPLAYABLE',
|
|
1826
|
-
'severity': 'warning',
|
|
1827
|
-
'message': 'Idempotent event ' + event + ' is not replayable after first application from ' + runtime['state'] + '.',
|
|
1828
|
-
'path': [{'from': runtime['state'], 'event': event, 'to': once['state']}],
|
|
1829
|
-
'evidence': {'state': runtime['state'], 'event': event},
|
|
1830
|
-
})
|
|
1831
|
-
continue
|
|
1832
|
-
for twice in twice_options:
|
|
1833
|
-
if _runtime_key(twice) != _runtime_key(once):
|
|
1834
|
-
findings.append({
|
|
1835
|
-
'code': 'A8_NOT_IDEMPOTENT',
|
|
1836
|
-
'severity': 'error',
|
|
1837
|
-
'message': 'Idempotent event ' + event + ' changes state when applied twice from ' + runtime['state'] + '.',
|
|
1838
|
-
'path': [{'from': runtime['state'], 'event': event, 'to': once['state']},
|
|
1839
|
-
{'from': once['state'], 'event': event, 'to': twice['state']}],
|
|
1840
|
-
'evidence': {'state': runtime['state'], 'event': event, 'afterOnce': once, 'afterTwice': twice},
|
|
1841
|
-
})
|
|
1842
|
-
break
|
|
1843
|
-
return _check_result('A8', 'Idempotent Replay', findings,
|
|
1844
|
-
'Idempotent events are replay-safe' if not findings else 'Idempotent replay findings: ' + str(len(findings)))
|
|
1845
|
-
|
|
1846
|
-
|
|
1847
|
-
def S8_monotonic_variables(model):
|
|
1848
|
-
findings = []
|
|
1849
|
-
for variable in model.get('variables') or []:
|
|
1850
|
-
if variable.get('monotonic') is None:
|
|
1851
|
-
continue
|
|
1852
|
-
for transition in model.get('transitions') or []:
|
|
1853
|
-
for update in transition.get('updates') or []:
|
|
1854
|
-
if update['variable'] != variable['name']:
|
|
1855
|
-
continue
|
|
1856
|
-
if variable['monotonic'] == 'inc' and update['op'] == 'dec':
|
|
1857
|
-
findings.append({'code': 'S8_MONOTONIC_DECREASE', 'severity': 'error',
|
|
1858
|
-
'message': 'Monotonic (inc) variable ' + variable['name'] + ' is decreased by ' + transition['event'] + '.',
|
|
1859
|
-
'evidence': {'variable': variable['name'], 'transition': transition}})
|
|
1860
|
-
elif variable['monotonic'] == 'dec' and update['op'] == 'inc':
|
|
1861
|
-
findings.append({'code': 'S8_MONOTONIC_INCREASE', 'severity': 'error',
|
|
1862
|
-
'message': 'Monotonic (dec) variable ' + variable['name'] + ' is increased by ' + transition['event'] + '.',
|
|
1863
|
-
'evidence': {'variable': variable['name'], 'transition': transition}})
|
|
1864
|
-
elif update['op'] == 'set':
|
|
1865
|
-
findings.append({'code': 'S8_MONOTONIC_SET_REVIEW', 'severity': 'warning',
|
|
1866
|
-
'message': 'Monotonic variable ' + variable['name'] + ' uses set in ' + transition['event'] + '; verify it cannot move backwards.',
|
|
1867
|
-
'evidence': {'variable': variable['name'], 'transition': transition}})
|
|
1868
|
-
return _check_result('S8', 'Monotonic Variables', findings,
|
|
1869
|
-
'Monotonic variables are respected' if not findings else 'Monotonic findings: ' + str(len(findings)))
|
|
1870
|
-
|
|
1871
|
-
|
|
1872
|
-
def _find_leads_to_bad_path(model, start, target):
|
|
1873
|
-
# Find a run from start that never reaches target, or None when every run does.
|
|
1874
|
-
# The property is universal (see the A9 row in SKILL.md): one branch that loops
|
|
1875
|
-
# forever, or that stops before the target, refutes it.
|
|
1876
|
-
#
|
|
1877
|
-
# Depth-first walk of the run graph with three colours; target runs are success
|
|
1878
|
-
# leaves that are never expanded. A node reached while it is on the current walk
|
|
1879
|
-
# (GRAY) closes a cycle that avoids the target. A node with no outgoing step at
|
|
1880
|
-
# all stops the machine where it stands, which is the same violation for a
|
|
1881
|
-
# different reason. A node whose walk completed without a violation is BLACK, and
|
|
1882
|
-
# reaching it again from another branch is a shared sub-graph, not a cycle -- that
|
|
1883
|
-
# is why the colour map cannot be a plain visited set: a diamond (two branches
|
|
1884
|
-
# rejoining) is acyclic and must pass, while a genuine cycle must not. A run is
|
|
1885
|
-
# identified by its state plus its variable values.
|
|
1886
|
-
if start['state'] == target:
|
|
1887
|
-
return None
|
|
1888
|
-
GRAY = 1
|
|
1889
|
-
BLACK = 2
|
|
1890
|
-
color = {}
|
|
1891
|
-
stack = [{'runtime': start, 'path': [], 'nexts': None, 'index': 0}]
|
|
1892
|
-
color[_runtime_key(start)] = GRAY
|
|
1893
|
-
while stack:
|
|
1894
|
-
frame = stack[-1]
|
|
1895
|
-
if frame['nexts'] is None:
|
|
1896
|
-
nexts = []
|
|
1897
|
-
for event in _all_events(model):
|
|
1898
|
-
for nxt in _step_runtime(model, frame['runtime'], event):
|
|
1899
|
-
nexts.append({'next': nxt, 'event': event})
|
|
1900
|
-
frame['nexts'] = nexts
|
|
1901
|
-
if not nexts:
|
|
1902
|
-
return {'path': frame['path'], 'reason': 'Dead end before target ' + target}
|
|
1903
|
-
if frame['index'] >= len(frame['nexts']):
|
|
1904
|
-
color[_runtime_key(frame['runtime'])] = BLACK
|
|
1905
|
-
stack.pop()
|
|
1906
|
-
continue
|
|
1907
|
-
item = frame['nexts'][frame['index']]
|
|
1908
|
-
frame['index'] += 1
|
|
1909
|
-
step = {'from': frame['runtime']['state'], 'event': item['event'], 'to': item['next']['state']}
|
|
1910
|
-
if item['next']['state'] == target:
|
|
1911
|
-
continue
|
|
1912
|
-
key = _runtime_key(item['next'])
|
|
1913
|
-
seen = color.get(key)
|
|
1914
|
-
if seen == GRAY:
|
|
1915
|
-
return {'path': frame['path'] + [step], 'reason': 'Cycle avoids target ' + target}
|
|
1916
|
-
if seen == BLACK:
|
|
1917
|
-
continue
|
|
1918
|
-
color[key] = GRAY
|
|
1919
|
-
stack.append({'runtime': item['next'], 'path': frame['path'] + [step], 'nexts': None, 'index': 0})
|
|
1920
|
-
# Every branch either reached the target or rejoined a branch that did.
|
|
1921
|
-
return None
|
|
1922
|
-
|
|
1923
|
-
|
|
1924
|
-
def A9_leads_to(model, exploration):
|
|
1925
|
-
findings = []
|
|
1926
|
-
for invariant in model.get('invariants') or []:
|
|
1927
|
-
if invariant['kind'] != 'leads-to':
|
|
1928
|
-
continue
|
|
1929
|
-
for runtime in exploration['reachable']:
|
|
1930
|
-
if runtime['state'] != invariant['from']:
|
|
1931
|
-
continue
|
|
1932
|
-
bad = _find_leads_to_bad_path(model, runtime, invariant['to'])
|
|
1933
|
-
if bad is not None:
|
|
1934
|
-
findings.append({
|
|
1935
|
-
'code': 'A9_LEADS_TO_VIOLATION',
|
|
1936
|
-
'severity': 'error',
|
|
1937
|
-
'message': 'Leads-to invariant "' + invariant['id'] + '" violated from ' + invariant['from'] + ': ' + bad['reason'],
|
|
1938
|
-
'path': bad['path'],
|
|
1939
|
-
'evidence': {'invariant': invariant},
|
|
1940
|
-
})
|
|
1941
|
-
break
|
|
1942
|
-
return _check_result('A9', 'Leads-To', findings,
|
|
1943
|
-
'All leads-to invariants hold' if not findings else 'Leads-to findings: ' + str(len(findings)))
|
|
1944
|
-
|
|
1945
|
-
|
|
1946
|
-
def _find_sequence_violation(model, max_states, invariant):
|
|
1947
|
-
events = invariant['events']
|
|
1948
|
-
init = _initial_state(model)
|
|
1949
|
-
|
|
1950
|
-
def key(runtime, progress):
|
|
1951
|
-
return _runtime_key(runtime) + '|' + str(progress)
|
|
1952
|
-
|
|
1953
|
-
visited = set([key(init, 0)])
|
|
1954
|
-
queue = deque([{'runtime': init, 'progress': 0, 'path': []}])
|
|
1955
|
-
steps = 0
|
|
1956
|
-
while queue:
|
|
1957
|
-
entry = queue.popleft()
|
|
1958
|
-
steps += 1
|
|
1959
|
-
if steps > max_states:
|
|
1960
|
-
break
|
|
1961
|
-
for event in _all_events(model):
|
|
1962
|
-
for nxt in _step_runtime(model, entry['runtime'], event):
|
|
1963
|
-
progress = entry['progress']
|
|
1964
|
-
violation = False
|
|
1965
|
-
if progress < len(events) and event == events[progress]:
|
|
1966
|
-
progress += 1
|
|
1967
|
-
else:
|
|
1968
|
-
if event in events:
|
|
1969
|
-
index = events.index(event)
|
|
1970
|
-
if index > progress:
|
|
1971
|
-
violation = True
|
|
1972
|
-
p = list(entry['path'])
|
|
1973
|
-
p.append({'from': entry['runtime']['state'], 'event': event, 'to': nxt['state']})
|
|
1974
|
-
if violation:
|
|
1975
|
-
return {'invariant': invariant, 'path': p, 'reason': 'Event ' + event + ' occurred before ' + events[progress]}
|
|
1976
|
-
nk = key(nxt, progress)
|
|
1977
|
-
if nk not in visited:
|
|
1978
|
-
visited.add(nk)
|
|
1979
|
-
queue.append({'runtime': nxt, 'progress': progress, 'path': p})
|
|
1980
|
-
return None
|
|
1981
|
-
|
|
1982
|
-
|
|
1983
|
-
def A10_sequence_order(model, max_states):
|
|
1984
|
-
findings = []
|
|
1985
|
-
for invariant in model.get('invariants') or []:
|
|
1986
|
-
if invariant['kind'] != 'sequence':
|
|
1987
|
-
continue
|
|
1988
|
-
violation = _find_sequence_violation(model, max_states, invariant)
|
|
1989
|
-
if violation is not None:
|
|
1990
|
-
findings.append({
|
|
1991
|
-
'code': 'A10_SEQUENCE_VIOLATION',
|
|
1992
|
-
'severity': 'error',
|
|
1993
|
-
'message': 'Sequence invariant "' + invariant['id'] + '" violated: ' + violation['reason'],
|
|
1994
|
-
'path': violation['path'],
|
|
1995
|
-
'evidence': {'invariant': invariant},
|
|
1996
|
-
})
|
|
1997
|
-
return _check_result('A10', 'Sequence Order', findings,
|
|
1998
|
-
'All sequence invariants hold' if not findings else 'Sequence findings: ' + str(len(findings)))
|
|
1999
|
-
|
|
2000
|
-
|
|
2001
|
-
def _find_atomicity_violation(model, max_states, invariant):
|
|
2002
|
-
atomic = set(invariant['events'])
|
|
2003
|
-
init = _initial_state(model)
|
|
2004
|
-
|
|
2005
|
-
def key(runtime, started, closed):
|
|
2006
|
-
return _runtime_key(runtime) + '|' + ('1' if started else '0') + '|' + ('1' if closed else '0')
|
|
2007
|
-
|
|
2008
|
-
visited = set([key(init, False, False)])
|
|
2009
|
-
queue = deque([{'runtime': init, 'started': False, 'closed': False, 'path': []}])
|
|
2010
|
-
steps = 0
|
|
2011
|
-
while queue:
|
|
2012
|
-
entry = queue.popleft()
|
|
2013
|
-
steps += 1
|
|
2014
|
-
if steps > max_states:
|
|
2015
|
-
break
|
|
2016
|
-
for event in _all_events(model):
|
|
2017
|
-
for nxt in _step_runtime(model, entry['runtime'], event):
|
|
2018
|
-
started = entry['started'] or event in atomic
|
|
2019
|
-
closed = entry['closed'] or event == invariant['commit'] or (invariant.get('rollback') is not None and event == invariant['rollback'])
|
|
2020
|
-
p = list(entry['path'])
|
|
2021
|
-
p.append({'from': entry['runtime']['state'], 'event': event, 'to': nxt['state']})
|
|
2022
|
-
if entry['started'] and not entry['closed'] and event not in atomic and event != invariant['commit'] and event != invariant.get('rollback'):
|
|
2023
|
-
return {'invariant': invariant, 'path': p, 'reason': 'Left atomic scope via ' + event + ' without commit/rollback'}
|
|
2024
|
-
if started and not closed and _is_terminal(model, nxt['state']):
|
|
2025
|
-
return {'invariant': invariant, 'path': p, 'reason': 'Terminal state reached with incomplete atomic group'}
|
|
2026
|
-
nk = key(nxt, started, closed)
|
|
2027
|
-
if nk not in visited:
|
|
2028
|
-
visited.add(nk)
|
|
2029
|
-
queue.append({'runtime': nxt, 'started': started, 'closed': closed, 'path': p})
|
|
2030
|
-
return None
|
|
2031
|
-
|
|
2032
|
-
|
|
2033
|
-
def A11_atomicity(model, max_states):
|
|
2034
|
-
findings = []
|
|
2035
|
-
for invariant in model.get('invariants') or []:
|
|
2036
|
-
if invariant['kind'] != 'atomicity':
|
|
2037
|
-
continue
|
|
2038
|
-
violation = _find_atomicity_violation(model, max_states, invariant)
|
|
2039
|
-
if violation is not None:
|
|
2040
|
-
findings.append({
|
|
2041
|
-
'code': 'A11_ATOMICITY_VIOLATION',
|
|
2042
|
-
'severity': 'error',
|
|
2043
|
-
'message': 'Atomicity invariant "' + invariant['id'] + '" violated: ' + violation['reason'],
|
|
2044
|
-
'path': violation['path'],
|
|
2045
|
-
'evidence': {'invariant': invariant},
|
|
2046
|
-
})
|
|
2047
|
-
return _check_result('A11', 'Atomicity', findings,
|
|
2048
|
-
'All atomicity invariants hold' if not findings else 'Atomicity findings: ' + str(len(findings)))
|
|
2049
|
-
|
|
2050
|
-
|
|
2051
|
-
# ---------------------------------------------------------------------------
|
|
2052
|
-
# A12 — Budget (worst-case path cost)
|
|
2053
|
-
# ---------------------------------------------------------------------------
|
|
2054
|
-
|
|
2055
|
-
def _transition_cost(transition):
|
|
2056
|
-
return transition.get('cost', 1)
|
|
2057
|
-
|
|
2058
|
-
|
|
2059
|
-
def _runtime_graph(model, max_states):
|
|
2060
|
-
group_map = _group_transitions(model)
|
|
2061
|
-
exploration = _explore(model, max_states)
|
|
2062
|
-
edges = {}
|
|
2063
|
-
for runtime in exploration['reachable']:
|
|
2064
|
-
out = []
|
|
2065
|
-
for event in _all_events(model):
|
|
2066
|
-
group = group_map.get(runtime['state'] + '|' + event)
|
|
2067
|
-
if group is None or len(group) == 0:
|
|
2068
|
-
continue
|
|
2069
|
-
for transition in _applicable_transitions(group, runtime):
|
|
2070
|
-
nxt = _apply_updates(model, transition, runtime)
|
|
2071
|
-
out.append({
|
|
2072
|
-
'to': _runtime_key(nxt),
|
|
2073
|
-
'step': {'from': runtime['state'], 'event': transition['event'], 'to': nxt['state']},
|
|
2074
|
-
'cost': _transition_cost(transition),
|
|
2075
|
-
})
|
|
2076
|
-
edges[_runtime_key(runtime)] = out
|
|
2077
|
-
return {
|
|
2078
|
-
'edges': edges,
|
|
2079
|
-
'keys': [_runtime_key(r) for r in exploration['reachable']],
|
|
2080
|
-
'initKey': _runtime_key(exploration['initial_state']),
|
|
2081
|
-
}
|
|
2082
|
-
|
|
2083
|
-
|
|
2084
|
-
def _key_of_from_step(graph, step, target_key):
|
|
2085
|
-
for frm, out_edges in graph['edges'].items():
|
|
2086
|
-
for edge in out_edges:
|
|
2087
|
-
if (edge['to'] == target_key and edge['step'].get('event') == step.get('event')
|
|
2088
|
-
and edge['step'].get('from') == step.get('from') and edge['step'].get('to') == step.get('to')):
|
|
2089
|
-
return frm
|
|
2090
|
-
return graph['initKey']
|
|
2091
|
-
|
|
2092
|
-
|
|
2093
|
-
def _runtime_path_to_key(graph, target_key):
|
|
2094
|
-
parent = {}
|
|
2095
|
-
visited = set([graph['initKey']])
|
|
2096
|
-
queue = deque([graph['initKey']])
|
|
2097
|
-
while queue:
|
|
2098
|
-
current = queue.popleft()
|
|
2099
|
-
if current == target_key:
|
|
2100
|
-
steps = []
|
|
2101
|
-
k = target_key
|
|
2102
|
-
while k != graph['initKey']:
|
|
2103
|
-
step = parent.get(k)
|
|
2104
|
-
if step is None:
|
|
2105
|
-
break
|
|
2106
|
-
steps.insert(0, step)
|
|
2107
|
-
k = _key_of_from_step(graph, step, k)
|
|
2108
|
-
return steps
|
|
2109
|
-
for edge in graph['edges'].get(current) or []:
|
|
2110
|
-
if edge['to'] in visited:
|
|
2111
|
-
continue
|
|
2112
|
-
visited.add(edge['to'])
|
|
2113
|
-
parent[edge['to']] = edge['step']
|
|
2114
|
-
queue.append(edge['to'])
|
|
2115
|
-
return None
|
|
2116
|
-
|
|
2117
|
-
|
|
2118
|
-
def _find_unbounded_cycle(model, max_states):
|
|
2119
|
-
graph = _runtime_graph(model, max_states)
|
|
2120
|
-
color = {}
|
|
2121
|
-
for start in graph['keys']:
|
|
2122
|
-
if start in color:
|
|
2123
|
-
continue
|
|
2124
|
-
node_stack = [start]
|
|
2125
|
-
idx_stack = [0]
|
|
2126
|
-
depth_of = {start: 0}
|
|
2127
|
-
cost_at = {start: 0}
|
|
2128
|
-
prev_key = {}
|
|
2129
|
-
prev_step = {}
|
|
2130
|
-
color[start] = 1
|
|
2131
|
-
while node_stack:
|
|
2132
|
-
node = node_stack[-1]
|
|
2133
|
-
outs = graph['edges'].get(node) or []
|
|
2134
|
-
if idx_stack[-1] < len(outs):
|
|
2135
|
-
edge = outs[idx_stack[-1]]
|
|
2136
|
-
idx_stack[-1] += 1
|
|
2137
|
-
seen = color.get(edge['to'])
|
|
2138
|
-
if seen is None:
|
|
2139
|
-
color[edge['to']] = 1
|
|
2140
|
-
depth_of[edge['to']] = depth_of[node] + 1
|
|
2141
|
-
cost_at[edge['to']] = cost_at[node] + edge['cost']
|
|
2142
|
-
prev_key[edge['to']] = node
|
|
2143
|
-
prev_step[edge['to']] = edge['step']
|
|
2144
|
-
node_stack.append(edge['to'])
|
|
2145
|
-
idx_stack.append(0)
|
|
2146
|
-
elif seen == 1:
|
|
2147
|
-
cycle_cost = cost_at[node] + edge['cost'] - cost_at[edge['to']]
|
|
2148
|
-
if cycle_cost > 0:
|
|
2149
|
-
entry = _runtime_path_to_key(graph, edge['to']) or []
|
|
2150
|
-
round_steps = []
|
|
2151
|
-
cursor = node
|
|
2152
|
-
while cursor != edge['to'] and cursor in prev_step:
|
|
2153
|
-
round_steps.insert(0, prev_step[cursor])
|
|
2154
|
-
cursor = prev_key[cursor]
|
|
2155
|
-
round_steps.append(edge['step'])
|
|
2156
|
-
return list(entry) + round_steps
|
|
2157
|
-
else:
|
|
2158
|
-
color[node] = 2
|
|
2159
|
-
node_stack.pop()
|
|
2160
|
-
idx_stack.pop()
|
|
2161
|
-
depth_of.pop(node, None)
|
|
2162
|
-
cost_at.pop(node, None)
|
|
2163
|
-
prev_key.pop(node, None)
|
|
2164
|
-
prev_step.pop(node, None)
|
|
2165
|
-
return None
|
|
2166
|
-
|
|
2167
|
-
|
|
2168
|
-
def _find_budget_violation(model, max_states, invariant):
|
|
2169
|
-
group_map = _group_transitions(model)
|
|
2170
|
-
init = _initial_state(model)
|
|
2171
|
-
best_cost = {_runtime_key(init): 0}
|
|
2172
|
-
queue = deque([{'runtime': init, 'cost': 0, 'path': []}])
|
|
2173
|
-
steps = 0
|
|
2174
|
-
while queue:
|
|
2175
|
-
entry = queue.popleft()
|
|
2176
|
-
steps += 1
|
|
2177
|
-
if steps > max_states:
|
|
2178
|
-
break
|
|
2179
|
-
for event in _all_events(model):
|
|
2180
|
-
group = group_map.get(entry['runtime']['state'] + '|' + event)
|
|
2181
|
-
if group is None or len(group) == 0:
|
|
2182
|
-
continue
|
|
2183
|
-
for transition in _applicable_transitions(group, entry['runtime']):
|
|
2184
|
-
nxt = _apply_updates(model, transition, entry['runtime'])
|
|
2185
|
-
cost = entry['cost'] + _transition_cost(transition)
|
|
2186
|
-
p = list(entry['path'])
|
|
2187
|
-
p.append({'from': entry['runtime']['state'], 'event': transition['event'], 'to': nxt['state']})
|
|
2188
|
-
if cost > invariant['budget']:
|
|
2189
|
-
return {'path': p, 'cost': cost, 'unbounded': False}
|
|
2190
|
-
k = _runtime_key(nxt)
|
|
2191
|
-
best = best_cost.get(k)
|
|
2192
|
-
if best is None or cost < best:
|
|
2193
|
-
best_cost[k] = cost
|
|
2194
|
-
queue.append({'runtime': nxt, 'cost': cost, 'path': p})
|
|
2195
|
-
cycle = _find_unbounded_cycle(model, max_states)
|
|
2196
|
-
if cycle is not None:
|
|
2197
|
-
return {'path': cycle, 'unbounded': True}
|
|
2198
|
-
return None
|
|
2199
|
-
|
|
2200
|
-
|
|
2201
|
-
def A12_budget(model, max_states):
|
|
2202
|
-
budgets = [inv for inv in model.get('invariants') or [] if inv['kind'] == 'budget']
|
|
2203
|
-
uses_cost = any(t.get('cost') is not None for t in model.get('transitions') or [])
|
|
2204
|
-
findings = []
|
|
2205
|
-
if uses_cost and not budgets:
|
|
2206
|
-
findings.append({
|
|
2207
|
-
'code': 'A12_COST_WITHOUT_BUDGET',
|
|
2208
|
-
'severity': 'warning',
|
|
2209
|
-
'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.',
|
|
2210
|
-
})
|
|
2211
|
-
for invariant in budgets:
|
|
2212
|
-
violation = _find_budget_violation(model, max_states, invariant)
|
|
2213
|
-
if violation is not None:
|
|
2214
|
-
if violation['unbounded']:
|
|
2215
|
-
message = ('Budget invariant "' + invariant['id'] + '" (' + invariant['description']
|
|
2216
|
-
+ ') exceeded: a reachable positive-cost cycle lets path cost grow without bound, so no finite budget '
|
|
2217
|
-
+ js_number(invariant['budget']) + ' holds.')
|
|
2218
|
-
evidence = {'invariant': invariant, 'unbounded': True}
|
|
2219
|
-
else:
|
|
2220
|
-
message = ('Budget invariant "' + invariant['id'] + '" (' + invariant['description']
|
|
2221
|
-
+ ') exceeded: worst-case path cost ' + js_number(violation['cost']) + ' is over budget '
|
|
2222
|
-
+ js_number(invariant['budget']) + '.')
|
|
2223
|
-
evidence = {'invariant': invariant, 'totalCost': violation['cost'], 'unbounded': False}
|
|
2224
|
-
findings.append({'code': 'A12_BUDGET_OVER', 'severity': 'error', 'message': message,
|
|
2225
|
-
'path': violation['path'], 'evidence': evidence})
|
|
2226
|
-
if not findings:
|
|
2227
|
-
detail = 'No budget invariants declared' if not budgets else 'All budget invariants hold'
|
|
2228
|
-
else:
|
|
2229
|
-
detail = 'Budget findings: ' + str(len(findings))
|
|
2230
|
-
return _check_result('A12', 'Budget', findings, detail)
|
|
2231
|
-
|
|
2232
|
-
|
|
2233
|
-
# ---------------------------------------------------------------------------
|
|
2234
|
-
# A13 — Probability reachability (DTMC)
|
|
2235
|
-
# ---------------------------------------------------------------------------
|
|
2236
|
-
|
|
2237
|
-
def _probability_outcomes(model, runtime):
|
|
2238
|
-
group_map = _group_transitions(model)
|
|
2239
|
-
outcomes = {}
|
|
2240
|
-
for event in _all_events(model):
|
|
2241
|
-
group = group_map.get(runtime['state'] + '|' + event)
|
|
2242
|
-
if group is None or len(group) == 0:
|
|
2243
|
-
continue
|
|
2244
|
-
for transition in _applicable_transitions(group, runtime):
|
|
2245
|
-
weight = transition.get('weight', 1)
|
|
2246
|
-
if not (weight > 0):
|
|
2247
|
-
continue
|
|
2248
|
-
nxt = _apply_updates(model, transition, runtime)
|
|
2249
|
-
k = _runtime_key(nxt)
|
|
2250
|
-
if k not in outcomes:
|
|
2251
|
-
outcomes[k] = {'state': nxt['state'], 'weight': weight}
|
|
2252
|
-
else:
|
|
2253
|
-
outcomes[k]['weight'] += weight
|
|
2254
|
-
return outcomes
|
|
2255
|
-
|
|
2256
|
-
|
|
2257
|
-
def _compute_hit_probability(model, max_states, target_state):
|
|
2258
|
-
exploration = _explore(model, max_states)
|
|
2259
|
-
reachable = exploration['reachable']
|
|
2260
|
-
n = len(reachable)
|
|
2261
|
-
key_index = {}
|
|
2262
|
-
for i, runtime in enumerate(reachable):
|
|
2263
|
-
key_index[_runtime_key(runtime)] = i
|
|
2264
|
-
fixed_value = [None] * n
|
|
2265
|
-
chains = []
|
|
2266
|
-
for i in range(n):
|
|
2267
|
-
runtime = reachable[i]
|
|
2268
|
-
if runtime['state'] == target_state:
|
|
2269
|
-
fixed_value[i] = 1
|
|
2270
|
-
chains.append([])
|
|
2271
|
-
continue
|
|
2272
|
-
outs = _probability_outcomes(model, runtime)
|
|
2273
|
-
total = 0.0
|
|
2274
|
-
lst = []
|
|
2275
|
-
for k, entry in outs.items():
|
|
2276
|
-
j = key_index.get(k)
|
|
2277
|
-
if j is None:
|
|
2278
|
-
continue
|
|
2279
|
-
lst.append({'j': j, 'w': entry['weight']})
|
|
2280
|
-
total += entry['weight']
|
|
2281
|
-
if total <= 0:
|
|
2282
|
-
fixed_value[i] = 0
|
|
2283
|
-
chains.append([])
|
|
2284
|
-
continue
|
|
2285
|
-
chains.append([{'j': item['j'], 'w': item['w'] / total} for item in lst])
|
|
2286
|
-
p = [0.0] * n
|
|
2287
|
-
converged = False
|
|
2288
|
-
for _iter in range(20000):
|
|
2289
|
-
max_delta = 0.0
|
|
2290
|
-
for i in range(n):
|
|
2291
|
-
if fixed_value[i] is not None:
|
|
2292
|
-
continue
|
|
2293
|
-
lst = chains[i]
|
|
2294
|
-
acc = 0.0
|
|
2295
|
-
for item in lst:
|
|
2296
|
-
fixed = fixed_value[item['j']]
|
|
2297
|
-
acc += item['w'] * (p[item['j']] if fixed is None else fixed)
|
|
2298
|
-
delta = abs(acc - p[i])
|
|
2299
|
-
if delta > max_delta:
|
|
2300
|
-
max_delta = delta
|
|
2301
|
-
p[i] = acc
|
|
2302
|
-
if max_delta < 1e-9:
|
|
2303
|
-
converged = True
|
|
2304
|
-
break
|
|
2305
|
-
init_index = key_index.get(_runtime_key(exploration['initial_state']), 0)
|
|
2306
|
-
fixed = fixed_value[init_index]
|
|
2307
|
-
return {'probability': p[init_index] if fixed is None else fixed, 'converged': converged}
|
|
2308
|
-
|
|
2309
|
-
|
|
2310
|
-
def A13_probability(model, max_states):
|
|
2311
|
-
findings = []
|
|
2312
|
-
eps = 1e-9
|
|
2313
|
-
for invariant in model.get('invariants') or []:
|
|
2314
|
-
if invariant['kind'] != 'probability':
|
|
2315
|
-
continue
|
|
2316
|
-
result = _compute_hit_probability(model, max_states, invariant['target'])
|
|
2317
|
-
probability = result['probability']
|
|
2318
|
-
converged = result['converged']
|
|
2319
|
-
op = invariant['op']
|
|
2320
|
-
p_bound = invariant['p']
|
|
2321
|
-
violated = False
|
|
2322
|
-
if op == '>=':
|
|
2323
|
-
violated = probability < p_bound - eps
|
|
2324
|
-
elif op == '>':
|
|
2325
|
-
violated = probability <= p_bound + eps
|
|
2326
|
-
elif op == '<=':
|
|
2327
|
-
violated = probability > p_bound + eps
|
|
2328
|
-
else:
|
|
2329
|
-
violated = probability >= p_bound - eps
|
|
2330
|
-
if violated:
|
|
2331
|
-
findings.append({
|
|
2332
|
-
'code': 'A13_PROBABILITY_VIOLATION',
|
|
2333
|
-
'severity': 'error',
|
|
2334
|
-
'message': ('Probability invariant "' + invariant['id'] + '" (' + invariant['description']
|
|
2335
|
-
+ ') violated: P(hit ' + invariant['target'] + ') = ' + format(probability, '.6f')
|
|
2336
|
-
+ ' does not satisfy ' + op + ' ' + js_number(p_bound) + '.'),
|
|
2337
|
-
'evidence': {'invariant': invariant, 'computed': probability, 'converged': converged},
|
|
2338
|
-
})
|
|
2339
|
-
elif not converged:
|
|
2340
|
-
findings.append({
|
|
2341
|
-
'code': 'A13_NO_CONVERGENCE',
|
|
2342
|
-
'severity': 'warning',
|
|
2343
|
-
'message': ('Probability invariant "' + invariant['id'] + '" passes, but value iteration did not fully converge within the iteration cap.'),
|
|
2344
|
-
'evidence': {'invariant': invariant, 'computed': probability, 'converged': converged},
|
|
2345
|
-
})
|
|
2346
|
-
return _check_result('A13', 'Probability Reachability', findings,
|
|
2347
|
-
'No probability invariants declared or all hold' if not findings else 'Probability findings: ' + str(len(findings)))
|
|
2348
|
-
|
|
2349
|
-
|
|
2350
|
-
# ---------------------------------------------------------------------------
|
|
2351
|
-
# A14 — Deadline (discrete tick clock)
|
|
2352
|
-
# ---------------------------------------------------------------------------
|
|
2353
|
-
|
|
2354
|
-
def A14_deadline(model, max_states):
|
|
2355
|
-
findings = []
|
|
2356
|
-
tick_events = set(model.get('tickEvents') or [])
|
|
2357
|
-
limits = {}
|
|
2358
|
-
has_limit = False
|
|
2359
|
-
for state in model.get('states') or []:
|
|
2360
|
-
if state.get('maxTicks') is not None:
|
|
2361
|
-
limits[state['id']] = state['maxTicks']
|
|
2362
|
-
has_limit = True
|
|
2363
|
-
if not has_limit:
|
|
2364
|
-
return _check_result('A14', 'Deadline', [], 'No state declares maxTicks')
|
|
2365
|
-
if not tick_events:
|
|
2366
|
-
return _check_result('A14', 'Deadline', [{
|
|
2367
|
-
'code': 'A14_NO_TICK_EVENTS',
|
|
2368
|
-
'severity': 'warning',
|
|
2369
|
-
'message': 'States declare maxTicks, but no tickEvents are declared, so deadline compliance cannot be verified.',
|
|
2370
|
-
}], 'Missing tick events')
|
|
2371
|
-
group_map = _group_transitions(model)
|
|
2372
|
-
init = _initial_state(model)
|
|
2373
|
-
queue = deque([{'runtime': init, 'ticks': 0, 'path': []}])
|
|
2374
|
-
seen = set()
|
|
2375
|
-
steps = 0
|
|
2376
|
-
while queue:
|
|
2377
|
-
entry = queue.popleft()
|
|
2378
|
-
steps += 1
|
|
2379
|
-
if steps > max_states:
|
|
2380
|
-
break
|
|
2381
|
-
k = _runtime_key(entry['runtime']) + '|' + str(entry['ticks'])
|
|
2382
|
-
if k in seen:
|
|
2383
|
-
continue
|
|
2384
|
-
seen.add(k)
|
|
2385
|
-
for event in _all_events(model):
|
|
2386
|
-
group = group_map.get(entry['runtime']['state'] + '|' + event)
|
|
2387
|
-
if group is None or len(group) == 0:
|
|
2388
|
-
continue
|
|
2389
|
-
for transition in _applicable_transitions(group, entry['runtime']):
|
|
2390
|
-
nxt = _apply_updates(model, transition, entry['runtime'])
|
|
2391
|
-
is_tick = event in tick_events
|
|
2392
|
-
stays = nxt['state'] == entry['runtime']['state']
|
|
2393
|
-
p = list(entry['path'])
|
|
2394
|
-
p.append({'from': entry['runtime']['state'], 'event': transition['event'], 'to': nxt['state']})
|
|
2395
|
-
if is_tick and stays:
|
|
2396
|
-
lim = limits.get(nxt['state'])
|
|
2397
|
-
if lim is not None and entry['ticks'] + 1 > lim:
|
|
2398
|
-
findings.append({
|
|
2399
|
-
'code': 'A14_DEADLINE_MISS',
|
|
2400
|
-
'severity': 'error',
|
|
2401
|
-
'message': 'Deadline missed: state ' + nxt['state'] + ' can remain resident for more than ' + js_number(lim) + ' tick(s).',
|
|
2402
|
-
'path': p,
|
|
2403
|
-
'evidence': {'state': nxt['state'], 'maxTicks': lim, 'ticks': entry['ticks'] + 1},
|
|
2404
|
-
})
|
|
2405
|
-
continue
|
|
2406
|
-
ticks = entry['ticks'] + 1
|
|
2407
|
-
elif is_tick and not stays:
|
|
2408
|
-
ticks = 0
|
|
2409
|
-
elif not is_tick and stays:
|
|
2410
|
-
ticks = entry['ticks']
|
|
2411
|
-
else:
|
|
2412
|
-
ticks = 0
|
|
2413
|
-
nk = _runtime_key(nxt) + '|' + str(ticks)
|
|
2414
|
-
if nk not in seen:
|
|
2415
|
-
queue.append({'runtime': nxt, 'ticks': ticks, 'path': p})
|
|
2416
|
-
return _check_result('A14', 'Deadline', findings,
|
|
2417
|
-
'All deadlines respected' if not findings else 'Deadline findings: ' + str(len(findings)))
|
|
2418
|
-
|
|
2419
|
-
|
|
2420
|
-
# ---------------------------------------------------------------------------
|
|
2421
|
-
# Coverage notes (informational gap notices)
|
|
2422
|
-
# ---------------------------------------------------------------------------
|
|
2423
|
-
|
|
2424
|
-
_TIMING_RE = re.compile(r'(?<![a-z0-9])(timeout|watchdog|timer|tick|deadline|period|delay|elapsed|latency)(?![a-z0-9])', re.IGNORECASE)
|
|
2425
|
-
_PREEMPTION_RE = re.compile(r'(?<![a-z0-9])(isr|irq|interrupt|task|thread|preempt|rtos)(?![a-z0-9])', re.IGNORECASE)
|
|
2426
|
-
_HYBRID_RE = re.compile(r'(?<![a-z0-9])(pid|plant|feedback|control.?loop|stability|stable|settling|damping|oscillat|chatter|kalman|foc|field.?weaken|motor|torque)(?![a-z0-9])', re.IGNORECASE)
|
|
2427
|
-
_PROBABILISTIC_RE = re.compile(r'(?<![a-z0-9])(mtbf|mttf|failure.?rate|reliability|probability|probabilistic|markov|stochastic|fault.?tree|fmea)(?![a-z0-9])', re.IGNORECASE)
|
|
2428
|
-
|
|
2429
|
-
|
|
2430
|
-
def compute_coverage_notes(model):
|
|
2431
|
-
names = []
|
|
2432
|
-
for state in model.get('states') or []:
|
|
2433
|
-
names.append(state['id'])
|
|
2434
|
-
for action in state.get('onEntry') or []:
|
|
2435
|
-
names.append(action)
|
|
2436
|
-
for action in state.get('onExit') or []:
|
|
2437
|
-
names.append(action)
|
|
2438
|
-
for transition in model.get('transitions') or []:
|
|
2439
|
-
names.append(transition['event'])
|
|
2440
|
-
corpus = ' '.join(names)
|
|
2441
|
-
notes = []
|
|
2442
|
-
if _TIMING_RE.search(corpus):
|
|
2443
|
-
notes.append('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).')
|
|
2444
|
-
if _PREEMPTION_RE.search(corpus):
|
|
2445
|
-
notes.append('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+).')
|
|
2446
|
-
if _HYBRID_RE.search(corpus):
|
|
2447
|
-
notes.append('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).')
|
|
2448
|
-
if _PROBABILISTIC_RE.search(corpus):
|
|
2449
|
-
notes.append('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.')
|
|
2450
|
-
return notes
|
|
2451
|
-
|
|
2452
|
-
|
|
2453
|
-
# ---------------------------------------------------------------------------
|
|
2454
|
-
# main entry
|
|
2455
|
-
# ---------------------------------------------------------------------------
|
|
2456
|
-
|
|
2457
|
-
def run_verification(input_value, options=None):
|
|
2458
|
-
if options is None:
|
|
2459
|
-
options = {}
|
|
2460
|
-
max_states = options.get('maxStates', DEFAULT_MAX_STATES)
|
|
2461
|
-
max_permutation_events = options.get('maxPermutationEvents', DEFAULT_MAX_PERMUTATION_EVENTS)
|
|
2462
|
-
ok_model, model_or_errors = validate_model(input_value)
|
|
2463
|
-
if not ok_model:
|
|
2464
|
-
errors = model_or_errors
|
|
2465
|
-
return {
|
|
2466
|
-
'ok': False,
|
|
2467
|
-
'schemaVersion': 1,
|
|
2468
|
-
'modelHash': '',
|
|
2469
|
-
'summary': {'states': 0, 'transitions': 0, 'errors': len(errors), 'warnings': 0, 'checksRun': 0},
|
|
2470
|
-
'checks': [{
|
|
2471
|
-
'id': 'MODEL',
|
|
2472
|
-
'name': 'Model Validation',
|
|
2473
|
-
'status': 'fail',
|
|
2474
|
-
'detail': 'Model schema validation failed: ' + str(len(errors)) + ' errors',
|
|
2475
|
-
'findings': [{'code': 'MODEL_INVALID', 'severity': 'error', 'message': message} for message in errors],
|
|
2476
|
-
}],
|
|
2477
|
-
}
|
|
2478
|
-
model = model_or_errors
|
|
2479
|
-
exploration = _explore(model, max_states)
|
|
2480
|
-
checks = [
|
|
2481
|
-
S1_reachability(model, exploration),
|
|
2482
|
-
S2_deadlock(model),
|
|
2483
|
-
S3_liveness(model),
|
|
2484
|
-
S4_determinism(model),
|
|
2485
|
-
S5_event_completeness(model),
|
|
2486
|
-
S6_guard_completeness(model),
|
|
2487
|
-
S7_invariants(model, max_states),
|
|
2488
|
-
S8_monotonic_variables(model),
|
|
2489
|
-
A1_unexpected_events(model),
|
|
2490
|
-
A2_race_interleaving(model, exploration),
|
|
2491
|
-
A3_order_permutation(model, max_permutation_events),
|
|
2492
|
-
A4_pair_symmetry(model),
|
|
2493
|
-
A5_boundary_blast(model),
|
|
2494
|
-
A6_resource_injection(model),
|
|
2495
|
-
A7_shortest_violations(model, max_states),
|
|
2496
|
-
A8_idempotent_replay(model, exploration),
|
|
2497
|
-
A9_leads_to(model, exploration),
|
|
2498
|
-
A10_sequence_order(model, max_states),
|
|
2499
|
-
A11_atomicity(model, max_states),
|
|
2500
|
-
A12_budget(model, max_states),
|
|
2501
|
-
A13_probability(model, max_states),
|
|
2502
|
-
A14_deadline(model, max_states),
|
|
2503
|
-
]
|
|
2504
|
-
comparison = None
|
|
2505
|
-
before_model = options.get('beforeModel')
|
|
2506
|
-
if before_model is not None:
|
|
2507
|
-
before_ok, before_or_errors = validate_model(before_model)
|
|
2508
|
-
if not before_ok:
|
|
2509
|
-
before_errors = before_or_errors
|
|
2510
|
-
return {
|
|
2511
|
-
'ok': False,
|
|
2512
|
-
'schemaVersion': 1,
|
|
2513
|
-
'modelHash': model_hash(model),
|
|
2514
|
-
'summary': {
|
|
2515
|
-
'states': len(model.get('states') or []),
|
|
2516
|
-
'transitions': len(model.get('transitions') or []),
|
|
2517
|
-
'errors': len(before_errors),
|
|
2518
|
-
'warnings': 0,
|
|
2519
|
-
'checksRun': len(checks) + 1,
|
|
2520
|
-
'truncated': exploration['truncated'],
|
|
2521
|
-
},
|
|
2522
|
-
'checks': list(checks) + [{
|
|
2523
|
-
'id': 'BEFORE_MODEL',
|
|
2524
|
-
'name': 'Before Model Validation',
|
|
2525
|
-
'status': 'fail',
|
|
2526
|
-
'detail': 'Before model schema validation failed: ' + str(len(before_errors)) + ' errors',
|
|
2527
|
-
'findings': [{'code': 'BEFORE_MODEL_INVALID', 'severity': 'error', 'message': message} for message in before_errors],
|
|
2528
|
-
}],
|
|
2529
|
-
}
|
|
2530
|
-
before = before_or_errors
|
|
2531
|
-
mapping = options.get('stateMapping') or {}
|
|
2532
|
-
checks.append(D1_behavioral_preservation(before, model, mapping))
|
|
2533
|
-
checks.append(D2_invariant_continuity(before, model, max_states, mapping))
|
|
2534
|
-
checks.append(D3_regression_delta(before, model, mapping))
|
|
2535
|
-
checks.append(D4_deadlock_liveness_regression(before, model, mapping))
|
|
2536
|
-
comparison = _build_comparison_summary(before, model, mapping)
|
|
2537
|
-
errors = sum(1 for check in checks for f in check['findings'] if f.get('severity') == 'error')
|
|
2538
|
-
warnings = sum(1 for check in checks for f in check['findings'] if f.get('severity') == 'warning')
|
|
2539
|
-
coverage_notes = compute_coverage_notes(model)
|
|
2540
|
-
report = {
|
|
2541
|
-
'ok': True,
|
|
2542
|
-
'schemaVersion': 1,
|
|
2543
|
-
'modelHash': model_hash(model),
|
|
2544
|
-
'summary': {
|
|
2545
|
-
'states': len(model.get('states') or []),
|
|
2546
|
-
'transitions': len(model.get('transitions') or []),
|
|
2547
|
-
'errors': errors,
|
|
2548
|
-
'warnings': warnings,
|
|
2549
|
-
'checksRun': len(checks),
|
|
2550
|
-
'truncated': exploration['truncated'],
|
|
2551
|
-
},
|
|
2552
|
-
'checks': checks,
|
|
2553
|
-
}
|
|
2554
|
-
if model.get('narrative') is not None:
|
|
2555
|
-
report['narrative'] = model['narrative']
|
|
2556
|
-
if comparison is not None:
|
|
2557
|
-
report['comparison'] = comparison
|
|
2558
|
-
if coverage_notes:
|
|
2559
|
-
report['coverageNotes'] = coverage_notes
|
|
2560
|
-
return report
|
|
2561
|
-
|
|
2562
|
-
|
|
2563
|
-
# ---------------------------------------------------------------------------
|
|
2564
|
-
# Composition verification (N machines)
|
|
2565
|
-
# ---------------------------------------------------------------------------
|
|
2566
|
-
|
|
2567
|
-
def run_composition_verification(machines_input, options=None):
|
|
2568
|
-
if options is None:
|
|
2569
|
-
options = {}
|
|
2570
|
-
rendezvous_set = set(options.get('rendezvous') or [])
|
|
2571
|
-
max_states = options.get('maxStates', DEFAULT_MAX_STATES)
|
|
2572
|
-
models = []
|
|
2573
|
-
hashes = []
|
|
2574
|
-
model_findings = []
|
|
2575
|
-
for index, input_value in enumerate(machines_input):
|
|
2576
|
-
ok_model, model_or_errors = validate_model(input_value)
|
|
2577
|
-
if not ok_model:
|
|
2578
|
-
model_findings.append({'code': 'MODEL_INVALID', 'severity': 'error',
|
|
2579
|
-
'message': 'machine ' + str(index) + ' invalid: ' + '; '.join(model_or_errors)})
|
|
2580
|
-
else:
|
|
2581
|
-
models.append(model_or_errors)
|
|
2582
|
-
hashes.append(model_hash(model_or_errors))
|
|
2583
|
-
machine_summary = [{'modelHash': hashes[i] if i < len(hashes) else '', 'states': len(m['states'] or []), 'transitions': len(m['transitions'] or [])}
|
|
2584
|
-
for i, m in enumerate(models)]
|
|
2585
|
-
if model_findings or len(models) < 2:
|
|
2586
|
-
if len(models) < 2 and not model_findings:
|
|
2587
|
-
model_findings.append({'code': 'MODEL_INVALID', 'severity': 'error',
|
|
2588
|
-
'message': 'composition requires at least two machines'})
|
|
2589
|
-
return {
|
|
2590
|
-
'ok': False,
|
|
2591
|
-
'summary': {'machineCount': len(models), 'machines': machine_summary, 'compositeStates': 0,
|
|
2592
|
-
'errors': len(model_findings), 'warnings': 0, 'truncated': False},
|
|
2593
|
-
'checks': [{'id': 'MODEL', 'name': 'Machine Validation', 'status': 'fail',
|
|
2594
|
-
'detail': 'composition input validation failed', 'findings': model_findings}],
|
|
2595
|
-
}
|
|
2596
|
-
machine_event_sets = [set(t['event'] for t in m.get('transitions') or []) for m in models]
|
|
2597
|
-
|
|
2598
|
-
def composition_moves(node):
|
|
2599
|
-
moves = []
|
|
2600
|
-
for i in range(len(models)):
|
|
2601
|
-
if _is_terminal(models[i], node['runtimes'][i]['state']):
|
|
2602
|
-
continue
|
|
2603
|
-
for event in _all_events(models[i]):
|
|
2604
|
-
if event in rendezvous_set:
|
|
2605
|
-
continue
|
|
2606
|
-
for nxt in _step_runtime(models[i], node['runtimes'][i], event):
|
|
2607
|
-
runtimes = list(node['runtimes'])
|
|
2608
|
-
runtimes[i] = nxt
|
|
2609
|
-
moves.append({'next': {'runtimes': runtimes, 'path': []}, 'event': event, 'machines': [i]})
|
|
2610
|
-
for event in rendezvous_set:
|
|
2611
|
-
participants = []
|
|
2612
|
-
for i in range(len(models)):
|
|
2613
|
-
if _is_terminal(models[i], node['runtimes'][i]['state']):
|
|
2614
|
-
continue
|
|
2615
|
-
if event not in machine_event_sets[i]:
|
|
2616
|
-
continue
|
|
2617
|
-
participants.append(i)
|
|
2618
|
-
if len(participants) < 2:
|
|
2619
|
-
continue
|
|
2620
|
-
outcomes = [_step_runtime(models[i], node['runtimes'][i], event) for i in participants]
|
|
2621
|
-
if any(len(lst) == 0 for lst in outcomes):
|
|
2622
|
-
continue
|
|
2623
|
-
combos = [list(node['runtimes'])]
|
|
2624
|
-
for slot, i in enumerate(participants):
|
|
2625
|
-
next_combos = []
|
|
2626
|
-
for combo in combos:
|
|
2627
|
-
for outcome in outcomes[slot]:
|
|
2628
|
-
copy = list(combo)
|
|
2629
|
-
copy[i] = outcome
|
|
2630
|
-
next_combos.append(copy)
|
|
2631
|
-
combos = next_combos
|
|
2632
|
-
for runtimes in combos:
|
|
2633
|
-
moves.append({'next': {'runtimes': runtimes, 'path': []}, 'event': event, 'machines': participants})
|
|
2634
|
-
return moves
|
|
2635
|
-
|
|
2636
|
-
def key_of(node):
|
|
2637
|
-
return '|'.join(_runtime_key(r) for r in node['runtimes'])
|
|
2638
|
-
|
|
2639
|
-
init = {'runtimes': [_initial_state(m) for m in models], 'path': []}
|
|
2640
|
-
visited = set()
|
|
2641
|
-
queue = deque([init])
|
|
2642
|
-
composite_states = 0
|
|
2643
|
-
truncated = False
|
|
2644
|
-
fired_count = {}
|
|
2645
|
-
for event in rendezvous_set:
|
|
2646
|
-
fired_count[event] = 0
|
|
2647
|
-
c1_findings = []
|
|
2648
|
-
while queue:
|
|
2649
|
-
node = queue.popleft()
|
|
2650
|
-
k = key_of(node)
|
|
2651
|
-
if k in visited:
|
|
2652
|
-
continue
|
|
2653
|
-
visited.add(k)
|
|
2654
|
-
composite_states += 1
|
|
2655
|
-
if composite_states > max_states:
|
|
2656
|
-
truncated = True
|
|
2657
|
-
break
|
|
2658
|
-
moves = composition_moves(node)
|
|
2659
|
-
all_terminal = all(_is_terminal(models[i], r['state']) for i, r in enumerate(node['runtimes']))
|
|
2660
|
-
if not moves and not all_terminal:
|
|
2661
|
-
c1_findings.append({
|
|
2662
|
-
'code': 'C1_COMPOSITION_DEADLOCK',
|
|
2663
|
-
'severity': 'error',
|
|
2664
|
-
'message': 'Composition deadlock: no machine can advance from (' + ', '.join(r['state'] for r in node['runtimes']) + ') while at least one is not terminal.',
|
|
2665
|
-
'evidence': {'steps': node['path'], 'states': [r['state'] for r in node['runtimes']]},
|
|
2666
|
-
})
|
|
2667
|
-
continue
|
|
2668
|
-
for move in moves:
|
|
2669
|
-
if len(move['machines']) > 1:
|
|
2670
|
-
fired_count[move['event']] = fired_count.get(move['event'], 0) + 1
|
|
2671
|
-
nk = key_of(move['next'])
|
|
2672
|
-
if nk in visited:
|
|
2673
|
-
continue
|
|
2674
|
-
queue.append({'runtimes': move['next']['runtimes'],
|
|
2675
|
-
'path': list(node['path']) + [{'event': move['event'], 'machines': move['machines']}]})
|
|
2676
|
-
c2_findings = []
|
|
2677
|
-
all_events_set = set()
|
|
2678
|
-
for s in machine_event_sets:
|
|
2679
|
-
all_events_set.update(s)
|
|
2680
|
-
for event in rendezvous_set:
|
|
2681
|
-
if event not in all_events_set:
|
|
2682
|
-
continue
|
|
2683
|
-
if fired_count.get(event, 0) == 0:
|
|
2684
|
-
c2_findings.append({
|
|
2685
|
-
'code': 'C2_RENDEZVOUS_NEVER_FIRES',
|
|
2686
|
-
'severity': 'warning',
|
|
2687
|
-
'message': 'Rendezvous event ' + event + ' can never fire: fewer than two machines ever jointly enable it.',
|
|
2688
|
-
})
|
|
2689
|
-
errors = len(c1_findings)
|
|
2690
|
-
warnings = len(c2_findings)
|
|
2691
|
-
checks = [
|
|
2692
|
-
_check_result('C1', 'Composition Deadlock', c1_findings,
|
|
2693
|
-
'No composition deadlock reachable' if not c1_findings else 'Composition deadlocks: ' + str(len(c1_findings))),
|
|
2694
|
-
_check_result('C2', 'Rendezvous Sync', c2_findings,
|
|
2695
|
-
'All rendezvous events can fire' if not c2_findings else 'Rendezvous warnings: ' + str(len(c2_findings))),
|
|
2696
|
-
]
|
|
2697
|
-
return {
|
|
2698
|
-
'ok': errors == 0,
|
|
2699
|
-
'summary': {'machineCount': len(models), 'machines': machine_summary, 'compositeStates': composite_states,
|
|
2700
|
-
'errors': errors, 'warnings': warnings, 'truncated': truncated},
|
|
2701
|
-
'checks': checks,
|
|
2702
|
-
}
|
|
2703
|
-
|
|
2704
|
-
|
|
2705
|
-
# ---------------------------------------------------------------------------
|
|
2706
|
-
# External-tool exporters (UPPAAL / TLA+ / PRISM / SPIN)
|
|
2707
|
-
# ---------------------------------------------------------------------------
|
|
2708
|
-
|
|
2709
|
-
def _prepare_model(input_value):
|
|
2710
|
-
ok_model, model_or_errors = validate_model(input_value)
|
|
2711
|
-
if not ok_model:
|
|
2712
|
-
raise ValueError('model invalid: ' + '; '.join(model_or_errors))
|
|
2713
|
-
model = model_or_errors
|
|
2714
|
-
used = set()
|
|
2715
|
-
state_ids = []
|
|
2716
|
-
index_of = {}
|
|
2717
|
-
state_id_of = {}
|
|
2718
|
-
|
|
2719
|
-
def sanitize(raw):
|
|
2720
|
-
out = re.sub(r'[^A-Za-z0-9_]', '_', raw)
|
|
2721
|
-
if len(out) == 0 or re.match(r'^[0-9]', out):
|
|
2722
|
-
out = 's_' + out
|
|
2723
|
-
return out
|
|
2724
|
-
|
|
2725
|
-
for index, state in enumerate(model.get('states') or []):
|
|
2726
|
-
candidate = sanitize(state['id'])
|
|
2727
|
-
if candidate in used:
|
|
2728
|
-
candidate = candidate + '_' + str(index)
|
|
2729
|
-
used.add(candidate)
|
|
2730
|
-
state_ids.append(candidate)
|
|
2731
|
-
index_of[state['id']] = index
|
|
2732
|
-
state_id_of[state['id']] = candidate
|
|
2733
|
-
return {'model': model, 'stateIds': state_ids, 'indexOf': index_of,
|
|
2734
|
-
'initId': state_id_of.get(model['init'], state_ids[0] if state_ids else ''), 'stateIdOf': state_id_of}
|
|
2735
|
-
|
|
2736
|
-
|
|
2737
|
-
def _num_value(value):
|
|
2738
|
-
return (1 if value else 0) if isinstance(value, bool) else value
|
|
2739
|
-
|
|
2740
|
-
|
|
2741
|
-
def _leaf_expr(node, var_name):
|
|
2742
|
-
return var_name(node['variable']) + ' ' + node['op'] + ' ' + str(_num_value(node['value']))
|
|
2743
|
-
|
|
2744
|
-
|
|
2745
|
-
def _guard_expr(node, and_op, or_op, not_op, var_name, op_for=None):
|
|
2746
|
-
if op_for is None:
|
|
2747
|
-
op_for = lambda op: op
|
|
2748
|
-
if node is None:
|
|
2749
|
-
return ''
|
|
2750
|
-
if 'variable' in node:
|
|
2751
|
-
return var_name(node['variable']) + ' ' + op_for(node['op']) + ' ' + str(_num_value(node['value']))
|
|
2752
|
-
if 'all' in node:
|
|
2753
|
-
return '(' + _join_exprs(node['all'], and_op, or_op, not_op, var_name, op_for, and_op) + ')'
|
|
2754
|
-
if 'any' in node:
|
|
2755
|
-
return '(' + _join_exprs(node['any'], and_op, or_op, not_op, var_name, op_for, or_op) + ')'
|
|
2756
|
-
if 'not' in node:
|
|
2757
|
-
return not_op + '(' + _guard_expr(node['not'], and_op, or_op, not_op, var_name, op_for) + ')'
|
|
2758
|
-
return 'true'
|
|
2759
|
-
|
|
2760
|
-
|
|
2761
|
-
def _join_exprs(children, and_op, or_op, not_op, var_name, op_for, sep):
|
|
2762
|
-
parts = []
|
|
2763
|
-
for child in children:
|
|
2764
|
-
parts.append(_guard_expr(child, and_op, or_op, not_op, var_name, op_for))
|
|
2765
|
-
return (' ' + sep + ' ').join(parts)
|
|
2766
|
-
|
|
2767
|
-
|
|
2768
|
-
def _update_assignments(updates, var_name):
|
|
2769
|
-
out = []
|
|
2770
|
-
for update in updates or []:
|
|
2771
|
-
name = var_name(update['variable'])
|
|
2772
|
-
value = update.get('value', 1)
|
|
2773
|
-
op = update['op']
|
|
2774
|
-
if op == 'set':
|
|
2775
|
-
out.append(name + ' := ' + str(value))
|
|
2776
|
-
elif op == 'inc':
|
|
2777
|
-
out.append(name + ' := ' + name + ' + ' + str(value))
|
|
2778
|
-
else:
|
|
2779
|
-
out.append(name + ' := ' + name + ' - ' + str(value))
|
|
2780
|
-
return out
|
|
2781
|
-
|
|
2782
|
-
|
|
2783
|
-
def _forbidden_indexes(ex):
|
|
2784
|
-
out = []
|
|
2785
|
-
for invariant in ex['model'].get('invariants') or []:
|
|
2786
|
-
if invariant['kind'] == 'never-states':
|
|
2787
|
-
for s in invariant['states']:
|
|
2788
|
-
index = ex['indexOf'].get(s)
|
|
2789
|
-
if index is not None:
|
|
2790
|
-
out.append(index)
|
|
2791
|
-
return out
|
|
2792
|
-
|
|
2793
|
-
|
|
2794
|
-
def _export_uppaal(ex):
|
|
2795
|
-
model = ex['model']
|
|
2796
|
-
state_ids = ex['stateIds']
|
|
2797
|
-
index_of = ex['indexOf']
|
|
2798
|
-
warnings = []
|
|
2799
|
-
|
|
2800
|
-
def xml(s):
|
|
2801
|
-
return s.replace('&', '&').replace('<', '<').replace('>', '>').replace('"', '"')
|
|
2802
|
-
|
|
2803
|
-
def safe_event(event):
|
|
2804
|
-
return xml(re.sub(r'[^A-Za-z0-9_]', '_', event))
|
|
2805
|
-
|
|
2806
|
-
globals_lines = []
|
|
2807
|
-
for v in model.get('variables') or []:
|
|
2808
|
-
globals_lines.append('int ' + v['name'] + ' = ' + str(_num_value(v['init'])) + ';')
|
|
2809
|
-
globals_str = '\n'.join(globals_lines)
|
|
2810
|
-
locs = []
|
|
2811
|
-
for i, state in enumerate(model.get('states') or []):
|
|
2812
|
-
name_el = '\n <name x="16" y="16">' + xml(ex['stateIdOf'].get(state['id'], state['id'])) + '</name>'
|
|
2813
|
-
locs.append(' <location id="id' + str(i) + '" x="' + str(i * 120) + '" y="0">' + name_el + '\n </location>')
|
|
2814
|
-
locs_str = '\n'.join(locs)
|
|
2815
|
-
edges = []
|
|
2816
|
-
for i, tr in enumerate(model.get('transitions') or []):
|
|
2817
|
-
guard = _guard_expr(tr.get('guard'), '&&', '||', '!', lambda v: v)
|
|
2818
|
-
assigns = _update_assignments(tr.get('updates'), lambda v: v)
|
|
2819
|
-
labels = []
|
|
2820
|
-
if guard != '':
|
|
2821
|
-
labels.append(' <label kind="guard" x="16" y="16">' + xml(guard) + '</label>')
|
|
2822
|
-
if len(assigns) > 0:
|
|
2823
|
-
labels.append(' <label kind="assignment" x="16" y="16">' + xml(',\n'.join(assigns)) + '</label>')
|
|
2824
|
-
labels.append(' <label kind="synchronisation" x="16" y="16">' + safe_event(tr['event']) + '!</label>')
|
|
2825
|
-
edges.append(' <transition id="id' + str(1000 + i) + '">\n <source ref="id' + str(index_of.get(tr['from'])) + '"/>\n <target ref="id' + str(index_of.get(tr['to'])) + '"/>\n' + '\n'.join(labels) + '\n </transition>')
|
|
2826
|
-
edges_str = '\n'.join(edges)
|
|
2827
|
-
queries = []
|
|
2828
|
-
for state in model.get('states') or []:
|
|
2829
|
-
if state.get('terminal'):
|
|
2830
|
-
queries.append('E<> LogicProbe.' + ex['stateIdOf'].get(state['id'], state['id']))
|
|
2831
|
-
for index in _forbidden_indexes(ex):
|
|
2832
|
-
queries.append('A[] not LogicProbe.' + state_ids[index])
|
|
2833
|
-
for invariant in model.get('invariants') or []:
|
|
2834
|
-
if invariant['kind'] != 'never-states':
|
|
2835
|
-
warnings.append('UPPAAL export carries invariant kind ' + invariant['kind'] + ' only as a comment (not expressible in the exported XML).')
|
|
2836
|
-
init_index = index_of.get(model['init'], 0)
|
|
2837
|
-
xta = ('<?xml version="1.0" encoding="utf-8"?>\n'
|
|
2838
|
-
+ '<!DOCTYPE nta PUBLIC \'-//Uppaal Team//DTD Flat System 1.6//EN\' \'http://www.it.uu.se/research/group/darts/uppaal/flat-1_6.dtd\'>\n'
|
|
2839
|
-
+ '<nta>\n'
|
|
2840
|
-
+ ' <declaration>// Generated by logicprobe (UPPAAL XML export; booleans as int).\n' + (globals_str + '\n' if globals_str != '' else '') + '</declaration>\n'
|
|
2841
|
-
+ ' <template>\n'
|
|
2842
|
-
+ ' <name x="5" y="5">LogicProbe</name>\n'
|
|
2843
|
-
+ ' <declaration/>\n'
|
|
2844
|
-
+ locs_str + '\n'
|
|
2845
|
-
+ ' <init ref="id' + str(init_index) + '"/>\n'
|
|
2846
|
-
+ edges_str + '\n'
|
|
2847
|
-
+ ' </template>\n'
|
|
2848
|
-
+ ' <system>system LogicProbe;</system>\n'
|
|
2849
|
-
+ '</nta>\n')
|
|
2850
|
-
extras = {'queries': '\n'.join(queries) + ('\n' if queries else '')}
|
|
2851
|
-
return {'format': 'uppaal', 'primary': xta, 'extras': extras, 'warnings': warnings}
|
|
2852
|
-
|
|
2853
|
-
|
|
2854
|
-
def _export_tla(ex):
|
|
2855
|
-
model = ex['model']
|
|
2856
|
-
state_ids = ex['stateIds']
|
|
2857
|
-
state_id_of = ex['stateIdOf']
|
|
2858
|
-
init_id = ex['initId']
|
|
2859
|
-
warnings = []
|
|
2860
|
-
bs = chr(92)
|
|
2861
|
-
CONJ = '/' + bs
|
|
2862
|
-
DISJ = bs + '/'
|
|
2863
|
-
MEM = bs + 'in'
|
|
2864
|
-
NOTIN = bs + 'notin'
|
|
2865
|
-
variables = ['pc'] + [v['name'] for v in model.get('variables') or []]
|
|
2866
|
-
type_var_parts = []
|
|
2867
|
-
for v in model.get('variables') or []:
|
|
2868
|
-
lo = _num_value(v.get('min', 0))
|
|
2869
|
-
hi = max(1, _num_value(v.get('max', 1)))
|
|
2870
|
-
type_var_parts.append(v['name'] + ' ' + MEM + ' ' + str(lo) + '..' + str(hi))
|
|
2871
|
-
type_var = (' ' + CONJ + ' ').join(type_var_parts)
|
|
2872
|
-
init_parts = ['pc = "' + init_id + '"'] + [v['name'] + ' = ' + str(_num_value(v['init'])) for v in model.get('variables') or []]
|
|
2873
|
-
init_str = (' ' + CONJ + ' ').join(init_parts)
|
|
2874
|
-
next_parts = []
|
|
2875
|
-
for tr in model.get('transitions') or []:
|
|
2876
|
-
guard = _guard_expr(tr.get('guard'), CONJ, DISJ, '~', lambda v: v)
|
|
2877
|
-
guard = guard.replace(' == ', ' = ').replace(' != ', ' /= ')
|
|
2878
|
-
updates = []
|
|
2879
|
-
for u in tr.get('updates') or []:
|
|
2880
|
-
value = u.get('value', 1)
|
|
2881
|
-
if u['op'] == 'set':
|
|
2882
|
-
updates.append(u['variable'] + ' = ' + str(value))
|
|
2883
|
-
elif u['op'] == 'inc':
|
|
2884
|
-
updates.append(u['variable'] + ' = ' + u['variable'] + ' + ' + str(value))
|
|
2885
|
-
else:
|
|
2886
|
-
updates.append(u['variable'] + ' = ' + u['variable'] + ' - ' + str(value))
|
|
2887
|
-
pc_part = "pc'" + ' = "' + state_id_of.get(tr['to'], tr['to']) + '"'
|
|
2888
|
-
rest = (' ' + CONJ + ' ' + ' '.join(updates)) if updates else ''
|
|
2889
|
-
if guard == '':
|
|
2890
|
-
guard_part = 'pc = "' + state_id_of.get(tr['from'], tr['from']) + '"'
|
|
2891
|
-
else:
|
|
2892
|
-
guard_part = 'pc = "' + state_id_of.get(tr['from'], tr['from']) + '" ' + CONJ + ' ' + guard
|
|
2893
|
-
next_parts.append(DISJ + ' ' + guard_part + ' ' + CONJ + ' ' + pc_part + rest)
|
|
2894
|
-
next_str = '\n'.join(next_parts)
|
|
2895
|
-
forbids = ['"' + state_ids[i] + '"' for i in _forbidden_indexes(ex)]
|
|
2896
|
-
props = []
|
|
2897
|
-
if forbids:
|
|
2898
|
-
props.append('CheckSafety == [] (pc ' + NOTIN + ' {' + ', '.join(forbids) + '})')
|
|
2899
|
-
for invariant in model.get('invariants') or []:
|
|
2900
|
-
if invariant['kind'] != 'never-states':
|
|
2901
|
-
warnings.append('TLA+ v1 does not translate invariant kind ' + invariant['kind'] + '; only never-states becomes a property.')
|
|
2902
|
-
state_lit = ', '.join('"' + s + '"' for s in state_ids)
|
|
2903
|
-
spec = ('---- MODULE LogicProbe ----\n'
|
|
2904
|
-
+ 'EXTENDS Integers\n'
|
|
2905
|
-
+ 'VARIABLES ' + ', '.join(variables) + '\n'
|
|
2906
|
-
+ 'States == {' + state_lit + '}\n'
|
|
2907
|
-
+ 'TypeOK == pc ' + MEM + ' States' + ((' ' + CONJ + ' ' + type_var) if type_var != '' else '') + '\n'
|
|
2908
|
-
+ 'Init == ' + init_str + '\n'
|
|
2909
|
-
+ 'Next ==\n' + next_str + '\n'
|
|
2910
|
-
+ (('\n' + '\n\n'.join(props) + '\n') if props else '')
|
|
2911
|
-
+ '====\n')
|
|
2912
|
-
extras = {'properties': '\n'.join(props) + '\n'} if props else {}
|
|
2913
|
-
return {'format': 'tla', 'primary': spec, 'extras': extras, 'warnings': warnings}
|
|
2914
|
-
|
|
2915
|
-
|
|
2916
|
-
def _export_prism(ex):
|
|
2917
|
-
model = ex['model']
|
|
2918
|
-
index_of = ex['indexOf']
|
|
2919
|
-
state_id_of = ex['stateIdOf']
|
|
2920
|
-
warnings = []
|
|
2921
|
-
ranges = []
|
|
2922
|
-
for v in model.get('variables') or []:
|
|
2923
|
-
ranges.append({'name': v['name'],
|
|
2924
|
-
'lo': _num_value(v.get('min', 0)),
|
|
2925
|
-
'hi': max(1, _num_value(v.get('max', 3))),
|
|
2926
|
-
'init': _num_value(v['init'])})
|
|
2927
|
-
valuations = []
|
|
2928
|
-
|
|
2929
|
-
def build(i, acc):
|
|
2930
|
-
if i == len(ranges):
|
|
2931
|
-
valuations.append(acc)
|
|
2932
|
-
return
|
|
2933
|
-
for value in range(ranges[i]['lo'], ranges[i]['hi'] + 1):
|
|
2934
|
-
copy = dict(acc)
|
|
2935
|
-
copy[ranges[i]['name']] = value
|
|
2936
|
-
build(i + 1, copy)
|
|
2937
|
-
|
|
2938
|
-
build(0, {})
|
|
2939
|
-
size = len(model.get('states') or []) * max(1, len(valuations))
|
|
2940
|
-
if size > 20000:
|
|
2941
|
-
raise ValueError('PRISM enumeration too large: ' + str(len(model.get('states') or [])) + ' x ' + str(len(valuations)))
|
|
2942
|
-
|
|
2943
|
-
def eval_guard(guard, val):
|
|
2944
|
-
if guard is None:
|
|
2945
|
-
return True
|
|
2946
|
-
if 'variable' in guard:
|
|
2947
|
-
if guard['variable'] in val:
|
|
2948
|
-
left = val[guard['variable']]
|
|
2949
|
-
else:
|
|
2950
|
-
init_val = 0
|
|
2951
|
-
for v in model.get('variables') or []:
|
|
2952
|
-
if v['name'] == guard['variable']:
|
|
2953
|
-
init_val = _num_value(v.get('init', 0))
|
|
2954
|
-
left = init_val
|
|
2955
|
-
right = _num_value(guard['value'])
|
|
2956
|
-
op = guard['op']
|
|
2957
|
-
if op == '==':
|
|
2958
|
-
return left == right
|
|
2959
|
-
if op == '!=':
|
|
2960
|
-
return left != right
|
|
2961
|
-
if op == '<':
|
|
2962
|
-
return left < right
|
|
2963
|
-
if op == '<=':
|
|
2964
|
-
return left <= right
|
|
2965
|
-
if op == '>':
|
|
2966
|
-
return left > right
|
|
2967
|
-
if op == '>=':
|
|
2968
|
-
return left >= right
|
|
2969
|
-
return False
|
|
2970
|
-
if 'all' in guard:
|
|
2971
|
-
return all(eval_guard(g, val) for g in guard['all'])
|
|
2972
|
-
if 'any' in guard:
|
|
2973
|
-
return any(eval_guard(g, val) for g in guard['any'])
|
|
2974
|
-
if 'not' in guard:
|
|
2975
|
-
return not eval_guard(guard['not'], val)
|
|
2976
|
-
return False
|
|
2977
|
-
|
|
2978
|
-
def apply_updates(t, val):
|
|
2979
|
-
out = dict(val)
|
|
2980
|
-
for u in t.get('updates') or []:
|
|
2981
|
-
cur = out.get(u['variable'], 0)
|
|
2982
|
-
value = u.get('value', 1)
|
|
2983
|
-
if u['op'] == 'set':
|
|
2984
|
-
out[u['variable']] = value
|
|
2985
|
-
elif u['op'] == 'inc':
|
|
2986
|
-
out[u['variable']] = cur + value
|
|
2987
|
-
else:
|
|
2988
|
-
out[u['variable']] = cur - value
|
|
2989
|
-
return out
|
|
2990
|
-
|
|
2991
|
-
commands = []
|
|
2992
|
-
for state in model.get('states') or []:
|
|
2993
|
-
for val in valuations:
|
|
2994
|
-
groups = {}
|
|
2995
|
-
for t in model.get('transitions') or []:
|
|
2996
|
-
if t['from'] == state['id']:
|
|
2997
|
-
groups.setdefault(t['event'], []).append(t)
|
|
2998
|
-
outcomes = {}
|
|
2999
|
-
total_w = 0
|
|
3000
|
-
for group in groups.values():
|
|
3001
|
-
guarded = [t for t in group if t.get('guard') is not None and eval_guard(t['guard'], val)]
|
|
3002
|
-
chosen = guarded if guarded else [t for t in group if t.get('guard') is None]
|
|
3003
|
-
for t in chosen:
|
|
3004
|
-
weight = t.get('weight', 1)
|
|
3005
|
-
nv = apply_updates(t, val)
|
|
3006
|
-
key = str(index_of.get(t['to'])) + '|' + js_stringify(nv)
|
|
3007
|
-
if key not in outcomes:
|
|
3008
|
-
outcomes[key] = {'weight': weight, 'pc': index_of.get(t['to'], 0), 'val': nv}
|
|
3009
|
-
total_w += weight
|
|
3010
|
-
else:
|
|
3011
|
-
outcomes[key]['weight'] += weight
|
|
3012
|
-
if len(outcomes) == 0:
|
|
3013
|
-
continue
|
|
3014
|
-
guard_parts = ['pc = ' + str(index_of.get(state['id'], 0))]
|
|
3015
|
-
for r in ranges:
|
|
3016
|
-
guard_parts.append(r['name'] + ' = ' + str(val.get(r['name'], r['init'])))
|
|
3017
|
-
terms = []
|
|
3018
|
-
for item in outcomes.values():
|
|
3019
|
-
p = item['weight'] / total_w if total_w > 0 else 0
|
|
3020
|
-
prob = format(p, '.6f').rstrip('0').rstrip('.')
|
|
3021
|
-
upd = ["(pc' = " + str(item['pc']) + ")"]
|
|
3022
|
-
for r in ranges:
|
|
3023
|
-
v = item['val'].get(r['name'], r['init'])
|
|
3024
|
-
if v != val.get(r['name'], r['init']):
|
|
3025
|
-
upd.append("(" + r['name'] + "' = " + str(v) + ")")
|
|
3026
|
-
terms.append(prob + ' : ' + ' & '.join(upd))
|
|
3027
|
-
commands.append('[] ' + ' & '.join(guard_parts) + ' ->\n ' + ' +\n '.join(terms))
|
|
3028
|
-
pc_hi = len(model.get('states') or []) - 1
|
|
3029
|
-
range_decls = '\n'.join(' ' + r['name'] + ' : [' + str(r['lo']) + '..' + str(r['hi']) + '] init ' + str(r['init']) + ';' for r in ranges)
|
|
3030
|
-
module = ('// Generated by logicprobe (DTMC export; booleans as 0/1 integers).\n'
|
|
3031
|
-
+ 'dtmc\n\n'
|
|
3032
|
-
+ 'module LogicProbe\n'
|
|
3033
|
-
+ ' pc : [0..' + str(pc_hi) + '] init ' + str(index_of.get(model['init'], 0)) + ';\n'
|
|
3034
|
-
+ range_decls + '\n\n'
|
|
3035
|
-
+ '\n\n'.join(commands) + '\n'
|
|
3036
|
-
+ 'endmodule\n')
|
|
3037
|
-
labels = []
|
|
3038
|
-
for state in model.get('states') or []:
|
|
3039
|
-
if state.get('terminal'):
|
|
3040
|
-
labels.append('label "term_' + (state_id_of.get(state['id'], 's')) + '" = pc = ' + str(index_of.get(state['id'])))
|
|
3041
|
-
forb = _forbidden_indexes(ex)
|
|
3042
|
-
if forb:
|
|
3043
|
-
labels.append('label "forbidden" = ' + ' | '.join('pc = ' + str(i) for i in forb))
|
|
3044
|
-
pctl = []
|
|
3045
|
-
for invariant in model.get('invariants') or []:
|
|
3046
|
-
if invariant['kind'] == 'probability':
|
|
3047
|
-
target = index_of.get(invariant['target'])
|
|
3048
|
-
if target is not None:
|
|
3049
|
-
labels.append('label "target" = pc = ' + str(target))
|
|
3050
|
-
pctl.append('P' + invariant['op'] + js_number(invariant['p']) + ' [ F "target" ]')
|
|
3051
|
-
if forb:
|
|
3052
|
-
pctl.append('P>=1 [ G !"forbidden" ]')
|
|
3053
|
-
module = module + '\n' + '\n'.join(labels) + '\n'
|
|
3054
|
-
extras = {'properties': '\n'.join(pctl) + '\n'}
|
|
3055
|
-
for invariant in model.get('invariants') or []:
|
|
3056
|
-
if invariant['kind'] != 'probability' and invariant['kind'] != 'never-states':
|
|
3057
|
-
warnings.append('PRISM v1 does not translate invariant kind ' + invariant['kind'] + '.')
|
|
3058
|
-
return {'format': 'prism', 'primary': module, 'extras': extras, 'warnings': warnings}
|
|
3059
|
-
|
|
3060
|
-
|
|
3061
|
-
def _export_spin(ex):
|
|
3062
|
-
model = ex['model']
|
|
3063
|
-
index_of = ex['indexOf']
|
|
3064
|
-
warnings = []
|
|
3065
|
-
var_decl_parts = []
|
|
3066
|
-
for v in model.get('variables') or []:
|
|
3067
|
-
var_decl_parts.append('int ' + v['name'] + ' = ' + str(_num_value(v['init'])) + ';')
|
|
3068
|
-
var_decl = '\n'.join(var_decl_parts)
|
|
3069
|
-
lines = []
|
|
3070
|
-
|
|
3071
|
-
def prom_assigns(updates):
|
|
3072
|
-
out = []
|
|
3073
|
-
for u in updates or []:
|
|
3074
|
-
value = u.get('value', 1)
|
|
3075
|
-
if u['op'] == 'set':
|
|
3076
|
-
out.append(u['variable'] + ' = ' + str(value))
|
|
3077
|
-
elif u['op'] == 'inc':
|
|
3078
|
-
out.append(u['variable'] + ' = ' + u['variable'] + ' + ' + str(value))
|
|
3079
|
-
else:
|
|
3080
|
-
out.append(u['variable'] + ' = ' + u['variable'] + ' - ' + str(value))
|
|
3081
|
-
return out
|
|
3082
|
-
|
|
3083
|
-
for t in model.get('transitions') or []:
|
|
3084
|
-
guard = _guard_expr(t.get('guard'), '&&', '||', '!', lambda v: v)
|
|
3085
|
-
assigns = prom_assigns(t.get('updates'))
|
|
3086
|
-
if guard == '':
|
|
3087
|
-
cond = 'pc == ' + str(index_of.get(t['from']))
|
|
3088
|
-
else:
|
|
3089
|
-
cond = 'pc == ' + str(index_of.get(t['from'])) + ' && ' + guard
|
|
3090
|
-
prefix = (' ' + '; '.join(assigns) + ';') if assigns else ''
|
|
3091
|
-
lines.append(' :: (' + cond + ') ->' + prefix + ' pc = ' + str(index_of.get(t['to'])) + ';')
|
|
3092
|
-
for state in model.get('states') or []:
|
|
3093
|
-
if state.get('terminal'):
|
|
3094
|
-
lines.append(' :: (pc == ' + str(index_of.get(state['id'])) + ') -> goto done;')
|
|
3095
|
-
promela = ('// Generated by logicprobe (Promela v1; booleans as 0/1 integers).\n'
|
|
3096
|
-
+ 'int pc = ' + str(index_of.get(model['init'], 0)) + ';\n'
|
|
3097
|
-
+ (var_decl + '\n' if var_decl != '' else '')
|
|
3098
|
-
+ 'active proctype LogicProbe() {\n'
|
|
3099
|
-
+ ' do\n'
|
|
3100
|
-
+ '\n'.join(lines) + '\n'
|
|
3101
|
-
+ ' od\n'
|
|
3102
|
-
+ 'done: skip\n'
|
|
3103
|
-
+ '}\n')
|
|
3104
|
-
extras = {}
|
|
3105
|
-
forb = _forbidden_indexes(ex)
|
|
3106
|
-
if forb:
|
|
3107
|
-
extras['properties'] = 'ltl safety { [] (!(pc == ' + ' && !(pc == '.join(str(i) for i in forb) + ')) }\n'
|
|
3108
|
-
for invariant in model.get('invariants') or []:
|
|
3109
|
-
if invariant['kind'] != 'never-states':
|
|
3110
|
-
warnings.append('SPIN v1 does not translate invariant kind ' + invariant['kind'] + '.')
|
|
3111
|
-
return {'format': 'spin', 'primary': promela, 'extras': extras, 'warnings': warnings}
|
|
3112
|
-
|
|
3113
|
-
|
|
3114
|
-
def export_model(input_value, fmt):
|
|
3115
|
-
ex = _prepare_model(input_value)
|
|
3116
|
-
if fmt == 'uppaal':
|
|
3117
|
-
return _export_uppaal(ex)
|
|
3118
|
-
if fmt == 'tla':
|
|
3119
|
-
return _export_tla(ex)
|
|
3120
|
-
if fmt == 'prism':
|
|
3121
|
-
return _export_prism(ex)
|
|
3122
|
-
return _export_spin(ex)
|
|
3123
|
-
|
|
3124
|
-
|
|
3125
|
-
# ---------------------------------------------------------------------------
|
|
3126
|
-
# UML front end (mirror of src/uml.ts)
|
|
3127
|
-
# ---------------------------------------------------------------------------
|
|
3128
|
-
|
|
3129
|
-
UML_NOTATIONS = ('mermaid', 'plantuml')
|
|
3130
|
-
UML_DIAGRAMS = ('state', 'activity', 'sequence')
|
|
3131
|
-
|
|
3132
|
-
# Marker every generated diagram carries; renderers ignore it, the parser uses it.
|
|
3133
|
-
_DIRECTIVE_NAMESPACE = 'logicprobe:'
|
|
3134
|
-
|
|
3135
|
-
# State id / event name characters that would break the generated label syntax.
|
|
3136
|
-
_UNSAFE_LABEL_RE = re.compile(r'[\[\]/\n\r\t]')
|
|
3137
|
-
|
|
3138
|
-
# Mermaid flowchart keywords that cannot stand alone as a node id.
|
|
3139
|
-
_RESERVED_NODE_IDS = frozenset(['end', 'graph', 'subgraph', 'class', 'classDef', 'click', 'style',
|
|
3140
|
-
'linkStyle', 'direction'])
|
|
3141
|
-
|
|
3142
|
-
# Ids the notation can spell without an alias (JS `$` is end-of-input, hence \Z).
|
|
3143
|
-
_PLAIN_ID_RE = re.compile(r'^[A-Za-z_][A-Za-z0-9_]*\Z')
|
|
3144
|
-
|
|
3145
|
-
# JavaScript's \s / String.prototype.trim() character set, so guard tokenising and
|
|
3146
|
-
# label trimming treat exactly the same characters as whitespace as the TypeScript side.
|
|
3147
|
-
_JS_WHITESPACE = ('\t\n\x0b\x0c\r \xa0\u1680\u2000\u2001\u2002\u2003\u2004\u2005\u2006\u2007'
|
|
3148
|
-
'\u2008\u2009\u200a\u2028\u2029\u202f\u205f\u3000\ufeff')
|
|
3149
|
-
_JS_SPACE_RE = re.compile('[' + _JS_WHITESPACE + ']')
|
|
3150
|
-
|
|
3151
|
-
|
|
3152
|
-
def _js_trim(text):
|
|
3153
|
-
return text.strip(_JS_WHITESPACE)
|
|
3154
|
-
|
|
3155
|
-
|
|
3156
|
-
def _js_string(value):
|
|
3157
|
-
"""JavaScript String(value) for the value kinds this module renders."""
|
|
3158
|
-
if value is None:
|
|
3159
|
-
return 'undefined'
|
|
3160
|
-
if isinstance(value, bool):
|
|
3161
|
-
return 'true' if value else 'false'
|
|
3162
|
-
if isinstance(value, str):
|
|
3163
|
-
return value
|
|
3164
|
-
if isinstance(value, (int, float)):
|
|
3165
|
-
return js_number(value)
|
|
3166
|
-
return str(value)
|
|
3167
|
-
|
|
3168
|
-
|
|
3169
|
-
def _char_at(text, index):
|
|
3170
|
-
"""JavaScript `text[index]`: '' when out of range (undefined never equals a char)."""
|
|
3171
|
-
return text[index] if 0 <= index < len(text) else ''
|
|
3172
|
-
|
|
3173
|
-
|
|
3174
|
-
def _is_digit(ch):
|
|
3175
|
-
return len(ch) == 1 and '0' <= ch <= '9'
|
|
3176
|
-
|
|
3177
|
-
|
|
3178
|
-
def _is_ascii_letter(ch):
|
|
3179
|
-
return len(ch) == 1 and (('a' <= ch <= 'z') or ('A' <= ch <= 'Z'))
|
|
3180
|
-
|
|
3181
|
-
|
|
3182
|
-
def _is_ident_char(ch):
|
|
3183
|
-
return _is_ascii_letter(ch) or _is_digit(ch) or ch == '_' or ch == '.'
|
|
3184
|
-
|
|
3185
|
-
|
|
3186
|
-
def _number_from_token(text):
|
|
3187
|
-
"""JavaScript Number(literal): exact for integers inside the safe range."""
|
|
3188
|
-
value = int(text)
|
|
3189
|
-
if -9007199254740991 <= value <= 9007199254740991:
|
|
3190
|
-
return value
|
|
3191
|
-
return float(text)
|
|
3192
|
-
|
|
3193
|
-
|
|
3194
|
-
def _js_strict_equal(left, right):
|
|
3195
|
-
"""JavaScript === for guard literal values (booleans are never equal to numbers)."""
|
|
3196
|
-
if isinstance(left, bool) or isinstance(right, bool):
|
|
3197
|
-
return isinstance(left, bool) and isinstance(right, bool) and left == right
|
|
3198
|
-
return left == right
|
|
3199
|
-
|
|
3200
|
-
|
|
3201
|
-
def _literal_text(value):
|
|
3202
|
-
if isinstance(value, bool):
|
|
3203
|
-
return 'true' if value else 'false'
|
|
3204
|
-
return js_number(value)
|
|
3205
|
-
|
|
3206
|
-
|
|
3207
|
-
def guard_text(node):
|
|
3208
|
-
"""Canonical guard text; the inverse of parse_guard_text."""
|
|
3209
|
-
if 'variable' in node:
|
|
3210
|
-
return node['variable'] + ' ' + node['op'] + ' ' + _literal_text(node['value'])
|
|
3211
|
-
if 'all' in node:
|
|
3212
|
-
return '(' + ' && '.join(guard_text(child) for child in node['all']) + ')'
|
|
3213
|
-
if 'any' in node:
|
|
3214
|
-
return '(' + ' || '.join(guard_text(child) for child in node['any']) + ')'
|
|
3215
|
-
return '!(' + guard_text(node['not']) + ')'
|
|
3216
|
-
|
|
3217
|
-
|
|
3218
|
-
def _updates_text(updates):
|
|
3219
|
-
parts = []
|
|
3220
|
-
for update in updates:
|
|
3221
|
-
value = update['value'] if update.get('value') is not None else (0 if update['op'] == 'set' else 1)
|
|
3222
|
-
if update['op'] == 'set':
|
|
3223
|
-
parts.append(update['variable'] + ' := ' + _literal_text(value))
|
|
3224
|
-
elif update['op'] == 'inc':
|
|
3225
|
-
parts.append(update['variable'] + ' := ' + update['variable'] + ' + ' + _literal_text(value))
|
|
3226
|
-
else:
|
|
3227
|
-
parts.append(update['variable'] + ' := ' + update['variable'] + ' - ' + _literal_text(value))
|
|
3228
|
-
return ', '.join(parts)
|
|
3229
|
-
|
|
3230
|
-
|
|
3231
|
-
def _transition_text(transition):
|
|
3232
|
-
text = transition['event']
|
|
3233
|
-
if transition.get('guard') is not None:
|
|
3234
|
-
text += ' [' + guard_text(transition['guard']) + ']'
|
|
3235
|
-
updates = transition.get('updates')
|
|
3236
|
-
if updates is not None and len(updates) > 0:
|
|
3237
|
-
text += ' / ' + _updates_text(updates)
|
|
3238
|
-
return text
|
|
3239
|
-
|
|
3240
|
-
|
|
3241
|
-
def _display_text(text):
|
|
3242
|
-
return _js_trim(re.sub(r'[\r\n\t]+', ' ', text).replace('"', "'"))
|
|
3243
|
-
|
|
3244
|
-
|
|
3245
|
-
# ---- rendering ------------------------------------------------------------
|
|
3246
|
-
|
|
3247
|
-
def _prepare_render(input_value):
|
|
3248
|
-
ok, model_or_errors = validate_model(input_value)
|
|
3249
|
-
if not ok:
|
|
3250
|
-
raise ValueError('model invalid: ' + '; '.join(model_or_errors))
|
|
3251
|
-
model = model_or_errors
|
|
3252
|
-
warnings = []
|
|
3253
|
-
used = set()
|
|
3254
|
-
alias = {}
|
|
3255
|
-
display = {}
|
|
3256
|
-
for state in model['states']:
|
|
3257
|
-
sid = state['id']
|
|
3258
|
-
candidate = sid
|
|
3259
|
-
if _PLAIN_ID_RE.match(candidate) is None:
|
|
3260
|
-
candidate = re.sub(r'[^A-Za-z0-9_]', '_', candidate)
|
|
3261
|
-
if candidate == '' or _is_digit(candidate[0]):
|
|
3262
|
-
candidate = 'S_' + candidate
|
|
3263
|
-
warnings.append('UML_RENDER_ID_SANITIZED: state id "' + sid + '" is not a plain identifier; the diagram draws it as "'
|
|
3264
|
-
+ candidate + '" and pins the original with a ' + _DIRECTIVE_NAMESPACE + 'alias directive.')
|
|
3265
|
-
if candidate in _RESERVED_NODE_IDS:
|
|
3266
|
-
warnings.append('UML_RENDER_RESERVED_ID: state alias "' + candidate + '" collides with a diagram keyword; Mermaid renders it, but a hand edit may not.')
|
|
3267
|
-
unique = candidate
|
|
3268
|
-
suffix = 2
|
|
3269
|
-
while unique in used:
|
|
3270
|
-
unique = candidate + '_' + str(suffix)
|
|
3271
|
-
suffix += 1
|
|
3272
|
-
if unique != candidate:
|
|
3273
|
-
warnings.append('UML_RENDER_ALIAS_COLLISION: state id "' + sid + '" shares an alias with another state; the diagram uses "' + unique + '".')
|
|
3274
|
-
used.add(unique)
|
|
3275
|
-
alias[sid] = unique
|
|
3276
|
-
meaning = None
|
|
3277
|
-
narrative = model.get('narrative')
|
|
3278
|
-
if narrative is not None and narrative.get('states') is not None:
|
|
3279
|
-
meaning = narrative['states'].get(sid)
|
|
3280
|
-
display[sid] = sid if meaning is None else _display_text(sid + '(' + meaning + ')')
|
|
3281
|
-
for transition in model['transitions']:
|
|
3282
|
-
if _UNSAFE_LABEL_RE.search(transition['event']):
|
|
3283
|
-
warnings.append('UML_RENDER_LABEL_UNSAFE: event "' + transition['event'] + '" contains a character (one of [ ] / or a line break) '
|
|
3284
|
-
'that the diagram label syntax uses; the rendered diagram cannot be read back verbatim.')
|
|
3285
|
-
terminal = set(state['id'] for state in model['states'] if state.get('terminal') is True)
|
|
3286
|
-
return {'model': model, 'alias': alias, 'display': display, 'terminal': terminal, 'warnings': warnings}
|
|
3287
|
-
|
|
3288
|
-
|
|
3289
|
-
def _directive_lines(notation, diagram, context):
|
|
3290
|
-
prefix = '%%' if notation == 'mermaid' else "'"
|
|
3291
|
-
model = context['model']
|
|
3292
|
-
lines = [prefix + _DIRECTIVE_NAMESPACE + 'uml v1 notation=' + notation + ' diagram=' + diagram]
|
|
3293
|
-
lines.append(prefix + _DIRECTIVE_NAMESPACE + 'init ' + model['init'])
|
|
3294
|
-
terminals = [state['id'] for state in model['states'] if state.get('terminal') is True]
|
|
3295
|
-
if len(terminals) > 0:
|
|
3296
|
-
lines.append(prefix + _DIRECTIVE_NAMESPACE + 'terminal ' + ','.join(terminals))
|
|
3297
|
-
for state in model['states']:
|
|
3298
|
-
if context['alias'].get(state['id']) != state['id']:
|
|
3299
|
-
lines.append(prefix + _DIRECTIVE_NAMESPACE + 'alias ' + context['alias'][state['id']] + ' ' + state['id'])
|
|
3300
|
-
# Variable kinds are not recoverable from the notation: `armed := 1` reads as an
|
|
3301
|
-
# integer assignment whichever kind the model declared. Pinning them keeps the
|
|
3302
|
-
# round trip exact instead of reporting a fidelity loss that is really a
|
|
3303
|
-
# notation limit.
|
|
3304
|
-
for variable in model.get('variables') or []:
|
|
3305
|
-
lines.append(prefix + _DIRECTIVE_NAMESPACE + 'variable ' + variable['name'] + ' ' + variable['kind'])
|
|
3306
|
-
return lines
|
|
3307
|
-
|
|
3308
|
-
|
|
3309
|
-
def _grouped_transitions(model):
|
|
3310
|
-
order = []
|
|
3311
|
-
groups = {}
|
|
3312
|
-
for transition in model['transitions']:
|
|
3313
|
-
frm = transition['from']
|
|
3314
|
-
if frm not in groups:
|
|
3315
|
-
groups[frm] = [transition]
|
|
3316
|
-
order.append(frm)
|
|
3317
|
-
else:
|
|
3318
|
-
groups[frm].append(transition)
|
|
3319
|
-
return [{'from': frm, 'transitions': groups[frm]} for frm in order]
|
|
3320
|
-
|
|
3321
|
-
|
|
3322
|
-
def _render_mermaid_state(context):
|
|
3323
|
-
model = context['model']
|
|
3324
|
-
alias = context['alias']
|
|
3325
|
-
lines = _directive_lines('mermaid', 'state', context)
|
|
3326
|
-
lines.append('stateDiagram-v2')
|
|
3327
|
-
lines.append(' [*] --> ' + alias[model['init']])
|
|
3328
|
-
for state in model['states']:
|
|
3329
|
-
state_alias = alias[state['id']]
|
|
3330
|
-
label = context['display'].get(state['id'], state['id'])
|
|
3331
|
-
# Every state is declared, even when its label equals its alias. A state that
|
|
3332
|
-
# no transition touches (an isolated terminal, a start state with no edge yet)
|
|
3333
|
-
# would otherwise leave no trace in the text at all, and the round-trip check
|
|
3334
|
-
# would have to report a loss the notation never caused.
|
|
3335
|
-
lines.append(' state "' + label + '" as ' + state_alias)
|
|
3336
|
-
for group in _grouped_transitions(model):
|
|
3337
|
-
for transition in group['transitions']:
|
|
3338
|
-
lines.append(' ' + alias[transition['from']] + ' --> ' + alias[transition['to']] + ' : ' + _transition_text(transition))
|
|
3339
|
-
for state in model['states']:
|
|
3340
|
-
if state.get('terminal') is True:
|
|
3341
|
-
lines.append(' ' + alias[state['id']] + ' --> [*]')
|
|
3342
|
-
return '\n'.join(lines) + '\n'
|
|
3343
|
-
|
|
3344
|
-
|
|
3345
|
-
def _render_plantuml_state(context):
|
|
3346
|
-
model = context['model']
|
|
3347
|
-
alias = context['alias']
|
|
3348
|
-
lines = ['@startuml']
|
|
3349
|
-
lines.extend(_directive_lines('plantuml', 'state', context))
|
|
3350
|
-
lines.append('[*] --> ' + alias[model['init']])
|
|
3351
|
-
for state in model['states']:
|
|
3352
|
-
lines.append('state "' + context['display'].get(state['id'], state['id']) + '" as ' + alias[state['id']])
|
|
3353
|
-
for group in _grouped_transitions(model):
|
|
3354
|
-
for transition in group['transitions']:
|
|
3355
|
-
lines.append(alias[transition['from']] + ' --> ' + alias[transition['to']] + ' : ' + _transition_text(transition))
|
|
3356
|
-
for state in model['states']:
|
|
3357
|
-
if state.get('terminal') is True:
|
|
3358
|
-
lines.append(alias[state['id']] + ' --> [*]')
|
|
3359
|
-
lines.append('@enduml')
|
|
3360
|
-
return '\n'.join(lines) + '\n'
|
|
3361
|
-
|
|
3362
|
-
|
|
3363
|
-
def _render_mermaid_activity(context):
|
|
3364
|
-
model = context['model']
|
|
3365
|
-
alias = context['alias']
|
|
3366
|
-
lines = _directive_lines('mermaid', 'activity', context)
|
|
3367
|
-
lines.append('flowchart TD')
|
|
3368
|
-
for state in model['states']:
|
|
3369
|
-
state_alias = alias[state['id']]
|
|
3370
|
-
text = context['display'].get(state['id'], state['id'])
|
|
3371
|
-
lines.append(' ' + state_alias + ('(["' + text + '"])' if state.get('terminal') is True else '["' + text + '"]'))
|
|
3372
|
-
for group in _grouped_transitions(model):
|
|
3373
|
-
for transition in group['transitions']:
|
|
3374
|
-
lines.append(' ' + alias[transition['from']] + ' -->|"' + _transition_text(transition) + '"| ' + alias[transition['to']])
|
|
3375
|
-
return '\n'.join(lines) + '\n'
|
|
3376
|
-
|
|
3377
|
-
|
|
3378
|
-
def _render_sequence(context, notation, max_steps):
|
|
3379
|
-
"""One BFS trace of the machine, written as a sequence diagram."""
|
|
3380
|
-
model = context['model']
|
|
3381
|
-
alias = context['alias']
|
|
3382
|
-
lines = []
|
|
3383
|
-
if notation == 'mermaid':
|
|
3384
|
-
lines.extend(_directive_lines('mermaid', 'sequence', context))
|
|
3385
|
-
lines.append('sequenceDiagram')
|
|
3386
|
-
lines.append(' participant ENV as Environment')
|
|
3387
|
-
lines.append(' participant M as Machine')
|
|
3388
|
-
else:
|
|
3389
|
-
lines.append('@startuml')
|
|
3390
|
-
lines.extend(_directive_lines('plantuml', 'sequence', context))
|
|
3391
|
-
lines.append('participant ENV as Environment')
|
|
3392
|
-
lines.append('participant M as Machine')
|
|
3393
|
-
by_from = {}
|
|
3394
|
-
for transition in model['transitions']:
|
|
3395
|
-
by_from.setdefault(transition['from'], []).append(transition)
|
|
3396
|
-
seen = set([model['init']])
|
|
3397
|
-
queue = deque([model['init']])
|
|
3398
|
-
arrow = 'ENV->>M: ' if notation == 'mermaid' else 'ENV -> M : '
|
|
3399
|
-
note = ' Note over M: ' if notation == 'mermaid' else 'note over M : '
|
|
3400
|
-
lines.append((' ' if notation == 'mermaid' else '') + 'Note over M: init ' + model['init'])
|
|
3401
|
-
steps = 0
|
|
3402
|
-
truncated = False
|
|
3403
|
-
while len(queue) > 0:
|
|
3404
|
-
current = queue.popleft()
|
|
3405
|
-
for transition in by_from.get(current, []):
|
|
3406
|
-
if steps >= max_steps:
|
|
3407
|
-
truncated = True
|
|
3408
|
-
break
|
|
3409
|
-
steps += 1
|
|
3410
|
-
lines.append((' ' if notation == 'mermaid' else '') + arrow + _transition_text(transition))
|
|
3411
|
-
lines.append(note + alias[transition['from']] + ' -> ' + alias[transition['to']])
|
|
3412
|
-
if transition['to'] not in seen:
|
|
3413
|
-
seen.add(transition['to'])
|
|
3414
|
-
queue.append(transition['to'])
|
|
3415
|
-
if truncated:
|
|
3416
|
-
break
|
|
3417
|
-
if notation == 'plantuml':
|
|
3418
|
-
lines.append('@enduml')
|
|
3419
|
-
return {'text': '\n'.join(lines) + '\n', 'truncated': truncated}
|
|
3420
|
-
|
|
3421
|
-
|
|
3422
|
-
def render_uml(input_value, notation='mermaid', diagram='state', max_steps=60):
|
|
3423
|
-
"""Mirror of renderUml: a validated LogicModelV1 as Mermaid/PlantUML text."""
|
|
3424
|
-
if notation not in UML_NOTATIONS:
|
|
3425
|
-
raise ValueError('unknown notation "' + _js_string(notation) + '"; expected ' + ' | '.join(UML_NOTATIONS))
|
|
3426
|
-
if diagram not in UML_DIAGRAMS:
|
|
3427
|
-
raise ValueError('unknown diagram "' + _js_string(diagram) + '"; expected ' + ' | '.join(UML_DIAGRAMS))
|
|
3428
|
-
if notation == 'plantuml' and diagram == 'activity':
|
|
3429
|
-
raise ValueError('plantuml has no faithful activity view here (its activity syntax is a structured flowchart language; '
|
|
3430
|
-
'a graph with merges or cycles needs a while/if reconstruction logicprobe does not perform) — '
|
|
3431
|
-
'use notation "mermaid" for the activity view, or diagram "state"')
|
|
3432
|
-
context = _prepare_render(input_value)
|
|
3433
|
-
warnings = list(context['warnings'])
|
|
3434
|
-
if diagram == 'state':
|
|
3435
|
-
primary = _render_mermaid_state(context) if notation == 'mermaid' else _render_plantuml_state(context)
|
|
3436
|
-
elif diagram == 'activity':
|
|
3437
|
-
primary = _render_mermaid_activity(context)
|
|
3438
|
-
else:
|
|
3439
|
-
rendered = _render_sequence(context, notation, max_steps)
|
|
3440
|
-
primary = rendered['text']
|
|
3441
|
-
if rendered['truncated']:
|
|
3442
|
-
warnings.append('UML_RENDER_SEQUENCE_TRUNCATED: the trace was capped at ' + str(max_steps) + ' steps; a sequence diagram is one trace, '
|
|
3443
|
-
'not the whole machine — use diagram "state" for the full topology.')
|
|
3444
|
-
warnings.append('UML_RENDER_SEQUENCE_IS_TRACE: a sequence diagram shows one BFS trace; branches appear as separate guarded messages '
|
|
3445
|
-
'and unreachable branches are absent by construction.')
|
|
3446
|
-
return {'notation': notation, 'diagram': diagram, 'primary': primary, 'warnings': warnings}
|
|
3447
|
-
|
|
3448
|
-
|
|
3449
|
-
# ---- parsing: guard expressions -------------------------------------------
|
|
3450
|
-
|
|
3451
|
-
def _tokenize_guard(text):
|
|
3452
|
-
tokens = []
|
|
3453
|
-
index = 0
|
|
3454
|
-
while index < len(text):
|
|
3455
|
-
char = text[index]
|
|
3456
|
-
if _JS_SPACE_RE.match(char):
|
|
3457
|
-
index += 1
|
|
3458
|
-
continue
|
|
3459
|
-
if char == '(':
|
|
3460
|
-
tokens.append(('lparen', char))
|
|
3461
|
-
index += 1
|
|
3462
|
-
continue
|
|
3463
|
-
if char == ')':
|
|
3464
|
-
tokens.append(('rparen', char))
|
|
3465
|
-
index += 1
|
|
3466
|
-
continue
|
|
3467
|
-
if char == '&' and _char_at(text, index + 1) == '&':
|
|
3468
|
-
tokens.append(('and', '&&'))
|
|
3469
|
-
index += 2
|
|
3470
|
-
continue
|
|
3471
|
-
if char == '|' and _char_at(text, index + 1) == '|':
|
|
3472
|
-
tokens.append(('or', '||'))
|
|
3473
|
-
index += 2
|
|
3474
|
-
continue
|
|
3475
|
-
if char == '!':
|
|
3476
|
-
if _char_at(text, index + 1) == '=':
|
|
3477
|
-
tokens.append(('op', '!='))
|
|
3478
|
-
index += 2
|
|
3479
|
-
continue
|
|
3480
|
-
tokens.append(('not', '!'))
|
|
3481
|
-
index += 1
|
|
3482
|
-
continue
|
|
3483
|
-
two = text[index:index + 2]
|
|
3484
|
-
if two == '==' or two == '<=' or two == '>=':
|
|
3485
|
-
tokens.append(('op', two))
|
|
3486
|
-
index += 2
|
|
3487
|
-
continue
|
|
3488
|
-
if char == '<' or char == '>':
|
|
3489
|
-
tokens.append(('op', char))
|
|
3490
|
-
index += 1
|
|
3491
|
-
continue
|
|
3492
|
-
if char == '=':
|
|
3493
|
-
tokens.append(('op', '=='))
|
|
3494
|
-
index += 1
|
|
3495
|
-
continue
|
|
3496
|
-
if _is_digit(char) or (char == '-' and _is_digit(_char_at(text, index + 1))):
|
|
3497
|
-
end = index + 1
|
|
3498
|
-
while end < len(text) and _is_digit(text[end]):
|
|
3499
|
-
end += 1
|
|
3500
|
-
tokens.append(('number', text[index:end]))
|
|
3501
|
-
index = end
|
|
3502
|
-
continue
|
|
3503
|
-
if _is_ascii_letter(char) or char == '_':
|
|
3504
|
-
end = index + 1
|
|
3505
|
-
while end < len(text) and _is_ident_char(text[end]):
|
|
3506
|
-
end += 1
|
|
3507
|
-
word = text[index:end]
|
|
3508
|
-
index = end
|
|
3509
|
-
if word == 'and':
|
|
3510
|
-
tokens.append(('and', word))
|
|
3511
|
-
elif word == 'or':
|
|
3512
|
-
tokens.append(('or', word))
|
|
3513
|
-
elif word == 'not':
|
|
3514
|
-
tokens.append(('not', word))
|
|
3515
|
-
elif word == 'true' or word == 'false':
|
|
3516
|
-
tokens.append(('boolean', word))
|
|
3517
|
-
else:
|
|
3518
|
-
tokens.append(('ident', word))
|
|
3519
|
-
continue
|
|
3520
|
-
raise ValueError('guard text not understood near "' + text[index:] + '"')
|
|
3521
|
-
return tokens
|
|
3522
|
-
|
|
3523
|
-
|
|
3524
|
-
class _GuardReader:
|
|
3525
|
-
def __init__(self, tokens, source):
|
|
3526
|
-
self.tokens = tokens
|
|
3527
|
-
self.source = source
|
|
3528
|
-
self.position = 0
|
|
3529
|
-
|
|
3530
|
-
def parse(self):
|
|
3531
|
-
node = self._parse_or()
|
|
3532
|
-
if self.position != len(self.tokens):
|
|
3533
|
-
raise ValueError('trailing tokens in guard "' + self.source + '"')
|
|
3534
|
-
return node
|
|
3535
|
-
|
|
3536
|
-
def _peek(self):
|
|
3537
|
-
return self.tokens[self.position] if self.position < len(self.tokens) else None
|
|
3538
|
-
|
|
3539
|
-
def _parse_or(self):
|
|
3540
|
-
parts = [self._parse_and()]
|
|
3541
|
-
while self._peek() is not None and self._peek()[0] == 'or':
|
|
3542
|
-
self.position += 1
|
|
3543
|
-
parts.append(self._parse_and())
|
|
3544
|
-
return parts[0] if len(parts) == 1 else {'any': parts}
|
|
3545
|
-
|
|
3546
|
-
def _parse_and(self):
|
|
3547
|
-
parts = [self._parse_unary()]
|
|
3548
|
-
while self._peek() is not None and self._peek()[0] == 'and':
|
|
3549
|
-
self.position += 1
|
|
3550
|
-
parts.append(self._parse_unary())
|
|
3551
|
-
return parts[0] if len(parts) == 1 else {'all': parts}
|
|
3552
|
-
|
|
3553
|
-
def _parse_unary(self):
|
|
3554
|
-
token = self._peek()
|
|
3555
|
-
if token is not None and token[0] == 'not':
|
|
3556
|
-
self.position += 1
|
|
3557
|
-
return {'not': self._parse_unary()}
|
|
3558
|
-
return self._parse_primary()
|
|
3559
|
-
|
|
3560
|
-
def _parse_primary(self):
|
|
3561
|
-
token = self._peek()
|
|
3562
|
-
if token is not None and token[0] == 'lparen':
|
|
3563
|
-
self.position += 1
|
|
3564
|
-
inner = self._parse_or()
|
|
3565
|
-
closing = self._peek()
|
|
3566
|
-
if closing is None or closing[0] != 'rparen':
|
|
3567
|
-
raise ValueError('unbalanced parentheses in guard "' + self.source + '"')
|
|
3568
|
-
self.position += 1
|
|
3569
|
-
return inner
|
|
3570
|
-
if token is None or token[0] != 'ident':
|
|
3571
|
-
raise ValueError('expected a variable name in guard "' + self.source + '"')
|
|
3572
|
-
self.position += 1
|
|
3573
|
-
op = self._peek()
|
|
3574
|
-
if op is None or op[0] != 'op':
|
|
3575
|
-
raise ValueError('expected a comparison operator after "' + token[1] + '" in guard "' + self.source + '"')
|
|
3576
|
-
self.position += 1
|
|
3577
|
-
value = self._peek()
|
|
3578
|
-
if value is not None and value[0] == 'number':
|
|
3579
|
-
self.position += 1
|
|
3580
|
-
return {'variable': token[1], 'op': op[1], 'value': _number_from_token(value[1])}
|
|
3581
|
-
if value is not None and value[0] == 'boolean':
|
|
3582
|
-
if op[1] != '==' and op[1] != '!=':
|
|
3583
|
-
raise ValueError('boolean variable "' + token[1] + '" only supports == / != (guard "' + self.source + '")')
|
|
3584
|
-
self.position += 1
|
|
3585
|
-
return {'variable': token[1], 'op': op[1], 'value': value[1] == 'true'}
|
|
3586
|
-
raise ValueError('expected a literal value for "' + token[1] + '" in guard "' + self.source + '"')
|
|
3587
|
-
|
|
3588
|
-
|
|
3589
|
-
def parse_guard_text(text):
|
|
3590
|
-
"""Parse a guard expression such as `(retry < 3 && armed == true)`."""
|
|
3591
|
-
return _GuardReader(_tokenize_guard(text), text).parse()
|
|
3592
|
-
|
|
3593
|
-
|
|
3594
|
-
_UPDATE_SHIM_RE = re.compile(r'^([A-Za-z_][A-Za-z0-9_]*)\s*(\+\+|--)\Z')
|
|
3595
|
-
_UPDATE_ASSIGN_RE = re.compile(r'^([A-Za-z_][A-Za-z0-9_]*)\s*:?=\s*(.+)\Z')
|
|
3596
|
-
_UPDATE_BOOL_RE = re.compile(r'^(true|false)\Z')
|
|
3597
|
-
_UPDATE_INT_RE = re.compile(r'^-?[0-9]+\Z')
|
|
3598
|
-
_UPDATE_ARITH_RE = re.compile(r'^([A-Za-z_][A-Za-z0-9_]*)\s*([+-])\s*([0-9]+)\Z')
|
|
3599
|
-
|
|
3600
|
-
|
|
3601
|
-
def parse_updates_text(text, warnings):
|
|
3602
|
-
"""Parse a UML action clause such as `retry := retry + 1, armed := true`."""
|
|
3603
|
-
out = []
|
|
3604
|
-
for raw in text.split(','):
|
|
3605
|
-
clause = _js_trim(raw)
|
|
3606
|
-
if clause == '':
|
|
3607
|
-
continue
|
|
3608
|
-
shim = _UPDATE_SHIM_RE.match(clause)
|
|
3609
|
-
if shim is not None:
|
|
3610
|
-
out.append({'variable': shim.group(1), 'op': 'inc' if shim.group(2) == '++' else 'dec', 'value': 1})
|
|
3611
|
-
continue
|
|
3612
|
-
assignment = _UPDATE_ASSIGN_RE.match(clause)
|
|
3613
|
-
if assignment is None:
|
|
3614
|
-
raise ValueError('action clause not understood: "' + clause + '" (expected "var := value")')
|
|
3615
|
-
name = assignment.group(1)
|
|
3616
|
-
value = _js_trim(assignment.group(2))
|
|
3617
|
-
if value == name:
|
|
3618
|
-
warnings.append('UML_PARSE_NOOP_UPDATE: action "' + clause + '" assigns the variable to itself; dropped.')
|
|
3619
|
-
continue
|
|
3620
|
-
if _UPDATE_BOOL_RE.match(value):
|
|
3621
|
-
out.append({'variable': name, 'op': 'set', 'value': 1 if value == 'true' else 0})
|
|
3622
|
-
continue
|
|
3623
|
-
if _UPDATE_INT_RE.match(value):
|
|
3624
|
-
out.append({'variable': name, 'op': 'set', 'value': _number_from_token(value)})
|
|
3625
|
-
continue
|
|
3626
|
-
arithmetic = _UPDATE_ARITH_RE.match(value)
|
|
3627
|
-
if arithmetic is None:
|
|
3628
|
-
raise ValueError('action value not understood: "' + value + '" (expected a literal, or "var + n" / "var - n")')
|
|
3629
|
-
if arithmetic.group(1) != name:
|
|
3630
|
-
raise ValueError('action "' + clause + '" reads a different variable; LogicModelV1 updates touch one variable')
|
|
3631
|
-
out.append({'variable': name, 'op': 'inc' if arithmetic.group(2) == '+' else 'dec',
|
|
3632
|
-
'value': _number_from_token(arithmetic.group(3))})
|
|
3633
|
-
return out
|
|
3634
|
-
|
|
3635
|
-
|
|
3636
|
-
# ---- parsing: diagram text ------------------------------------------------
|
|
3637
|
-
|
|
3638
|
-
_STATE_EDGE_RE = re.compile(r'^(.+?)\s*-->\s*(.+?)(?:\s*:\s*(.*))?\Z')
|
|
3639
|
-
_STATE_NOTE_RE = re.compile(r'^note\s+(?:over|right of|left of)\s+([A-Za-z_][A-Za-z0-9_]*)\s*:\s*(.+)\Z', re.I)
|
|
3640
|
-
_NOTE_RE = re.compile(r'^note\b', re.I)
|
|
3641
|
-
_END_NOTE_RE = re.compile(r'^end\s*note\Z', re.I)
|
|
3642
|
-
_STATE_DECL_RE = re.compile(r'^state\s+"([^"]*)"\s+as\s+([A-Za-z_][A-Za-z0-9_]*)\Z')
|
|
3643
|
-
_BARE_STATE_RE = re.compile(r'^state\s+([A-Za-z_][A-Za-z0-9_]*)\s*\{?\Z')
|
|
3644
|
-
_CONCURRENCY_RE = re.compile(r'^\}\s*\Z|^--\s*\Z')
|
|
3645
|
-
_COMPOSITE_STATE_RE = re.compile(r'^state\s+(.+)\s*\{\Z')
|
|
3646
|
-
_DESCRIPTION_RE = re.compile(r'^([A-Za-z_][A-Za-z0-9_]*)\s*:\s*(.+)\Z')
|
|
3647
|
-
_MERMAID_STATE_SKIP_RE = re.compile(r'^(stateDiagram|stateDiagram-v2|direction\b|classDef\b|class\b|style\b|linkStyle\b|click\b|hide\b|scale\b|title\b|accTitle\b|accDescr\b|%%\{)')
|
|
3648
|
-
_PLANTUML_STATE_SKIP_RE = re.compile(r'^(@startuml|@enduml|scale\b|skinparam\b|title\b|hide\b|left to right direction|top to bottom direction|autonumber|!theme)')
|
|
3649
|
-
|
|
3650
|
-
_ACTIVITY_SKIP_RE = re.compile(r'^(flowchart|graph)\b|^(classDef|class|style|linkStyle|click|direction)\b|^%%\{')
|
|
3651
|
-
_SUBGRAPH_RE = re.compile(r'^subgraph\b')
|
|
3652
|
-
_SHAPED_EDGE_RE = re.compile(r'^(.*?)\s*-->\s*\|(.*?)\|\s*(.+)\Z')
|
|
3653
|
-
_BARE_EDGE_RE = re.compile(r'^(.*?)\s*-->\s*(.+)\Z')
|
|
3654
|
-
_STRIP_NODE_RE = re.compile(r'^([A-Za-z_][A-Za-z0-9_.-]*)\s*(\(\[|\[\(|\{\{|\[|\(|\(\(|>)?\s*([\s\S]*?)\s*\Z')
|
|
3655
|
-
_QUOTED_ONLY_RE = re.compile(r'^"([^"]+)"\Z')
|
|
3656
|
-
_QUOTED_IN_RE = re.compile(r'"([^"]*)"')
|
|
3657
|
-
|
|
3658
|
-
|
|
3659
|
-
def _detect_notation(text):
|
|
3660
|
-
if re.search(r'^\s*@start', text, re.M):
|
|
3661
|
-
return 'plantuml'
|
|
3662
|
-
if re.search(r'^\s*(stateDiagram|stateDiagram-v2|flowchart|graph|sequenceDiagram)\b', text, re.M):
|
|
3663
|
-
return 'mermaid'
|
|
3664
|
-
raise ValueError('cannot tell whether this is Mermaid or PlantUML text: '
|
|
3665
|
-
'expected `stateDiagram-v2` / `flowchart` / `sequenceDiagram`, or `@startuml`')
|
|
3666
|
-
|
|
3667
|
-
|
|
3668
|
-
def _detect_diagram(text):
|
|
3669
|
-
if re.search(r'^\s*stateDiagram', text, re.M):
|
|
3670
|
-
return 'state'
|
|
3671
|
-
if re.search(r'^\s*(flowchart|graph)\b', text, re.M):
|
|
3672
|
-
return 'activity'
|
|
3673
|
-
if re.search(r'^\s*sequenceDiagram\b', text, re.M):
|
|
3674
|
-
return 'sequence'
|
|
3675
|
-
if re.search(r'^\s*@startuml', text, re.M):
|
|
3676
|
-
# PlantUML declares the diagram kind by its body; the state keyword is the only
|
|
3677
|
-
# structural one logicprobe emits, everything else in that family is a state diagram too.
|
|
3678
|
-
if re.search(r'^\s*participant\b', text, re.M) or re.search(r'->>\s*', text):
|
|
3679
|
-
return 'sequence'
|
|
3680
|
-
return 'state'
|
|
3681
|
-
raise ValueError('cannot tell which diagram kind this text declares')
|
|
3682
|
-
|
|
3683
|
-
|
|
3684
|
-
def _comment_prefix(notation):
|
|
3685
|
-
return '%%' if notation == 'mermaid' else "'"
|
|
3686
|
-
|
|
3687
|
-
|
|
3688
|
-
def _directive_body(line, notation):
|
|
3689
|
-
prefix = _comment_prefix(notation)
|
|
3690
|
-
trimmed = _js_trim(line)
|
|
3691
|
-
if not trimmed.startswith(prefix):
|
|
3692
|
-
return None
|
|
3693
|
-
body = _js_trim(trimmed[len(prefix):])
|
|
3694
|
-
if not body.startswith(_DIRECTIVE_NAMESPACE):
|
|
3695
|
-
return None
|
|
3696
|
-
return body[len(_DIRECTIVE_NAMESPACE):]
|
|
3697
|
-
|
|
3698
|
-
|
|
3699
|
-
def _read_directives(lines, notation):
|
|
3700
|
-
"""Consume the `logicprobe:` directives; returns (directives, body_lines)."""
|
|
3701
|
-
directives = {'terminals': [], 'aliases': {}, 'variables': {}}
|
|
3702
|
-
body = []
|
|
3703
|
-
for line in lines:
|
|
3704
|
-
text = _directive_body(line, notation)
|
|
3705
|
-
if text is None:
|
|
3706
|
-
body.append(line)
|
|
3707
|
-
continue
|
|
3708
|
-
if text.startswith('uml '):
|
|
3709
|
-
continue
|
|
3710
|
-
if text.startswith('init '):
|
|
3711
|
-
directives['init'] = _js_trim(text[5:])
|
|
3712
|
-
continue
|
|
3713
|
-
if text.startswith('terminal '):
|
|
3714
|
-
for sid in text[9:].split(','):
|
|
3715
|
-
trimmed = _js_trim(sid)
|
|
3716
|
-
if trimmed != '':
|
|
3717
|
-
directives['terminals'].append(trimmed)
|
|
3718
|
-
continue
|
|
3719
|
-
if text.startswith('alias '):
|
|
3720
|
-
rest = _js_trim(text[6:])
|
|
3721
|
-
split = rest.find(' ')
|
|
3722
|
-
if split > 0:
|
|
3723
|
-
directives['aliases'][rest[:split]] = _js_trim(rest[split + 1:])
|
|
3724
|
-
continue
|
|
3725
|
-
if text.startswith('variable '):
|
|
3726
|
-
rest = _js_trim(text[9:])
|
|
3727
|
-
split = rest.rfind(' ')
|
|
3728
|
-
if split > 0:
|
|
3729
|
-
kind = _js_trim(rest[split + 1:])
|
|
3730
|
-
if kind == 'integer' or kind == 'boolean':
|
|
3731
|
-
directives['variables'][_js_trim(rest[:split])] = kind
|
|
3732
|
-
continue
|
|
3733
|
-
body.append(line)
|
|
3734
|
-
return directives, body
|
|
3735
|
-
|
|
3736
|
-
|
|
3737
|
-
def _parse_transition_label(label, warnings):
|
|
3738
|
-
rest = _js_trim(label)
|
|
3739
|
-
guard = None
|
|
3740
|
-
bracket = rest.find('[')
|
|
3741
|
-
if bracket >= 0:
|
|
3742
|
-
close = rest.rfind(']')
|
|
3743
|
-
if close < bracket:
|
|
3744
|
-
raise ValueError('unbalanced guard brackets in transition label "' + label + '"')
|
|
3745
|
-
guard = parse_guard_text(_js_trim(rest[bracket + 1:close]))
|
|
3746
|
-
rest = _js_trim(rest[:bracket] + ' ' + rest[close + 1:])
|
|
3747
|
-
updates = None
|
|
3748
|
-
slash = rest.find('/')
|
|
3749
|
-
if slash >= 0:
|
|
3750
|
-
action_text = _js_trim(rest[slash + 1:])
|
|
3751
|
-
updates = parse_updates_text(action_text, warnings)
|
|
3752
|
-
if len(updates) == 0:
|
|
3753
|
-
updates = None
|
|
3754
|
-
rest = _js_trim(rest[:slash])
|
|
3755
|
-
event = _js_trim(rest)
|
|
3756
|
-
if event == '':
|
|
3757
|
-
raise ValueError('transition label "' + label + '" carries no event name; label the arrow as `event [guard] / actions`')
|
|
3758
|
-
parsed = {'event': event}
|
|
3759
|
-
if guard is not None:
|
|
3760
|
-
parsed['guard'] = guard
|
|
3761
|
-
if updates is not None:
|
|
3762
|
-
parsed['updates'] = updates
|
|
3763
|
-
return parsed
|
|
3764
|
-
|
|
3765
|
-
|
|
3766
|
-
def _infer_variables(transitions, declared):
|
|
3767
|
-
"""Recover the variable list from the diagram; directives win, guards/actions are the fallback."""
|
|
3768
|
-
kinds = dict(declared)
|
|
3769
|
-
|
|
3770
|
-
def note(name, kind):
|
|
3771
|
-
if name in declared:
|
|
3772
|
-
return
|
|
3773
|
-
current = kinds.get(name)
|
|
3774
|
-
if current is None:
|
|
3775
|
-
kinds[name] = kind
|
|
3776
|
-
elif current != kind:
|
|
3777
|
-
kinds[name] = 'integer'
|
|
3778
|
-
|
|
3779
|
-
def walk(guard):
|
|
3780
|
-
if guard is None:
|
|
3781
|
-
return
|
|
3782
|
-
if 'variable' in guard:
|
|
3783
|
-
note(guard['variable'], 'boolean' if isinstance(guard['value'], bool) else 'integer')
|
|
3784
|
-
return
|
|
3785
|
-
if 'all' in guard:
|
|
3786
|
-
for child in guard['all']:
|
|
3787
|
-
walk(child)
|
|
3788
|
-
return
|
|
3789
|
-
if 'any' in guard:
|
|
3790
|
-
for child in guard['any']:
|
|
3791
|
-
walk(child)
|
|
3792
|
-
return
|
|
3793
|
-
walk(guard['not'])
|
|
3794
|
-
|
|
3795
|
-
for transition in transitions:
|
|
3796
|
-
walk(transition.get('guard'))
|
|
3797
|
-
for update in transition.get('updates') or []:
|
|
3798
|
-
note(update['variable'], 'integer')
|
|
3799
|
-
return [{'name': name, 'kind': kind, 'init': False if kind == 'boolean' else 0} for name, kind in kinds.items()]
|
|
3800
|
-
|
|
3801
|
-
|
|
3802
|
-
def _raw_to_model(raw, directives, warnings):
|
|
3803
|
-
aliases = directives['aliases']
|
|
3804
|
-
|
|
3805
|
-
def id_of(name):
|
|
3806
|
-
return aliases.get(name, name)
|
|
3807
|
-
|
|
3808
|
-
# Alias directives restore ids the notation cannot spell; they win over the alias itself.
|
|
3809
|
-
names = []
|
|
3810
|
-
seen_names = set()
|
|
3811
|
-
|
|
3812
|
-
def add_name(name):
|
|
3813
|
-
if name is None or name in seen_names:
|
|
3814
|
-
return
|
|
3815
|
-
seen_names.add(name)
|
|
3816
|
-
names.append(name)
|
|
3817
|
-
|
|
3818
|
-
for name in raw['states']:
|
|
3819
|
-
add_name(name)
|
|
3820
|
-
# A state can be known without ever being an edge endpoint: the initial
|
|
3821
|
-
# pseudostate (`[*] --> X`) and the final mark (`Y --> [*]`) both name states a
|
|
3822
|
-
# hand-written diagram never declares, and an isolated state has no edge at all.
|
|
3823
|
-
add_name(raw['initialState'])
|
|
3824
|
-
for name in raw['finalMarks']:
|
|
3825
|
-
add_name(name)
|
|
3826
|
-
for name in raw['terminals']:
|
|
3827
|
-
add_name(name)
|
|
3828
|
-
for edge in raw['edges']:
|
|
3829
|
-
add_name(edge['from'])
|
|
3830
|
-
add_name(edge['to'])
|
|
3831
|
-
states = []
|
|
3832
|
-
seen_states = set()
|
|
3833
|
-
for name in names:
|
|
3834
|
-
sid = id_of(name)
|
|
3835
|
-
if sid in seen_states:
|
|
3836
|
-
continue
|
|
3837
|
-
seen_states.add(sid)
|
|
3838
|
-
states.append({'id': sid})
|
|
3839
|
-
transitions = []
|
|
3840
|
-
synthetic = set()
|
|
3841
|
-
for edge in raw['edges']:
|
|
3842
|
-
frm = id_of(edge['from'])
|
|
3843
|
-
to = id_of(edge['to'])
|
|
3844
|
-
label = _js_trim(edge['label'])
|
|
3845
|
-
guard = None
|
|
3846
|
-
updates = None
|
|
3847
|
-
if label == '':
|
|
3848
|
-
candidate = 't_' + frm + '_' + to
|
|
3849
|
-
suffix = 2
|
|
3850
|
-
while candidate in synthetic:
|
|
3851
|
-
candidate = 't_' + frm + '_' + to + '_' + str(suffix)
|
|
3852
|
-
suffix += 1
|
|
3853
|
-
synthetic.add(candidate)
|
|
3854
|
-
event = candidate
|
|
3855
|
-
warnings.append('UML_PARSE_SYNTHETIC_EVENT: arrow ' + frm + ' -> ' + to + ' carries no label; it was named "' + candidate
|
|
3856
|
-
+ '". Label the arrow as `event [guard] / actions` so the model keeps the real event name.')
|
|
3857
|
-
else:
|
|
3858
|
-
parsed = _parse_transition_label(label, warnings)
|
|
3859
|
-
event = parsed['event']
|
|
3860
|
-
guard = parsed.get('guard')
|
|
3861
|
-
updates = parsed.get('updates')
|
|
3862
|
-
transition = {'from': frm, 'event': event, 'to': to}
|
|
3863
|
-
if guard is not None:
|
|
3864
|
-
transition['guard'] = guard
|
|
3865
|
-
if updates is not None:
|
|
3866
|
-
transition['updates'] = updates
|
|
3867
|
-
transitions.append(transition)
|
|
3868
|
-
# Init: an explicit `[*] --> X` wins; a directive is the fallback the activity
|
|
3869
|
-
# view needs (a flowchart has no initial pseudostate).
|
|
3870
|
-
init = None
|
|
3871
|
-
if raw['initialState'] is not None:
|
|
3872
|
-
init = id_of(raw['initialState'])
|
|
3873
|
-
if init is None and directives.get('init') is not None:
|
|
3874
|
-
init = id_of(directives['init'])
|
|
3875
|
-
if raw['initialState'] is not None and directives.get('init') is not None and id_of(raw['initialState']) != directives['init']:
|
|
3876
|
-
warnings.append('UML_PARSE_INIT_CONFLICT: the diagram enters ' + id_of(raw['initialState']) + ' from its initial pseudostate but declares init '
|
|
3877
|
-
+ directives['init'] + '; the pseudostate wins.')
|
|
3878
|
-
if init is None:
|
|
3879
|
-
targeted = set(transition['to'] for transition in transitions)
|
|
3880
|
-
roots = [state['id'] for state in states if state['id'] not in targeted]
|
|
3881
|
-
if len(roots) == 1:
|
|
3882
|
-
init = roots[0]
|
|
3883
|
-
warnings.append('UML_PARSE_INIT_INFERRED: no initial state was declared; "' + init
|
|
3884
|
-
+ '" is the only state nothing enters, so it is used as init.')
|
|
3885
|
-
else:
|
|
3886
|
-
raise ValueError('no initial state: add `[*] --> <state>` (or a `' + _DIRECTIVE_NAMESPACE
|
|
3887
|
-
+ 'init <state>` directive); found ' + str(len(roots)) + ' entry states')
|
|
3888
|
-
terminal_names = set(id_of(name) for name in raw['terminals'])
|
|
3889
|
-
for name in directives['terminals']:
|
|
3890
|
-
terminal_names.add(id_of(name))
|
|
3891
|
-
for name in raw['finalMarks']:
|
|
3892
|
-
terminal_names.add(id_of(name))
|
|
3893
|
-
for state in states:
|
|
3894
|
-
if state['id'] in terminal_names:
|
|
3895
|
-
state['terminal'] = True
|
|
3896
|
-
variables = _infer_variables(transitions, directives['variables'])
|
|
3897
|
-
model = {'schemaVersion': 1, 'init': init, 'states': states, 'transitions': transitions}
|
|
3898
|
-
if len(variables) > 0:
|
|
3899
|
-
model['variables'] = variables
|
|
3900
|
-
ok, model_or_errors = validate_model(model)
|
|
3901
|
-
if not ok:
|
|
3902
|
-
raise ValueError('the diagram parsed into an invalid model: ' + '; '.join(model_or_errors))
|
|
3903
|
-
return model_or_errors
|
|
3904
|
-
|
|
3905
|
-
|
|
3906
|
-
def _parse_state_diagram(text, notation):
|
|
3907
|
-
lines = re.split(r'\r?\n', text)
|
|
3908
|
-
directives, body = _read_directives(lines, notation)
|
|
3909
|
-
raw = {'states': [], 'display': {}, 'edges': [], 'initialState': None, 'terminals': [], 'finalMarks': [], 'warnings': []}
|
|
3910
|
-
declared = set()
|
|
3911
|
-
|
|
3912
|
-
def declare(name):
|
|
3913
|
-
if name not in declared:
|
|
3914
|
-
declared.add(name)
|
|
3915
|
-
raw['states'].append(name)
|
|
3916
|
-
|
|
3917
|
-
skip = _MERMAID_STATE_SKIP_RE if notation == 'mermaid' else _PLANTUML_STATE_SKIP_RE
|
|
3918
|
-
for raw_line in body:
|
|
3919
|
-
line = _js_trim(raw_line)
|
|
3920
|
-
if line == '' or (line.startswith('--') and '-->' not in line):
|
|
3921
|
-
continue
|
|
3922
|
-
if skip.match(line):
|
|
3923
|
-
continue
|
|
3924
|
-
note = _STATE_NOTE_RE.match(line)
|
|
3925
|
-
if note is not None:
|
|
3926
|
-
declare(note.group(1))
|
|
3927
|
-
if note.group(1) not in raw['display']:
|
|
3928
|
-
raw['display'][note.group(1)] = _js_trim(note.group(2))
|
|
3929
|
-
continue
|
|
3930
|
-
if _NOTE_RE.match(line) or _END_NOTE_RE.match(line):
|
|
3931
|
-
continue
|
|
3932
|
-
state_decl = _STATE_DECL_RE.match(line)
|
|
3933
|
-
if state_decl is not None:
|
|
3934
|
-
declare(state_decl.group(2))
|
|
3935
|
-
raw['display'][state_decl.group(2)] = state_decl.group(1)
|
|
3936
|
-
continue
|
|
3937
|
-
bare_state = _BARE_STATE_RE.match(line)
|
|
3938
|
-
if bare_state is not None:
|
|
3939
|
-
declare(bare_state.group(1))
|
|
3940
|
-
continue
|
|
3941
|
-
if _CONCURRENCY_RE.match(line):
|
|
3942
|
-
raw['warnings'].append('UML_PARSE_CONCURRENCY_FLATTENED: a concurrency region or composite block was flattened; '
|
|
3943
|
-
'LogicModelV1 has no region construct (use logicprobe_compose_verify for parallel machines).')
|
|
3944
|
-
continue
|
|
3945
|
-
composite = _COMPOSITE_STATE_RE.match(line)
|
|
3946
|
-
if composite is not None:
|
|
3947
|
-
raw['warnings'].append('UML_PARSE_COMPOSITE_FLATTENED: composite state "' + _js_trim(composite.group(1))
|
|
3948
|
-
+ '" was flattened into its members.')
|
|
3949
|
-
continue
|
|
3950
|
-
description = _DESCRIPTION_RE.match(line)
|
|
3951
|
-
if description is not None:
|
|
3952
|
-
declare(description.group(1))
|
|
3953
|
-
if description.group(1) not in raw['display']:
|
|
3954
|
-
raw['display'][description.group(1)] = _js_trim(description.group(2))
|
|
3955
|
-
continue
|
|
3956
|
-
edge = _STATE_EDGE_RE.match(line)
|
|
3957
|
-
if edge is not None:
|
|
3958
|
-
frm = _js_trim(edge.group(1))
|
|
3959
|
-
to = _js_trim(edge.group(2))
|
|
3960
|
-
label = _js_trim(edge.group(3) or '')
|
|
3961
|
-
if frm == '[*]':
|
|
3962
|
-
if raw['initialState'] is not None and raw['initialState'] != to:
|
|
3963
|
-
raw['warnings'].append('UML_PARSE_MULTIPLE_INIT: the diagram enters ' + raw['initialState'] + ' and ' + to
|
|
3964
|
-
+ ' from initial pseudostates; LogicModelV1 has one init, so ' + raw['initialState'] + ' is kept.')
|
|
3965
|
-
else:
|
|
3966
|
-
raw['initialState'] = to
|
|
3967
|
-
continue
|
|
3968
|
-
if to == '[*]':
|
|
3969
|
-
raw['finalMarks'].append(frm)
|
|
3970
|
-
continue
|
|
3971
|
-
declare(frm)
|
|
3972
|
-
declare(to)
|
|
3973
|
-
raw['edges'].append({'from': frm, 'to': to, 'label': label})
|
|
3974
|
-
continue
|
|
3975
|
-
raw['warnings'].append('UML_PARSE_IGNORED_LINE: "' + line + '" is not a state diagram statement; it was ignored.')
|
|
3976
|
-
model = _raw_to_model(raw, directives, raw['warnings'])
|
|
3977
|
-
labels = _map_labels(raw['display'], directives)
|
|
3978
|
-
return {'notation': notation, 'diagram': 'state', 'model': model, 'labels': labels, 'warnings': raw['warnings']}
|
|
3979
|
-
|
|
3980
|
-
|
|
3981
|
-
def _strip_quotes(text):
|
|
3982
|
-
return re.sub(r'^"|"$', '', text)
|
|
3983
|
-
|
|
3984
|
-
|
|
3985
|
-
def _strip_node(text, raw, declare):
|
|
3986
|
-
"""Read one node reference (`A`, `A["label"]`, `A(["label"])`) and its display label."""
|
|
3987
|
-
trimmed = _js_trim(text)
|
|
3988
|
-
if trimmed == '':
|
|
3989
|
-
return None
|
|
3990
|
-
match = _STRIP_NODE_RE.match(trimmed)
|
|
3991
|
-
if match is None:
|
|
3992
|
-
quoted_only = _QUOTED_ONLY_RE.match(trimmed)
|
|
3993
|
-
if quoted_only is not None:
|
|
3994
|
-
declare(quoted_only.group(1))
|
|
3995
|
-
return quoted_only.group(1)
|
|
3996
|
-
return None
|
|
3997
|
-
name = match.group(1)
|
|
3998
|
-
shape = match.group(2) or ''
|
|
3999
|
-
rest = match.group(3) or ''
|
|
4000
|
-
declare(name)
|
|
4001
|
-
quoted = _QUOTED_IN_RE.search(rest)
|
|
4002
|
-
if quoted is not None:
|
|
4003
|
-
label = _js_trim(quoted.group(1))
|
|
4004
|
-
else:
|
|
4005
|
-
label = _js_trim(re.sub(r'[\[\](){}>]+$', '', re.sub(r'^[\[\](){}>]+', '', rest)))
|
|
4006
|
-
if label != '' and name not in raw['display']:
|
|
4007
|
-
raw['display'][name] = label
|
|
4008
|
-
if shape == '([' or shape == '((' or rest.startswith('(['):
|
|
4009
|
-
raw['terminals'].append(name)
|
|
4010
|
-
return name
|
|
4011
|
-
|
|
4012
|
-
|
|
4013
|
-
def _parse_activity_diagram(text, notation):
|
|
4014
|
-
lines = re.split(r'\r?\n', text)
|
|
4015
|
-
directives, body = _read_directives(lines, notation)
|
|
4016
|
-
raw = {'states': [], 'display': {}, 'edges': [], 'initialState': None, 'terminals': [], 'finalMarks': [], 'warnings': []}
|
|
4017
|
-
declared = set()
|
|
4018
|
-
|
|
4019
|
-
def declare(name):
|
|
4020
|
-
if name not in declared:
|
|
4021
|
-
declared.add(name)
|
|
4022
|
-
raw['states'].append(name)
|
|
4023
|
-
|
|
4024
|
-
in_subgraph = False
|
|
4025
|
-
for raw_line in body:
|
|
4026
|
-
line = _js_trim(raw_line)
|
|
4027
|
-
if line == '' or _ACTIVITY_SKIP_RE.match(line):
|
|
4028
|
-
continue
|
|
4029
|
-
if _SUBGRAPH_RE.match(line):
|
|
4030
|
-
if not in_subgraph:
|
|
4031
|
-
in_subgraph = True
|
|
4032
|
-
raw['warnings'].append('UML_PARSE_SUBGRAPH_FLATTENED: subgraph blocks were flattened; LogicModelV1 has no hierarchy.')
|
|
4033
|
-
continue
|
|
4034
|
-
if line == 'end':
|
|
4035
|
-
in_subgraph = False
|
|
4036
|
-
continue
|
|
4037
|
-
shaped = _SHAPED_EDGE_RE.match(line)
|
|
4038
|
-
bare = None if shaped is not None else _BARE_EDGE_RE.match(line)
|
|
4039
|
-
if shaped is not None or bare is not None:
|
|
4040
|
-
match = shaped if shaped is not None else bare
|
|
4041
|
-
frm = _strip_node(_js_trim(match.group(1)), raw, declare)
|
|
4042
|
-
# Flowchart labels live between bars; the state-diagram form `A --> B : label`
|
|
4043
|
-
# is accepted too so a hand-written file that mixes the two still reads.
|
|
4044
|
-
raw_to = _js_trim(match.group(3) if shaped is not None else match.group(2))
|
|
4045
|
-
label = _js_trim(_strip_quotes(match.group(2))) if shaped is not None else ''
|
|
4046
|
-
if shaped is None:
|
|
4047
|
-
colon = raw_to.find(':')
|
|
4048
|
-
if colon >= 0:
|
|
4049
|
-
label = _strip_quotes(_js_trim(raw_to[colon + 1:]))
|
|
4050
|
-
raw_to = _js_trim(raw_to[:colon])
|
|
4051
|
-
to = _strip_node(raw_to, raw, declare)
|
|
4052
|
-
if frm is None or to is None:
|
|
4053
|
-
continue
|
|
4054
|
-
raw['edges'].append({'from': frm, 'to': to, 'label': label})
|
|
4055
|
-
continue
|
|
4056
|
-
node = _strip_node(line, raw, declare)
|
|
4057
|
-
if node is None:
|
|
4058
|
-
raw['warnings'].append('UML_PARSE_IGNORED_LINE: "' + line + '" is not a flowchart statement; it was ignored.')
|
|
4059
|
-
model = _raw_to_model(raw, directives, raw['warnings'])
|
|
4060
|
-
labels = _map_labels(raw['display'], directives)
|
|
4061
|
-
return {'notation': notation, 'diagram': 'activity', 'model': model, 'labels': labels, 'warnings': raw['warnings']}
|
|
4062
|
-
|
|
4063
|
-
|
|
4064
|
-
def _map_labels(display, directives):
|
|
4065
|
-
out = {}
|
|
4066
|
-
for name, label in display.items():
|
|
4067
|
-
out[directives['aliases'].get(name, name)] = label
|
|
4068
|
-
return out
|
|
4069
|
-
|
|
4070
|
-
|
|
4071
|
-
def parse_uml(text, notation='auto'):
|
|
4072
|
-
"""Parse a Mermaid or PlantUML diagram back into a LogicModelV1."""
|
|
4073
|
-
resolved = _detect_notation(text) if notation == 'auto' else notation
|
|
4074
|
-
kind = _detect_diagram(text)
|
|
4075
|
-
if kind == 'sequence':
|
|
4076
|
-
raise ValueError('a sequence diagram is a trace, not a machine: parsing it would drop every branch the trace did not walk. '
|
|
4077
|
-
'Render diagram "state" or "activity" and parse that instead.')
|
|
4078
|
-
return _parse_state_diagram(text, resolved) if kind == 'state' else _parse_activity_diagram(text, resolved)
|
|
4079
|
-
|
|
4080
|
-
|
|
4081
|
-
# ---- review ---------------------------------------------------------------
|
|
4082
|
-
|
|
4083
|
-
def _reachable_states(model):
|
|
4084
|
-
adjacency = {}
|
|
4085
|
-
for transition in model['transitions']:
|
|
4086
|
-
adjacency.setdefault(transition['from'], []).append(transition['to'])
|
|
4087
|
-
visited = set([model['init']])
|
|
4088
|
-
queue = deque([model['init']])
|
|
4089
|
-
while len(queue) > 0:
|
|
4090
|
-
current = queue.popleft()
|
|
4091
|
-
for nxt in adjacency.get(current, []):
|
|
4092
|
-
if nxt not in visited:
|
|
4093
|
-
visited.add(nxt)
|
|
4094
|
-
queue.append(nxt)
|
|
4095
|
-
return visited
|
|
4096
|
-
|
|
4097
|
-
|
|
4098
|
-
def _canonical_transition(transition):
|
|
4099
|
-
guard = guard_text(transition['guard']) if transition.get('guard') is not None else ''
|
|
4100
|
-
updates = _updates_text(transition['updates']) if transition.get('updates') is not None else ''
|
|
4101
|
-
return transition['from'] + '|' + transition['event'] + '|' + transition['to'] + '|' + guard + '|' + updates
|
|
4102
|
-
|
|
4103
|
-
|
|
4104
|
-
def _documented_meaning(label, sid):
|
|
4105
|
-
"""The narrative meaning a diagram label carries, if it carries one beyond the bare id."""
|
|
4106
|
-
if label is None:
|
|
4107
|
-
return None
|
|
4108
|
-
trimmed = _js_trim(label)
|
|
4109
|
-
if trimmed == '' or trimmed == sid:
|
|
4110
|
-
return None
|
|
4111
|
-
for open_char, close_char in (('(', ')'), ('(', ')')):
|
|
4112
|
-
prefix = sid + open_char
|
|
4113
|
-
if trimmed.startswith(prefix) and trimmed.endswith(close_char):
|
|
4114
|
-
return trimmed[len(prefix):len(trimmed) - len(close_char)]
|
|
4115
|
-
return trimmed
|
|
4116
|
-
|
|
4117
|
-
|
|
4118
|
-
def _collect_guard_variables(guard, sink):
|
|
4119
|
-
if 'variable' in guard:
|
|
4120
|
-
sink.add(guard['variable'])
|
|
4121
|
-
return
|
|
4122
|
-
if 'all' in guard:
|
|
4123
|
-
for child in guard['all']:
|
|
4124
|
-
_collect_guard_variables(child, sink)
|
|
4125
|
-
return
|
|
4126
|
-
if 'any' in guard:
|
|
4127
|
-
for child in guard['any']:
|
|
4128
|
-
_collect_guard_variables(child, sink)
|
|
4129
|
-
return
|
|
4130
|
-
_collect_guard_variables(guard['not'], sink)
|
|
4131
|
-
|
|
4132
|
-
|
|
4133
|
-
def _positive_leaves(guard, sink):
|
|
4134
|
-
"""Positive, conjunctively-reached leaves — the only ones a complementarity witness may use."""
|
|
4135
|
-
if 'variable' in guard:
|
|
4136
|
-
sink.append(guard)
|
|
4137
|
-
return
|
|
4138
|
-
if 'all' in guard:
|
|
4139
|
-
for child in guard['all']:
|
|
4140
|
-
_positive_leaves(child, sink)
|
|
4141
|
-
return
|
|
4142
|
-
# `any` and `not` subtrees are skipped on purpose: a leaf under a disjunction is
|
|
4143
|
-
# not implied by its branch, and `not (x < 3)` is `x >= 3` only for totally
|
|
4144
|
-
# ordered integers — neither is a sound complementarity witness.
|
|
4145
|
-
|
|
4146
|
-
|
|
4147
|
-
_COMPLEMENTS = (('<', '>='), ('<=', '>'), ('==', '!='))
|
|
4148
|
-
|
|
4149
|
-
|
|
4150
|
-
def _complementary_variable(guards):
|
|
4151
|
-
"""Name a variable whose guards contain a complementary pair, or None when there is none."""
|
|
4152
|
-
leaves = []
|
|
4153
|
-
for guard in guards:
|
|
4154
|
-
_positive_leaves(guard, leaves)
|
|
4155
|
-
for i in range(len(leaves)):
|
|
4156
|
-
for j in range(i + 1, len(leaves)):
|
|
4157
|
-
left = leaves[i]
|
|
4158
|
-
right = leaves[j]
|
|
4159
|
-
if left['variable'] != right['variable']:
|
|
4160
|
-
continue
|
|
4161
|
-
if not _js_strict_equal(left['value'], right['value']):
|
|
4162
|
-
continue
|
|
4163
|
-
for one, other in _COMPLEMENTS:
|
|
4164
|
-
if (left['op'] == one and right['op'] == other) or (left['op'] == other and right['op'] == one):
|
|
4165
|
-
return left['variable']
|
|
4166
|
-
return None
|
|
4167
|
-
|
|
4168
|
-
|
|
4169
|
-
def _structural_findings(model, labels, warnings):
|
|
4170
|
-
findings = []
|
|
4171
|
-
reachable = _reachable_states(model)
|
|
4172
|
-
outgoing = {}
|
|
4173
|
-
for transition in model['transitions']:
|
|
4174
|
-
outgoing.setdefault(transition['from'], []).append(transition)
|
|
4175
|
-
|
|
4176
|
-
unreachable = [state['id'] for state in model['states'] if state['id'] not in reachable]
|
|
4177
|
-
if len(unreachable) > 0:
|
|
4178
|
-
findings.append({
|
|
4179
|
-
'code': 'UML002_UNREACHABLE_STATE',
|
|
4180
|
-
'severity': 'error',
|
|
4181
|
-
'message': str(len(unreachable)) + ' state(s) cannot be entered from init along any transition, so the diagram draws flow nobody can reach.',
|
|
4182
|
-
'states': unreachable,
|
|
4183
|
-
'detail': 'Structural reachability (guards ignored). Guard-aware reachability is S1 in logicprobe_verify.',
|
|
4184
|
-
})
|
|
4185
|
-
|
|
4186
|
-
dead_ends = [state['id'] for state in model['states']
|
|
4187
|
-
if state.get('terminal') is not True and len(outgoing.get(state['id'], [])) == 0]
|
|
4188
|
-
if len(dead_ends) > 0:
|
|
4189
|
-
findings.append({
|
|
4190
|
-
'code': 'UML003_DEAD_END_STATE',
|
|
4191
|
-
'severity': 'error',
|
|
4192
|
-
'message': str(len(dead_ends)) + ' non-terminal state(s) have no outgoing transition: the flow stops there without a modelled terminal.',
|
|
4193
|
-
'states': dead_ends,
|
|
4194
|
-
'detail': 'Either the state is terminal (add `X --> [*]`) or the outgoing flow is missing from the model. '
|
|
4195
|
-
'logicprobe_verify S2 reports the same shape at runtime granularity.',
|
|
4196
|
-
})
|
|
4197
|
-
|
|
4198
|
-
groups = {}
|
|
4199
|
-
for transition in model['transitions']:
|
|
4200
|
-
key = transition['from'] + '\u0000' + transition['event']
|
|
4201
|
-
groups.setdefault(key, []).append(transition)
|
|
4202
|
-
ambiguous = []
|
|
4203
|
-
overlapping = []
|
|
4204
|
-
inexhaustive = []
|
|
4205
|
-
probably_exhaustive = []
|
|
4206
|
-
complementary = []
|
|
4207
|
-
for group in groups.values():
|
|
4208
|
-
unguarded = [transition for transition in group if transition.get('guard') is None]
|
|
4209
|
-
guarded = [transition for transition in group if transition.get('guard') is not None]
|
|
4210
|
-
if len(unguarded) > 1:
|
|
4211
|
-
for transition in unguarded:
|
|
4212
|
-
ambiguous.append({'from': transition['from'], 'event': transition['event'], 'to': transition['to']})
|
|
4213
|
-
seen_guards = {}
|
|
4214
|
-
for transition in guarded:
|
|
4215
|
-
text = guard_text(transition['guard'])
|
|
4216
|
-
if text in seen_guards:
|
|
4217
|
-
overlapping.append({'from': transition['from'], 'event': transition['event'], 'to': transition['to']})
|
|
4218
|
-
else:
|
|
4219
|
-
seen_guards[text] = transition
|
|
4220
|
-
if len(guarded) > 0 and len(unguarded) == 0:
|
|
4221
|
-
row = {'from': group[0]['from'], 'event': group[0]['event'], 'to': group[0]['to']}
|
|
4222
|
-
# A complementary pair on one variable (`x < 3` / `x >= 3`) is exhaustive for
|
|
4223
|
-
# any valuation, so the structural warning would be a false alarm there. The
|
|
4224
|
-
# pair test is deliberately narrow — it cannot prove exhaustiveness, only
|
|
4225
|
-
# recognise the common shape, which is why the finding stays on the report at
|
|
4226
|
-
# info severity and still routes to S6.
|
|
4227
|
-
witness = _complementary_variable([transition['guard'] for transition in guarded])
|
|
4228
|
-
if witness is None:
|
|
4229
|
-
inexhaustive.append(row)
|
|
4230
|
-
else:
|
|
4231
|
-
probably_exhaustive.append(row)
|
|
4232
|
-
complementary.append(witness)
|
|
4233
|
-
if len(ambiguous) > 0:
|
|
4234
|
-
findings.append({
|
|
4235
|
-
'code': 'UML004_AMBIGUOUS_BRANCH',
|
|
4236
|
-
'severity': 'error',
|
|
4237
|
-
'message': str(len(ambiguous)) + ' branch(es) share a (state, event) with no guard at all: '
|
|
4238
|
-
'the diagram shows two unconditional arrows for one event, which no reader can resolve.',
|
|
4239
|
-
'transitions': ambiguous,
|
|
4240
|
-
'detail': 'Keep one unguarded branch per (state, event) as the else case, and guard the others. '
|
|
4241
|
-
'logicprobe_verify S4 is the authoritative determinism check.',
|
|
4242
|
-
})
|
|
4243
|
-
if len(overlapping) > 0:
|
|
4244
|
-
findings.append({
|
|
4245
|
-
'code': 'UML005_OVERLAPPING_GUARD',
|
|
4246
|
-
'severity': 'warning',
|
|
4247
|
-
'message': str(len(overlapping)) + ' transition(s) repeat a guard already used by another branch of the same (state, event).',
|
|
4248
|
-
'transitions': overlapping,
|
|
4249
|
-
})
|
|
4250
|
-
if len(inexhaustive) > 0:
|
|
4251
|
-
findings.append({
|
|
4252
|
-
'code': 'UML006_INEXHAUSTIVE_BRANCH',
|
|
4253
|
-
'severity': 'warning',
|
|
4254
|
-
'message': str(len(inexhaustive)) + ' (state, event) group(s) have only guarded branches and no default: '
|
|
4255
|
-
'if every guard is false the flow vanishes, and the diagram still implies coverage.',
|
|
4256
|
-
'transitions': inexhaustive,
|
|
4257
|
-
'detail': 'Add an unguarded else branch, or confirm exhaustiveness with logicprobe_verify S6 (which evaluates guards over real valuations).',
|
|
4258
|
-
})
|
|
4259
|
-
if len(probably_exhaustive) > 0:
|
|
4260
|
-
findings.append({
|
|
4261
|
-
'code': 'UML006_INEXHAUSTIVE_BRANCH',
|
|
4262
|
-
'severity': 'info',
|
|
4263
|
-
'message': str(len(probably_exhaustive)) + ' (state, event) group(s) have guards that look complementary on '
|
|
4264
|
-
+ ', '.join(dict.fromkeys(complementary))
|
|
4265
|
-
+ ', so they are probably exhaustive — but no default branch exists and only logicprobe_verify S6 can settle it.',
|
|
4266
|
-
'transitions': probably_exhaustive,
|
|
4267
|
-
})
|
|
4268
|
-
|
|
4269
|
-
events_by_reachable = dict.fromkeys(transition['event'] for transition in model['transitions']
|
|
4270
|
-
if transition['from'] in reachable)
|
|
4271
|
-
events_anywhere = dict.fromkeys(transition['event'] for transition in model['transitions'])
|
|
4272
|
-
dead_events = [event for event in events_anywhere if event not in events_by_reachable]
|
|
4273
|
-
if len(dead_events) > 0:
|
|
4274
|
-
findings.append({
|
|
4275
|
-
'code': 'UML007_UNUSED_EVENT',
|
|
4276
|
-
'severity': 'warning',
|
|
4277
|
-
'message': str(len(dead_events)) + ' event(s) only fire from states nothing can reach, so the diagram shows messages that never arrive.',
|
|
4278
|
-
'events': dead_events,
|
|
4279
|
-
})
|
|
4280
|
-
|
|
4281
|
-
self_loops = [transition for transition in model['transitions']
|
|
4282
|
-
if transition['from'] == transition['to'] and transition.get('guard') is None
|
|
4283
|
-
and len(outgoing.get(transition['from'], [])) == 1]
|
|
4284
|
-
if len(self_loops) > 0:
|
|
4285
|
-
findings.append({
|
|
4286
|
-
'code': 'UML008_SELF_LOOP_NO_EXIT',
|
|
4287
|
-
'severity': 'warning',
|
|
4288
|
-
'message': str(len(self_loops)) + ' state(s) have a single unguarded self-loop and no exit: '
|
|
4289
|
-
'the flow can never leave, which the diagram presents as activity.',
|
|
4290
|
-
'states': list(dict.fromkeys(transition['from'] for transition in self_loops)),
|
|
4291
|
-
'detail': 'logicprobe_verify S3 reports absorbing cycles (liveness).',
|
|
4292
|
-
})
|
|
4293
|
-
|
|
4294
|
-
seen_transitions = {}
|
|
4295
|
-
for transition in model['transitions']:
|
|
4296
|
-
key = _canonical_transition(transition)
|
|
4297
|
-
seen_transitions[key] = seen_transitions.get(key, 0) + 1
|
|
4298
|
-
duplicates = [transition for transition in model['transitions']
|
|
4299
|
-
if seen_transitions.get(_canonical_transition(transition), 0) > 1]
|
|
4300
|
-
if len(duplicates) > 0:
|
|
4301
|
-
findings.append({
|
|
4302
|
-
'code': 'UML009_DUPLICATE_TRANSITION',
|
|
4303
|
-
'severity': 'warning',
|
|
4304
|
-
'message': str(len(duplicates)) + ' transition(s) duplicate an identical (from, event, guard, actions, to) row; '
|
|
4305
|
-
'the diagram draws the same arrow twice.',
|
|
4306
|
-
'transitions': [{'from': transition['from'], 'event': transition['event'], 'to': transition['to']} for transition in duplicates],
|
|
4307
|
-
})
|
|
4308
|
-
|
|
4309
|
-
guard_variables = set()
|
|
4310
|
-
for transition in model['transitions']:
|
|
4311
|
-
if transition.get('guard') is not None:
|
|
4312
|
-
_collect_guard_variables(transition['guard'], guard_variables)
|
|
4313
|
-
updated_variables = set()
|
|
4314
|
-
for transition in model['transitions']:
|
|
4315
|
-
for update in transition.get('updates') or []:
|
|
4316
|
-
updated_variables.add(update['variable'])
|
|
4317
|
-
unused_variables = [variable['name'] for variable in (model.get('variables') or [])
|
|
4318
|
-
if variable['name'] not in guard_variables and variable['name'] not in updated_variables]
|
|
4319
|
-
if len(unused_variables) > 0:
|
|
4320
|
-
findings.append({
|
|
4321
|
-
'code': 'UML010_UNUSED_VARIABLE',
|
|
4322
|
-
'severity': 'warning',
|
|
4323
|
-
'message': str(len(unused_variables)) + ' variable(s) are never read by a guard and never written: '
|
|
4324
|
-
'the diagram carries a symbol with no source.',
|
|
4325
|
-
'events': unused_variables,
|
|
4326
|
-
})
|
|
4327
|
-
unbounded = [variable['name'] for variable in (model.get('variables') or [])
|
|
4328
|
-
if variable['kind'] == 'integer' and ('min' not in variable or 'max' not in variable)]
|
|
4329
|
-
if len(unbounded) > 0:
|
|
4330
|
-
findings.append({
|
|
4331
|
-
'code': 'UML011_UNBOUNDED_VARIABLE',
|
|
4332
|
-
'severity': 'info',
|
|
4333
|
-
'message': str(len(unbounded)) + ' integer variable(s) declare no min/max, so no range invariant can be checked '
|
|
4334
|
-
'and A5 boundary probing has no declared domain.',
|
|
4335
|
-
'detail': ', '.join(unbounded),
|
|
4336
|
-
})
|
|
4337
|
-
|
|
4338
|
-
terminals = [state['id'] for state in model['states'] if state.get('terminal') is True]
|
|
4339
|
-
if len(terminals) == 0:
|
|
4340
|
-
findings.append({
|
|
4341
|
-
'code': 'UML012_NO_TERMINAL',
|
|
4342
|
-
'severity': 'warning',
|
|
4343
|
-
'message': 'no state is terminal: the diagram has no `--> [*]`, so completion, failure and a stuck flow look the same.',
|
|
4344
|
-
'detail': 'Mark absorbing states terminal, or state explicitly that the machine is non-terminating.',
|
|
4345
|
-
})
|
|
4346
|
-
|
|
4347
|
-
if 'narrative' not in model:
|
|
4348
|
-
findings.append({
|
|
4349
|
-
'code': 'UML013_NO_NARRATIVE',
|
|
4350
|
-
'severity': 'info',
|
|
4351
|
-
'message': 'the model carries no narrative block: no state, event or scenario has a natural-language meaning, '
|
|
4352
|
-
'so a reader must re-derive every symbol from the source.',
|
|
4353
|
-
'detail': 'Add narrative.states / narrative.events / narrative.scenarios — the schema requires all three and full coverage once the block is present.',
|
|
4354
|
-
})
|
|
4355
|
-
|
|
4356
|
-
documented = None
|
|
4357
|
-
if labels is not None:
|
|
4358
|
-
documented = [sid for sid in labels if _documented_meaning(labels[sid], sid) is not None]
|
|
4359
|
-
if documented is not None:
|
|
4360
|
-
undocumented = [state['id'] for state in model['states']
|
|
4361
|
-
if _documented_meaning(labels.get(state['id']), state['id']) is None]
|
|
4362
|
-
if len(undocumented) > 0:
|
|
4363
|
-
findings.append({
|
|
4364
|
-
'code': 'UML014_UNDOCUMENTED_STATE',
|
|
4365
|
-
'severity': 'info',
|
|
4366
|
-
'message': str(len(undocumented)) + ' of ' + str(len(model['states']))
|
|
4367
|
-
+ ' states carry no meaning in the diagram (they render as their bare id).',
|
|
4368
|
-
'states': undocumented,
|
|
4369
|
-
'detail': 'Give each state a `state "meaning" as ID` label or `ID : meaning` description so the diagram can be read against the code.',
|
|
4370
|
-
})
|
|
4371
|
-
narrative = model.get('narrative')
|
|
4372
|
-
if narrative is not None and narrative.get('states') is not None:
|
|
4373
|
-
drift = []
|
|
4374
|
-
for state in model['states']:
|
|
4375
|
-
meaning = _documented_meaning(labels.get(state['id']), state['id'])
|
|
4376
|
-
declared = narrative['states'].get(state['id'])
|
|
4377
|
-
if meaning is not None and declared is not None and meaning != declared:
|
|
4378
|
-
drift.append(state['id'] + ': diagram "' + meaning + '" vs narrative "' + declared + '"')
|
|
4379
|
-
if len(drift) > 0:
|
|
4380
|
-
findings.append({
|
|
4381
|
-
'code': 'UML015_LABEL_DRIFT',
|
|
4382
|
-
'severity': 'warning',
|
|
4383
|
-
'message': str(len(drift)) + ' state label(s) disagree with the model narrative: one of the two is stale, '
|
|
4384
|
-
'and the review cannot tell which.',
|
|
4385
|
-
'detail': ' | '.join(drift),
|
|
4386
|
-
})
|
|
4387
|
-
|
|
4388
|
-
if len(warnings) > 0:
|
|
4389
|
-
findings.append({
|
|
4390
|
-
'code': 'UML016_DIAGRAM_PARSE_NOTES',
|
|
4391
|
-
'severity': 'info',
|
|
4392
|
-
'message': str(len(warnings)) + ' note(s) were produced while rendering or reading the diagram; '
|
|
4393
|
-
'they mark information the notation could not carry.',
|
|
4394
|
-
'detail': ' | '.join(warnings),
|
|
4395
|
-
})
|
|
4396
|
-
|
|
4397
|
-
return findings
|
|
4398
|
-
|
|
4399
|
-
|
|
4400
|
-
def _compiled_model(input_value):
|
|
4401
|
-
ok, model_or_errors = validate_model(input_value)
|
|
4402
|
-
if not ok:
|
|
4403
|
-
raise ValueError('model invalid: ' + '; '.join(model_or_errors))
|
|
4404
|
-
return model_or_errors
|
|
4405
|
-
|
|
4406
|
-
|
|
4407
|
-
def _round_trip_of(model, notation, diagram, max_steps):
|
|
4408
|
-
rendered = render_uml(model, notation, diagram, max_steps)
|
|
4409
|
-
parsed = parse_uml(rendered['primary'], notation)
|
|
4410
|
-
diffs = diff_models(model, parsed['model'])
|
|
4411
|
-
report = {
|
|
4412
|
-
'notation': notation,
|
|
4413
|
-
'diagram': diagram,
|
|
4414
|
-
'ok': len(diffs) == 0,
|
|
4415
|
-
'modelHash': model_hash(model),
|
|
4416
|
-
'parsedHash': model_hash(parsed['model']),
|
|
4417
|
-
'diffs': diffs,
|
|
4418
|
-
'warnings': list(rendered['warnings']) + list(parsed['warnings']),
|
|
4419
|
-
}
|
|
4420
|
-
return {'report': report, 'primary': rendered['primary']}
|
|
4421
|
-
|
|
4422
|
-
|
|
4423
|
-
def diff_models(left, right):
|
|
4424
|
-
"""Compare two machines by structure — the fidelity measure behind the round-trip check."""
|
|
4425
|
-
diffs = []
|
|
4426
|
-
if left['init'] != right['init']:
|
|
4427
|
-
diffs.append('init: ' + left['init'] + ' vs ' + right['init'])
|
|
4428
|
-
left_states = [state['id'] for state in left['states']]
|
|
4429
|
-
right_states = [state['id'] for state in right['states']]
|
|
4430
|
-
for sid in left_states:
|
|
4431
|
-
if sid not in right_states:
|
|
4432
|
-
diffs.append('state missing after parse: ' + sid)
|
|
4433
|
-
for sid in right_states:
|
|
4434
|
-
if sid not in left_states:
|
|
4435
|
-
diffs.append('state invented by the diagram: ' + sid)
|
|
4436
|
-
left_terminal = sorted(state['id'] for state in left['states'] if state.get('terminal') is True)
|
|
4437
|
-
right_terminal = sorted(state['id'] for state in right['states'] if state.get('terminal') is True)
|
|
4438
|
-
if ', '.join(left_terminal) != ', '.join(right_terminal):
|
|
4439
|
-
diffs.append('terminal states: [' + ', '.join(left_terminal) + '] vs [' + ', '.join(right_terminal) + ']')
|
|
4440
|
-
left_transitions = sorted(_canonical_transition(transition) for transition in left['transitions'])
|
|
4441
|
-
right_transitions = sorted(_canonical_transition(transition) for transition in right['transitions'])
|
|
4442
|
-
left_count = {}
|
|
4443
|
-
for key in left_transitions:
|
|
4444
|
-
left_count[key] = left_count.get(key, 0) + 1
|
|
4445
|
-
right_count = {}
|
|
4446
|
-
for key in right_transitions:
|
|
4447
|
-
right_count[key] = right_count.get(key, 0) + 1
|
|
4448
|
-
for key, count in left_count.items():
|
|
4449
|
-
other = right_count.get(key, 0)
|
|
4450
|
-
if other < count:
|
|
4451
|
-
diffs.append('transition lost in the diagram (' + str(count - other) + 'x): ' + key.replace('|', ' '))
|
|
4452
|
-
for key, count in right_count.items():
|
|
4453
|
-
other = left_count.get(key, 0)
|
|
4454
|
-
if other < count:
|
|
4455
|
-
diffs.append('transition invented by the diagram (' + str(count - other) + 'x): ' + key.replace('|', ' '))
|
|
4456
|
-
left_variables = sorted(variable['name'] + ':' + variable['kind'] for variable in (left.get('variables') or []))
|
|
4457
|
-
right_variables = sorted(variable['name'] + ':' + variable['kind'] for variable in (right.get('variables') or []))
|
|
4458
|
-
for name in left_variables:
|
|
4459
|
-
if name not in right_variables:
|
|
4460
|
-
diffs.append('variable missing after parse: ' + name)
|
|
4461
|
-
for name in right_variables:
|
|
4462
|
-
if name not in left_variables:
|
|
4463
|
-
diffs.append('variable invented by the diagram: ' + name)
|
|
4464
|
-
return diffs
|
|
4465
|
-
|
|
4466
|
-
|
|
4467
|
-
def review_uml(options):
|
|
4468
|
-
"""Mirror of reviewUml: review a machine, a diagram, or the match between the two."""
|
|
4469
|
-
warnings = []
|
|
4470
|
-
findings = []
|
|
4471
|
-
has_model = 'model' in options
|
|
4472
|
-
has_diagram = isinstance(options.get('diagram'), str) and _js_trim(options['diagram']) != ''
|
|
4473
|
-
if not has_model and not has_diagram:
|
|
4474
|
-
raise ValueError('review needs `model`, `diagram`, or both')
|
|
4475
|
-
|
|
4476
|
-
notation = 'mermaid'
|
|
4477
|
-
if options.get('notation') is not None and options['notation'] != 'auto':
|
|
4478
|
-
notation = options['notation']
|
|
4479
|
-
kind = options['diagramKind'] if options.get('diagramKind') is not None else 'state'
|
|
4480
|
-
model = None
|
|
4481
|
-
labels = None
|
|
4482
|
-
primary = None
|
|
4483
|
-
round_trip = None
|
|
4484
|
-
|
|
4485
|
-
if has_diagram:
|
|
4486
|
-
try:
|
|
4487
|
-
parsed = parse_uml(options['diagram'], options['notation'] if options.get('notation') is not None else 'auto')
|
|
4488
|
-
except ValueError as exc:
|
|
4489
|
-
return {
|
|
4490
|
-
'ok': False,
|
|
4491
|
-
'source': 'model+diagram' if has_model else 'diagram',
|
|
4492
|
-
'summary': {'errors': 1, 'warnings': 0, 'info': 0, 'states': 0, 'events': 0, 'transitions': 0,
|
|
4493
|
-
'terminalStates': 0, 'reachableStates': 0, 'documentedStates': 0},
|
|
4494
|
-
'findings': [{'code': 'UML001_DIAGRAM_UNREADABLE', 'severity': 'error', 'message': str(exc),
|
|
4495
|
-
'detail': 'The diagram could not be read as a Mermaid/PlantUML state or activity diagram.'}],
|
|
4496
|
-
'roundTrip': None,
|
|
4497
|
-
'warnings': warnings,
|
|
4498
|
-
'nextSteps': ['Fix the diagram syntax (or render one from a model with logicprobe_uml action=render) and review again.'],
|
|
4499
|
-
}
|
|
4500
|
-
notation = parsed['notation']
|
|
4501
|
-
labels = parsed['labels']
|
|
4502
|
-
warnings.extend(parsed['warnings'])
|
|
4503
|
-
if has_model:
|
|
4504
|
-
model = _compiled_model(options['model'])
|
|
4505
|
-
diffs = diff_models(model, parsed['model'])
|
|
4506
|
-
round_trip = {
|
|
4507
|
-
'notation': parsed['notation'],
|
|
4508
|
-
'diagram': parsed['diagram'],
|
|
4509
|
-
'ok': len(diffs) == 0,
|
|
4510
|
-
'modelHash': model_hash(model),
|
|
4511
|
-
'parsedHash': model_hash(parsed['model']),
|
|
4512
|
-
'diffs': diffs,
|
|
4513
|
-
'warnings': list(parsed['warnings']),
|
|
4514
|
-
}
|
|
4515
|
-
if len(diffs) > 0:
|
|
4516
|
-
detail = ' | '.join(diffs[:12])
|
|
4517
|
-
if len(diffs) > 12:
|
|
4518
|
-
detail += ' | … ' + str(len(diffs) - 12) + ' more'
|
|
4519
|
-
findings.append({
|
|
4520
|
-
'code': 'UML017_ROUND_TRIP_MISMATCH',
|
|
4521
|
-
'severity': 'error',
|
|
4522
|
-
'message': 'the diagram does not carry the model it is presented with: ' + str(len(diffs)) + ' structural difference(s).',
|
|
4523
|
-
'detail': detail,
|
|
4524
|
-
})
|
|
4525
|
-
if len(diffs) == 0 and labels is not None and 'narrative' not in model:
|
|
4526
|
-
warnings.append('UML_REVIEW_DIAGRAM_LABELS_IGNORED_BY_MODEL: the diagram carries state labels but the model has no narrative block, '
|
|
4527
|
-
'so the labels live only in the diagram.')
|
|
4528
|
-
else:
|
|
4529
|
-
model = parsed['model']
|
|
4530
|
-
findings.append({
|
|
4531
|
-
'code': 'UML018_FIDELITY_UNCHECKED',
|
|
4532
|
-
'severity': 'info',
|
|
4533
|
-
'message': 'only a diagram was supplied, so the review reads the diagram as the model: nothing here proves the diagram matches the code it claims to describe.',
|
|
4534
|
-
'detail': 'Compare the parsed model against the code (each state/event/guard needs a source citation), '
|
|
4535
|
-
'or pass the machine alongside the diagram to check the two against each other.',
|
|
4536
|
-
})
|
|
4537
|
-
else:
|
|
4538
|
-
model = _compiled_model(options['model'])
|
|
4539
|
-
max_steps = options['maxSteps'] if options.get('maxSteps') is not None else 60
|
|
4540
|
-
# A sequence view is a trace, so parsing it back would drop every branch the
|
|
4541
|
-
# walk never took: the fidelity check cannot apply to it, and pretending it did
|
|
4542
|
-
# would either fail spuriously or hide the difference. Render it anyway (the
|
|
4543
|
-
# caller asked for that view) and say the check does not apply.
|
|
4544
|
-
if kind == 'sequence':
|
|
4545
|
-
try:
|
|
4546
|
-
rendered = render_uml(model, notation, kind, max_steps)
|
|
4547
|
-
primary = rendered['primary']
|
|
4548
|
-
warnings.extend(rendered['warnings'])
|
|
4549
|
-
except ValueError as exc:
|
|
4550
|
-
warnings.append('UML_REVIEW_RENDER_SKIPPED: ' + str(exc))
|
|
4551
|
-
findings.append({
|
|
4552
|
-
'code': 'UML019_ROUND_TRIP_SKIPPED',
|
|
4553
|
-
'severity': 'warning',
|
|
4554
|
-
'message': 'a sequence view is one trace, not a restatement of the machine, so the render/parse fidelity check does not apply to it; '
|
|
4555
|
-
'render diagram "state" or "activity" to have the diagram checked against the model.',
|
|
4556
|
-
})
|
|
4557
|
-
elif options.get('roundTrip') is not False:
|
|
4558
|
-
try:
|
|
4559
|
-
rendered = _round_trip_of(model, notation, kind, max_steps)
|
|
4560
|
-
primary = rendered['primary']
|
|
4561
|
-
round_trip = rendered['report']
|
|
4562
|
-
warnings.extend(rendered['report']['warnings'])
|
|
4563
|
-
if not rendered['report']['ok']:
|
|
4564
|
-
findings.append({
|
|
4565
|
-
'code': 'UML017_ROUND_TRIP_MISMATCH',
|
|
4566
|
-
'severity': 'error',
|
|
4567
|
-
'message': 'the rendered diagram does not read back as the model: ' + str(len(rendered['report']['diffs']))
|
|
4568
|
-
+ ' structural difference(s).',
|
|
4569
|
-
'detail': ' | '.join(rendered['report']['diffs'][:12]),
|
|
4570
|
-
})
|
|
4571
|
-
except ValueError as exc:
|
|
4572
|
-
message = str(exc)
|
|
4573
|
-
warnings.append('UML_REVIEW_ROUND_TRIP_SKIPPED: ' + message)
|
|
4574
|
-
findings.append({
|
|
4575
|
-
'code': 'UML019_ROUND_TRIP_SKIPPED',
|
|
4576
|
-
'severity': 'warning',
|
|
4577
|
-
'message': 'the fidelity check could not run for ' + notation + '/' + kind + ': ' + message,
|
|
4578
|
-
})
|
|
4579
|
-
else:
|
|
4580
|
-
findings.append({
|
|
4581
|
-
'code': 'UML019_ROUND_TRIP_SKIPPED',
|
|
4582
|
-
'severity': 'warning',
|
|
4583
|
-
'message': 'the fidelity check was switched off (roundTrip=false); nothing here proves the diagram carries the model.',
|
|
4584
|
-
})
|
|
4585
|
-
|
|
4586
|
-
findings.extend(_structural_findings(model, labels, warnings))
|
|
4587
|
-
errors = len([finding for finding in findings if finding['severity'] == 'error'])
|
|
4588
|
-
warning_count = len([finding for finding in findings if finding['severity'] == 'warning'])
|
|
4589
|
-
info = len([finding for finding in findings if finding['severity'] == 'info'])
|
|
4590
|
-
reachable = _reachable_states(model)
|
|
4591
|
-
if labels is None:
|
|
4592
|
-
narrative = model.get('narrative')
|
|
4593
|
-
documented_states = 0 if narrative is None or narrative.get('states') is None else len(narrative['states'])
|
|
4594
|
-
else:
|
|
4595
|
-
documented_states = len([sid for sid in labels if _documented_meaning(labels[sid], sid) is not None])
|
|
4596
|
-
events = dict.fromkeys(transition['event'] for transition in model['transitions'])
|
|
4597
|
-
next_steps = []
|
|
4598
|
-
if errors > 0:
|
|
4599
|
-
next_steps.append('Resolve the error findings first — a diagram that cannot be read (or that disagrees with its model) '
|
|
4600
|
-
'will mislead every later review.')
|
|
4601
|
-
next_steps.append('Run logicprobe_verify on this model for the behavioural checks (S1-S8 structural, A1-A14 adversarial); '
|
|
4602
|
-
'the review above covers modelling, not behaviour.')
|
|
4603
|
-
if 'narrative' not in model:
|
|
4604
|
-
next_steps.append('Add narrative.states/events/scenarios so the diagram is readable against the code.')
|
|
4605
|
-
if any(finding['code'] == 'UML011_UNBOUNDED_VARIABLE' for finding in findings):
|
|
4606
|
-
next_steps.append('Declare min/max (or boundaryChecks) before relying on A5 boundary probes.')
|
|
4607
|
-
report = {
|
|
4608
|
-
'ok': True,
|
|
4609
|
-
'source': 'model+diagram' if (has_model and has_diagram) else ('diagram' if has_diagram else 'model'),
|
|
4610
|
-
'summary': {
|
|
4611
|
-
'errors': errors,
|
|
4612
|
-
'warnings': warning_count,
|
|
4613
|
-
'info': info,
|
|
4614
|
-
'states': len(model['states']),
|
|
4615
|
-
'events': len(events),
|
|
4616
|
-
'transitions': len(model['transitions']),
|
|
4617
|
-
'terminalStates': len([state for state in model['states'] if state.get('terminal') is True]),
|
|
4618
|
-
'reachableStates': len(reachable),
|
|
4619
|
-
'documentedStates': documented_states,
|
|
4620
|
-
},
|
|
4621
|
-
'findings': findings,
|
|
4622
|
-
'roundTrip': round_trip,
|
|
4623
|
-
}
|
|
4624
|
-
if labels is not None:
|
|
4625
|
-
report['labels'] = labels
|
|
4626
|
-
if has_diagram:
|
|
4627
|
-
report['model'] = model
|
|
4628
|
-
if primary is not None:
|
|
4629
|
-
report['primary'] = primary
|
|
4630
|
-
report['warnings'] = warnings
|
|
4631
|
-
report['nextSteps'] = next_steps
|
|
4632
|
-
return report
|
|
4633
|
-
|
|
4634
|
-
|
|
4635
|
-
# ---------------------------------------------------------------------------
|
|
4636
|
-
# CLI
|
|
4637
|
-
# ---------------------------------------------------------------------------
|
|
4638
|
-
|
|
4639
|
-
def _load_json_file(file_path):
|
|
4640
|
-
with open(file_path, 'r', encoding='utf-8') as handle:
|
|
4641
|
-
return json.load(handle)
|
|
4642
|
-
|
|
4643
|
-
|
|
4644
|
-
def _cmd_verify(args):
|
|
4645
|
-
model = _load_json_file(args.model)
|
|
4646
|
-
options = {}
|
|
4647
|
-
if args.max_states:
|
|
4648
|
-
options['maxStates'] = args.max_states
|
|
4649
|
-
if args.max_permutation_events:
|
|
4650
|
-
options['maxPermutationEvents'] = args.max_permutation_events
|
|
4651
|
-
if args.before_model:
|
|
4652
|
-
options['beforeModel'] = _load_json_file(args.before_model)
|
|
4653
|
-
if args.state_mapping:
|
|
4654
|
-
options['stateMapping'] = _load_json_file(args.state_mapping)
|
|
4655
|
-
report = run_verification(model, options)
|
|
4656
|
-
print(json.dumps(report, indent=2))
|
|
4657
|
-
sys.exit(0 if report['ok'] and report['summary'].get('errors', 0) == 0 else 2)
|
|
4658
|
-
|
|
4659
|
-
|
|
4660
|
-
def _cmd_compose(args):
|
|
4661
|
-
machines = [_load_json_file(m) for m in args.machines]
|
|
4662
|
-
options = {}
|
|
4663
|
-
if args.rendezvous:
|
|
4664
|
-
options['rendezvous'] = [e for e in args.rendezvous.split(',') if e]
|
|
4665
|
-
if args.max_states:
|
|
4666
|
-
options['maxStates'] = args.max_states
|
|
4667
|
-
report = run_composition_verification(machines, options)
|
|
4668
|
-
print(json.dumps(report, indent=2))
|
|
4669
|
-
sys.exit(0 if report['ok'] else 2)
|
|
4670
|
-
|
|
4671
|
-
|
|
4672
|
-
def _cmd_export(args):
|
|
4673
|
-
model = _load_json_file(args.model)
|
|
4674
|
-
try:
|
|
4675
|
-
result = export_model(model, args.format)
|
|
4676
|
-
except ValueError as exc:
|
|
4677
|
-
print(json.dumps({'ok': False, 'error': str(exc)}))
|
|
4678
|
-
sys.exit(2)
|
|
4679
|
-
out = {'format': result['format'], 'primary': result['primary'],
|
|
4680
|
-
'extras': result['extras'], 'warnings': result['warnings']}
|
|
4681
|
-
print(json.dumps(out, indent=2))
|
|
4682
|
-
|
|
4683
|
-
|
|
4684
|
-
def _cmd_uml_render(args):
|
|
4685
|
-
try:
|
|
4686
|
-
model = _load_json_file(args.model)
|
|
4687
|
-
result = render_uml(model, args.notation, args.diagram, args.max_steps)
|
|
4688
|
-
except (OSError, ValueError) as exc:
|
|
4689
|
-
print(json.dumps({'ok': False, 'error': str(exc)}))
|
|
4690
|
-
sys.exit(2)
|
|
4691
|
-
out = {'notation': result['notation'], 'diagram': result['diagram'],
|
|
4692
|
-
'primary': result['primary'], 'warnings': result['warnings']}
|
|
4693
|
-
print(json.dumps(out, indent=2))
|
|
4694
|
-
|
|
4695
|
-
|
|
4696
|
-
def _cmd_uml_parse(args):
|
|
4697
|
-
try:
|
|
4698
|
-
with open(args.diagram, 'r', encoding='utf-8') as handle:
|
|
4699
|
-
text = handle.read()
|
|
4700
|
-
result = parse_uml(text, args.notation)
|
|
4701
|
-
except (OSError, ValueError) as exc:
|
|
4702
|
-
print(json.dumps({'ok': False, 'error': str(exc)}))
|
|
4703
|
-
sys.exit(2)
|
|
4704
|
-
out = {'notation': result['notation'], 'diagram': result['diagram'], 'model': result['model'],
|
|
4705
|
-
'labels': result['labels'], 'warnings': result['warnings']}
|
|
4706
|
-
print(json.dumps(out, indent=2))
|
|
4707
|
-
|
|
4708
|
-
|
|
4709
|
-
def _cmd_uml_review(args):
|
|
4710
|
-
try:
|
|
4711
|
-
options = {}
|
|
4712
|
-
if args.model:
|
|
4713
|
-
options['model'] = _load_json_file(args.model)
|
|
4714
|
-
if args.diagram:
|
|
4715
|
-
with open(args.diagram, 'r', encoding='utf-8') as handle:
|
|
4716
|
-
options['diagram'] = handle.read()
|
|
4717
|
-
if args.notation is not None:
|
|
4718
|
-
options['notation'] = args.notation
|
|
4719
|
-
if args.diagram_kind is not None:
|
|
4720
|
-
options['diagramKind'] = args.diagram_kind
|
|
4721
|
-
if args.no_round_trip:
|
|
4722
|
-
options['roundTrip'] = False
|
|
4723
|
-
if args.max_steps is not None:
|
|
4724
|
-
options['maxSteps'] = args.max_steps
|
|
4725
|
-
report = review_uml(options)
|
|
4726
|
-
except (OSError, ValueError) as exc:
|
|
4727
|
-
print(json.dumps({'ok': False, 'error': str(exc)}))
|
|
4728
|
-
sys.exit(2)
|
|
4729
|
-
print(json.dumps(report, indent=2))
|
|
4730
|
-
|
|
4731
|
-
|
|
4732
|
-
def _build_parser():
|
|
4733
|
-
parser = argparse.ArgumentParser(prog='logicprobe-engine.py',
|
|
4734
|
-
description='Standalone LogicModelV1 verification + composition + export (non-DSH mirror)')
|
|
4735
|
-
sub = parser.add_subparsers(dest='command', required=True)
|
|
4736
|
-
p_verify = sub.add_parser('verify', help='verify a LogicModelV1 JSON model (S1-S8/A1-A14/D1-D4)')
|
|
4737
|
-
p_verify.add_argument('model')
|
|
4738
|
-
p_verify.add_argument('--before-model')
|
|
4739
|
-
p_verify.add_argument('--state-mapping')
|
|
4740
|
-
p_verify.add_argument('--max-states', type=int)
|
|
4741
|
-
p_verify.add_argument('--max-permutation-events', type=int)
|
|
4742
|
-
p_verify.set_defaults(func=_cmd_verify)
|
|
4743
|
-
p_compose = sub.add_parser('compose', help='compose two or more machines (C1/C2)')
|
|
4744
|
-
p_compose.add_argument('machines', nargs='+')
|
|
4745
|
-
p_compose.add_argument('--rendezvous', help='comma-separated handshake events')
|
|
4746
|
-
p_compose.add_argument('--max-states', type=int)
|
|
4747
|
-
p_compose.set_defaults(func=_cmd_compose)
|
|
4748
|
-
p_export = sub.add_parser('export', help='export a model to UPPAAL/TLA+/PRISM/SPIN')
|
|
4749
|
-
p_export.add_argument('model')
|
|
4750
|
-
p_export.add_argument('--format', required=True, choices=['uppaal', 'tla', 'prism', 'spin'])
|
|
4751
|
-
p_export.set_defaults(func=_cmd_export)
|
|
4752
|
-
p_uml_render = sub.add_parser('uml-render', help='render a LogicModelV1 as Mermaid/PlantUML UML text')
|
|
4753
|
-
p_uml_render.add_argument('model')
|
|
4754
|
-
p_uml_render.add_argument('--notation', choices=['mermaid', 'plantuml'], default='mermaid')
|
|
4755
|
-
p_uml_render.add_argument('--diagram', choices=['state', 'activity', 'sequence'], default='state')
|
|
4756
|
-
p_uml_render.add_argument('--max-steps', type=int, default=60)
|
|
4757
|
-
p_uml_render.set_defaults(func=_cmd_uml_render)
|
|
4758
|
-
p_uml_parse = sub.add_parser('uml-parse', help='parse a Mermaid/PlantUML diagram back into a LogicModelV1')
|
|
4759
|
-
p_uml_parse.add_argument('diagram')
|
|
4760
|
-
p_uml_parse.add_argument('--notation', choices=['auto', 'mermaid', 'plantuml'], default='auto')
|
|
4761
|
-
p_uml_parse.set_defaults(func=_cmd_uml_parse)
|
|
4762
|
-
p_uml_review = sub.add_parser('uml-review', help='review a machine, a diagram, or the match between the two')
|
|
4763
|
-
p_uml_review.add_argument('--model')
|
|
4764
|
-
p_uml_review.add_argument('--diagram')
|
|
4765
|
-
p_uml_review.add_argument('--notation', choices=['auto', 'mermaid', 'plantuml'], default='auto')
|
|
4766
|
-
p_uml_review.add_argument('--diagram-kind', choices=['state', 'activity', 'sequence'])
|
|
4767
|
-
p_uml_review.add_argument('--no-round-trip', action='store_true')
|
|
4768
|
-
p_uml_review.add_argument('--max-steps', type=int)
|
|
4769
|
-
p_uml_review.set_defaults(func=_cmd_uml_review)
|
|
4770
|
-
return parser
|
|
4771
|
-
|
|
4772
|
-
|
|
4773
|
-
def main(argv=None):
|
|
4774
|
-
parser = _build_parser()
|
|
4775
|
-
args = parser.parse_args(argv)
|
|
4776
|
-
args.func(args)
|
|
4777
|
-
|
|
4778
|
-
|
|
4779
|
-
if __name__ == '__main__':
|
|
4780
|
-
main()
|