@zombie-mermaid/mermaid-parser 2.2.1
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 +22 -0
- package/dist/index.cjs +3 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +879 -0
- package/dist/index.d.ts +879 -0
- package/dist/index.js +994 -0
- package/dist/index.js.map +1 -0
- package/package.json +35 -0
- package/src/__tests__/box-color.test.ts +80 -0
- package/src/__tests__/sequence-activation-fix.test.ts +91 -0
- package/src/__tests__/xychart-colors.test.ts +149 -0
- package/src/class/format.ts +15 -0
- package/src/class/parser.ts +593 -0
- package/src/class/types.ts +166 -0
- package/src/er/parser.ts +286 -0
- package/src/er/types.ts +99 -0
- package/src/expanded-shapes.ts +376 -0
- package/src/index.ts +48 -0
- package/src/sequence/activation-check.ts +149 -0
- package/src/sequence/activation-fix.ts +112 -0
- package/src/sequence/box-color.ts +85 -0
- package/src/sequence/parser.ts +613 -0
- package/src/sequence/types.ts +242 -0
- package/src/xychart/colors.ts +177 -0
- package/src/xychart/parser.ts +246 -0
- package/src/xychart/types.ts +150 -0
|
@@ -0,0 +1,376 @@
|
|
|
1
|
+
// ============================================================================
|
|
2
|
+
// zombie-mermaid — expanded node syntax A@{ shape: ..., label: ... }
|
|
3
|
+
//
|
|
4
|
+
// Mermaid v11.3.0 added a metadata form for node definitions:
|
|
5
|
+
//
|
|
6
|
+
// A@{ shape: rounded, label: "Start here" }
|
|
7
|
+
// B@{ shape: doc }
|
|
8
|
+
// C@{ icon: "fa:bell", form: "circle", label: "Alert" }
|
|
9
|
+
// D@{ img: "https://example.com/a.png", label: "Diagram", w: 120, h: 80 }
|
|
10
|
+
//
|
|
11
|
+
// It exposes ~30 semantic shape names (many with several aliases) that the
|
|
12
|
+
// classic bracket syntax cannot express. This module owns both halves of
|
|
13
|
+
// supporting it: parsing the metadata block, and resolving a semantic name to
|
|
14
|
+
// the geometry this renderer draws.
|
|
15
|
+
// ============================================================================
|
|
16
|
+
|
|
17
|
+
import type { NodeShape } from '@zombie-mermaid/core'
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Every documented Mermaid shape name (and alias) mapped to the geometry this
|
|
21
|
+
* renderer draws for it.
|
|
22
|
+
*
|
|
23
|
+
* Mermaid's list is deliberately semantic — `database`, `manual-input`,
|
|
24
|
+
* `paper-tape` — while a renderer only has so many distinct outlines. Names
|
|
25
|
+
* that share an outline map to the same `NodeShape`; that is a rendering
|
|
26
|
+
* choice, not a parse failure, and it is documented in docs/diagrams.md so
|
|
27
|
+
* the collapse is discoverable rather than surprising.
|
|
28
|
+
*
|
|
29
|
+
* Keys are lowercase; lookup lowercases its input.
|
|
30
|
+
*/
|
|
31
|
+
const SHAPE_ALIASES: Record<string, NodeShape> = {
|
|
32
|
+
// --- Rectangle family ---
|
|
33
|
+
rect: 'rectangle',
|
|
34
|
+
rectangle: 'rectangle',
|
|
35
|
+
proc: 'rectangle',
|
|
36
|
+
process: 'rectangle',
|
|
37
|
+
'normal-rect': 'rectangle',
|
|
38
|
+
|
|
39
|
+
// --- Rounded ---
|
|
40
|
+
rounded: 'rounded',
|
|
41
|
+
event: 'rounded',
|
|
42
|
+
'rounded-rect': 'rounded',
|
|
43
|
+
|
|
44
|
+
// --- Stadium / terminal ---
|
|
45
|
+
stadium: 'stadium',
|
|
46
|
+
pill: 'stadium',
|
|
47
|
+
terminal: 'stadium',
|
|
48
|
+
|
|
49
|
+
// --- Subroutine / framed rectangle ---
|
|
50
|
+
subproc: 'subroutine',
|
|
51
|
+
subprocess: 'subroutine',
|
|
52
|
+
subroutine: 'subroutine',
|
|
53
|
+
'framed-rectangle': 'subroutine',
|
|
54
|
+
'fr-rect': 'subroutine',
|
|
55
|
+
|
|
56
|
+
// --- Cylinder / database ---
|
|
57
|
+
cyl: 'cylinder',
|
|
58
|
+
cylinder: 'cylinder',
|
|
59
|
+
db: 'cylinder',
|
|
60
|
+
database: 'cylinder',
|
|
61
|
+
'h-cyl': 'cylinder',
|
|
62
|
+
das: 'cylinder',
|
|
63
|
+
'horizontal-cylinder': 'cylinder',
|
|
64
|
+
'lin-cyl': 'cylinder',
|
|
65
|
+
'lined-cylinder': 'cylinder',
|
|
66
|
+
disk: 'cylinder',
|
|
67
|
+
|
|
68
|
+
// --- Circle ---
|
|
69
|
+
circ: 'circle',
|
|
70
|
+
circle: 'circle',
|
|
71
|
+
start: 'circle',
|
|
72
|
+
|
|
73
|
+
// --- Double circle ---
|
|
74
|
+
'dbl-circ': 'doublecircle',
|
|
75
|
+
'double-circle': 'doublecircle',
|
|
76
|
+
stop: 'doublecircle',
|
|
77
|
+
|
|
78
|
+
// --- Filled / crossed circles ---
|
|
79
|
+
'f-circ': 'filled-circle',
|
|
80
|
+
'filled-circle': 'filled-circle',
|
|
81
|
+
junction: 'filled-circle',
|
|
82
|
+
'cross-circ': 'crossed-circle',
|
|
83
|
+
'crossed-circle': 'crossed-circle',
|
|
84
|
+
summary: 'crossed-circle',
|
|
85
|
+
|
|
86
|
+
// --- Diamond / decision ---
|
|
87
|
+
diam: 'diamond',
|
|
88
|
+
diamond: 'diamond',
|
|
89
|
+
decision: 'diamond',
|
|
90
|
+
question: 'diamond',
|
|
91
|
+
|
|
92
|
+
// --- Hexagon / prepare ---
|
|
93
|
+
hex: 'hexagon',
|
|
94
|
+
hexagon: 'hexagon',
|
|
95
|
+
prepare: 'hexagon',
|
|
96
|
+
|
|
97
|
+
// --- Asymmetric / odd ---
|
|
98
|
+
odd: 'asymmetric',
|
|
99
|
+
'rect-left-inv-arrow': 'asymmetric',
|
|
100
|
+
|
|
101
|
+
// --- Parallelograms ---
|
|
102
|
+
'lean-r': 'parallelogram',
|
|
103
|
+
'lean-right': 'parallelogram',
|
|
104
|
+
'in-out': 'parallelogram',
|
|
105
|
+
'lean-l': 'parallelogram-alt',
|
|
106
|
+
'lean-left': 'parallelogram-alt',
|
|
107
|
+
'out-in': 'parallelogram-alt',
|
|
108
|
+
|
|
109
|
+
// --- Trapezoids ---
|
|
110
|
+
'trap-b': 'trapezoid',
|
|
111
|
+
'trapezoid-bottom': 'trapezoid',
|
|
112
|
+
priority: 'trapezoid',
|
|
113
|
+
'trap-t': 'trapezoid-alt',
|
|
114
|
+
'trapezoid-top': 'trapezoid-alt',
|
|
115
|
+
manual: 'trapezoid-alt',
|
|
116
|
+
'curv-trap': 'trapezoid-alt',
|
|
117
|
+
'curved-trapezoid': 'trapezoid-alt',
|
|
118
|
+
display: 'trapezoid-alt',
|
|
119
|
+
|
|
120
|
+
// --- Document family ---
|
|
121
|
+
doc: 'document',
|
|
122
|
+
document: 'document',
|
|
123
|
+
'lin-doc': 'document',
|
|
124
|
+
'lined-document': 'document',
|
|
125
|
+
'tag-doc': 'document',
|
|
126
|
+
'tagged-document': 'document',
|
|
127
|
+
docs: 'stacked-document',
|
|
128
|
+
documents: 'stacked-document',
|
|
129
|
+
'st-doc': 'stacked-document',
|
|
130
|
+
'stacked-document': 'stacked-document',
|
|
131
|
+
|
|
132
|
+
// --- Card / notched rectangle ---
|
|
133
|
+
'notch-rect': 'card',
|
|
134
|
+
card: 'card',
|
|
135
|
+
'notched-rectangle': 'card',
|
|
136
|
+
|
|
137
|
+
// --- Lined / divided / tagged rectangles ---
|
|
138
|
+
'lin-rect': 'lined-process',
|
|
139
|
+
'lined-rectangle': 'lined-process',
|
|
140
|
+
'lin-proc': 'lined-process',
|
|
141
|
+
'shaded-process': 'lined-process',
|
|
142
|
+
'div-rect': 'divided-process',
|
|
143
|
+
'divided-rectangle': 'divided-process',
|
|
144
|
+
'div-proc': 'divided-process',
|
|
145
|
+
'tag-rect': 'rectangle',
|
|
146
|
+
'tagged-rectangle': 'rectangle',
|
|
147
|
+
'tag-proc': 'rectangle',
|
|
148
|
+
procs: 'stacked-process',
|
|
149
|
+
processes: 'stacked-process',
|
|
150
|
+
'st-rect': 'stacked-process',
|
|
151
|
+
'stacked-rectangle': 'stacked-process',
|
|
152
|
+
|
|
153
|
+
// --- Triangles ---
|
|
154
|
+
tri: 'triangle',
|
|
155
|
+
triangle: 'triangle',
|
|
156
|
+
extract: 'triangle',
|
|
157
|
+
'flip-tri': 'flipped-triangle',
|
|
158
|
+
'flipped-triangle': 'flipped-triangle',
|
|
159
|
+
'manual-file': 'flipped-triangle',
|
|
160
|
+
|
|
161
|
+
// --- Window pane / internal storage ---
|
|
162
|
+
'win-pane': 'window-pane',
|
|
163
|
+
'window-pane': 'window-pane',
|
|
164
|
+
'internal-storage': 'window-pane',
|
|
165
|
+
|
|
166
|
+
// --- Fork / join ---
|
|
167
|
+
fork: 'fork-join',
|
|
168
|
+
join: 'fork-join',
|
|
169
|
+
'long-rect': 'fork-join',
|
|
170
|
+
|
|
171
|
+
// --- Notched pentagon / loop limit ---
|
|
172
|
+
'notch-pent': 'notched-pentagon',
|
|
173
|
+
'loop-limit': 'notched-pentagon',
|
|
174
|
+
'notched-pentagon': 'notched-pentagon',
|
|
175
|
+
|
|
176
|
+
// --- Sloped rectangle / manual input ---
|
|
177
|
+
'sl-rect': 'sloped-rectangle',
|
|
178
|
+
'sloped-rectangle': 'sloped-rectangle',
|
|
179
|
+
'manual-input': 'sloped-rectangle',
|
|
180
|
+
|
|
181
|
+
// --- Flag / paper tape ---
|
|
182
|
+
flag: 'flag',
|
|
183
|
+
'paper-tape': 'flag',
|
|
184
|
+
|
|
185
|
+
// --- Bow-tie rectangle / stored data ---
|
|
186
|
+
'bow-rect': 'bow-tie-rectangle',
|
|
187
|
+
'bow-tie-rectangle': 'bow-tie-rectangle',
|
|
188
|
+
'stored-data': 'bow-tie-rectangle',
|
|
189
|
+
|
|
190
|
+
// --- Delay / half-rounded rectangle ---
|
|
191
|
+
delay: 'half-rounded-rectangle',
|
|
192
|
+
'half-rounded-rectangle': 'half-rounded-rectangle',
|
|
193
|
+
|
|
194
|
+
// --- Braces / comment ---
|
|
195
|
+
brace: 'brace',
|
|
196
|
+
'brace-l': 'brace',
|
|
197
|
+
comment: 'brace',
|
|
198
|
+
'brace-r': 'brace-right',
|
|
199
|
+
braces: 'braces',
|
|
200
|
+
|
|
201
|
+
// --- Lightning bolt / communication link ---
|
|
202
|
+
bolt: 'bolt',
|
|
203
|
+
'com-link': 'bolt',
|
|
204
|
+
'lightning-bolt': 'bolt',
|
|
205
|
+
|
|
206
|
+
// --- Bare text, no outline ---
|
|
207
|
+
text: 'text',
|
|
208
|
+
|
|
209
|
+
// --- Anchor / hidden ---
|
|
210
|
+
anchor: 'anchor',
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Resolve a Mermaid shape name to the geometry this renderer draws.
|
|
215
|
+
* Returns `undefined` for an unrecognized name so the caller can decide
|
|
216
|
+
* whether to fall back or report it.
|
|
217
|
+
*/
|
|
218
|
+
export function resolveShapeName(name: string): NodeShape | undefined {
|
|
219
|
+
return SHAPE_ALIASES[name.trim().toLowerCase()]
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/** Every shape name this renderer accepts, for documentation and tests. */
|
|
223
|
+
export function knownShapeNames(): string[] {
|
|
224
|
+
return Object.keys(SHAPE_ALIASES).sort()
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/** The metadata a `@{ ... }` block can carry. */
|
|
228
|
+
export interface ExpandedNodeMeta {
|
|
229
|
+
shape?: string
|
|
230
|
+
label?: string
|
|
231
|
+
icon?: string
|
|
232
|
+
img?: string
|
|
233
|
+
/** Outline drawn around an icon or image: `square`, `circle`, `rounded`. */
|
|
234
|
+
form?: string
|
|
235
|
+
/** Explicit width/height for an image node. */
|
|
236
|
+
w?: string
|
|
237
|
+
h?: string
|
|
238
|
+
/** Image fit mode Mermaid accepts alongside `img`. */
|
|
239
|
+
constraint?: string
|
|
240
|
+
/** Any other key seen, preserved rather than dropped. */
|
|
241
|
+
[key: string]: string | undefined
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Parse the body of a `@{ ... }` block into key/value pairs.
|
|
246
|
+
*
|
|
247
|
+
* The body is a comma-separated list of `key: value`. Values may be quoted
|
|
248
|
+
* with `"` or `'`, and a quoted value may contain commas, colons, and braces
|
|
249
|
+
* — which is why this is a scanner rather than a `split(',')`.
|
|
250
|
+
*
|
|
251
|
+
* Mermaid also accepts a bare value with no key as shorthand for the shape
|
|
252
|
+
* (`A@{ rounded }`); that is handled by the caller, which sees an entry with
|
|
253
|
+
* an empty key.
|
|
254
|
+
*/
|
|
255
|
+
export function parseExpandedMeta(body: string): ExpandedNodeMeta {
|
|
256
|
+
const meta: ExpandedNodeMeta = {}
|
|
257
|
+
|
|
258
|
+
for (const entry of splitTopLevel(body, ',')) {
|
|
259
|
+
const trimmed = entry.trim()
|
|
260
|
+
if (trimmed.length === 0) continue
|
|
261
|
+
|
|
262
|
+
const colon = indexOfTopLevel(trimmed, ':')
|
|
263
|
+
if (colon === -1) {
|
|
264
|
+
// Bare value — Mermaid's shorthand for `shape: <value>`.
|
|
265
|
+
meta.shape ??= stripQuotes(trimmed)
|
|
266
|
+
continue
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
const key = trimmed.slice(0, colon).trim().toLowerCase()
|
|
270
|
+
const value = stripQuotes(trimmed.slice(colon + 1).trim())
|
|
271
|
+
if (key.length > 0) meta[key] = value
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
return meta
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/** Split on `separator`, ignoring separators inside quotes. */
|
|
278
|
+
function splitTopLevel(text: string, separator: string): string[] {
|
|
279
|
+
const parts: string[] = []
|
|
280
|
+
let current = ''
|
|
281
|
+
let quote: string | null = null
|
|
282
|
+
|
|
283
|
+
for (const ch of text) {
|
|
284
|
+
if (quote !== null) {
|
|
285
|
+
current += ch
|
|
286
|
+
if (ch === quote) quote = null
|
|
287
|
+
continue
|
|
288
|
+
}
|
|
289
|
+
if (ch === '"' || ch === "'") {
|
|
290
|
+
quote = ch
|
|
291
|
+
current += ch
|
|
292
|
+
continue
|
|
293
|
+
}
|
|
294
|
+
if (ch === separator) {
|
|
295
|
+
parts.push(current)
|
|
296
|
+
current = ''
|
|
297
|
+
continue
|
|
298
|
+
}
|
|
299
|
+
current += ch
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
parts.push(current)
|
|
303
|
+
return parts
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/** Index of the first `needle` outside quotes, or -1. */
|
|
307
|
+
function indexOfTopLevel(text: string, needle: string): number {
|
|
308
|
+
let quote: string | null = null
|
|
309
|
+
for (let i = 0; i < text.length; i++) {
|
|
310
|
+
const ch = text[i]!
|
|
311
|
+
if (quote !== null) {
|
|
312
|
+
if (ch === quote) quote = null
|
|
313
|
+
continue
|
|
314
|
+
}
|
|
315
|
+
if (ch === '"' || ch === "'") {
|
|
316
|
+
quote = ch
|
|
317
|
+
continue
|
|
318
|
+
}
|
|
319
|
+
if (ch === needle) return i
|
|
320
|
+
}
|
|
321
|
+
return -1
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/** Remove one layer of matching wrapping quotes. */
|
|
325
|
+
function stripQuotes(value: string): string {
|
|
326
|
+
if (
|
|
327
|
+
value.length >= 2 &&
|
|
328
|
+
((value.startsWith('"') && value.endsWith('"')) ||
|
|
329
|
+
(value.startsWith("'") && value.endsWith("'")))
|
|
330
|
+
) {
|
|
331
|
+
return value.slice(1, -1)
|
|
332
|
+
}
|
|
333
|
+
return value
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/**
|
|
337
|
+
* Find the `@{ ... }` block at the start of `text`, returning its body and
|
|
338
|
+
* total length.
|
|
339
|
+
*
|
|
340
|
+
* Brace matching is depth-aware and quote-aware, so a label containing `}`
|
|
341
|
+
* (`A@{ label: "a } b" }`) does not terminate the block early. Returns
|
|
342
|
+
* `undefined` if `text` does not open with `@{` or the block is unterminated.
|
|
343
|
+
*/
|
|
344
|
+
export function matchExpandedBlock(
|
|
345
|
+
text: string,
|
|
346
|
+
): { body: string; length: number } | undefined {
|
|
347
|
+
if (!text.startsWith('@{')) return undefined
|
|
348
|
+
|
|
349
|
+
let depth = 0
|
|
350
|
+
let quote: string | null = null
|
|
351
|
+
|
|
352
|
+
for (let i = 1; i < text.length; i++) {
|
|
353
|
+
const ch = text[i]!
|
|
354
|
+
|
|
355
|
+
if (quote !== null) {
|
|
356
|
+
if (ch === quote) quote = null
|
|
357
|
+
continue
|
|
358
|
+
}
|
|
359
|
+
if (ch === '"' || ch === "'") {
|
|
360
|
+
quote = ch
|
|
361
|
+
continue
|
|
362
|
+
}
|
|
363
|
+
if (ch === '{') {
|
|
364
|
+
depth++
|
|
365
|
+
continue
|
|
366
|
+
}
|
|
367
|
+
if (ch === '}') {
|
|
368
|
+
depth--
|
|
369
|
+
if (depth === 0) {
|
|
370
|
+
return { body: text.slice(2, i), length: i + 1 }
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
return undefined
|
|
376
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
// ============================================================================
|
|
2
|
+
// @zombie-mermaid/mermaid-parser — per-type diagram parsers
|
|
3
|
+
//
|
|
4
|
+
// Class, ER, sequence, and XY chart diagrams have no shared generic model
|
|
5
|
+
// the way flowcharts/state diagrams do (`MermaidGraph`, parsed by the
|
|
6
|
+
// umbrella's own `src/parser.ts`). Both `svg-renderer` and `ascii-renderer`
|
|
7
|
+
// import each type's parse function and types directly — confirmed by grep
|
|
8
|
+
// (zombie-mermaid#624, umbrella #620, `monorepo-conversion-scoping.md`
|
|
9
|
+
// finding 2) — so this package's public API is every per-type parse
|
|
10
|
+
// function plus its types, not a single generic entry point.
|
|
11
|
+
//
|
|
12
|
+
// Each of `src/class/`, `src/er/`, `src/sequence/`, `src/xychart/` used to
|
|
13
|
+
// mix this parser half (`parser.ts`, `types.ts`, and — per file, not
|
|
14
|
+
// per-half-pair, see the scoping doc's addendum correction 3 —
|
|
15
|
+
// `class/format.ts`, `sequence/box-color.ts`, `sequence/activation-check.ts`,
|
|
16
|
+
// `xychart/colors.ts`) with a renderer half (`layout.ts`, `renderer.ts`) in
|
|
17
|
+
// the same directory. The renderer half moved into
|
|
18
|
+
// `packages/svg-renderer/src/<type>/` instead — see that package's `index.ts`
|
|
19
|
+
// header for the reverse dependency this split introduces (`svg-renderer`
|
|
20
|
+
// depends on this package for the positioned-diagram types and a handful of
|
|
21
|
+
// parser-side helpers, an ordinary acyclic workspace shape per finding 2).
|
|
22
|
+
//
|
|
23
|
+
// This package depends only on `@zombie-mermaid/core` — verified: no file
|
|
24
|
+
// below imports `elkjs`, `@zombie-mermaid/svg-renderer`, or anything from
|
|
25
|
+
// the umbrella. `toDirection` moved here from the umbrella's `src/parser.ts`
|
|
26
|
+
// (alongside `isDirection`, already `core` since #625) specifically so
|
|
27
|
+
// `er/parser.ts` below could use it without importing the umbrella and
|
|
28
|
+
// creating a cycle — see `packages/core/src/direction.ts`'s header.
|
|
29
|
+
// ============================================================================
|
|
30
|
+
|
|
31
|
+
export * from './class/parser.ts'
|
|
32
|
+
export * from './class/types.ts'
|
|
33
|
+
export * from './class/format.ts'
|
|
34
|
+
|
|
35
|
+
export * from './er/parser.ts'
|
|
36
|
+
export * from './er/types.ts'
|
|
37
|
+
|
|
38
|
+
export * from './sequence/parser.ts'
|
|
39
|
+
export * from './sequence/types.ts'
|
|
40
|
+
export * from './sequence/box-color.ts'
|
|
41
|
+
export * from './sequence/activation-check.ts'
|
|
42
|
+
export * from './sequence/activation-fix.ts'
|
|
43
|
+
|
|
44
|
+
export * from './xychart/parser.ts'
|
|
45
|
+
export * from './xychart/types.ts'
|
|
46
|
+
export * from './xychart/colors.ts'
|
|
47
|
+
|
|
48
|
+
export * from './expanded-shapes.ts'
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
// ============================================================================
|
|
2
|
+
// Sequence diagram — activation/deactivation balance check
|
|
3
|
+
//
|
|
4
|
+
// A mechanical, deterministic semantic check: every `activate X` (or the
|
|
5
|
+
// `+` arrow shorthand) must be closed by a matching `deactivate X` (or the
|
|
6
|
+
// `-` shorthand) before the diagram ends. This doesn't need an LLM judge —
|
|
7
|
+
// it's the same activation-stack bookkeeping
|
|
8
|
+
// packages/svg-renderer/src/sequence/layout.ts already runs to *draw*
|
|
9
|
+
// activation bars (see its `activationStacks` map), reused here to *report*
|
|
10
|
+
// imbalance instead of silently rendering around it.
|
|
11
|
+
//
|
|
12
|
+
// Motivation (issue #539, split from #536): research on LLM-generated
|
|
13
|
+
// Mermaid sequence diagrams found they fail mostly on activation handling
|
|
14
|
+
// and error/status tracking, not basic syntax — which existing validators
|
|
15
|
+
// (syntax checkers, and agentic-mermaid's structural/geometric/lint
|
|
16
|
+
// `verify` tool: dangling edges, label overflow, duplicate/unreachable
|
|
17
|
+
// nodes) already cover well. Activation balance is a concrete, narrow,
|
|
18
|
+
// mechanically-checkable instance of that gap.
|
|
19
|
+
// ============================================================================
|
|
20
|
+
|
|
21
|
+
import type { SequenceDiagram, Message } from './types.ts'
|
|
22
|
+
|
|
23
|
+
export type ActivationIssueCode =
|
|
24
|
+
'DANGLING_ACTIVATION' | 'UNMATCHED_DEACTIVATION'
|
|
25
|
+
|
|
26
|
+
export interface ActivationIssue {
|
|
27
|
+
code: ActivationIssueCode
|
|
28
|
+
/** Actor whose activation is unbalanced. */
|
|
29
|
+
actorId: string
|
|
30
|
+
/** Human-readable explanation, including a source-position hint. */
|
|
31
|
+
message: string
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface ActivationCheckResult {
|
|
35
|
+
/** true when every activation is balanced (no issues found). */
|
|
36
|
+
ok: boolean
|
|
37
|
+
issues: ActivationIssue[]
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** One `activate`/`deactivate` event in source order, whichever form wrote it. */
|
|
41
|
+
interface Event {
|
|
42
|
+
actorId: string
|
|
43
|
+
kind: 'start' | 'end'
|
|
44
|
+
afterIndex: number
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Describe where an event occurred, for a human-readable issue message.
|
|
49
|
+
* Mirrors the `afterIndex` semantics of `SequenceDiagram.activations` (see
|
|
50
|
+
* types.ts): -1 means "before the first message", otherwise the index of
|
|
51
|
+
* the message this event follows.
|
|
52
|
+
*/
|
|
53
|
+
function describePosition(
|
|
54
|
+
diagram: SequenceDiagram,
|
|
55
|
+
afterIndex: number,
|
|
56
|
+
): string {
|
|
57
|
+
if (afterIndex < 0) return 'before the first message'
|
|
58
|
+
const msg: Message | undefined = diagram.messages[afterIndex]
|
|
59
|
+
if (!msg) return 'at an unresolved position'
|
|
60
|
+
const label = msg.label ? `: ${msg.label}` : ''
|
|
61
|
+
return `after message ${afterIndex + 1} ("${msg.from} -> ${msg.to}${label}")`
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Check a parsed sequence diagram for activation/deactivation imbalance.
|
|
66
|
+
*
|
|
67
|
+
* Merges the inline `+`/`-` arrow shorthand (`Message.activate` /
|
|
68
|
+
* `Message.deactivate`) with standalone `activate X` / `deactivate X`
|
|
69
|
+
* statements (`SequenceDiagram.activations`) into one chronological event
|
|
70
|
+
* stream per actor — the same merge `layout.ts` performs to position
|
|
71
|
+
* activation bars — then walks a stack per actor:
|
|
72
|
+
*
|
|
73
|
+
* - A `deactivate` with nothing open on that actor's stack is reported as
|
|
74
|
+
* `UNMATCHED_DEACTIVATION` (the renderer silently ignores this case —
|
|
75
|
+
* see `endActivation` in layout.ts — this check surfaces it instead).
|
|
76
|
+
* - Anything left on a stack once every message has been processed is
|
|
77
|
+
* reported as `DANGLING_ACTIVATION` (the renderer draws these extending
|
|
78
|
+
* to the bottom of the diagram, which usually isn't what the author
|
|
79
|
+
* intended).
|
|
80
|
+
*/
|
|
81
|
+
export function checkActivationBalance(
|
|
82
|
+
diagram: SequenceDiagram,
|
|
83
|
+
): ActivationCheckResult {
|
|
84
|
+
// Standalone activations, grouped by the message index they follow — same
|
|
85
|
+
// grouping layout.ts builds for its `activationEventsByAfterIndex`.
|
|
86
|
+
const standaloneByAfterIndex = new Map<number, typeof diagram.activations>()
|
|
87
|
+
for (const event of diagram.activations) {
|
|
88
|
+
const list = standaloneByAfterIndex.get(event.afterIndex) ?? []
|
|
89
|
+
list.push(event)
|
|
90
|
+
standaloneByAfterIndex.set(event.afterIndex, list)
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const events: Event[] = []
|
|
94
|
+
function pushStandalone(afterIndex: number): void {
|
|
95
|
+
for (const event of standaloneByAfterIndex.get(afterIndex) ?? []) {
|
|
96
|
+
events.push({ actorId: event.actorId, kind: event.kind, afterIndex })
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Events before the first message open first, same as layout.ts.
|
|
101
|
+
pushStandalone(-1)
|
|
102
|
+
for (let i = 0; i < diagram.messages.length; i++) {
|
|
103
|
+
const msg = diagram.messages[i]!
|
|
104
|
+
if (msg.activate) {
|
|
105
|
+
events.push({ actorId: msg.to, kind: 'start', afterIndex: i })
|
|
106
|
+
}
|
|
107
|
+
if (msg.deactivate) {
|
|
108
|
+
events.push({ actorId: msg.from, kind: 'end', afterIndex: i })
|
|
109
|
+
}
|
|
110
|
+
pushStandalone(i)
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// actorId -> stack of afterIndex values where an activation was opened.
|
|
114
|
+
const stacks = new Map<string, number[]>()
|
|
115
|
+
const issues: ActivationIssue[] = []
|
|
116
|
+
|
|
117
|
+
for (const event of events) {
|
|
118
|
+
const stack = stacks.get(event.actorId) ?? []
|
|
119
|
+
stacks.set(event.actorId, stack)
|
|
120
|
+
if (event.kind === 'start') {
|
|
121
|
+
stack.push(event.afterIndex)
|
|
122
|
+
continue
|
|
123
|
+
}
|
|
124
|
+
const openedAt = stack.pop()
|
|
125
|
+
if (openedAt === undefined) {
|
|
126
|
+
issues.push({
|
|
127
|
+
code: 'UNMATCHED_DEACTIVATION',
|
|
128
|
+
actorId: event.actorId,
|
|
129
|
+
message:
|
|
130
|
+
`"deactivate ${event.actorId}" ${describePosition(diagram, event.afterIndex)} ` +
|
|
131
|
+
`has no matching "activate ${event.actorId}" open at that point.`,
|
|
132
|
+
})
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
for (const [actorId, stack] of stacks) {
|
|
137
|
+
for (const openedAt of stack) {
|
|
138
|
+
issues.push({
|
|
139
|
+
code: 'DANGLING_ACTIVATION',
|
|
140
|
+
actorId,
|
|
141
|
+
message:
|
|
142
|
+
`"activate ${actorId}" opened ${describePosition(diagram, openedAt)} ` +
|
|
143
|
+
`is never closed with a matching "deactivate ${actorId}".`,
|
|
144
|
+
})
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
return { ok: issues.length === 0, issues }
|
|
149
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
// ============================================================================
|
|
2
|
+
// Sequence diagram — activation/deactivation auto-fix
|
|
3
|
+
//
|
|
4
|
+
// Extends activation-check.ts (issue #539) with the "optionally proposes a
|
|
5
|
+
// fix" half of that issue's scope. Deliberately narrow: only
|
|
6
|
+
// `DANGLING_ACTIVATION` (an `activate X` never closed) is auto-fixable —
|
|
7
|
+
// the mechanical, unambiguous repair is to append a matching `deactivate X`
|
|
8
|
+
// after the diagram's last statement, which is always syntactically valid
|
|
9
|
+
// regardless of block nesting (Mermaid's `deactivate` isn't block-scoped).
|
|
10
|
+
//
|
|
11
|
+
// `UNMATCHED_DEACTIVATION` (a `deactivate X` with nothing open) is
|
|
12
|
+
// deliberately NOT auto-fixed: fixing it means either deleting that
|
|
13
|
+
// specific statement or inserting a preceding `activate X`, and this
|
|
14
|
+
// package doesn't track source line/position per activation statement (see
|
|
15
|
+
// SequenceDiagram.activations — only a message-index `afterIndex`, no raw
|
|
16
|
+
// line offset), so a text-level edit could not target the right occurrence
|
|
17
|
+
// with confidence when the same statement text repeats. Rather than guess
|
|
18
|
+
// and risk mangling unrelated source, this case is reported back as a
|
|
19
|
+
// remaining (unfixable) issue for a human — or a fuzzier LLM-based layer,
|
|
20
|
+
// per the issue's own caveat about unvalidated frontier-model behavior — to
|
|
21
|
+
// resolve.
|
|
22
|
+
// ============================================================================
|
|
23
|
+
|
|
24
|
+
import { detectDiagramType, splitStatements } from '@zombie-mermaid/core'
|
|
25
|
+
import { parseSequenceDiagram } from './parser.ts'
|
|
26
|
+
import {
|
|
27
|
+
checkActivationBalance,
|
|
28
|
+
type ActivationIssue,
|
|
29
|
+
} from './activation-check.ts'
|
|
30
|
+
|
|
31
|
+
export interface ActivationFixResult {
|
|
32
|
+
/** true when the returned diagram has no remaining activation issues. */
|
|
33
|
+
ok: boolean
|
|
34
|
+
/** Original diagram, unchanged if there was nothing fixable. */
|
|
35
|
+
originalDiagram: string
|
|
36
|
+
/**
|
|
37
|
+
* Diagram with a "deactivate X" statement appended for every
|
|
38
|
+
* DANGLING_ACTIVATION issue found. Identical to `originalDiagram` when
|
|
39
|
+
* there was nothing to fix.
|
|
40
|
+
*/
|
|
41
|
+
fixedDiagram: string
|
|
42
|
+
/** Human-readable description of each fix actually applied. */
|
|
43
|
+
fixesApplied: string[]
|
|
44
|
+
/**
|
|
45
|
+
* Issues that could not be auto-fixed (currently always
|
|
46
|
+
* UNMATCHED_DEACTIVATION — see module header). Empty when `ok` is true.
|
|
47
|
+
*/
|
|
48
|
+
remainingIssues: ActivationIssue[]
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Check a Mermaid sequence diagram for activation/deactivation imbalance
|
|
53
|
+
* and, where mechanically safe, return a corrected version.
|
|
54
|
+
*
|
|
55
|
+
* Throws the same way `parseSequenceDiagram`/`detectDiagramType` do on
|
|
56
|
+
* invalid or non-sequence input — callers (e.g. the MCP tool handler) are
|
|
57
|
+
* expected to catch and translate, matching `check-sequence-activations.ts`'s
|
|
58
|
+
* own error handling.
|
|
59
|
+
*/
|
|
60
|
+
export function fixActivationBalance(
|
|
61
|
+
sourceDiagram: string,
|
|
62
|
+
): ActivationFixResult {
|
|
63
|
+
const diagramType = detectDiagramType(sourceDiagram)
|
|
64
|
+
if (diagramType !== 'sequence') {
|
|
65
|
+
throw new Error(
|
|
66
|
+
'fixActivationBalance only supports sequence diagrams (source must ' +
|
|
67
|
+
`start with "sequenceDiagram"); detected diagram type: "${diagramType}".`,
|
|
68
|
+
)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const lines = splitStatements(sourceDiagram)
|
|
72
|
+
const diagram = parseSequenceDiagram(lines)
|
|
73
|
+
const result = checkActivationBalance(diagram)
|
|
74
|
+
|
|
75
|
+
if (result.ok) {
|
|
76
|
+
return {
|
|
77
|
+
ok: true,
|
|
78
|
+
originalDiagram: sourceDiagram,
|
|
79
|
+
fixedDiagram: sourceDiagram,
|
|
80
|
+
fixesApplied: [],
|
|
81
|
+
remainingIssues: [],
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const dangling = result.issues.filter(
|
|
86
|
+
(issue) => issue.code === 'DANGLING_ACTIVATION',
|
|
87
|
+
)
|
|
88
|
+
const remainingIssues = result.issues.filter(
|
|
89
|
+
(issue) => issue.code !== 'DANGLING_ACTIVATION',
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
const appended = dangling
|
|
93
|
+
.map((issue) => ` deactivate ${issue.actorId}`)
|
|
94
|
+
.join('\n')
|
|
95
|
+
const fixedDiagram =
|
|
96
|
+
dangling.length === 0
|
|
97
|
+
? sourceDiagram
|
|
98
|
+
: `${sourceDiagram.replace(/\s+$/, '')}\n${appended}`
|
|
99
|
+
|
|
100
|
+
const fixesApplied = dangling.map(
|
|
101
|
+
(issue) =>
|
|
102
|
+
`Appended "deactivate ${issue.actorId}" to close: ${issue.message}`,
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
return {
|
|
106
|
+
ok: remainingIssues.length === 0,
|
|
107
|
+
originalDiagram: sourceDiagram,
|
|
108
|
+
fixedDiagram,
|
|
109
|
+
fixesApplied,
|
|
110
|
+
remainingIssues,
|
|
111
|
+
}
|
|
112
|
+
}
|