@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.
@@ -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
+ }