@diagrammo/dgmo 0.4.1 → 0.4.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/.claude/skills/dgmo-chart/SKILL.md +28 -0
  2. package/.claude/skills/dgmo-generate/SKILL.md +1 -0
  3. package/.claude/skills/dgmo-sequence/SKILL.md +24 -1
  4. package/.cursorrules +27 -2
  5. package/.github/copilot-instructions.md +36 -3
  6. package/.windsurfrules +27 -2
  7. package/README.md +12 -3
  8. package/dist/cli.cjs +611 -153
  9. package/dist/index.cjs +8371 -3200
  10. package/dist/index.cjs.map +1 -1
  11. package/dist/index.d.cts +502 -58
  12. package/dist/index.d.ts +502 -58
  13. package/dist/index.js +8594 -3444
  14. package/dist/index.js.map +1 -1
  15. package/docs/ai-integration.md +1 -1
  16. package/docs/language-reference.md +336 -17
  17. package/docs/migration-sequence-color-to-tags.md +98 -0
  18. package/package.json +1 -1
  19. package/src/c4/renderer.ts +1 -20
  20. package/src/class/renderer.ts +1 -11
  21. package/src/cli.ts +40 -0
  22. package/src/d3.ts +92 -2
  23. package/src/dgmo-router.ts +11 -0
  24. package/src/echarts.ts +74 -8
  25. package/src/er/parser.ts +29 -3
  26. package/src/er/renderer.ts +1 -15
  27. package/src/graph/flowchart-parser.ts +7 -30
  28. package/src/graph/flowchart-renderer.ts +62 -69
  29. package/src/graph/layout.ts +5 -0
  30. package/src/graph/state-parser.ts +388 -0
  31. package/src/graph/state-renderer.ts +496 -0
  32. package/src/graph/types.ts +4 -2
  33. package/src/index.ts +42 -1
  34. package/src/infra/compute.ts +1113 -0
  35. package/src/infra/layout.ts +575 -0
  36. package/src/infra/parser.ts +559 -0
  37. package/src/infra/renderer.ts +1509 -0
  38. package/src/infra/roles.ts +60 -0
  39. package/src/infra/serialize.ts +67 -0
  40. package/src/infra/types.ts +221 -0
  41. package/src/infra/validation.ts +192 -0
  42. package/src/initiative-status/layout.ts +56 -61
  43. package/src/initiative-status/renderer.ts +13 -13
  44. package/src/kanban/renderer.ts +1 -24
  45. package/src/org/layout.ts +28 -37
  46. package/src/org/parser.ts +16 -1
  47. package/src/org/renderer.ts +159 -121
  48. package/src/org/resolver.ts +90 -23
  49. package/src/palettes/color-utils.ts +30 -0
  50. package/src/render.ts +2 -0
  51. package/src/sequence/parser.ts +202 -42
  52. package/src/sequence/renderer.ts +576 -113
  53. package/src/sequence/tag-resolution.ts +163 -0
  54. package/src/sitemap/collapse.ts +187 -0
  55. package/src/sitemap/layout.ts +738 -0
  56. package/src/sitemap/parser.ts +489 -0
  57. package/src/sitemap/renderer.ts +774 -0
  58. package/src/sitemap/types.ts +42 -0
  59. package/src/utils/tag-groups.ts +119 -0
@@ -6,7 +6,9 @@ import { inferParticipantType } from './participant-inference';
6
6
  import type { DgmoError } from '../diagnostics';
7
7
  import { makeDgmoError, formatDgmoError, suggest } from '../diagnostics';
8
8
  import { parseArrow } from '../utils/arrows';
9
- import { measureIndent } from '../utils/parsing';
9
+ import { measureIndent, extractColor, parsePipeMetadata } from '../utils/parsing';
10
+ import type { TagGroup } from '../utils/tag-groups';
11
+ import { matchTagBlockHeading, validateTagValues } from '../utils/tag-groups';
10
12
 
11
13
  /**
12
14
  * Participant types that can be declared via "Name is a type" syntax.
@@ -49,6 +51,8 @@ export interface SequenceParticipant {
49
51
  lineNumber: number;
50
52
  /** Explicit layout position override (0-based from left, negative from right) */
51
53
  position?: number;
54
+ /** Pipe-delimited tag metadata (e.g. `| role: Gateway`) */
55
+ metadata?: Record<string, string>;
52
56
  }
