@diagrammo/dgmo 0.8.5 → 0.8.6

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 (64) hide show
  1. package/.claude/commands/dgmo.md +33 -0
  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/dist/cli.cjs +189 -190
  7. package/dist/editor.cjs +3 -18
  8. package/dist/editor.cjs.map +1 -1
  9. package/dist/editor.js +3 -18
  10. package/dist/editor.js.map +1 -1
  11. package/dist/highlight.cjs +4 -21
  12. package/dist/highlight.cjs.map +1 -1
  13. package/dist/highlight.js +4 -21
  14. package/dist/highlight.js.map +1 -1
  15. package/dist/index.cjs +2785 -2996
  16. package/dist/index.cjs.map +1 -1
  17. package/dist/index.d.cts +56 -56
  18. package/dist/index.d.ts +56 -56
  19. package/dist/index.js +2780 -2989
  20. package/dist/index.js.map +1 -1
  21. package/docs/ai-integration.md +1 -1
  22. package/docs/language-reference.md +97 -25
  23. package/gallery/fixtures/boxes-and-lines.dgmo +64 -0
  24. package/package.json +1 -1
  25. package/src/boxes-and-lines/collapse.ts +78 -0
  26. package/src/boxes-and-lines/layout.ts +319 -0
  27. package/src/boxes-and-lines/parser.ts +694 -0
  28. package/src/boxes-and-lines/renderer.ts +848 -0
  29. package/src/boxes-and-lines/types.ts +40 -0
  30. package/src/c4/parser.ts +10 -5
  31. package/src/c4/renderer.ts +232 -56
  32. package/src/chart.ts +9 -4
  33. package/src/cli.ts +6 -5
  34. package/src/completion.ts +25 -33
  35. package/src/d3.ts +26 -27
  36. package/src/dgmo-router.ts +3 -7
  37. package/src/echarts.ts +38 -2
  38. package/src/editor/keywords.ts +4 -19
  39. package/src/er/parser.ts +10 -4
  40. package/src/gantt/parser.ts +7 -4
  41. package/src/gantt/renderer.ts +3 -5
  42. package/src/index.ts +17 -26
  43. package/src/infra/parser.ts +7 -5
  44. package/src/infra/renderer.ts +2 -2
  45. package/src/kanban/parser.ts +7 -5
  46. package/src/kanban/renderer.ts +43 -18
  47. package/src/org/parser.ts +7 -4
  48. package/src/org/renderer.ts +40 -29
  49. package/src/sequence/parser.ts +11 -5
  50. package/src/sequence/renderer.ts +114 -45
  51. package/src/sitemap/parser.ts +8 -4
  52. package/src/sitemap/renderer.ts +137 -57
  53. package/src/utils/legend-svg.ts +44 -20
  54. package/src/utils/parsing.ts +1 -1
  55. package/src/utils/tag-groups.ts +21 -1
  56. package/gallery/fixtures/initiative-status-full.dgmo +0 -46
  57. package/gallery/fixtures/initiative-status-phases.dgmo +0 -29
  58. package/gallery/fixtures/initiative-status.dgmo +0 -9
  59. package/src/initiative-status/collapse.ts +0 -76
  60. package/src/initiative-status/filter.ts +0 -63
  61. package/src/initiative-status/layout.ts +0 -650
  62. package/src/initiative-status/parser.ts +0 -629
  63. package/src/initiative-status/renderer.ts +0 -1199
  64. package/src/initiative-status/types.ts +0 -57
