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
@@ -0,0 +1,566 @@
1
+
2
+ /**
3
+ * The use case diagram module.
4
+ *
5
+ * A use case diagram is not a machine: it says who uses the system and which services
6
+ * the system offers, not in what order anything happens. So it has its own model
7
+ * (actors, use cases, a subject, and the relations between them), its own renderer,
8
+ * and its own review. Handing this text to the state parser was the bug this module
9
+ * exists to fix: `:User: --> (Login)` used to come back as a state machine with no
10
+ * finding at all.
11
+ *
12
+ * PlantUML is the only notation that draws one. Mermaid has no use case diagram, and
13
+ * the engine's capability table says so with a reason instead of rendering something
14
+ * else under the name.
15
+ *
16
+ * @module model-everything-uml-usecase
17
+ */
18
+
19
+ import {
20
+ UmlError, UML_PLAIN_ID, directiveLine, readUmlDirectives, declaredFamily, umlSourceLines, splitUmlEdge, escapeRegExp,
21
+ type UmlNotation, type UmlFamilyModule, type UmlFamilyParse, type UmlFamilyRender, type UmlFamilyFinding, type UmlFamilyRenderOptions,
22
+ } from './uml-syntax.js'
23
+
24
+ export interface UseCaseElement {
25
+ id: string
26
+ /** Human-readable name, when the diagram gave one that differs from the id. */
27
+ label?: string
28
+ }
29
+
30
+ export type UseCaseRelationKind = 'association' | 'include' | 'extend' | 'generalization'
31
+
32
+ export interface UseCaseRelation {
33
+ /** The element the relation starts at. For a generalization this is the more specific one. */
34
+ from: string
35
+ to: string
36
+ kind: UseCaseRelationKind
37
+ }
38
+
39
+ /**
40
+ * A use case diagram as a model.
41
+ *
42
+ * The narrative is optional and holds the same information a reader needs: what each
43
+ * actor and each use case means in the words of the system being described.
44
+ */
45
+ export interface UseCaseModelV1 {
46
+ schemaVersion: 1
47
+ /** The system under discussion, when the diagram draws its boundary. */
48
+ subject?: string
49
+ actors: UseCaseElement[]
50
+ useCases: UseCaseElement[]
51
+ relations: UseCaseRelation[]
52
+ /** Ids a relation named that no declaration introduced; the review reports them. */
53
+ undeclared?: string[]
54
+ narrative?: { subject?: string; actors?: Record<string, string>; useCases?: Record<string, string> }
55
+ }
56
+
57
+ const RELATION_KINDS: readonly UseCaseRelationKind[] = ['association', 'include', 'extend', 'generalization']
58
+
59
+ /** PlantUML ids that cannot stand alone in a declaration. */
60
+ const RESERVED_IDS = new Set(['actor', 'usecase', 'rectangle', 'package', 'end', 'left', 'right', 'top', 'bottom', 'note', 'hide', 'show', 'skinparam', 'title'])
61
+
62
+ function isPlainId(id: string): boolean {
63
+ return UML_PLAIN_ID.test(id) && !RESERVED_IDS.has(id.toLowerCase())
64
+ }
65
+
66
+ export function validateUseCaseModel(input: unknown): UseCaseModelV1 {
67
+ if (input === null || typeof input !== 'object' || Array.isArray(input)) {
68
+ throw new UmlError('a use case model must be an object with schemaVersion 1, actors, useCases and relations', 'UML_INPUT')
69
+ }
70
+ const model = input as Partial<UseCaseModelV1>
71
+ if (model.schemaVersion !== 1) {
72
+ throw new UmlError('a use case model must declare schemaVersion 1', 'UML_INPUT')
73
+ }
74
+ const actors = model.actors ?? []
75
+ const useCases = model.useCases ?? []
76
+ if (!Array.isArray(actors) || !Array.isArray(useCases)) {
77
+ throw new UmlError('actors and useCases must be arrays', 'UML_INPUT')
78
+ }
79
+ const ids = new Set<string>()
80
+ for (const element of [...actors, ...useCases]) {
81
+ if (element === null || typeof element !== 'object' || typeof element.id !== 'string' || element.id.trim() === '') {
82
+ throw new UmlError('every actor and use case needs a non-empty string id', 'UML_INPUT')
83
+ }
84
+ if (ids.has(element.id)) throw new UmlError('duplicate element id "' + element.id + '"', 'UML_INPUT')
85
+ ids.add(element.id)
86
+ }
87
+ for (const relation of model.relations ?? []) {
88
+ if (relation === null || typeof relation !== 'object') throw new UmlError('every relation must be an object', 'UML_INPUT')
89
+ if (!ids.has(relation.from) || !ids.has(relation.to)) {
90
+ throw new UmlError('relation ' + relation.from + ' -> ' + relation.to + ' names an element the model does not declare', 'UML_INPUT')
91
+ }
92
+ if (!RELATION_KINDS.includes(relation.kind)) {
93
+ throw new UmlError('unknown relation kind "' + String(relation.kind) + '"; expected ' + RELATION_KINDS.join(' | '), 'UML_INPUT')
94
+ }
95
+ }
96
+ return {
97
+ schemaVersion: 1,
98
+ ...(model.subject === undefined ? {} : { subject: model.subject }),
99
+ actors: actors.map((element) => ({ id: element.id, ...(element.label === undefined ? {} : { label: element.label }) })),
100
+ useCases: useCases.map((element) => ({ id: element.id, ...(element.label === undefined ? {} : { label: element.label }) })),
101
+ relations: (model.relations ?? []).map((relation) => ({ from: relation.from, to: relation.to, kind: relation.kind })),
102
+ ...(model.undeclared === undefined || model.undeclared.length === 0 ? {} : { undeclared: [...model.undeclared] }),
103
+ ...(model.narrative === undefined ? {} : { narrative: model.narrative }),
104
+ }
105
+ }
106
+
107
+ function labelOf(element: UseCaseElement): string {
108
+ return element.label ?? element.id
109
+ }
110
+
111
+ function safeLabel(text: string, warnings: string[]): string {
112
+ if (/["\n\r]/.test(text)) {
113
+ warnings.push('UML_RENDER_LABEL_UNSAFE: the label "' + text + '" contains a quote or a line break, which the declaration syntax uses; it was written with single quotes instead and will not read back verbatim.')
114
+ return text.replace(/["\n\r]+/g, "'")
115
+ }
116
+ return text
117
+ }
118
+
119
+ function relationOperator(kind: UseCaseRelationKind): string {
120
+ if (kind === 'generalization') return '--|>'
121
+ if (kind === 'include' || kind === 'extend') return '..>'
122
+ return '-->'
123
+ }
124
+
125
+ function relationLabel(kind: UseCaseRelationKind): string | undefined {
126
+ if (kind === 'include') return '<<include>>'
127
+ if (kind === 'extend') return '<<extend>>'
128
+ return undefined
129
+ }
130
+
131
+ /**
132
+ * The Mermaid view: `usecase-beta` (Mermaid 12+). It draws the same model, and it is
133
+ * still an approximation - an extension point has no spelling there - so the engine
134
+ * marks the render as one and the round trip stays in plantuml.
135
+ */
136
+ function renderMermaidUseCase(model: UseCaseModelV1, warnings: string[]): UmlFamilyRender {
137
+ const lines: string[] = ['usecase-beta']
138
+ lines.push(directiveLine('mermaid', 'uml v1 notation=mermaid family=use-case'))
139
+ if (model.subject !== undefined) lines.push(directiveLine('mermaid', 'subject ' + model.subject))
140
+ const idOf = new Map<string, string>()
141
+ const taken = new Set<string>()
142
+ const aliasFor = (id: string): string => {
143
+ if (isPlainId(id) && !taken.has(id)) { taken.add(id); return id }
144
+ let index = 1
145
+ let candidate = 'E' + String(index)
146
+ while (taken.has(candidate)) { index += 1; candidate = 'E' + String(index) }
147
+ taken.add(candidate)
148
+ lines.push(directiveLine('mermaid', 'alias ' + candidate + ' ' + id))
149
+ return candidate
150
+ }
151
+ for (const actor of model.actors) {
152
+ const id = aliasFor(actor.id)
153
+ const label = safeLabel(labelOf(actor), warnings)
154
+ idOf.set(actor.id, id)
155
+ lines.push(id === label ? 'actor ' + id : 'actor ' + id + '["' + label + '"]')
156
+ }
157
+ const useCaseLines: string[] = []
158
+ for (const useCase of model.useCases) {
159
+ const id = aliasFor(useCase.id)
160
+ const label = safeLabel(labelOf(useCase), warnings)
161
+ idOf.set(useCase.id, id)
162
+ useCaseLines.push(id === label ? ' ' + id : ' ' + id + '["' + label + '"]')
163
+ }
164
+ if (model.subject === undefined) lines.push(...useCaseLines)
165
+ else lines.push('systemBoundary "' + safeLabel(model.subject, warnings) + '"', ...useCaseLines, 'end')
166
+ for (const relation of model.relations) {
167
+ const from = idOf.get(relation.from) ?? relation.from
168
+ const to = idOf.get(relation.to) ?? relation.to
169
+ const label = relationLabel(relation.kind)
170
+ lines.push(from + ' ' + relationOperator(relation.kind) + ' ' + to + (label === undefined ? '' : ' : ' + label))
171
+ }
172
+ return { primary: lines.join('\n') + '\n', warnings }
173
+ }
174
+
175
+ export function renderUseCase(input: unknown, notation: UmlNotation): UmlFamilyRender {
176
+ const model = validateUseCaseModel(input)
177
+ const warnings: string[] = []
178
+ if (notation === 'mermaid') return renderMermaidUseCase(model, warnings)
179
+ const lines: string[] = ['@startuml']
180
+ lines.push(directiveLine('plantuml', 'uml v1 notation=plantuml family=use-case'))
181
+ if (model.subject !== undefined) lines.push(directiveLine('plantuml', 'subject ' + model.subject))
182
+
183
+ const alias = new Map<string, string>()
184
+ const taken = new Set<string>()
185
+ const aliasFor = (id: string): string => {
186
+ if (isPlainId(id) && !taken.has(id)) { taken.add(id); return id }
187
+ let index = 1
188
+ let candidate = 'E' + String(index)
189
+ while (taken.has(candidate)) { index += 1; candidate = 'E' + String(index) }
190
+ taken.add(candidate)
191
+ alias.set(candidate, id)
192
+ return candidate
193
+ }
194
+ const actorIds = model.actors.map((element) => aliasFor(element.id))
195
+ const useCaseIds = model.useCases.map((element) => aliasFor(element.id))
196
+ for (const [notationId, modelId] of alias) lines.push(directiveLine('plantuml', 'alias ' + notationId + ' ' + modelId))
197
+
198
+ model.actors.forEach((element, index) => {
199
+ const id = actorIds[index]
200
+ const label = safeLabel(labelOf(element), warnings)
201
+ lines.push(id === label ? 'actor ' + id : 'actor "' + label + '" as ' + id)
202
+ })
203
+ model.useCases.forEach((element, index) => {
204
+ const id = useCaseIds[index]
205
+ const label = safeLabel(labelOf(element), warnings)
206
+ lines.push(id === label ? 'usecase ' + id : 'usecase "' + label + '" as ' + id)
207
+ })
208
+ const idOf = new Map<string, string>()
209
+ model.actors.forEach((element, index) => idOf.set(element.id, actorIds[index]))
210
+ model.useCases.forEach((element, index) => idOf.set(element.id, useCaseIds[index]))
211
+ for (const relation of model.relations) {
212
+ const from = idOf.get(relation.from) ?? relation.from
213
+ const to = idOf.get(relation.to) ?? relation.to
214
+ const label = relationLabel(relation.kind)
215
+ lines.push(from + ' ' + relationOperator(relation.kind) + ' ' + to + (label === undefined ? '' : ' : ' + label))
216
+ }
217
+ lines.push('@enduml')
218
+ return { primary: lines.join('\n') + '\n', warnings }
219
+ }
220
+
221
+ // ---------------------------------------------------------------------------
222
+ // Parsing
223
+ // ---------------------------------------------------------------------------
224
+
225
+ interface Declared { id: string; label?: string }
226
+
227
+ /** Read a PlantUML declaration target: a bare id, a bare label, or a quoted label as an id. */
228
+ function readTarget(rest: string): Declared | undefined {
229
+ const trimmed = rest.trim().replace(/[;{]\s*$/, '').trim()
230
+ if (trimmed === '') return undefined
231
+ const aliased = /^(.*?)\s+as\s+([A-Za-z_][A-Za-z0-9_]*)\s*$/.exec(trimmed)
232
+ if (aliased !== null) {
233
+ const label = aliased[1].trim().replace(/^"|"$/g, '').trim()
234
+ return label === '' ? { id: aliased[2] } : { id: aliased[2], label }
235
+ }
236
+ const quoted = /^"([^"]*)"\s*$/.exec(trimmed)
237
+ if (quoted !== null) return quoted[1] === '' ? undefined : { id: quoted[1] }
238
+ const bare = /^([A-Za-z_][A-Za-z0-9_]*)\s*$/.exec(trimmed)
239
+ if (bare !== null) return { id: bare[1] }
240
+ return { id: trimmed.replace(/^"|"$/g, '').trim() }
241
+ }
242
+
243
+ /** (Label), (Label) as (ID), (Label) as ID. */
244
+ const PAREN_USE_CASE = /^\(\s*([^()]*?)\s*\)(?:\s+as\s+\(?\s*([^()\s]+)\s*\)?)?$/
245
+
246
+ /** :Actor: or :Actor: as ID. */
247
+ const COLON_ACTOR = /^:\s*([^:]+?)\s*:(?:\s+as\s+([A-Za-z_][A-Za-z0-9_]*))?$/
248
+
249
+ /** The system boundary, which PlantUML draws as a container. */
250
+ const BOUNDARY = /^(rectangle|package|frame)\s+(?:"([^"]*)"|([A-Za-z_][A-Za-z0-9_.-]*))\s*\{?\s*$/i
251
+
252
+ /** A declaration from another family, kept so a mixed file does not parse silently. */
253
+ const FOREIGN_DECLARATION = /^\s*(class|interface|enum|enumeration|abstract|object|component|artifact|node|database|storage|cloud|state|participant|boundary|control|entity|collections|queue|robust|concise|binary|clock)\b/i
254
+
255
+ function relationKindFromLabel(label: string | undefined): { kind: UseCaseRelationKind; assumed: boolean } {
256
+ if (label === undefined) return { kind: 'include', assumed: true }
257
+ const text = label.toLowerCase()
258
+ if (text.includes('include')) return { kind: 'include', assumed: false }
259
+ if (text.includes('extend')) return { kind: 'extend', assumed: false }
260
+ return { kind: 'include', assumed: true }
261
+ }
262
+
263
+ export function parseUseCase(text: string, notation: UmlNotation): UmlFamilyParse<UseCaseModelV1> {
264
+ if (notation !== 'plantuml') {
265
+ throw new UmlError('mermaid has no use case diagram: there is no text to read back. Write the diagram in plantuml, or review the model instead.', 'UML_VIEW_UNREPRESENTABLE')
266
+ }
267
+ const all = text.split(/\r?\n/)
268
+ const directives = readUmlDirectives(all, 'plantuml')
269
+ const alias = new Map<string, string>()
270
+ for (const value of directives.values.get('alias') ?? []) {
271
+ const split = value.indexOf(' ')
272
+ if (split > 0) alias.set(value.slice(0, split).trim(), value.slice(split + 1).trim())
273
+ }
274
+ const restore = (id: string): string => alias.get(id) ?? id
275
+
276
+ const actors: UseCaseElement[] = []
277
+ const useCases: UseCaseElement[] = []
278
+ const labels: Record<string, string> = {}
279
+ const warnings: string[] = []
280
+ const discarded: Array<{ construct: string; line: number; text: string }> = []
281
+ const declared = new Map<string, 'actor' | 'use-case'>()
282
+ const undeclared: string[] = []
283
+
284
+ const declareActor = (entry: Declared): string => {
285
+ const id = restore(entry.id)
286
+ if (!declared.has(id)) { declared.set(id, 'actor'); actors.push({ id, ...(entry.label === undefined ? {} : { label: entry.label }) }) }
287
+ if (entry.label !== undefined) labels[id] = entry.label
288
+ return id
289
+ }
290
+ const declareUseCase = (entry: Declared): string => {
291
+ const id = restore(entry.id)
292
+ if (!declared.has(id)) { declared.set(id, 'use-case'); useCases.push({ id, ...(entry.label === undefined ? {} : { label: entry.label }) }) }
293
+ if (entry.label !== undefined) labels[id] = entry.label
294
+ return id
295
+ }
296
+
297
+ // An endpoint may spell itself as an actor or a use case instead of naming a
298
+ // declared id: `:User: --> (Login)` is the everyday hand-written form.
299
+ const resolveEndpoint = (raw: string): string => {
300
+ const actor = COLON_ACTOR.exec(raw)
301
+ if (actor !== null) {
302
+ const label = actor[1].trim()
303
+ return declareActor(actor[2] === undefined ? { id: label } : { id: actor[2], label })
304
+ }
305
+ const paren = PAREN_USE_CASE.exec(raw)
306
+ if (paren !== null && paren[1].trim() !== '') {
307
+ return declareUseCase(paren[2] === undefined ? { id: paren[1].trim() } : { id: paren[2], label: paren[1].trim() })
308
+ }
309
+ return restore(raw)
310
+ }
311
+
312
+ let subject: string | undefined
313
+ for (const value of directives.values.get('subject') ?? []) subject = value
314
+
315
+ const lines = umlSourceLines(text, 'plantuml')
316
+ const relations: UseCaseRelation[] = []
317
+ for (const line of lines) {
318
+ const source = line.text
319
+ const boundary = BOUNDARY.exec(source)
320
+ if (boundary !== null) {
321
+ if (subject === undefined || subject === '') subject = (boundary[2] ?? boundary[3] ?? '').trim()
322
+ continue
323
+ }
324
+ if (source === '}' || source === '{') continue
325
+ const declaration = /^(usecase|actor)\s+(.+)$/i.exec(source)
326
+ if (declaration !== null) {
327
+ const target = readTarget(declaration[2])
328
+ if (target === undefined) {
329
+ warnings.push('UML_PARSE_IGNORED_LINE: "' + source + '" declares nothing; it was ignored.')
330
+ continue
331
+ }
332
+ if (declaration[1].toLowerCase() === 'usecase') declareUseCase(target)
333
+ else declareActor(target)
334
+ continue
335
+ }
336
+ const paren = PAREN_USE_CASE.exec(source)
337
+ if (paren !== null) {
338
+ const label = paren[1].trim()
339
+ if (label === '') {
340
+ warnings.push('UML_PARSE_IGNORED_LINE: "' + source + '" declares no use case name; it was ignored.')
341
+ continue
342
+ }
343
+ declareUseCase(paren[2] === undefined ? { id: label } : { id: paren[2], label })
344
+ continue
345
+ }
346
+ const colon = COLON_ACTOR.exec(source)
347
+ if (colon !== null) {
348
+ const label = colon[1].trim()
349
+ declareActor(colon[2] === undefined ? { id: label } : { id: colon[2], label })
350
+ continue
351
+ }
352
+ const foreign = FOREIGN_DECLARATION.exec(source)
353
+ if (foreign !== null) {
354
+ discarded.push({ construct: foreign[1].toLowerCase(), line: line.number, text: source })
355
+ continue
356
+ }
357
+ const edge = splitUmlEdge(source, 'plantuml')
358
+ if (edge !== undefined) {
359
+ const fromRaw = resolveEndpoint(edge.from)
360
+ const toRaw = resolveEndpoint(edge.to)
361
+ const operator = edge.operator
362
+ const generalization = operator === '--|>' || operator === '<|--'
363
+ // A generalization arrow points at the general element, so the specific element
364
+ // is the left side for --|> and the right side for <|--.
365
+ const from = generalization && operator === '<|--' ? toRaw : fromRaw
366
+ const to = generalization && operator === '<|--' ? fromRaw : toRaw
367
+ let kind: UseCaseRelationKind = generalization ? 'generalization' : 'association'
368
+ if (!generalization && (operator === '..>' || operator === '.>')) {
369
+ const read = relationKindFromLabel(edge.label)
370
+ kind = read.kind
371
+ if (read.assumed) {
372
+ warnings.push('UML_PARSE_ASSUMED_INCLUDE: the dashed arrow ' + edge.from + ' ..> ' + edge.to + ' carries ' + (edge.label === undefined ? 'no stereotype' : '"' + edge.label + '"') + ', which is neither include nor extend; it was read as an include.')
373
+ }
374
+ }
375
+ for (const id of [from, to]) {
376
+ if (declared.has(id) || undeclared.includes(id)) continue
377
+ undeclared.push(id)
378
+ warnings.push('UML_PARSE_UNDECLARED_ENDPOINT: "' + id + '" is used as a relation endpoint but nothing declares it; it is kept so the review can report it.')
379
+ }
380
+ relations.push({ from, to, kind })
381
+ continue
382
+ }
383
+ warnings.push('UML_PARSE_IGNORED_LINE: "' + source + '" is not a use case statement; it was ignored.')
384
+ }
385
+ for (const value of directives.values.get('undeclared') ?? []) {
386
+ const id = restore(value.trim())
387
+ if (id !== '' && !undeclared.includes(id)) undeclared.push(id)
388
+ }
389
+
390
+ const model: UseCaseModelV1 = {
391
+ schemaVersion: 1,
392
+ ...(subject === undefined || subject === '' ? {} : { subject }),
393
+ actors,
394
+ useCases,
395
+ relations,
396
+ ...(undeclared.length === 0 ? {} : { undeclared }),
397
+ }
398
+ return { model, labels, warnings, discardedConstructs: discarded, discardedEdges: 0 }
399
+ }
400
+
401
+ // ---------------------------------------------------------------------------
402
+ // Review
403
+ // ---------------------------------------------------------------------------
404
+
405
+ export type UseCaseFindingCode =
406
+ | 'UML030_USE_CASE_NO_ACTORS'
407
+ | 'UML031_USE_CASE_NO_USE_CASES'
408
+ | 'UML032_ISOLATED_ACTOR'
409
+ | 'UML033_USE_CASE_WITHOUT_ACTOR'
410
+ | 'UML034_DANGLING_RELATION_ENDPOINT'
411
+ | 'UML035_INCLUDE_EXTEND_CYCLE'
412
+ | 'UML036_ACTOR_TO_ACTOR_ASSOCIATION'
413
+ | 'UML037_ACTOR_INCLUDE_EXTEND'
414
+ | 'UML038_SELF_RELATION'
415
+ | 'UML039_USE_CASE_UNDOCUMENTED'
416
+
417
+ export const USE_CASE_FINDING_CODES: readonly UseCaseFindingCode[] = [
418
+ 'UML030_USE_CASE_NO_ACTORS',
419
+ 'UML031_USE_CASE_NO_USE_CASES',
420
+ 'UML032_ISOLATED_ACTOR',
421
+ 'UML033_USE_CASE_WITHOUT_ACTOR',
422
+ 'UML034_DANGLING_RELATION_ENDPOINT',
423
+ 'UML035_INCLUDE_EXTEND_CYCLE',
424
+ 'UML036_ACTOR_TO_ACTOR_ASSOCIATION',
425
+ 'UML037_ACTOR_INCLUDE_EXTEND',
426
+ 'UML038_SELF_RELATION',
427
+ 'UML039_USE_CASE_UNDOCUMENTED',
428
+ ]
429
+
430
+ /** The first include/extend cycle reachable from the given start, as a path. */
431
+ function includeCycle(relations: UseCaseRelation[]): string[] | undefined {
432
+ const adjacency = new Map<string, string[]>()
433
+ for (const relation of relations) {
434
+ if (relation.kind !== 'include' && relation.kind !== 'extend') continue
435
+ const list = adjacency.get(relation.from)
436
+ if (list === undefined) adjacency.set(relation.from, [relation.to])
437
+ else list.push(relation.to)
438
+ }
439
+ const state = new Map<string, 'visiting' | 'done'>()
440
+ const path: string[] = []
441
+ const walk = (node: string): string[] | undefined => {
442
+ const seen = state.get(node)
443
+ if (seen === 'visiting') {
444
+ const start = path.indexOf(node)
445
+ return [...path.slice(start), node]
446
+ }
447
+ if (seen === 'done') return undefined
448
+ state.set(node, 'visiting')
449
+ path.push(node)
450
+ for (const next of adjacency.get(node) ?? []) {
451
+ const cycle = walk(next)
452
+ if (cycle !== undefined) return cycle
453
+ }
454
+ path.pop()
455
+ state.set(node, 'done')
456
+ return undefined
457
+ }
458
+ for (const node of adjacency.keys()) {
459
+ const cycle = walk(node)
460
+ if (cycle !== undefined) return cycle
461
+ }
462
+ return undefined
463
+ }
464
+
465
+ /** Every element reachable from an actor over associations, includes, extends and generalizations. */
466
+ function reachedFromActors(model: UseCaseModelV1): Set<string> {
467
+ const adjacency = new Map<string, string[]>()
468
+ const link = (left: string, right: string): void => {
469
+ const list = adjacency.get(left)
470
+ if (list === undefined) adjacency.set(left, [right])
471
+ else list.push(right)
472
+ }
473
+ for (const relation of model.relations) {
474
+ link(relation.from, relation.to)
475
+ link(relation.to, relation.from)
476
+ }
477
+ const reached = new Set<string>()
478
+ const queue = model.actors.map((actor) => actor.id)
479
+ while (queue.length > 0) {
480
+ const node = queue.shift() as string
481
+ if (reached.has(node)) continue
482
+ reached.add(node)
483
+ for (const next of adjacency.get(node) ?? []) if (!reached.has(next)) queue.push(next)
484
+ }
485
+ return reached
486
+ }
487
+
488
+ export function reviewUseCaseModel(model: UseCaseModelV1, labels: Record<string, string>): UmlFamilyFinding[] {
489
+ const findings: UmlFamilyFinding[] = []
490
+ const actorIds = new Set(model.actors.map((actor) => actor.id))
491
+ const useCaseIds = new Set(model.useCases.map((useCase) => useCase.id))
492
+ const name = (id: string): string => labels[id] ?? id
493
+
494
+ if (model.actors.length === 0) {
495
+ findings.push({ code: 'UML030_USE_CASE_NO_ACTORS', severity: 'error', message: 'the diagram declares no actor, so it does not say who uses the system.', evidence: { actors: 0 } })
496
+ }
497
+ if (model.useCases.length === 0) {
498
+ findings.push({ code: 'UML031_USE_CASE_NO_USE_CASES', severity: 'error', message: 'the diagram declares no use case, so it does not say what the system offers.', evidence: { useCases: 0 } })
499
+ }
500
+ const undeclared = model.undeclared ?? []
501
+ if (undeclared.length > 0) {
502
+ findings.push({
503
+ code: 'UML034_DANGLING_RELATION_ENDPOINT',
504
+ severity: 'error',
505
+ message: 'a relation names ' + String(undeclared.length) + ' element(s) the diagram never declares, so the line points at nothing a reader can look up.',
506
+ detail: undeclared.join(', '),
507
+ evidence: { ids: undeclared },
508
+ })
509
+ }
510
+ for (const relation of model.relations) {
511
+ if (relation.from === relation.to) {
512
+ findings.push({ code: 'UML038_SELF_RELATION', severity: 'warning', message: name(relation.from) + ' relates to itself, which says nothing.', evidence: { id: relation.from, kind: relation.kind } })
513
+ }
514
+ if (relation.kind === 'association' && actorIds.has(relation.from) && actorIds.has(relation.to)) {
515
+ findings.push({
516
+ code: 'UML036_ACTOR_TO_ACTOR_ASSOCIATION',
517
+ severity: 'error',
518
+ message: 'an association joins two actors (' + name(relation.from) + ' and ' + name(relation.to) + '): actors do not use each other. A shared role is a generalization, and shared behaviour belongs in a use case.',
519
+ evidence: { from: relation.from, to: relation.to },
520
+ })
521
+ }
522
+ if ((relation.kind === 'include' || relation.kind === 'extend') && (actorIds.has(relation.from) || actorIds.has(relation.to))) {
523
+ findings.push({
524
+ code: 'UML037_ACTOR_INCLUDE_EXTEND',
525
+ severity: 'error',
526
+ message: 'an actor ' + relation.kind + 's a use case (' + name(relation.from) + ' and ' + name(relation.to) + '): only use cases include or extend each other. An actor reaches a use case by an association.',
527
+ evidence: { from: relation.from, to: relation.to, kind: relation.kind },
528
+ })
529
+ }
530
+ }
531
+ const cycle = includeCycle(model.relations)
532
+ if (cycle !== undefined) {
533
+ findings.push({
534
+ code: 'UML035_INCLUDE_EXTEND_CYCLE',
535
+ severity: 'error',
536
+ message: 'include and extend form a cycle, so the use cases can never be performed in a finite order.',
537
+ detail: cycle.map((id) => name(id)).join(' -> '),
538
+ evidence: { path: cycle },
539
+ })
540
+ }
541
+ const reached = reachedFromActors(model)
542
+ for (const actor of model.actors) {
543
+ const touched = model.relations.some((relation) => relation.from === actor.id || relation.to === actor.id)
544
+ if (!touched) {
545
+ findings.push({ code: 'UML032_ISOLATED_ACTOR', severity: 'info', message: 'actor ' + name(actor.id) + ' takes part in nothing: no use case is associated with it.', evidence: { id: actor.id } })
546
+ }
547
+ }
548
+ for (const useCase of model.useCases) {
549
+ if (!reached.has(useCase.id)) {
550
+ findings.push({ code: 'UML033_USE_CASE_WITHOUT_ACTOR', severity: 'warning', message: 'use case ' + name(useCase.id) + ' is connected to no actor, directly or through an include or extend: nothing says who triggers it.', evidence: { id: useCase.id } })
551
+ }
552
+ const narrative = model.narrative?.useCases?.[useCase.id]
553
+ if (labels[useCase.id] === undefined && narrative === undefined) {
554
+ findings.push({ code: 'UML039_USE_CASE_UNDOCUMENTED', severity: 'info', message: 'use case ' + useCase.id + ' carries no name a reader can map back to the system: give it a display label or a narrative entry.', evidence: { id: useCase.id } })
555
+ }
556
+ }
557
+ return findings
558
+ }
559
+
560
+ /** The use case family, as the engine's registry expects it. */
561
+ export const useCaseModule: UmlFamilyModule<UseCaseModelV1> = {
562
+ id: 'use-case',
563
+ render: (input: unknown, notation: UmlNotation, _options: UmlFamilyRenderOptions): UmlFamilyRender => renderUseCase(input, notation),
564
+ parse: (text: string, notation: UmlNotation): UmlFamilyParse<UseCaseModelV1> => parseUseCase(text, notation),
565
+ review: (model: UseCaseModelV1, labels: Record<string, string>): UmlFamilyFinding[] => reviewUseCaseModel(model, labels),
566
+ }