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.
package/lib/uml.js ADDED
@@ -0,0 +1,1544 @@
1
+ /**
2
+ * UML front end for LogicModelV1 — model a code flow as a UML diagram, then
3
+ * review the modelling itself.
4
+ *
5
+ * Two halves, one data flow:
6
+ *
7
+ * 1. `renderUml` turns a validated LogicModelV1 into Mermaid or PlantUML text
8
+ * (state machine, activity/flow, or sequence trace). The diagram is a
9
+ * *view*: it never invents structure the model does not have, and anything
10
+ * the notation cannot express is reported as a warning instead of being
11
+ * dropped silently.
12
+ * 2. `parseUml` reads that text back into a LogicModelV1, and `reviewUml`
13
+ * compares the two. That comparison is the point of the feature: a UML
14
+ * diagram of a code flow is itself a model, and a model can be wrong —
15
+ * ambiguous branches, dead ends, flows nobody can enter, symbols the
16
+ * source never names. A diagram that does not round-trip to the model it
17
+ * was drawn from is mis-modelled, and the review says so.
18
+ *
19
+ * Why the round trip is the fidelity check: rendering and parsing are inverse
20
+ * only if every construct survives the notation. The generated text carries
21
+ * `logicprobe:` directives (ignored by Mermaid/PlantUML renderers) that pin the
22
+ * initial state, the terminal states and any state id the notation cannot spell
23
+ * verbatim, so an exact comparison is possible rather than a fuzzy one.
24
+ *
25
+ * The review deliberately does NOT replace `logicprobe_verify`: it checks the
26
+ * modelling (structure the diagram claims, documentation coverage, notation
27
+ * fidelity), while S1-S8/A1-A14 check the machine's behaviour (guard
28
+ * exhaustiveness under real valuations, invariant paths, deadlock/liveness in
29
+ * the runtime state space). Findings name the engine check to run next.
30
+ *
31
+ * @module logicprobe-uml
32
+ */
33
+ import { validateModel, modelHash } from './engine.js';
34
+ export const UML_NOTATIONS = ['mermaid', 'plantuml'];
35
+ export const UML_DIAGRAMS = ['state', 'activity', 'sequence'];
36
+ /** Marker every generated diagram carries; renderers ignore it, the parser uses it. */
37
+ const DIRECTIVE_NAMESPACE = 'logicprobe:';
38
+ /** State id / event name characters that would break the generated label syntax. */
39
+ const UNSAFE_LABEL = /[[\]/\n\r\t]/;
40
+ /** Mermaid flowchart keywords that cannot stand alone as a node id. */
41
+ const RESERVED_NODE_IDS = new Set(['end', 'graph', 'subgraph', 'class', 'classDef', 'click', 'style', 'linkStyle', 'direction']);
42
+ /** Ids the notation can spell without an alias. */
43
+ const PLAIN_ID = /^[A-Za-z_][A-Za-z0-9_]*$/;
44
+ export class UmlError extends Error {
45
+ }
46
+ // ---------------------------------------------------------------------------
47
+ // Shared helpers
48
+ // ---------------------------------------------------------------------------
49
+ function literalText(value) {
50
+ return typeof value === 'boolean' ? String(value) : String(value);
51
+ }
52
+ /** Canonical guard text. Rendering wraps every composite node in parentheses, and the parser flattens same-operator chains, so render∘parse is the identity. */
53
+ export function guardText(node) {
54
+ if ('variable' in node)
55
+ return node.variable + ' ' + node.op + ' ' + literalText(node.value);
56
+ if ('all' in node)
57
+ return '(' + node.all.map((guard) => guardText(guard)).join(' && ') + ')';
58
+ if ('any' in node)
59
+ return '(' + node.any.map((guard) => guardText(guard)).join(' || ') + ')';
60
+ return '!(' + guardText(node.not) + ')';
61
+ }
62
+ function updatesText(updates) {
63
+ return updates.map((update) => {
64
+ const value = update.value ?? (update.op === 'set' ? 0 : 1);
65
+ if (update.op === 'set')
66
+ return update.variable + ' := ' + literalText(value);
67
+ if (update.op === 'inc')
68
+ return update.variable + ' := ' + update.variable + ' + ' + literalText(value);
69
+ return update.variable + ' := ' + update.variable + ' - ' + literalText(value);
70
+ }).join(', ');
71
+ }
72
+ function transitionText(transition) {
73
+ let text = transition.event;
74
+ if (transition.guard !== undefined)
75
+ text += ' [' + guardText(transition.guard) + ']';
76
+ if (transition.updates !== undefined && transition.updates.length > 0)
77
+ text += ' / ' + updatesText(transition.updates);
78
+ return text;
79
+ }
80
+ function displayText(text) {
81
+ return text.replace(/[\r\n\t]+/g, ' ').replace(/"/g, '\'').trim();
82
+ }
83
+ function prepareRender(input) {
84
+ const validation = validateModel(input);
85
+ if (!validation.ok)
86
+ throw new UmlError('model invalid: ' + validation.errors.join('; '));
87
+ const model = validation.model;
88
+ const warnings = [];
89
+ const used = new Set();
90
+ const alias = new Map();
91
+ const display = new Map();
92
+ for (const state of model.states) {
93
+ let candidate = state.id;
94
+ if (!PLAIN_ID.test(candidate)) {
95
+ candidate = candidate.replace(/[^A-Za-z0-9_]/g, '_');
96
+ if (candidate === '' || /^[0-9]/.test(candidate))
97
+ candidate = 'S_' + candidate;
98
+ warnings.push('UML_RENDER_ID_SANITIZED: state id "' + state.id + '" is not a plain identifier; the diagram draws it as "' + candidate + '" and pins the original with a ' + DIRECTIVE_NAMESPACE + 'alias directive.');
99
+ }
100
+ if (RESERVED_NODE_IDS.has(candidate))
101
+ warnings.push('UML_RENDER_RESERVED_ID: state alias "' + candidate + '" collides with a diagram keyword; Mermaid renders it, but a hand edit may not.');
102
+ let unique = candidate;
103
+ let suffix = 2;
104
+ while (used.has(unique)) {
105
+ unique = candidate + '_' + String(suffix);
106
+ suffix += 1;
107
+ }
108
+ if (unique !== candidate)
109
+ warnings.push('UML_RENDER_ALIAS_COLLISION: state id "' + state.id + '" shares an alias with another state; the diagram uses "' + unique + '".');
110
+ used.add(unique);
111
+ alias.set(state.id, unique);
112
+ const meaning = model.narrative?.states?.[state.id];
113
+ display.set(state.id, meaning === undefined ? state.id : displayText(state.id + '(' + meaning + ')'));
114
+ }
115
+ for (const transition of model.transitions) {
116
+ if (UNSAFE_LABEL.test(transition.event)) {
117
+ warnings.push('UML_RENDER_LABEL_UNSAFE: event "' + transition.event + '" contains a character (one of [ ] / or a line break) that the diagram label syntax uses; the rendered diagram cannot be read back verbatim.');
118
+ }
119
+ }
120
+ const terminal = new Set(model.states.filter((state) => state.terminal === true).map((state) => state.id));
121
+ return { model, alias, display, terminal, warnings };
122
+ }
123
+ function directiveLines(notation, diagram, context) {
124
+ const prefix = notation === 'mermaid' ? '%%' : "'";
125
+ const lines = [prefix + DIRECTIVE_NAMESPACE + 'uml v1 notation=' + notation + ' diagram=' + diagram];
126
+ lines.push(prefix + DIRECTIVE_NAMESPACE + 'init ' + context.model.init);
127
+ const terminals = context.model.states.filter((state) => state.terminal === true).map((state) => state.id);
128
+ if (terminals.length > 0)
129
+ lines.push(prefix + DIRECTIVE_NAMESPACE + 'terminal ' + terminals.join(','));
130
+ for (const state of context.model.states) {
131
+ if (context.alias.get(state.id) !== state.id) {
132
+ lines.push(prefix + DIRECTIVE_NAMESPACE + 'alias ' + String(context.alias.get(state.id)) + ' ' + state.id);
133
+ }
134
+ }
135
+ // Variable kinds are not recoverable from the notation: `armed := 1` reads as an
136
+ // integer assignment whichever kind the model declared, and a variable no guard
137
+ // reads and no action writes leaves no trace at all. Pinning them keeps the
138
+ // round trip exact instead of reporting a fidelity loss that is really a
139
+ // notation limit.
140
+ for (const variable of context.model.variables ?? []) {
141
+ lines.push(prefix + DIRECTIVE_NAMESPACE + 'variable ' + variable.name + ' ' + variable.kind);
142
+ }
143
+ return lines;
144
+ }
145
+ function groupedTransitions(model) {
146
+ const order = [];
147
+ const groups = new Map();
148
+ for (const transition of model.transitions) {
149
+ const list = groups.get(transition.from);
150
+ if (list === undefined) {
151
+ groups.set(transition.from, [transition]);
152
+ order.push(transition.from);
153
+ }
154
+ else
155
+ list.push(transition);
156
+ }
157
+ return order.map((from) => ({ from, transitions: groups.get(from) ?? [] }));
158
+ }
159
+ // ---------------------------------------------------------------------------
160
+ // Rendering
161
+ // ---------------------------------------------------------------------------
162
+ function renderMermaidState(context) {
163
+ const lines = directiveLines('mermaid', 'state', context);
164
+ lines.push('stateDiagram-v2');
165
+ lines.push(' [*] --> ' + String(context.alias.get(context.model.init)));
166
+ for (const state of context.model.states) {
167
+ const alias = String(context.alias.get(state.id));
168
+ const label = context.display.get(state.id) ?? state.id;
169
+ // Every state is declared, even when its label equals its alias. A state that
170
+ // no transition touches (an isolated terminal, a start state with no edge yet)
171
+ // would otherwise leave no trace in the text at all, and the round-trip check
172
+ // would have to report a loss the notation never caused.
173
+ lines.push(' state "' + label + '" as ' + alias);
174
+ }
175
+ for (const group of groupedTransitions(context.model)) {
176
+ for (const transition of group.transitions) {
177
+ lines.push(' ' + String(context.alias.get(transition.from)) + ' --> ' + String(context.alias.get(transition.to)) + ' : ' + transitionText(transition));
178
+ }
179
+ }
180
+ for (const state of context.model.states) {
181
+ if (state.terminal === true)
182
+ lines.push(' ' + String(context.alias.get(state.id)) + ' --> [*]');
183
+ }
184
+ return lines.join('\n') + '\n';
185
+ }
186
+ function renderPlantUmlState(context) {
187
+ const lines = ['@startuml'];
188
+ lines.push(...directiveLines('plantuml', 'state', context));
189
+ lines.push('[*] --> ' + String(context.alias.get(context.model.init)));
190
+ for (const state of context.model.states) {
191
+ const alias = String(context.alias.get(state.id));
192
+ const label = context.display.get(state.id) ?? state.id;
193
+ lines.push('state "' + label + '" as ' + alias);
194
+ }
195
+ for (const group of groupedTransitions(context.model)) {
196
+ for (const transition of group.transitions) {
197
+ lines.push(String(context.alias.get(transition.from)) + ' --> ' + String(context.alias.get(transition.to)) + ' : ' + transitionText(transition));
198
+ }
199
+ }
200
+ for (const state of context.model.states) {
201
+ if (state.terminal === true)
202
+ lines.push(String(context.alias.get(state.id)) + ' --> [*]');
203
+ }
204
+ lines.push('@enduml');
205
+ return lines.join('\n') + '\n';
206
+ }
207
+ function renderMermaidActivity(context) {
208
+ const lines = directiveLines('mermaid', 'activity', context);
209
+ lines.push('flowchart TD');
210
+ for (const state of context.model.states) {
211
+ const alias = String(context.alias.get(state.id));
212
+ const text = context.display.get(state.id) ?? state.id;
213
+ lines.push(' ' + alias + (state.terminal === true ? '(["' + text + '"])' : '["' + text + '"]'));
214
+ }
215
+ for (const group of groupedTransitions(context.model)) {
216
+ for (const transition of group.transitions) {
217
+ lines.push(' ' + String(context.alias.get(transition.from)) + ' -->|"' + transitionText(transition) + '"| ' + String(context.alias.get(transition.to)));
218
+ }
219
+ }
220
+ return lines.join('\n') + '\n';
221
+ }
222
+ /**
223
+ * One BFS trace of the machine, written as a sequence diagram. A sequence
224
+ * diagram is a trace by construction — branches are messages with guards, and
225
+ * the diagram is explicitly capped so a cyclic machine cannot produce an
226
+ * unbounded file.
227
+ */
228
+ function renderSequence(context, notation, maxSteps) {
229
+ const lines = [];
230
+ if (notation === 'mermaid') {
231
+ lines.push(...directiveLines('mermaid', 'sequence', context));
232
+ lines.push('sequenceDiagram');
233
+ lines.push(' participant ENV as Environment');
234
+ lines.push(' participant M as Machine');
235
+ }
236
+ else {
237
+ lines.push('@startuml');
238
+ lines.push(...directiveLines('plantuml', 'sequence', context));
239
+ lines.push('participant ENV as Environment');
240
+ lines.push('participant M as Machine');
241
+ }
242
+ const byFrom = new Map();
243
+ for (const transition of context.model.transitions) {
244
+ const list = byFrom.get(transition.from);
245
+ if (list === undefined)
246
+ byFrom.set(transition.from, [transition]);
247
+ else
248
+ list.push(transition);
249
+ }
250
+ const seen = new Set([context.model.init]);
251
+ const queue = [context.model.init];
252
+ const arrow = notation === 'mermaid' ? 'ENV->>M: ' : 'ENV -> M : ';
253
+ const note = notation === 'mermaid' ? ' Note over M: ' : 'note over M : ';
254
+ lines.push((notation === 'mermaid' ? ' ' : '') + 'Note over M: init ' + context.model.init);
255
+ let steps = 0;
256
+ let truncated = false;
257
+ while (queue.length > 0) {
258
+ const current = queue.shift();
259
+ for (const transition of byFrom.get(current) ?? []) {
260
+ if (steps >= maxSteps) {
261
+ truncated = true;
262
+ break;
263
+ }
264
+ steps += 1;
265
+ const label = transitionText(transition);
266
+ lines.push((notation === 'mermaid' ? ' ' : '') + arrow + label);
267
+ lines.push(note + String(context.alias.get(transition.from)) + ' -> ' + String(context.alias.get(transition.to)));
268
+ if (!seen.has(transition.to)) {
269
+ seen.add(transition.to);
270
+ queue.push(transition.to);
271
+ }
272
+ }
273
+ if (truncated)
274
+ break;
275
+ }
276
+ if (notation === 'plantuml')
277
+ lines.push('@enduml');
278
+ return { text: lines.join('\n') + '\n', truncated };
279
+ }
280
+ /**
281
+ * Render a LogicModelV1 as UML.
282
+ *
283
+ * PlantUML has no faithful activity view here: its activity syntax is a
284
+ * structured flowchart language, so a graph with merges or cycles needs a
285
+ * while/if reconstruction this module does not perform. Refusing is the honest
286
+ * outcome — quietly emitting a state diagram under an "activity" request would
287
+ * mislabel the model. Mermaid covers all three views.
288
+ *
289
+ * @param input - candidate LogicModelV1.
290
+ * @param notation - `mermaid` (default) or `plantuml`.
291
+ * @param diagram - `state` (default), `activity`, or `sequence`.
292
+ * @param maxSteps - cap on the sequence trace length.
293
+ */
294
+ export function renderUml(input, notation = 'mermaid', diagram = 'state', maxSteps = 60) {
295
+ if (!UML_NOTATIONS.includes(notation))
296
+ throw new UmlError('unknown notation "' + String(notation) + '"; expected ' + UML_NOTATIONS.join(' | '));
297
+ if (!UML_DIAGRAMS.includes(diagram))
298
+ throw new UmlError('unknown diagram "' + String(diagram) + '"; expected ' + UML_DIAGRAMS.join(' | '));
299
+ if (notation === 'plantuml' && diagram === 'activity') {
300
+ throw new UmlError('plantuml has no faithful activity view here (its activity syntax is a structured flowchart language; a graph with merges or cycles needs a while/if reconstruction logicprobe does not perform) — use notation "mermaid" for the activity view, or diagram "state"');
301
+ }
302
+ const context = prepareRender(input);
303
+ const warnings = [...context.warnings];
304
+ let primary;
305
+ if (diagram === 'state')
306
+ primary = notation === 'mermaid' ? renderMermaidState(context) : renderPlantUmlState(context);
307
+ else if (diagram === 'activity')
308
+ primary = renderMermaidActivity(context);
309
+ else {
310
+ const rendered = renderSequence(context, notation, maxSteps);
311
+ primary = rendered.text;
312
+ if (rendered.truncated)
313
+ warnings.push('UML_RENDER_SEQUENCE_TRUNCATED: the trace was capped at ' + String(maxSteps) + ' steps; a sequence diagram is one trace, not the whole machine — use diagram "state" for the full topology.');
314
+ warnings.push('UML_RENDER_SEQUENCE_IS_TRACE: a sequence diagram shows one BFS trace; branches appear as separate guarded messages and unreachable branches are absent by construction.');
315
+ }
316
+ return { notation, diagram, primary, warnings };
317
+ }
318
+ function tokenizeGuard(text) {
319
+ const tokens = [];
320
+ let index = 0;
321
+ while (index < text.length) {
322
+ const char = text[index];
323
+ if (/\s/.test(char)) {
324
+ index += 1;
325
+ continue;
326
+ }
327
+ if (char === '(') {
328
+ tokens.push({ kind: 'lparen', text: char });
329
+ index += 1;
330
+ continue;
331
+ }
332
+ if (char === ')') {
333
+ tokens.push({ kind: 'rparen', text: char });
334
+ index += 1;
335
+ continue;
336
+ }
337
+ if (char === '&' && text[index + 1] === '&') {
338
+ tokens.push({ kind: 'and', text: '&&' });
339
+ index += 2;
340
+ continue;
341
+ }
342
+ if (char === '|' && text[index + 1] === '|') {
343
+ tokens.push({ kind: 'or', text: '||' });
344
+ index += 2;
345
+ continue;
346
+ }
347
+ if (char === '!') {
348
+ if (text[index + 1] === '=') {
349
+ tokens.push({ kind: 'op', text: '!=' });
350
+ index += 2;
351
+ continue;
352
+ }
353
+ tokens.push({ kind: 'not', text: '!' });
354
+ index += 1;
355
+ continue;
356
+ }
357
+ const two = text.slice(index, index + 2);
358
+ if (two === '==' || two === '<=' || two === '>=') {
359
+ tokens.push({ kind: 'op', text: two });
360
+ index += 2;
361
+ continue;
362
+ }
363
+ if (char === '<' || char === '>') {
364
+ tokens.push({ kind: 'op', text: char });
365
+ index += 1;
366
+ continue;
367
+ }
368
+ if (char === '=') {
369
+ tokens.push({ kind: 'op', text: '==' });
370
+ index += 1;
371
+ continue;
372
+ }
373
+ if (/[0-9]/.test(char) || (char === '-' && /[0-9]/.test(text[index + 1] ?? ''))) {
374
+ let end = index + 1;
375
+ while (end < text.length && /[0-9]/.test(text[end]))
376
+ end += 1;
377
+ tokens.push({ kind: 'number', text: text.slice(index, end) });
378
+ index = end;
379
+ continue;
380
+ }
381
+ if (/[A-Za-z_]/.test(char)) {
382
+ let end = index + 1;
383
+ while (end < text.length && /[A-Za-z0-9_.]/.test(text[end]))
384
+ end += 1;
385
+ const word = text.slice(index, end);
386
+ index = end;
387
+ if (word === 'and')
388
+ tokens.push({ kind: 'and', text: word });
389
+ else if (word === 'or')
390
+ tokens.push({ kind: 'or', text: word });
391
+ else if (word === 'not')
392
+ tokens.push({ kind: 'not', text: word });
393
+ else if (word === 'true' || word === 'false')
394
+ tokens.push({ kind: 'boolean', text: word });
395
+ else
396
+ tokens.push({ kind: 'ident', text: word });
397
+ continue;
398
+ }
399
+ throw new UmlError('guard text not understood near "' + text.slice(index) + '"');
400
+ }
401
+ return tokens;
402
+ }
403
+ class GuardReader {
404
+ tokens;
405
+ source;
406
+ position = 0;
407
+ constructor(tokens, source) {
408
+ this.tokens = tokens;
409
+ this.source = source;
410
+ }
411
+ parse() {
412
+ const node = this.parseOr();
413
+ if (this.position !== this.tokens.length)
414
+ throw new UmlError('trailing tokens in guard "' + this.source + '"');
415
+ return node;
416
+ }
417
+ peek() {
418
+ return this.tokens[this.position];
419
+ }
420
+ parseOr() {
421
+ const parts = [this.parseAnd()];
422
+ while (this.peek()?.kind === 'or') {
423
+ this.position += 1;
424
+ parts.push(this.parseAnd());
425
+ }
426
+ return parts.length === 1 ? parts[0] : { any: parts };
427
+ }
428
+ parseAnd() {
429
+ const parts = [this.parseUnary()];
430
+ while (this.peek()?.kind === 'and') {
431
+ this.position += 1;
432
+ parts.push(this.parseUnary());
433
+ }
434
+ return parts.length === 1 ? parts[0] : { all: parts };
435
+ }
436
+ parseUnary() {
437
+ if (this.peek()?.kind === 'not') {
438
+ this.position += 1;
439
+ return { not: this.parseUnary() };
440
+ }
441
+ return this.parsePrimary();
442
+ }
443
+ parsePrimary() {
444
+ const token = this.peek();
445
+ if (token?.kind === 'lparen') {
446
+ this.position += 1;
447
+ const inner = this.parseOr();
448
+ if (this.peek()?.kind !== 'rparen')
449
+ throw new UmlError('unbalanced parentheses in guard "' + this.source + '"');
450
+ this.position += 1;
451
+ return inner;
452
+ }
453
+ if (token?.kind !== 'ident')
454
+ throw new UmlError('expected a variable name in guard "' + this.source + '"');
455
+ this.position += 1;
456
+ const op = this.peek();
457
+ if (op?.kind !== 'op')
458
+ throw new UmlError('expected a comparison operator after "' + token.text + '" in guard "' + this.source + '"');
459
+ this.position += 1;
460
+ const value = this.peek();
461
+ if (value?.kind === 'number') {
462
+ this.position += 1;
463
+ return { variable: token.text, op: op.text, value: Number(value.text) };
464
+ }
465
+ if (value?.kind === 'boolean') {
466
+ if (op.text !== '==' && op.text !== '!=')
467
+ throw new UmlError('boolean variable "' + token.text + '" only supports == / != (guard "' + this.source + '")');
468
+ this.position += 1;
469
+ return { variable: token.text, op: op.text, value: value.text === 'true' };
470
+ }
471
+ throw new UmlError('expected a literal value for "' + token.text + '" in guard "' + this.source + '"');
472
+ }
473
+ }
474
+ /** Parse a guard expression such as `(retry < 3 && armed == true)`. */
475
+ export function parseGuardText(text) {
476
+ return new GuardReader(tokenizeGuard(text), text).parse();
477
+ }
478
+ /** Parse a UML action clause such as `retry := retry + 1, armed := true`. */
479
+ export function parseUpdatesText(text, warnings) {
480
+ const out = [];
481
+ for (const raw of text.split(',')) {
482
+ const clause = raw.trim();
483
+ if (clause === '')
484
+ continue;
485
+ const shim = /^([A-Za-z_][A-Za-z0-9_]*)\s*(\+\+|--)$/.exec(clause);
486
+ if (shim !== null) {
487
+ out.push({ variable: shim[1], op: shim[2] === '++' ? 'inc' : 'dec', value: 1 });
488
+ continue;
489
+ }
490
+ const assignment = /^([A-Za-z_][A-Za-z0-9_]*)\s*:?=\s*(.+)$/.exec(clause);
491
+ if (assignment === null)
492
+ throw new UmlError('action clause not understood: "' + clause + '" (expected "var := value")');
493
+ const name = assignment[1];
494
+ const value = assignment[2].trim();
495
+ if (value === name) {
496
+ warnings.push('UML_PARSE_NOOP_UPDATE: action "' + clause + '" assigns the variable to itself; dropped.');
497
+ continue;
498
+ }
499
+ if (/^(true|false)$/.test(value)) {
500
+ out.push({ variable: name, op: 'set', value: value === 'true' ? 1 : 0 });
501
+ continue;
502
+ }
503
+ if (/^-?[0-9]+$/.test(value)) {
504
+ out.push({ variable: name, op: 'set', value: Number(value) });
505
+ continue;
506
+ }
507
+ const arithmetic = /^([A-Za-z_][A-Za-z0-9_]*)\s*([+-])\s*([0-9]+)$/.exec(value);
508
+ if (arithmetic === null)
509
+ throw new UmlError('action value not understood: "' + value + '" (expected a literal, or "var + n" / "var - n")');
510
+ if (arithmetic[1] !== name)
511
+ throw new UmlError('action "' + clause + '" reads a different variable; LogicModelV1 updates touch one variable');
512
+ out.push({ variable: name, op: arithmetic[2] === '+' ? 'inc' : 'dec', value: Number(arithmetic[3]) });
513
+ }
514
+ return out;
515
+ }
516
+ function parseTransitionLabel(label, warnings) {
517
+ let rest = label.trim();
518
+ let guard;
519
+ const bracket = rest.indexOf('[');
520
+ if (bracket >= 0) {
521
+ const close = rest.lastIndexOf(']');
522
+ if (close < bracket)
523
+ throw new UmlError('unbalanced guard brackets in transition label "' + label + '"');
524
+ guard = parseGuardText(rest.slice(bracket + 1, close).trim());
525
+ rest = (rest.slice(0, bracket) + ' ' + rest.slice(close + 1)).trim();
526
+ }
527
+ let updates;
528
+ const slash = rest.indexOf('/');
529
+ if (slash >= 0) {
530
+ const actionText = rest.slice(slash + 1).trim();
531
+ updates = parseUpdatesText(actionText, warnings);
532
+ if (updates.length === 0)
533
+ updates = undefined;
534
+ rest = rest.slice(0, slash).trim();
535
+ }
536
+ const event = rest.trim();
537
+ if (event === '')
538
+ throw new UmlError('transition label "' + label + '" carries no event name; label the arrow as `event [guard] / actions`');
539
+ return { event, ...(guard === undefined ? {} : { guard }), ...(updates === undefined ? {} : { updates }) };
540
+ }
541
+ function detectNotation(text) {
542
+ if (/^\s*@start/m.test(text))
543
+ return 'plantuml';
544
+ if (/^\s*(stateDiagram|stateDiagram-v2|flowchart|graph|sequenceDiagram)\b/m.test(text))
545
+ return 'mermaid';
546
+ throw new UmlError('cannot tell whether this is Mermaid or PlantUML text: expected `stateDiagram-v2` / `flowchart` / `sequenceDiagram`, or `@startuml`');
547
+ }
548
+ function detectDiagram(text) {
549
+ if (/^\s*stateDiagram/m.test(text))
550
+ return 'state';
551
+ if (/^\s*(flowchart|graph)\b/m.test(text))
552
+ return 'activity';
553
+ if (/^\s*sequenceDiagram\b/m.test(text))
554
+ return 'sequence';
555
+ if (/^\s*@startuml/m.test(text)) {
556
+ // PlantUML declares the diagram kind by its body; the state keyword is the only
557
+ // structural one logicprobe emits, everything else in that family is a state diagram too.
558
+ if (/^\s*participant\b/m.test(text) || /->>\s*/.test(text))
559
+ return 'sequence';
560
+ return 'state';
561
+ }
562
+ throw new UmlError('cannot tell which diagram kind this text declares');
563
+ }
564
+ function commentPrefix(notation) {
565
+ return notation === 'mermaid' ? '%%' : "'";
566
+ }
567
+ function directiveBody(line, notation) {
568
+ const prefix = commentPrefix(notation);
569
+ const trimmed = line.trim();
570
+ if (!trimmed.startsWith(prefix))
571
+ return null;
572
+ const body = trimmed.slice(prefix.length).trim();
573
+ if (!body.startsWith(DIRECTIVE_NAMESPACE))
574
+ return null;
575
+ return body.slice(DIRECTIVE_NAMESPACE.length);
576
+ }
577
+ function readDirectives(lines, notation) {
578
+ const directives = { terminals: [], aliases: new Map(), variables: new Map() };
579
+ const body = [];
580
+ for (const line of lines) {
581
+ const text = directiveBody(line, notation);
582
+ if (text === null) {
583
+ body.push(line);
584
+ continue;
585
+ }
586
+ if (text.startsWith('uml ')) {
587
+ const notationMatch = /notation=([a-z]+)/.exec(text);
588
+ const diagramMatch = /diagram=([a-z]+)/.exec(text);
589
+ directives.declared = { ...(notationMatch === null ? {} : { notation: notationMatch[1] }), ...(diagramMatch === null ? {} : { diagram: diagramMatch[1] }) };
590
+ continue;
591
+ }
592
+ if (text.startsWith('init ')) {
593
+ directives.init = text.slice(5).trim();
594
+ continue;
595
+ }
596
+ if (text.startsWith('terminal ')) {
597
+ for (const id of text.slice(9).split(',')) {
598
+ const trimmed = id.trim();
599
+ if (trimmed !== '')
600
+ directives.terminals.push(trimmed);
601
+ }
602
+ continue;
603
+ }
604
+ if (text.startsWith('alias ')) {
605
+ const rest = text.slice(6).trim();
606
+ const split = rest.indexOf(' ');
607
+ if (split > 0)
608
+ directives.aliases.set(rest.slice(0, split), rest.slice(split + 1).trim());
609
+ continue;
610
+ }
611
+ if (text.startsWith('variable ')) {
612
+ const rest = text.slice(9).trim();
613
+ const split = rest.lastIndexOf(' ');
614
+ if (split > 0) {
615
+ const kind = rest.slice(split + 1).trim();
616
+ if (kind === 'integer' || kind === 'boolean')
617
+ directives.variables.set(rest.slice(0, split).trim(), kind);
618
+ }
619
+ continue;
620
+ }
621
+ body.push(line);
622
+ }
623
+ return { directives, ...(directives.declared?.diagram === undefined ? {} : { kindHint: directives.declared.diagram }), body };
624
+ }
625
+ function rawToModel(raw, directives, warnings) {
626
+ // Alias directives restore ids the notation cannot spell; they win over the
627
+ // alias itself, which is the whole reason the renderer writes them.
628
+ const idOf = (name) => directives.aliases.get(name) ?? name;
629
+ const names = [];
630
+ const seenNames = new Set();
631
+ const addName = (name) => {
632
+ if (name === undefined || seenNames.has(name))
633
+ return;
634
+ seenNames.add(name);
635
+ names.push(name);
636
+ };
637
+ for (const name of raw.states)
638
+ addName(name);
639
+ // A state can be known without ever being an edge endpoint: the initial
640
+ // pseudostate (`[*] --> X`) and the final mark (`Y --> [*]`) both name states a
641
+ // hand-written diagram never declares, and an isolated state has no edge at all.
642
+ addName(raw.initialState);
643
+ for (const name of raw.finalMarks)
644
+ addName(name);
645
+ for (const name of raw.terminals)
646
+ addName(name);
647
+ for (const edge of raw.edges) {
648
+ addName(edge.from);
649
+ addName(edge.to);
650
+ }
651
+ const states = [];
652
+ const seenStates = new Set();
653
+ for (const name of names) {
654
+ const id = idOf(name);
655
+ if (seenStates.has(id))
656
+ continue;
657
+ seenStates.add(id);
658
+ states.push({ id });
659
+ }
660
+ const transitions = [];
661
+ const synthetic = new Set();
662
+ for (const edge of raw.edges) {
663
+ const from = idOf(edge.from);
664
+ const to = idOf(edge.to);
665
+ const label = edge.label.trim();
666
+ let event;
667
+ let guard;
668
+ let updates;
669
+ if (label === '') {
670
+ let candidate = 't_' + from + '_' + to;
671
+ let suffix = 2;
672
+ while (synthetic.has(candidate)) {
673
+ candidate = 't_' + from + '_' + to + '_' + String(suffix);
674
+ suffix += 1;
675
+ }
676
+ synthetic.add(candidate);
677
+ event = candidate;
678
+ warnings.push('UML_PARSE_SYNTHETIC_EVENT: arrow ' + from + ' -> ' + to + ' carries no label; it was named "' + candidate + '". Label the arrow as `event [guard] / actions` so the model keeps the real event name.');
679
+ }
680
+ else {
681
+ const parsed = parseTransitionLabel(label, warnings);
682
+ event = parsed.event;
683
+ guard = parsed.guard;
684
+ updates = parsed.updates;
685
+ }
686
+ transitions.push({ from, event, to, ...(guard === undefined ? {} : { guard }), ...(updates === undefined ? {} : { updates }) });
687
+ }
688
+ // Init: an explicit `[*] --> X` wins; a directive is the fallback the activity
689
+ // view needs (a flowchart has no initial pseudostate).
690
+ let init = raw.initialState === undefined ? undefined : idOf(raw.initialState);
691
+ if (init === undefined && directives.init !== undefined)
692
+ init = idOf(directives.init);
693
+ if (raw.initialState !== undefined && directives.init !== undefined && idOf(raw.initialState) !== directives.init) {
694
+ warnings.push('UML_PARSE_INIT_CONFLICT: the diagram enters ' + idOf(raw.initialState) + ' from its initial pseudostate but declares init ' + directives.init + '; the pseudostate wins.');
695
+ }
696
+ if (init === undefined) {
697
+ const targeted = new Set(transitions.map((transition) => transition.to));
698
+ const roots = states.map((state) => state.id).filter((id) => !targeted.has(id));
699
+ if (roots.length === 1) {
700
+ init = roots[0];
701
+ warnings.push('UML_PARSE_INIT_INFERRED: no initial state was declared; "' + init + '" is the only state nothing enters, so it is used as init.');
702
+ }
703
+ else {
704
+ throw new UmlError('no initial state: add `[*] --> <state>` (or a `' + DIRECTIVE_NAMESPACE + 'init <state>` directive); found ' + String(roots.length) + ' entry states');
705
+ }
706
+ }
707
+ const terminalNames = new Set(raw.terminals.map((name) => idOf(name)));
708
+ for (const name of directives.terminals)
709
+ terminalNames.add(idOf(name));
710
+ for (const name of raw.finalMarks)
711
+ terminalNames.add(idOf(name));
712
+ for (const state of states)
713
+ if (terminalNames.has(state.id))
714
+ state.terminal = true;
715
+ const variables = inferVariables(transitions, directives.variables);
716
+ const model = {
717
+ schemaVersion: 1,
718
+ init,
719
+ states,
720
+ transitions,
721
+ ...(variables.length === 0 ? {} : { variables }),
722
+ };
723
+ const validation = validateModel(model);
724
+ if (!validation.ok)
725
+ throw new UmlError('the diagram parsed into an invalid model: ' + validation.errors.join('; '));
726
+ return validation.model;
727
+ }
728
+ /**
729
+ * Recover the variable list from the diagram. A `logicprobe:variable` directive
730
+ * wins, because the notation itself cannot tell `armed := 1` on a boolean from an
731
+ * integer assignment; guards and actions are the fallback for hand-written text.
732
+ */
733
+ function inferVariables(transitions, declared) {
734
+ const kinds = new Map(declared);
735
+ const note = (name, kind) => {
736
+ if (declared.has(name))
737
+ return;
738
+ const current = kinds.get(name);
739
+ if (current === undefined)
740
+ kinds.set(name, kind);
741
+ else if (current !== kind)
742
+ kinds.set(name, 'integer');
743
+ };
744
+ const walk = (guard) => {
745
+ if (guard === undefined)
746
+ return;
747
+ if ('variable' in guard) {
748
+ note(guard.variable, typeof guard.value === 'boolean' ? 'boolean' : 'integer');
749
+ return;
750
+ }
751
+ if ('all' in guard) {
752
+ for (const child of guard.all)
753
+ walk(child);
754
+ return;
755
+ }
756
+ if ('any' in guard) {
757
+ for (const child of guard.any)
758
+ walk(child);
759
+ return;
760
+ }
761
+ walk(guard.not);
762
+ };
763
+ for (const transition of transitions) {
764
+ walk(transition.guard);
765
+ for (const update of transition.updates ?? [])
766
+ note(update.variable, 'integer');
767
+ }
768
+ return [...kinds.entries()].map(([name, kind]) => ({ name, kind, init: kind === 'boolean' ? false : 0 }));
769
+ }
770
+ function parseStateDiagram(text, notation) {
771
+ const lines = text.split(/\r?\n/);
772
+ const { directives, body } = readDirectives(lines, notation);
773
+ const raw = { states: [], display: new Map(), edges: [], terminals: [], finalMarks: [], warnings: [] };
774
+ const declared = new Set();
775
+ const declare = (name) => { if (!declared.has(name)) {
776
+ declared.add(name);
777
+ raw.states.push(name);
778
+ } };
779
+ const skip = notation === 'mermaid'
780
+ ? /^(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|%%\{)/
781
+ : /^(@startuml|@enduml|scale\b|skinparam\b|title\b|hide\b|left to right direction|top to bottom direction|autonumber|!theme)/;
782
+ for (const rawLine of body) {
783
+ const line = rawLine.trim();
784
+ if (line === '' || (line.startsWith('--') && !line.includes('-->')))
785
+ continue;
786
+ if (skip.test(line))
787
+ continue;
788
+ const note = /^note\s+(?:over|right of|left of)\s+([A-Za-z_][A-Za-z0-9_]*)\s*:\s*(.+)$/i.exec(line);
789
+ if (note !== null) {
790
+ declare(note[1]);
791
+ const existing = raw.display.get(note[1]);
792
+ if (existing === undefined)
793
+ raw.display.set(note[1], note[2].trim());
794
+ continue;
795
+ }
796
+ if (/^note\b/i.test(line) || /^end\s*note$/i.test(line))
797
+ continue;
798
+ const stateDecl = /^state\s+"([^"]*)"\s+as\s+([A-Za-z_][A-Za-z0-9_]*)$/.exec(line);
799
+ if (stateDecl !== null) {
800
+ declare(stateDecl[2]);
801
+ raw.display.set(stateDecl[2], stateDecl[1]);
802
+ continue;
803
+ }
804
+ const bareState = /^state\s+([A-Za-z_][A-Za-z0-9_]*)\s*\{?$/.exec(line);
805
+ if (bareState !== null) {
806
+ declare(bareState[1]);
807
+ continue;
808
+ }
809
+ const concurrency = /^\}\s*$|^--\s*$/.test(line);
810
+ if (concurrency) {
811
+ raw.warnings.push('UML_PARSE_CONCURRENCY_FLATTENED: a concurrency region or composite block was flattened; LogicModelV1 has no region construct (use logicprobe_compose_verify for parallel machines).');
812
+ continue;
813
+ }
814
+ const composite = /^state\s+(.+)\s*\{$/.exec(line);
815
+ if (composite !== null) {
816
+ raw.warnings.push('UML_PARSE_COMPOSITE_FLATTENED: composite state "' + composite[1].trim() + '" was flattened into its members.');
817
+ continue;
818
+ }
819
+ const description = /^([A-Za-z_][A-Za-z0-9_]*)\s*:\s*(.+)$/.exec(line);
820
+ if (description !== null) {
821
+ declare(description[1]);
822
+ if (!raw.display.has(description[1]))
823
+ raw.display.set(description[1], description[2].trim());
824
+ continue;
825
+ }
826
+ const edge = /^(.+?)\s*-->\s*(.+?)(?:\s*:\s*(.*))?$/.exec(line);
827
+ if (edge !== null) {
828
+ const from = edge[1].trim();
829
+ const to = edge[2].trim();
830
+ const label = (edge[3] ?? '').trim();
831
+ if (from === '[*]') {
832
+ if (raw.initialState !== undefined && raw.initialState !== to) {
833
+ raw.warnings.push('UML_PARSE_MULTIPLE_INIT: the diagram enters ' + raw.initialState + ' and ' + to + ' from initial pseudostates; LogicModelV1 has one init, so ' + raw.initialState + ' is kept.');
834
+ }
835
+ else {
836
+ raw.initialState = to;
837
+ }
838
+ continue;
839
+ }
840
+ if (to === '[*]') {
841
+ raw.finalMarks.push(from);
842
+ continue;
843
+ }
844
+ declare(from);
845
+ declare(to);
846
+ raw.edges.push({ from, to, label });
847
+ continue;
848
+ }
849
+ raw.warnings.push('UML_PARSE_IGNORED_LINE: "' + line + '" is not a state diagram statement; it was ignored.');
850
+ }
851
+ const model = rawToModel(raw, directives, raw.warnings);
852
+ const labels = mapLabels(raw.display, directives);
853
+ return { notation, diagram: 'state', model, labels, warnings: raw.warnings };
854
+ }
855
+ function parseActivityDiagram(text, notation) {
856
+ const lines = text.split(/\r?\n/);
857
+ const { directives, body } = readDirectives(lines, notation);
858
+ const raw = { states: [], display: new Map(), edges: [], terminals: [], finalMarks: [], warnings: [] };
859
+ const declared = new Set();
860
+ const declare = (name) => { if (!declared.has(name)) {
861
+ declared.add(name);
862
+ raw.states.push(name);
863
+ } };
864
+ const skip = /^(flowchart|graph)\b|^(classDef|class|style|linkStyle|click|direction)\b|^%%\{/;
865
+ let inSubgraph = false;
866
+ for (const rawLine of body) {
867
+ const line = rawLine.trim();
868
+ if (line === '' || skip.test(line))
869
+ continue;
870
+ if (/^subgraph\b/.test(line)) {
871
+ if (!inSubgraph) {
872
+ inSubgraph = true;
873
+ raw.warnings.push('UML_PARSE_SUBGRAPH_FLATTENED: subgraph blocks were flattened; LogicModelV1 has no hierarchy.');
874
+ }
875
+ continue;
876
+ }
877
+ if (line === 'end') {
878
+ inSubgraph = false;
879
+ continue;
880
+ }
881
+ const shaped = /^(.*?)\s*-->\s*\|(.*?)\|\s*(.+)$/.exec(line);
882
+ const bare = shaped === null ? /^(.*?)\s*-->\s*(.+)$/.exec(line) : null;
883
+ if (shaped !== null || bare !== null) {
884
+ const match = (shaped ?? bare);
885
+ const from = stripNode(match[1].trim(), raw, declare);
886
+ // Flowchart labels live between bars; the state-diagram form `A --> B : label`
887
+ // is accepted too so a hand-written file that mixes the two still reads.
888
+ let rawTo = (shaped === null ? match[2] : match[3]).trim();
889
+ let label = shaped === null ? '' : shaped[2].replace(/^"|"$/g, '').trim();
890
+ if (shaped === null) {
891
+ const colon = rawTo.indexOf(':');
892
+ if (colon >= 0) {
893
+ label = rawTo.slice(colon + 1).trim().replace(/^"|"$/g, '');
894
+ rawTo = rawTo.slice(0, colon).trim();
895
+ }
896
+ }
897
+ const to = stripNode(rawTo, raw, declare);
898
+ if (from === undefined || to === undefined)
899
+ continue;
900
+ raw.edges.push({ from, to, label });
901
+ continue;
902
+ }
903
+ const node = stripNode(line, raw, declare);
904
+ if (node === undefined)
905
+ raw.warnings.push('UML_PARSE_IGNORED_LINE: "' + line + '" is not a flowchart statement; it was ignored.');
906
+ }
907
+ const model = rawToModel(raw, directives, raw.warnings);
908
+ const labels = mapLabels(raw.display, directives);
909
+ return { notation, diagram: 'activity', model, labels, warnings: raw.warnings };
910
+ }
911
+ /**
912
+ * Read one node reference (`A`, `A["label"]`, `A(["label"])`) and register the id
913
+ * plus any display label it carries. Returns the node name, or undefined when the
914
+ * text is not a node reference at all.
915
+ */
916
+ function stripNode(text, raw, declare) {
917
+ const trimmed = text.trim();
918
+ if (trimmed === '')
919
+ return undefined;
920
+ const match = /^([A-Za-z_][A-Za-z0-9_.-]*)\s*(\(\[|\[\(|\{\{|\[|\(|\(\(|>)?\s*([\s\S]*?)\s*$/.exec(trimmed);
921
+ if (match === null) {
922
+ const quoted = /^"([^"]+)"$/.exec(trimmed);
923
+ if (quoted !== null) {
924
+ declare(quoted[1]);
925
+ return quoted[1];
926
+ }
927
+ return undefined;
928
+ }
929
+ const name = match[1];
930
+ const shape = match[2] ?? '';
931
+ const rest = match[3] ?? '';
932
+ declare(name);
933
+ const quoted = /"([^"]*)"/.exec(rest);
934
+ const label = quoted !== null ? quoted[1].trim() : rest.replace(/^[\[\](){}>]+/, '').replace(/[\[\](){}>]+$/, '').trim();
935
+ if (label !== '' && !raw.display.has(name))
936
+ raw.display.set(name, label);
937
+ if (shape === '([' || shape === '((' || rest.startsWith('(['))
938
+ raw.terminals.push(name);
939
+ return name;
940
+ }
941
+ function mapLabels(display, directives) {
942
+ const out = {};
943
+ for (const [name, label] of display)
944
+ out[directives.aliases.get(name) ?? name] = label;
945
+ return out;
946
+ }
947
+ /**
948
+ * Parse a Mermaid or PlantUML diagram back into a LogicModelV1.
949
+ *
950
+ * State and activity diagrams carry the whole machine, so they parse into a
951
+ * complete model. Sequence diagrams do not: a trace shows the paths that were
952
+ * walked, not the branches that were not, so parsing one would silently prune
953
+ * the machine. That case is refused rather than approximated.
954
+ *
955
+ * @param text - diagram source.
956
+ * @param notation - `auto` (default) detects Mermaid vs PlantUML from the text.
957
+ */
958
+ export function parseUml(text, notation = 'auto') {
959
+ const resolved = notation === 'auto' ? detectNotation(text) : notation;
960
+ const kind = detectDiagram(text);
961
+ if (kind === 'sequence') {
962
+ throw new UmlError('a sequence diagram is a trace, not a machine: parsing it would drop every branch the trace did not walk. Render diagram "state" or "activity" and parse that instead.');
963
+ }
964
+ return kind === 'state' ? parseStateDiagram(text, resolved) : parseActivityDiagram(text, resolved);
965
+ }
966
+ // ---------------------------------------------------------------------------
967
+ // Review
968
+ // ---------------------------------------------------------------------------
969
+ function reachableStates(model) {
970
+ const adjacency = new Map();
971
+ for (const transition of model.transitions) {
972
+ const list = adjacency.get(transition.from);
973
+ if (list === undefined)
974
+ adjacency.set(transition.from, [transition.to]);
975
+ else
976
+ list.push(transition.to);
977
+ }
978
+ const visited = new Set([model.init]);
979
+ const queue = [model.init];
980
+ while (queue.length > 0) {
981
+ const current = queue.shift();
982
+ for (const next of adjacency.get(current) ?? []) {
983
+ if (!visited.has(next)) {
984
+ visited.add(next);
985
+ queue.push(next);
986
+ }
987
+ }
988
+ }
989
+ return visited;
990
+ }
991
+ function canonicalTransition(transition) {
992
+ return transition.from + '|' + transition.event + '|' + transition.to + '|' + (transition.guard === undefined ? '' : guardText(transition.guard)) + '|' + (transition.updates === undefined ? '' : updatesText(transition.updates));
993
+ }
994
+ /** The narrative meaning a diagram label carries, if it carries one beyond the bare id. */
995
+ function documentedMeaning(label, id) {
996
+ if (label === undefined)
997
+ return undefined;
998
+ const trimmed = label.trim();
999
+ if (trimmed === '' || trimmed === id)
1000
+ return undefined;
1001
+ for (const [open, close] of [['(', ')'], ['(', ')']]) {
1002
+ const prefix = id + open;
1003
+ if (trimmed.startsWith(prefix) && trimmed.endsWith(close))
1004
+ return trimmed.slice(prefix.length, trimmed.length - close.length);
1005
+ }
1006
+ return trimmed;
1007
+ }
1008
+ function structuralFindings(model, labels, warnings) {
1009
+ const findings = [];
1010
+ const reachable = reachableStates(model);
1011
+ const outgoing = new Map();
1012
+ for (const transition of model.transitions) {
1013
+ const list = outgoing.get(transition.from);
1014
+ if (list === undefined)
1015
+ outgoing.set(transition.from, [transition]);
1016
+ else
1017
+ list.push(transition);
1018
+ }
1019
+ const unreachable = model.states.map((state) => state.id).filter((id) => !reachable.has(id));
1020
+ if (unreachable.length > 0) {
1021
+ findings.push({
1022
+ code: 'UML002_UNREACHABLE_STATE',
1023
+ severity: 'error',
1024
+ message: unreachable.length + ' state(s) cannot be entered from init along any transition, so the diagram draws flow nobody can reach.',
1025
+ states: unreachable,
1026
+ detail: 'Structural reachability (guards ignored). Guard-aware reachability is S1 in logicprobe_verify.',
1027
+ });
1028
+ }
1029
+ const deadEnds = model.states.filter((state) => state.terminal !== true && (outgoing.get(state.id) ?? []).length === 0).map((state) => state.id);
1030
+ if (deadEnds.length > 0) {
1031
+ findings.push({
1032
+ code: 'UML003_DEAD_END_STATE',
1033
+ severity: 'error',
1034
+ message: deadEnds.length + ' non-terminal state(s) have no outgoing transition: the flow stops there without a modelled terminal.',
1035
+ states: deadEnds,
1036
+ detail: 'Either the state is terminal (add `X --> [*]`) or the outgoing flow is missing from the model. logicprobe_verify S2 reports the same shape at runtime granularity.',
1037
+ });
1038
+ }
1039
+ const groups = new Map();
1040
+ for (const transition of model.transitions) {
1041
+ const key = transition.from + '\u0000' + transition.event;
1042
+ const list = groups.get(key);
1043
+ if (list === undefined)
1044
+ groups.set(key, [transition]);
1045
+ else
1046
+ list.push(transition);
1047
+ }
1048
+ const ambiguous = [];
1049
+ const overlapping = [];
1050
+ const inexhaustive = [];
1051
+ const probablyExhaustive = [];
1052
+ const complementary = [];
1053
+ for (const group of groups.values()) {
1054
+ const unguarded = group.filter((transition) => transition.guard === undefined);
1055
+ const guarded = group.filter((transition) => transition.guard !== undefined);
1056
+ if (unguarded.length > 1) {
1057
+ for (const transition of unguarded)
1058
+ ambiguous.push({ from: transition.from, event: transition.event, to: transition.to });
1059
+ }
1060
+ const seenGuards = new Map();
1061
+ for (const transition of guarded) {
1062
+ const text = guardText(transition.guard);
1063
+ const previous = seenGuards.get(text);
1064
+ if (previous !== undefined)
1065
+ overlapping.push({ from: transition.from, event: transition.event, to: transition.to });
1066
+ else
1067
+ seenGuards.set(text, transition);
1068
+ }
1069
+ if (guarded.length > 0 && unguarded.length === 0) {
1070
+ const row = { from: group[0].from, event: group[0].event, to: group[0].to };
1071
+ // A complementary pair on one variable (`x < 3` / `x >= 3`) is exhaustive for
1072
+ // any valuation, so the structural warning would be a false alarm there. The
1073
+ // pair test is deliberately narrow — it cannot prove exhaustiveness, only
1074
+ // recognise the common shape, which is why the finding stays on the report at
1075
+ // info severity and still routes to S6.
1076
+ const witness = complementaryVariable(guarded.map((transition) => transition.guard));
1077
+ if (witness === undefined)
1078
+ inexhaustive.push(row);
1079
+ else {
1080
+ probablyExhaustive.push(row);
1081
+ complementary.push(witness);
1082
+ }
1083
+ }
1084
+ }
1085
+ if (ambiguous.length > 0) {
1086
+ findings.push({
1087
+ code: 'UML004_AMBIGUOUS_BRANCH',
1088
+ severity: 'error',
1089
+ message: ambiguous.length + ' branch(es) share a (state, event) with no guard at all: the diagram shows two unconditional arrows for one event, which no reader can resolve.',
1090
+ transitions: ambiguous,
1091
+ detail: 'Keep one unguarded branch per (state, event) as the else case, and guard the others. logicprobe_verify S4 is the authoritative determinism check.',
1092
+ });
1093
+ }
1094
+ if (overlapping.length > 0) {
1095
+ findings.push({
1096
+ code: 'UML005_OVERLAPPING_GUARD',
1097
+ severity: 'warning',
1098
+ message: overlapping.length + ' transition(s) repeat a guard already used by another branch of the same (state, event).',
1099
+ transitions: overlapping,
1100
+ });
1101
+ }
1102
+ if (inexhaustive.length > 0) {
1103
+ findings.push({
1104
+ code: 'UML006_INEXHAUSTIVE_BRANCH',
1105
+ severity: 'warning',
1106
+ message: inexhaustive.length + ' (state, event) group(s) have only guarded branches and no default: if every guard is false the flow vanishes, and the diagram still implies coverage.',
1107
+ transitions: inexhaustive,
1108
+ detail: 'Add an unguarded else branch, or confirm exhaustiveness with logicprobe_verify S6 (which evaluates guards over real valuations).',
1109
+ });
1110
+ }
1111
+ if (probablyExhaustive.length > 0) {
1112
+ findings.push({
1113
+ code: 'UML006_INEXHAUSTIVE_BRANCH',
1114
+ severity: 'info',
1115
+ message: probablyExhaustive.length + ' (state, event) group(s) have guards that look complementary on ' + [...new Set(complementary)].join(', ') + ', so they are probably exhaustive — but no default branch exists and only logicprobe_verify S6 can settle it.',
1116
+ transitions: probablyExhaustive,
1117
+ });
1118
+ }
1119
+ const eventsByReachable = new Set();
1120
+ const eventsAnywhere = new Set();
1121
+ for (const transition of model.transitions) {
1122
+ eventsAnywhere.add(transition.event);
1123
+ if (reachable.has(transition.from))
1124
+ eventsByReachable.add(transition.event);
1125
+ }
1126
+ const deadEvents = [...eventsAnywhere].filter((event) => !eventsByReachable.has(event));
1127
+ if (deadEvents.length > 0) {
1128
+ findings.push({
1129
+ code: 'UML007_UNUSED_EVENT',
1130
+ severity: 'warning',
1131
+ message: deadEvents.length + ' event(s) only fire from states nothing can reach, so the diagram shows messages that never arrive.',
1132
+ events: deadEvents,
1133
+ });
1134
+ }
1135
+ const selfLoops = model.transitions.filter((transition) => transition.from === transition.to && transition.guard === undefined
1136
+ && (outgoing.get(transition.from) ?? []).length === 1);
1137
+ if (selfLoops.length > 0) {
1138
+ findings.push({
1139
+ code: 'UML008_SELF_LOOP_NO_EXIT',
1140
+ severity: 'warning',
1141
+ message: selfLoops.length + ' state(s) have a single unguarded self-loop and no exit: the flow can never leave, which the diagram presents as activity.',
1142
+ states: [...new Set(selfLoops.map((transition) => transition.from))],
1143
+ detail: 'logicprobe_verify S3 reports absorbing cycles (liveness).',
1144
+ });
1145
+ }
1146
+ const seenTransitions = new Map();
1147
+ for (const transition of model.transitions) {
1148
+ const key = canonicalTransition(transition);
1149
+ seenTransitions.set(key, (seenTransitions.get(key) ?? 0) + 1);
1150
+ }
1151
+ const duplicates = model.transitions.filter((transition) => (seenTransitions.get(canonicalTransition(transition)) ?? 0) > 1);
1152
+ if (duplicates.length > 0) {
1153
+ findings.push({
1154
+ code: 'UML009_DUPLICATE_TRANSITION',
1155
+ severity: 'warning',
1156
+ message: duplicates.length + ' transition(s) duplicate an identical (from, event, guard, actions, to) row; the diagram draws the same arrow twice.',
1157
+ transitions: duplicates.map((transition) => ({ from: transition.from, event: transition.event, to: transition.to })),
1158
+ });
1159
+ }
1160
+ const guardVariables = new Set();
1161
+ for (const transition of model.transitions) {
1162
+ if (transition.guard !== undefined)
1163
+ collectGuardVariables(transition.guard, guardVariables);
1164
+ }
1165
+ const updatedVariables = new Set();
1166
+ for (const transition of model.transitions)
1167
+ for (const update of transition.updates ?? [])
1168
+ updatedVariables.add(update.variable);
1169
+ const unusedVariables = (model.variables ?? []).map((variable) => variable.name).filter((name) => !guardVariables.has(name) && !updatedVariables.has(name));
1170
+ if (unusedVariables.length > 0) {
1171
+ findings.push({
1172
+ code: 'UML010_UNUSED_VARIABLE',
1173
+ severity: 'warning',
1174
+ message: unusedVariables.length + ' variable(s) are never read by a guard and never written: the diagram carries a symbol with no source.',
1175
+ events: unusedVariables,
1176
+ });
1177
+ }
1178
+ const unbounded = (model.variables ?? []).filter((variable) => variable.kind === 'integer' && (variable.min === undefined || variable.max === undefined)).map((variable) => variable.name);
1179
+ if (unbounded.length > 0) {
1180
+ findings.push({
1181
+ code: 'UML011_UNBOUNDED_VARIABLE',
1182
+ severity: 'info',
1183
+ message: unbounded.length + ' integer variable(s) declare no min/max, so no range invariant can be checked and A5 boundary probing has no declared domain.',
1184
+ detail: unbounded.join(', '),
1185
+ });
1186
+ }
1187
+ const terminals = model.states.filter((state) => state.terminal === true).map((state) => state.id);
1188
+ if (terminals.length === 0) {
1189
+ findings.push({
1190
+ code: 'UML012_NO_TERMINAL',
1191
+ severity: 'warning',
1192
+ message: 'no state is terminal: the diagram has no `--> [*]`, so completion, failure and a stuck flow look the same.',
1193
+ detail: 'Mark absorbing states terminal, or state explicitly that the machine is non-terminating.',
1194
+ });
1195
+ }
1196
+ if (model.narrative === undefined) {
1197
+ findings.push({
1198
+ code: 'UML013_NO_NARRATIVE',
1199
+ severity: 'info',
1200
+ message: 'the model carries no narrative block: no state, event or scenario has a natural-language meaning, so a reader must re-derive every symbol from the source.',
1201
+ detail: 'Add narrative.states / narrative.events / narrative.scenarios — the schema requires all three and full coverage once the block is present.',
1202
+ });
1203
+ }
1204
+ const documented = labels === undefined ? undefined : Object.keys(labels).filter((id) => documentedMeaning(labels[id], id) !== undefined);
1205
+ if (documented !== undefined) {
1206
+ const undocumented = model.states.map((state) => state.id).filter((id) => documentedMeaning(labels?.[id], id) === undefined);
1207
+ if (undocumented.length > 0) {
1208
+ findings.push({
1209
+ code: 'UML014_UNDOCUMENTED_STATE',
1210
+ severity: 'info',
1211
+ message: undocumented.length + ' of ' + String(model.states.length) + ' states carry no meaning in the diagram (they render as their bare id).',
1212
+ states: undocumented,
1213
+ detail: 'Give each state a `state "meaning" as ID` label or `ID : meaning` description so the diagram can be read against the code.',
1214
+ });
1215
+ }
1216
+ if (model.narrative?.states !== undefined) {
1217
+ const drift = [];
1218
+ for (const state of model.states) {
1219
+ const meaning = documentedMeaning(labels?.[state.id], state.id);
1220
+ const declared = model.narrative.states[state.id];
1221
+ if (meaning !== undefined && declared !== undefined && meaning !== declared)
1222
+ drift.push(state.id + ': diagram "' + meaning + '" vs narrative "' + declared + '"');
1223
+ }
1224
+ if (drift.length > 0) {
1225
+ findings.push({
1226
+ code: 'UML015_LABEL_DRIFT',
1227
+ severity: 'warning',
1228
+ message: drift.length + ' state label(s) disagree with the model narrative: one of the two is stale, and the review cannot tell which.',
1229
+ detail: drift.join(' | '),
1230
+ });
1231
+ }
1232
+ }
1233
+ }
1234
+ if (warnings.length > 0) {
1235
+ findings.push({
1236
+ code: 'UML016_DIAGRAM_PARSE_NOTES',
1237
+ severity: 'info',
1238
+ message: warnings.length + ' note(s) were produced while rendering or reading the diagram; they mark information the notation could not carry.',
1239
+ detail: warnings.join(' | '),
1240
+ });
1241
+ }
1242
+ return findings;
1243
+ }
1244
+ function collectGuardVariables(guard, sink) {
1245
+ if ('variable' in guard) {
1246
+ sink.add(guard.variable);
1247
+ return;
1248
+ }
1249
+ if ('all' in guard) {
1250
+ for (const child of guard.all)
1251
+ collectGuardVariables(child, sink);
1252
+ return;
1253
+ }
1254
+ if ('any' in guard) {
1255
+ for (const child of guard.any)
1256
+ collectGuardVariables(child, sink);
1257
+ return;
1258
+ }
1259
+ collectGuardVariables(guard.not, sink);
1260
+ }
1261
+ /** Positive, conjunctively-reached leaves of a guard tree — the only ones a complementarity witness may use. */
1262
+ function positiveLeaves(guard, sink) {
1263
+ if ('variable' in guard) {
1264
+ sink.push(guard);
1265
+ return;
1266
+ }
1267
+ if ('all' in guard) {
1268
+ for (const child of guard.all)
1269
+ positiveLeaves(child, sink);
1270
+ return;
1271
+ }
1272
+ // `any` and `not` subtrees are skipped on purpose: a leaf under a disjunction is
1273
+ // not implied by its branch, and `not (x < 3)` is `x >= 3` only for totally
1274
+ // ordered integers — neither is a sound complementarity witness.
1275
+ }
1276
+ const COMPLEMENTS = [['<', '>='], ['<=', '>'], ['==', '!=']];
1277
+ /**
1278
+ * Name a variable whose guards contain a complementary pair (`x < 3` next to
1279
+ * `x >= 3`), which makes the branch group exhaustive for every valuation. Returns
1280
+ * undefined when no such pair exists — absence is not proof of a gap.
1281
+ */
1282
+ function complementaryVariable(guards) {
1283
+ const leaves = [];
1284
+ for (const guard of guards)
1285
+ positiveLeaves(guard, leaves);
1286
+ for (let i = 0; i < leaves.length; i += 1) {
1287
+ for (let j = i + 1; j < leaves.length; j += 1) {
1288
+ const left = leaves[i];
1289
+ const right = leaves[j];
1290
+ if (left.variable !== right.variable)
1291
+ continue;
1292
+ if (left.value !== right.value)
1293
+ continue;
1294
+ for (const [one, other] of COMPLEMENTS) {
1295
+ if ((left.op === one && right.op === other) || (left.op === other && right.op === one))
1296
+ return left.variable;
1297
+ }
1298
+ }
1299
+ }
1300
+ return undefined;
1301
+ }
1302
+ function compiledModel(input) {
1303
+ const validation = validateModel(input);
1304
+ if (!validation.ok)
1305
+ throw new UmlError('model invalid: ' + validation.errors.join('; '));
1306
+ return validation.model;
1307
+ }
1308
+ function roundTripOf(model, notation, diagram, maxSteps) {
1309
+ const rendered = renderUml(model, notation, diagram, maxSteps);
1310
+ const parsed = parseUml(rendered.primary, notation);
1311
+ const diffs = diffModels(model, parsed.model);
1312
+ return {
1313
+ report: {
1314
+ notation,
1315
+ diagram,
1316
+ ok: diffs.length === 0,
1317
+ modelHash: modelHash(model),
1318
+ parsedHash: modelHash(parsed.model),
1319
+ diffs,
1320
+ warnings: [...rendered.warnings, ...parsed.warnings],
1321
+ },
1322
+ primary: rendered.primary,
1323
+ };
1324
+ }
1325
+ /** Compare two machines by structure — the fidelity measure behind the round-trip check. */
1326
+ export function diffModels(left, right) {
1327
+ const diffs = [];
1328
+ if (left.init !== right.init)
1329
+ diffs.push('init: ' + left.init + ' vs ' + right.init);
1330
+ const leftStates = left.states.map((state) => state.id);
1331
+ const rightStates = right.states.map((state) => state.id);
1332
+ for (const id of leftStates)
1333
+ if (!rightStates.includes(id))
1334
+ diffs.push('state missing after parse: ' + id);
1335
+ for (const id of rightStates)
1336
+ if (!leftStates.includes(id))
1337
+ diffs.push('state invented by the diagram: ' + id);
1338
+ const leftTerminal = left.states.filter((state) => state.terminal === true).map((state) => state.id).sort();
1339
+ const rightTerminal = right.states.filter((state) => state.terminal === true).map((state) => state.id).sort();
1340
+ if (leftTerminal.join(',') !== rightTerminal.join(','))
1341
+ diffs.push('terminal states: [' + leftTerminal.join(', ') + '] vs [' + rightTerminal.join(', ') + ']');
1342
+ const leftTransitions = left.transitions.map(canonicalTransition).sort();
1343
+ const rightTransitions = right.transitions.map(canonicalTransition).sort();
1344
+ const leftCount = new Map();
1345
+ for (const key of leftTransitions)
1346
+ leftCount.set(key, (leftCount.get(key) ?? 0) + 1);
1347
+ const rightCount = new Map();
1348
+ for (const key of rightTransitions)
1349
+ rightCount.set(key, (rightCount.get(key) ?? 0) + 1);
1350
+ for (const [key, count] of leftCount) {
1351
+ const other = rightCount.get(key) ?? 0;
1352
+ if (other < count)
1353
+ diffs.push('transition lost in the diagram (' + String(count - other) + 'x): ' + key.replace(/\|/g, ' '));
1354
+ }
1355
+ for (const [key, count] of rightCount) {
1356
+ const other = leftCount.get(key) ?? 0;
1357
+ if (other < count)
1358
+ diffs.push('transition invented by the diagram (' + String(count - other) + 'x): ' + key.replace(/\|/g, ' '));
1359
+ }
1360
+ const leftVariables = (left.variables ?? []).map((variable) => variable.name + ':' + variable.kind).sort();
1361
+ const rightVariables = (right.variables ?? []).map((variable) => variable.name + ':' + variable.kind).sort();
1362
+ for (const name of leftVariables)
1363
+ if (!rightVariables.includes(name))
1364
+ diffs.push('variable missing after parse: ' + name);
1365
+ for (const name of rightVariables)
1366
+ if (!leftVariables.includes(name))
1367
+ diffs.push('variable invented by the diagram: ' + name);
1368
+ return diffs;
1369
+ }
1370
+ /**
1371
+ * Review a UML model of a code flow.
1372
+ *
1373
+ * Three inputs are possible and each answers a different question:
1374
+ *
1375
+ * - `model` only — "is this machine well-modelled?" The review checks the
1376
+ * structure the diagram would draw, then renders and re-parses it to prove the
1377
+ * diagram carries the machine faithfully (round trip).
1378
+ * - `diagram` only — "what does this diagram actually say?" The diagram is parsed
1379
+ * into a model, and that model is reviewed; nothing can be said about fidelity
1380
+ * to a machine the caller did not provide.
1381
+ * - both — "does this diagram match this model?" Any structural difference is a
1382
+ * modelling defect and is reported both as round-trip diffs and as a finding.
1383
+ */
1384
+ export function reviewUml(options) {
1385
+ const warnings = [];
1386
+ const findings = [];
1387
+ const hasModel = options.model !== undefined;
1388
+ const hasDiagram = typeof options.diagram === 'string' && options.diagram.trim() !== '';
1389
+ if (!hasModel && !hasDiagram)
1390
+ throw new UmlError('review needs `model`, `diagram`, or both');
1391
+ let notation = options.notation === undefined || options.notation === 'auto' ? 'mermaid' : options.notation;
1392
+ const kind = options.diagramKind ?? 'state';
1393
+ let model;
1394
+ let labels;
1395
+ let parsed;
1396
+ let primary;
1397
+ let roundTrip = null;
1398
+ if (hasDiagram) {
1399
+ try {
1400
+ parsed = parseUml(options.diagram, options.notation ?? 'auto');
1401
+ }
1402
+ catch (error) {
1403
+ const message = error instanceof Error ? error.message : String(error);
1404
+ return {
1405
+ ok: false,
1406
+ source: hasModel ? 'model+diagram' : 'diagram',
1407
+ summary: { errors: 1, warnings: 0, info: 0, states: 0, events: 0, transitions: 0, terminalStates: 0, reachableStates: 0, documentedStates: 0 },
1408
+ findings: [{ code: 'UML001_DIAGRAM_UNREADABLE', severity: 'error', message, detail: 'The diagram could not be read as a Mermaid/PlantUML state or activity diagram.' }],
1409
+ roundTrip: null,
1410
+ warnings,
1411
+ nextSteps: ['Fix the diagram syntax (or render one from a model with logicprobe_uml action=render) and review again.'],
1412
+ };
1413
+ }
1414
+ notation = parsed.notation;
1415
+ labels = parsed.labels;
1416
+ warnings.push(...parsed.warnings);
1417
+ if (hasModel) {
1418
+ model = compiledModel(options.model);
1419
+ const diffs = diffModels(model, parsed.model);
1420
+ roundTrip = {
1421
+ notation: parsed.notation,
1422
+ diagram: parsed.diagram,
1423
+ ok: diffs.length === 0,
1424
+ modelHash: modelHash(model),
1425
+ parsedHash: modelHash(parsed.model),
1426
+ diffs,
1427
+ warnings: [...parsed.warnings],
1428
+ };
1429
+ if (diffs.length > 0) {
1430
+ findings.push({
1431
+ code: 'UML017_ROUND_TRIP_MISMATCH',
1432
+ severity: 'error',
1433
+ message: 'the diagram does not carry the model it is presented with: ' + String(diffs.length) + ' structural difference(s).',
1434
+ detail: diffs.slice(0, 12).join(' | ') + (diffs.length > 12 ? ' | … ' + String(diffs.length - 12) + ' more' : ''),
1435
+ });
1436
+ }
1437
+ if (diffs.length === 0 && labels !== undefined && model.narrative === undefined) {
1438
+ warnings.push('UML_REVIEW_DIAGRAM_LABELS_IGNORED_BY_MODEL: the diagram carries state labels but the model has no narrative block, so the labels live only in the diagram.');
1439
+ }
1440
+ }
1441
+ else {
1442
+ model = parsed.model;
1443
+ findings.push({
1444
+ code: 'UML018_FIDELITY_UNCHECKED',
1445
+ severity: 'info',
1446
+ 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.',
1447
+ detail: 'Compare the parsed model against the code (each state/event/guard needs a source citation), or pass the machine alongside the diagram to check the two against each other.',
1448
+ });
1449
+ }
1450
+ }
1451
+ else {
1452
+ model = compiledModel(options.model);
1453
+ // A sequence view is a trace, so parsing it back would drop every branch the
1454
+ // walk never took: the fidelity check cannot apply to it, and pretending it did
1455
+ // would either fail spuriously or hide the difference. Render it anyway (the
1456
+ // caller asked for that view) and say the check does not apply.
1457
+ if (kind === 'sequence') {
1458
+ try {
1459
+ const rendered = renderUml(model, notation, kind, options.maxSteps ?? 60);
1460
+ primary = rendered.primary;
1461
+ warnings.push(...rendered.warnings);
1462
+ }
1463
+ catch (error) {
1464
+ warnings.push('UML_REVIEW_RENDER_SKIPPED: ' + (error instanceof Error ? error.message : String(error)));
1465
+ }
1466
+ findings.push({
1467
+ code: 'UML019_ROUND_TRIP_SKIPPED',
1468
+ severity: 'warning',
1469
+ message: 'a sequence view is one trace, not a restatement of the machine, so the render/parse fidelity check does not apply to it; render diagram "state" or "activity" to have the diagram checked against the model.',
1470
+ });
1471
+ }
1472
+ else if (options.roundTrip !== false) {
1473
+ try {
1474
+ const rendered = roundTripOf(model, notation, kind, options.maxSteps ?? 60);
1475
+ primary = rendered.primary;
1476
+ roundTrip = rendered.report;
1477
+ warnings.push(...rendered.report.warnings);
1478
+ if (!rendered.report.ok) {
1479
+ findings.push({
1480
+ code: 'UML017_ROUND_TRIP_MISMATCH',
1481
+ severity: 'error',
1482
+ message: 'the rendered diagram does not read back as the model: ' + String(rendered.report.diffs.length) + ' structural difference(s).',
1483
+ detail: rendered.report.diffs.slice(0, 12).join(' | '),
1484
+ });
1485
+ }
1486
+ }
1487
+ catch (error) {
1488
+ const message = error instanceof Error ? error.message : String(error);
1489
+ warnings.push('UML_REVIEW_ROUND_TRIP_SKIPPED: ' + message);
1490
+ findings.push({
1491
+ code: 'UML019_ROUND_TRIP_SKIPPED',
1492
+ severity: 'warning',
1493
+ message: 'the fidelity check could not run for ' + notation + '/' + kind + ': ' + message,
1494
+ });
1495
+ }
1496
+ }
1497
+ else {
1498
+ findings.push({
1499
+ code: 'UML019_ROUND_TRIP_SKIPPED',
1500
+ severity: 'warning',
1501
+ message: 'the fidelity check was switched off (roundTrip=false); nothing here proves the diagram carries the model.',
1502
+ });
1503
+ }
1504
+ }
1505
+ findings.push(...structuralFindings(model, labels, warnings));
1506
+ const errors = findings.filter((finding) => finding.severity === 'error').length;
1507
+ const warningCount = findings.filter((finding) => finding.severity === 'warning').length;
1508
+ const info = findings.filter((finding) => finding.severity === 'info').length;
1509
+ const reachable = reachableStates(model);
1510
+ const documentedStates = labels === undefined
1511
+ ? (model.narrative?.states === undefined ? 0 : Object.keys(model.narrative.states).length)
1512
+ : Object.keys(labels).filter((id) => documentedMeaning(labels?.[id], id) !== undefined).length;
1513
+ const events = new Set(model.transitions.map((transition) => transition.event));
1514
+ const nextSteps = [];
1515
+ if (errors > 0)
1516
+ nextSteps.push('Resolve the error findings first — a diagram that cannot be read (or that disagrees with its model) will mislead every later review.');
1517
+ nextSteps.push('Run logicprobe_verify on this model for the behavioural checks (S1-S8 structural, A1-A14 adversarial); the review above covers modelling, not behaviour.');
1518
+ if (model.narrative === undefined)
1519
+ nextSteps.push('Add narrative.states/events/scenarios so the diagram is readable against the code.');
1520
+ if (findings.some((finding) => finding.code === 'UML011_UNBOUNDED_VARIABLE'))
1521
+ nextSteps.push('Declare min/max (or boundaryChecks) before relying on A5 boundary probes.');
1522
+ return {
1523
+ ok: true,
1524
+ source: hasModel && hasDiagram ? 'model+diagram' : (hasDiagram ? 'diagram' : 'model'),
1525
+ summary: {
1526
+ errors,
1527
+ warnings: warningCount,
1528
+ info,
1529
+ states: model.states.length,
1530
+ events: events.size,
1531
+ transitions: model.transitions.length,
1532
+ terminalStates: model.states.filter((state) => state.terminal === true).length,
1533
+ reachableStates: reachable.size,
1534
+ documentedStates,
1535
+ },
1536
+ findings,
1537
+ roundTrip,
1538
+ ...(labels === undefined ? {} : { labels }),
1539
+ ...(hasDiagram ? { model } : {}),
1540
+ ...(primary === undefined ? {} : { primary }),
1541
+ warnings,
1542
+ nextSteps,
1543
+ };
1544
+ }