dsh-model-everything 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (130) hide show
  1. package/LICENSE +21 -0
  2. package/README.en-US.md +319 -0
  3. package/README.md +319 -3
  4. package/cordis.patch.yml +20 -0
  5. package/examples/README.md +48 -0
  6. package/examples/control-dispatcher.json +113 -0
  7. package/examples/control-dispatcher.overbudget.json +113 -0
  8. package/examples/ecommerce-data-model.json +185 -0
  9. package/examples/order-state-machine.after.json +116 -0
  10. package/examples/order-state-machine.before.json +101 -0
  11. package/examples/user-migration.after.json +29 -0
  12. package/examples/user-migration.before.json +29 -0
  13. package/gate/locale/en.json +6 -0
  14. package/gate/locale/zh.json +6 -0
  15. package/gate/package.json +5 -0
  16. package/lib/baseline.js +249 -0
  17. package/lib/compose-tool.js +76 -0
  18. package/lib/concurrency-tool.js +40 -0
  19. package/lib/concurrency.js +206 -0
  20. package/lib/data-engine.js +1141 -0
  21. package/lib/data-tool.js +79 -0
  22. package/lib/design-engine.js +1149 -0
  23. package/lib/design-tool.js +55 -0
  24. package/lib/engine.js +4343 -0
  25. package/lib/export-tool.js +78 -0
  26. package/lib/exporters.js +434 -0
  27. package/lib/gate.js +274 -0
  28. package/lib/granularity.js +237 -0
  29. package/lib/index.js +237 -0
  30. package/lib/json-value.js +18 -0
  31. package/lib/option-shape.js +62 -0
  32. package/lib/report-diff-tool.js +63 -0
  33. package/lib/report-view.js +408 -0
  34. package/lib/structure-tool.js +91 -0
  35. package/lib/structure.js +711 -0
  36. package/lib/tool.js +85 -0
  37. package/lib/types/baseline.d.ts +65 -0
  38. package/lib/types/compose-tool.d.ts +7 -0
  39. package/lib/types/concurrency-tool.d.ts +8 -0
  40. package/lib/types/concurrency.d.ts +37 -0
  41. package/lib/types/data-engine.d.ts +211 -0
  42. package/lib/types/data-tool.d.ts +10 -0
  43. package/lib/types/design-engine.d.ts +215 -0
  44. package/lib/types/design-tool.d.ts +11 -0
  45. package/lib/types/engine.d.ts +494 -0
  46. package/lib/types/export-tool.d.ts +7 -0
  47. package/lib/types/exporters.d.ts +37 -0
  48. package/lib/types/gate.d.ts +91 -0
  49. package/lib/types/granularity.d.ts +106 -0
  50. package/lib/types/index.d.ts +45 -0
  51. package/lib/types/json-value.d.ts +21 -0
  52. package/lib/types/option-shape.d.ts +22 -0
  53. package/lib/types/report-diff-tool.d.ts +8 -0
  54. package/lib/types/report-view.d.ts +43 -0
  55. package/lib/types/structure-tool.d.ts +10 -0
  56. package/lib/types/structure.d.ts +152 -0
  57. package/lib/types/tool.d.ts +11 -0
  58. package/lib/types/uml-communication.d.ts +109 -0
  59. package/lib/types/uml-composite.d.ts +107 -0
  60. package/lib/types/uml-interaction-overview.d.ts +91 -0
  61. package/lib/types/uml-profile.d.ts +112 -0
  62. package/lib/types/uml-structure.d.ts +53 -0
  63. package/lib/types/uml-syntax.d.ts +296 -0
  64. package/lib/types/uml-timing.d.ts +152 -0
  65. package/lib/types/uml-tool.d.ts +12 -0
  66. package/lib/types/uml-usecase.d.ts +58 -0
  67. package/lib/types/uml.d.ts +303 -0
  68. package/lib/uml-communication.js +353 -0
  69. package/lib/uml-composite.js +442 -0
  70. package/lib/uml-interaction-overview.js +277 -0
  71. package/lib/uml-profile.js +373 -0
  72. package/lib/uml-structure.js +306 -0
  73. package/lib/uml-syntax.js +545 -0
  74. package/lib/uml-timing.js +974 -0
  75. package/lib/uml-tool.js +213 -0
  76. package/lib/uml-usecase.js +532 -0
  77. package/lib/uml.js +2110 -0
  78. package/locale/en.json +6 -0
  79. package/locale/zh.json +6 -0
  80. package/package.json +124 -6
  81. package/skills/model-everything/SKILL.md +482 -0
  82. package/skills/model-everything/references/dsh-model-schema.md +437 -0
  83. package/skills/model-everything/references/gap-routing-guide.md +42 -0
  84. package/skills/model-everything/references/hash-spec.md +152 -0
  85. package/skills/model-everything/references/logic-verification-guide.md +422 -0
  86. package/skills/model-everything-datamodel/SKILL.md +130 -0
  87. package/skills/model-everything-datamodel/references/data-model-guide.md +84 -0
  88. package/skills/model-everything-datamodel/references/data-model-schema.md +128 -0
  89. package/skills/model-everything-design/SKILL.md +115 -0
  90. package/skills/model-everything-interleaving/SKILL.md +63 -0
  91. package/skills/model-everything-interleaving/SKILL.zh-CN.md +60 -0
  92. package/skills/model-everything-interleaving/references/concurrency-risk-guide.md +63 -0
  93. package/skills/model-everything-report-reading/SKILL.md +137 -0
  94. package/skills/model-everything-report-reading/references/reading-a-report.md +166 -0
  95. package/skills/model-everything-report-reading/references/reading-a-report.mmd +59 -0
  96. package/skills/model-everything-structure/SKILL.md +96 -0
  97. package/skills/model-everything-structure/references/structure-review-guide.md +201 -0
  98. package/skills/model-everything-uml/SKILL.md +60 -0
  99. package/skills/model-everything-uml/references/uml-modeling-guide.md +551 -0
  100. package/src/baseline.ts +288 -0
  101. package/src/compose-tool.ts +78 -0
  102. package/src/concurrency-tool.ts +44 -0
  103. package/src/concurrency.ts +254 -0
  104. package/src/data-engine.ts +1208 -0
  105. package/src/data-tool.ts +83 -0
  106. package/src/design-engine.ts +1263 -0
  107. package/src/design-tool.ts +60 -0
  108. package/src/engine.ts +4564 -0
  109. package/src/export-tool.ts +81 -0
  110. package/src/exporters.ts +399 -0
  111. package/src/gate.ts +330 -0
  112. package/src/granularity.ts +312 -0
  113. package/src/index.ts +289 -0
  114. package/src/json-value.ts +20 -0
  115. package/src/option-shape.ts +64 -0
  116. package/src/report-diff-tool.ts +67 -0
  117. package/src/report-view.ts +451 -0
  118. package/src/structure-tool.ts +93 -0
  119. package/src/structure.ts +786 -0
  120. package/src/tool.ts +94 -0
  121. package/src/uml-communication.ts +428 -0
  122. package/src/uml-composite.ts +520 -0
  123. package/src/uml-interaction-overview.ts +321 -0
  124. package/src/uml-profile.ts +439 -0
  125. package/src/uml-structure.ts +317 -0
  126. package/src/uml-syntax.ts +764 -0
  127. package/src/uml-timing.ts +1106 -0
  128. package/src/uml-tool.ts +230 -0
  129. package/src/uml-usecase.ts +566 -0
  130. package/src/uml.ts +2139 -0
