dsh-model-everything 0.0.0-stage → 0.1.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.
Files changed (130) hide show
  1. package/LICENSE +21 -0
  2. package/README.en-US.md +319 -0
  3. package/README.md +319 -3
  4. package/cordis.patch.yml +20 -0
  5. package/examples/README.md +48 -0
  6. package/examples/control-dispatcher.json +113 -0
  7. package/examples/control-dispatcher.overbudget.json +113 -0
  8. package/examples/ecommerce-data-model.json +185 -0
  9. package/examples/order-state-machine.after.json +116 -0
  10. package/examples/order-state-machine.before.json +101 -0
  11. package/examples/user-migration.after.json +29 -0
  12. package/examples/user-migration.before.json +29 -0
  13. package/gate/locale/en.json +6 -0
  14. package/gate/locale/zh.json +6 -0
  15. package/gate/package.json +5 -0
  16. package/lib/baseline.js +249 -0
  17. package/lib/compose-tool.js +76 -0
  18. package/lib/concurrency-tool.js +40 -0
  19. package/lib/concurrency.js +206 -0
  20. package/lib/data-engine.js +1141 -0
  21. package/lib/data-tool.js +79 -0
  22. package/lib/design-engine.js +1149 -0
  23. package/lib/design-tool.js +55 -0
  24. package/lib/engine.js +4343 -0
  25. package/lib/export-tool.js +78 -0
  26. package/lib/exporters.js +434 -0
  27. package/lib/gate.js +274 -0
  28. package/lib/granularity.js +237 -0
  29. package/lib/index.js +237 -0
  30. package/lib/json-value.js +18 -0
  31. package/lib/option-shape.js +62 -0
  32. package/lib/report-diff-tool.js +63 -0
  33. package/lib/report-view.js +408 -0
  34. package/lib/structure-tool.js +91 -0
  35. package/lib/structure.js +711 -0
  36. package/lib/tool.js +85 -0
  37. package/lib/types/baseline.d.ts +65 -0
  38. package/lib/types/compose-tool.d.ts +7 -0
  39. package/lib/types/concurrency-tool.d.ts +8 -0
  40. package/lib/types/concurrency.d.ts +37 -0
  41. package/lib/types/data-engine.d.ts +211 -0
  42. package/lib/types/data-tool.d.ts +10 -0
  43. package/lib/types/design-engine.d.ts +215 -0
  44. package/lib/types/design-tool.d.ts +11 -0
  45. package/lib/types/engine.d.ts +494 -0
  46. package/lib/types/export-tool.d.ts +7 -0
  47. package/lib/types/exporters.d.ts +37 -0
  48. package/lib/types/gate.d.ts +91 -0
  49. package/lib/types/granularity.d.ts +106 -0
  50. package/lib/types/index.d.ts +45 -0
  51. package/lib/types/json-value.d.ts +21 -0
  52. package/lib/types/option-shape.d.ts +22 -0
  53. package/lib/types/report-diff-tool.d.ts +8 -0
  54. package/lib/types/report-view.d.ts +43 -0
  55. package/lib/types/structure-tool.d.ts +10 -0
  56. package/lib/types/structure.d.ts +152 -0
  57. package/lib/types/tool.d.ts +11 -0
  58. package/lib/types/uml-communication.d.ts +109 -0
  59. package/lib/types/uml-composite.d.ts +107 -0
  60. package/lib/types/uml-interaction-overview.d.ts +91 -0
  61. package/lib/types/uml-profile.d.ts +112 -0
  62. package/lib/types/uml-structure.d.ts +53 -0
  63. package/lib/types/uml-syntax.d.ts +296 -0
  64. package/lib/types/uml-timing.d.ts +152 -0
  65. package/lib/types/uml-tool.d.ts +12 -0
  66. package/lib/types/uml-usecase.d.ts +58 -0
  67. package/lib/types/uml.d.ts +303 -0
  68. package/lib/uml-communication.js +353 -0
  69. package/lib/uml-composite.js +442 -0
  70. package/lib/uml-interaction-overview.js +277 -0
  71. package/lib/uml-profile.js +373 -0
  72. package/lib/uml-structure.js +306 -0
  73. package/lib/uml-syntax.js +545 -0
  74. package/lib/uml-timing.js +974 -0
  75. package/lib/uml-tool.js +213 -0
  76. package/lib/uml-usecase.js +532 -0
  77. package/lib/uml.js +2110 -0
  78. package/locale/en.json +6 -0
  79. package/locale/zh.json +6 -0
  80. package/package.json +124 -6
  81. package/skills/model-everything/SKILL.md +482 -0
  82. package/skills/model-everything/references/dsh-model-schema.md +437 -0
  83. package/skills/model-everything/references/gap-routing-guide.md +42 -0
  84. package/skills/model-everything/references/hash-spec.md +152 -0
  85. package/skills/model-everything/references/logic-verification-guide.md +422 -0
  86. package/skills/model-everything-datamodel/SKILL.md +130 -0
  87. package/skills/model-everything-datamodel/references/data-model-guide.md +84 -0
  88. package/skills/model-everything-datamodel/references/data-model-schema.md +128 -0
  89. package/skills/model-everything-design/SKILL.md +115 -0
  90. package/skills/model-everything-interleaving/SKILL.md +63 -0
  91. package/skills/model-everything-interleaving/SKILL.zh-CN.md +60 -0
  92. package/skills/model-everything-interleaving/references/concurrency-risk-guide.md +63 -0
  93. package/skills/model-everything-report-reading/SKILL.md +137 -0
  94. package/skills/model-everything-report-reading/references/reading-a-report.md +166 -0
  95. package/skills/model-everything-report-reading/references/reading-a-report.mmd +59 -0
  96. package/skills/model-everything-structure/SKILL.md +96 -0
  97. package/skills/model-everything-structure/references/structure-review-guide.md +201 -0
  98. package/skills/model-everything-uml/SKILL.md +60 -0
  99. package/skills/model-everything-uml/references/uml-modeling-guide.md +551 -0
  100. package/src/baseline.ts +288 -0
  101. package/src/compose-tool.ts +78 -0
  102. package/src/concurrency-tool.ts +44 -0
  103. package/src/concurrency.ts +254 -0
  104. package/src/data-engine.ts +1208 -0
  105. package/src/data-tool.ts +83 -0
  106. package/src/design-engine.ts +1263 -0
  107. package/src/design-tool.ts +60 -0
  108. package/src/engine.ts +4564 -0
  109. package/src/export-tool.ts +81 -0
  110. package/src/exporters.ts +399 -0
  111. package/src/gate.ts +330 -0
  112. package/src/granularity.ts +312 -0
  113. package/src/index.ts +289 -0
  114. package/src/json-value.ts +20 -0
  115. package/src/option-shape.ts +64 -0
  116. package/src/report-diff-tool.ts +67 -0
  117. package/src/report-view.ts +451 -0
  118. package/src/structure-tool.ts +93 -0
  119. package/src/structure.ts +786 -0
  120. package/src/tool.ts +94 -0
  121. package/src/uml-communication.ts +428 -0
  122. package/src/uml-composite.ts +520 -0
  123. package/src/uml-interaction-overview.ts +321 -0
  124. package/src/uml-profile.ts +439 -0
  125. package/src/uml-structure.ts +317 -0
  126. package/src/uml-syntax.ts +764 -0
  127. package/src/uml-timing.ts +1106 -0
  128. package/src/uml-tool.ts +230 -0
  129. package/src/uml-usecase.ts +566 -0
  130. package/src/uml.ts +2139 -0