53
57
 
54
58
  /**
@@ -60,6 +64,8 @@ export interface SequenceMessage {
60
64
  label: string;
61
65
  lineNumber: number;
62
66
  async?: boolean;
67
+ /** Pipe-delimited tag metadata (e.g. `| c: Caching`) */
68
+ metadata?: Record<string, string>;
63
69
  }
64
70
 
65
71
  /**
@@ -86,7 +92,6 @@ export interface SequenceBlock {
86
92
  export interface SequenceSection {
87
93
  kind: 'section';
88
94
  label: string;
89
- color?: string;
90
95
  lineNumber: number;
91
96
  }
92
97
 
@@ -125,9 +130,10 @@ export function isSequenceNote(el: SequenceElement): el is SequenceNote {
125
130
  */
126
131
  export interface SequenceGroup {
127
132
  name: string;
128
- color?: string;
129
133
  participantIds: string[];
130
134
  lineNumber: number;
135
+ /** Pipe-delimited tag metadata (e.g. `[Backend | t: Product]`) */
136
+ metadata?: Record<string, string>;
131
137
  }
132
138
 
133
139
  /**
@@ -141,6 +147,7 @@ export interface ParsedSequenceDgmo {
141
147
  elements: SequenceElement[];
142
148
  groups: SequenceGroup[];
143
149
  sections: SequenceSection[];
150
+ tagGroups: TagGroup[];
144
151
  options: Record<string, string>;
145
152
  diagnostics: DgmoError[];
146
153
  error: string | null;
@@ -153,8 +160,13 @@ const IS_A_PATTERN = /^(\S+)\s+is\s+an?\s+(\w+)(?:\s+(.+))?$/i;
153
160
  // Standalone "Name position N" pattern — e.g. "DB position -1"
154
161
  const POSITION_ONLY_PATTERN = /^(\S+)\s+position\s+(-?\d+)$/i;
155
162
 
156
- // Group heading pattern"## Backend", "## API Services(blue)", "## Backend(#hex)"
157
- const GROUP_HEADING_PATTERN = /^##\s+(.+?)(?:\(([^)]+)\))?\s*$/;
163
+ // Colored participant declaratione.g. "Tapin2(green)", "API(blue)"
164
+ const COLORED_PARTICIPANT_PATTERN = /^(\S+?)\(([^)]+)\)\s*$/;
165
+
166
+ // Group heading pattern — "[Backend]", "[API Services(blue)]", "[Backend(#hex)]"
167
+ const GROUP_HEADING_PATTERN = /^\[(.+?)(?:\(([^)]+)\))?\]\s*$/;
168
+ // Legacy ## syntax — detect and emit migration error
169
+ const LEGACY_GROUP_PATTERN = /^##\s+(.+?)(?:\(([^)]+)\))?\s*$/;
158
170
 
159
171
  // Section divider pattern — "== Label ==", "== Label(color) ==", or "== Label" (trailing == optional)
160
172
  const SECTION_PATTERN = /^==\s+(.+?)(?:\s*==)?\s*$/;
@@ -178,6 +190,7 @@ export function parseSequenceDgmo(content: string): ParsedSequenceDgmo {
178
190
  elements: [],
179
191
  groups: [],
180
192
  sections: [],
193
+ tagGroups: [],
181
194
  options: {},
182
195
  diagnostics: [],
183
196
  error: null,
@@ -197,6 +210,11 @@ export function parseSequenceDgmo(content: string): ParsedSequenceDgmo {
197
210
  if (!result.error) result.error = formatDgmoError(diag);
198
211
  };
199
212
 
213
+ /** Push a non-fatal warning (does not set result.error). */
214
+ const pushWarning = (line: number, message: string): void => {
215
+ result.diagnostics.push(makeDgmoError(line, message, 'warning'));
216
+ };
217
+
200
218
  if (!content || !content.trim()) {
201
219
  return fail(0, 'Empty content');
202
220
  }
