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
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 }
|