dsh-model-everything 0.0.0-stage → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (133) hide show
  1. package/LICENSE +21 -0
  2. package/README.en-US.md +332 -0
  3. package/README.md +332 -3
  4. package/cordis.patch.yml +20 -0
  5. package/examples/README.md +48 -0
  6. package/examples/control-dispatcher.json +113 -0
  7. package/examples/control-dispatcher.overbudget.json +113 -0
  8. package/examples/ecommerce-data-model.json +185 -0
  9. package/examples/order-state-machine.after.json +116 -0
  10. package/examples/order-state-machine.before.json +101 -0
  11. package/examples/user-migration.after.json +29 -0
  12. package/examples/user-migration.before.json +29 -0
  13. package/gate/locale/en.json +6 -0
  14. package/gate/locale/zh.json +6 -0
  15. package/gate/package.json +5 -0
  16. package/lib/baseline.js +249 -0
  17. package/lib/compose-tool.js +80 -0
  18. package/lib/concurrency-tool.js +40 -0
  19. package/lib/concurrency.js +206 -0
  20. package/lib/data-engine.js +1141 -0
  21. package/lib/data-tool.js +87 -0
  22. package/lib/design-engine.js +1149 -0
  23. package/lib/design-tool.js +63 -0
  24. package/lib/engine.js +4343 -0
  25. package/lib/export-tool.js +78 -0
  26. package/lib/exporters.js +434 -0
  27. package/lib/gate.js +274 -0
  28. package/lib/granularity.js +237 -0
  29. package/lib/index.js +237 -0
  30. package/lib/json-value.js +18 -0
  31. package/lib/model-file.js +48 -0
  32. package/lib/option-shape.js +62 -0
  33. package/lib/report-diff-tool.js +63 -0
  34. package/lib/report-view.js +433 -0
  35. package/lib/structure-tool.js +91 -0
  36. package/lib/structure.js +711 -0
  37. package/lib/tool.js +94 -0
  38. package/lib/types/baseline.d.ts +65 -0
  39. package/lib/types/compose-tool.d.ts +7 -0
  40. package/lib/types/concurrency-tool.d.ts +8 -0
  41. package/lib/types/concurrency.d.ts +37 -0
  42. package/lib/types/data-engine.d.ts +211 -0
  43. package/lib/types/data-tool.d.ts +10 -0
  44. package/lib/types/design-engine.d.ts +215 -0
  45. package/lib/types/design-tool.d.ts +11 -0
  46. package/lib/types/engine.d.ts +494 -0
  47. package/lib/types/export-tool.d.ts +7 -0
  48. package/lib/types/exporters.d.ts +37 -0
  49. package/lib/types/gate.d.ts +91 -0
  50. package/lib/types/granularity.d.ts +106 -0
  51. package/lib/types/index.d.ts +45 -0
  52. package/lib/types/json-value.d.ts +21 -0
  53. package/lib/types/model-file.d.ts +7 -0
  54. package/lib/types/option-shape.d.ts +22 -0
  55. package/lib/types/report-diff-tool.d.ts +8 -0
  56. package/lib/types/report-view.d.ts +50 -0
  57. package/lib/types/structure-tool.d.ts +10 -0
  58. package/lib/types/structure.d.ts +152 -0
  59. package/lib/types/tool.d.ts +11 -0
  60. package/lib/types/uml-communication.d.ts +109 -0
  61. package/lib/types/uml-composite.d.ts +107 -0
  62. package/lib/types/uml-interaction-overview.d.ts +91 -0
  63. package/lib/types/uml-profile.d.ts +112 -0
  64. package/lib/types/uml-structure.d.ts +53 -0
  65. package/lib/types/uml-syntax.d.ts +296 -0
  66. package/lib/types/uml-timing.d.ts +152 -0
  67. package/lib/types/uml-tool.d.ts +12 -0
  68. package/lib/types/uml-usecase.d.ts +58 -0
  69. package/lib/types/uml.d.ts +303 -0
  70. package/lib/uml-communication.js +353 -0
  71. package/lib/uml-composite.js +442 -0
  72. package/lib/uml-interaction-overview.js +277 -0
  73. package/lib/uml-profile.js +373 -0
  74. package/lib/uml-structure.js +306 -0
  75. package/lib/uml-syntax.js +545 -0
  76. package/lib/uml-timing.js +974 -0
  77. package/lib/uml-tool.js +213 -0
  78. package/lib/uml-usecase.js +532 -0
  79. package/lib/uml.js +2110 -0
  80. package/locale/en.json +6 -0
  81. package/locale/zh.json +6 -0
  82. package/package.json +128 -6
  83. package/skills/model-everything/SKILL.md +450 -0
  84. package/skills/model-everything/references/dsh-model-schema.md +437 -0
  85. package/skills/model-everything/references/gap-routing-guide.md +42 -0
  86. package/skills/model-everything/references/hash-spec.md +152 -0
  87. package/skills/model-everything/references/logic-verification-guide.md +422 -0
  88. package/skills/model-everything-datamodel/SKILL.md +130 -0
  89. package/skills/model-everything-datamodel/references/data-model-guide.md +84 -0
  90. package/skills/model-everything-datamodel/references/data-model-schema.md +128 -0
  91. package/skills/model-everything-design/SKILL.md +115 -0
  92. package/skills/model-everything-interleaving/SKILL.md +63 -0
  93. package/skills/model-everything-interleaving/SKILL.zh-CN.md +60 -0
  94. package/skills/model-everything-interleaving/references/concurrency-risk-guide.md +63 -0
  95. package/skills/model-everything-report-reading/SKILL.md +137 -0
  96. package/skills/model-everything-report-reading/references/reading-a-report.md +166 -0
  97. package/skills/model-everything-report-reading/references/reading-a-report.mmd +59 -0
  98. package/skills/model-everything-structure/SKILL.md +96 -0
  99. package/skills/model-everything-structure/references/structure-review-guide.md +201 -0
  100. package/skills/model-everything-uml/SKILL.md +60 -0
  101. package/skills/model-everything-uml/references/uml-modeling-guide.md +551 -0
  102. package/src/baseline.ts +288 -0
  103. package/src/compose-tool.ts +82 -0
  104. package/src/concurrency-tool.ts +44 -0
  105. package/src/concurrency.ts +254 -0
  106. package/src/data-engine.ts +1208 -0
  107. package/src/data-tool.ts +91 -0
  108. package/src/design-engine.ts +1263 -0
  109. package/src/design-tool.ts +68 -0
  110. package/src/engine.ts +4564 -0
  111. package/src/export-tool.ts +81 -0
  112. package/src/exporters.ts +399 -0
  113. package/src/gate.ts +330 -0
  114. package/src/granularity.ts +312 -0
  115. package/src/index.ts +289 -0
  116. package/src/json-value.ts +20 -0
  117. package/src/model-file.ts +49 -0
  118. package/src/option-shape.ts +64 -0
  119. package/src/report-diff-tool.ts +67 -0
  120. package/src/report-view.ts +483 -0
  121. package/src/structure-tool.ts +93 -0
  122. package/src/structure.ts +786 -0
  123. package/src/tool.ts +103 -0
  124. package/src/uml-communication.ts +428 -0
  125. package/src/uml-composite.ts +520 -0
  126. package/src/uml-interaction-overview.ts +321 -0
  127. package/src/uml-profile.ts +439 -0
  128. package/src/uml-structure.ts +317 -0
  129. package/src/uml-syntax.ts +764 -0
  130. package/src/uml-timing.ts +1106 -0
  131. package/src/uml-tool.ts +230 -0
  132. package/src/uml-usecase.ts +566 -0
  133. package/src/uml.ts +2139 -0