@@ -205,12 +223,26 @@ export function parseSequenceDgmo(content: string): ParsedSequenceDgmo {
205
223
  let hasExplicitChart = false;
206
224
  let contentStarted = false;
207
225
 
208
- // Group parsing state — tracks the active ## group heading
226
+ // Group parsing state — tracks the active [Group] heading
209
227
  let activeGroup: SequenceGroup | null = null;
210
228
 
211
229
  // Track participant → group name for duplicate membership detection
212
230
  const participantGroupMap = new Map<string, string>();
213
231
 
232
+ // Tag group parsing state
233
+ let currentTagGroup: TagGroup | null = null;
234
+ const aliasMap = new Map<string, string>();
235
+
236
+ /** Split pipe metadata from a line: "core | k: v" → { core, meta } */
237
+ const splitPipe = (text: string): { core: string; meta?: Record<string, string> } => {
238
+ const idx = text.indexOf('|');
239
+ if (idx < 0) return { core: text };
240
+ const core = text.substring(0, idx).trimEnd();
241
+ const segments = text.substring(idx).split('|');
242
+ const meta = parsePipeMetadata(segments, aliasMap);
243
+ return Object.keys(meta).length > 0 ? { core, meta } : { core };
244
+ };
245
+
214
246
  // Block parsing state
215
247
  const blockStack: {
216
248
  block: SequenceBlock;
@@ -236,28 +268,58 @@ export function parseSequenceDgmo(content: string): ParsedSequenceDgmo {
236
268
  // Skip empty lines
237
269
  if (!trimmed) {
238
270
  activeGroup = null;
271
+ currentTagGroup = null;
239
272
  continue;
240
273
  }
241
274
 
242
- // Parse group heading — must be checked before comment skip since ## starts with #
275
+ // Parse group heading — [Group Name] or [Group Name(color)] or [Group | k: v]
243
276
  const groupMatch = trimmed.match(GROUP_HEADING_PATTERN);
244
277
  if (groupMatch) {
245
- const groupColor = groupMatch[2]?.trim();
246
- if (groupColor && groupColor.startsWith('#')) {
247
- pushError(lineNumber, 'Use a named color instead of hex (e.g., blue, red, teal)');
248
- continue;
278
+ let groupName = groupMatch[1].trim();
279
+ let groupColor = groupMatch[2]?.trim();
280
+ let groupMeta: Record<string, string> | undefined;
281
+
282
+ // Check for pipe metadata inside brackets
283
+ const gpipeIdx = groupName.indexOf('|');
284
+ if (gpipeIdx >= 0) {
285
+ const nameAndColor = groupName.substring(0, gpipeIdx).trimEnd();
286
+ const segments = groupName.substring(gpipeIdx).split('|');
287
+ const meta = parsePipeMetadata(segments, aliasMap);
288
+ if (Object.keys(meta).length > 0) groupMeta = meta;
289
+ // Re-extract color from name part
290
+ const colorSuffix = nameAndColor.match(/^(.+?)\(([^)]+)\)$/);
291
+ if (colorSuffix) {
292
+ groupName = colorSuffix[1].trim();
293
+ groupColor = colorSuffix[2].trim();
294
+ } else {
295
+ groupName = nameAndColor;
296
+ }
297
+ }
298
+
299
+ if (groupColor) {
300
+ pushWarning(lineNumber, `(${groupColor}) color syntax removed from sequence diagrams — use 'tag:' groups for coloring`);
249
301
  }
250
302
  contentStarted = true;
251
303
  activeGroup = {
252
- name: groupMatch[1].trim(),
253
- color: groupColor || undefined,
304
+ name: groupName,
254
305
  participantIds: [],
255
306
  lineNumber,
307
+ ...(groupMeta ? { metadata: groupMeta } : {}),
256
308
  };
257
309
  result.groups.push(activeGroup);
258
310
  continue;
259
311
  }
260
312
 
313
+ // Reject legacy ## group syntax with migration hint
314
+ if (trimmed.match(LEGACY_GROUP_PATTERN)) {
315
+ const legacyMatch = trimmed.match(LEGACY_GROUP_PATTERN)!;
316
+ const name = legacyMatch[1].trim();
317
+ const color = legacyMatch[2]?.trim();
318
+ const suggestion = color ? `[${name}(${color})]` : `[${name}]`;
319
+ pushError(lineNumber, `'## ${name}' group syntax is no longer supported. Use '${suggestion}' instead`);
320
+ continue;
321
+ }
322
+
261
323
  // Close active group on non-indented, non-group lines
262
324
  if (activeGroup && measureIndent(raw) === 0) {
263
325
  activeGroup = null;
@@ -266,12 +328,56 @@ export function parseSequenceDgmo(content: string): ParsedSequenceDgmo {
266
328
  // Skip comments — only // is supported
267
329
  if (trimmed.startsWith('//')) continue;
268
330
 
269
- // Reject # as comment syntax (## is for group headings, handled above)
270
- if (trimmed.startsWith('#') && !trimmed.startsWith('##')) {
271
- pushError(lineNumber, 'Use // for comments. # is reserved for group headings (##)');
331
+ // Reject # as comment syntax
332
+ if (trimmed.startsWith('#')) {
333
+ pushError(lineNumber, 'Use // for comments');
334
+ continue;
335
+ }
336
+
337
+ // ---- Tag group handling ----
338
+ // Tag block heading: "tag: Name [alias X]"
339
+ const tagBlockMatch = matchTagBlockHeading(trimmed);
340
+ if (tagBlockMatch && !tagBlockMatch.deprecated) {
341
+ if (contentStarted) {
342
+ pushError(lineNumber, 'Tag groups must appear before sequence content');
343
+ continue;
344
+ }
345
+ currentTagGroup = {
346
+ name: tagBlockMatch.name,
347
+ alias: tagBlockMatch.alias,
348
+ entries: [],
349
+ lineNumber,
350
+ };
351
+ if (tagBlockMatch.alias) {
352
+ aliasMap.set(tagBlockMatch.alias.toLowerCase(), tagBlockMatch.name.toLowerCase());
353
+ }
354
+ result.tagGroups.push(currentTagGroup);
355
+ continue;
356
+ }
357
+
358
+ // Tag group entries (indented Value(color) [default] under tag: heading)
359
+ if (currentTagGroup && !contentStarted && measureIndent(raw) > 0) {
360
+ const isDefault = /\bdefault\s*$/.test(trimmed);
361
+ const entryText = isDefault
362
+ ? trimmed.replace(/\s+default\s*$/, '').trim()
363
+ : trimmed;
364
+ const { label, color } = extractColor(entryText);
365
+ if (!color) {
366
+ pushError(lineNumber, `Expected 'Value(color)' in tag group '${currentTagGroup.name}'`);
367
+ continue;
368
+ }
369
+ if (isDefault) {
370
+ currentTagGroup.defaultValue = label;
371
+ }
372
+ currentTagGroup.entries.push({ value: label, color, lineNumber });
272
373
  continue;
273
374
  }
274
375
 
376
+ // Non-indented line after tag group — close it
377
+ if (currentTagGroup) {
378
+ currentTagGroup = null;
379
+ }
380
+
275
381
  // Parse section dividers — "== Label ==" or "== Label(color) =="
276
382
  // Close blocks first — sections at indent 0 should not nest inside blocks
277
383
  const sectionMatch = trimmed.match(SECTION_PATTERN);
@@ -284,15 +390,13 @@ export function parseSequenceDgmo(content: string): ParsedSequenceDgmo {
284
390
  }
285
391
  const labelRaw = sectionMatch[1].trim();
286
392
  const colorMatch = labelRaw.match(/^(.+?)\(([^)]+)\)$/);
287
- if (colorMatch && colorMatch[2].trim().startsWith('#')) {
288
- pushError(lineNumber, 'Use a named color instead of hex (e.g., blue, red, teal)');
289
- continue;
393
+ if (colorMatch) {
394
+ pushWarning(lineNumber, `(${colorMatch[2].trim()}) color syntax removed from sequence diagrams use 'tag:' groups for coloring`);
290
395
  }
291
396
  contentStarted = true;
292
397
  const section: SequenceSection = {
293
398
  kind: 'section',
294
399
  label: colorMatch ? colorMatch[1].trim() : labelRaw,
295
- color: colorMatch ? colorMatch[2].trim() : undefined,
296
400
  lineNumber,
297
401
  };
298
402
  result.sections.push(section);
@@ -303,7 +407,7 @@ export function parseSequenceDgmo(content: string): ParsedSequenceDgmo {
303
407
  // Parse header key: value lines (always top-level)
304
408
  // Skip 'note' lines — parsed in the indent-aware section below
305
409
  const colonIndex = trimmed.indexOf(':');
306
- if (colonIndex > 0 && !trimmed.includes('->') && !trimmed.includes('~>') && !trimmed.includes('<-') && !trimmed.includes('<~')) {
410
+ if (colonIndex > 0 && !trimmed.includes('->') && !trimmed.includes('~>') && !trimmed.includes('<-') && !trimmed.includes('<~') && !trimmed.includes('|')) {
307
411
  const key = trimmed.substring(0, colonIndex).trim().toLowerCase();
308
412
  if (key === 'note' || key.startsWith('note ')) {
309
413
  // Fall through to indent-aware note parsing below
@@ -337,7 +441,8 @@ export function parseSequenceDgmo(content: string): ParsedSequenceDgmo {
337
441
  }
338
442
 
339
443
  // Parse "Name is a type [aka Alias]" declarations (always top-level)
340
- const isAMatch = trimmed.match(IS_A_PATTERN);
444
+ const { core: isACore, meta: isAMeta } = splitPipe(trimmed);
445
+ const isAMatch = isACore.match(IS_A_PATTERN);
341
446
  if (isAMatch) {
342
447
  contentStarted = true;
343
448
  const id = isAMatch[1];
@@ -366,6 +471,7 @@ export function parseSequenceDgmo(content: string): ParsedSequenceDgmo {
366
471
  type: participantType,
367
472
  lineNumber,
368
473
  ...(position !== undefined ? { position } : {}),
474
+ ...(isAMeta ? { metadata: isAMeta } : {}),
369
475
  });
370
476
  }
371
477
  // Track group membership
@@ -382,7 +488,8 @@ export function parseSequenceDgmo(content: string): ParsedSequenceDgmo {
382
488
  }
383
489
 
384
490
  // Parse standalone "Name position N" (no "is a" type)
385
- const posOnlyMatch = trimmed.match(POSITION_ONLY_PATTERN);
491
+ const { core: posCore, meta: posMeta } = splitPipe(trimmed);
492
+ const posOnlyMatch = posCore.match(POSITION_ONLY_PATTERN);
386
493
  if (posOnlyMatch) {
387
494
  contentStarted = true;
388
495
  const id = posOnlyMatch[1];
@@ -395,6 +502,7 @@ export function parseSequenceDgmo(content: string): ParsedSequenceDgmo {
395
502
  type: inferParticipantType(id),
396
503
  lineNumber,
397
504
  position,
505
+ ...(posMeta ? { metadata: posMeta } : {}),
398
506
  });
399
507
  }
400
508
  // Track group membership
@@ -410,19 +518,25 @@ export function parseSequenceDgmo(content: string): ParsedSequenceDgmo {
410
518
  continue;
411
519
  }
412
520
 
413
- // Bare participant name inside an active group (single identifier on an indented line)
414
- if (activeGroup && measureIndent(raw) > 0 && /^\S+$/.test(trimmed)) {
521
+ // Colored participant declaration "Name(color)" at any level
522
+ // Color syntax is deprecated emit warning and register without color
523
+ const { core: colorCore, meta: colorMeta } = splitPipe(trimmed);
524
+ const coloredMatch = colorCore.match(COLORED_PARTICIPANT_PATTERN);
525
+ if (coloredMatch && !ARROW_PATTERN.test(colorCore)) {
526
+ const id = coloredMatch[1];
527
+ const color = coloredMatch[2].trim();
528
+ pushWarning(lineNumber, `(${color}) color syntax removed from sequence diagrams — use 'tag:' groups for coloring`);
415
529
  contentStarted = true;
416
- const id = trimmed;
417
530
  if (!result.participants.some((p) => p.id === id)) {
418
531
  result.participants.push({
419
532
  id,
420
533
  label: id,
421
534
  type: inferParticipantType(id),
422
535
  lineNumber,
536
+ ...(colorMeta ? { metadata: colorMeta } : {}),
423
537
  });
424
538
  }
425
- if (!activeGroup.participantIds.includes(id)) {
539
+ if (activeGroup && !activeGroup.participantIds.includes(id)) {
426
540
  const existingGroup = participantGroupMap.get(id);
427
541
  if (existingGroup) {
428
542
  pushError(lineNumber, `Participant '${id}' is already in group '${existingGroup}' — participants can only belong to one group`);
@@ -434,6 +548,36 @@ export function parseSequenceDgmo(content: string): ParsedSequenceDgmo {
434
548
  continue;
435
549
  }
436
550
 
551
+ // Bare participant name — either inside an active group (indented) or top-level declaration
552
+ // Supports pipe metadata: " API | c: Gateway" or "Tapin2 | l:Park"
553
+ {
554
+ const { core: bareCore, meta: bareMeta } = splitPipe(trimmed);
555
+ const inGroup = activeGroup && measureIndent(raw) > 0;
556
+ if (/^\S+$/.test(bareCore) && !ARROW_PATTERN.test(bareCore) && (inGroup || !contentStarted || bareMeta)) {
557
+ contentStarted = true;
558
+ const id = bareCore;
559
+ if (!result.participants.some((p) => p.id === id)) {
560
+ result.participants.push({
561
+ id,
562
+ label: id,
563
+ type: inferParticipantType(id),
564
+ lineNumber,
565
+ ...(bareMeta ? { metadata: bareMeta } : {}),
566
+ });
567
+ }
568
+ if (activeGroup && !activeGroup.participantIds.includes(id)) {
569
+ const existingGroup = participantGroupMap.get(id);
570
+ if (existingGroup) {
571
+ pushError(lineNumber, `Participant '${id}' is already in group '${existingGroup}' — participants can only belong to one group`);
572
+ } else {
573
+ activeGroup.participantIds.push(id);
574
+ participantGroupMap.set(id, activeGroup.name);
575
+ }
576
+ }
577
+ continue;
578
+ }
579
+ }
580
+
437
581
  // ---- Indent-aware parsing for messages and block keywords ----
438
582
  const indent = measureIndent(raw);
439
583
 
@@ -452,9 +596,12 @@ export function parseSequenceDgmo(content: string): ParsedSequenceDgmo {
452
596
  blockStack.pop();
453
597
  }
454
598
 
599
+ // Split pipe metadata before arrow parsing (arrows use $ anchor)
600
+ const { core: arrowCore, meta: arrowMeta } = splitPipe(trimmed);
601
+
455
602
  // Parse message lines first — arrows take priority over keywords
456
603
  // Reject "async" keyword prefix — use ~> instead
457
- const asyncPrefixMatch = trimmed.match(/^async\s+(.+)$/i);
604
+ const asyncPrefixMatch = arrowCore.match(/^async\s+(.+)$/i);
458
605
  if (asyncPrefixMatch && ARROW_PATTERN.test(asyncPrefixMatch[1])) {
459
606
  pushError(lineNumber, 'Use ~> for async messages: A ~> B: message');
460
607
  continue;
@@ -462,7 +609,7 @@ export function parseSequenceDgmo(content: string): ParsedSequenceDgmo {
462
609
 
463
610
  // ---- Labeled arrows: -label->, ~label~> ----
464
611
  // Must be checked BEFORE plain arrow patterns to avoid partial matches
465
- const labeledArrow = parseArrow(trimmed);
612
+ const labeledArrow = parseArrow(arrowCore);
466
613
  if (labeledArrow && 'error' in labeledArrow) {
467
614
  pushError(lineNumber, labeledArrow.error);
468
615
  continue;
@@ -478,6 +625,7 @@ export function parseSequenceDgmo(content: string): ParsedSequenceDgmo {
478
625
  label,
479
626
  lineNumber,
480
627
  ...(isAsync ? { async: true } : {}),
628
+ ...(arrowMeta ? { metadata: arrowMeta } : {}),
481
629
  };
482
630
  result.messages.push(msg);
483
631
  currentContainer().push(msg);
@@ -503,10 +651,10 @@ export function parseSequenceDgmo(content: string): ParsedSequenceDgmo {
503
651
  }
504
652
 
505
653
  // ---- Error: old colon-postfix syntax (A -> B: msg) ----
506
- const colonPostfixSync = trimmed.match(
654
+ const colonPostfixSync = arrowCore.match(
507
655
  /^(\S+)\s*->\s*([^\s:]+)\s*:\s*(.+)$/
508
656
  );
509
- const colonPostfixAsync = trimmed.match(
657
+ const colonPostfixAsync = arrowCore.match(
510
658
  /^(\S+)\s*~>\s*([^\s:]+)\s*:\s*(.+)$/
511
659
  );
512
660
  const colonPostfix = colonPostfixSync || colonPostfixAsync;
@@ -524,7 +672,7 @@ export function parseSequenceDgmo(content: string): ParsedSequenceDgmo {
524
672
  }
525
673
 
526
674
  // ---- Error: plain bidirectional arrows (A <-> B, A <~> B) ----
527
- const bidiPlainMatch = trimmed.match(
675
+ const bidiPlainMatch = arrowCore.match(
528
676
  /^(\S+)\s*(?:<->|<~>)\s*(\S+)/
529
677
  );
530
678
  if (bidiPlainMatch) {
@@ -536,8 +684,8 @@ export function parseSequenceDgmo(content: string): ParsedSequenceDgmo {
536
684
  }
537
685
 
538
686
  // ---- Deprecated bare return arrows: A <- B, A <~ B ----
539
- const bareReturnSync = trimmed.match(/^(\S+)\s+<-\s+(\S+)$/);
540
- const bareReturnAsync = trimmed.match(/^(\S+)\s+<~\s+(\S+)$/);
687
+ const bareReturnSync = arrowCore.match(/^(\S+)\s+<-\s+(\S+)$/);
688
+ const bareReturnAsync = arrowCore.match(/^(\S+)\s+<~\s+(\S+)$/);
541
689
  const bareReturn = bareReturnSync || bareReturnAsync;
542
690
  if (bareReturn) {
543
691
  const to = bareReturn[1];
@@ -550,8 +698,8 @@ export function parseSequenceDgmo(content: string): ParsedSequenceDgmo {
550
698
  }
551
699
 
552
700
  // ---- Bare (unlabeled) call arrows: A -> B, A ~> B ----
553
- const bareCallSync = trimmed.match(/^(\S+)\s*->\s*(\S+)$/);
554
- const bareCallAsync = trimmed.match(/^(\S+)\s*~>\s*(\S+)$/);
701
+ const bareCallSync = arrowCore.match(/^(\S+)\s*->\s*(\S+)$/);
702
+ const bareCallAsync = arrowCore.match(/^(\S+)\s*~>\s*(\S+)$/);
555
703
  const bareCall = bareCallSync || bareCallAsync;
556
704
  if (bareCall) {
557
705
  contentStarted = true;
@@ -565,6 +713,7 @@ export function parseSequenceDgmo(content: string): ParsedSequenceDgmo {
565
713
  label: '',
566
714
  lineNumber,
567
715
  ...(bareCallAsync ? { async: true } : {}),
716
+ ...(arrowMeta ? { metadata: arrowMeta } : {}),
568
717
  };
569
718
  result.messages.push(msg);
570
719
  currentContainer().push(msg);
@@ -747,10 +896,6 @@ export function parseSequenceDgmo(content: string): ParsedSequenceDgmo {
747
896
  }
748
897
  }
749
898
 
750
- const warn = (line: number, message: string): void => {
751
- result.diagnostics.push(makeDgmoError(line, message, 'warning'));
752
- };
753
-
754
899
  // Warn about unused participants (only when the diagram has messages)
755
900
  if (result.messages.length > 0) {
756
901
  const usedIds = new Set<string>();
@@ -778,7 +923,7 @@ export function parseSequenceDgmo(content: string): ParsedSequenceDgmo {
778
923
 
779
924
  for (const p of result.participants) {
780
925
  if (!usedIds.has(p.id)) {
781
- warn(p.lineNumber, `Participant "${p.label}" is declared but never used in any message or note`);
926
+ pushWarning(p.lineNumber, `Participant "${p.label}" is declared but never used in any message or note`);
782
927
  }
783
928
  }
784
929
  }
@@ -786,8 +931,23 @@ export function parseSequenceDgmo(content: string): ParsedSequenceDgmo {
786
931
  // Warn about empty groups
787
932
  for (const group of result.groups) {
788
933
  if (group.participantIds.length === 0) {
789
- warn(group.lineNumber, `Group "${group.name}" has no participants`);
934
+ pushWarning(group.lineNumber, `Group "${group.name}" has no participants`);
935
+ }
936
+ }
937
+
938
+ // Validate tag group values on participants and messages
939
+ if (result.tagGroups.length > 0) {
940
+ const entities: Array<{ metadata: Record<string, string>; lineNumber: number }> = [];
941
+ for (const p of result.participants) {
942
+ if (p.metadata) entities.push({ metadata: p.metadata, lineNumber: p.lineNumber });
943
+ }
944
+ for (const m of result.messages) {
945
+ if (m.metadata) entities.push({ metadata: m.metadata, lineNumber: m.lineNumber });
946
+ }
947
+ for (const g of result.groups) {
948
+ if (g.metadata) entities.push({ metadata: g.metadata, lineNumber: g.lineNumber });
790
949
  }
950
+ validateTagValues(entities, result.tagGroups, pushWarning, suggest);
791
951
  }
792
952
 
793
953
  return result;