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