package/src/tool.ts ADDED
@@ -0,0 +1,103 @@
1
+ import { defineTool } from '@deepseek-ai/dsh-tools'
2
+ import type { JsonValue } from './json-value.js'
3
+ import { runVerification } from './engine.js'
4
+ import { renderReportText } from './report-view.js'
5
+ import { OptionShapeError, optionRefusal, requireObjectOption } from './option-shape.js'
6
+ import { oneOf } from './model-file.js'
7
+
8
+ export const MODEL_VERIFY_TOOL_NAME = 'model_verify'
9
+
10
+ /**
11
+ * Model-visible DSH tool wrapping the bundled TypeScript verification engine.
12
+ * The model passes a LogicModelV1 object; the engine validates it and returns
13
+ * the 22-check report (S1-S8 structural, A1-A14), or a 26-check
14
+ * report when beforeModel is supplied (D1 behavioral preservation, D2
15
+ * invariant continuity, D3 regression delta, D4 deadlock/liveness regression).
16
+ * This is the dsh-native path: the model is built and run here, with no
17
+ * template to fill in by hand.
18
+ */
19
+ export const modelVerifyTool = defineTool({
20
+ name: MODEL_VERIFY_TOOL_NAME,
21
+ description:
22
+ 'Verify one state machine: does this LogicModelV1 reach a state it must not, deadlock, livelock, or leave an invariant undecided. Model shape: schemaVersion 1, init, states ({id, terminal?}), transitions ({from, event, to, guard?, updates?, cost?}), plus optional variables, invariants, concurrentPairs, boundaryChecks, resourcePairs, idempotentEvents and narrative. Guards are structured: {variable, op, value}, {all}, {any} or {not}. Invariants cover never-states, var-in-range, event-before-state, leads-to, sequence, atomicity, budget, probability and deadline. `cost` defaults to 1 and drives the path-cost-against-budget check. `weight` defaults to 1 and makes the model a DTMC, which is what the probabilistic-reachability check needs. A state with `maxTicks` needs top-level `tickEvents`, or the deadline check reports it as over-resident. The schema is closed: an undeclared key is a validation error. Keys starting with `_` are metadata: ignored, kept out of `modelHash`, and echoed as `metadataKeys`. Variables support monotonic inc and dec. Pass `beforeModel` and `stateMapping` to compare the two models of a refactor for behaviour preservation, invariant continuity and deadlock regression. For two or more machines interacting, use `model_compose_verify` instead: this tool decides one machine at a time. Read `verdict`, never `ok`. The full schema is in the `model_verify` skill: references/dsh-model-schema.md.',
23
+ parameters: {
24
+ model: {
25
+ type: 'json',
26
+ description: 'LogicModelV1 state machine model to verify. Pass this, or modelPath, not both.',
27
+ },
28
+ modelPath: {
29
+ type: 'string',
30
+ description: 'Path to a JSON file holding the model, instead of inlining it. Formatting does not matter: whitespace, a BOM and pretty printing all parse. Relative paths resolve against the process working directory, which is the session workspace.',
31
+ },
32
+ maxStates: {
33
+ type: 'integer',
34
+ description: 'Maximum runtime states to explore. Default 10000.',
35
+ },
36
+ maxPermutationEvents: {
37
+ type: 'integer',
38
+ description: 'Maximum event count for A3 order permutation. Default 5.',
39
+ },
40
+ beforeModel: {
41
+ type: 'json',
42
+ description: 'Optional BEFORE LogicModelV1 state machine model for refactoring/migration regression comparison.',
43
+ },
44
+ stateMapping: {
45
+ type: 'json',
46
+ description: 'Optional object mapping BEFORE state ids to AFTER state ids. Omit for identity mapping (same state names).',
47
+ },
48
+ check: {
49
+ type: 'string',
50
+ description: 'Print one check in full when the digest truncated it. Pass the check id the CHECKS line lists (for example A9); the per-check budget no longer applies, and an id this report does not carry is answered with the ids that it does.',
51
+ },
52
+ reportView: {
53
+ type: 'string',
54
+ enum: ['digest', 'full'],
55
+ description: 'digest (default) prints the readable body; full prints the structured report, which report_diff needs.',
56
+ },
57
+ },
58
+ output: {
59
+ schema: {
60
+ type: 'json',
61
+ description: 'this plugin verification report with summary and per-check findings.',
62
+ },
63
+ render(args, value) {
64
+ return [{ type: 'text' as const, text: renderReportText(value, { full: args.reportView === 'full', check: args.check }) }]
65
+ },
66
+ },
67
+ timeoutMs: 10_000,
68
+ isConcurrencySafe: () => true,
69
+ async execute(args) {
70
+ try {
71
+ // `stateMapping` is declared `json`, so a string, a list or a map to non-strings all arrive here, and the
72
+ // engine reads the option with `mapping[state] ?? state`: every one of those quietly became the identity
73
+ // mapping. A renamed state then read as "A was removed and A2 was added" - a D1 error produced by the shape
74
+ // of an option rather than by the refactor, which is the one thing a regression comparison must not do.
75
+ requireObjectOption(args.stateMapping, 'stateMapping', 'VERIFY_OPTION_INVALID')
76
+ const mapping = args.stateMapping as Record<string, unknown> | undefined
77
+ if (mapping !== undefined) {
78
+ if (args.beforeModel === undefined) {
79
+ throw new OptionShapeError(
80
+ 'VERIFY_OPTION_INVALID',
81
+ 'stateMapping was passed without beforeModel: the mapping says how BEFORE state ids turn into AFTER ones, so there is nothing to map. Pass beforeModel, or omit the mapping.',
82
+ )
83
+ }
84
+ const [from, to] = Object.entries(mapping).find(([, value]) => typeof value !== 'string' || value === '') ?? []
85
+ if (from !== undefined) {
86
+ throw new OptionShapeError(
87
+ 'VERIFY_OPTION_INVALID',
88
+ 'stateMapping.' + from + ' is not a state id (it is ' + (Array.isArray(to) ? 'an array' : to === null ? 'null' : 'a ' + typeof to) + '). Every value is the AFTER state id the BEFORE state maps to, so it has to be a non-empty string.',
89
+ )
90
+ }
91
+ }
92
+ const model = oneOf(args.model, args.modelPath, 'model', 'modelPath', 'VERIFY_OPTION_INVALID')
93
+ return runVerification(model, {
94
+ maxStates: args.maxStates,
95
+ maxPermutationEvents: args.maxPermutationEvents,
96
+ beforeModel: args.beforeModel,
97
+ stateMapping: mapping as Record<string, string> | undefined,
98
+ }) as unknown as JsonValue
99
+ } catch (error) {
100
+ return optionRefusal(error, 'VERIFY_OPTION_INVALID') as unknown as JsonValue
101
+ }
102
+ },
103
+ })
@@ -0,0 +1,428 @@
1
+ /**
2
+ * The communication diagram module.
3
+ *
4
+ * A communication diagram is a set of lifelines joined by links, with numbered messages
5
+ * travelling along the links. PlantUML has no communication-diagram type - the page is a
6
+ * 404 and the navigation lists none - but the drawing is expressible with the object
7
+ * diagram's vocabulary: objects for the lifelines, arrows for the links, and the sequence
8
+ * number in the arrow's label.
9
+ *
10
+ * That last point is why this family is *not* refused. An earlier version of the family
11
+ * table said neither notation spells the numbering or the link roles, so a parse would
12
+ * invent an order. The numbering is spelled - `C --> O : 1: create` carries both the link
13
+ * and the order - and the renderer writes it back the same way. What the notation cannot
14
+ * say is the *nesting* that a number like `1.1` implies (which message is a nested call of
15
+ * which), so the model keeps the number as the string the diagram wrote and makes no claim
16
+ * about the tree it suggests.
17
+ *
18
+ * @module model-everything-uml-communication
19
+ */
20
+
21
+ import {
22
+ UmlError, UML_PLAIN_ID, directiveLine, readUmlDirectives, type UmlNotation, type UmlFamilyModule,
23
+ type UmlFamilyParse, type UmlFamilyRender, type UmlFamilyFinding, type UmlFamilyRenderOptions,
24
+ } from './uml-syntax.js'
25
+
26
+ /**
27
+ * A message number as the diagram wrote it: `1`, `2`, `1.1`, `3a`.
28
+ *
29
+ * It is a string, not a number, and the module does not parse its structure. `1.1` reads as
30
+ * a nested call in UML, but that is a claim about the design; the diagram only draws a
31
+ * label, and inventing the call tree from a dotted string would be the sort of guess this
32
+ * plugin refuses to make elsewhere.
33
+ */
34
+ export type CommunicationSequence = string
35
+
36
+ export interface CommunicationLifeline {
37
+ id: string
38
+ label?: string
39
+ /** `:Name` in the drawing is a classifier, `Name` alone is an instance. */
40
+ classifier?: string
41
+ }
42
+
43
+ export interface CommunicationLink {
44
+ from: string
45
+ to: string
46
+ label?: string
47
+ }
48
+
49
+ export interface CommunicationMessage {
50
+ sequence: CommunicationSequence
51
+ from: string
52
+ to: string
53
+ /** The message name and arguments, without the number. */
54
+ label?: string
55
+ }
56
+
57
+ /**
58
+ * A communication diagram as a model.
59
+ *
60
+ * `links` is what the diagram draws between lifelines; a message travels along one. The two
61
+ * are kept apart because a link with no message is a real thing to notice, and so is a
62
+ * message with no link to carry it.
63
+ */
64
+ export interface CommunicationModelV1 {
65
+ schemaVersion: 1
66
+ lifelines: CommunicationLifeline[]
67
+ links: CommunicationLink[]
68
+ messages: CommunicationMessage[]
69
+ /** Ids a message or link named that no declaration introduced. */
70
+ undeclared?: string[]
71
+ narrative?: { lifelines?: Record<string, string> }
72
+ }
73
+
74
+ /** The number a message carries, matched at the front of an arrow label. */
75
+ const SEQUENCE_PREFIX = /^\s*(\d+(?:\.\d+)*[a-zA-Z]?)\s*:\s*(.*)$/
76
+
77
+ const OBJECT_DECLARATION = /^object\s+(?:"([^"]*)"|(\S+))(?:\s+as\s+(\S+))?\s*$/i
78
+ const ARROW = /^(\S+?)\s*(-->|->|--|\.\.>|\.\.|<--|<-)\s*(\S+?)(?:\s*:\s*(.*))?$/
79
+
80
+ function quote(text: string): string {
81
+ return '"' + text.replace(/"/g, "'") + '"'
82
+ }
83
+
84
+ /**
85
+ * Validate a candidate model and normalize it.
86
+ *
87
+ * A sequence number must be unique: two messages with the same number is not a style
88
+ * question, it is a diagram whose order has two answers.
89
+ */
90
+ export function validateCommunicationModel(input: unknown): CommunicationModelV1 {
91
+ if (input === null || typeof input !== 'object' || Array.isArray(input)) {
92
+ throw new UmlError('a communication model must be an object with schemaVersion 1, lifelines, links and messages', 'UML_INPUT')
93
+ }
94
+ const model = input as Partial<CommunicationModelV1>
95
+ if (model.schemaVersion !== 1) throw new UmlError('a communication model must declare schemaVersion 1', 'UML_INPUT')
96
+ // A model from another family validated here: every list this family reads defaults to empty, so a structure
97
+ // model became "a communication diagram with nothing in it" - it rendered as an empty page, and the review threw
98
+ // a TypeError on `lifelines.length`. The three lists ARE the family, so a model that declares other fields and
99
+ // none of them is not this family's model, and naming both sides is the answer a reader can act on. A model with
100
+ // no fields at all is not a mismatch: it is an empty communication model, which `UML063` reports.
101
+ const declared = Object.keys(model).filter((key) => key !== 'schemaVersion')
102
+ const declaresLists = ['lifelines', 'links', 'messages'].some((key) => (model as Record<string, unknown>)[key] !== undefined)
103
+ if (!declaresLists && declared.length > 0) {
104
+ throw new UmlError(
105
+ 'this is not a communication model: a communication diagram is lifelines, links and numbered messages, and this model declares none of them (it declares ' + declared.slice(0, 6).join(', ') + '). Pass the lifelines/links/messages model, or review it as the family it belongs to.',
106
+ 'UML041_MODEL_FAMILY_MISMATCH',
107
+ )
108
+ }
109
+ for (const [name, value] of Object.entries({ lifelines: model.lifelines ?? [], links: model.links ?? [], messages: model.messages ?? [] })) {
110
+ if (!Array.isArray(value)) throw new UmlError(name + ' must be an array', 'UML_INPUT')
111
+ }
112
+
113
+ const lifelines: CommunicationLifeline[] = []
114
+ const known = new Set<string>()
115
+ for (const lifeline of model.lifelines ?? []) {
116
+ if (lifeline === null || typeof lifeline !== 'object' || typeof lifeline.id !== 'string' || lifeline.id.trim() === '') {
117
+ throw new UmlError('every lifeline needs a non-empty string id', 'UML_INPUT')
118
+ }
119
+ if (known.has(lifeline.id)) throw new UmlError('lifeline ' + quote(lifeline.id) + ' is declared more than once', 'UML_INPUT')
120
+ known.add(lifeline.id)
121
+ lifelines.push({
122
+ id: lifeline.id,
123
+ ...(lifeline.label === undefined ? {} : { label: lifeline.label }),
124
+ ...(lifeline.classifier === undefined ? {} : { classifier: lifeline.classifier }),
125
+ })
126
+ }
127
+
128
+ const undeclared = new Set<string>()
129
+ const links: CommunicationLink[] = []
130
+ for (const link of model.links ?? []) {
131
+ if (link === null || typeof link !== 'object') throw new UmlError('every link must be an object', 'UML_INPUT')
132
+ for (const side of ['from', 'to'] as const) {
133
+ const endpoint = link[side]
134
+ if (typeof endpoint !== 'string' || endpoint.trim() === '') throw new UmlError('every link needs a non-empty `' + side + '` end', 'UML_INPUT')
135
+ if (!known.has(endpoint)) undeclared.add(endpoint)
136
+ }
137
+ if (link.from === link.to) throw new UmlError('a link cannot join ' + quote(link.from) + ' to itself', 'UML_INPUT')
138
+ links.push({ from: link.from, to: link.to, ...(link.label === undefined ? {} : { label: link.label }) })
139
+ }
140
+
141
+ const messages: CommunicationMessage[] = []
142
+ const sequences = new Map<string, number>()
143
+ for (const message of model.messages ?? []) {
144
+ if (message === null || typeof message !== 'object') throw new UmlError('every message must be an object', 'UML_INPUT')
145
+ if (typeof message.sequence !== 'string' || message.sequence.trim() === '') {
146
+ throw new UmlError('every message needs a sequence number as a non-empty string', 'UML_INPUT')
147
+ }
148
+ const sequence = message.sequence.trim()
149
+ const previous = sequences.get(sequence)
150
+ if (previous !== undefined) {
151
+ throw new UmlError('sequence number ' + quote(sequence) + ' is used twice (message ' + String(previous + 1) + ' and this one): the order of the diagram would have two answers', 'UML_INPUT')
152
+ }
153
+ sequences.set(sequence, messages.length)
154
+ for (const side of ['from', 'to'] as const) {
155
+ const endpoint = message[side]
156
+ if (typeof endpoint !== 'string' || endpoint.trim() === '') throw new UmlError('every message needs a non-empty `' + side + '` end', 'UML_INPUT')
157
+ if (!known.has(endpoint)) undeclared.add(endpoint)
158
+ }
159
+ messages.push({ sequence, from: message.from, to: message.to, ...(message.label === undefined ? {} : { label: message.label }) })
160
+ }
161
+
162
+ return {
163
+ schemaVersion: 1,
164
+ lifelines,
165
+ links,
166
+ messages,
167
+ ...(undeclared.size === 0 ? {} : { undeclared: [...undeclared].sort() }),
168
+ ...(model.narrative === undefined ? {} : { narrative: model.narrative }),
169
+ }
170
+ }
171
+
172
+ /** The label a message is drawn with: `1: create`. */
173
+ function messageLabel(message: CommunicationMessage): string {
174
+ return message.sequence + ': ' + (message.label ?? '')
175
+ }
176
+
177
+ // ---------------------------------------------------------------------------
178
+ // Rendering
179
+ // ---------------------------------------------------------------------------
180
+
181
+ /**
182
+ * Render a communication diagram as PlantUML.
183
+ *
184
+ * Objects for the lifelines, arrows for the links, and one arrow per message with its
185
+ * number in the label. A message whose lifelines have no declared link is still drawn -
186
+ * the notation allows it - and the review reports it rather than the renderer hiding it.
187
+ *
188
+ * @param input - candidate `CommunicationModelV1`.
189
+ * @param notation - only `plantuml` draws this family.
190
+ */
191
+ export function renderCommunication(input: unknown, notation: UmlNotation): UmlFamilyRender {
192
+ if (notation !== 'plantuml') {
193
+ throw new UmlError('a communication diagram can only be drawn in plantuml; mermaid has no lifeline-and-link notation with numbered messages. Render it with notation "plantuml".', 'UML_VIEW_UNREPRESENTABLE')
194
+ }
195
+ const model = validateCommunicationModel(input)
196
+ const warnings: string[] = [
197
+ 'UML_RENDER_APPROXIMATE_VIEW: plantuml has no communication diagram type; this is an object-diagram drawing of one. Lifelines, links and the sequence numbers are carried; the nesting a dotted number like 1.1 suggests is not drawn and not claimed.',
198
+ ]
199
+ const lines: string[] = ['@startuml']
200
+ lines.push(directiveLine('plantuml', 'uml v1 notation=plantuml family=communication'))
201
+ for (const lifeline of model.lifelines) {
202
+ const shown = lifeline.classifier === undefined ? quote(lifeline.label ?? lifeline.id) : quote(':' + lifeline.classifier + ':')
203
+ const alias = lifeline.id === lifeline.label || lifeline.label === undefined ? lifeline.id : lifeline.id
204
+ lines.push('object ' + shown + ' as ' + alias)
205
+ }
206
+ for (const link of model.links) {
207
+ lines.push(link.from + ' -- ' + link.to + (link.label === undefined ? '' : ' : ' + link.label))
208
+ }
209
+ for (const message of model.messages) {
210
+ lines.push(message.from + ' --> ' + message.to + ' : ' + messageLabel(message))
211
+ }
212
+ lines.push('@enduml')
213
+ return { primary: lines.join('\n') + '\n', warnings }
214
+ }
215
+
216
+ // ---------------------------------------------------------------------------
217
+ // Parsing
218
+ // ---------------------------------------------------------------------------
219
+
220
+ /**
221
+ * Read a communication diagram back into a model.
222
+ *
223
+ * Hand-written text is read the same way the renderer writes it: `object` declarations are
224
+ * the lifelines, `--` arrows are the links, and an arrow whose label starts with a number
225
+ * and a colon is a message. A line that is neither is reported in `discardedConstructs`.
226
+ */
227
+ export function parseCommunication(text: string, notation: UmlNotation): UmlFamilyParse<CommunicationModelV1> {
228
+ if (notation !== 'plantuml') {
229
+ throw new UmlError('a communication diagram can only be read from plantuml', 'UML_VIEW_UNREPRESENTABLE')
230
+ }
231
+ const warnings: string[] = []
232
+ const discarded: Array<{ line: number; text: string; construct: string }> = []
233
+ const labels: Record<string, string> = {}
234
+ const lifelines: CommunicationLifeline[] = []
235
+ const links: CommunicationLink[] = []
236
+ const messages: CommunicationMessage[] = []
237
+
238
+ const raw = text.split(/\r?\n/)
239
+ for (let index = 0; index < raw.length; index += 1) {
240
+ const line = raw[index].trim()
241
+ if (line === '' || line.startsWith('@')) continue
242
+ if (line.startsWith("'")) {
243
+ // The renderer's directive line is not a construct; a comment is not either.
244
+ continue
245
+ }
246
+ if (/^(left to right direction|top to bottom direction|skinparam|hide|show|title|scale|allowmixing)\b/i.test(line)) {
247
+ discarded.push({ line: index + 1, text: line, construct: line.split(/\s+/)[0].toLowerCase() })
248
+ continue
249
+ }
250
+
251
+ const declaration = OBJECT_DECLARATION.exec(line)
252
+ if (declaration !== null) {
253
+ const shown = declaration[1] ?? declaration[2] ?? ''
254
+ const alias = declaration[3]
255
+ const id = alias ?? shown
256
+ const classifier = /^:(.*):$/.exec(shown)
257
+ const entry: CommunicationLifeline = { id }
258
+ if (classifier !== null) entry.classifier = classifier[1]
259
+ else if (shown !== '' && shown !== id) entry.label = shown
260
+ if (lifelines.some((existing) => existing.id === id)) {
261
+ warnings.push('line ' + String(index + 1) + ': lifeline ' + id + ' is declared twice; the second declaration is ignored')
262
+ continue
263
+ }
264
+ lifelines.push(entry)
265
+ if (entry.label !== undefined) labels[id] = entry.label
266
+ continue
267
+ }
268
+
269
+ const arrow = ARROW.exec(line)
270
+ if (arrow !== null) {
271
+ const [, from, operator, to, rawLabel] = arrow
272
+ const [left, right] = operator.startsWith('<') ? [to, from] : [from, to]
273
+ const labelText = rawLabel === undefined ? undefined : rawLabel.trim()
274
+ const sequence = labelText === undefined ? null : SEQUENCE_PREFIX.exec(labelText)
275
+ if (operator.includes('>') && sequence !== null) {
276
+ messages.push({ sequence: sequence[1], from: left, to: right, ...(sequence[2] === '' ? {} : { label: sequence[2] }) })
277
+ } else {
278
+ links.push({ from: left, to: right, ...(labelText === undefined || labelText === '' ? {} : { label: labelText }) })
279
+ }
280
+ continue
281
+ }
282
+ discarded.push({ line: index + 1, text: line, construct: 'line this model does not carry' })
283
+ }
284
+
285
+ const known = new Set(lifelines.map((lifeline) => lifeline.id))
286
+ const undeclared = [...new Set([...links, ...messages].flatMap((entry) => [entry.from, entry.to]))]
287
+ .filter((endpoint) => !known.has(endpoint))
288
+ .sort()
289
+ const model: CommunicationModelV1 = {
290
+ schemaVersion: 1,
291
+ lifelines,
292
+ links,
293
+ messages,
294
+ ...(undeclared.length === 0 ? {} : { undeclared }),
295
+ }
296
+ return { model, labels, warnings, discardedConstructs: discarded, discardedEdges: 0 }
297
+ }
298
+
299
+ // ---------------------------------------------------------------------------
300
+ // Review
301
+ // ---------------------------------------------------------------------------
302
+
303
+ export type CommunicationFindingCode =
304
+ | 'UML063_COMMUNICATION_NO_LIFELINES'
305
+ | 'UML064_COMMUNICATION_LIFELINE_UNLINKED'
306
+ | 'UML065_COMMUNICATION_MESSAGE_WITHOUT_LINK'
307
+ | 'UML066_COMMUNICATION_UNDECLARED_ENDPOINT'
308
+ | 'UML067_COMMUNICATION_SEQUENCE_DUPLICATE'
309
+ | 'UML068_COMMUNICATION_UNDOCUMENTED'
310
+
311
+ export const COMMUNICATION_FINDING_CODES: readonly CommunicationFindingCode[] = [
312
+ 'UML063_COMMUNICATION_NO_LIFELINES',
313
+ 'UML064_COMMUNICATION_LIFELINE_UNLINKED',
314
+ 'UML065_COMMUNICATION_MESSAGE_WITHOUT_LINK',
315
+ 'UML066_COMMUNICATION_UNDECLARED_ENDPOINT',
316
+ 'UML067_COMMUNICATION_SEQUENCE_DUPLICATE',
317
+ 'UML068_COMMUNICATION_UNDOCUMENTED',
318
+ ]
319
+
320
+ /** Sequence numbers as they sort: `2` before `10`, `1.1` before `1.2`. */
321
+ function sequenceOrder(left: string, right: string): number {
322
+ const parts = (value: string): number[] => value.split('.').map((part) => Number.parseInt(part, 10)).map((part) => (Number.isNaN(part) ? 0 : part))
323
+ const a = parts(left)
324
+ const b = parts(right)
325
+ for (let index = 0; index < Math.max(a.length, b.length); index += 1) {
326
+ const difference = (a[index] ?? -1) - (b[index] ?? -1)
327
+ if (difference !== 0) return difference
328
+ }
329
+ return 0
330
+ }
331
+
332
+ /**
333
+ * What a reader would get wrong about a communication diagram.
334
+ *
335
+ * The checks are the ones the diagram exists to answer: is every lifeline reachable, does
336
+ * every message travel a link that was drawn, does any number appear twice, and is each
337
+ * participant named after something in the design. The order the numbers imply is *not*
338
+ * checked against the drawing order - the numbers are the order, and the drawing order is
339
+ * not a claim.
340
+ */
341
+ export function reviewCommunicationModel(model: CommunicationModelV1, labels: Record<string, string>): UmlFamilyFinding[] {
342
+ const findings: UmlFamilyFinding[] = []
343
+ const name = (id: string): string => labels[id] ?? id
344
+
345
+ if (model.lifelines.length === 0) {
346
+ findings.push({
347
+ code: 'UML063_COMMUNICATION_NO_LIFELINES',
348
+ severity: 'error',
349
+ message: 'the diagram declares no lifeline, so no participant is named and nothing can travel.',
350
+ evidence: { lifelines: 0 },
351
+ })
352
+ }
353
+
354
+ const touched = new Set([...model.links, ...model.messages].flatMap((entry) => [entry.from, entry.to]))
355
+ for (const lifeline of model.lifelines) {
356
+ if (touched.has(lifeline.id)) continue
357
+ findings.push({
358
+ code: 'UML064_COMMUNICATION_LIFELINE_UNLINKED',
359
+ severity: 'warning',
360
+ message: 'lifeline ' + name(lifeline.id) + ' takes part in no link and no message, so nothing reaches it.',
361
+ evidence: { id: lifeline.id },
362
+ })
363
+ }
364
+
365
+ const linked = new Set(model.links.map((link) => [link.from, link.to].sort().join('\u0000')))
366
+ for (const message of model.messages) {
367
+ if (message.from === message.to) continue
368
+ if (linked.has([message.from, message.to].sort().join('\u0000'))) continue
369
+ findings.push({
370
+ code: 'UML065_COMMUNICATION_MESSAGE_WITHOUT_LINK',
371
+ severity: 'warning',
372
+ message: 'message ' + message.sequence + ' travels from ' + name(message.from) + ' to ' + name(message.to) + ', but the diagram draws no link between them.',
373
+ evidence: { sequence: message.sequence, from: message.from, to: message.to },
374
+ })
375
+ }
376
+
377
+ const undeclared = model.undeclared ?? []
378
+ if (undeclared.length > 0) {
379
+ findings.push({
380
+ code: 'UML066_COMMUNICATION_UNDECLARED_ENDPOINT',
381
+ severity: 'error',
382
+ message: 'a link or message names ' + String(undeclared.length) + ' participant(s) the diagram never declares, so the line points at nothing a reader can look up.',
383
+ detail: undeclared.join(', '),
384
+ evidence: { ids: undeclared },
385
+ })
386
+ }
387
+
388
+ const seen = new Map<string, number>()
389
+ for (const message of model.messages) {
390
+ const count = (seen.get(message.sequence) ?? 0) + 1
391
+ seen.set(message.sequence, count)
392
+ if (count > 1) {
393
+ findings.push({
394
+ code: 'UML067_COMMUNICATION_SEQUENCE_DUPLICATE',
395
+ severity: 'error',
396
+ message: 'sequence number ' + message.sequence + ' is used more than once, so the order of the diagram has two answers.',
397
+ evidence: { sequence: message.sequence },
398
+ })
399
+ }
400
+ }
401
+
402
+ if (model.narrative?.lifelines === undefined) {
403
+ for (const lifeline of model.lifelines) {
404
+ if (lifeline.label !== undefined || lifeline.classifier !== undefined || labels[lifeline.id] !== undefined) continue
405
+ findings.push({
406
+ code: 'UML068_COMMUNICATION_UNDOCUMENTED',
407
+ severity: 'info',
408
+ message: 'lifeline ' + lifeline.id + ' carries no name a reader can map back to the design: give it a display label, a classifier or a narrative entry.',
409
+ evidence: { id: lifeline.id },
410
+ })
411
+ }
412
+ }
413
+ return findings
414
+ }
415
+
416
+ /** The communication family, as the engine's registry expects it. */
417
+ export const communicationModule: UmlFamilyModule<CommunicationModelV1> = {
418
+ id: 'communication',
419
+ render: (input: unknown, notation: UmlNotation, _options: UmlFamilyRenderOptions): UmlFamilyRender => renderCommunication(input, notation),
420
+ parse: (text: string, notation: UmlNotation): UmlFamilyParse<CommunicationModelV1> => parseCommunication(text, notation),
421
+ // Validated, not trusted: the review path can hand over the model exactly as the caller wrote it, and every field
422
+ // this family reads is optional. Without this line `model.lifelines.length` is a TypeError on a foreign model -
423
+ // a crash where a review belongs, and one the validator already knows how to explain.
424
+ review: (model: CommunicationModelV1, labels: Record<string, string>): UmlFamilyFinding[] => reviewCommunicationModel(validateCommunicationModel(model), labels),
425
+ }
426
+
427
+ /** Sort two sequence numbers the way a reader reads them: `2` before `10`, `1.1` before `1.2`. */
428
+ export { sequenceOrder as communicationSequenceOrder }