@diagrammo/dgmo 0.8.5 → 0.8.7

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 (65) hide show
  1. package/.claude/commands/dgmo.md +34 -27
  2. package/.cursorrules +20 -2
  3. package/.github/copilot-instructions.md +20 -2
  4. package/.windsurfrules +20 -2
  5. package/AGENTS.md +23 -3
  6. package/README.md +0 -1
  7. package/dist/cli.cjs +189 -190
  8. package/dist/editor.cjs +3 -18
  9. package/dist/editor.cjs.map +1 -1
  10. package/dist/editor.js +3 -18
  11. package/dist/editor.js.map +1 -1
  12. package/dist/highlight.cjs +4 -21
  13. package/dist/highlight.cjs.map +1 -1
  14. package/dist/highlight.js +4 -21
  15. package/dist/highlight.js.map +1 -1
  16. package/dist/index.cjs +2791 -2999
  17. package/dist/index.cjs.map +1 -1
  18. package/dist/index.d.cts +56 -56
  19. package/dist/index.d.ts +56 -56
  20. package/dist/index.js +2786 -2992
  21. package/dist/index.js.map +1 -1
  22. package/docs/ai-integration.md +1 -1
  23. package/docs/language-reference.md +112 -121
  24. package/gallery/fixtures/boxes-and-lines.dgmo +64 -0
  25. package/package.json +1 -1
  26. package/src/boxes-and-lines/collapse.ts +78 -0
  27. package/src/boxes-and-lines/layout.ts +319 -0
  28. package/src/boxes-and-lines/parser.ts +697 -0
  29. package/src/boxes-and-lines/renderer.ts +848 -0
  30. package/src/boxes-and-lines/types.ts +40 -0
  31. package/src/c4/parser.ts +10 -5
  32. package/src/c4/renderer.ts +232 -56
  33. package/src/chart.ts +9 -4
  34. package/src/cli.ts +6 -5
  35. package/src/completion.ts +25 -33
  36. package/src/d3.ts +26 -27
  37. package/src/dgmo-router.ts +3 -7
  38. package/src/echarts.ts +38 -2
  39. package/src/editor/keywords.ts +4 -19
  40. package/src/er/parser.ts +10 -4
  41. package/src/gantt/parser.ts +10 -4
  42. package/src/gantt/renderer.ts +3 -5
  43. package/src/index.ts +17 -26
  44. package/src/infra/parser.ts +10 -5
  45. package/src/infra/renderer.ts +2 -2
  46. package/src/kanban/parser.ts +10 -5
  47. package/src/kanban/renderer.ts +43 -18
  48. package/src/org/parser.ts +7 -4
  49. package/src/org/renderer.ts +40 -29
  50. package/src/sequence/parser.ts +11 -5
  51. package/src/sequence/renderer.ts +114 -45
  52. package/src/sitemap/parser.ts +8 -4
  53. package/src/sitemap/renderer.ts +137 -57
  54. package/src/utils/legend-svg.ts +44 -20
  55. package/src/utils/parsing.ts +1 -1
  56. package/src/utils/tag-groups.ts +59 -15
  57. package/gallery/fixtures/initiative-status-full.dgmo +0 -46
  58. package/gallery/fixtures/initiative-status-phases.dgmo +0 -29
  59. package/gallery/fixtures/initiative-status.dgmo +0 -9
  60. package/src/initiative-status/collapse.ts +0 -76
  61. package/src/initiative-status/filter.ts +0 -63
  62. package/src/initiative-status/layout.ts +0 -650
  63. package/src/initiative-status/parser.ts +0 -629
  64. package/src/initiative-status/renderer.ts +0 -1199
  65. package/src/initiative-status/types.ts +0 -57
