dsh-logicprobe 0.7.1 → 0.8.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.
@@ -5,7 +5,8 @@ Reads the same LogicModelV1 JSON schema as the DSH `logicprobe_verify` /
5
5
  `logicprobe_compose_verify` / `logicprobe_export` tools and runs the same
6
6
  checks (S1-S8 structural, A1-A14 adversarial, D1-D4 before/after regression,
7
7
  C1/C2 composition) plus the four external-tool exporters (UPPAAL / TLA+ /
8
- PRISM / SPIN). Pure stdlib, no third-party imports.
8
+ PRISM / SPIN) and the UML front end (render / parse / review, mirroring
9
+ `src/uml.ts`). Pure stdlib, no third-party imports.
9
10
 
10
11
  Usage:
11
12
  python logicprobe-engine.py verify model.json [--before-model before.json]
@@ -13,9 +14,16 @@ Usage:
13
14
  python logicprobe-engine.py compose m1.json m2.json [m3.json ...]
14
15
  [--rendezvous ev1,ev2] [--max-states N]
15
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]
16
23
 
17
24
  Output: JSON verification/composition report on stdout (same shape as the DSH
18
- tool result), or the export result JSON (format / primary / extras / warnings).
25
+ tool result), or the export result JSON (format / primary / extras / warnings),
26
+ or the UML render/parse/review JSON.
19
27
  """
20
28
  import argparse
21
29
  import hashlib
@@ -3087,6 +3095,1516 @@ def export_model(input_value, fmt):
3087
3095
  return _export_spin(ex)
3088
3096
 
3089
3097
 
3098
+ # ---------------------------------------------------------------------------
3099
+ # UML front end (mirror of src/uml.ts)
3100
+ # ---------------------------------------------------------------------------
3101
+
3102
+ UML_NOTATIONS = ('mermaid', 'plantuml')
3103
+ UML_DIAGRAMS = ('state', 'activity', 'sequence')
3104
+
3105
+ # Marker every generated diagram carries; renderers ignore it, the parser uses it.
3106
+ _DIRECTIVE_NAMESPACE = 'logicprobe:'
3107
+
3108
+ # State id / event name characters that would break the generated label syntax.
3109
+ _UNSAFE_LABEL_RE = re.compile(r'[\[\]/\n\r\t]')
3110
+
3111
+ # Mermaid flowchart keywords that cannot stand alone as a node id.
3112
+ _RESERVED_NODE_IDS = frozenset(['end', 'graph', 'subgraph', 'class', 'classDef', 'click', 'style',
3113
+ 'linkStyle', 'direction'])
3114
+
3115
+ # Ids the notation can spell without an alias (JS `$` is end-of-input, hence \Z).
3116
+ _PLAIN_ID_RE = re.compile(r'^[A-Za-z_][A-Za-z0-9_]*\Z')
3117
+
3118
+ # JavaScript's \s / String.prototype.trim() character set, so guard tokenising and
3119
+ # label trimming treat exactly the same characters as whitespace as the TypeScript side.
3120
+ _JS_WHITESPACE = ('\t\n\x0b\x0c\r \xa0\u1680\u2000\u2001\u2002\u2003\u2004\u2005\u2006\u2007'
3121
+ '\u2008\u2009\u200a\u2028\u2029\u202f\u205f\u3000\ufeff')
3122
+ _JS_SPACE_RE = re.compile('[' + _JS_WHITESPACE + ']')
3123
+
3124
+
3125
+ def _js_trim(text):
3126
+ return text.strip(_JS_WHITESPACE)
3127
+
3128
+
3129
+ def _js_string(value):
3130
+ """JavaScript String(value) for the value kinds this module renders."""
3131
+ if value is None:
3132
+ return 'undefined'
3133
+ if isinstance(value, bool):
3134
+ return 'true' if value else 'false'
3135
+ if isinstance(value, str):
3136
+ return value
3137
+ if isinstance(value, (int, float)):
3138
+ return js_number(value)
3139
+ return str(value)
3140
+
3141
+
3142
+ def _char_at(text, index):
3143
+ """JavaScript `text[index]`: '' when out of range (undefined never equals a char)."""
3144
+ return text[index] if 0 <= index < len(text) else ''
3145
+
3146
+
3147
+ def _is_digit(ch):
3148
+ return len(ch) == 1 and '0' <= ch <= '9'
3149
+
3150
+
3151
+ def _is_ascii_letter(ch):
3152
+ return len(ch) == 1 and (('a' <= ch <= 'z') or ('A' <= ch <= 'Z'))
3153
+
3154
+
3155
+ def _is_ident_char(ch):
3156
+ return _is_ascii_letter(ch) or _is_digit(ch) or ch == '_' or ch == '.'
3157
+
3158
+
3159
+ def _number_from_token(text):
3160
+ """JavaScript Number(literal): exact for integers inside the safe range."""
3161
+ value = int(text)
3162
+ if -9007199254740991 <= value <= 9007199254740991:
3163
+ return value
3164
+ return float(text)
3165
+
3166
+
3167
+ def _js_strict_equal(left, right):
3168
+ """JavaScript === for guard literal values (booleans are never equal to numbers)."""
3169
+ if isinstance(left, bool) or isinstance(right, bool):
3170
+ return isinstance(left, bool) and isinstance(right, bool) and left == right
3171
+ return left == right
3172
+
3173
+
3174
+ def _literal_text(value):
3175
+ if isinstance(value, bool):
3176
+ return 'true' if value else 'false'
3177
+ return js_number(value)
3178
+
3179
+
3180
+ def guard_text(node):
3181
+ """Canonical guard text; the inverse of parse_guard_text."""
3182
+ if 'variable' in node:
3183
+ return node['variable'] + ' ' + node['op'] + ' ' + _literal_text(node['value'])
3184
+ if 'all' in node:
3185
+ return '(' + ' && '.join(guard_text(child) for child in node['all']) + ')'
3186
+ if 'any' in node:
3187
+ return '(' + ' || '.join(guard_text(child) for child in node['any']) + ')'
3188
+ return '!(' + guard_text(node['not']) + ')'
3189
+
3190
+
3191
+ def _updates_text(updates):
3192
+ parts = []
3193
+ for update in updates:
3194
+ value = update['value'] if update.get('value') is not None else (0 if update['op'] == 'set' else 1)
3195
+ if update['op'] == 'set':
3196
+ parts.append(update['variable'] + ' := ' + _literal_text(value))
3197
+ elif update['op'] == 'inc':
3198
+ parts.append(update['variable'] + ' := ' + update['variable'] + ' + ' + _literal_text(value))
3199
+ else:
3200
+ parts.append(update['variable'] + ' := ' + update['variable'] + ' - ' + _literal_text(value))
3201
+ return ', '.join(parts)
3202
+
3203
+
3204
+ def _transition_text(transition):
3205
+ text = transition['event']
3206
+ if transition.get('guard') is not None:
3207
+ text += ' [' + guard_text(transition['guard']) + ']'
3208
+ updates = transition.get('updates')
3209
+ if updates is not None and len(updates) > 0:
3210
+ text += ' / ' + _updates_text(updates)
3211
+ return text
3212
+
3213
+
3214
+ def _display_text(text):
3215
+ return _js_trim(re.sub(r'[\r\n\t]+', ' ', text).replace('"', "'"))
3216
+
3217
+
3218
+ # ---- rendering ------------------------------------------------------------
3219
+
3220
+ def _prepare_render(input_value):
3221
+ ok, model_or_errors = validate_model(input_value)
3222
+ if not ok:
3223
+ raise ValueError('model invalid: ' + '; '.join(model_or_errors))
3224
+ model = model_or_errors
3225
+ warnings = []
3226
+ used = set()
3227
+ alias = {}
3228
+ display = {}
3229
+ for state in model['states']:
3230
+ sid = state['id']
3231
+ candidate = sid
3232
+ if _PLAIN_ID_RE.match(candidate) is None:
3233
+ candidate = re.sub(r'[^A-Za-z0-9_]', '_', candidate)
3234
+ if candidate == '' or _is_digit(candidate[0]):
3235
+ candidate = 'S_' + candidate
3236
+ warnings.append('UML_RENDER_ID_SANITIZED: state id "' + sid + '" is not a plain identifier; the diagram draws it as "'
3237
+ + candidate + '" and pins the original with a ' + _DIRECTIVE_NAMESPACE + 'alias directive.')
3238
+ if candidate in _RESERVED_NODE_IDS:
3239
+ warnings.append('UML_RENDER_RESERVED_ID: state alias "' + candidate + '" collides with a diagram keyword; Mermaid renders it, but a hand edit may not.')
3240
+ unique = candidate
3241
+ suffix = 2
3242
+ while unique in used:
3243
+ unique = candidate + '_' + str(suffix)
3244
+ suffix += 1
3245
+ if unique != candidate:
3246
+ warnings.append('UML_RENDER_ALIAS_COLLISION: state id "' + sid + '" shares an alias with another state; the diagram uses "' + unique + '".')
3247
+ used.add(unique)
3248
+ alias[sid] = unique
3249
+ meaning = None
3250
+ narrative = model.get('narrative')
3251
+ if narrative is not None and narrative.get('states') is not None:
3252
+ meaning = narrative['states'].get(sid)
3253
+ display[sid] = sid if meaning is None else _display_text(sid + '(' + meaning + ')')
3254
+ for transition in model['transitions']:
3255
+ if _UNSAFE_LABEL_RE.search(transition['event']):
3256
+ warnings.append('UML_RENDER_LABEL_UNSAFE: event "' + transition['event'] + '" contains a character (one of [ ] / or a line break) '
3257
+ 'that the diagram label syntax uses; the rendered diagram cannot be read back verbatim.')
3258
+ terminal = set(state['id'] for state in model['states'] if state.get('terminal') is True)
3259
+ return {'model': model, 'alias': alias, 'display': display, 'terminal': terminal, 'warnings': warnings}
3260
+
3261
+
3262
+ def _directive_lines(notation, diagram, context):
3263
+ prefix = '%%' if notation == 'mermaid' else "'"
3264
+ model = context['model']
3265
+ lines = [prefix + _DIRECTIVE_NAMESPACE + 'uml v1 notation=' + notation + ' diagram=' + diagram]
3266
+ lines.append(prefix + _DIRECTIVE_NAMESPACE + 'init ' + model['init'])
3267
+ terminals = [state['id'] for state in model['states'] if state.get('terminal') is True]
3268
+ if len(terminals) > 0:
3269
+ lines.append(prefix + _DIRECTIVE_NAMESPACE + 'terminal ' + ','.join(terminals))
3270
+ for state in model['states']:
3271
+ if context['alias'].get(state['id']) != state['id']:
3272
+ lines.append(prefix + _DIRECTIVE_NAMESPACE + 'alias ' + context['alias'][state['id']] + ' ' + state['id'])
3273
+ # Variable kinds are not recoverable from the notation: `armed := 1` reads as an
3274
+ # integer assignment whichever kind the model declared. Pinning them keeps the
3275
+ # round trip exact instead of reporting a fidelity loss that is really a
3276
+ # notation limit.
3277
+ for variable in model.get('variables') or []:
3278
+ lines.append(prefix + _DIRECTIVE_NAMESPACE + 'variable ' + variable['name'] + ' ' + variable['kind'])
3279
+ return lines
3280
+
3281
+
3282
+ def _grouped_transitions(model):
3283
+ order = []
3284
+ groups = {}
3285
+ for transition in model['transitions']:
3286
+ frm = transition['from']
3287
+ if frm not in groups:
3288
+ groups[frm] = [transition]
3289
+ order.append(frm)
3290
+ else:
3291
+ groups[frm].append(transition)
3292
+ return [{'from': frm, 'transitions': groups[frm]} for frm in order]
3293
+
3294
+
3295
+ def _render_mermaid_state(context):
3296
+ model = context['model']
3297
+ alias = context['alias']
3298
+ lines = _directive_lines('mermaid', 'state', context)
3299
+ lines.append('stateDiagram-v2')
3300
+ lines.append(' [*] --> ' + alias[model['init']])
3301
+ for state in model['states']:
3302
+ state_alias = alias[state['id']]
3303
+ label = context['display'].get(state['id'], state['id'])
3304
+ # Every state is declared, even when its label equals its alias. A state that
3305
+ # no transition touches (an isolated terminal, a start state with no edge yet)
3306
+ # would otherwise leave no trace in the text at all, and the round-trip check
3307
+ # would have to report a loss the notation never caused.
3308
+ lines.append(' state "' + label + '" as ' + state_alias)
3309
+ for group in _grouped_transitions(model):
3310
+ for transition in group['transitions']:
3311
+ lines.append(' ' + alias[transition['from']] + ' --> ' + alias[transition['to']] + ' : ' + _transition_text(transition))
3312
+ for state in model['states']:
3313
+ if state.get('terminal') is True:
3314
+ lines.append(' ' + alias[state['id']] + ' --> [*]')
3315
+ return '\n'.join(lines) + '\n'
3316
+
3317
+
3318
+ def _render_plantuml_state(context):
3319
+ model = context['model']
3320
+ alias = context['alias']
3321
+ lines = ['@startuml']
3322
+ lines.extend(_directive_lines('plantuml', 'state', context))
3323
+ lines.append('[*] --> ' + alias[model['init']])
3324
+ for state in model['states']:
3325
+ lines.append('state "' + context['display'].get(state['id'], state['id']) + '" as ' + alias[state['id']])
3326
+ for group in _grouped_transitions(model):
3327
+ for transition in group['transitions']:
3328
+ lines.append(alias[transition['from']] + ' --> ' + alias[transition['to']] + ' : ' + _transition_text(transition))
3329
+ for state in model['states']:
3330
+ if state.get('terminal') is True:
3331
+ lines.append(alias[state['id']] + ' --> [*]')
3332
+ lines.append('@enduml')
3333
+ return '\n'.join(lines) + '\n'
3334
+
3335
+
3336
+ def _render_mermaid_activity(context):
3337
+ model = context['model']
3338
+ alias = context['alias']
3339
+ lines = _directive_lines('mermaid', 'activity', context)
3340
+ lines.append('flowchart TD')
3341
+ for state in model['states']:
3342
+ state_alias = alias[state['id']]
3343
+ text = context['display'].get(state['id'], state['id'])
3344
+ lines.append(' ' + state_alias + ('(["' + text + '"])' if state.get('terminal') is True else '["' + text + '"]'))
3345
+ for group in _grouped_transitions(model):
3346
+ for transition in group['transitions']:
3347
+ lines.append(' ' + alias[transition['from']] + ' -->|"' + _transition_text(transition) + '"| ' + alias[transition['to']])
3348
+ return '\n'.join(lines) + '\n'
3349
+
3350
+
3351
+ def _render_sequence(context, notation, max_steps):
3352
+ """One BFS trace of the machine, written as a sequence diagram."""
3353
+ model = context['model']
3354
+ alias = context['alias']
3355
+ lines = []
3356
+ if notation == 'mermaid':
3357
+ lines.extend(_directive_lines('mermaid', 'sequence', context))
3358
+ lines.append('sequenceDiagram')
3359
+ lines.append(' participant ENV as Environment')
3360
+ lines.append(' participant M as Machine')
3361
+ else:
3362
+ lines.append('@startuml')
3363
+ lines.extend(_directive_lines('plantuml', 'sequence', context))
3364
+ lines.append('participant ENV as Environment')
3365
+ lines.append('participant M as Machine')
3366
+ by_from = {}
3367
+ for transition in model['transitions']:
3368
+ by_from.setdefault(transition['from'], []).append(transition)
3369
+ seen = set([model['init']])
3370
+ queue = deque([model['init']])
3371
+ arrow = 'ENV->>M: ' if notation == 'mermaid' else 'ENV -> M : '
3372
+ note = ' Note over M: ' if notation == 'mermaid' else 'note over M : '
3373
+ lines.append((' ' if notation == 'mermaid' else '') + 'Note over M: init ' + model['init'])
3374
+ steps = 0
3375
+ truncated = False
3376
+ while len(queue) > 0:
3377
+ current = queue.popleft()
3378
+ for transition in by_from.get(current, []):
3379
+ if steps >= max_steps:
3380
+ truncated = True
3381
+ break
3382
+ steps += 1
3383
+ lines.append((' ' if notation == 'mermaid' else '') + arrow + _transition_text(transition))
3384
+ lines.append(note + alias[transition['from']] + ' -> ' + alias[transition['to']])
3385
+ if transition['to'] not in seen:
3386
+ seen.add(transition['to'])
3387
+ queue.append(transition['to'])
3388
+ if truncated:
3389
+ break
3390
+ if notation == 'plantuml':
3391
+ lines.append('@enduml')
3392
+ return {'text': '\n'.join(lines) + '\n', 'truncated': truncated}
3393
+
3394
+
3395
+ def render_uml(input_value, notation='mermaid', diagram='state', max_steps=60):
3396
+ """Mirror of renderUml: a validated LogicModelV1 as Mermaid/PlantUML text."""
3397
+ if notation not in UML_NOTATIONS:
3398
+ raise ValueError('unknown notation "' + _js_string(notation) + '"; expected ' + ' | '.join(UML_NOTATIONS))
3399
+ if diagram not in UML_DIAGRAMS:
3400
+ raise ValueError('unknown diagram "' + _js_string(diagram) + '"; expected ' + ' | '.join(UML_DIAGRAMS))
3401
+ if notation == 'plantuml' and diagram == 'activity':
3402
+ raise ValueError('plantuml has no faithful activity view here (its activity syntax is a structured flowchart language; '
3403
+ 'a graph with merges or cycles needs a while/if reconstruction logicprobe does not perform) — '
3404
+ 'use notation "mermaid" for the activity view, or diagram "state"')
3405
+ context = _prepare_render(input_value)
3406
+ warnings = list(context['warnings'])
3407
+ if diagram == 'state':
3408
+ primary = _render_mermaid_state(context) if notation == 'mermaid' else _render_plantuml_state(context)
3409
+ elif diagram == 'activity':
3410
+ primary = _render_mermaid_activity(context)
3411
+ else:
3412
+ rendered = _render_sequence(context, notation, max_steps)
3413
+ primary = rendered['text']
3414
+ if rendered['truncated']:
3415
+ warnings.append('UML_RENDER_SEQUENCE_TRUNCATED: the trace was capped at ' + str(max_steps) + ' steps; a sequence diagram is one trace, '
3416
+ 'not the whole machine — use diagram "state" for the full topology.')
3417
+ warnings.append('UML_RENDER_SEQUENCE_IS_TRACE: a sequence diagram shows one BFS trace; branches appear as separate guarded messages '
3418
+ 'and unreachable branches are absent by construction.')
3419
+ return {'notation': notation, 'diagram': diagram, 'primary': primary, 'warnings': warnings}
3420
+
3421
+
3422
+ # ---- parsing: guard expressions -------------------------------------------
3423
+
3424
+ def _tokenize_guard(text):
3425
+ tokens = []
3426
+ index = 0
3427
+ while index < len(text):
3428
+ char = text[index]
3429
+ if _JS_SPACE_RE.match(char):
3430
+ index += 1
3431
+ continue
3432
+ if char == '(':
3433
+ tokens.append(('lparen', char))
3434
+ index += 1
3435
+ continue
3436
+ if char == ')':
3437
+ tokens.append(('rparen', char))
3438
+ index += 1
3439
+ continue
3440
+ if char == '&' and _char_at(text, index + 1) == '&':
3441
+ tokens.append(('and', '&&'))
3442
+ index += 2
3443
+ continue
3444
+ if char == '|' and _char_at(text, index + 1) == '|':
3445
+ tokens.append(('or', '||'))
3446
+ index += 2
3447
+ continue
3448
+ if char == '!':
3449
+ if _char_at(text, index + 1) == '=':
3450
+ tokens.append(('op', '!='))
3451
+ index += 2
3452
+ continue
3453
+ tokens.append(('not', '!'))
3454
+ index += 1
3455
+ continue
3456
+ two = text[index:index + 2]
3457
+ if two == '==' or two == '<=' or two == '>=':
3458
+ tokens.append(('op', two))
3459
+ index += 2
3460
+ continue
3461
+ if char == '<' or char == '>':
3462
+ tokens.append(('op', char))
3463
+ index += 1
3464
+ continue
3465
+ if char == '=':
3466
+ tokens.append(('op', '=='))
3467
+ index += 1
3468
+ continue
3469
+ if _is_digit(char) or (char == '-' and _is_digit(_char_at(text, index + 1))):
3470
+ end = index + 1
3471
+ while end < len(text) and _is_digit(text[end]):
3472
+ end += 1
3473
+ tokens.append(('number', text[index:end]))
3474
+ index = end
3475
+ continue
3476
+ if _is_ascii_letter(char) or char == '_':
3477
+ end = index + 1
3478
+ while end < len(text) and _is_ident_char(text[end]):
3479
+ end += 1
3480
+ word = text[index:end]
3481
+ index = end
3482
+ if word == 'and':
3483
+ tokens.append(('and', word))
3484
+ elif word == 'or':
3485
+ tokens.append(('or', word))
3486
+ elif word == 'not':
3487
+ tokens.append(('not', word))
3488
+ elif word == 'true' or word == 'false':
3489
+ tokens.append(('boolean', word))
3490
+ else:
3491
+ tokens.append(('ident', word))
3492
+ continue
3493
+ raise ValueError('guard text not understood near "' + text[index:] + '"')
3494
+ return tokens
3495
+
3496
+
3497
+ class _GuardReader:
3498
+ def __init__(self, tokens, source):
3499
+ self.tokens = tokens
3500
+ self.source = source
3501
+ self.position = 0
3502
+
3503
+ def parse(self):
3504
+ node = self._parse_or()
3505
+ if self.position != len(self.tokens):
3506
+ raise ValueError('trailing tokens in guard "' + self.source + '"')
3507
+ return node
3508
+
3509
+ def _peek(self):
3510
+ return self.tokens[self.position] if self.position < len(self.tokens) else None
3511
+
3512
+ def _parse_or(self):
3513
+ parts = [self._parse_and()]
3514
+ while self._peek() is not None and self._peek()[0] == 'or':
3515
+ self.position += 1
3516
+ parts.append(self._parse_and())
3517
+ return parts[0] if len(parts) == 1 else {'any': parts}
3518
+
3519
+ def _parse_and(self):
3520
+ parts = [self._parse_unary()]
3521
+ while self._peek() is not None and self._peek()[0] == 'and':
3522
+ self.position += 1
3523
+ parts.append(self._parse_unary())
3524
+ return parts[0] if len(parts) == 1 else {'all': parts}
3525
+
3526
+ def _parse_unary(self):
3527
+ token = self._peek()
3528
+ if token is not None and token[0] == 'not':
3529
+ self.position += 1
3530
+ return {'not': self._parse_unary()}
3531
+ return self._parse_primary()
3532
+
3533
+ def _parse_primary(self):
3534
+ token = self._peek()
3535
+ if token is not None and token[0] == 'lparen':
3536
+ self.position += 1
3537
+ inner = self._parse_or()
3538
+ closing = self._peek()
3539
+ if closing is None or closing[0] != 'rparen':
3540
+ raise ValueError('unbalanced parentheses in guard "' + self.source + '"')
3541
+ self.position += 1
3542
+ return inner
3543
+ if token is None or token[0] != 'ident':
3544
+ raise ValueError('expected a variable name in guard "' + self.source + '"')
3545
+ self.position += 1
3546
+ op = self._peek()
3547
+ if op is None or op[0] != 'op':
3548
+ raise ValueError('expected a comparison operator after "' + token[1] + '" in guard "' + self.source + '"')
3549
+ self.position += 1
3550
+ value = self._peek()
3551
+ if value is not None and value[0] == 'number':
3552
+ self.position += 1
3553
+ return {'variable': token[1], 'op': op[1], 'value': _number_from_token(value[1])}
3554
+ if value is not None and value[0] == 'boolean':
3555
+ if op[1] != '==' and op[1] != '!=':
3556
+ raise ValueError('boolean variable "' + token[1] + '" only supports == / != (guard "' + self.source + '")')
3557
+ self.position += 1
3558
+ return {'variable': token[1], 'op': op[1], 'value': value[1] == 'true'}
3559
+ raise ValueError('expected a literal value for "' + token[1] + '" in guard "' + self.source + '"')
3560
+
3561
+
3562
+ def parse_guard_text(text):
3563
+ """Parse a guard expression such as `(retry < 3 && armed == true)`."""
3564
+ return _GuardReader(_tokenize_guard(text), text).parse()
3565
+
3566
+
3567
+ _UPDATE_SHIM_RE = re.compile(r'^([A-Za-z_][A-Za-z0-9_]*)\s*(\+\+|--)\Z')
3568
+ _UPDATE_ASSIGN_RE = re.compile(r'^([A-Za-z_][A-Za-z0-9_]*)\s*:?=\s*(.+)\Z')
3569
+ _UPDATE_BOOL_RE = re.compile(r'^(true|false)\Z')
3570
+ _UPDATE_INT_RE = re.compile(r'^-?[0-9]+\Z')
3571
+ _UPDATE_ARITH_RE = re.compile(r'^([A-Za-z_][A-Za-z0-9_]*)\s*([+-])\s*([0-9]+)\Z')
3572
+
3573
+
3574
+ def parse_updates_text(text, warnings):
3575
+ """Parse a UML action clause such as `retry := retry + 1, armed := true`."""
3576
+ out = []
3577
+ for raw in text.split(','):
3578
+ clause = _js_trim(raw)
3579
+ if clause == '':
3580
+ continue
3581
+ shim = _UPDATE_SHIM_RE.match(clause)
3582
+ if shim is not None:
3583
+ out.append({'variable': shim.group(1), 'op': 'inc' if shim.group(2) == '++' else 'dec', 'value': 1})
3584
+ continue
3585
+ assignment = _UPDATE_ASSIGN_RE.match(clause)
3586
+ if assignment is None:
3587
+ raise ValueError('action clause not understood: "' + clause + '" (expected "var := value")')
3588
+ name = assignment.group(1)
3589
+ value = _js_trim(assignment.group(2))
3590
+ if value == name:
3591
+ warnings.append('UML_PARSE_NOOP_UPDATE: action "' + clause + '" assigns the variable to itself; dropped.')
3592
+ continue
3593
+ if _UPDATE_BOOL_RE.match(value):
3594
+ out.append({'variable': name, 'op': 'set', 'value': 1 if value == 'true' else 0})
3595
+ continue
3596
+ if _UPDATE_INT_RE.match(value):
3597
+ out.append({'variable': name, 'op': 'set', 'value': _number_from_token(value)})
3598
+ continue
3599
+ arithmetic = _UPDATE_ARITH_RE.match(value)
3600
+ if arithmetic is None:
3601
+ raise ValueError('action value not understood: "' + value + '" (expected a literal, or "var + n" / "var - n")')
3602
+ if arithmetic.group(1) != name:
3603
+ raise ValueError('action "' + clause + '" reads a different variable; LogicModelV1 updates touch one variable')
3604
+ out.append({'variable': name, 'op': 'inc' if arithmetic.group(2) == '+' else 'dec',
3605
+ 'value': _number_from_token(arithmetic.group(3))})
3606
+ return out
3607
+
3608
+
3609
+ # ---- parsing: diagram text ------------------------------------------------
3610
+
3611
+ _STATE_EDGE_RE = re.compile(r'^(.+?)\s*-->\s*(.+?)(?:\s*:\s*(.*))?\Z')
3612
+ _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)
3613
+ _NOTE_RE = re.compile(r'^note\b', re.I)
3614
+ _END_NOTE_RE = re.compile(r'^end\s*note\Z', re.I)
3615
+ _STATE_DECL_RE = re.compile(r'^state\s+"([^"]*)"\s+as\s+([A-Za-z_][A-Za-z0-9_]*)\Z')
3616
+ _BARE_STATE_RE = re.compile(r'^state\s+([A-Za-z_][A-Za-z0-9_]*)\s*\{?\Z')
3617
+ _CONCURRENCY_RE = re.compile(r'^\}\s*\Z|^--\s*\Z')
3618
+ _COMPOSITE_STATE_RE = re.compile(r'^state\s+(.+)\s*\{\Z')
3619
+ _DESCRIPTION_RE = re.compile(r'^([A-Za-z_][A-Za-z0-9_]*)\s*:\s*(.+)\Z')
3620
+ _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|%%\{)')
3621
+ _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)')
3622
+
3623
+ _ACTIVITY_SKIP_RE = re.compile(r'^(flowchart|graph)\b|^(classDef|class|style|linkStyle|click|direction)\b|^%%\{')
3624
+ _SUBGRAPH_RE = re.compile(r'^subgraph\b')
3625
+ _SHAPED_EDGE_RE = re.compile(r'^(.*?)\s*-->\s*\|(.*?)\|\s*(.+)\Z')
3626
+ _BARE_EDGE_RE = re.compile(r'^(.*?)\s*-->\s*(.+)\Z')
3627
+ _STRIP_NODE_RE = re.compile(r'^([A-Za-z_][A-Za-z0-9_.-]*)\s*(\(\[|\[\(|\{\{|\[|\(|\(\(|>)?\s*([\s\S]*?)\s*\Z')
3628
+ _QUOTED_ONLY_RE = re.compile(r'^"([^"]+)"\Z')
3629
+ _QUOTED_IN_RE = re.compile(r'"([^"]*)"')
3630
+
3631
+
3632
+ def _detect_notation(text):
3633
+ if re.search(r'^\s*@start', text, re.M):
3634
+ return 'plantuml'
3635
+ if re.search(r'^\s*(stateDiagram|stateDiagram-v2|flowchart|graph|sequenceDiagram)\b', text, re.M):
3636
+ return 'mermaid'
3637
+ raise ValueError('cannot tell whether this is Mermaid or PlantUML text: '
3638
+ 'expected `stateDiagram-v2` / `flowchart` / `sequenceDiagram`, or `@startuml`')
3639
+
3640
+
3641
+ def _detect_diagram(text):
3642
+ if re.search(r'^\s*stateDiagram', text, re.M):
3643
+ return 'state'
3644
+ if re.search(r'^\s*(flowchart|graph)\b', text, re.M):
3645
+ return 'activity'
3646
+ if re.search(r'^\s*sequenceDiagram\b', text, re.M):
3647
+ return 'sequence'
3648
+ if re.search(r'^\s*@startuml', text, re.M):
3649
+ # PlantUML declares the diagram kind by its body; the state keyword is the only
3650
+ # structural one logicprobe emits, everything else in that family is a state diagram too.
3651
+ if re.search(r'^\s*participant\b', text, re.M) or re.search(r'->>\s*', text):
3652
+ return 'sequence'
3653
+ return 'state'
3654
+ raise ValueError('cannot tell which diagram kind this text declares')
3655
+
3656
+
3657
+ def _comment_prefix(notation):
3658
+ return '%%' if notation == 'mermaid' else "'"
3659
+
3660
+
3661
+ def _directive_body(line, notation):
3662
+ prefix = _comment_prefix(notation)
3663
+ trimmed = _js_trim(line)
3664
+ if not trimmed.startswith(prefix):
3665
+ return None
3666
+ body = _js_trim(trimmed[len(prefix):])
3667
+ if not body.startswith(_DIRECTIVE_NAMESPACE):
3668
+ return None
3669
+ return body[len(_DIRECTIVE_NAMESPACE):]
3670
+
3671
+
3672
+ def _read_directives(lines, notation):
3673
+ """Consume the `logicprobe:` directives; returns (directives, body_lines)."""
3674
+ directives = {'terminals': [], 'aliases': {}, 'variables': {}}
3675
+ body = []
3676
+ for line in lines:
3677
+ text = _directive_body(line, notation)
3678
+ if text is None:
3679
+ body.append(line)
3680
+ continue
3681
+ if text.startswith('uml '):
3682
+ continue
3683
+ if text.startswith('init '):
3684
+ directives['init'] = _js_trim(text[5:])
3685
+ continue
3686
+ if text.startswith('terminal '):
3687
+ for sid in text[9:].split(','):
3688
+ trimmed = _js_trim(sid)
3689
+ if trimmed != '':
3690
+ directives['terminals'].append(trimmed)
3691
+ continue
3692
+ if text.startswith('alias '):
3693
+ rest = _js_trim(text[6:])
3694
+ split = rest.find(' ')
3695
+ if split > 0:
3696
+ directives['aliases'][rest[:split]] = _js_trim(rest[split + 1:])
3697
+ continue
3698
+ if text.startswith('variable '):
3699
+ rest = _js_trim(text[9:])
3700
+ split = rest.rfind(' ')
3701
+ if split > 0:
3702
+ kind = _js_trim(rest[split + 1:])
3703
+ if kind == 'integer' or kind == 'boolean':
3704
+ directives['variables'][_js_trim(rest[:split])] = kind
3705
+ continue
3706
+ body.append(line)
3707
+ return directives, body
3708
+
3709
+
3710
+ def _parse_transition_label(label, warnings):
3711
+ rest = _js_trim(label)
3712
+ guard = None
3713
+ bracket = rest.find('[')
3714
+ if bracket >= 0:
3715
+ close = rest.rfind(']')
3716
+ if close < bracket:
3717
+ raise ValueError('unbalanced guard brackets in transition label "' + label + '"')
3718
+ guard = parse_guard_text(_js_trim(rest[bracket + 1:close]))
3719
+ rest = _js_trim(rest[:bracket] + ' ' + rest[close + 1:])
3720
+ updates = None
3721
+ slash = rest.find('/')
3722
+ if slash >= 0:
3723
+ action_text = _js_trim(rest[slash + 1:])
3724
+ updates = parse_updates_text(action_text, warnings)
3725
+ if len(updates) == 0:
3726
+ updates = None
3727
+ rest = _js_trim(rest[:slash])
3728
+ event = _js_trim(rest)
3729
+ if event == '':
3730
+ raise ValueError('transition label "' + label + '" carries no event name; label the arrow as `event [guard] / actions`')
3731
+ parsed = {'event': event}
3732
+ if guard is not None:
3733
+ parsed['guard'] = guard
3734
+ if updates is not None:
3735
+ parsed['updates'] = updates
3736
+ return parsed
3737
+
3738
+
3739
+ def _infer_variables(transitions, declared):
3740
+ """Recover the variable list from the diagram; directives win, guards/actions are the fallback."""
3741
+ kinds = dict(declared)
3742
+
3743
+ def note(name, kind):
3744
+ if name in declared:
3745
+ return
3746
+ current = kinds.get(name)
3747
+ if current is None:
3748
+ kinds[name] = kind
3749
+ elif current != kind:
3750
+ kinds[name] = 'integer'
3751
+
3752
+ def walk(guard):
3753
+ if guard is None:
3754
+ return
3755
+ if 'variable' in guard:
3756
+ note(guard['variable'], 'boolean' if isinstance(guard['value'], bool) else 'integer')
3757
+ return
3758
+ if 'all' in guard:
3759
+ for child in guard['all']:
3760
+ walk(child)
3761
+ return
3762
+ if 'any' in guard:
3763
+ for child in guard['any']:
3764
+ walk(child)
3765
+ return
3766
+ walk(guard['not'])
3767
+
3768
+ for transition in transitions:
3769
+ walk(transition.get('guard'))
3770
+ for update in transition.get('updates') or []:
3771
+ note(update['variable'], 'integer')
3772
+ return [{'name': name, 'kind': kind, 'init': False if kind == 'boolean' else 0} for name, kind in kinds.items()]
3773
+
3774
+
3775
+ def _raw_to_model(raw, directives, warnings):
3776
+ aliases = directives['aliases']
3777
+
3778
+ def id_of(name):
3779
+ return aliases.get(name, name)
3780
+
3781
+ # Alias directives restore ids the notation cannot spell; they win over the alias itself.
3782
+ names = []
3783
+ seen_names = set()
3784
+
3785
+ def add_name(name):
3786
+ if name is None or name in seen_names:
3787
+ return
3788
+ seen_names.add(name)
3789
+ names.append(name)
3790
+
3791
+ for name in raw['states']:
3792
+ add_name(name)
3793
+ # A state can be known without ever being an edge endpoint: the initial
3794
+ # pseudostate (`[*] --> X`) and the final mark (`Y --> [*]`) both name states a
3795
+ # hand-written diagram never declares, and an isolated state has no edge at all.
3796
+ add_name(raw['initialState'])
3797
+ for name in raw['finalMarks']:
3798
+ add_name(name)
3799
+ for name in raw['terminals']:
3800
+ add_name(name)
3801
+ for edge in raw['edges']:
3802
+ add_name(edge['from'])
3803
+ add_name(edge['to'])
3804
+ states = []
3805
+ seen_states = set()
3806
+ for name in names:
3807
+ sid = id_of(name)
3808
+ if sid in seen_states:
3809
+ continue
3810
+ seen_states.add(sid)
3811
+ states.append({'id': sid})
3812
+ transitions = []
3813
+ synthetic = set()
3814
+ for edge in raw['edges']:
3815
+ frm = id_of(edge['from'])
3816
+ to = id_of(edge['to'])
3817
+ label = _js_trim(edge['label'])
3818
+ guard = None
3819
+ updates = None
3820
+ if label == '':
3821
+ candidate = 't_' + frm + '_' + to
3822
+ suffix = 2
3823
+ while candidate in synthetic:
3824
+ candidate = 't_' + frm + '_' + to + '_' + str(suffix)
3825
+ suffix += 1
3826
+ synthetic.add(candidate)
3827
+ event = candidate
3828
+ warnings.append('UML_PARSE_SYNTHETIC_EVENT: arrow ' + frm + ' -> ' + to + ' carries no label; it was named "' + candidate
3829
+ + '". Label the arrow as `event [guard] / actions` so the model keeps the real event name.')
3830
+ else:
3831
+ parsed = _parse_transition_label(label, warnings)
3832
+ event = parsed['event']
3833
+ guard = parsed.get('guard')
3834
+ updates = parsed.get('updates')
3835
+ transition = {'from': frm, 'event': event, 'to': to}
3836
+ if guard is not None:
3837
+ transition['guard'] = guard
3838
+ if updates is not None:
3839
+ transition['updates'] = updates
3840
+ transitions.append(transition)
3841
+ # Init: an explicit `[*] --> X` wins; a directive is the fallback the activity
3842
+ # view needs (a flowchart has no initial pseudostate).
3843
+ init = None
3844
+ if raw['initialState'] is not None:
3845
+ init = id_of(raw['initialState'])
3846
+ if init is None and directives.get('init') is not None:
3847
+ init = id_of(directives['init'])
3848
+ if raw['initialState'] is not None and directives.get('init') is not None and id_of(raw['initialState']) != directives['init']:
3849
+ warnings.append('UML_PARSE_INIT_CONFLICT: the diagram enters ' + id_of(raw['initialState']) + ' from its initial pseudostate but declares init '
3850
+ + directives['init'] + '; the pseudostate wins.')
3851
+ if init is None:
3852
+ targeted = set(transition['to'] for transition in transitions)
3853
+ roots = [state['id'] for state in states if state['id'] not in targeted]
3854
+ if len(roots) == 1:
3855
+ init = roots[0]
3856
+ warnings.append('UML_PARSE_INIT_INFERRED: no initial state was declared; "' + init
3857
+ + '" is the only state nothing enters, so it is used as init.')
3858
+ else:
3859
+ raise ValueError('no initial state: add `[*] --> <state>` (or a `' + _DIRECTIVE_NAMESPACE
3860
+ + 'init <state>` directive); found ' + str(len(roots)) + ' entry states')
3861
+ terminal_names = set(id_of(name) for name in raw['terminals'])
3862
+ for name in directives['terminals']:
3863
+ terminal_names.add(id_of(name))
3864
+ for name in raw['finalMarks']:
3865
+ terminal_names.add(id_of(name))
3866
+ for state in states:
3867
+ if state['id'] in terminal_names:
3868
+ state['terminal'] = True
3869
+ variables = _infer_variables(transitions, directives['variables'])
3870
+ model = {'schemaVersion': 1, 'init': init, 'states': states, 'transitions': transitions}
3871
+ if len(variables) > 0:
3872
+ model['variables'] = variables
3873
+ ok, model_or_errors = validate_model(model)
3874
+ if not ok:
3875
+ raise ValueError('the diagram parsed into an invalid model: ' + '; '.join(model_or_errors))
3876
+ return model_or_errors
3877
+
3878
+
3879
+ def _parse_state_diagram(text, notation):
3880
+ lines = re.split(r'\r?\n', text)
3881
+ directives, body = _read_directives(lines, notation)
3882
+ raw = {'states': [], 'display': {}, 'edges': [], 'initialState': None, 'terminals': [], 'finalMarks': [], 'warnings': []}
3883
+ declared = set()
3884
+
3885
+ def declare(name):
3886
+ if name not in declared:
3887
+ declared.add(name)
3888
+ raw['states'].append(name)
3889
+
3890
+ skip = _MERMAID_STATE_SKIP_RE if notation == 'mermaid' else _PLANTUML_STATE_SKIP_RE
3891
+ for raw_line in body:
3892
+ line = _js_trim(raw_line)
3893
+ if line == '' or (line.startswith('--') and '-->' not in line):
3894
+ continue
3895
+ if skip.match(line):
3896
+ continue
3897
+ note = _STATE_NOTE_RE.match(line)
3898
+ if note is not None:
3899
+ declare(note.group(1))
3900
+ if note.group(1) not in raw['display']:
3901
+ raw['display'][note.group(1)] = _js_trim(note.group(2))
3902
+ continue
3903
+ if _NOTE_RE.match(line) or _END_NOTE_RE.match(line):
3904
+ continue
3905
+ state_decl = _STATE_DECL_RE.match(line)
3906
+ if state_decl is not None:
3907
+ declare(state_decl.group(2))
3908
+ raw['display'][state_decl.group(2)] = state_decl.group(1)
3909
+ continue
3910
+ bare_state = _BARE_STATE_RE.match(line)
3911
+ if bare_state is not None:
3912
+ declare(bare_state.group(1))
3913
+ continue
3914
+ if _CONCURRENCY_RE.match(line):
3915
+ raw['warnings'].append('UML_PARSE_CONCURRENCY_FLATTENED: a concurrency region or composite block was flattened; '
3916
+ 'LogicModelV1 has no region construct (use logicprobe_compose_verify for parallel machines).')
3917
+ continue
3918
+ composite = _COMPOSITE_STATE_RE.match(line)
3919
+ if composite is not None:
3920
+ raw['warnings'].append('UML_PARSE_COMPOSITE_FLATTENED: composite state "' + _js_trim(composite.group(1))
3921
+ + '" was flattened into its members.')
3922
+ continue
3923
+ description = _DESCRIPTION_RE.match(line)
3924
+ if description is not None:
3925
+ declare(description.group(1))
3926
+ if description.group(1) not in raw['display']:
3927
+ raw['display'][description.group(1)] = _js_trim(description.group(2))
3928
+ continue
3929
+ edge = _STATE_EDGE_RE.match(line)
3930
+ if edge is not None:
3931
+ frm = _js_trim(edge.group(1))
3932
+ to = _js_trim(edge.group(2))
3933
+ label = _js_trim(edge.group(3) or '')
3934
+ if frm == '[*]':
3935
+ if raw['initialState'] is not None and raw['initialState'] != to:
3936
+ raw['warnings'].append('UML_PARSE_MULTIPLE_INIT: the diagram enters ' + raw['initialState'] + ' and ' + to
3937
+ + ' from initial pseudostates; LogicModelV1 has one init, so ' + raw['initialState'] + ' is kept.')
3938
+ else:
3939
+ raw['initialState'] = to
3940
+ continue
3941
+ if to == '[*]':
3942
+ raw['finalMarks'].append(frm)
3943
+ continue
3944
+ declare(frm)
3945
+ declare(to)
3946
+ raw['edges'].append({'from': frm, 'to': to, 'label': label})
3947
+ continue
3948
+ raw['warnings'].append('UML_PARSE_IGNORED_LINE: "' + line + '" is not a state diagram statement; it was ignored.')
3949
+ model = _raw_to_model(raw, directives, raw['warnings'])
3950
+ labels = _map_labels(raw['display'], directives)
3951
+ return {'notation': notation, 'diagram': 'state', 'model': model, 'labels': labels, 'warnings': raw['warnings']}
3952
+
3953
+
3954
+ def _strip_quotes(text):
3955
+ return re.sub(r'^"|"$', '', text)
3956
+
3957
+
3958
+ def _strip_node(text, raw, declare):
3959
+ """Read one node reference (`A`, `A["label"]`, `A(["label"])`) and its display label."""
3960
+ trimmed = _js_trim(text)
3961
+ if trimmed == '':
3962
+ return None
3963
+ match = _STRIP_NODE_RE.match(trimmed)
3964
+ if match is None:
3965
+ quoted_only = _QUOTED_ONLY_RE.match(trimmed)
3966
+ if quoted_only is not None:
3967
+ declare(quoted_only.group(1))
3968
+ return quoted_only.group(1)
3969
+ return None
3970
+ name = match.group(1)
3971
+ shape = match.group(2) or ''
3972
+ rest = match.group(3) or ''
3973
+ declare(name)
3974
+ quoted = _QUOTED_IN_RE.search(rest)
3975
+ if quoted is not None:
3976
+ label = _js_trim(quoted.group(1))
3977
+ else:
3978
+ label = _js_trim(re.sub(r'[\[\](){}>]+$', '', re.sub(r'^[\[\](){}>]+', '', rest)))
3979
+ if label != '' and name not in raw['display']:
3980
+ raw['display'][name] = label
3981
+ if shape == '([' or shape == '((' or rest.startswith('(['):
3982
+ raw['terminals'].append(name)
3983
+ return name
3984
+
3985
+
3986
+ def _parse_activity_diagram(text, notation):
3987
+ lines = re.split(r'\r?\n', text)
3988
+ directives, body = _read_directives(lines, notation)
3989
+ raw = {'states': [], 'display': {}, 'edges': [], 'initialState': None, 'terminals': [], 'finalMarks': [], 'warnings': []}
3990
+ declared = set()
3991
+
3992
+ def declare(name):
3993
+ if name not in declared:
3994
+ declared.add(name)
3995
+ raw['states'].append(name)
3996
+
3997
+ in_subgraph = False
3998
+ for raw_line in body:
3999
+ line = _js_trim(raw_line)
4000
+ if line == '' or _ACTIVITY_SKIP_RE.match(line):
4001
+ continue
4002
+ if _SUBGRAPH_RE.match(line):
4003
+ if not in_subgraph:
4004
+ in_subgraph = True
4005
+ raw['warnings'].append('UML_PARSE_SUBGRAPH_FLATTENED: subgraph blocks were flattened; LogicModelV1 has no hierarchy.')
4006
+ continue
4007
+ if line == 'end':
4008
+ in_subgraph = False
4009
+ continue
4010
+ shaped = _SHAPED_EDGE_RE.match(line)
4011
+ bare = None if shaped is not None else _BARE_EDGE_RE.match(line)
4012
+ if shaped is not None or bare is not None:
4013
+ match = shaped if shaped is not None else bare
4014
+ frm = _strip_node(_js_trim(match.group(1)), raw, declare)
4015
+ # Flowchart labels live between bars; the state-diagram form `A --> B : label`
4016
+ # is accepted too so a hand-written file that mixes the two still reads.
4017
+ raw_to = _js_trim(match.group(3) if shaped is not None else match.group(2))
4018
+ label = _js_trim(_strip_quotes(match.group(2))) if shaped is not None else ''
4019
+ if shaped is None:
4020
+ colon = raw_to.find(':')
4021
+ if colon >= 0:
4022
+ label = _strip_quotes(_js_trim(raw_to[colon + 1:]))
4023
+ raw_to = _js_trim(raw_to[:colon])
4024
+ to = _strip_node(raw_to, raw, declare)
4025
+ if frm is None or to is None:
4026
+ continue
4027
+ raw['edges'].append({'from': frm, 'to': to, 'label': label})
4028
+ continue
4029
+ node = _strip_node(line, raw, declare)
4030
+ if node is None:
4031
+ raw['warnings'].append('UML_PARSE_IGNORED_LINE: "' + line + '" is not a flowchart statement; it was ignored.')
4032
+ model = _raw_to_model(raw, directives, raw['warnings'])
4033
+ labels = _map_labels(raw['display'], directives)
4034
+ return {'notation': notation, 'diagram': 'activity', 'model': model, 'labels': labels, 'warnings': raw['warnings']}
4035
+
4036
+
4037
+ def _map_labels(display, directives):
4038
+ out = {}
4039
+ for name, label in display.items():
4040
+ out[directives['aliases'].get(name, name)] = label
4041
+ return out
4042
+
4043
+
4044
+ def parse_uml(text, notation='auto'):
4045
+ """Parse a Mermaid or PlantUML diagram back into a LogicModelV1."""
4046
+ resolved = _detect_notation(text) if notation == 'auto' else notation
4047
+ kind = _detect_diagram(text)
4048
+ if kind == 'sequence':
4049
+ raise ValueError('a sequence diagram is a trace, not a machine: parsing it would drop every branch the trace did not walk. '
4050
+ 'Render diagram "state" or "activity" and parse that instead.')
4051
+ return _parse_state_diagram(text, resolved) if kind == 'state' else _parse_activity_diagram(text, resolved)
4052
+
4053
+
4054
+ # ---- review ---------------------------------------------------------------
4055
+
4056
+ def _reachable_states(model):
4057
+ adjacency = {}
4058
+ for transition in model['transitions']:
4059
+ adjacency.setdefault(transition['from'], []).append(transition['to'])
4060
+ visited = set([model['init']])
4061
+ queue = deque([model['init']])
4062
+ while len(queue) > 0:
4063
+ current = queue.popleft()
4064
+ for nxt in adjacency.get(current, []):
4065
+ if nxt not in visited:
4066
+ visited.add(nxt)
4067
+ queue.append(nxt)
4068
+ return visited
4069
+
4070
+
4071
+ def _canonical_transition(transition):
4072
+ guard = guard_text(transition['guard']) if transition.get('guard') is not None else ''
4073
+ updates = _updates_text(transition['updates']) if transition.get('updates') is not None else ''
4074
+ return transition['from'] + '|' + transition['event'] + '|' + transition['to'] + '|' + guard + '|' + updates
4075
+
4076
+
4077
+ def _documented_meaning(label, sid):
4078
+ """The narrative meaning a diagram label carries, if it carries one beyond the bare id."""
4079
+ if label is None:
4080
+ return None
4081
+ trimmed = _js_trim(label)
4082
+ if trimmed == '' or trimmed == sid:
4083
+ return None
4084
+ for open_char, close_char in (('(', ')'), ('(', ')')):
4085
+ prefix = sid + open_char
4086
+ if trimmed.startswith(prefix) and trimmed.endswith(close_char):
4087
+ return trimmed[len(prefix):len(trimmed) - len(close_char)]
4088
+ return trimmed
4089
+
4090
+
4091
+ def _collect_guard_variables(guard, sink):
4092
+ if 'variable' in guard:
4093
+ sink.add(guard['variable'])
4094
+ return
4095
+ if 'all' in guard:
4096
+ for child in guard['all']:
4097
+ _collect_guard_variables(child, sink)
4098
+ return
4099
+ if 'any' in guard:
4100
+ for child in guard['any']:
4101
+ _collect_guard_variables(child, sink)
4102
+ return
4103
+ _collect_guard_variables(guard['not'], sink)
4104
+
4105
+
4106
+ def _positive_leaves(guard, sink):
4107
+ """Positive, conjunctively-reached leaves — the only ones a complementarity witness may use."""
4108
+ if 'variable' in guard:
4109
+ sink.append(guard)
4110
+ return
4111
+ if 'all' in guard:
4112
+ for child in guard['all']:
4113
+ _positive_leaves(child, sink)
4114
+ return
4115
+ # `any` and `not` subtrees are skipped on purpose: a leaf under a disjunction is
4116
+ # not implied by its branch, and `not (x < 3)` is `x >= 3` only for totally
4117
+ # ordered integers — neither is a sound complementarity witness.
4118
+
4119
+
4120
+ _COMPLEMENTS = (('<', '>='), ('<=', '>'), ('==', '!='))
4121
+
4122
+
4123
+ def _complementary_variable(guards):
4124
+ """Name a variable whose guards contain a complementary pair, or None when there is none."""
4125
+ leaves = []
4126
+ for guard in guards:
4127
+ _positive_leaves(guard, leaves)
4128
+ for i in range(len(leaves)):
4129
+ for j in range(i + 1, len(leaves)):
4130
+ left = leaves[i]
4131
+ right = leaves[j]
4132
+ if left['variable'] != right['variable']:
4133
+ continue
4134
+ if not _js_strict_equal(left['value'], right['value']):
4135
+ continue
4136
+ for one, other in _COMPLEMENTS:
4137
+ if (left['op'] == one and right['op'] == other) or (left['op'] == other and right['op'] == one):
4138
+ return left['variable']
4139
+ return None
4140
+
4141
+
4142
+ def _structural_findings(model, labels, warnings):
4143
+ findings = []
4144
+ reachable = _reachable_states(model)
4145
+ outgoing = {}
4146
+ for transition in model['transitions']:
4147
+ outgoing.setdefault(transition['from'], []).append(transition)
4148
+
4149
+ unreachable = [state['id'] for state in model['states'] if state['id'] not in reachable]
4150
+ if len(unreachable) > 0:
4151
+ findings.append({
4152
+ 'code': 'UML002_UNREACHABLE_STATE',
4153
+ 'severity': 'error',
4154
+ 'message': str(len(unreachable)) + ' state(s) cannot be entered from init along any transition, so the diagram draws flow nobody can reach.',
4155
+ 'states': unreachable,
4156
+ 'detail': 'Structural reachability (guards ignored). Guard-aware reachability is S1 in logicprobe_verify.',
4157
+ })
4158
+
4159
+ dead_ends = [state['id'] for state in model['states']
4160
+ if state.get('terminal') is not True and len(outgoing.get(state['id'], [])) == 0]
4161
+ if len(dead_ends) > 0:
4162
+ findings.append({
4163
+ 'code': 'UML003_DEAD_END_STATE',
4164
+ 'severity': 'error',
4165
+ 'message': str(len(dead_ends)) + ' non-terminal state(s) have no outgoing transition: the flow stops there without a modelled terminal.',
4166
+ 'states': dead_ends,
4167
+ 'detail': 'Either the state is terminal (add `X --> [*]`) or the outgoing flow is missing from the model. '
4168
+ 'logicprobe_verify S2 reports the same shape at runtime granularity.',
4169
+ })
4170
+
4171
+ groups = {}
4172
+ for transition in model['transitions']:
4173
+ key = transition['from'] + '\u0000' + transition['event']
4174
+ groups.setdefault(key, []).append(transition)
4175
+ ambiguous = []
4176
+ overlapping = []
4177
+ inexhaustive = []
4178
+ probably_exhaustive = []
4179
+ complementary = []
4180
+ for group in groups.values():
4181
+ unguarded = [transition for transition in group if transition.get('guard') is None]
4182
+ guarded = [transition for transition in group if transition.get('guard') is not None]
4183
+ if len(unguarded) > 1:
4184
+ for transition in unguarded:
4185
+ ambiguous.append({'from': transition['from'], 'event': transition['event'], 'to': transition['to']})
4186
+ seen_guards = {}
4187
+ for transition in guarded:
4188
+ text = guard_text(transition['guard'])
4189
+ if text in seen_guards:
4190
+ overlapping.append({'from': transition['from'], 'event': transition['event'], 'to': transition['to']})
4191
+ else:
4192
+ seen_guards[text] = transition
4193
+ if len(guarded) > 0 and len(unguarded) == 0:
4194
+ row = {'from': group[0]['from'], 'event': group[0]['event'], 'to': group[0]['to']}
4195
+ # A complementary pair on one variable (`x < 3` / `x >= 3`) is exhaustive for
4196
+ # any valuation, so the structural warning would be a false alarm there. The
4197
+ # pair test is deliberately narrow — it cannot prove exhaustiveness, only
4198
+ # recognise the common shape, which is why the finding stays on the report at
4199
+ # info severity and still routes to S6.
4200
+ witness = _complementary_variable([transition['guard'] for transition in guarded])
4201
+ if witness is None:
4202
+ inexhaustive.append(row)
4203
+ else:
4204
+ probably_exhaustive.append(row)
4205
+ complementary.append(witness)
4206
+ if len(ambiguous) > 0:
4207
+ findings.append({
4208
+ 'code': 'UML004_AMBIGUOUS_BRANCH',
4209
+ 'severity': 'error',
4210
+ 'message': str(len(ambiguous)) + ' branch(es) share a (state, event) with no guard at all: '
4211
+ 'the diagram shows two unconditional arrows for one event, which no reader can resolve.',
4212
+ 'transitions': ambiguous,
4213
+ 'detail': 'Keep one unguarded branch per (state, event) as the else case, and guard the others. '
4214
+ 'logicprobe_verify S4 is the authoritative determinism check.',
4215
+ })
4216
+ if len(overlapping) > 0:
4217
+ findings.append({
4218
+ 'code': 'UML005_OVERLAPPING_GUARD',
4219
+ 'severity': 'warning',
4220
+ 'message': str(len(overlapping)) + ' transition(s) repeat a guard already used by another branch of the same (state, event).',
4221
+ 'transitions': overlapping,
4222
+ })
4223
+ if len(inexhaustive) > 0:
4224
+ findings.append({
4225
+ 'code': 'UML006_INEXHAUSTIVE_BRANCH',
4226
+ 'severity': 'warning',
4227
+ 'message': str(len(inexhaustive)) + ' (state, event) group(s) have only guarded branches and no default: '
4228
+ 'if every guard is false the flow vanishes, and the diagram still implies coverage.',
4229
+ 'transitions': inexhaustive,
4230
+ 'detail': 'Add an unguarded else branch, or confirm exhaustiveness with logicprobe_verify S6 (which evaluates guards over real valuations).',
4231
+ })
4232
+ if len(probably_exhaustive) > 0:
4233
+ findings.append({
4234
+ 'code': 'UML006_INEXHAUSTIVE_BRANCH',
4235
+ 'severity': 'info',
4236
+ 'message': str(len(probably_exhaustive)) + ' (state, event) group(s) have guards that look complementary on '
4237
+ + ', '.join(dict.fromkeys(complementary))
4238
+ + ', so they are probably exhaustive — but no default branch exists and only logicprobe_verify S6 can settle it.',
4239
+ 'transitions': probably_exhaustive,
4240
+ })
4241
+
4242
+ events_by_reachable = dict.fromkeys(transition['event'] for transition in model['transitions']
4243
+ if transition['from'] in reachable)
4244
+ events_anywhere = dict.fromkeys(transition['event'] for transition in model['transitions'])
4245
+ dead_events = [event for event in events_anywhere if event not in events_by_reachable]
4246
+ if len(dead_events) > 0:
4247
+ findings.append({
4248
+ 'code': 'UML007_UNUSED_EVENT',
4249
+ 'severity': 'warning',
4250
+ 'message': str(len(dead_events)) + ' event(s) only fire from states nothing can reach, so the diagram shows messages that never arrive.',
4251
+ 'events': dead_events,
4252
+ })
4253
+
4254
+ self_loops = [transition for transition in model['transitions']
4255
+ if transition['from'] == transition['to'] and transition.get('guard') is None
4256
+ and len(outgoing.get(transition['from'], [])) == 1]
4257
+ if len(self_loops) > 0:
4258
+ findings.append({
4259
+ 'code': 'UML008_SELF_LOOP_NO_EXIT',
4260
+ 'severity': 'warning',
4261
+ 'message': str(len(self_loops)) + ' state(s) have a single unguarded self-loop and no exit: '
4262
+ 'the flow can never leave, which the diagram presents as activity.',
4263
+ 'states': list(dict.fromkeys(transition['from'] for transition in self_loops)),
4264
+ 'detail': 'logicprobe_verify S3 reports absorbing cycles (liveness).',
4265
+ })
4266
+
4267
+ seen_transitions = {}
4268
+ for transition in model['transitions']:
4269
+ key = _canonical_transition(transition)
4270
+ seen_transitions[key] = seen_transitions.get(key, 0) + 1
4271
+ duplicates = [transition for transition in model['transitions']
4272
+ if seen_transitions.get(_canonical_transition(transition), 0) > 1]
4273
+ if len(duplicates) > 0:
4274
+ findings.append({
4275
+ 'code': 'UML009_DUPLICATE_TRANSITION',
4276
+ 'severity': 'warning',
4277
+ 'message': str(len(duplicates)) + ' transition(s) duplicate an identical (from, event, guard, actions, to) row; '
4278
+ 'the diagram draws the same arrow twice.',
4279
+ 'transitions': [{'from': transition['from'], 'event': transition['event'], 'to': transition['to']} for transition in duplicates],
4280
+ })
4281
+
4282
+ guard_variables = set()
4283
+ for transition in model['transitions']:
4284
+ if transition.get('guard') is not None:
4285
+ _collect_guard_variables(transition['guard'], guard_variables)
4286
+ updated_variables = set()
4287
+ for transition in model['transitions']:
4288
+ for update in transition.get('updates') or []:
4289
+ updated_variables.add(update['variable'])
4290
+ unused_variables = [variable['name'] for variable in (model.get('variables') or [])
4291
+ if variable['name'] not in guard_variables and variable['name'] not in updated_variables]
4292
+ if len(unused_variables) > 0:
4293
+ findings.append({
4294
+ 'code': 'UML010_UNUSED_VARIABLE',
4295
+ 'severity': 'warning',
4296
+ 'message': str(len(unused_variables)) + ' variable(s) are never read by a guard and never written: '
4297
+ 'the diagram carries a symbol with no source.',
4298
+ 'events': unused_variables,
4299
+ })
4300
+ unbounded = [variable['name'] for variable in (model.get('variables') or [])
4301
+ if variable['kind'] == 'integer' and ('min' not in variable or 'max' not in variable)]
4302
+ if len(unbounded) > 0:
4303
+ findings.append({
4304
+ 'code': 'UML011_UNBOUNDED_VARIABLE',
4305
+ 'severity': 'info',
4306
+ 'message': str(len(unbounded)) + ' integer variable(s) declare no min/max, so no range invariant can be checked '
4307
+ 'and A5 boundary probing has no declared domain.',
4308
+ 'detail': ', '.join(unbounded),
4309
+ })
4310
+
4311
+ terminals = [state['id'] for state in model['states'] if state.get('terminal') is True]
4312
+ if len(terminals) == 0:
4313
+ findings.append({
4314
+ 'code': 'UML012_NO_TERMINAL',
4315
+ 'severity': 'warning',
4316
+ 'message': 'no state is terminal: the diagram has no `--> [*]`, so completion, failure and a stuck flow look the same.',
4317
+ 'detail': 'Mark absorbing states terminal, or state explicitly that the machine is non-terminating.',
4318
+ })
4319
+
4320
+ if 'narrative' not in model:
4321
+ findings.append({
4322
+ 'code': 'UML013_NO_NARRATIVE',
4323
+ 'severity': 'info',
4324
+ 'message': 'the model carries no narrative block: no state, event or scenario has a natural-language meaning, '
4325
+ 'so a reader must re-derive every symbol from the source.',
4326
+ 'detail': 'Add narrative.states / narrative.events / narrative.scenarios — the schema requires all three and full coverage once the block is present.',
4327
+ })
4328
+
4329
+ documented = None
4330
+ if labels is not None:
4331
+ documented = [sid for sid in labels if _documented_meaning(labels[sid], sid) is not None]
4332
+ if documented is not None:
4333
+ undocumented = [state['id'] for state in model['states']
4334
+ if _documented_meaning(labels.get(state['id']), state['id']) is None]
4335
+ if len(undocumented) > 0:
4336
+ findings.append({
4337
+ 'code': 'UML014_UNDOCUMENTED_STATE',
4338
+ 'severity': 'info',
4339
+ 'message': str(len(undocumented)) + ' of ' + str(len(model['states']))
4340
+ + ' states carry no meaning in the diagram (they render as their bare id).',
4341
+ 'states': undocumented,
4342
+ 'detail': 'Give each state a `state "meaning" as ID` label or `ID : meaning` description so the diagram can be read against the code.',
4343
+ })
4344
+ narrative = model.get('narrative')
4345
+ if narrative is not None and narrative.get('states') is not None:
4346
+ drift = []
4347
+ for state in model['states']:
4348
+ meaning = _documented_meaning(labels.get(state['id']), state['id'])
4349
+ declared = narrative['states'].get(state['id'])
4350
+ if meaning is not None and declared is not None and meaning != declared:
4351
+ drift.append(state['id'] + ': diagram "' + meaning + '" vs narrative "' + declared + '"')
4352
+ if len(drift) > 0:
4353
+ findings.append({
4354
+ 'code': 'UML015_LABEL_DRIFT',
4355
+ 'severity': 'warning',
4356
+ 'message': str(len(drift)) + ' state label(s) disagree with the model narrative: one of the two is stale, '
4357
+ 'and the review cannot tell which.',
4358
+ 'detail': ' | '.join(drift),
4359
+ })
4360
+
4361
+ if len(warnings) > 0:
4362
+ findings.append({
4363
+ 'code': 'UML016_DIAGRAM_PARSE_NOTES',
4364
+ 'severity': 'info',
4365
+ 'message': str(len(warnings)) + ' note(s) were produced while rendering or reading the diagram; '
4366
+ 'they mark information the notation could not carry.',
4367
+ 'detail': ' | '.join(warnings),
4368
+ })
4369
+
4370
+ return findings
4371
+
4372
+
4373
+ def _compiled_model(input_value):
4374
+ ok, model_or_errors = validate_model(input_value)
4375
+ if not ok:
4376
+ raise ValueError('model invalid: ' + '; '.join(model_or_errors))
4377
+ return model_or_errors
4378
+
4379
+
4380
+ def _round_trip_of(model, notation, diagram, max_steps):
4381
+ rendered = render_uml(model, notation, diagram, max_steps)
4382
+ parsed = parse_uml(rendered['primary'], notation)
4383
+ diffs = diff_models(model, parsed['model'])
4384
+ report = {
4385
+ 'notation': notation,
4386
+ 'diagram': diagram,
4387
+ 'ok': len(diffs) == 0,
4388
+ 'modelHash': model_hash(model),
4389
+ 'parsedHash': model_hash(parsed['model']),
4390
+ 'diffs': diffs,
4391
+ 'warnings': list(rendered['warnings']) + list(parsed['warnings']),
4392
+ }
4393
+ return {'report': report, 'primary': rendered['primary']}
4394
+
4395
+
4396
+ def diff_models(left, right):
4397
+ """Compare two machines by structure — the fidelity measure behind the round-trip check."""
4398
+ diffs = []
4399
+ if left['init'] != right['init']:
4400
+ diffs.append('init: ' + left['init'] + ' vs ' + right['init'])
4401
+ left_states = [state['id'] for state in left['states']]
4402
+ right_states = [state['id'] for state in right['states']]
4403
+ for sid in left_states:
4404
+ if sid not in right_states:
4405
+ diffs.append('state missing after parse: ' + sid)
4406
+ for sid in right_states:
4407
+ if sid not in left_states:
4408
+ diffs.append('state invented by the diagram: ' + sid)
4409
+ left_terminal = sorted(state['id'] for state in left['states'] if state.get('terminal') is True)
4410
+ right_terminal = sorted(state['id'] for state in right['states'] if state.get('terminal') is True)
4411
+ if ', '.join(left_terminal) != ', '.join(right_terminal):
4412
+ diffs.append('terminal states: [' + ', '.join(left_terminal) + '] vs [' + ', '.join(right_terminal) + ']')
4413
+ left_transitions = sorted(_canonical_transition(transition) for transition in left['transitions'])
4414
+ right_transitions = sorted(_canonical_transition(transition) for transition in right['transitions'])
4415
+ left_count = {}
4416
+ for key in left_transitions:
4417
+ left_count[key] = left_count.get(key, 0) + 1
4418
+ right_count = {}
4419
+ for key in right_transitions:
4420
+ right_count[key] = right_count.get(key, 0) + 1
4421
+ for key, count in left_count.items():
4422
+ other = right_count.get(key, 0)
4423
+ if other < count:
4424
+ diffs.append('transition lost in the diagram (' + str(count - other) + 'x): ' + key.replace('|', ' '))
4425
+ for key, count in right_count.items():
4426
+ other = left_count.get(key, 0)
4427
+ if other < count:
4428
+ diffs.append('transition invented by the diagram (' + str(count - other) + 'x): ' + key.replace('|', ' '))
4429
+ left_variables = sorted(variable['name'] + ':' + variable['kind'] for variable in (left.get('variables') or []))
4430
+ right_variables = sorted(variable['name'] + ':' + variable['kind'] for variable in (right.get('variables') or []))
4431
+ for name in left_variables:
4432
+ if name not in right_variables:
4433
+ diffs.append('variable missing after parse: ' + name)
4434
+ for name in right_variables:
4435
+ if name not in left_variables:
4436
+ diffs.append('variable invented by the diagram: ' + name)
4437
+ return diffs
4438
+
4439
+
4440
+ def review_uml(options):
4441
+ """Mirror of reviewUml: review a machine, a diagram, or the match between the two."""
4442
+ warnings = []
4443
+ findings = []
4444
+ has_model = 'model' in options
4445
+ has_diagram = isinstance(options.get('diagram'), str) and _js_trim(options['diagram']) != ''
4446
+ if not has_model and not has_diagram:
4447
+ raise ValueError('review needs `model`, `diagram`, or both')
4448
+
4449
+ notation = 'mermaid'
4450
+ if options.get('notation') is not None and options['notation'] != 'auto':
4451
+ notation = options['notation']
4452
+ kind = options['diagramKind'] if options.get('diagramKind') is not None else 'state'
4453
+ model = None
4454
+ labels = None
4455
+ primary = None
4456
+ round_trip = None
4457
+
4458
+ if has_diagram:
4459
+ try:
4460
+ parsed = parse_uml(options['diagram'], options['notation'] if options.get('notation') is not None else 'auto')
4461
+ except ValueError as exc:
4462
+ return {
4463
+ 'ok': False,
4464
+ 'source': 'model+diagram' if has_model else 'diagram',
4465
+ 'summary': {'errors': 1, 'warnings': 0, 'info': 0, 'states': 0, 'events': 0, 'transitions': 0,
4466
+ 'terminalStates': 0, 'reachableStates': 0, 'documentedStates': 0},
4467
+ 'findings': [{'code': 'UML001_DIAGRAM_UNREADABLE', 'severity': 'error', 'message': str(exc),
4468
+ 'detail': 'The diagram could not be read as a Mermaid/PlantUML state or activity diagram.'}],
4469
+ 'roundTrip': None,
4470
+ 'warnings': warnings,
4471
+ 'nextSteps': ['Fix the diagram syntax (or render one from a model with logicprobe_uml action=render) and review again.'],
4472
+ }
4473
+ notation = parsed['notation']
4474
+ labels = parsed['labels']
4475
+ warnings.extend(parsed['warnings'])
4476
+ if has_model:
4477
+ model = _compiled_model(options['model'])
4478
+ diffs = diff_models(model, parsed['model'])
4479
+ round_trip = {
4480
+ 'notation': parsed['notation'],
4481
+ 'diagram': parsed['diagram'],
4482
+ 'ok': len(diffs) == 0,
4483
+ 'modelHash': model_hash(model),
4484
+ 'parsedHash': model_hash(parsed['model']),
4485
+ 'diffs': diffs,
4486
+ 'warnings': list(parsed['warnings']),
4487
+ }
4488
+ if len(diffs) > 0:
4489
+ detail = ' | '.join(diffs[:12])
4490
+ if len(diffs) > 12:
4491
+ detail += ' | … ' + str(len(diffs) - 12) + ' more'
4492
+ findings.append({
4493
+ 'code': 'UML017_ROUND_TRIP_MISMATCH',
4494
+ 'severity': 'error',
4495
+ 'message': 'the diagram does not carry the model it is presented with: ' + str(len(diffs)) + ' structural difference(s).',
4496
+ 'detail': detail,
4497
+ })
4498
+ if len(diffs) == 0 and labels is not None and 'narrative' not in model:
4499
+ warnings.append('UML_REVIEW_DIAGRAM_LABELS_IGNORED_BY_MODEL: the diagram carries state labels but the model has no narrative block, '
4500
+ 'so the labels live only in the diagram.')
4501
+ else:
4502
+ model = parsed['model']
4503
+ findings.append({
4504
+ 'code': 'UML018_FIDELITY_UNCHECKED',
4505
+ 'severity': 'info',
4506
+ '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.',
4507
+ 'detail': 'Compare the parsed model against the code (each state/event/guard needs a source citation), '
4508
+ 'or pass the machine alongside the diagram to check the two against each other.',
4509
+ })
4510
+ else:
4511
+ model = _compiled_model(options['model'])
4512
+ max_steps = options['maxSteps'] if options.get('maxSteps') is not None else 60
4513
+ # A sequence view is a trace, so parsing it back would drop every branch the
4514
+ # walk never took: the fidelity check cannot apply to it, and pretending it did
4515
+ # would either fail spuriously or hide the difference. Render it anyway (the
4516
+ # caller asked for that view) and say the check does not apply.
4517
+ if kind == 'sequence':
4518
+ try:
4519
+ rendered = render_uml(model, notation, kind, max_steps)
4520
+ primary = rendered['primary']
4521
+ warnings.extend(rendered['warnings'])
4522
+ except ValueError as exc:
4523
+ warnings.append('UML_REVIEW_RENDER_SKIPPED: ' + str(exc))
4524
+ findings.append({
4525
+ 'code': 'UML019_ROUND_TRIP_SKIPPED',
4526
+ 'severity': 'warning',
4527
+ 'message': 'a sequence view is one trace, not a restatement of the machine, so the render/parse fidelity check does not apply to it; '
4528
+ 'render diagram "state" or "activity" to have the diagram checked against the model.',
4529
+ })
4530
+ elif options.get('roundTrip') is not False:
4531
+ try:
4532
+ rendered = _round_trip_of(model, notation, kind, max_steps)
4533
+ primary = rendered['primary']
4534
+ round_trip = rendered['report']
4535
+ warnings.extend(rendered['report']['warnings'])
4536
+ if not rendered['report']['ok']:
4537
+ findings.append({
4538
+ 'code': 'UML017_ROUND_TRIP_MISMATCH',
4539
+ 'severity': 'error',
4540
+ 'message': 'the rendered diagram does not read back as the model: ' + str(len(rendered['report']['diffs']))
4541
+ + ' structural difference(s).',
4542
+ 'detail': ' | '.join(rendered['report']['diffs'][:12]),
4543
+ })
4544
+ except ValueError as exc:
4545
+ message = str(exc)
4546
+ warnings.append('UML_REVIEW_ROUND_TRIP_SKIPPED: ' + message)
4547
+ findings.append({
4548
+ 'code': 'UML019_ROUND_TRIP_SKIPPED',
4549
+ 'severity': 'warning',
4550
+ 'message': 'the fidelity check could not run for ' + notation + '/' + kind + ': ' + message,
4551
+ })
4552
+ else:
4553
+ findings.append({
4554
+ 'code': 'UML019_ROUND_TRIP_SKIPPED',
4555
+ 'severity': 'warning',
4556
+ 'message': 'the fidelity check was switched off (roundTrip=false); nothing here proves the diagram carries the model.',
4557
+ })
4558
+
4559
+ findings.extend(_structural_findings(model, labels, warnings))
4560
+ errors = len([finding for finding in findings if finding['severity'] == 'error'])
4561
+ warning_count = len([finding for finding in findings if finding['severity'] == 'warning'])
4562
+ info = len([finding for finding in findings if finding['severity'] == 'info'])
4563
+ reachable = _reachable_states(model)
4564
+ if labels is None:
4565
+ narrative = model.get('narrative')
4566
+ documented_states = 0 if narrative is None or narrative.get('states') is None else len(narrative['states'])
4567
+ else:
4568
+ documented_states = len([sid for sid in labels if _documented_meaning(labels[sid], sid) is not None])
4569
+ events = dict.fromkeys(transition['event'] for transition in model['transitions'])
4570
+ next_steps = []
4571
+ if errors > 0:
4572
+ next_steps.append('Resolve the error findings first — a diagram that cannot be read (or that disagrees with its model) '
4573
+ 'will mislead every later review.')
4574
+ next_steps.append('Run logicprobe_verify on this model for the behavioural checks (S1-S8 structural, A1-A14 adversarial); '
4575
+ 'the review above covers modelling, not behaviour.')
4576
+ if 'narrative' not in model:
4577
+ next_steps.append('Add narrative.states/events/scenarios so the diagram is readable against the code.')
4578
+ if any(finding['code'] == 'UML011_UNBOUNDED_VARIABLE' for finding in findings):
4579
+ next_steps.append('Declare min/max (or boundaryChecks) before relying on A5 boundary probes.')
4580
+ report = {
4581
+ 'ok': True,
4582
+ 'source': 'model+diagram' if (has_model and has_diagram) else ('diagram' if has_diagram else 'model'),
4583
+ 'summary': {
4584
+ 'errors': errors,
4585
+ 'warnings': warning_count,
4586
+ 'info': info,
4587
+ 'states': len(model['states']),
4588
+ 'events': len(events),
4589
+ 'transitions': len(model['transitions']),
4590
+ 'terminalStates': len([state for state in model['states'] if state.get('terminal') is True]),
4591
+ 'reachableStates': len(reachable),
4592
+ 'documentedStates': documented_states,
4593
+ },
4594
+ 'findings': findings,
4595
+ 'roundTrip': round_trip,
4596
+ }
4597
+ if labels is not None:
4598
+ report['labels'] = labels
4599
+ if has_diagram:
4600
+ report['model'] = model
4601
+ if primary is not None:
4602
+ report['primary'] = primary
4603
+ report['warnings'] = warnings
4604
+ report['nextSteps'] = next_steps
4605
+ return report
4606
+
4607
+
3090
4608
  # ---------------------------------------------------------------------------
3091
4609
  # CLI
3092
4610
  # ---------------------------------------------------------------------------
@@ -3136,6 +4654,54 @@ def _cmd_export(args):
3136
4654
  print(json.dumps(out, indent=2))
3137
4655
 
3138
4656
 
4657
+ def _cmd_uml_render(args):
4658
+ try:
4659
+ model = _load_json_file(args.model)
4660
+ result = render_uml(model, args.notation, args.diagram, args.max_steps)
4661
+ except (OSError, ValueError) as exc:
4662
+ print(json.dumps({'ok': False, 'error': str(exc)}))
4663
+ sys.exit(2)
4664
+ out = {'notation': result['notation'], 'diagram': result['diagram'],
4665
+ 'primary': result['primary'], 'warnings': result['warnings']}
4666
+ print(json.dumps(out, indent=2))
4667
+
4668
+
4669
+ def _cmd_uml_parse(args):
4670
+ try:
4671
+ with open(args.diagram, 'r', encoding='utf-8') as handle:
4672
+ text = handle.read()
4673
+ result = parse_uml(text, args.notation)
4674
+ except (OSError, ValueError) as exc:
4675
+ print(json.dumps({'ok': False, 'error': str(exc)}))
4676
+ sys.exit(2)
4677
+ out = {'notation': result['notation'], 'diagram': result['diagram'], 'model': result['model'],
4678
+ 'labels': result['labels'], 'warnings': result['warnings']}
4679
+ print(json.dumps(out, indent=2))
4680
+
4681
+
4682
+ def _cmd_uml_review(args):
4683
+ try:
4684
+ options = {}
4685
+ if args.model:
4686
+ options['model'] = _load_json_file(args.model)
4687
+ if args.diagram:
4688
+ with open(args.diagram, 'r', encoding='utf-8') as handle:
4689
+ options['diagram'] = handle.read()
4690
+ if args.notation is not None:
4691
+ options['notation'] = args.notation
4692
+ if args.diagram_kind is not None:
4693
+ options['diagramKind'] = args.diagram_kind
4694
+ if args.no_round_trip:
4695
+ options['roundTrip'] = False
4696
+ if args.max_steps is not None:
4697
+ options['maxSteps'] = args.max_steps
4698
+ report = review_uml(options)
4699
+ except (OSError, ValueError) as exc:
4700
+ print(json.dumps({'ok': False, 'error': str(exc)}))
4701
+ sys.exit(2)
4702
+ print(json.dumps(report, indent=2))
4703
+
4704
+
3139
4705
  def _build_parser():
3140
4706
  parser = argparse.ArgumentParser(prog='logicprobe-engine.py',
3141
4707
  description='Standalone LogicModelV1 verification + composition + export (non-DSH mirror)')
@@ -3156,6 +4722,24 @@ def _build_parser():
3156
4722
  p_export.add_argument('model')
3157
4723
  p_export.add_argument('--format', required=True, choices=['uppaal', 'tla', 'prism', 'spin'])
3158
4724
  p_export.set_defaults(func=_cmd_export)
4725
+ p_uml_render = sub.add_parser('uml-render', help='render a LogicModelV1 as Mermaid/PlantUML UML text')
4726
+ p_uml_render.add_argument('model')
4727
+ p_uml_render.add_argument('--notation', choices=['mermaid', 'plantuml'], default='mermaid')
4728
+ p_uml_render.add_argument('--diagram', choices=['state', 'activity', 'sequence'], default='state')
4729
+ p_uml_render.add_argument('--max-steps', type=int, default=60)
4730
+ p_uml_render.set_defaults(func=_cmd_uml_render)
4731
+ p_uml_parse = sub.add_parser('uml-parse', help='parse a Mermaid/PlantUML diagram back into a LogicModelV1')
4732
+ p_uml_parse.add_argument('diagram')
4733
+ p_uml_parse.add_argument('--notation', choices=['auto', 'mermaid', 'plantuml'], default='auto')
4734
+ p_uml_parse.set_defaults(func=_cmd_uml_parse)
4735
+ p_uml_review = sub.add_parser('uml-review', help='review a machine, a diagram, or the match between the two')
4736
+ p_uml_review.add_argument('--model')
4737
+ p_uml_review.add_argument('--diagram')
4738
+ p_uml_review.add_argument('--notation', choices=['auto', 'mermaid', 'plantuml'], default='auto')
4739
+ p_uml_review.add_argument('--diagram-kind', choices=['state', 'activity', 'sequence'])
4740
+ p_uml_review.add_argument('--no-round-trip', action='store_true')
4741
+ p_uml_review.add_argument('--max-steps', type=int)
4742
+ p_uml_review.set_defaults(func=_cmd_uml_review)
3159
4743
  return parser
3160
4744
 
3161
4745