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.
- package/LICENSE +21 -0
- package/README.en-US.md +332 -0
- package/README.md +332 -3
- package/cordis.patch.yml +20 -0
- package/examples/README.md +48 -0
- package/examples/control-dispatcher.json +113 -0
- package/examples/control-dispatcher.overbudget.json +113 -0
- package/examples/ecommerce-data-model.json +185 -0
- package/examples/order-state-machine.after.json +116 -0
- package/examples/order-state-machine.before.json +101 -0
- package/examples/user-migration.after.json +29 -0
- package/examples/user-migration.before.json +29 -0
- package/gate/locale/en.json +6 -0
- package/gate/locale/zh.json +6 -0
- package/gate/package.json +5 -0
- package/lib/baseline.js +249 -0
- package/lib/compose-tool.js +80 -0
- package/lib/concurrency-tool.js +40 -0
- package/lib/concurrency.js +206 -0
- package/lib/data-engine.js +1141 -0
- package/lib/data-tool.js +87 -0
- package/lib/design-engine.js +1149 -0
- package/lib/design-tool.js +63 -0
- package/lib/engine.js +4343 -0
- package/lib/export-tool.js +78 -0
- package/lib/exporters.js +434 -0
- package/lib/gate.js +274 -0
- package/lib/granularity.js +237 -0
- package/lib/index.js +237 -0
- package/lib/json-value.js +18 -0
- package/lib/model-file.js +48 -0
- package/lib/option-shape.js +62 -0
- package/lib/report-diff-tool.js +63 -0
- package/lib/report-view.js +433 -0
- package/lib/structure-tool.js +91 -0
- package/lib/structure.js +711 -0
- package/lib/tool.js +94 -0
- package/lib/types/baseline.d.ts +65 -0
- package/lib/types/compose-tool.d.ts +7 -0
- package/lib/types/concurrency-tool.d.ts +8 -0
- package/lib/types/concurrency.d.ts +37 -0
- package/lib/types/data-engine.d.ts +211 -0
- package/lib/types/data-tool.d.ts +10 -0
- package/lib/types/design-engine.d.ts +215 -0
- package/lib/types/design-tool.d.ts +11 -0
- package/lib/types/engine.d.ts +494 -0
- package/lib/types/export-tool.d.ts +7 -0
- package/lib/types/exporters.d.ts +37 -0
- package/lib/types/gate.d.ts +91 -0
- package/lib/types/granularity.d.ts +106 -0
- package/lib/types/index.d.ts +45 -0
- package/lib/types/json-value.d.ts +21 -0
- package/lib/types/model-file.d.ts +7 -0
- package/lib/types/option-shape.d.ts +22 -0
- package/lib/types/report-diff-tool.d.ts +8 -0
- package/lib/types/report-view.d.ts +50 -0
- package/lib/types/structure-tool.d.ts +10 -0
- package/lib/types/structure.d.ts +152 -0
- package/lib/types/tool.d.ts +11 -0
- package/lib/types/uml-communication.d.ts +109 -0
- package/lib/types/uml-composite.d.ts +107 -0
- package/lib/types/uml-interaction-overview.d.ts +91 -0
- package/lib/types/uml-profile.d.ts +112 -0
- package/lib/types/uml-structure.d.ts +53 -0
- package/lib/types/uml-syntax.d.ts +296 -0
- package/lib/types/uml-timing.d.ts +152 -0
- package/lib/types/uml-tool.d.ts +12 -0
- package/lib/types/uml-usecase.d.ts +58 -0
- package/lib/types/uml.d.ts +303 -0
- package/lib/uml-communication.js +353 -0
- package/lib/uml-composite.js +442 -0
- package/lib/uml-interaction-overview.js +277 -0
- package/lib/uml-profile.js +373 -0
- package/lib/uml-structure.js +306 -0
- package/lib/uml-syntax.js +545 -0
- package/lib/uml-timing.js +974 -0
- package/lib/uml-tool.js +213 -0
- package/lib/uml-usecase.js +532 -0
- package/lib/uml.js +2110 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +128 -6
- package/skills/model-everything/SKILL.md +450 -0
- package/skills/model-everything/references/dsh-model-schema.md +437 -0
- package/skills/model-everything/references/gap-routing-guide.md +42 -0
- package/skills/model-everything/references/hash-spec.md +152 -0
- package/skills/model-everything/references/logic-verification-guide.md +422 -0
- package/skills/model-everything-datamodel/SKILL.md +130 -0
- package/skills/model-everything-datamodel/references/data-model-guide.md +84 -0
- package/skills/model-everything-datamodel/references/data-model-schema.md +128 -0
- package/skills/model-everything-design/SKILL.md +115 -0
- package/skills/model-everything-interleaving/SKILL.md +63 -0
- package/skills/model-everything-interleaving/SKILL.zh-CN.md +60 -0
- package/skills/model-everything-interleaving/references/concurrency-risk-guide.md +63 -0
- package/skills/model-everything-report-reading/SKILL.md +137 -0
- package/skills/model-everything-report-reading/references/reading-a-report.md +166 -0
- package/skills/model-everything-report-reading/references/reading-a-report.mmd +59 -0
- package/skills/model-everything-structure/SKILL.md +96 -0
- package/skills/model-everything-structure/references/structure-review-guide.md +201 -0
- package/skills/model-everything-uml/SKILL.md +60 -0
- package/skills/model-everything-uml/references/uml-modeling-guide.md +551 -0
- package/src/baseline.ts +288 -0
- package/src/compose-tool.ts +82 -0
- package/src/concurrency-tool.ts +44 -0
- package/src/concurrency.ts +254 -0
- package/src/data-engine.ts +1208 -0
- package/src/data-tool.ts +91 -0
- package/src/design-engine.ts +1263 -0
- package/src/design-tool.ts +68 -0
- package/src/engine.ts +4564 -0
- package/src/export-tool.ts +81 -0
- package/src/exporters.ts +399 -0
- package/src/gate.ts +330 -0
- package/src/granularity.ts +312 -0
- package/src/index.ts +289 -0
- package/src/json-value.ts +20 -0
- package/src/model-file.ts +49 -0
- package/src/option-shape.ts +64 -0
- package/src/report-diff-tool.ts +67 -0
- package/src/report-view.ts +483 -0
- package/src/structure-tool.ts +93 -0
- package/src/structure.ts +786 -0
- package/src/tool.ts +103 -0
- package/src/uml-communication.ts +428 -0
- package/src/uml-composite.ts +520 -0
- package/src/uml-interaction-overview.ts +321 -0
- package/src/uml-profile.ts +439 -0
- package/src/uml-structure.ts +317 -0
- package/src/uml-syntax.ts +764 -0
- package/src/uml-timing.ts +1106 -0
- package/src/uml-tool.ts +230 -0
- package/src/uml-usecase.ts +566 -0
- 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
|
+
}
|