@@ -0,0 +1,697 @@
1
+ // ============================================================
2
+ // Boxes and Lines Diagram — Parser
3
+ // ============================================================
4
+
5
+ import { makeDgmoError, suggest } from '../diagnostics';
6
+ import type { DgmoError } from '../diagnostics';
7
+ import type { ParsedBoxesAndLines, BLNode, BLEdge, BLGroup } from './types';
8
+ import {
9
+ matchTagBlockHeading,
10
+ injectDefaultTagMetadata,
11
+ validateTagValues,
12
+ stripDefaultModifier,
13
+ } from '../utils/tag-groups';
14
+ import type { TagGroup } from '../utils/tag-groups';
15
+ import {
16
+ extractColor,
17
+ parseFirstLine,
18
+ OPTION_NOCOLON_RE,
19
+ } from '../utils/parsing';
20
+
21
+ const MAX_GROUP_DEPTH = 1;
22
+
23
+ /** Boxes-and-lines requires explicit first line — no heuristic detection. */
24
+ export function looksLikeBoxesAndLines(_content: string): boolean {
25
+ return false;
26
+ }
27
+
28
+ /** Measure leading whitespace (tabs = 4 spaces) */
29
+ function measureIndent(line: string): number {
30
+ let count = 0;
31
+ for (const ch of line) {
32
+ if (ch === ' ') count++;
33
+ else if (ch === '\t') count += 4;
34
+ else break;
35
+ }
36
+ return count;
37
+ }
38
+
39
+ /**
40
+ * Parse pipe metadata segment: `key: value, key2: value2`
41
+ * Returns resolved metadata record. Extracts `description` separately.
42
+ */
43
+ function parsePipeMetadata(
44
+ segment: string,
45
+ aliasMap: Map<string, string>
46
+ ): { metadata: Record<string, string>; description?: string } {
47
+ const metadata: Record<string, string> = {};
48
+ let description: string | undefined;
49
+
50
+ const items = segment.split(',');
51
+ for (const item of items) {
52
+ const trimmed = item.trim();
53
+ if (!trimmed) continue;
54
+
55
+ const colonIdx = trimmed.indexOf(':');
56
+ if (colonIdx >= 0) {
57
+ const rawKey = trimmed.slice(0, colonIdx).trim().toLowerCase();
58
+ const value = trimmed.slice(colonIdx + 1).trim();
59
+ if (rawKey === 'description') {
60
+ description = value;
61
+ } else {
62
+ const resolvedKey = aliasMap.get(rawKey) ?? rawKey;
63
+ metadata[resolvedKey] = value;
64
+ }
65
+ }
66
+ // Bare words are ignored (no status system)
67
+ }
68
+
69
+ return { metadata, description };
70
+ }
71
+
72
+ /** Convert group label to internal ID */
73
+ function groupId(label: string): string {
74
+ return `__group_${label}`;
75
+ }
76
+
77
+ export function parseBoxesAndLines(content: string): ParsedBoxesAndLines {
78
+ const result: ParsedBoxesAndLines = {
79
+ type: 'boxes-and-lines',
80
+ title: null,
81
+ titleLineNumber: null,
82
+ nodes: [],
83
+ edges: [],
84
+ groups: [],
85
+ tagGroups: [],
86
+ options: {},
87
+ initialHiddenTagValues: new Map(),
88
+ direction: 'LR',
89
+ diagnostics: [],
90
+ error: null,
91
+ };
92
+
93
+ const lines = content.split('\n');
94
+ const nodeLabels = new Set<string>();
95
+ const groupLabels = new Set<string>();
96
+ let lastNodeLabel: string | null = null;
97
+
98
+ // Group stack for nesting
99
+ interface GroupState {
100
+ group: BLGroup;
101
+ indent: number;
102
+ depth: number;
103
+ }
104
+ const groupStack: GroupState[] = [];
105
+
106
+ // Tag block state
107
+ let contentStarted = false;
108
+ let currentTagGroup: TagGroup | null = null;
109
+ const aliasMap = new Map<string, string>();
110
+
111
+ const pushWarning = (lineNumber: number, message: string) => {
112
+ result.diagnostics.push(makeDgmoError(lineNumber, message, 'warning'));
113
+ };
114
+
115
+ /** Get the innermost active group, if any */
116
+ function currentGroupState(): GroupState | null {
117
+ return groupStack.length > 0 ? groupStack[groupStack.length - 1] : null;
118
+ }
119
+
120
+ /** Close groups that are at or deeper than a given indent level */
121
+ function closeGroupsToIndent(indent: number) {
122
+ while (
123
+ groupStack.length > 0 &&
124
+ groupStack[groupStack.length - 1].indent >= indent
125
+ ) {
126
+ const gs = groupStack.pop()!;
127
+ result.groups.push(gs.group);
128
+ }
129
+ }
130
+
131
+ /** Ensure a node exists (implicit creation) */
132
+ function ensureNode(label: string, lineNum: number) {
133
+ if (!nodeLabels.has(label)) {
134
+ result.nodes.push({
135
+ label,
136
+ lineNumber: lineNum,
137
+ metadata: {},
138
+ });
139
+ nodeLabels.add(label);
140
+ }
141
+ }
142
+
143
+ for (let i = 0; i < lines.length; i++) {
144
+ const lineNum = i + 1;
145
+ const raw = lines[i];
146
+ const trimmed = raw.trim();
147
+ const indent = measureIndent(raw);
148
+
149
+ // Skip blanks and comments
150
+ if (!trimmed || trimmed.startsWith('//')) continue;
151
+
152
+ // First line: `boxes-and-lines [Title]`
153
+ const firstLineResult = parseFirstLine(trimmed);
154
+ if (firstLineResult && !contentStarted && i < 5) {
155
+ if (firstLineResult.chartType !== 'boxes-and-lines') {
156
+ const diag = makeDgmoError(
157
+ lineNum,
158
+ `Expected chart type "boxes-and-lines", got "${firstLineResult.chartType}"`
159
+ );
160
+ result.diagnostics.push(diag);
161
+ result.error = diag.message;
162
+ return result;
163
+ }
164
+ if (firstLineResult.title) {
165
+ result.title = firstLineResult.title;
166
+ result.titleLineNumber = lineNum;
167
+ }
168
+ continue;
169
+ }
170
+
171
+ // Directives (non-indented, before or during content)
172
+ if (indent === 0) {
173
+ // direction TB / direction LR
174
+ const dirMatch = trimmed.match(/^direction\s+(TB|LR)$/i);
175
+ if (dirMatch) {
176
+ result.direction = dirMatch[1].toUpperCase() as 'LR' | 'TB';
177
+ continue;
178
+ }
179
+
180
+ // hide directive: `hide team:Backend, team:Frontend`
181
+ const hideMatch = trimmed.match(/^hide\s+(.+)/i);
182
+ if (hideMatch && !trimmed.match(/^hide\s*\|/)) {
183
+ const pairs = hideMatch[1].split(',');
184
+ for (const pair of pairs) {
185
+ const colonIdx = pair.indexOf(':');
186
+ if (colonIdx > 0) {
187
+ const groupKey = pair.substring(0, colonIdx).trim().toLowerCase();
188
+ const value = pair
189
+ .substring(colonIdx + 1)
190
+ .trim()
191
+ .toLowerCase();
192
+ if (groupKey && value) {
193
+ if (!result.initialHiddenTagValues.has(groupKey)) {
194
+ result.initialHiddenTagValues.set(groupKey, new Set());
195
+ }
196
+ result.initialHiddenTagValues.get(groupKey)!.add(value);
197
+ }
198
+ }
199
+ }
200
+ continue;
201
+ }
202
+
203
+ // active-tag directive
204
+ if (!contentStarted) {
205
+ const optMatch = trimmed.match(OPTION_NOCOLON_RE);
206
+ if (optMatch) {
207
+ const key = optMatch[1].toLowerCase();
208
+ const value = optMatch[2].trim();
209
+ if (key === 'active-tag') {
210
+ result.options[key] = value;
211
+ continue;
212
+ }
213
+ }
214
+ }
215
+ }
216
+
217
+ // Tag group heading — must be checked BEFORE group/node/edge matching
218
+ const tagBlockMatch = matchTagBlockHeading(trimmed);
219
+ if (tagBlockMatch && indent === 0) {
220
+ if (contentStarted) {
221
+ result.diagnostics.push(
222
+ makeDgmoError(
223
+ lineNum,
224
+ 'Tag groups must appear before diagram content',
225
+ 'error'
226
+ )
227
+ );
228
+ continue;
229
+ }
230
+ currentTagGroup = {
231
+ name: tagBlockMatch.name,
232
+ alias: tagBlockMatch.alias,
233
+ entries: [],
234
+ lineNumber: lineNum,
235
+ };
236
+ if (tagBlockMatch.alias) {
237
+ aliasMap.set(
238
+ tagBlockMatch.alias.toLowerCase(),
239
+ tagBlockMatch.name.toLowerCase()
240
+ );
241
+ }
242
+ if (tagBlockMatch.inlineValues) {
243
+ for (const rawVal of tagBlockMatch.inlineValues) {
244
+ const { text: cleanVal, isDefault } = stripDefaultModifier(rawVal);
245
+ const { label, color } = extractColor(cleanVal);
246
+ currentTagGroup.entries.push({
247
+ value: label,
248
+ color: color ?? '',
249
+ lineNumber: lineNum,
250
+ });
251
+ if (isDefault) currentTagGroup.defaultValue = label;
252
+ }
253
+ if (
254
+ !currentTagGroup.defaultValue &&
255
+ currentTagGroup.entries.length > 0
256
+ ) {
257
+ currentTagGroup.defaultValue = currentTagGroup.entries[0].value;
258
+ }
259
+ }
260
+ result.tagGroups.push(currentTagGroup);
261
+ continue;
262
+ }
263
+
264
+ // Tag group entries (indented under tag heading)
265
+ if (currentTagGroup && !contentStarted && indent > 0) {
266
+ const { text: cleanEntry, isDefault } = stripDefaultModifier(trimmed);
267
+ const { label, color } = extractColor(cleanEntry);
268
+ currentTagGroup.entries.push({
269
+ value: label,
270
+ color: color ?? '',
271
+ lineNumber: lineNum,
272
+ });
273
+ if (isDefault) {
274
+ currentTagGroup.defaultValue = label;
275
+ } else if (currentTagGroup.entries.length === 1) {
276
+ currentTagGroup.defaultValue = label;
277
+ }
278
+ continue;
279
+ }
280
+
281
+ // Non-indented line closes tag group
282
+ if (currentTagGroup && indent === 0) {
283
+ currentTagGroup = null; // eslint-disable-line no-useless-assignment
284
+ }
285
+
286
+ // Close groups that are no longer scoped by indent
287
+ if (indent === 0) {
288
+ closeGroupsToIndent(0);
289
+ } else if (groupStack.length > 0) {
290
+ // Close groups deeper than current indent
291
+ closeGroupsToIndent(indent);
292
+ }
293
+
294
+ // Group-to-group edge: [Group A] -> [Group B] or [Group A] <-> [Group B]
295
+ const groupEdgeMatch = trimmed.match(
296
+ /^\[(.+?)\]\s*(<->|->)\s*\[(.+?)\]\s*(?:\|\s*(.+))?$/
297
+ );
298
+ if (groupEdgeMatch) {
299
+ contentStarted = true;
300
+ currentTagGroup = null;
301
+ const sourceLabel = groupEdgeMatch[1];
302
+ const arrow = groupEdgeMatch[2];
303
+ const targetLabel = groupEdgeMatch[3];
304
+ const metaSeg = groupEdgeMatch[4];
305
+
306
+ let edgeMeta: Record<string, string> = {};
307
+ if (metaSeg) {
308
+ const parsed = parsePipeMetadata(metaSeg, aliasMap);
309
+ edgeMeta = parsed.metadata;
310
+ }
311
+
312
+ result.edges.push({
313
+ source: groupId(sourceLabel),
314
+ target: groupId(targetLabel),
315
+ label: undefined,
316
+ bidirectional: arrow === '<->',
317
+ lineNumber: lineNum,
318
+ metadata: edgeMeta,
319
+ });
320
+ continue;
321
+ }
322
+
323
+ // Labeled group-to-group edge: [Group A] -label-> [Group B]
324
+ const labeledGroupEdgeMatch = trimmed.match(
325
+ /^\[(.+?)\]\s*(?:<-(.+)->|-(.+)->)\s*\[(.+?)\]\s*(?:\|\s*(.+))?$/
326
+ );
327
+ if (labeledGroupEdgeMatch) {
328
+ contentStarted = true;
329
+ currentTagGroup = null;
330
+ const sourceLabel = labeledGroupEdgeMatch[1];
331
+ const biLabel = labeledGroupEdgeMatch[2];
332
+ const uniLabel = labeledGroupEdgeMatch[3];
333
+ const targetLabel = labeledGroupEdgeMatch[4];
334
+ const metaSeg = labeledGroupEdgeMatch[5];
335
+
336
+ let edgeMeta: Record<string, string> = {};
337
+ if (metaSeg) {
338
+ const parsed = parsePipeMetadata(metaSeg, aliasMap);
339
+ edgeMeta = parsed.metadata;
340
+ }
341
+
342
+ result.edges.push({
343
+ source: groupId(sourceLabel),
344
+ target: groupId(targetLabel),
345
+ label: (biLabel ?? uniLabel)?.trim(),
346
+ bidirectional: !!biLabel,
347
+ lineNumber: lineNum,
348
+ metadata: edgeMeta,
349
+ });
350
+ continue;
351
+ }
352
+
353
+ // Group header: [Group Name] or [Group Name] | metadata
354
+ const groupMatch = trimmed.match(/^\[(.+?)\]\s*(?:\|\s*(.+))?$/);
355
+ if (groupMatch && !trimmed.includes('->') && !trimmed.includes('<->')) {
356
+ contentStarted = true;
357
+ currentTagGroup = null;
358
+ const label = groupMatch[1];
359
+
360
+ // Check nesting depth
361
+ const currentDepth = groupStack.length + 1;
362
+ if (currentDepth > MAX_GROUP_DEPTH) {
363
+ result.diagnostics.push(
364
+ makeDgmoError(
365
+ lineNum,
366
+ `Group nesting exceeds maximum depth of ${MAX_GROUP_DEPTH}`,
367
+ 'warning'
368
+ )
369
+ );
370
+ continue;
371
+ }
372
+
373
+ const groupMeta: Record<string, string> = {};
374
+ if (groupMatch[2]) {
375
+ const items = groupMatch[2].split(',');
376
+ for (const item of items) {
377
+ const ci = item.indexOf(':');
378
+ if (ci >= 0) {
379
+ const rawKey = item.slice(0, ci).trim().toLowerCase();
380
+ const value = item.slice(ci + 1).trim();
381
+ const resolvedKey = aliasMap.get(rawKey) ?? rawKey;
382
+ groupMeta[resolvedKey] = value;
383
+ }
384
+ }
385
+ }
386
+
387
+ const group: BLGroup = {
388
+ label,
389
+ children: [],
390
+ lineNumber: lineNum,
391
+ metadata: groupMeta,
392
+ };
393
+
394
+ groupLabels.add(label);
395
+ groupStack.push({ group, indent, depth: currentDepth });
396
+ continue;
397
+ }
398
+
399
+ // Edge detection: contains `->` or `<->`
400
+ if (trimmed.includes('->') || trimmed.includes('<->')) {
401
+ contentStarted = true;
402
+ currentTagGroup = null;
403
+ let edgeText = trimmed;
404
+
405
+ // Indented shorthand: `-> Target` or `-label-> Target`
406
+ if (trimmed.startsWith('->') || /^-[^>].*->/.test(trimmed)) {
407
+ if (!lastNodeLabel) {
408
+ result.diagnostics.push(
409
+ makeDgmoError(
410
+ lineNum,
411
+ 'Indented edge has no preceding node to use as source',
412
+ 'warning'
413
+ )
414
+ );
415
+ continue;
416
+ }
417
+ edgeText = `${lastNodeLabel} ${trimmed}`;
418
+ }
419
+
420
+ const edge = parseEdgeLine(
421
+ edgeText,
422
+ lineNum,
423
+ aliasMap,
424
+ result.diagnostics
425
+ );
426
+ if (edge) {
427
+ result.edges.push(edge);
428
+ // Add to current group if indented
429
+ // (edges don't become group children, but their nodes might)
430
+ }
431
+ continue;
432
+ }
433
+
434
+ // Node: everything else
435
+ contentStarted = true;
436
+ currentTagGroup = null;
437
+ const node = parseNodeLine(trimmed, lineNum, aliasMap, result.diagnostics);
438
+ if (!node) {
439
+ result.diagnostics.push(
440
+ makeDgmoError(lineNum, `Unexpected line: '${trimmed}'.`, 'warning')
441
+ );
442
+ continue;
443
+ }
444
+ lastNodeLabel = node.label;
445
+
446
+ const gs = currentGroupState();
447
+ const isGroupChild = gs && indent > gs.indent;
448
+
449
+ if (nodeLabels.has(node.label)) {
450
+ // Already declared — if inside a group, just add as child (no duplicate)
451
+ if (isGroupChild) {
452
+ gs.group.children.push(node.label);
453
+ continue;
454
+ }
455
+ result.diagnostics.push(
456
+ makeDgmoError(lineNum, `Duplicate node "${node.label}"`, 'warning')
457
+ );
458
+ } else {
459
+ nodeLabels.add(node.label);
460
+ }
461
+
462
+ // Cascade group metadata into node (group provides defaults, node overrides)
463
+ if (isGroupChild) {
464
+ for (const [key, val] of Object.entries(gs.group.metadata)) {
465
+ if (!(key in node.metadata)) {
466
+ node.metadata[key] = val;
467
+ }
468
+ }
469
+ gs.group.children.push(node.label);
470
+ }
471
+
472
+ result.nodes.push(node);
473
+ }
474
+
475
+ // Close any remaining groups
476
+ while (groupStack.length > 0) {
477
+ const gs = groupStack.pop()!;
478
+ result.groups.push(gs.group);
479
+ }
480
+
481
+ // Implicit node creation for edge endpoints
482
+ for (const edge of result.edges) {
483
+ // Skip group references
484
+ if (!edge.source.startsWith('__group_')) {
485
+ ensureNode(edge.source, edge.lineNumber);
486
+ }
487
+ if (!edge.target.startsWith('__group_')) {
488
+ ensureNode(edge.target, edge.lineNumber);
489
+ }
490
+ }
491
+
492
+ // Post-parse: inject default tag metadata and validate tag values
493
+ if (result.tagGroups.length > 0) {
494
+ injectDefaultTagMetadata(result.nodes, result.tagGroups);
495
+ validateTagValues(result.nodes, result.tagGroups, pushWarning, suggest);
496
+ }
497
+
498
+ return result;
499
+ }
500
+
501
+ // ============================================================
502
+ // Line parsers
503
+ // ============================================================
504
+
505
+ /**
506
+ * Parse a node line. Supports:
507
+ * - `Label`
508
+ * - `Label | key: value, key2: value2`
509
+ */
510
+ function parseNodeLine(
511
+ trimmed: string,
512
+ lineNum: number,
513
+ aliasMap: Map<string, string>,
514
+ _diagnostics: DgmoError[]
515
+ ): BLNode | null {
516
+ let metadata: Record<string, string> = {};
517
+ let description: string | undefined;
518
+
519
+ // Split on pipe for metadata
520
+ const pipeIdx = trimmed.indexOf('|');
521
+ let label: string;
522
+
523
+ if (pipeIdx >= 0) {
524
+ label = trimmed.slice(0, pipeIdx).trim();
525
+ const metaSegment = trimmed.slice(pipeIdx + 1).trim();
526
+ const parsed = parsePipeMetadata(metaSegment, aliasMap);
527
+ metadata = parsed.metadata;
528
+ description = parsed.description;
529
+ } else {
530
+ label = trimmed;
531
+ }
532
+
533
+ if (!label) return null;
534
+
535
+ return {
536
+ label,
537
+ lineNumber: lineNum,
538
+ metadata,
539
+ description,
540
+ };
541
+ }
542
+
543
+ /**
544
+ * Parse an edge line. Supports:
545
+ * - `Source -> Target`
546
+ * - `Source -> Target | key: value`
547
+ * - `Source -label-> Target`
548
+ * - `Source <-> Target`
549
+ * - `Source <-label-> Target`
550
+ * - `Source -label-> Target | key: value`
551
+ */
552
+ function parseEdgeLine(
553
+ trimmed: string,
554
+ lineNum: number,
555
+ aliasMap: Map<string, string>,
556
+ diagnostics: DgmoError[]
557
+ ): BLEdge | null {
558
+ // Check for bidirectional labeled: `Source <-label-> Target`
559
+ const biLabeledMatch = trimmed.match(/^(.+?)\s*<-(.+)->\s*(.+)$/);
560
+ if (biLabeledMatch) {
561
+ const source = biLabeledMatch[1].trim();
562
+ const label = biLabeledMatch[2].trim();
563
+ let rest = biLabeledMatch[3].trim();
564
+
565
+ let metadata: Record<string, string> = {};
566
+ const pipeIdx = rest.indexOf('|');
567
+ if (pipeIdx >= 0) {
568
+ const parsed = parsePipeMetadata(
569
+ rest.slice(pipeIdx + 1).trim(),
570
+ aliasMap
571
+ );
572
+ metadata = parsed.metadata;
573
+ rest = rest.slice(0, pipeIdx).trim();
574
+ }
575
+
576
+ if (!source || !rest) {
577
+ diagnostics.push(
578
+ makeDgmoError(lineNum, 'Edge is missing source or target')
579
+ );
580
+ return null;
581
+ }
582
+
583
+ return {
584
+ source,
585
+ target: rest,
586
+ label: label || undefined,
587
+ bidirectional: true,
588
+ lineNumber: lineNum,
589
+ metadata,
590
+ };
591
+ }
592
+
593
+ // Check for bidirectional plain: `Source <-> Target`
594
+ const biIdx = trimmed.indexOf('<->');
595
+ if (biIdx >= 0) {
596
+ const source = trimmed.slice(0, biIdx).trim();
597
+ let rest = trimmed.slice(biIdx + 3).trim();
598
+
599
+ let metadata: Record<string, string> = {};
600
+ const pipeIdx = rest.indexOf('|');
601
+ if (pipeIdx >= 0) {
602
+ const parsed = parsePipeMetadata(
603
+ rest.slice(pipeIdx + 1).trim(),
604
+ aliasMap
605
+ );
606
+ metadata = parsed.metadata;
607
+ rest = rest.slice(0, pipeIdx).trim();
608
+ }
609
+
610
+ if (!source || !rest) {
611
+ diagnostics.push(
612
+ makeDgmoError(lineNum, 'Edge is missing source or target')
613
+ );
614
+ return null;
615
+ }
616
+
617
+ return {
618
+ source,
619
+ target: rest,
620
+ bidirectional: true,
621
+ lineNumber: lineNum,
622
+ metadata,
623
+ };
624
+ }
625
+
626
+ // Check for labeled arrow: `Source -label-> Target`
627
+ const labeledMatch = trimmed.match(/^(.+?)\s+-(.+)->\s*(.+)$/);
628
+ if (labeledMatch) {
629
+ const source = labeledMatch[1].trim();
630
+ const label = labeledMatch[2].trim();
631
+ let rest = labeledMatch[3].trim();
632
+
633
+ if (label) {
634
+ let metadata: Record<string, string> = {};
635
+ const pipeIdx = rest.indexOf('|');
636
+ if (pipeIdx >= 0) {
637
+ const parsed = parsePipeMetadata(
638
+ rest.slice(pipeIdx + 1).trim(),
639
+ aliasMap
640
+ );
641
+ metadata = parsed.metadata;
642
+ rest = rest.slice(0, pipeIdx).trim();
643
+ }
644
+
645
+ if (!source || !rest) {
646
+ diagnostics.push(
647
+ makeDgmoError(lineNum, 'Edge is missing source or target')
648
+ );
649
+ return null;
650
+ }
651
+
652
+ return {
653
+ source,
654
+ target: rest,
655
+ label,
656
+ bidirectional: false,
657
+ lineNumber: lineNum,
658
+ metadata,
659
+ };
660
+ }
661
+ }
662
+
663
+ // Plain arrow: `Source -> Target`
664
+ const arrowIdx = trimmed.indexOf('->');
665
+ if (arrowIdx < 0) return null;
666
+
667
+ const source = trimmed.slice(0, arrowIdx).trim();
668
+ let rest = trimmed.slice(arrowIdx + 2).trim();
669
+
670
+ if (!source || !rest) {
671
+ diagnostics.push(
672
+ makeDgmoError(lineNum, 'Edge is missing source or target')
673
+ );
674
+ return null;
675
+ }
676
+
677
+ let metadata: Record<string, string> = {};
678
+ const pipeIdx = rest.indexOf('|');
679
+ if (pipeIdx >= 0) {
680
+ const parsed = parsePipeMetadata(rest.slice(pipeIdx + 1).trim(), aliasMap);
681
+ metadata = parsed.metadata;
682
+ rest = rest.slice(0, pipeIdx).trim();
683
+ }
684
+
685
+ if (!rest) {
686
+ diagnostics.push(makeDgmoError(lineNum, 'Edge is missing target'));
687
+ return null;
688
+ }
689
+
690
+ return {
691
+ source,
692
+ target: rest,
693
+ bidirectional: false,
694
+ lineNumber: lineNum,
695
+ metadata,
696
+ };
697
+ }