@@ -0,0 +1,520 @@
1
+ /**
2
+ * The composite structure diagram module.
3
+ *
4
+ * A composite structure diagram is a structured classifier seen from the inside: the parts
5
+ * it is made of, the ports on its boundary, and the connectors between them. That reads
6
+ * back out of the text, which is what separates this family from the profile.
7
+ *
8
+ * PlantUML has no composite-structure page, so the view is an approximation built from the
9
+ * deployment diagram's vocabulary: a nested element for the classifier, the nestable
10
+ * elements for its parts, `port`/`portin`/`portout` for the boundary, and the arrows for
11
+ * connectors. Nesting is spelled, port direction is spelled, and both connector ends are
12
+ * spelled - so the model survives the round trip. What the notation cannot say (a part's
13
+ * type, a port's required interface, a connector's name) stays out of the model rather
14
+ * than being invented on the way back.
15
+ *
16
+ * @module model-everything-uml-composite
17
+ */
18
+
19
+ import {
20
+ UmlError, UML_PLAIN_ID, directiveLine, type UmlNotation, type UmlFamilyModule,
21
+ type UmlFamilyParse, type UmlFamilyRender, type UmlFamilyFinding, type UmlFamilyRenderOptions,
22
+ } from './uml-syntax.js'
23
+
24
+ /** One end of a connector: the classifier itself, one of its ports, or one of its parts. */
25
+ export type CompositeEndpoint = string
26
+
27
+ /** The structured classifier being opened up. */
28
+ export interface CompositeSpec {
29
+ id: string
30
+ label?: string
31
+ /** The keyword it is drawn with; `component` is the deployment diagram's default. */
32
+ kind: string
33
+ }
34
+
35
+ export interface CompositePart {
36
+ id: string
37
+ label?: string
38
+ }
39
+
40
+ export type CompositePortDirection = 'in' | 'out' | 'inout'
41
+
42
+ export interface CompositePort {
43
+ id: string
44
+ /** `in` is `portin`, `out` is `portout`, `inout` is the bare `port`. */
45
+ direction: CompositePortDirection
46
+ }
47
+
48
+ export interface CompositeConnector {
49
+ from: CompositeEndpoint
50
+ to: CompositeEndpoint
51
+ label?: string
52
+ }
53
+
54
+ /**
55
+ * A composite structure diagram as a model.
56
+ *
57
+ * `self` is the classifier. Ports and parts belong to it, and a connector endpoint names
58
+ * one of the three - the classifier itself, a port, or a part. The model has no nesting:
59
+ * a part of a part is a second diagram, which is how a reader keeps each view readable.
60
+ */
61
+ export interface CompositeModelV1 {
62
+ schemaVersion: 1
63
+ self: CompositeSpec
64
+ parts: CompositePart[]
65
+ ports: CompositePort[]
66
+ connectors: CompositeConnector[]
67
+ /** Ids a connector named that no declaration introduced; the review reports them. */
68
+ undeclared?: string[]
69
+ narrative?: { self?: string; parts?: Record<string, string>; ports?: Record<string, string> }
70
+ }
71
+
72
+ /** Elements PlantUML can nest; a part is declared with one of these. */
73
+ const NESTABLE = [
74
+ 'component', 'node', 'artifact', 'database', 'file', 'folder', 'frame', 'cloud',
75
+ 'package', 'queue', 'rectangle', 'stack', 'storage', 'card', 'hexagon', 'action',
76
+ 'process', 'collections',
77
+ ] as const
78
+
79
+ const PORT_KEYWORDS: Record<string, CompositePortDirection> = { port: 'inout', portin: 'in', portout: 'out' }
80
+
81
+ /** The port keyword a direction is drawn with. */
82
+ function portKeyword(direction: CompositePortDirection): string {
83
+ if (direction === 'in') return 'portin'
84
+ if (direction === 'out') return 'portout'
85
+ return 'port'
86
+ }
87
+
88
+ function quote(text: string): string {
89
+ return '"' + text.replace(/"/g, "'") + '"'
90
+ }
91
+
92
+ function isPlainId(id: string): boolean {
93
+ return UML_PLAIN_ID.test(id) && !(NESTABLE as readonly string[]).includes(id.toLowerCase()) && id !== 'port' && id !== 'portin' && id !== 'portout'
94
+ }
95
+
96
+ /**
97
+ * Validate a candidate model and normalize it.
98
+ *
99
+ * A port id and a part id may not collide: a connector endpoint is a bare name, so two
100
+ * declarations sharing one would make the drawing say two things at once.
101
+ */
102
+ export function validateCompositeModel(input: unknown): CompositeModelV1 {
103
+ if (input === null || typeof input !== 'object' || Array.isArray(input)) {
104
+ throw new UmlError('a composite structure model must be an object with schemaVersion 1, a self, parts, ports and connectors', 'UML_INPUT')
105
+ }
106
+ const model = input as Partial<CompositeModelV1>
107
+ if (model.schemaVersion !== 1) throw new UmlError('a composite structure model must declare schemaVersion 1', 'UML_INPUT')
108
+ const self = model.self
109
+ if (self === null || typeof self !== 'object' || typeof self.id !== 'string' || self.id.trim() === '') {
110
+ throw new UmlError('a composite structure model needs a `self` with a non-empty id', 'UML_INPUT')
111
+ }
112
+ const kind = self.kind ?? 'component'
113
+ if (!(NESTABLE as readonly string[]).includes(kind)) {
114
+ throw new UmlError('self.kind must be one of ' + NESTABLE.join(' | ') + ', got ' + quote(String(kind)), 'UML_INPUT')
115
+ }
116
+ for (const [name, value] of Object.entries({ parts: model.parts ?? [], ports: model.ports ?? [], connectors: model.connectors ?? [] })) {
117
+ if (!Array.isArray(value)) throw new UmlError(name + ' must be an array', 'UML_INPUT')
118
+ }
119
+
120
+ const declared = new Set<string>([self.id])
121
+ const parts: CompositePart[] = []
122
+ for (const part of model.parts ?? []) {
123
+ if (part === null || typeof part !== 'object' || typeof part.id !== 'string' || part.id.trim() === '') {
124
+ throw new UmlError('every part needs a non-empty string id', 'UML_INPUT')
125
+ }
126
+ if (declared.has(part.id)) throw new UmlError(quote(part.id) + ' is declared more than once as a part or a port', 'UML_INPUT')
127
+ declared.add(part.id)
128
+ parts.push({ id: part.id, ...(part.label === undefined ? {} : { label: part.label }) })
129
+ }
130
+ const ports: CompositePort[] = []
131
+ for (const port of model.ports ?? []) {
132
+ if (port === null || typeof port !== 'object' || typeof port.id !== 'string' || port.id.trim() === '') {
133
+ throw new UmlError('every port needs a non-empty string id', 'UML_INPUT')
134
+ }
135
+ if (declared.has(port.id)) throw new UmlError(quote(port.id) + ' is declared more than once as a part or a port', 'UML_INPUT')
136
+ declared.add(port.id)
137
+ const direction = port.direction ?? 'inout'
138
+ if (direction !== 'in' && direction !== 'out' && direction !== 'inout') {
139
+ throw new UmlError('port direction must be in | out | inout, got ' + quote(String(direction)), 'UML_INPUT')
140
+ }
141
+ ports.push({ id: port.id, direction })
142
+ }
143
+
144
+ const undeclared = new Set<string>()
145
+ const connectors: CompositeConnector[] = []
146
+ for (const connector of model.connectors ?? []) {
147
+ if (connector === null || typeof connector !== 'object') throw new UmlError('every connector must be an object', 'UML_INPUT')
148
+ for (const side of ['from', 'to'] as const) {
149
+ const endpoint = connector[side]
150
+ if (typeof endpoint !== 'string' || endpoint.trim() === '') {
151
+ throw new UmlError('every connector needs a non-empty `' + side + '` end', 'UML_INPUT')
152
+ }
153
+ if (!declared.has(endpoint)) undeclared.add(endpoint)
154
+ }
155
+ if (connector.from === connector.to) {
156
+ throw new UmlError('a connector cannot join ' + quote(connector.from) + ' to itself', 'UML_INPUT')
157
+ }
158
+ connectors.push({
159
+ from: connector.from,
160
+ to: connector.to,
161
+ ...(connector.label === undefined ? {} : { label: connector.label }),
162
+ })
163
+ }
164
+
165
+ return {
166
+ schemaVersion: 1,
167
+ self: { id: self.id, kind, ...(self.label === undefined ? {} : { label: self.label }) },
168
+ parts,
169
+ ports,
170
+ connectors,
171
+ ...(undeclared.size === 0 ? {} : { undeclared: [...undeclared].sort() }),
172
+ ...(model.narrative === undefined ? {} : { narrative: model.narrative }),
173
+ }
174
+ }
175
+
176
+ // ---------------------------------------------------------------------------
177
+ // Rendering
178
+ // ---------------------------------------------------------------------------
179
+
180
+ /**
181
+ * Render a composite structure diagram as PlantUML.
182
+ *
183
+ * The classifier is drawn as a nested element, its parts inside it, its ports on its
184
+ * boundary, and the connectors after the block. The `self` directive carries the
185
+ * classifier's own id, because a nested element's closing brace is the only thing that
186
+ * says where its inside ends.
187
+ *
188
+ * @param input - candidate `CompositeModelV1`.
189
+ * @param notation - only `plantuml` draws this family; mermaid has no ports or connectors.
190
+ */
191
+ export function renderComposite(input: unknown, notation: UmlNotation): UmlFamilyRender {
192
+ if (notation !== 'plantuml') {
193
+ throw new UmlError('a composite structure diagram can only be drawn in plantuml; mermaid has no port or connector element and no nested element to put parts in. Render it with notation "plantuml".', 'UML_VIEW_UNREPRESENTABLE')
194
+ }
195
+ const model = validateCompositeModel(input)
196
+ const warnings: string[] = [
197
+ 'UML_RENDER_APPROXIMATE_VIEW: plantuml has no composite structure diagram; this is a deployment-diagram drawing of one. Nesting, part names, port direction and both connector ends are carried; a part\'s type, a port\'s interfaces and a connector\'s name are not expressible and are not drawn.',
198
+ ]
199
+ const lines: string[] = ['@startuml']
200
+ lines.push(directiveLine('plantuml', 'uml v1 notation=plantuml family=composite-structure'))
201
+ lines.push(directiveLine('plantuml', 'compositeSelf ' + model.self.kind + ' ' + model.self.id))
202
+ const label = model.self.label === undefined ? quote(model.self.id) : quote(model.self.label)
203
+ lines.push(model.self.kind + ' ' + label + ' as ' + model.self.id + ' {')
204
+ for (const port of model.ports) {
205
+ // A port declaration takes no alias, so its id is what the reader sees; a port whose
206
+ // id PlantUML cannot use is reported rather than silently renamed.
207
+ lines.push(' ' + portKeyword(port.direction) + ' ' + port.id)
208
+ }
209
+ for (const part of model.parts) {
210
+ if (part.label === undefined) lines.push(' component ' + part.id)
211
+ else lines.push(' component ' + quote(part.label) + ' as ' + part.id)
212
+ }
213
+ lines.push('}')
214
+ for (const connector of model.connectors) {
215
+ lines.push(connector.from + ' --> ' + connector.to + (connector.label === undefined ? '' : ' : ' + connector.label))
216
+ }
217
+ lines.push('@enduml')
218
+ return { primary: lines.join('\n') + '\n', warnings }
219
+ }
220
+
221
+ // ---------------------------------------------------------------------------
222
+ // Parsing
223
+ // ---------------------------------------------------------------------------
224
+
225
+ interface CompositeLine {
226
+ line: number
227
+ text: string
228
+ }
229
+
230
+ const SELF_DIRECTIVE = /^compositeSelf\s+([A-Za-z_][A-Za-z0-9_]*)\s+(\S+)\s*$/
231
+ const DECLARATION = new RegExp('^(' + NESTABLE.join('|') + ')\\s+(?:"([^"]*)"|(\\S+))(?:\\s+as\\s+(\\S+))?\\s*(\\{)?\\s*$', 'i')
232
+ const PORT_DECLARATION = /^(port|portin|portout)\s+(\S+)\s*$/i
233
+ const CONNECTOR = /^(\S+)\s+(-->|->|--|\.\.>|\.\.)\s+(\S+?)(?:\s*:\s*(.*))?$/
234
+
235
+ /**
236
+ * Read a composite structure diagram back into a model.
237
+ *
238
+ * Faithful to this module's renderer and tolerant of hand-written text: the classifier is
239
+ * the outermost block (named by the `compositeSelf` directive when the renderer wrote it),
240
+ * `port`/`portin`/`portout` lines are its ports, nestable declarations inside the block are
241
+ * its parts, and every arrow between two names is a connector. A construct the notation
242
+ * carries but this model does not - a nested block inside a part, a stereotype, a note - is
243
+ * reported in `discardedConstructs`, never dropped in silence.
244
+ */
245
+ export function parseComposite(text: string, notation: UmlNotation): UmlFamilyParse<CompositeModelV1> {
246
+ if (notation !== 'plantuml') {
247
+ throw new UmlError('a composite structure diagram can only be read from plantuml', 'UML_VIEW_UNREPRESENTABLE')
248
+ }
249
+ const all: CompositeLine[] = text.split(/\r?\n/).map((raw, index) => ({ line: index + 1, text: raw.trim() }))
250
+ const warnings: string[] = []
251
+ const discarded: Array<{ line: number; text: string; construct: string }> = []
252
+ const labels: Record<string, string> = {}
253
+
254
+ let self: CompositeSpec | undefined
255
+ let fromDirective: { kind: string; id: string } | undefined
256
+ const parts: CompositePart[] = []
257
+ const ports: CompositePort[] = []
258
+ const connectors: CompositeConnector[] = []
259
+ let depth = 0
260
+ let seenConnector = false
261
+
262
+ for (const entry of all) {
263
+ const line = entry.text
264
+ if (line === '' || line.startsWith('@') || line.startsWith("'")) {
265
+ // The renderer's own directives are read elsewhere; a comment is not a construct.
266
+ if (line.startsWith("'dsh-model-everything:")) {
267
+ const directive = line.slice("'dsh-model-everything:".length).trim()
268
+ const selfMatch = SELF_DIRECTIVE.exec(directive)
269
+ if (selfMatch !== null) fromDirective = { kind: selfMatch[1].toLowerCase(), id: selfMatch[2] }
270
+ }
271
+ continue
272
+ }
273
+ if (/^(left to right direction|top to bottom direction|skinparam|hide|show|title|scale|allowmixing|allow_mixing)\b/i.test(line)) {
274
+ discarded.push({ line: entry.line, text: line, construct: line.split(/\s+/)[0].toLowerCase() })
275
+ continue
276
+ }
277
+
278
+ const port = PORT_DECLARATION.exec(line)
279
+ if (port !== null) {
280
+ if (depth === 0) {
281
+ discarded.push({ line: entry.line, text: line, construct: 'port outside a classifier' })
282
+ continue
283
+ }
284
+ const id = port[2]
285
+ if (ports.some((entry) => entry.id === id) || parts.some((entry) => entry.id === id)) {
286
+ warnings.push('line ' + String(entry.line) + ': ' + id + ' is declared twice; the second declaration is ignored')
287
+ continue
288
+ }
289
+ ports.push({ id, direction: PORT_KEYWORDS[port[1].toLowerCase()] })
290
+ continue
291
+ }
292
+
293
+ const declaration = DECLARATION.exec(line)
294
+ if (declaration !== null) {
295
+ const kind = declaration[1].toLowerCase()
296
+ const quoted = declaration[2]
297
+ const bare = declaration[3]
298
+ const alias = declaration[4]
299
+ const opens = declaration[5] === '{'
300
+ const id = alias ?? bare ?? quoted ?? ''
301
+ // `component "App" as app` names the id in the alias and the label in the quotes.
302
+ // `component App as app` puts the label in the bare position instead, and that is a
303
+ // label rather than an id: the alias is what the rest of the diagram refers to.
304
+ const labelText = quoted ?? (alias === undefined ? undefined : bare)
305
+ if (labelText !== undefined) labels[id] = labelText
306
+ if (depth === 0) {
307
+ if (self !== undefined) {
308
+ discarded.push({ line: entry.line, text: line, construct: 'second top-level block' })
309
+ } else {
310
+ self = { id, kind, ...(labelText === undefined ? {} : { label: labelText }) }
311
+ }
312
+ } else if (depth === 1) {
313
+ if (opens) {
314
+ // A block inside the classifier would be a part with its own inside. The model
315
+ // has no nesting, so the part is kept and the block it opens is reported.
316
+ discarded.push({ line: entry.line, text: line, construct: 'nested block inside a part' })
317
+ }
318
+ if (!parts.some((entry) => entry.id === id) && !ports.some((entry) => entry.id === id)) {
319
+ parts.push({ id, ...(labelText === undefined ? {} : { label: labelText }) })
320
+ } else {
321
+ warnings.push('line ' + String(entry.line) + ': ' + id + ' is declared twice; the second declaration is ignored')
322
+ }
323
+ } else {
324
+ // Whatever is declared inside a part is that part's inside, and this model has no
325
+ // place for it. Reporting it is the only alternative to inventing a structure.
326
+ discarded.push({ line: entry.line, text: line, construct: 'declaration inside a part' })
327
+ }
328
+ if (opens) depth += 1
329
+ continue
330
+ }
331
+ if (line === '}') {
332
+ depth = Math.max(0, depth - 1)
333
+ seenConnector = false
334
+ continue
335
+ }
336
+
337
+ const connector = CONNECTOR.exec(line)
338
+ if (connector !== null) {
339
+ const [, from, , to, label] = connector
340
+ connectors.push({ from, to, ...(label === undefined || label === '' ? {} : { label }) })
341
+ seenConnector = true
342
+ continue
343
+ }
344
+ if (seenConnector || depth > 0) {
345
+ discarded.push({ line: entry.line, text: line, construct: 'line this model does not carry' })
346
+ } else {
347
+ discarded.push({ line: entry.line, text: line, construct: 'statement outside any classifier' })
348
+ }
349
+ }
350
+
351
+ if (self === undefined) {
352
+ if (fromDirective === undefined) {
353
+ throw new UmlError('no structured classifier found: a composite structure diagram needs one block that holds its parts, such as `component Order as order { ... }`', 'UML_INPUT')
354
+ }
355
+ self = { id: fromDirective.id, kind: fromDirective.kind }
356
+ } else if (fromDirective !== undefined) {
357
+ // The directive is the renderer's own record; it wins, because the block's alias can
358
+ // be a label PlantUML was given.
359
+ self = { ...self, id: fromDirective.id, kind: fromDirective.kind }
360
+ }
361
+
362
+ const declared = new Set<string>([self.id, ...parts.map((part) => part.id), ...ports.map((port) => port.id)])
363
+ const undeclared = [...new Set(connectors.flatMap((connector) => [connector.from, connector.to]))].filter((endpoint) => !declared.has(endpoint)).sort()
364
+ // No narrative is invented here. The labels a reader gave the drawing come back in
365
+ // `labels`, which is where a display name belongs; writing them into the model as well
366
+ // would add a field the model never carried, and the round trip would stop being exact.
367
+ const model: CompositeModelV1 = {
368
+ schemaVersion: 1,
369
+ self,
370
+ parts,
371
+ ports,
372
+ connectors,
373
+ ...(undeclared.length === 0 ? {} : { undeclared }),
374
+ }
375
+ return { model, labels, warnings, discardedConstructs: discarded, discardedEdges: 0 }
376
+ }
377
+
378
+ // ---------------------------------------------------------------------------
379
+ // Review
380
+ // ---------------------------------------------------------------------------
381
+
382
+ export type CompositeFindingCode =
383
+ | 'UML057_COMPOSITE_NO_PARTS'
384
+ | 'UML058_COMPOSITE_PORT_UNCONNECTED'
385
+ | 'UML059_COMPOSITE_CONNECTOR_ENDPOINT_MISSING'
386
+ | 'UML060_COMPOSITE_DUPLICATE_PART'
387
+ | 'UML061_COMPOSITE_PORT_COLLISION'
388
+ | 'UML062_COMPOSITE_UNDOCUMENTED'
389
+
390
+ export const COMPOSITE_FINDING_CODES: readonly CompositeFindingCode[] = [
391
+ 'UML057_COMPOSITE_NO_PARTS',
392
+ 'UML058_COMPOSITE_PORT_UNCONNECTED',
393
+ 'UML059_COMPOSITE_CONNECTOR_ENDPOINT_MISSING',
394
+ 'UML060_COMPOSITE_DUPLICATE_PART',
395
+ 'UML061_COMPOSITE_PORT_COLLISION',
396
+ 'UML062_COMPOSITE_UNDOCUMENTED',
397
+ ]
398
+
399
+ /**
400
+ * What a reader would get wrong about a composite structure.
401
+ *
402
+ * The questions are the ones the diagram exists to answer: is anything inside, does every
403
+ * port lead somewhere, does every connector join two things that exist, and is each part
404
+ * and port named after something in the system rather than after its drawing position.
405
+ * Whether the decomposition is *right* is a question about the design, and this does not
406
+ * answer it.
407
+ */
408
+ export function reviewCompositeModel(model: CompositeModelV1, labels: Record<string, string>): UmlFamilyFinding[] {
409
+ const findings: UmlFamilyFinding[] = []
410
+ const name = (id: string): string => labels[id] ?? id
411
+
412
+ if (model.parts.length === 0) {
413
+ findings.push({
414
+ code: 'UML057_COMPOSITE_NO_PARTS',
415
+ severity: 'error',
416
+ message: 'the classifier is drawn as a composite structure but holds no part: a structure diagram with an empty inside says nothing about how it is built.',
417
+ evidence: { parts: 0 },
418
+ })
419
+ }
420
+
421
+ const connected = new Set(model.connectors.flatMap((connector) => [connector.from, connector.to]))
422
+ for (const port of model.ports) {
423
+ if (connected.has(port.id)) continue
424
+ findings.push({
425
+ code: 'UML058_COMPOSITE_PORT_UNCONNECTED',
426
+ severity: 'warning',
427
+ message: 'port ' + name(port.id) + ' (' + port.direction + ') has no connector, so nothing reaches the classifier through it.',
428
+ evidence: { id: port.id, direction: port.direction },
429
+ })
430
+ }
431
+
432
+ const undeclared = model.undeclared ?? []
433
+ if (undeclared.length > 0) {
434
+ findings.push({
435
+ code: 'UML059_COMPOSITE_CONNECTOR_ENDPOINT_MISSING',
436
+ severity: 'error',
437
+ message: 'a connector ends at ' + String(undeclared.length) + ' name(s) the classifier never declares, so the line points at nothing a reader can look up.',
438
+ detail: undeclared.join(', '),
439
+ evidence: { ids: undeclared },
440
+ })
441
+ }
442
+
443
+ const seen = new Set<string>()
444
+ for (const part of model.parts) {
445
+ if (seen.has(part.id)) {
446
+ findings.push({
447
+ code: 'UML060_COMPOSITE_DUPLICATE_PART',
448
+ severity: 'error',
449
+ message: 'part ' + part.id + ' is declared more than once, so a connector that names it is ambiguous.',
450
+ evidence: { id: part.id },
451
+ })
452
+ }
453
+ seen.add(part.id)
454
+ }
455
+ if (seen.has(model.self.id)) {
456
+ findings.push({
457
+ code: 'UML061_COMPOSITE_PORT_COLLISION',
458
+ severity: 'error',
459
+ message: 'the classifier itself is also declared as a part (' + model.self.id + '): a nested element inside itself has no reading.',
460
+ evidence: { id: model.self.id },
461
+ })
462
+ }
463
+ for (const port of model.ports) {
464
+ if (seen.has(port.id)) {
465
+ findings.push({
466
+ code: 'UML061_COMPOSITE_PORT_COLLISION',
467
+ severity: 'error',
468
+ message: 'port ' + port.id + ' shares its name with a part: a connector that names it cannot be read.',
469
+ evidence: { id: port.id },
470
+ })
471
+ }
472
+ }
473
+
474
+ if (model.self.label === undefined && labels[model.self.id] === undefined) {
475
+ findings.push({
476
+ code: 'UML062_COMPOSITE_UNDOCUMENTED',
477
+ severity: 'info',
478
+ message: 'the classifier carries no name a reader can map back to the design: give it a display label or a narrative entry.',
479
+ evidence: { id: model.self.id },
480
+ })
481
+ }
482
+ for (const part of model.parts) {
483
+ if (part.label !== undefined || labels[part.id] !== undefined) continue
484
+ findings.push({
485
+ code: 'UML062_COMPOSITE_UNDOCUMENTED',
486
+ severity: 'info',
487
+ message: 'part ' + part.id + ' carries no name a reader can map back to the design: give it a display label or a narrative entry.',
488
+ evidence: { id: part.id },
489
+ })
490
+ }
491
+ return findings
492
+ }
493
+
494
+ /** The composite structure family, as the engine's registry expects it. */
495
+ export const compositeModule: UmlFamilyModule<CompositeModelV1> = {
496
+ id: 'composite-structure',
497
+ render: (input: unknown, notation: UmlNotation, _options: UmlFamilyRenderOptions): UmlFamilyRender => renderComposite(input, notation),
498
+ parse: (text: string, notation: UmlNotation): UmlFamilyParse<CompositeModelV1> => parseComposite(text, notation),
499
+ // The self's `kind` is always drawn - the renderer writes it into the `compositeSelf` directive
500
+ // and into the declaration - so the parser always reads one back. A model that left it out is
501
+ // normalized to the same shape before the fidelity comparison, or the review reports its own
502
+ // default as drift: "self.kind invented by the diagram" on a diagram that says exactly what the
503
+ // model meant.
504
+ //
505
+ // `undeclared` is carried through deliberately. It is what `UML059` reads, and it is a fact about
506
+ // the supplied model rather than about the diagram, so normalizing it away would silence the one
507
+ // finding that says a connector ends at a name the classifier never declares - the silent pass
508
+ // this file's own comments promise not to produce.
509
+ normalize: (input: unknown): CompositeModelV1 | undefined => {
510
+ try {
511
+ const normalized = validateCompositeModel(input)
512
+ const undeclared = (input as { undeclared?: unknown }).undeclared
513
+ if (!Array.isArray(undeclared)) return normalized
514
+ return { ...normalized, undeclared: undeclared.filter((entry): entry is string => typeof entry === 'string') }
515
+ } catch {
516
+ return undefined
517
+ }
518
+ },
519
+ review: (model: CompositeModelV1, labels: Record<string, string>): UmlFamilyFinding[] => reviewCompositeModel(model, labels),
520
+ }