package/lib/uml.js ADDED
@@ -0,0 +1,2110 @@
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
+ * `dsh-model-everything:` 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 `model_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 model-everything-uml
32
+ */
33
+ import { REPORT_SCHEMAS, validateModel, modelHash, valueHash, metadataKeysOf, narrativeComplete, narrativeCoverageOf, verdictOfFindings, DEFAULT_HASH_SPEC } from './engine.js';
34
+ import { UmlError, UML_FAMILY_IDS, umlCapability, detectUmlNotation, detectUmlFamily, declaredFamily, readUmlDirectives, directiveLine, MERMAID_RESERVED_NODE_IDS, UML_PLAIN_ID, UML_UNSAFE_LABEL, commentPrefix, DIRECTIVE_NAMESPACE, isUmlNotation, UML_NOTATIONS, umlFamilySpec, } from './uml-syntax.js';
35
+ import { useCaseModule, validateUseCaseModel } from './uml-usecase.js';
36
+ import { structureFamilyModule } from './uml-structure.js';
37
+ import { timingModule, validateTimingModel } from './uml-timing.js';
38
+ import { profileModule, validateProfileModel } from './uml-profile.js';
39
+ import { compositeModule, validateCompositeModel } from './uml-composite.js';
40
+ import { communicationModule, validateCommunicationModel } from './uml-communication.js';
41
+ import { interactionOverviewModule, validateInteractionOverviewModel } from './uml-interaction-overview.js';
42
+ export { UmlError, UML_NOTATIONS } from './uml-syntax.js';
43
+ // Internals a white-box test drives directly, because the tool entry points cannot reach
44
+ // them: the model validator and its refusals, and the segment a parse builds. They are
45
+ // exported for the suite, not for callers - the public surface is renderUml/parseUml/
46
+ // reviewUml and the tools built on them.
47
+ export { validateTimingModel, reviewTimingModel, TIMING_FINDING_CODES, parseTiming, renderTiming } from './uml-timing.js';
48
+ export { validateProfileModel, reviewProfileModel, PROFILE_FINDING_CODES, renderProfile, profileModule } from './uml-profile.js';
49
+ export { validateCompositeModel, reviewCompositeModel, COMPOSITE_FINDING_CODES, renderComposite, parseComposite, compositeModule } from './uml-composite.js';
50
+ export { validateCommunicationModel, reviewCommunicationModel, COMMUNICATION_FINDING_CODES, renderCommunication, parseCommunication, communicationModule, communicationSequenceOrder } from './uml-communication.js';
51
+ export { validateInteractionOverviewModel, reviewInteractionOverviewModel, INTERACTION_OVERVIEW_FINDING_CODES, renderInteractionOverview, parseInteractionOverview, interactionOverviewModule } from './uml-interaction-overview.js';
52
+ export const UML_DIAGRAMS = UML_FAMILY_IDS;
53
+ export const UML_INTENTS = ['present', 'verify', 'fidelity'];
54
+ // Notation syntax lives in the engine now. These aliases keep the machine parser
55
+ // reading the way it always did while the engine owns the rules.
56
+ const UNSAFE_LABEL = UML_UNSAFE_LABEL;
57
+ const RESERVED_NODE_IDS = MERMAID_RESERVED_NODE_IDS;
58
+ const PLAIN_ID = UML_PLAIN_ID;
59
+ // UmlError lives in the syntax engine and is re-exported above, so its class identity
60
+ // is the same for every importer and `instanceof` keeps working.
61
+ // ---------------------------------------------------------------------------
62
+ // Shared helpers
63
+ // ---------------------------------------------------------------------------
64
+ function literalText(value) {
65
+ return typeof value === 'boolean' ? String(value) : String(value);
66
+ }
67
+ /** Canonical guard text. Rendering wraps every composite node in parentheses, and the parser flattens same-operator chains, so render∘parse is the identity. */
68
+ export function guardText(node) {
69
+ if ('variable' in node)
70
+ return node.variable + ' ' + node.op + ' ' + literalText(node.value);
71
+ if ('all' in node)
72
+ return '(' + node.all.map((guard) => guardText(guard)).join(' && ') + ')';
73
+ if ('any' in node)
74
+ return '(' + node.any.map((guard) => guardText(guard)).join(' || ') + ')';
75
+ return '!(' + guardText(node.not) + ')';
76
+ }
77
+ function updatesText(updates) {
78
+ return updates.map((update) => {
79
+ const value = update.value ?? (update.op === 'set' ? 0 : 1);
80
+ if (update.op === 'set')
81
+ return update.variable + ' := ' + literalText(value);
82
+ if (update.op === 'inc')
83
+ return update.variable + ' := ' + update.variable + ' + ' + literalText(value);
84
+ return update.variable + ' := ' + update.variable + ' - ' + literalText(value);
85
+ }).join(', ');
86
+ }
87
+ function transitionText(transition) {
88
+ let text = transition.event;
89
+ if (transition.guard !== undefined)
90
+ text += ' [' + guardText(transition.guard) + ']';
91
+ if (transition.updates !== undefined && transition.updates.length > 0)
92
+ text += ' / ' + updatesText(transition.updates);
93
+ return text;
94
+ }
95
+ function displayText(text) {
96
+ return text.replace(/[\r\n\t]+/g, ' ').replace(/"/g, '\'').trim();
97
+ }
98
+ function prepareRender(input) {
99
+ const validation = validateModel(input);
100
+ if (!validation.ok)
101
+ throw new UmlError('model invalid: ' + validation.errors.join('; '));
102
+ const model = validation.model;
103
+ const warnings = [];
104
+ const used = new Set();
105
+ const alias = new Map();
106
+ const display = new Map();
107
+ for (const state of model.states) {
108
+ let candidate = state.id;
109
+ if (!PLAIN_ID.test(candidate)) {
110
+ candidate = candidate.replace(/[^A-Za-z0-9_]/g, '_');
111
+ if (candidate === '' || /^[0-9]/.test(candidate))
112
+ candidate = 'S_' + candidate;
113
+ 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.');
114
+ }
115
+ if (RESERVED_NODE_IDS.has(candidate))
116
+ warnings.push('UML_RENDER_RESERVED_ID: state alias "' + candidate + '" collides with a diagram keyword; Mermaid renders it, but a hand edit may not.');
117
+ let unique = candidate;
118
+ let suffix = 2;
119
+ while (used.has(unique)) {
120
+ unique = candidate + '_' + String(suffix);
121
+ suffix += 1;
122
+ }
123
+ if (unique !== candidate)
124
+ warnings.push('UML_RENDER_ALIAS_COLLISION: state id "' + state.id + '" shares an alias with another state; the diagram uses "' + unique + '".');
125
+ used.add(unique);
126
+ alias.set(state.id, unique);
127
+ const meaning = model.narrative?.states?.[state.id];
128
+ display.set(state.id, meaning === undefined ? state.id : displayText(state.id + '(' + meaning + ')'));
129
+ }
130
+ for (const transition of model.transitions) {
131
+ if (UNSAFE_LABEL.test(transition.event)) {
132
+ 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.');
133
+ }
134
+ }
135
+ const terminal = new Set(model.states.filter((state) => state.terminal === true).map((state) => state.id));
136
+ return { model, alias, display, terminal, warnings };
137
+ }
138
+ function directiveLines(notation, diagram, context) {
139
+ const prefix = notation === 'mermaid' ? '%%' : "'";
140
+ const lines = [prefix + DIRECTIVE_NAMESPACE + 'uml v1 notation=' + notation + ' diagram=' + diagram];
141
+ lines.push(prefix + DIRECTIVE_NAMESPACE + 'init ' + context.model.init);
142
+ const terminals = context.model.states.filter((state) => state.terminal === true).map((state) => state.id);
143
+ if (terminals.length > 0)
144
+ lines.push(prefix + DIRECTIVE_NAMESPACE + 'terminal ' + terminals.join(','));
145
+ for (const state of context.model.states) {
146
+ if (context.alias.get(state.id) !== state.id) {
147
+ lines.push(prefix + DIRECTIVE_NAMESPACE + 'alias ' + String(context.alias.get(state.id)) + ' ' + state.id);
148
+ }
149
+ }
150
+ // Variable kinds are not recoverable from the notation: `armed := 1` reads as an
151
+ // integer assignment whichever kind the model declared, and a variable no guard
152
+ // reads and no action writes leaves no trace at all. Pinning them keeps the
153
+ // round trip exact instead of reporting a fidelity loss that is really a
154
+ // notation limit.
155
+ for (const variable of context.model.variables ?? []) {
156
+ lines.push(prefix + DIRECTIVE_NAMESPACE + 'variable ' + variable.name + ' ' + variable.kind);
157
+ }
158
+ return lines;
159
+ }
160
+ function groupedTransitions(model) {
161
+ const order = [];
162
+ const groups = new Map();
163
+ for (const transition of model.transitions) {
164
+ const list = groups.get(transition.from);
165
+ if (list === undefined) {
166
+ groups.set(transition.from, [transition]);
167
+ order.push(transition.from);
168
+ }
169
+ else
170
+ list.push(transition);
171
+ }
172
+ return order.map((from) => ({ from, transitions: groups.get(from) ?? [] }));
173
+ }
174
+ // ---------------------------------------------------------------------------
175
+ // Rendering
176
+ // ---------------------------------------------------------------------------
177
+ function renderMermaidState(context) {
178
+ const lines = directiveLines('mermaid', 'state', context);
179
+ lines.push('stateDiagram-v2');
180
+ lines.push(' [*] --> ' + String(context.alias.get(context.model.init)));
181
+ for (const state of context.model.states) {
182
+ const alias = String(context.alias.get(state.id));
183
+ const label = context.display.get(state.id) ?? state.id;
184
+ // Every state is declared, even when its label equals its alias. A state that
185
+ // no transition touches (an isolated terminal, a start state with no edge yet)
186
+ // would otherwise leave no trace in the text at all, and the round-trip check
187
+ // would have to report a loss the notation never caused.
188
+ lines.push(' state "' + label + '" as ' + alias);
189
+ }
190
+ for (const group of groupedTransitions(context.model)) {
191
+ for (const transition of group.transitions) {
192
+ lines.push(' ' + String(context.alias.get(transition.from)) + ' --> ' + String(context.alias.get(transition.to)) + ' : ' + transitionText(transition));
193
+ }
194
+ }
195
+ for (const state of context.model.states) {
196
+ if (state.terminal === true)
197
+ lines.push(' ' + String(context.alias.get(state.id)) + ' --> [*]');
198
+ }
199
+ return lines.join('\n') + '\n';
200
+ }
201
+ function renderPlantUmlState(context) {
202
+ const lines = ['@startuml'];
203
+ lines.push(...directiveLines('plantuml', 'state', context));
204
+ lines.push('[*] --> ' + String(context.alias.get(context.model.init)));
205
+ for (const state of context.model.states) {
206
+ const alias = String(context.alias.get(state.id));
207
+ const label = context.display.get(state.id) ?? state.id;
208
+ lines.push('state "' + label + '" as ' + alias);
209
+ }
210
+ for (const group of groupedTransitions(context.model)) {
211
+ for (const transition of group.transitions) {
212
+ lines.push(String(context.alias.get(transition.from)) + ' --> ' + String(context.alias.get(transition.to)) + ' : ' + transitionText(transition));
213
+ }
214
+ }
215
+ for (const state of context.model.states) {
216
+ if (state.terminal === true)
217
+ lines.push(String(context.alias.get(state.id)) + ' --> [*]');
218
+ }
219
+ lines.push('@enduml');
220
+ return lines.join('\n') + '\n';
221
+ }
222
+ function renderMermaidActivity(context) {
223
+ const lines = directiveLines('mermaid', 'activity', context);
224
+ lines.push('flowchart TD');
225
+ for (const state of context.model.states) {
226
+ const alias = String(context.alias.get(state.id));
227
+ const text = context.display.get(state.id) ?? state.id;
228
+ lines.push(' ' + alias + (state.terminal === true ? '(["' + text + '"])' : '["' + text + '"]'));
229
+ }
230
+ for (const group of groupedTransitions(context.model)) {
231
+ for (const transition of group.transitions) {
232
+ lines.push(' ' + String(context.alias.get(transition.from)) + ' -->|"' + transitionText(transition) + '"| ' + String(context.alias.get(transition.to)));
233
+ }
234
+ }
235
+ return lines.join('\n') + '\n';
236
+ }
237
+ /**
238
+ * One BFS trace of the machine, written as a sequence diagram. A sequence
239
+ * diagram is a trace by construction — branches are messages with guards, and
240
+ * the diagram is explicitly capped so a cyclic machine cannot produce an
241
+ * unbounded file.
242
+ */
243
+ function renderSequence(context, notation, maxSteps) {
244
+ const lines = [];
245
+ if (notation === 'mermaid') {
246
+ lines.push(...directiveLines('mermaid', 'sequence', context));
247
+ lines.push('sequenceDiagram');
248
+ lines.push(' participant ENV as Environment');
249
+ lines.push(' participant M as Machine');
250
+ }
251
+ else {
252
+ lines.push('@startuml');
253
+ lines.push(...directiveLines('plantuml', 'sequence', context));
254
+ lines.push('participant ENV as Environment');
255
+ lines.push('participant M as Machine');
256
+ }
257
+ const byFrom = new Map();
258
+ for (const transition of context.model.transitions) {
259
+ const list = byFrom.get(transition.from);
260
+ if (list === undefined)
261
+ byFrom.set(transition.from, [transition]);
262
+ else
263
+ list.push(transition);
264
+ }
265
+ const seen = new Set([context.model.init]);
266
+ const queue = [context.model.init];
267
+ const arrow = notation === 'mermaid' ? 'ENV->>M: ' : 'ENV -> M : ';
268
+ const note = notation === 'mermaid' ? ' Note over M: ' : 'note over M : ';
269
+ lines.push((notation === 'mermaid' ? ' ' : '') + 'Note over M: init ' + context.model.init);
270
+ let steps = 0;
271
+ let truncated = false;
272
+ while (queue.length > 0) {
273
+ const current = queue.shift();
274
+ for (const transition of byFrom.get(current) ?? []) {
275
+ if (steps >= maxSteps) {
276
+ truncated = true;
277
+ break;
278
+ }
279
+ steps += 1;
280
+ const label = transitionText(transition);
281
+ lines.push((notation === 'mermaid' ? ' ' : '') + arrow + label);
282
+ lines.push(note + String(context.alias.get(transition.from)) + ' -> ' + String(context.alias.get(transition.to)));
283
+ if (!seen.has(transition.to)) {
284
+ seen.add(transition.to);
285
+ queue.push(transition.to);
286
+ }
287
+ }
288
+ if (truncated)
289
+ break;
290
+ }
291
+ if (notation === 'plantuml')
292
+ lines.push('@enduml');
293
+ return { text: lines.join('\n') + '\n', truncated };
294
+ }
295
+ /** What each action hands back, so a declared intent can be matched against it. */
296
+ const ACTION_PRODUCES = {
297
+ render: 'a diagram',
298
+ parse: 'a LogicModelV1 read out of a diagram',
299
+ review: 'an audit of the modelling',
300
+ labels: 'the label and comment conventions the parser accepts',
301
+ };
302
+ /**
303
+ * The intents each action can serve.
304
+ *
305
+ * `fidelity` is a review job: render produces a diagram, and a diagram nothing has
306
+ * compared back to its model proves nothing about it.
307
+ */
308
+ const ACTION_INTENTS = {
309
+ render: ['present', 'verify'],
310
+ parse: ['verify'],
311
+ review: ['present', 'verify', 'fidelity'],
312
+ labels: [],
313
+ };
314
+ /**
315
+ * The `(notation, view)` cells this module can draw.
316
+ *
317
+ * PlantUML activity is the cell that does not exist: its activity syntax is a
318
+ * structured flowchart language, so a graph with merges or cycles needs a while/if
319
+ * reconstruction this module does not perform. Refusing is the honest outcome —
320
+ * quietly emitting a state diagram under an "activity" request would mislabel the
321
+ * model.
322
+ */
323
+ /**
324
+ * What this build serves for one (notation, family, action) cell.
325
+ *
326
+ * The engine decides what the notation can express. The registry decides what this
327
+ * build implements. A family the table calls faithful but no module serves yet is
328
+ * refused here, by name, instead of being handed to the wrong parser.
329
+ */
330
+ function umlFamilyCapability(notation, family, action, options = {}) {
331
+ const capability = umlCapability(notation, family, action, options);
332
+ if (!capability.ok)
333
+ throw new UmlError(capability.message, capability.errorCode);
334
+ return capability;
335
+ }
336
+ /** Families served by a module of their own rather than by the machine path. */
337
+ const FAMILY_MODULES = {
338
+ 'use-case': useCaseModule,
339
+ timing: timingModule,
340
+ profile: profileModule,
341
+ 'composite-structure': compositeModule,
342
+ communication: communicationModule,
343
+ 'interaction-overview': interactionOverviewModule,
344
+ class: structureFamilyModule('class'),
345
+ object: structureFamilyModule('object'),
346
+ package: structureFamilyModule('package'),
347
+ component: structureFamilyModule('component'),
348
+ deployment: structureFamilyModule('deployment'),
349
+ };
350
+ function familyModule(family) {
351
+ return FAMILY_MODULES[family];
352
+ }
353
+ /**
354
+ * Refuse a view the requested notation cannot draw, before any work. The entry check
355
+ * every caller already used now asks the engine and the registry.
356
+ */
357
+ export function assertUmlView(notation, diagram) {
358
+ umlFamilyCapability(notation, diagram, 'render');
359
+ }
360
+ /**
361
+ * Refuse, before any work, a request whose declared purpose the view cannot serve.
362
+ *
363
+ * Without an intent this changes nothing: the review still reports `UML019` when the
364
+ * fidelity check does not apply to the view it was given. With one, an impossible
365
+ * pairing is an error carrying a code, instead of a report about the wrong artifact —
366
+ * which is the failure this check exists to prevent.
367
+ *
368
+ * @param intent - the declared purpose, or `undefined` to leave the behaviour unchanged.
369
+ * @param action - which action is about to run.
370
+ * @param view - the requested `(notation, diagram)`, for the actions that have one.
371
+ */
372
+ export function checkUmlIntent(intent, action, view) {
373
+ if (intent === undefined)
374
+ return;
375
+ if (!UML_INTENTS.includes(intent))
376
+ throw new UmlError('unknown intent "' + String(intent) + '"; expected ' + UML_INTENTS.join(' | '));
377
+ const served = ACTION_INTENTS[action];
378
+ if (!served.includes(intent)) {
379
+ throw new UmlError('intent "' + intent + '" cannot be served by action=' + action + ', which returns ' + ACTION_PRODUCES[action] + '; it serves ' +
380
+ (served.length === 0 ? 'no intent — leave `intent` off' : served.join(' | ')), 'UML_INTENT_MISMATCH');
381
+ }
382
+ if (view === undefined)
383
+ return;
384
+ assertUmlView(view.notation, view.diagram);
385
+ if (intent === 'present' || view.diagram !== 'sequence')
386
+ return;
387
+ throw new UmlError('intent "' + intent + '" needs a machine, and a sequence view is one BFS trace: ' +
388
+ (intent === 'fidelity'
389
+ ? 'the render/parse fidelity check does not apply to a trace, so nothing would be proven. Review kind "state" or "activity" to have the diagram checked against the model, and render the trace separately with intent "present".'
390
+ : '`parse` refuses a trace because it cannot reconstruct a machine, so the diagram cannot feed verification. Model the machine as a state or activity diagram, and render the trace separately with intent "present".'), 'UML_INTENT_UNSERVABLE');
391
+ }
392
+ /**
393
+ * Render a LogicModelV1 as UML.
394
+ *
395
+ * Mermaid covers all three views; PlantUML covers state and sequence (see
396
+ * `assertUmlView` for why activity is refused).
397
+ *
398
+ * @param input - candidate LogicModelV1.
399
+ * @param notation - `mermaid` (default) or `plantuml`.
400
+ * @param diagram - `state` (default), `activity`, or `sequence`.
401
+ * @param maxSteps - cap on the sequence trace length.
402
+ * @param intent - what the diagram is for; a view that cannot serve it is refused first.
403
+ */
404
+ export function renderUml(input, notation = 'mermaid', diagram = 'state', maxSteps = 60, intent) {
405
+ assertUmlView(notation, diagram);
406
+ const capability = umlFamilyCapability(notation, diagram, 'render');
407
+ checkUmlIntent(intent, 'render', { notation, diagram });
408
+ const module = familyModule(diagram);
409
+ if (module !== undefined) {
410
+ const rendered = module.render(input, notation, { maxSteps });
411
+ const warnings = [...(capability.warning === undefined ? [] : [capability.warning]), ...rendered.warnings];
412
+ return { notation, diagram, primary: rendered.primary, warnings };
413
+ }
414
+ const context = prepareRender(input);
415
+ const warnings = [...context.warnings];
416
+ let primary;
417
+ if (diagram === 'state')
418
+ primary = notation === 'mermaid' ? renderMermaidState(context) : renderPlantUmlState(context);
419
+ else if (diagram === 'activity')
420
+ primary = renderMermaidActivity(context);
421
+ else {
422
+ const rendered = renderSequence(context, notation, maxSteps);
423
+ primary = rendered.text;
424
+ if (rendered.truncated)
425
+ 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.');
426
+ 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.');
427
+ }
428
+ return { notation, diagram, primary, warnings };
429
+ }
430
+ function tokenizeGuard(text) {
431
+ const tokens = [];
432
+ let index = 0;
433
+ while (index < text.length) {
434
+ const char = text[index];
435
+ if (/\s/.test(char)) {
436
+ index += 1;
437
+ continue;
438
+ }
439
+ if (char === '(') {
440
+ tokens.push({ kind: 'lparen', text: char });
441
+ index += 1;
442
+ continue;
443
+ }
444
+ if (char === ')') {
445
+ tokens.push({ kind: 'rparen', text: char });
446
+ index += 1;
447
+ continue;
448
+ }
449
+ if (char === '&' && text[index + 1] === '&') {
450
+ tokens.push({ kind: 'and', text: '&&' });
451
+ index += 2;
452
+ continue;
453
+ }
454
+ if (char === '|' && text[index + 1] === '|') {
455
+ tokens.push({ kind: 'or', text: '||' });
456
+ index += 2;
457
+ continue;
458
+ }
459
+ if (char === '!') {
460
+ if (text[index + 1] === '=') {
461
+ tokens.push({ kind: 'op', text: '!=' });
462
+ index += 2;
463
+ continue;
464
+ }
465
+ tokens.push({ kind: 'not', text: '!' });
466
+ index += 1;
467
+ continue;
468
+ }
469
+ const two = text.slice(index, index + 2);
470
+ if (two === '==' || two === '<=' || two === '>=') {
471
+ tokens.push({ kind: 'op', text: two });
472
+ index += 2;
473
+ continue;
474
+ }
475
+ if (char === '<' || char === '>') {
476
+ tokens.push({ kind: 'op', text: char });
477
+ index += 1;
478
+ continue;
479
+ }
480
+ if (char === '=') {
481
+ tokens.push({ kind: 'op', text: '==' });
482
+ index += 1;
483
+ continue;
484
+ }
485
+ if (/[0-9]/.test(char) || (char === '-' && /[0-9]/.test(text[index + 1] ?? ''))) {
486
+ let end = index + 1;
487
+ while (end < text.length && /[0-9]/.test(text[end]))
488
+ end += 1;
489
+ tokens.push({ kind: 'number', text: text.slice(index, end) });
490
+ index = end;
491
+ continue;
492
+ }
493
+ if (/[A-Za-z_]/.test(char)) {
494
+ let end = index + 1;
495
+ while (end < text.length && /[A-Za-z0-9_.]/.test(text[end]))
496
+ end += 1;
497
+ const word = text.slice(index, end);
498
+ index = end;
499
+ if (word === 'and')
500
+ tokens.push({ kind: 'and', text: word });
501
+ else if (word === 'or')
502
+ tokens.push({ kind: 'or', text: word });
503
+ else if (word === 'not')
504
+ tokens.push({ kind: 'not', text: word });
505
+ else if (word === 'true' || word === 'false')
506
+ tokens.push({ kind: 'boolean', text: word });
507
+ else
508
+ tokens.push({ kind: 'ident', text: word });
509
+ continue;
510
+ }
511
+ throw new UmlError('guard text not understood near "' + text.slice(index) + '"');
512
+ }
513
+ return tokens;
514
+ }
515
+ class GuardReader {
516
+ tokens;
517
+ source;
518
+ position = 0;
519
+ constructor(tokens, source) {
520
+ this.tokens = tokens;
521
+ this.source = source;
522
+ }
523
+ parse() {
524
+ const node = this.parseOr();
525
+ if (this.position !== this.tokens.length)
526
+ throw new UmlError('trailing tokens in guard "' + this.source + '"');
527
+ return node;
528
+ }
529
+ peek() {
530
+ return this.tokens[this.position];
531
+ }
532
+ parseOr() {
533
+ const parts = [this.parseAnd()];
534
+ while (this.peek()?.kind === 'or') {
535
+ this.position += 1;
536
+ parts.push(this.parseAnd());
537
+ }
538
+ return parts.length === 1 ? parts[0] : { any: parts };
539
+ }
540
+ parseAnd() {
541
+ const parts = [this.parseUnary()];
542
+ while (this.peek()?.kind === 'and') {
543
+ this.position += 1;
544
+ parts.push(this.parseUnary());
545
+ }
546
+ return parts.length === 1 ? parts[0] : { all: parts };
547
+ }
548
+ parseUnary() {
549
+ if (this.peek()?.kind === 'not') {
550
+ this.position += 1;
551
+ return { not: this.parseUnary() };
552
+ }
553
+ return this.parsePrimary();
554
+ }
555
+ parsePrimary() {
556
+ const token = this.peek();
557
+ if (token?.kind === 'lparen') {
558
+ this.position += 1;
559
+ const inner = this.parseOr();
560
+ if (this.peek()?.kind !== 'rparen')
561
+ throw new UmlError('unbalanced parentheses in guard "' + this.source + '"');
562
+ this.position += 1;
563
+ return inner;
564
+ }
565
+ if (token?.kind !== 'ident')
566
+ throw new UmlError('expected a variable name in guard "' + this.source + '"');
567
+ this.position += 1;
568
+ const op = this.peek();
569
+ if (op?.kind !== 'op')
570
+ throw new UmlError('expected a comparison operator after "' + token.text + '" in guard "' + this.source + '"');
571
+ this.position += 1;
572
+ const value = this.peek();
573
+ if (value?.kind === 'number') {
574
+ this.position += 1;
575
+ return { variable: token.text, op: op.text, value: Number(value.text) };
576
+ }
577
+ if (value?.kind === 'boolean') {
578
+ if (op.text !== '==' && op.text !== '!=')
579
+ throw new UmlError('boolean variable "' + token.text + '" only supports == / != (guard "' + this.source + '")');
580
+ this.position += 1;
581
+ return { variable: token.text, op: op.text, value: value.text === 'true' };
582
+ }
583
+ throw new UmlError('expected a literal value for "' + token.text + '" in guard "' + this.source + '"');
584
+ }
585
+ }
586
+ /** Parse a guard expression such as `(retry < 3 && armed == true)`. */
587
+ export function parseGuardText(text) {
588
+ return new GuardReader(tokenizeGuard(text), text).parse();
589
+ }
590
+ /** Parse a UML action clause such as `retry := retry + 1, armed := true`. */
591
+ export function parseUpdatesText(text, warnings) {
592
+ const out = [];
593
+ for (const raw of text.split(',')) {
594
+ const clause = raw.trim();
595
+ if (clause === '')
596
+ continue;
597
+ const shim = /^([A-Za-z_][A-Za-z0-9_]*)\s*(\+\+|--)$/.exec(clause);
598
+ if (shim !== null) {
599
+ out.push({ variable: shim[1], op: shim[2] === '++' ? 'inc' : 'dec', value: 1 });
600
+ continue;
601
+ }
602
+ const assignment = /^([A-Za-z_][A-Za-z0-9_]*)\s*:?=\s*(.+)$/.exec(clause);
603
+ if (assignment === null)
604
+ throw new UmlError('action clause not understood: "' + clause + '" (expected "var := value")');
605
+ const name = assignment[1];
606
+ const value = assignment[2].trim();
607
+ if (value === name) {
608
+ warnings.push('UML_PARSE_NOOP_UPDATE: action "' + clause + '" assigns the variable to itself; dropped.');
609
+ continue;
610
+ }
611
+ if (/^(true|false)$/.test(value)) {
612
+ out.push({ variable: name, op: 'set', value: value === 'true' ? 1 : 0 });
613
+ continue;
614
+ }
615
+ if (/^-?[0-9]+$/.test(value)) {
616
+ out.push({ variable: name, op: 'set', value: Number(value) });
617
+ continue;
618
+ }
619
+ const arithmetic = /^([A-Za-z_][A-Za-z0-9_]*)\s*([+-])\s*([0-9]+)$/.exec(value);
620
+ if (arithmetic === null)
621
+ throw new UmlError('action value not understood: "' + value + '" (expected a literal, or "var + n" / "var - n")');
622
+ if (arithmetic[1] !== name)
623
+ throw new UmlError('action "' + clause + '" reads a different variable; LogicModelV1 updates touch one variable');
624
+ out.push({ variable: name, op: arithmetic[2] === '+' ? 'inc' : 'dec', value: Number(arithmetic[3]) });
625
+ }
626
+ return out;
627
+ }
628
+ function parseTransitionLabel(label, warnings) {
629
+ let rest = label.trim();
630
+ let guard;
631
+ const bracket = rest.indexOf('[');
632
+ if (bracket >= 0) {
633
+ const close = rest.lastIndexOf(']');
634
+ if (close < bracket)
635
+ throw new UmlError('unbalanced guard brackets in transition label "' + label + '"');
636
+ guard = parseGuardText(rest.slice(bracket + 1, close).trim());
637
+ rest = (rest.slice(0, bracket) + ' ' + rest.slice(close + 1)).trim();
638
+ }
639
+ let updates;
640
+ const slash = rest.indexOf('/');
641
+ if (slash >= 0) {
642
+ const actionText = rest.slice(slash + 1).trim();
643
+ updates = parseUpdatesText(actionText, warnings);
644
+ if (updates.length === 0)
645
+ updates = undefined;
646
+ rest = rest.slice(0, slash).trim();
647
+ }
648
+ const event = rest.trim();
649
+ if (event === '')
650
+ throw new UmlError('transition label "' + label + '" carries no event name; label the arrow as `event [guard] / actions`');
651
+ return { event, ...(guard === undefined ? {} : { guard }), ...(updates === undefined ? {} : { updates }) };
652
+ }
653
+ /**
654
+ * The machine families' view of the engine's directive reader: the same dsh-model-everything:
655
+ * lines, arranged the way the state parser wants them.
656
+ */
657
+ function readDirectives(lines, notation) {
658
+ const read = readUmlDirectives(lines, notation);
659
+ const directives = { terminals: [], aliases: new Map(), variables: new Map(), declared: read.declared };
660
+ for (const value of read.values.get('init') ?? [])
661
+ if (value !== '')
662
+ directives.init = value;
663
+ for (const value of read.values.get('terminal') ?? []) {
664
+ for (const id of value.split(',')) {
665
+ const trimmed = id.trim();
666
+ if (trimmed !== '')
667
+ directives.terminals.push(trimmed);
668
+ }
669
+ }
670
+ for (const value of read.values.get('alias') ?? []) {
671
+ const split = value.indexOf(' ');
672
+ if (split > 0)
673
+ directives.aliases.set(value.slice(0, split).trim(), value.slice(split + 1).trim());
674
+ }
675
+ for (const value of read.values.get('variable') ?? []) {
676
+ const split = value.lastIndexOf(' ');
677
+ if (split > 0) {
678
+ const kind = value.slice(split + 1).trim();
679
+ if (kind === 'integer' || kind === 'boolean')
680
+ directives.variables.set(value.slice(0, split).trim(), kind);
681
+ }
682
+ }
683
+ // A directive this family does not define must not vanish: it goes back into the
684
+ // body, where the parser reports it as a line it could not read.
685
+ const known = new Set(['init', 'terminal', 'alias', 'variable']);
686
+ const body = [...read.body];
687
+ for (const key of [...read.values.keys()].sort()) {
688
+ if (known.has(key))
689
+ continue;
690
+ for (const value of read.values.get(key) ?? [])
691
+ body.push(directiveLine(notation, key + (value === '' ? '' : ' ' + value)));
692
+ }
693
+ return { directives, ...(read.declared.diagram === undefined ? {} : { kindHint: read.declared.diagram }), body };
694
+ }
695
+ function rawToModel(raw, directives, warnings) {
696
+ // Alias directives restore ids the notation cannot spell; they win over the
697
+ // alias itself, which is the whole reason the renderer writes them.
698
+ const idOf = (name) => directives.aliases.get(name) ?? name;
699
+ const names = [];
700
+ const seenNames = new Set();
701
+ const addName = (name) => {
702
+ if (name === undefined || seenNames.has(name))
703
+ return;
704
+ seenNames.add(name);
705
+ names.push(name);
706
+ };
707
+ for (const name of raw.states)
708
+ addName(name);
709
+ // A state can be known without ever being an edge endpoint: the initial
710
+ // pseudostate (`[*] --> X`) and the final mark (`Y --> [*]`) both name states a
711
+ // hand-written diagram never declares, and an isolated state has no edge at all.
712
+ addName(raw.initialState);
713
+ for (const name of raw.finalMarks)
714
+ addName(name);
715
+ for (const name of raw.terminals)
716
+ addName(name);
717
+ for (const edge of raw.edges) {
718
+ addName(edge.from);
719
+ addName(edge.to);
720
+ }
721
+ const states = [];
722
+ const seenStates = new Set();
723
+ for (const name of names) {
724
+ const id = idOf(name);
725
+ if (seenStates.has(id))
726
+ continue;
727
+ seenStates.add(id);
728
+ states.push({ id });
729
+ }
730
+ const transitions = [];
731
+ const synthetic = new Set();
732
+ for (const edge of raw.edges) {
733
+ const from = idOf(edge.from);
734
+ const to = idOf(edge.to);
735
+ const label = edge.label.trim();
736
+ let event;
737
+ let guard;
738
+ let updates;
739
+ if (label === '') {
740
+ let candidate = 't_' + from + '_' + to;
741
+ let suffix = 2;
742
+ while (synthetic.has(candidate)) {
743
+ candidate = 't_' + from + '_' + to + '_' + String(suffix);
744
+ suffix += 1;
745
+ }
746
+ synthetic.add(candidate);
747
+ event = candidate;
748
+ 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.');
749
+ }
750
+ else {
751
+ const parsed = parseTransitionLabel(label, warnings);
752
+ event = parsed.event;
753
+ guard = parsed.guard;
754
+ updates = parsed.updates;
755
+ }
756
+ transitions.push({ from, event, to, ...(guard === undefined ? {} : { guard }), ...(updates === undefined ? {} : { updates }) });
757
+ }
758
+ // Init: an explicit `[*] --> X` wins; a directive is the fallback the activity
759
+ // view needs (a flowchart has no initial pseudostate).
760
+ let init = raw.initialState === undefined ? undefined : idOf(raw.initialState);
761
+ if (init === undefined && directives.init !== undefined)
762
+ init = idOf(directives.init);
763
+ if (raw.initialState !== undefined && directives.init !== undefined && idOf(raw.initialState) !== directives.init) {
764
+ warnings.push('UML_PARSE_INIT_CONFLICT: the diagram enters ' + idOf(raw.initialState) + ' from its initial pseudostate but declares init ' + directives.init + '; the pseudostate wins.');
765
+ }
766
+ if (init === undefined) {
767
+ const targeted = new Set(transitions.map((transition) => transition.to));
768
+ const roots = states.map((state) => state.id).filter((id) => !targeted.has(id));
769
+ if (roots.length === 1) {
770
+ init = roots[0];
771
+ warnings.push('UML_PARSE_INIT_INFERRED: no initial state was declared; "' + init + '" is the only state nothing enters, so it is used as init.');
772
+ }
773
+ else {
774
+ throw new UmlError('no initial state: add `[*] --> <state>` (or a `' + DIRECTIVE_NAMESPACE + 'init <state>` directive); found ' + String(roots.length) + ' entry states');
775
+ }
776
+ }
777
+ const terminalNames = new Set(raw.terminals.map((name) => idOf(name)));
778
+ for (const name of directives.terminals)
779
+ terminalNames.add(idOf(name));
780
+ for (const name of raw.finalMarks)
781
+ terminalNames.add(idOf(name));
782
+ for (const state of states)
783
+ if (terminalNames.has(state.id))
784
+ state.terminal = true;
785
+ const variables = inferVariables(transitions, directives.variables);
786
+ const model = {
787
+ schemaVersion: 1,
788
+ init,
789
+ states,
790
+ transitions,
791
+ ...(variables.length === 0 ? {} : { variables }),
792
+ };
793
+ const validation = validateModel(model);
794
+ if (!validation.ok)
795
+ throw new UmlError('the diagram parsed into an invalid model: ' + validation.errors.join('; '));
796
+ return validation.model;
797
+ }
798
+ /**
799
+ * Recover the variable list from the diagram. A `dsh-model-everything:variable` directive
800
+ * wins, because the notation itself cannot tell `armed := 1` on a boolean from an
801
+ * integer assignment; guards and actions are the fallback for hand-written text.
802
+ */
803
+ function inferVariables(transitions, declared) {
804
+ const kinds = new Map(declared);
805
+ const note = (name, kind) => {
806
+ if (declared.has(name))
807
+ return;
808
+ const current = kinds.get(name);
809
+ if (current === undefined)
810
+ kinds.set(name, kind);
811
+ else if (current !== kind)
812
+ kinds.set(name, 'integer');
813
+ };
814
+ const walk = (guard) => {
815
+ if (guard === undefined)
816
+ return;
817
+ if ('variable' in guard) {
818
+ note(guard.variable, typeof guard.value === 'boolean' ? 'boolean' : 'integer');
819
+ return;
820
+ }
821
+ if ('all' in guard) {
822
+ for (const child of guard.all)
823
+ walk(child);
824
+ return;
825
+ }
826
+ if ('any' in guard) {
827
+ for (const child of guard.any)
828
+ walk(child);
829
+ return;
830
+ }
831
+ walk(guard.not);
832
+ };
833
+ for (const transition of transitions) {
834
+ walk(transition.guard);
835
+ for (const update of transition.updates ?? [])
836
+ note(update.variable, 'integer');
837
+ }
838
+ return [...kinds.entries()].map(([name, kind]) => ({ name, kind, init: kind === 'boolean' ? false : 0 }));
839
+ }
840
+ /**
841
+ * PlantUML keywords that declare a construct LogicModelV1 has no place for. Their
842
+ * presence marks the text as a component, package, class, deployment or database
843
+ * diagram — a different diagram family. A state parser still reads *something* out of
844
+ * such a file (the arrows look like transitions), which is why the discarded
845
+ * declarations are collected and reported instead of being ignored.
846
+ */
847
+ const PLANTUML_OTHER_CONSTRUCT = /^(component|package|class|interface|enum|enumeration|object|actor|usecase|node|artifact|database|rectangle|folder|frame|cloud|storage|collections|queue|stack|agent|boundary|control|entity|deployment|protocol|struct|exception|metaclass|stereotype|circle|hexagon|label|port|portin|portout)\b/i;
848
+ /** The node identifier a declaration introduces, when it has one (`… as ID`, or a bare `component ID`). */
849
+ function declaredIdentifier(text) {
850
+ const alias = /\bas\s+([A-Za-z_][A-Za-z0-9_]*)\s*;?$/.exec(text);
851
+ if (alias !== null)
852
+ return alias[1];
853
+ const bare = /^[A-Za-z]+\s+([A-Za-z_][A-Za-z0-9_]*)\s*;?$/.exec(text);
854
+ return bare === null ? undefined : bare[1];
855
+ }
856
+ /**
857
+ * Findings a parse result carries on its own. A diagram from another family parses
858
+ * into something; without this finding that something is presented as a model of the
859
+ * file, which is a false guarantee of exactly the kind this plugin exists to prevent.
860
+ */
861
+ export function parseFindings(parsed) {
862
+ // A note block is reported in `discardedConstructs` for transparency, but it does not make
863
+ // the text "not a state diagram": the rest of the file still is one, so only the constructs
864
+ // that actually replace a state declaration count towards the refusal.
865
+ const unsupported = parsed.discardedConstructs.filter((entry) => entry.construct !== 'note');
866
+ if (unsupported.length === 0)
867
+ return [];
868
+ const counts = new Map();
869
+ for (const entry of unsupported)
870
+ counts.set(entry.construct, (counts.get(entry.construct) ?? 0) + 1);
871
+ const summary = [...counts.entries()]
872
+ .sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0))
873
+ .map(([construct, count]) => construct + ' ×' + String(count))
874
+ .join(', ');
875
+ const shown = unsupported.slice(0, 12).map((entry) => 'line ' + String(entry.line) + ': ' + entry.text).join(' | ');
876
+ return [{
877
+ code: 'UML_NOT_A_STATE_DIAGRAM',
878
+ severity: 'error',
879
+ message: 'the text is not a state or activity diagram: ' + String(unsupported.length) + ' declaration(s) of unsupported construct(s) (' + summary + ') and ' + String(parsed.discardedEdges) + ' arrow(s) between them were read as states and transitions, so the parsed model is not this diagram.',
880
+ detail: shown + (unsupported.length > 12 ? ' | … ' + String(unsupported.length - 12) + ' more' : ''),
881
+ }];
882
+ }
883
+ function parseStateDiagram(text, notation) {
884
+ const lines = text.split(/\r?\n/);
885
+ const { directives, body } = readDirectives(lines, notation);
886
+ const raw = { states: [], display: new Map(), edges: [], terminals: [], finalMarks: [], warnings: [], discarded: [], discardedNodes: new Set() };
887
+ let discardedEdges = 0;
888
+ const declared = new Set();
889
+ const declare = (name) => { if (!declared.has(name)) {
890
+ declared.add(name);
891
+ raw.states.push(name);
892
+ } };
893
+ const skip = notation === 'mermaid'
894
+ ? /^(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|%%\{)/
895
+ : /^(@startuml|@enduml|scale\b|skinparam\b|title\b|hide\b|left to right direction|top to bottom direction|autonumber|!theme)/;
896
+ let inNote = false;
897
+ body.forEach((rawLine, index) => {
898
+ const line = rawLine.trim();
899
+ if (line === '' || (line.startsWith('--') && !line.includes('-->')))
900
+ return;
901
+ if (skip.test(line))
902
+ return;
903
+ const noteStart = /^note\b/i.test(line);
904
+ const noteEnd = /^end\s*note$/i.test(line);
905
+ if (notation === 'plantuml' && !inNote && !noteStart && !noteEnd) {
906
+ const other = PLANTUML_OTHER_CONSTRUCT.exec(line);
907
+ if (other !== null) {
908
+ raw.discarded.push({ construct: other[1].toLowerCase(), line: index + 1, text: line });
909
+ const id = declaredIdentifier(line);
910
+ if (id !== undefined)
911
+ raw.discardedNodes.add(id);
912
+ return;
913
+ }
914
+ }
915
+ if (noteEnd) {
916
+ inNote = false;
917
+ return;
918
+ }
919
+ // A multi-line note body is prose, not a statement: skip it silently rather than
920
+ // reporting one ignored line per line of text.
921
+ if (inNote)
922
+ return;
923
+ if (noteStart && !/:\s*.+$/.test(line)) {
924
+ // R7: a multi-line note block is prose the notation carries and LogicModelV1 does not.
925
+ // Its body is still skipped line by line, but the block itself is NOT silent: it is
926
+ // reported once, like any other unsupported construct, so "the diagram hash changed and
927
+ // the report said nothing" cannot happen. It stays out of `warnings` (no per-line noise)
928
+ // and `parseFindings` keeps it out of the "not a state diagram" verdict.
929
+ raw.discarded.push({ construct: 'note', line: index + 1, text: line });
930
+ inNote = true;
931
+ return;
932
+ }
933
+ const note = /^note\s+(?:over|right of|left of)\s+([A-Za-z_][A-Za-z0-9_]*)\s*:\s*(.+)$/i.exec(line);
934
+ if (note !== null) {
935
+ declare(note[1]);
936
+ const existing = raw.display.get(note[1]);
937
+ if (existing === undefined)
938
+ raw.display.set(note[1], note[2].trim());
939
+ return;
940
+ }
941
+ if (noteStart)
942
+ return;
943
+ const stateDecl = /^state\s+"([^"]*)"\s+as\s+([A-Za-z_][A-Za-z0-9_]*)$/.exec(line);
944
+ if (stateDecl !== null) {
945
+ declare(stateDecl[2]);
946
+ raw.display.set(stateDecl[2], stateDecl[1]);
947
+ return;
948
+ }
949
+ const bareState = /^state\s+([A-Za-z_][A-Za-z0-9_]*)\s*\{?$/.exec(line);
950
+ if (bareState !== null) {
951
+ declare(bareState[1]);
952
+ return;
953
+ }
954
+ const concurrency = /^\}\s*$|^--\s*$/.test(line);
955
+ if (concurrency) {
956
+ raw.warnings.push('UML_PARSE_CONCURRENCY_FLATTENED: a concurrency region or composite block was flattened; LogicModelV1 has no region construct (use model_compose_verify for parallel machines).');
957
+ return;
958
+ }
959
+ const composite = /^state\s+(.+)\s*\{$/.exec(line);
960
+ if (composite !== null) {
961
+ raw.warnings.push('UML_PARSE_COMPOSITE_FLATTENED: composite state "' + composite[1].trim() + '" was flattened into its members.');
962
+ return;
963
+ }
964
+ const description = /^([A-Za-z_][A-Za-z0-9_]*)\s*:\s*(.+)$/.exec(line);
965
+ if (description !== null) {
966
+ declare(description[1]);
967
+ if (!raw.display.has(description[1]))
968
+ raw.display.set(description[1], description[2].trim());
969
+ return;
970
+ }
971
+ const edge = /^(.+?)\s*-->\s*(.+?)(?:\s*:\s*(.*))?$/.exec(line);
972
+ if (edge !== null) {
973
+ const from = edge[1].trim();
974
+ const to = edge[2].trim();
975
+ const label = (edge[3] ?? '').trim();
976
+ if (from === '[*]') {
977
+ if (raw.initialState !== undefined && raw.initialState !== to) {
978
+ 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.');
979
+ }
980
+ else {
981
+ raw.initialState = to;
982
+ }
983
+ return;
984
+ }
985
+ if (to === '[*]') {
986
+ raw.finalMarks.push(from);
987
+ return;
988
+ }
989
+ declare(from);
990
+ declare(to);
991
+ if (raw.discardedNodes.has(from) || raw.discardedNodes.has(to))
992
+ discardedEdges += 1;
993
+ raw.edges.push({ from, to, label });
994
+ return;
995
+ }
996
+ raw.warnings.push('UML_PARSE_IGNORED_LINE: "' + line + '" is not a state diagram statement; it was ignored.');
997
+ });
998
+ const model = rawToModel(raw, directives, raw.warnings);
999
+ const labels = mapLabels(raw.display, directives);
1000
+ return { notation, diagram: 'state', family: 'state', model, labels, discardedConstructs: raw.discarded, discardedEdges, warnings: raw.warnings };
1001
+ }
1002
+ function parseActivityDiagram(text, notation) {
1003
+ const lines = text.split(/\r?\n/);
1004
+ const { directives, body } = readDirectives(lines, notation);
1005
+ const raw = { states: [], display: new Map(), edges: [], terminals: [], finalMarks: [], warnings: [], discarded: [], discardedNodes: new Set() };
1006
+ const declared = new Set();
1007
+ const declare = (name) => { if (!declared.has(name)) {
1008
+ declared.add(name);
1009
+ raw.states.push(name);
1010
+ } };
1011
+ const skip = /^(flowchart|graph)\b|^(classDef|class|style|linkStyle|click|direction)\b|^%%\{/;
1012
+ let inSubgraph = false;
1013
+ for (const rawLine of body) {
1014
+ const line = rawLine.trim();
1015
+ if (line === '' || skip.test(line))
1016
+ continue;
1017
+ if (/^subgraph\b/.test(line)) {
1018
+ if (!inSubgraph) {
1019
+ inSubgraph = true;
1020
+ raw.warnings.push('UML_PARSE_SUBGRAPH_FLATTENED: subgraph blocks were flattened; LogicModelV1 has no hierarchy.');
1021
+ }
1022
+ continue;
1023
+ }
1024
+ if (line === 'end') {
1025
+ inSubgraph = false;
1026
+ continue;
1027
+ }
1028
+ const shaped = /^(.*?)\s*-->\s*\|(.*?)\|\s*(.+)$/.exec(line);
1029
+ const bare = shaped === null ? /^(.*?)\s*-->\s*(.+)$/.exec(line) : null;
1030
+ if (shaped !== null || bare !== null) {
1031
+ const match = (shaped ?? bare);
1032
+ const from = stripNode(match[1].trim(), raw, declare);
1033
+ // Flowchart labels live between bars; the state-diagram form `A --> B : label`
1034
+ // is accepted too so a hand-written file that mixes the two still reads.
1035
+ let rawTo = (shaped === null ? match[2] : match[3]).trim();
1036
+ let label = shaped === null ? '' : shaped[2].replace(/^"|"$/g, '').trim();
1037
+ if (shaped === null) {
1038
+ const colon = rawTo.indexOf(':');
1039
+ if (colon >= 0) {
1040
+ label = rawTo.slice(colon + 1).trim().replace(/^"|"$/g, '');
1041
+ rawTo = rawTo.slice(0, colon).trim();
1042
+ }
1043
+ }
1044
+ const to = stripNode(rawTo, raw, declare);
1045
+ if (from === undefined || to === undefined)
1046
+ continue;
1047
+ raw.edges.push({ from, to, label });
1048
+ continue;
1049
+ }
1050
+ const node = stripNode(line, raw, declare);
1051
+ if (node === undefined)
1052
+ raw.warnings.push('UML_PARSE_IGNORED_LINE: "' + line + '" is not a flowchart statement; it was ignored.');
1053
+ }
1054
+ const model = rawToModel(raw, directives, raw.warnings);
1055
+ const labels = mapLabels(raw.display, directives);
1056
+ return { notation, diagram: 'activity', family: 'activity', model, labels, discardedConstructs: [], discardedEdges: 0, warnings: raw.warnings };
1057
+ }
1058
+ /**
1059
+ * Read one node reference (`A`, `A["label"]`, `A(["label"])`) and register the id
1060
+ * plus any display label it carries. Returns the node name, or undefined when the
1061
+ * text is not a node reference at all.
1062
+ */
1063
+ function stripNode(text, raw, declare) {
1064
+ const trimmed = text.trim();
1065
+ if (trimmed === '')
1066
+ return undefined;
1067
+ const match = /^([A-Za-z_][A-Za-z0-9_.-]*)\s*(\(\[|\[\(|\{\{|\[|\(|\(\(|>)?\s*([\s\S]*?)\s*$/.exec(trimmed);
1068
+ if (match === null) {
1069
+ const quoted = /^"([^"]+)"$/.exec(trimmed);
1070
+ if (quoted !== null) {
1071
+ declare(quoted[1]);
1072
+ return quoted[1];
1073
+ }
1074
+ return undefined;
1075
+ }
1076
+ const name = match[1];
1077
+ const shape = match[2] ?? '';
1078
+ const rest = match[3] ?? '';
1079
+ declare(name);
1080
+ const quoted = /"([^"]*)"/.exec(rest);
1081
+ const label = quoted !== null ? quoted[1].trim() : rest.replace(/^[\[\](){}>]+/, '').replace(/[\[\](){}>]+$/, '').trim();
1082
+ if (label !== '' && !raw.display.has(name))
1083
+ raw.display.set(name, label);
1084
+ if (shape === '([' || shape === '((' || rest.startsWith('(['))
1085
+ raw.terminals.push(name);
1086
+ return name;
1087
+ }
1088
+ function mapLabels(display, directives) {
1089
+ const out = {};
1090
+ for (const [name, label] of display)
1091
+ out[directives.aliases.get(name) ?? name] = label;
1092
+ return out;
1093
+ }
1094
+ /**
1095
+ * What the front end expects of labels and comments, so "why does my hand-drawn diagram
1096
+ * report label drift or ignored lines?" has a printed answer instead of a guess.
1097
+ * `tests/uml/run.mjs` feeds every claim here back through the parser, so this text cannot
1098
+ * drift away from the behaviour it describes.
1099
+ */
1100
+ export function explainLabels(notation = 'mermaid') {
1101
+ const prefix = commentPrefix(notation);
1102
+ return {
1103
+ notation,
1104
+ directivePrefix: prefix + DIRECTIVE_NAMESPACE,
1105
+ directiveLines: [
1106
+ { example: prefix + DIRECTIVE_NAMESPACE + 'uml v1 notation=' + notation + ' diagram=state', meaning: 'declares the notation and diagram kind the file was rendered for' },
1107
+ { example: prefix + DIRECTIVE_NAMESPACE + 'init ID', meaning: 'pins the initial state (otherwise it is inferred as the only state nothing enters)' },
1108
+ { example: prefix + DIRECTIVE_NAMESPACE + 'terminal ID', meaning: 'marks a terminal state; comma-separate several' },
1109
+ { example: prefix + DIRECTIVE_NAMESPACE + 'alias NOTATION_ID model_id', meaning: 'restores a state id the notation cannot spell verbatim' },
1110
+ { example: prefix + DIRECTIVE_NAMESPACE + 'variable NAME integer', meaning: 'restores a variable and its kind' },
1111
+ ],
1112
+ acceptedLabelForms: [
1113
+ { form: 'bare id', example: 'state "IDLE" as IDLE', note: 'counts as NO meaning: the state stays undocumented (UML014)' },
1114
+ { form: 'ID(meaning)', example: 'state "IDLE(waiting for power)" as IDLE', note: 'the form the renderer writes; full-width parentheses' },
1115
+ { form: 'ID(meaning)', example: 'state "IDLE(waiting for power)" as IDLE', note: 'accepted as well' },
1116
+ { form: 'a description line', example: 'IDLE : waiting for power', note: 'the same meaning written as a state description' },
1117
+ { form: 'a single-line note', example: 'note right of IDLE : waiting for power', note: 'read as the state meaning, not as a comment' },
1118
+ { form: 'any other text', example: 'state "waiting for power" as IDLE', note: 'taken as the meaning verbatim' },
1119
+ ],
1120
+ renderedForm: 'state "ID(meaning)" as ID',
1121
+ ignoredLines: [
1122
+ { pattern: 'a `' + prefix + '` comment line that is not a `dsh-model-everything:` directive', behaviour: 'UML_PARSE_IGNORED_LINE warning: it carries no state-machine statement' },
1123
+ { pattern: 'a `note …` block written over several lines', behaviour: 'its body is skipped line by line (prose, not a statement) and the block itself is reported once in discardedConstructs' },
1124
+ { pattern: 'notation chrome: @startuml/@enduml, stateDiagram-v2, direction, classDef/style/linkStyle/click, scale, skinparam, title, hide, autonumber', behaviour: 'skipped silently' },
1125
+ { pattern: 'anything else the parser cannot read', behaviour: 'UML_PARSE_IGNORED_LINE warning, and the model is built from what it did read' },
1126
+ ],
1127
+ rules: [
1128
+ 'Label drift (UML015) compares the meaning inside the label with narrative.states[id]: the wrapper is stripped, the remaining text must match character for character.',
1129
+ 'A meaning that differs only in wording is still drift — one of the two is stale, and the review cannot tell which.',
1130
+ 'Keep non-directive comments out of the diagram: `dsh-model-everything:` directives are the only comment lines the parser consumes.',
1131
+ 'A missing label is not an error: the state renders as its bare id and is reported as undocumented (UML014).',
1132
+ ],
1133
+ };
1134
+ }
1135
+ /**
1136
+ * Parse a Mermaid or PlantUML diagram back into a LogicModelV1.
1137
+ *
1138
+ * State and activity diagrams carry the whole machine, so they parse into a
1139
+ * complete model. Sequence diagrams do not: a trace shows the paths that were
1140
+ * walked, not the branches that were not, so parsing one would silently prune
1141
+ * the machine. That case is refused rather than approximated.
1142
+ *
1143
+ * @param text - diagram source.
1144
+ * @param notation - `auto` (default) detects Mermaid vs PlantUML from the text.
1145
+ */
1146
+ export function parseUml(text, notation = 'auto') {
1147
+ const resolved = notation === 'auto' ? detectUmlNotation(text) : notation;
1148
+ const hint = declaredFamily(readUmlDirectives(text.split(/\r?\n/), resolved));
1149
+ const detection = detectUmlFamily(text, resolved, hint === undefined ? {} : { hint });
1150
+ const family = detection.family;
1151
+ const capability = umlCapability(resolved, family, 'parse');
1152
+ if (!capability.ok)
1153
+ throw new UmlError(capability.message, capability.errorCode);
1154
+ if (family === 'state' || family === 'activity') {
1155
+ return family === 'state' ? parseStateDiagram(text, resolved) : parseActivityDiagram(text, resolved);
1156
+ }
1157
+ const module = familyModule(family);
1158
+ if (module === undefined) {
1159
+ throw new UmlError('this text is a ' + umlFamilySpec(family).name + ' (' + detection.evidence + '), and this build has no module for it yet: reading it with the state parser would produce a model of a different diagram. Use uml_structure_verify when the question is where the dependencies point, or render the diagram you mean with uml action=render.', 'UML_NOT_A_STATE_DIAGRAM');
1160
+ }
1161
+ const parsed = module.parse(text, resolved);
1162
+ return { notation: resolved, diagram: family, family, model: parsed.model, labels: parsed.labels, discardedConstructs: parsed.discardedConstructs, discardedEdges: parsed.discardedEdges, warnings: parsed.warnings };
1163
+ }
1164
+ // ---------------------------------------------------------------------------
1165
+ // Review
1166
+ // ---------------------------------------------------------------------------
1167
+ function reachableStates(model) {
1168
+ const adjacency = new Map();
1169
+ for (const transition of model.transitions) {
1170
+ const list = adjacency.get(transition.from);
1171
+ if (list === undefined)
1172
+ adjacency.set(transition.from, [transition.to]);
1173
+ else
1174
+ list.push(transition.to);
1175
+ }
1176
+ const visited = new Set([model.init]);
1177
+ const queue = [model.init];
1178
+ while (queue.length > 0) {
1179
+ const current = queue.shift();
1180
+ for (const next of adjacency.get(current) ?? []) {
1181
+ if (!visited.has(next)) {
1182
+ visited.add(next);
1183
+ queue.push(next);
1184
+ }
1185
+ }
1186
+ }
1187
+ return visited;
1188
+ }
1189
+ function canonicalTransition(transition) {
1190
+ return transition.from + '|' + transition.event + '|' + transition.to + '|' + (transition.guard === undefined ? '' : guardText(transition.guard)) + '|' + (transition.updates === undefined ? '' : updatesText(transition.updates));
1191
+ }
1192
+ /** The narrative meaning a diagram label carries, if it carries one beyond the bare id. */
1193
+ function documentedMeaning(label, id) {
1194
+ if (label === undefined)
1195
+ return undefined;
1196
+ const trimmed = label.trim();
1197
+ if (trimmed === '' || trimmed === id)
1198
+ return undefined;
1199
+ for (const [open, close] of [['(', ')'], ['(', ')']]) {
1200
+ const prefix = id + open;
1201
+ if (trimmed.startsWith(prefix) && trimmed.endsWith(close))
1202
+ return trimmed.slice(prefix.length, trimmed.length - close.length);
1203
+ }
1204
+ return trimmed;
1205
+ }
1206
+ function structuralFindings(model, labels, warnings) {
1207
+ const findings = [];
1208
+ const reachable = reachableStates(model);
1209
+ const outgoing = new Map();
1210
+ for (const transition of model.transitions) {
1211
+ const list = outgoing.get(transition.from);
1212
+ if (list === undefined)
1213
+ outgoing.set(transition.from, [transition]);
1214
+ else
1215
+ list.push(transition);
1216
+ }
1217
+ const unreachable = model.states.map((state) => state.id).filter((id) => !reachable.has(id));
1218
+ if (unreachable.length > 0) {
1219
+ findings.push({
1220
+ code: 'UML002_UNREACHABLE_STATE',
1221
+ severity: 'error',
1222
+ message: unreachable.length + ' state(s) cannot be entered from init along any transition, so the diagram draws flow nobody can reach.',
1223
+ states: unreachable,
1224
+ detail: 'Structural reachability (guards ignored). Guard-aware reachability is S1 in model_verify.',
1225
+ });
1226
+ }
1227
+ const deadEnds = model.states.filter((state) => state.terminal !== true && (outgoing.get(state.id) ?? []).length === 0).map((state) => state.id);
1228
+ if (deadEnds.length > 0) {
1229
+ findings.push({
1230
+ code: 'UML003_DEAD_END_STATE',
1231
+ severity: 'error',
1232
+ message: deadEnds.length + ' non-terminal state(s) have no outgoing transition: the flow stops there without a modelled terminal.',
1233
+ states: deadEnds,
1234
+ detail: 'Either the state is terminal (add `X --> [*]`) or the outgoing flow is missing from the model. model_verify S2 reports the same shape at runtime granularity.',
1235
+ });
1236
+ }
1237
+ const groups = new Map();
1238
+ for (const transition of model.transitions) {
1239
+ const key = transition.from + '\u0000' + transition.event;
1240
+ const list = groups.get(key);
1241
+ if (list === undefined)
1242
+ groups.set(key, [transition]);
1243
+ else
1244
+ list.push(transition);
1245
+ }
1246
+ const ambiguous = [];
1247
+ const overlapping = [];
1248
+ const inexhaustive = [];
1249
+ const probablyExhaustive = [];
1250
+ const complementary = [];
1251
+ for (const group of groups.values()) {
1252
+ const unguarded = group.filter((transition) => transition.guard === undefined);
1253
+ const guarded = group.filter((transition) => transition.guard !== undefined);
1254
+ if (unguarded.length > 1) {
1255
+ for (const transition of unguarded)
1256
+ ambiguous.push({ from: transition.from, event: transition.event, to: transition.to });
1257
+ }
1258
+ const seenGuards = new Map();
1259
+ for (const transition of guarded) {
1260
+ const text = guardText(transition.guard);
1261
+ const previous = seenGuards.get(text);
1262
+ if (previous !== undefined)
1263
+ overlapping.push({ from: transition.from, event: transition.event, to: transition.to });
1264
+ else
1265
+ seenGuards.set(text, transition);
1266
+ }
1267
+ if (guarded.length > 0 && unguarded.length === 0) {
1268
+ const row = { from: group[0].from, event: group[0].event, to: group[0].to };
1269
+ // A complementary pair on one variable (`x < 3` / `x >= 3`) is exhaustive for
1270
+ // any valuation, so the structural warning would be a false alarm there. The
1271
+ // pair test is deliberately narrow — it cannot prove exhaustiveness, only
1272
+ // recognise the common shape, which is why the finding stays on the report at
1273
+ // info severity and still routes to S6.
1274
+ const witness = complementaryVariable(guarded.map((transition) => transition.guard));
1275
+ if (witness === undefined)
1276
+ inexhaustive.push(row);
1277
+ else {
1278
+ probablyExhaustive.push(row);
1279
+ complementary.push(witness);
1280
+ }
1281
+ }
1282
+ }
1283
+ if (ambiguous.length > 0) {
1284
+ findings.push({
1285
+ code: 'UML004_AMBIGUOUS_BRANCH',
1286
+ severity: 'error',
1287
+ 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.',
1288
+ transitions: ambiguous,
1289
+ detail: 'Keep one unguarded branch per (state, event) as the else case, and guard the others. model_verify S4 is the authoritative determinism check.',
1290
+ });
1291
+ }
1292
+ if (overlapping.length > 0) {
1293
+ findings.push({
1294
+ code: 'UML005_OVERLAPPING_GUARD',
1295
+ severity: 'warning',
1296
+ message: overlapping.length + ' transition(s) repeat a guard already used by another branch of the same (state, event).',
1297
+ transitions: overlapping,
1298
+ });
1299
+ }
1300
+ if (inexhaustive.length > 0) {
1301
+ findings.push({
1302
+ code: 'UML006_INEXHAUSTIVE_BRANCH',
1303
+ severity: 'warning',
1304
+ 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.',
1305
+ transitions: inexhaustive,
1306
+ detail: 'Add an unguarded else branch, or confirm exhaustiveness with model_verify S6 (which evaluates guards over real valuations).',
1307
+ });
1308
+ }
1309
+ if (probablyExhaustive.length > 0) {
1310
+ findings.push({
1311
+ code: 'UML006_INEXHAUSTIVE_BRANCH',
1312
+ severity: 'info',
1313
+ 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 model_verify S6 can settle it.',
1314
+ transitions: probablyExhaustive,
1315
+ });
1316
+ }
1317
+ const eventsByReachable = new Set();
1318
+ const eventsAnywhere = new Set();
1319
+ for (const transition of model.transitions) {
1320
+ eventsAnywhere.add(transition.event);
1321
+ if (reachable.has(transition.from))
1322
+ eventsByReachable.add(transition.event);
1323
+ }
1324
+ const deadEvents = [...eventsAnywhere].filter((event) => !eventsByReachable.has(event));
1325
+ if (deadEvents.length > 0) {
1326
+ findings.push({
1327
+ code: 'UML007_UNUSED_EVENT',
1328
+ severity: 'warning',
1329
+ message: deadEvents.length + ' event(s) only fire from states nothing can reach, so the diagram shows messages that never arrive.',
1330
+ events: deadEvents,
1331
+ });
1332
+ }
1333
+ const selfLoops = model.transitions.filter((transition) => transition.from === transition.to && transition.guard === undefined
1334
+ && (outgoing.get(transition.from) ?? []).length === 1);
1335
+ if (selfLoops.length > 0) {
1336
+ findings.push({
1337
+ code: 'UML008_SELF_LOOP_NO_EXIT',
1338
+ severity: 'warning',
1339
+ 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.',
1340
+ states: [...new Set(selfLoops.map((transition) => transition.from))],
1341
+ detail: 'model_verify S3 reports absorbing cycles (liveness).',
1342
+ });
1343
+ }
1344
+ const seenTransitions = new Map();
1345
+ for (const transition of model.transitions) {
1346
+ const key = canonicalTransition(transition);
1347
+ seenTransitions.set(key, (seenTransitions.get(key) ?? 0) + 1);
1348
+ }
1349
+ const duplicates = model.transitions.filter((transition) => (seenTransitions.get(canonicalTransition(transition)) ?? 0) > 1);
1350
+ if (duplicates.length > 0) {
1351
+ findings.push({
1352
+ code: 'UML009_DUPLICATE_TRANSITION',
1353
+ severity: 'warning',
1354
+ message: duplicates.length + ' transition(s) duplicate an identical (from, event, guard, actions, to) row; the diagram draws the same arrow twice.',
1355
+ transitions: duplicates.map((transition) => ({ from: transition.from, event: transition.event, to: transition.to })),
1356
+ });
1357
+ }
1358
+ const guardVariables = new Set();
1359
+ for (const transition of model.transitions) {
1360
+ if (transition.guard !== undefined)
1361
+ collectGuardVariables(transition.guard, guardVariables);
1362
+ }
1363
+ const updatedVariables = new Set();
1364
+ for (const transition of model.transitions)
1365
+ for (const update of transition.updates ?? [])
1366
+ updatedVariables.add(update.variable);
1367
+ const unusedVariables = (model.variables ?? []).map((variable) => variable.name).filter((name) => !guardVariables.has(name) && !updatedVariables.has(name));
1368
+ if (unusedVariables.length > 0) {
1369
+ findings.push({
1370
+ code: 'UML010_UNUSED_VARIABLE',
1371
+ severity: 'warning',
1372
+ message: unusedVariables.length + ' variable(s) are never read by a guard and never written: the diagram carries a symbol with no source.',
1373
+ events: unusedVariables,
1374
+ });
1375
+ }
1376
+ const unbounded = (model.variables ?? []).filter((variable) => variable.kind === 'integer' && (variable.min === undefined || variable.max === undefined)).map((variable) => variable.name);
1377
+ if (unbounded.length > 0) {
1378
+ findings.push({
1379
+ code: 'UML011_UNBOUNDED_VARIABLE',
1380
+ severity: 'info',
1381
+ 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.',
1382
+ detail: unbounded.join(', '),
1383
+ });
1384
+ }
1385
+ const terminals = model.states.filter((state) => state.terminal === true).map((state) => state.id);
1386
+ if (terminals.length === 0) {
1387
+ findings.push({
1388
+ code: 'UML012_NO_TERMINAL',
1389
+ severity: 'warning',
1390
+ message: 'no state is terminal: the diagram has no `--> [*]`, so completion, failure and a stuck flow look the same.',
1391
+ detail: 'Mark absorbing states terminal, or state explicitly that the machine is non-terminating.',
1392
+ });
1393
+ }
1394
+ if (model.narrative === undefined) {
1395
+ findings.push({
1396
+ code: 'UML013_NO_NARRATIVE',
1397
+ severity: 'info',
1398
+ 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.',
1399
+ detail: 'Add narrative.states / narrative.events / narrative.scenarios — a partial narrative is valid and its coverage is reported; write the states first and fill the rest in later.',
1400
+ });
1401
+ }
1402
+ else {
1403
+ // A narrative that covers part of the model is valid: states first, events and
1404
+ // scenarios later is the natural authoring order. The gap is reported as coverage
1405
+ // (and as this info finding), never as a validation failure.
1406
+ const coverage = narrativeCoverageOf(model);
1407
+ if (coverage !== undefined && !narrativeComplete(coverage)) {
1408
+ findings.push({
1409
+ code: 'UML027_NARRATIVE_PARTIAL',
1410
+ severity: 'info',
1411
+ message: 'the narrative covers part of the model: states ' + coverage.states + ', events ' + coverage.events + ', scenarios ' + coverage.scenarios + '. Every uncovered symbol still has to be re-derived from the source.',
1412
+ detail: 'narrativeCoverage carries the same numbers; complete the missing entries when the behaviour settles.',
1413
+ evidence: { narrativeCoverage: coverage },
1414
+ });
1415
+ }
1416
+ }
1417
+ const documented = labels === undefined ? undefined : Object.keys(labels).filter((id) => documentedMeaning(labels[id], id) !== undefined);
1418
+ if (documented !== undefined) {
1419
+ const undocumented = model.states.map((state) => state.id).filter((id) => documentedMeaning(labels?.[id], id) === undefined);
1420
+ if (undocumented.length > 0) {
1421
+ findings.push({
1422
+ code: 'UML014_UNDOCUMENTED_STATE',
1423
+ severity: 'info',
1424
+ message: undocumented.length + ' of ' + String(model.states.length) + ' states carry no meaning in the diagram (they render as their bare id).',
1425
+ states: undocumented,
1426
+ detail: 'Give each state a `state "meaning" as ID` label or `ID : meaning` description so the diagram can be read against the code.',
1427
+ });
1428
+ }
1429
+ if (model.narrative?.states !== undefined) {
1430
+ const drift = [];
1431
+ for (const state of model.states) {
1432
+ const meaning = documentedMeaning(labels?.[state.id], state.id);
1433
+ const declared = model.narrative.states[state.id];
1434
+ if (meaning !== undefined && declared !== undefined && meaning !== declared)
1435
+ drift.push(state.id + ': diagram "' + meaning + '" vs narrative "' + declared + '"');
1436
+ }
1437
+ if (drift.length > 0) {
1438
+ findings.push({
1439
+ code: 'UML015_LABEL_DRIFT',
1440
+ severity: 'warning',
1441
+ message: drift.length + ' state label(s) disagree with the model narrative: one of the two is stale, and the review cannot tell which.',
1442
+ detail: drift.join(' | '),
1443
+ });
1444
+ }
1445
+ }
1446
+ }
1447
+ if (warnings.length > 0) {
1448
+ findings.push({
1449
+ code: 'UML016_DIAGRAM_PARSE_NOTES',
1450
+ severity: 'info',
1451
+ message: warnings.length + ' note(s) were produced while rendering or reading the diagram; they mark information the notation could not carry.',
1452
+ detail: warnings.join(' | '),
1453
+ });
1454
+ }
1455
+ return findings;
1456
+ }
1457
+ function collectGuardVariables(guard, sink) {
1458
+ if ('variable' in guard) {
1459
+ sink.add(guard.variable);
1460
+ return;
1461
+ }
1462
+ if ('all' in guard) {
1463
+ for (const child of guard.all)
1464
+ collectGuardVariables(child, sink);
1465
+ return;
1466
+ }
1467
+ if ('any' in guard) {
1468
+ for (const child of guard.any)
1469
+ collectGuardVariables(child, sink);
1470
+ return;
1471
+ }
1472
+ collectGuardVariables(guard.not, sink);
1473
+ }
1474
+ /** Positive, conjunctively-reached leaves of a guard tree — the only ones a complementarity witness may use. */
1475
+ function positiveLeaves(guard, sink) {
1476
+ if ('variable' in guard) {
1477
+ sink.push(guard);
1478
+ return;
1479
+ }
1480
+ if ('all' in guard) {
1481
+ for (const child of guard.all)
1482
+ positiveLeaves(child, sink);
1483
+ return;
1484
+ }
1485
+ // `any` and `not` subtrees are skipped on purpose: a leaf under a disjunction is
1486
+ // not implied by its branch, and `not (x < 3)` is `x >= 3` only for totally
1487
+ // ordered integers — neither is a sound complementarity witness.
1488
+ }
1489
+ const COMPLEMENTS = [['<', '>='], ['<=', '>'], ['==', '!=']];
1490
+ /**
1491
+ * Name a variable whose guards contain a complementary pair (`x < 3` next to
1492
+ * `x >= 3`), which makes the branch group exhaustive for every valuation. Returns
1493
+ * undefined when no such pair exists — absence is not proof of a gap.
1494
+ */
1495
+ function complementaryVariable(guards) {
1496
+ const leaves = [];
1497
+ for (const guard of guards)
1498
+ positiveLeaves(guard, leaves);
1499
+ for (let i = 0; i < leaves.length; i += 1) {
1500
+ for (let j = i + 1; j < leaves.length; j += 1) {
1501
+ const left = leaves[i];
1502
+ const right = leaves[j];
1503
+ if (left.variable !== right.variable)
1504
+ continue;
1505
+ if (left.value !== right.value)
1506
+ continue;
1507
+ for (const [one, other] of COMPLEMENTS) {
1508
+ if ((left.op === one && right.op === other) || (left.op === other && right.op === one))
1509
+ return left.variable;
1510
+ }
1511
+ }
1512
+ }
1513
+ return undefined;
1514
+ }
1515
+ /** Narrow a family model to a machine, or say plainly that the review got the wrong one. */
1516
+ function asMachineModel(model) {
1517
+ const candidate = model;
1518
+ if (typeof candidate.init === 'string' && Array.isArray(candidate.states) && Array.isArray(candidate.transitions))
1519
+ return candidate;
1520
+ throw new UmlError('this review expected a state machine and the model is a different UML diagram: review the family model on its own, or model the machine as a state or activity diagram.', 'UML_INPUT');
1521
+ }
1522
+ /**
1523
+ * Compare two family models by structure: the fidelity measure for a family whose
1524
+ * model is not a machine. Every path that differs is named, so a mismatch reads as
1525
+ * the element that changed rather than as a wall of JSON.
1526
+ */
1527
+ function diffJsonModels(left, right, path = '') {
1528
+ const diffs = [];
1529
+ const at = (name) => (path === '' ? name : path + '.' + name);
1530
+ if (Array.isArray(left) && Array.isArray(right)) {
1531
+ const length = Math.max(left.length, right.length);
1532
+ for (let index = 0; index < length; index += 1) {
1533
+ if (index >= left.length) {
1534
+ diffs.push(at('[' + String(index) + ']') + ' invented by the diagram');
1535
+ continue;
1536
+ }
1537
+ if (index >= right.length) {
1538
+ diffs.push(at('[' + String(index) + ']') + ' missing after parse');
1539
+ continue;
1540
+ }
1541
+ diffs.push(...diffJsonModels(left[index], right[index], path + '[' + String(index) + ']'));
1542
+ }
1543
+ return diffs;
1544
+ }
1545
+ if (left !== null && right !== null && typeof left === 'object' && typeof right === 'object') {
1546
+ const leftRecord = left;
1547
+ const rightRecord = right;
1548
+ for (const key of [...new Set([...Object.keys(leftRecord), ...Object.keys(rightRecord)])].sort()) {
1549
+ if (!(key in rightRecord)) {
1550
+ diffs.push(at(key) + ' missing after parse');
1551
+ continue;
1552
+ }
1553
+ if (!(key in leftRecord)) {
1554
+ diffs.push(at(key) + ' invented by the diagram');
1555
+ continue;
1556
+ }
1557
+ diffs.push(...diffJsonModels(leftRecord[key], rightRecord[key], at(key)));
1558
+ }
1559
+ return diffs;
1560
+ }
1561
+ if (JSON.stringify(left) !== JSON.stringify(right))
1562
+ diffs.push((path === '' ? '(root)' : path) + ': ' + JSON.stringify(left) + ' vs ' + JSON.stringify(right));
1563
+ return diffs;
1564
+ }
1565
+ function compiledModel(input) {
1566
+ const validation = validateModel(input);
1567
+ if (!validation.ok)
1568
+ throw new UmlError('model invalid: ' + validation.errors.join('; '));
1569
+ return validation.model;
1570
+ }
1571
+ function roundTripOf(model, notation, diagram, maxSteps) {
1572
+ const rendered = renderUml(model, notation, diagram, maxSteps);
1573
+ const parsed = parseUml(rendered.primary, notation);
1574
+ const parsedModel = asMachineModel(parsed.model);
1575
+ const diffs = diffModels(model, parsedModel);
1576
+ return {
1577
+ report: {
1578
+ notation,
1579
+ diagram,
1580
+ ok: diffs.length === 0,
1581
+ hashSpec: DEFAULT_HASH_SPEC,
1582
+ modelHash: modelHash(model),
1583
+ parsedHash: modelHash(parsedModel),
1584
+ diffs,
1585
+ warnings: [...rendered.warnings, ...parsed.warnings],
1586
+ },
1587
+ primary: rendered.primary,
1588
+ parsed,
1589
+ };
1590
+ }
1591
+ /** Compare two machines by structure — the fidelity measure behind the round-trip check. */
1592
+ export function diffModels(left, right) {
1593
+ const diffs = [];
1594
+ if (left.init !== right.init)
1595
+ diffs.push('init: ' + left.init + ' vs ' + right.init);
1596
+ const leftStates = left.states.map((state) => state.id);
1597
+ const rightStates = right.states.map((state) => state.id);
1598
+ for (const id of leftStates)
1599
+ if (!rightStates.includes(id))
1600
+ diffs.push('state missing after parse: ' + id);
1601
+ for (const id of rightStates)
1602
+ if (!leftStates.includes(id))
1603
+ diffs.push('state invented by the diagram: ' + id);
1604
+ const leftTerminal = left.states.filter((state) => state.terminal === true).map((state) => state.id).sort();
1605
+ const rightTerminal = right.states.filter((state) => state.terminal === true).map((state) => state.id).sort();
1606
+ if (leftTerminal.join(',') !== rightTerminal.join(','))
1607
+ diffs.push('terminal states: [' + leftTerminal.join(', ') + '] vs [' + rightTerminal.join(', ') + ']');
1608
+ const leftTransitions = left.transitions.map(canonicalTransition).sort();
1609
+ const rightTransitions = right.transitions.map(canonicalTransition).sort();
1610
+ const leftCount = new Map();
1611
+ for (const key of leftTransitions)
1612
+ leftCount.set(key, (leftCount.get(key) ?? 0) + 1);
1613
+ const rightCount = new Map();
1614
+ for (const key of rightTransitions)
1615
+ rightCount.set(key, (rightCount.get(key) ?? 0) + 1);
1616
+ for (const [key, count] of leftCount) {
1617
+ const other = rightCount.get(key) ?? 0;
1618
+ if (other < count)
1619
+ diffs.push('transition lost in the diagram (' + String(count - other) + 'x): ' + key.replace(/\|/g, ' '));
1620
+ }
1621
+ for (const [key, count] of rightCount) {
1622
+ const other = leftCount.get(key) ?? 0;
1623
+ if (other < count)
1624
+ diffs.push('transition invented by the diagram (' + String(count - other) + 'x): ' + key.replace(/\|/g, ' '));
1625
+ }
1626
+ const leftVariables = (left.variables ?? []).map((variable) => variable.name + ':' + variable.kind).sort();
1627
+ const rightVariables = (right.variables ?? []).map((variable) => variable.name + ':' + variable.kind).sort();
1628
+ for (const name of leftVariables)
1629
+ if (!rightVariables.includes(name))
1630
+ diffs.push('variable missing after parse: ' + name);
1631
+ for (const name of rightVariables)
1632
+ if (!leftVariables.includes(name))
1633
+ diffs.push('variable invented by the diagram: ' + name);
1634
+ return diffs;
1635
+ }
1636
+ /**
1637
+ * Review a UML model of a code flow.
1638
+ *
1639
+ * Three inputs are possible and each answers a different question:
1640
+ *
1641
+ * - `model` only — "is this machine well-modelled?" The review checks the
1642
+ * structure the diagram would draw, then renders and re-parses it to prove the
1643
+ * diagram carries the machine faithfully (round trip).
1644
+ * - `diagram` only — "what does this diagram actually say?" The diagram is parsed
1645
+ * into a model, and that model is reviewed; nothing can be said about fidelity
1646
+ * to a machine the caller did not provide.
1647
+ * - both — "does this diagram match this model?" Any structural difference is a
1648
+ * modelling defect and is reported both as round-trip diffs and as a finding.
1649
+ */
1650
+ /**
1651
+ * The notation the caller asked for, or a refusal naming it.
1652
+ *
1653
+ * `auto` is the one value that means "decide for me". Anything else must be a notation this engine
1654
+ * reads or writes, because the review's answer depends on it: coercing `'graphviz'` into Mermaid and
1655
+ * reporting a Mermaid review answers a question the caller did not ask.
1656
+ */
1657
+ function requireNotation(value) {
1658
+ if (value === undefined || value === 'auto')
1659
+ return 'auto';
1660
+ if (isUmlNotation(value))
1661
+ return value;
1662
+ throw new UmlError('notation must be auto, ' + UML_NOTATIONS.join(' or ') + ', got ' + JSON.stringify(value) + '.', 'UML_INPUT');
1663
+ }
1664
+ export function reviewUml(options) {
1665
+ requireNotation(options.notation);
1666
+ const warnings = [];
1667
+ const findings = [];
1668
+ const hasModel = options.model !== undefined;
1669
+ const hasDiagram = typeof options.diagram === 'string' && options.diagram.trim() !== '';
1670
+ if (!hasModel && !hasDiagram)
1671
+ throw new UmlError('review needs `model`, `diagram`, or both');
1672
+ // A family with a module of its own is reviewed by that module. Detection happens
1673
+ // first: handing a use case diagram to the machine path is the defect this fixes.
1674
+ if (hasDiagram) {
1675
+ let detected;
1676
+ try {
1677
+ const text = options.diagram;
1678
+ const resolved = options.notation === undefined || options.notation === 'auto' ? detectUmlNotation(text) : options.notation;
1679
+ const hint = declaredFamily(readUmlDirectives(text.split(/\r?\n/), resolved));
1680
+ detected = { family: detectUmlFamily(text, resolved, hint === undefined ? {} : { hint }).family, notation: resolved };
1681
+ }
1682
+ catch {
1683
+ // Detection failed: the machine path reports that as UML001_DIAGRAM_UNREADABLE,
1684
+ // with the refusal message, and never as a pass. Only detection may fail here -
1685
+ // a review that throws must surface, not fall through to the wrong parser.
1686
+ detected = undefined;
1687
+ }
1688
+ if (detected !== undefined) {
1689
+ const module = familyModule(detected.family);
1690
+ if (module !== undefined) {
1691
+ return reviewFamilyUml({
1692
+ family: detected.family, module, notation: detected.notation, diagram: options.diagram,
1693
+ model: options.model, roundTrip: options.roundTrip !== false, metadataKeys: metadataKeysOf(options.model),
1694
+ intent: options.intent, canParse: umlFamilySpec(detected.family).parseable, parsedNotation: detected.notation,
1695
+ });
1696
+ }
1697
+ }
1698
+ }
1699
+ else if (options.diagramKind !== undefined) {
1700
+ const module = familyModule(options.diagramKind);
1701
+ if (module !== undefined) {
1702
+ const notation = options.notation === undefined || options.notation === 'auto' ? 'plantuml' : options.notation;
1703
+ return reviewFamilyUml({
1704
+ family: options.diagramKind, module, notation, model: options.model,
1705
+ roundTrip: options.roundTrip !== false, metadataKeys: metadataKeysOf(options.model), intent: options.intent,
1706
+ canParse: umlFamilySpec(options.diagramKind).parseable,
1707
+ });
1708
+ }
1709
+ }
1710
+ let notation = options.notation === undefined || options.notation === 'auto' ? 'mermaid' : options.notation;
1711
+ const kind = options.diagramKind ?? 'state';
1712
+ checkUmlIntent(options.intent, 'review', { notation, diagram: kind });
1713
+ let model;
1714
+ let labels;
1715
+ let parsed;
1716
+ let primary;
1717
+ let roundTrip = null;
1718
+ const metadataKeys = metadataKeysOf(hasModel ? options.model : undefined);
1719
+ const metadata = metadataKeys.length === 0 ? {} : { metadataKeys };
1720
+ let discardedConstructs = [];
1721
+ let discardedEdges = 0;
1722
+ if (hasDiagram) {
1723
+ try {
1724
+ parsed = parseUml(options.diagram, options.notation ?? 'auto');
1725
+ }
1726
+ catch (error) {
1727
+ const message = error instanceof Error ? error.message : String(error);
1728
+ const unreadable = { code: 'UML001_DIAGRAM_UNREADABLE', severity: 'error', message, detail: 'The diagram could not be read as a Mermaid/PlantUML state or activity diagram.' };
1729
+ return {
1730
+ // `ok: true`, because this IS a review: it ran, it examined the text, and it reported that the text
1731
+ // cannot be read. `ok: false` is for a refusal - no report at all - and a reader keying on it was told
1732
+ // "nothing happened" about the one answer that says exactly what is wrong. Every other family sets
1733
+ // `ok: true` for a failing review; this was the only report that did not.
1734
+ ok: true,
1735
+ ran: true,
1736
+ ...verdictOfFindings([unreadable]),
1737
+ schema: REPORT_SCHEMAS.umlReview,
1738
+ source: hasModel ? 'model+diagram' : 'diagram',
1739
+ summary: { errors: 1, warnings: 0, info: 0, states: 0, events: 0, transitions: 0, terminalStates: 0, reachableStates: 0, documentedStates: 0 },
1740
+ findings: [unreadable],
1741
+ roundTrip: null,
1742
+ ...metadata,
1743
+ hashes: { hashSpec: DEFAULT_HASH_SPEC },
1744
+ warnings,
1745
+ nextSteps: ['Fix the diagram syntax (or render one from a model with uml action=render) and review again.'],
1746
+ };
1747
+ }
1748
+ notation = parsed.notation;
1749
+ labels = parsed.labels;
1750
+ warnings.push(...parsed.warnings);
1751
+ // A diagram from another family parses into something; that something must never
1752
+ // be read as a model of this file, so the parse defects are review findings here too.
1753
+ findings.push(...parseFindings(parsed));
1754
+ if (parsed.discardedConstructs.length > 0) {
1755
+ discardedConstructs = parsed.discardedConstructs;
1756
+ discardedEdges = parsed.discardedEdges;
1757
+ }
1758
+ if (hasModel) {
1759
+ model = compiledModel(options.model);
1760
+ const parsedMachine = asMachineModel(parsed.model);
1761
+ const diffs = diffModels(model, parsedMachine);
1762
+ roundTrip = {
1763
+ notation: parsed.notation,
1764
+ diagram: parsed.diagram,
1765
+ ok: diffs.length === 0,
1766
+ hashSpec: DEFAULT_HASH_SPEC,
1767
+ modelHash: modelHash(model),
1768
+ parsedHash: modelHash(parsedMachine),
1769
+ diffs,
1770
+ warnings: [...parsed.warnings],
1771
+ };
1772
+ if (diffs.length > 0) {
1773
+ findings.push({
1774
+ code: 'UML017_ROUND_TRIP_MISMATCH',
1775
+ severity: 'error',
1776
+ message: 'the diagram does not carry the model it is presented with: ' + String(diffs.length) + ' structural difference(s).',
1777
+ detail: diffs.slice(0, 12).join(' | ') + (diffs.length > 12 ? ' | … ' + String(diffs.length - 12) + ' more' : ''),
1778
+ });
1779
+ }
1780
+ if (diffs.length === 0 && labels !== undefined && model.narrative === undefined) {
1781
+ 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.');
1782
+ }
1783
+ }
1784
+ else {
1785
+ model = asMachineModel(parsed.model);
1786
+ findings.push({
1787
+ code: 'UML018_FIDELITY_UNCHECKED',
1788
+ severity: 'info',
1789
+ 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.',
1790
+ 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.',
1791
+ });
1792
+ }
1793
+ }
1794
+ else {
1795
+ model = compiledModel(options.model);
1796
+ // A sequence view is a trace, so parsing it back would drop every branch the
1797
+ // walk never took: the fidelity check cannot apply to it, and pretending it did
1798
+ // would either fail spuriously or hide the difference. Render it anyway (the
1799
+ // caller asked for that view) and say the check does not apply.
1800
+ if (kind === 'sequence') {
1801
+ try {
1802
+ const rendered = renderUml(model, notation, kind, options.maxSteps ?? 60);
1803
+ primary = rendered.primary;
1804
+ warnings.push(...rendered.warnings);
1805
+ }
1806
+ catch (error) {
1807
+ warnings.push('UML_REVIEW_RENDER_SKIPPED: ' + (error instanceof Error ? error.message : String(error)));
1808
+ }
1809
+ findings.push({
1810
+ code: 'UML019_ROUND_TRIP_SKIPPED',
1811
+ severity: 'warning',
1812
+ 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.',
1813
+ });
1814
+ }
1815
+ else if (options.roundTrip !== false) {
1816
+ try {
1817
+ const rendered = roundTripOf(model, notation, kind, options.maxSteps ?? 60);
1818
+ primary = rendered.primary;
1819
+ roundTrip = rendered.report;
1820
+ warnings.push(...rendered.report.warnings);
1821
+ if (!rendered.report.ok) {
1822
+ findings.push({
1823
+ code: 'UML017_ROUND_TRIP_MISMATCH',
1824
+ severity: 'error',
1825
+ message: 'the rendered diagram does not read back as the model: ' + String(rendered.report.diffs.length) + ' structural difference(s).',
1826
+ detail: rendered.report.diffs.slice(0, 12).join(' | '),
1827
+ });
1828
+ }
1829
+ }
1830
+ catch (error) {
1831
+ const message = error instanceof Error ? error.message : String(error);
1832
+ warnings.push('UML_REVIEW_ROUND_TRIP_SKIPPED: ' + message);
1833
+ findings.push({
1834
+ code: 'UML019_ROUND_TRIP_SKIPPED',
1835
+ severity: 'warning',
1836
+ message: 'the fidelity check could not run for ' + notation + '/' + kind + ': ' + message,
1837
+ });
1838
+ }
1839
+ }
1840
+ else {
1841
+ findings.push({
1842
+ code: 'UML019_ROUND_TRIP_SKIPPED',
1843
+ severity: 'warning',
1844
+ message: 'the fidelity check was switched off (roundTrip=false); nothing here proves the diagram carries the model.',
1845
+ });
1846
+ }
1847
+ }
1848
+ findings.push(...structuralFindings(model, labels, warnings));
1849
+ const errors = findings.filter((finding) => finding.severity === 'error').length;
1850
+ const warningCount = findings.filter((finding) => finding.severity === 'warning').length;
1851
+ const info = findings.filter((finding) => finding.severity === 'info').length;
1852
+ const reachable = reachableStates(model);
1853
+ const documentedStates = labels === undefined
1854
+ ? (model.narrative?.states === undefined ? 0 : Object.keys(model.narrative.states).length)
1855
+ : Object.keys(labels).filter((id) => documentedMeaning(labels?.[id], id) !== undefined).length;
1856
+ const events = new Set(model.transitions.map((transition) => transition.event));
1857
+ const narrativeCoverage = narrativeCoverageOf(model);
1858
+ const nextSteps = [];
1859
+ if (errors > 0)
1860
+ nextSteps.push('Resolve the error findings first — a diagram that cannot be read (or that disagrees with its model) will mislead every later review.');
1861
+ if (findings.some((finding) => finding.code === 'UML_NOT_A_STATE_DIAGRAM'))
1862
+ nextSteps.push('This text is not a state or activity diagram: keep it as its own view and model the machine as a state/activity diagram before reviewing it.');
1863
+ nextSteps.push('Run model_verify on this model for the behavioural checks (S1-S8 structural, A1-A14 adversarial); the review above covers modelling, not behaviour.');
1864
+ if (model.narrative === undefined)
1865
+ nextSteps.push('Add narrative.states/events/scenarios so the diagram is readable against the code.');
1866
+ if (findings.some((finding) => finding.code === 'UML011_UNBOUNDED_VARIABLE'))
1867
+ nextSteps.push('Declare min/max (or boundaryChecks) before relying on A5 boundary probes.');
1868
+ return {
1869
+ ok: true,
1870
+ ran: true,
1871
+ ...verdictOfFindings(findings),
1872
+ schema: REPORT_SCHEMAS.umlReview,
1873
+ source: hasModel && hasDiagram ? 'model+diagram' : (hasDiagram ? 'diagram' : 'model'),
1874
+ summary: {
1875
+ errors,
1876
+ warnings: warningCount,
1877
+ info,
1878
+ states: model.states.length,
1879
+ events: events.size,
1880
+ transitions: model.transitions.length,
1881
+ terminalStates: model.states.filter((state) => state.terminal === true).length,
1882
+ reachableStates: reachable.size,
1883
+ documentedStates,
1884
+ },
1885
+ findings,
1886
+ roundTrip,
1887
+ ...(labels === undefined ? {} : { labels }),
1888
+ ...(hasDiagram ? { model } : {}),
1889
+ ...(primary === undefined ? {} : { primary }),
1890
+ ...metadata,
1891
+ ...(narrativeCoverage === undefined ? {} : { narrativeCoverage }),
1892
+ hashes: {
1893
+ hashSpec: DEFAULT_HASH_SPEC,
1894
+ ...(roundTrip === null ? {} : { modelHash: roundTrip.modelHash, parsedHash: roundTrip.parsedHash }),
1895
+ },
1896
+ ...(discardedConstructs.length === 0 ? {} : { discardedConstructs, discardedEdges }),
1897
+ warnings,
1898
+ nextSteps,
1899
+ };
1900
+ }
1901
+ /** Element counts for a family model, so the report says how big the diagram is. */
1902
+ function summarizeFamilyModel(model) {
1903
+ if (model === null || typeof model !== 'object')
1904
+ return undefined;
1905
+ const record = model;
1906
+ const summary = {};
1907
+ if (Array.isArray(record.actors))
1908
+ summary.actors = record.actors.length;
1909
+ if (Array.isArray(record.useCases))
1910
+ summary.useCases = record.useCases.length;
1911
+ if (Array.isArray(record.relations))
1912
+ summary.relations = record.relations.length;
1913
+ if (Array.isArray(record.nodes))
1914
+ summary.nodes = record.nodes.length;
1915
+ if (Array.isArray(record.edges))
1916
+ summary.edges = record.edges.length;
1917
+ return Object.keys(summary).length === 0 ? undefined : summary;
1918
+ }
1919
+ /**
1920
+ * Review a UML diagram whose model is not a machine.
1921
+ *
1922
+ * The contract is the machine review's: read `verdict`, never `ok`. What differs is
1923
+ * what can be checked. A use case diagram has actors and use cases, not states and
1924
+ * events, so the family module reports its own defects, and fidelity is proven the
1925
+ * same way: render the model, read it back, and name every path that changed.
1926
+ */
1927
+ function reviewFamilyUml(request) {
1928
+ checkUmlIntent(request.intent, 'review', { notation: request.notation, diagram: request.family });
1929
+ // A review of a model does not need a parse, so it is not refused for the notation
1930
+ // being unable to read the family back; `uml_review` on a model of an approximate
1931
+ // family is a review of the part that exists.
1932
+ const capability = umlFamilyCapability(request.notation, request.family, 'review', { requireParse: false });
1933
+ const spec = umlFamilySpec(request.family);
1934
+ const findings = [];
1935
+ const warnings = [];
1936
+ if (capability.warning !== undefined)
1937
+ warnings.push(capability.warning);
1938
+ let labels;
1939
+ let parsed;
1940
+ let primary;
1941
+ let roundTrip = null;
1942
+ if (request.diagram !== undefined && request.canParse) {
1943
+ parsed = request.module.parse(request.diagram, request.notation);
1944
+ labels = parsed.labels;
1945
+ warnings.push(...parsed.warnings);
1946
+ if (parsed.discardedConstructs.length > 0) {
1947
+ const summary = parsed.discardedConstructs.map((entry) => entry.construct).join(', ');
1948
+ findings.push({
1949
+ code: 'UML016_DIAGRAM_PARSE_NOTES',
1950
+ severity: 'warning',
1951
+ message: 'the diagram carries ' + String(parsed.discardedConstructs.length) + ' declaration(s) this family cannot represent (' + summary + '); they are not part of the model.',
1952
+ detail: parsed.discardedConstructs.slice(0, 12).map((entry) => 'line ' + String(entry.line) + ': ' + entry.text).join(' | '),
1953
+ });
1954
+ }
1955
+ }
1956
+ else if (request.diagram !== undefined) {
1957
+ // The notation draws this family and cannot read it back. Saying so is the review's
1958
+ // job: parsing anyway would be the fabrication the capability table exists to stop,
1959
+ // and throwing would leave the caller without the report the tool promised.
1960
+ findings.push({
1961
+ code: 'UML018_FIDELITY_UNCHECKED',
1962
+ severity: 'info',
1963
+ message: 'a ' + spec.name + ' is drawn by ' + request.notation + ' and cannot be read back out of it, so this text was not read and nothing here is checked against it.',
1964
+ detail: umlFamilySpec(request.family).parseRefusal ?? 'Keep the drawing and the model in one commit, and re-render with uml_render whenever the model changes.',
1965
+ });
1966
+ }
1967
+ const supplied = request.model;
1968
+ // The canonical shape of what was supplied, when the family says it has one. Fidelity is a
1969
+ // comparison between a model and what a diagram drawn from that model reads back as - and
1970
+ // that is the normalized shape, not the raw input, because a parser necessarily fills in
1971
+ // what the notation always draws.
1972
+ let canonical = supplied;
1973
+ if (supplied !== undefined && request.module.normalize !== undefined) {
1974
+ try {
1975
+ const normalized = request.module.normalize(supplied);
1976
+ if (normalized !== undefined)
1977
+ canonical = normalized;
1978
+ }
1979
+ catch {
1980
+ // Not a model of this family: the render below reports that, in its own words.
1981
+ }
1982
+ }
1983
+ let renderedForSupplied;
1984
+ if (supplied !== undefined) {
1985
+ try {
1986
+ // A review that has no parsed diagram has no notation from the text to trust, so it
1987
+ // renders in the one notation the capability table says can draw this family. Asking
1988
+ // for the caller's default instead would refuse a family whose view exists - and
1989
+ // report it as a model that belongs to another family.
1990
+ const renderNotation = request.parsedNotation ?? request.module.notation ?? request.notation;
1991
+ renderedForSupplied = request.module.render(supplied, renderNotation, { maxSteps: 60 });
1992
+ }
1993
+ catch (error) {
1994
+ const message = error instanceof Error ? error.message : String(error);
1995
+ findings.push({
1996
+ code: 'UML041_MODEL_FAMILY_MISMATCH',
1997
+ severity: 'error',
1998
+ message: 'the model supplied is not a ' + spec.name + ', so it cannot be compared with this diagram: ' + message,
1999
+ detail: 'Review the model with the family it belongs to, or pass a ' + spec.name + ' model alongside this diagram.',
2000
+ });
2001
+ }
2002
+ }
2003
+ if (renderedForSupplied !== undefined) {
2004
+ const rendered = renderedForSupplied;
2005
+ primary = rendered.primary;
2006
+ warnings.push(...rendered.warnings);
2007
+ if (!request.canParse) {
2008
+ // The diagram was drawn from the model, and the notation cannot read it back, so
2009
+ // there is no fidelity check to report - only that there is none.
2010
+ findings.push({
2011
+ code: 'UML018_FIDELITY_UNCHECKED',
2012
+ severity: 'info',
2013
+ message: 'the diagram was drawn from the model, but a ' + spec.name + ' cannot be read back out of ' + request.notation + ', so nothing here proves the drawing still says what the model says.',
2014
+ detail: 'Keep the drawing and the model in one commit, and re-render with uml_render whenever the model changes.',
2015
+ });
2016
+ }
2017
+ else {
2018
+ const reparsed = request.module.parse(rendered.primary, request.notation);
2019
+ const diffs = diffJsonModels(canonical, reparsed.model);
2020
+ roundTrip = {
2021
+ notation: request.notation,
2022
+ diagram: request.family,
2023
+ ok: diffs.length === 0,
2024
+ hashSpec: DEFAULT_HASH_SPEC,
2025
+ modelHash: valueHash(canonical),
2026
+ parsedHash: valueHash(reparsed.model),
2027
+ diffs,
2028
+ warnings: [...rendered.warnings, ...reparsed.warnings],
2029
+ };
2030
+ if (diffs.length > 0) {
2031
+ findings.push({
2032
+ code: 'UML017_ROUND_TRIP_MISMATCH',
2033
+ severity: 'error',
2034
+ message: 'the rendered diagram does not read back as the model: ' + String(diffs.length) + ' structural difference(s).',
2035
+ detail: diffs.slice(0, 12).join(' | '),
2036
+ });
2037
+ }
2038
+ if (parsed !== undefined) {
2039
+ const againstDiagram = diffJsonModels(canonical, parsed.model);
2040
+ if (againstDiagram.length > 0) {
2041
+ findings.push({
2042
+ code: 'UML017_ROUND_TRIP_MISMATCH',
2043
+ severity: 'error',
2044
+ message: 'the diagram does not carry the model it is presented with: ' + String(againstDiagram.length) + ' structural difference(s).',
2045
+ detail: againstDiagram.slice(0, 12).join(' | '),
2046
+ });
2047
+ }
2048
+ }
2049
+ if (labels === undefined)
2050
+ labels = reparsed.labels;
2051
+ }
2052
+ }
2053
+ else if (parsed !== undefined && supplied === undefined) {
2054
+ findings.push({
2055
+ code: 'UML018_FIDELITY_UNCHECKED',
2056
+ severity: 'info',
2057
+ message: 'only a diagram was supplied, so the review reads the diagram as the model: nothing here proves it matches the system it describes.',
2058
+ detail: 'Compare every element against the code or the requirements (each one needs a citation), or pass the family model alongside the diagram to check the two against each other.',
2059
+ });
2060
+ }
2061
+ // A model of another family is reported above and never handed to the family review:
2062
+ // its checks read fields this model does not have.
2063
+ //
2064
+ // What is handed over is the CANONICAL model, not the raw `supplied` one. They differ whenever a
2065
+ // family validates into a richer shape than the caller wrote - the composite validator records
2066
+ // the connector endpoints no declaration introduced, and `UML059` reads exactly that field. A
2067
+ // review given the raw model reports nothing for a dangling endpoint: a pass for a model whose
2068
+ // defect is only visible in the shape the family itself produces.
2069
+ const reviewable = supplied !== undefined && renderedForSupplied === undefined ? parsed?.model : (canonical ?? parsed?.model);
2070
+ if (reviewable !== undefined)
2071
+ findings.push(...(request.module.review?.(reviewable, labels ?? {}, request.notation) ?? []));
2072
+ const model = supplied ?? parsed?.model;
2073
+ const errors = findings.filter((finding) => finding.severity === 'error').length;
2074
+ const warningCount = findings.filter((finding) => finding.severity === 'warning').length;
2075
+ const info = findings.filter((finding) => finding.severity === 'info').length;
2076
+ const familySummary = summarizeFamilyModel(model);
2077
+ const nextSteps = [];
2078
+ if (errors > 0)
2079
+ nextSteps.push('Resolve the error findings first: a diagram that disagrees with itself misleads every later reader.');
2080
+ if (spec.group === 'structure')
2081
+ nextSteps.push('When the question is where the dependencies point, run uml_structure_verify on the same text: it audits the graph against a dependency matrix.');
2082
+ if (spec.machine)
2083
+ nextSteps.push('Run model_verify on this model for the behavioural checks (S1-S8 structural, A1-A14 adversarial).');
2084
+ else
2085
+ nextSteps.push('A ' + spec.name + ' describes structure or intent, not behaviour: model the flow as a state or activity diagram and let model_verify check that one.');
2086
+ nextSteps.push('A diagram is not evidence: cite the code or the requirement behind each element before presenting it as checked.');
2087
+ const metadata = request.metadataKeys.length === 0 ? {} : { metadataKeys: request.metadataKeys };
2088
+ return {
2089
+ ok: true,
2090
+ ran: true,
2091
+ ...verdictOfFindings(findings),
2092
+ schema: REPORT_SCHEMAS.umlReview,
2093
+ source: request.diagram === undefined ? 'model' : (supplied === undefined ? 'diagram' : 'model+diagram'),
2094
+ family: request.family,
2095
+ summary: { errors, warnings: warningCount, info, states: 0, events: 0, transitions: 0, terminalStates: 0, reachableStates: 0, documentedStates: 0 },
2096
+ ...(familySummary === undefined ? {} : { familySummary }),
2097
+ findings,
2098
+ roundTrip,
2099
+ ...(labels === undefined ? {} : { labels }),
2100
+ ...(model === undefined ? {} : { model: model }),
2101
+ ...(primary === undefined ? {} : { primary }),
2102
+ ...metadata,
2103
+ hashes: {
2104
+ hashSpec: DEFAULT_HASH_SPEC,
2105
+ ...(roundTrip === null ? {} : { modelHash: roundTrip.modelHash, parsedHash: roundTrip.parsedHash }),
2106
+ },
2107
+ warnings,
2108
+ nextSteps,
2109
+ };
2110
+ }