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