@@ -0,0 +1,694 @@
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 (!currentTagGroup.defaultValue && currentTagGroup.entries.length > 0) {
254
+ currentTagGroup.defaultValue = currentTagGroup.entries[0].value;
255
+ }
256
+ }
257
+ result.tagGroups.push(currentTagGroup);
258
+ continue;
259
+ }
260
+
261
+ // Tag group entries (indented under tag heading)
262
+ if (currentTagGroup && !contentStarted && indent > 0) {
263
+ const { text: cleanEntry, isDefault } = stripDefaultModifier(trimmed);
264
+ const { label, color } = extractColor(cleanEntry);
265
+ currentTagGroup.entries.push({
266
+ value: label,
267
+ color: color ?? '',
268
+ lineNumber: lineNum,
269
+ });
270
+ if (isDefault) {
271
+ currentTagGroup.defaultValue = label;
272
+ } else if (currentTagGroup.entries.length === 1) {
273
+ currentTagGroup.defaultValue = label;
274
+ }
275
+ continue;
276
+ }
277
+
278
+ // Non-indented line closes tag group
279
+ if (currentTagGroup && indent === 0) {
280
+ currentTagGroup = null; // eslint-disable-line no-useless-assignment
281
+ }
282
+
283
+ // Close groups that are no longer scoped by indent
284
+ if (indent === 0) {
285
+ closeGroupsToIndent(0);
286
+ } else if (groupStack.length > 0) {
287
+ // Close groups deeper than current indent
288
+ closeGroupsToIndent(indent);
289
+ }
290
+
291
+ // Group-to-group edge: [Group A] -> [Group B] or [Group A] <-> [Group B]
292
+ const groupEdgeMatch = trimmed.match(
293
+ /^\[(.+?)\]\s*(<->|->)\s*\[(.+?)\]\s*(?:\|\s*(.+))?$/
294
+ );
295
+ if (groupEdgeMatch) {
296
+ contentStarted = true;
297
+ currentTagGroup = null;
298
+ const sourceLabel = groupEdgeMatch[1];
299
+ const arrow = groupEdgeMatch[2];
300
+ const targetLabel = groupEdgeMatch[3];
301
+ const metaSeg = groupEdgeMatch[4];
302
+
303
+ let edgeMeta: Record<string, string> = {};
304
+ if (metaSeg) {
305
+ const parsed = parsePipeMetadata(metaSeg, aliasMap);
306
+ edgeMeta = parsed.metadata;
307
+ }
308
+
309
+ result.edges.push({
310
+ source: groupId(sourceLabel),
311
+ target: groupId(targetLabel),
312
+ label: undefined,
313
+ bidirectional: arrow === '<->',
314
+ lineNumber: lineNum,
315
+ metadata: edgeMeta,
316
+ });
317
+ continue;
318
+ }
319
+
320
+ // Labeled group-to-group edge: [Group A] -label-> [Group B]
321
+ const labeledGroupEdgeMatch = trimmed.match(
322
+ /^\[(.+?)\]\s*(?:<-(.+)->|-(.+)->)\s*\[(.+?)\]\s*(?:\|\s*(.+))?$/
323
+ );
324
+ if (labeledGroupEdgeMatch) {
325
+ contentStarted = true;
326
+ currentTagGroup = null;
327
+ const sourceLabel = labeledGroupEdgeMatch[1];
328
+ const biLabel = labeledGroupEdgeMatch[2];
329
+ const uniLabel = labeledGroupEdgeMatch[3];
330
+ const targetLabel = labeledGroupEdgeMatch[4];
331
+ const metaSeg = labeledGroupEdgeMatch[5];
332
+
333
+ let edgeMeta: Record<string, string> = {};
334
+ if (metaSeg) {
335
+ const parsed = parsePipeMetadata(metaSeg, aliasMap);
336
+ edgeMeta = parsed.metadata;
337
+ }
338
+
339
+ result.edges.push({
340
+ source: groupId(sourceLabel),
341
+ target: groupId(targetLabel),
342
+ label: (biLabel ?? uniLabel)?.trim(),
343
+ bidirectional: !!biLabel,
344
+ lineNumber: lineNum,
345
+ metadata: edgeMeta,
346
+ });
347
+ continue;
348
+ }
349
+
350
+ // Group header: [Group Name] or [Group Name] | metadata
351
+ const groupMatch = trimmed.match(/^\[(.+?)\]\s*(?:\|\s*(.+))?$/);
352
+ if (groupMatch && !trimmed.includes('->') && !trimmed.includes('<->')) {
353
+ contentStarted = true;
354
+ currentTagGroup = null;
355
+ const label = groupMatch[1];
356
+
357
+ // Check nesting depth
358
+ const currentDepth = groupStack.length + 1;
359
+ if (currentDepth > MAX_GROUP_DEPTH) {
360
+ result.diagnostics.push(
361
+ makeDgmoError(
362
+ lineNum,
363
+ `Group nesting exceeds maximum depth of ${MAX_GROUP_DEPTH}`,
364
+ 'warning'
365
+ )
366
+ );
367
+ continue;
368
+ }
369
+
370
+ const groupMeta: Record<string, string> = {};
371
+ if (groupMatch[2]) {
372
+ const items = groupMatch[2].split(',');
373
+ for (const item of items) {
374
+ const ci = item.indexOf(':');
375
+ if (ci >= 0) {
376
+ const rawKey = item.slice(0, ci).trim().toLowerCase();
377
+ const value = item.slice(ci + 1).trim();
378
+ const resolvedKey = aliasMap.get(rawKey) ?? rawKey;
379
+ groupMeta[resolvedKey] = value;
380
+ }
381
+ }
382
+ }
383
+
384
+ const group: BLGroup = {
385
+ label,
386
+ children: [],
387
+ lineNumber: lineNum,
388
+ metadata: groupMeta,
389
+ };
390
+
391
+ groupLabels.add(label);
392
+ groupStack.push({ group, indent, depth: currentDepth });
393
+ continue;
394
+ }
395
+
396
+ // Edge detection: contains `->` or `<->`
397
+ if (trimmed.includes('->') || trimmed.includes('<->')) {
398
+ contentStarted = true;
399
+ currentTagGroup = null;
400
+ let edgeText = trimmed;
401
+
402
+ // Indented shorthand: `-> Target` or `-label-> Target`
403
+ if (trimmed.startsWith('->') || /^-[^>].*->/.test(trimmed)) {
404
+ if (!lastNodeLabel) {
405
+ result.diagnostics.push(
406
+ makeDgmoError(
407
+ lineNum,
408
+ 'Indented edge has no preceding node to use as source',
409
+ 'warning'
410
+ )
411
+ );
412
+ continue;
413
+ }
414
+ edgeText = `${lastNodeLabel} ${trimmed}`;
415
+ }
416
+
417
+ const edge = parseEdgeLine(
418
+ edgeText,
419
+ lineNum,
420
+ aliasMap,
421
+ result.diagnostics
422
+ );
423
+ if (edge) {
424
+ result.edges.push(edge);
425
+ // Add to current group if indented
426
+ // (edges don't become group children, but their nodes might)
427
+ }
428
+ continue;
429
+ }
430
+
431
+ // Node: everything else
432
+ contentStarted = true;
433
+ currentTagGroup = null;
434
+ const node = parseNodeLine(trimmed, lineNum, aliasMap, result.diagnostics);
435
+ if (!node) {
436
+ result.diagnostics.push(
437
+ makeDgmoError(lineNum, `Unexpected line: '${trimmed}'.`, 'warning')
438
+ );
439
+ continue;
440
+ }
441
+ lastNodeLabel = node.label;
442
+
443
+ const gs = currentGroupState();
444
+ const isGroupChild = gs && indent > gs.indent;
445
+
446
+ if (nodeLabels.has(node.label)) {
447
+ // Already declared — if inside a group, just add as child (no duplicate)
448
+ if (isGroupChild) {
449
+ gs.group.children.push(node.label);
450
+ continue;
451
+ }
452
+ result.diagnostics.push(
453
+ makeDgmoError(lineNum, `Duplicate node "${node.label}"`, 'warning')
454
+ );
455
+ } else {
456
+ nodeLabels.add(node.label);
457
+ }
458
+
459
+ // Cascade group metadata into node (group provides defaults, node overrides)
460
+ if (isGroupChild) {
461
+ for (const [key, val] of Object.entries(gs.group.metadata)) {
462
+ if (!(key in node.metadata)) {
463
+ node.metadata[key] = val;
464
+ }
465
+ }
466
+ gs.group.children.push(node.label);
467
+ }
468
+
469
+ result.nodes.push(node);
470
+ }
471
+
472
+ // Close any remaining groups
473
+ while (groupStack.length > 0) {
474
+ const gs = groupStack.pop()!;
475
+ result.groups.push(gs.group);
476
+ }
477
+
478
+ // Implicit node creation for edge endpoints
479
+ for (const edge of result.edges) {
480
+ // Skip group references
481
+ if (!edge.source.startsWith('__group_')) {
482
+ ensureNode(edge.source, edge.lineNumber);
483
+ }
484
+ if (!edge.target.startsWith('__group_')) {
485
+ ensureNode(edge.target, edge.lineNumber);
486
+ }
487
+ }
488
+
489
+ // Post-parse: inject default tag metadata and validate tag values
490
+ if (result.tagGroups.length > 0) {
491
+ injectDefaultTagMetadata(result.nodes, result.tagGroups);
492
+ validateTagValues(result.nodes, result.tagGroups, pushWarning, suggest);
493
+ }
494
+
495
+ return result;
496
+ }
497
+
498
+ // ============================================================
499
+ // Line parsers
500
+ // ============================================================
501
+
502
+ /**
503
+ * Parse a node line. Supports:
504
+ * - `Label`
505
+ * - `Label | key: value, key2: value2`
506
+ */
507
+ function parseNodeLine(
508
+ trimmed: string,
509
+ lineNum: number,
510
+ aliasMap: Map<string, string>,
511
+ _diagnostics: DgmoError[]
512
+ ): BLNode | null {
513
+ let metadata: Record<string, string> = {};
514
+ let description: string | undefined;
515
+
516
+ // Split on pipe for metadata
517
+ const pipeIdx = trimmed.indexOf('|');
518
+ let label: string;
519
+
520
+ if (pipeIdx >= 0) {
521
+ label = trimmed.slice(0, pipeIdx).trim();
522
+ const metaSegment = trimmed.slice(pipeIdx + 1).trim();
523
+ const parsed = parsePipeMetadata(metaSegment, aliasMap);
524
+ metadata = parsed.metadata;
525
+ description = parsed.description;
526
+ } else {
527
+ label = trimmed;
528
+ }
529
+
530
+ if (!label) return null;
531
+
532
+ return {
533
+ label,
534
+ lineNumber: lineNum,
535
+ metadata,
536
+ description,
537
+ };
538
+ }
539
+
540
+ /**
541
+ * Parse an edge line. Supports:
542
+ * - `Source -> Target`
543
+ * - `Source -> Target | key: value`
544
+ * - `Source -label-> Target`
545
+ * - `Source <-> Target`
546
+ * - `Source <-label-> Target`
547
+ * - `Source -label-> Target | key: value`
548
+ */
549
+ function parseEdgeLine(
550
+ trimmed: string,
551
+ lineNum: number,
552
+ aliasMap: Map<string, string>,
553
+ diagnostics: DgmoError[]
554
+ ): BLEdge | null {
555
+ // Check for bidirectional labeled: `Source <-label-> Target`
556
+ const biLabeledMatch = trimmed.match(/^(.+?)\s*<-(.+)->\s*(.+)$/);
557
+ if (biLabeledMatch) {
558
+ const source = biLabeledMatch[1].trim();
559
+ const label = biLabeledMatch[2].trim();
560
+ let rest = biLabeledMatch[3].trim();
561
+
562
+ let metadata: Record<string, string> = {};
563
+ const pipeIdx = rest.indexOf('|');
564
+ if (pipeIdx >= 0) {
565
+ const parsed = parsePipeMetadata(
566
+ rest.slice(pipeIdx + 1).trim(),
567
+ aliasMap
568
+ );
569
+ metadata = parsed.metadata;
570
+ rest = rest.slice(0, pipeIdx).trim();
571
+ }
572
+
573
+ if (!source || !rest) {
574
+ diagnostics.push(
575
+ makeDgmoError(lineNum, 'Edge is missing source or target')
576
+ );
577
+ return null;
578
+ }
579
+
580
+ return {
581
+ source,
582
+ target: rest,
583
+ label: label || undefined,
584
+ bidirectional: true,
585
+ lineNumber: lineNum,
586
+ metadata,
587
+ };
588
+ }
589
+
590
+ // Check for bidirectional plain: `Source <-> Target`
591
+ const biIdx = trimmed.indexOf('<->');
592
+ if (biIdx >= 0) {
593
+ const source = trimmed.slice(0, biIdx).trim();
594
+ let rest = trimmed.slice(biIdx + 3).trim();
595
+
596
+ let metadata: Record<string, string> = {};
597
+ const pipeIdx = rest.indexOf('|');
598
+ if (pipeIdx >= 0) {
599
+ const parsed = parsePipeMetadata(
600
+ rest.slice(pipeIdx + 1).trim(),
601
+ aliasMap
602
+ );
603
+ metadata = parsed.metadata;
604
+ rest = rest.slice(0, pipeIdx).trim();
605
+ }
606
+
607
+ if (!source || !rest) {
608
+ diagnostics.push(
609
+ makeDgmoError(lineNum, 'Edge is missing source or target')
610
+ );
611
+ return null;
612
+ }
613
+
614
+ return {
615
+ source,
616
+ target: rest,
617
+ bidirectional: true,
618
+ lineNumber: lineNum,
619
+ metadata,
620
+ };
621
+ }
622
+
623
+ // Check for labeled arrow: `Source -label-> Target`
624
+ const labeledMatch = trimmed.match(/^(.+?)\s+-(.+)->\s*(.+)$/);
625
+ if (labeledMatch) {
626
+ const source = labeledMatch[1].trim();
627
+ const label = labeledMatch[2].trim();
628
+ let rest = labeledMatch[3].trim();
629
+
630
+ if (label) {
631
+ let metadata: Record<string, string> = {};
632
+ const pipeIdx = rest.indexOf('|');
633
+ if (pipeIdx >= 0) {
634
+ const parsed = parsePipeMetadata(
635
+ rest.slice(pipeIdx + 1).trim(),
636
+ aliasMap
637
+ );
638
+ metadata = parsed.metadata;
639
+ rest = rest.slice(0, pipeIdx).trim();
640
+ }
641
+
642
+ if (!source || !rest) {
643
+ diagnostics.push(
644
+ makeDgmoError(lineNum, 'Edge is missing source or target')
645
+ );
646
+ return null;
647
+ }
648
+
649
+ return {
650
+ source,
651
+ target: rest,
652
+ label,
653
+ bidirectional: false,
654
+ lineNumber: lineNum,
655
+ metadata,
656
+ };
657
+ }
658
+ }
659
+
660
+ // Plain arrow: `Source -> Target`
661
+ const arrowIdx = trimmed.indexOf('->');
662
+ if (arrowIdx < 0) return null;
663
+
664
+ const source = trimmed.slice(0, arrowIdx).trim();
665
+ let rest = trimmed.slice(arrowIdx + 2).trim();
666
+
667
+ if (!source || !rest) {
668
+ diagnostics.push(
669
+ makeDgmoError(lineNum, 'Edge is missing source or target')
670
+ );
671
+ return null;
672
+ }
673
+
674
+ let metadata: Record<string, string> = {};
675
+ const pipeIdx = rest.indexOf('|');
676
+ if (pipeIdx >= 0) {
677
+ const parsed = parsePipeMetadata(rest.slice(pipeIdx + 1).trim(), aliasMap);
678
+ metadata = parsed.metadata;
679
+ rest = rest.slice(0, pipeIdx).trim();
680
+ }
681
+
682
+ if (!rest) {
683
+ diagnostics.push(makeDgmoError(lineNum, 'Edge is missing target'));
684
+ return null;
685
+ }
686
+
687
+ return {
688
+ source,
689
+ target: rest,
690
+ bidirectional: false,
691
+ lineNumber: lineNum,
692
+ metadata,
693
+ };
694
+ }