@diagrammo/dgmo 0.8.4 → 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 (68) hide show
  1. package/.claude/commands/dgmo.md +300 -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 +191 -189
  7. package/dist/editor.cjs +5 -18
  8. package/dist/editor.cjs.map +1 -1
  9. package/dist/editor.js +5 -18
  10. package/dist/editor.js.map +1 -1
  11. package/dist/highlight.cjs +543 -0
  12. package/dist/highlight.cjs.map +1 -0
  13. package/dist/highlight.d.cts +32 -0
  14. package/dist/highlight.d.ts +32 -0
  15. package/dist/highlight.js +513 -0
  16. package/dist/highlight.js.map +1 -0
  17. package/dist/index.cjs +3253 -3356
  18. package/dist/index.cjs.map +1 -1
  19. package/dist/index.d.cts +77 -56
  20. package/dist/index.d.ts +77 -56
  21. package/dist/index.js +3247 -3349
  22. package/dist/index.js.map +1 -1
  23. package/docs/ai-integration.md +1 -1
  24. package/docs/language-reference.md +113 -33
  25. package/gallery/fixtures/boxes-and-lines.dgmo +64 -0
  26. package/gallery/fixtures/slope.dgmo +7 -6
  27. package/package.json +26 -6
  28. package/src/boxes-and-lines/collapse.ts +78 -0
  29. package/src/boxes-and-lines/layout.ts +319 -0
  30. package/src/boxes-and-lines/parser.ts +694 -0
  31. package/src/boxes-and-lines/renderer.ts +848 -0
  32. package/src/boxes-and-lines/types.ts +40 -0
  33. package/src/c4/parser.ts +10 -5
  34. package/src/c4/renderer.ts +232 -56
  35. package/src/chart.ts +9 -4
  36. package/src/cli.ts +49 -6
  37. package/src/completion.ts +25 -33
  38. package/src/d3.ts +187 -46
  39. package/src/dgmo-router.ts +3 -7
  40. package/src/echarts.ts +38 -2
  41. package/src/editor/highlight-api.ts +444 -0
  42. package/src/editor/keywords.ts +6 -19
  43. package/src/er/parser.ts +10 -4
  44. package/src/gantt/parser.ts +7 -4
  45. package/src/gantt/renderer.ts +3 -5
  46. package/src/index.ts +106 -50
  47. package/src/infra/parser.ts +7 -5
  48. package/src/infra/renderer.ts +2 -2
  49. package/src/kanban/parser.ts +7 -5
  50. package/src/kanban/renderer.ts +43 -18
  51. package/src/org/parser.ts +7 -4
  52. package/src/org/renderer.ts +40 -29
  53. package/src/sequence/parser.ts +11 -5
  54. package/src/sequence/renderer.ts +114 -45
  55. package/src/sitemap/parser.ts +8 -4
  56. package/src/sitemap/renderer.ts +137 -57
  57. package/src/utils/legend-svg.ts +44 -20
  58. package/src/utils/parsing.ts +1 -1
  59. package/src/utils/tag-groups.ts +21 -1
  60. package/gallery/fixtures/initiative-status-full.dgmo +0 -46
  61. package/gallery/fixtures/initiative-status-phases.dgmo +0 -29
  62. package/gallery/fixtures/initiative-status.dgmo +0 -9
  63. package/src/initiative-status/collapse.ts +0 -76
  64. package/src/initiative-status/filter.ts +0 -63
  65. package/src/initiative-status/layout.ts +0 -650
  66. package/src/initiative-status/parser.ts +0 -629
  67. package/src/initiative-status/renderer.ts +0 -1199
  68. package/src/initiative-status/types.ts +0 -57
@@ -1,629 +0,0 @@
1
- // ============================================================
2
- // Initiative Status Diagram — Parser
3
- // ============================================================
4
-
5
- import { makeDgmoError, formatDgmoError, suggest } from '../diagnostics';
6
- import type { DgmoError } from '../diagnostics';
7
- import type {
8
- ParsedInitiativeStatus,
9
- ISNode,
10
- ISEdge,
11
- ISGroup,
12
- InitiativeStatus,
13
- } from './types';
14
- import { VALID_STATUSES, STATUS_ALIASES } from './types';
15
- import { inferParticipantType } from '../sequence/participant-inference';
16
- import {
17
- matchTagBlockHeading,
18
- injectDefaultTagMetadata,
19
- validateTagValues,
20
- } from '../utils/tag-groups';
21
- import type { TagGroup } from '../utils/tag-groups';
22
- import {
23
- extractColor,
24
- parseFirstLine,
25
- OPTION_NOCOLON_RE,
26
- } from '../utils/parsing';
27
-
28
- // ============================================================
29
- // Heuristic — does this content look like an initiative-status diagram?
30
- // ============================================================
31
-
32
- /**
33
- * Returns true if the content looks like an initiative-status diagram.
34
- * Detects `->` arrows combined with `| done/wip/todo/na` status markers.
35
- */
36
- export function looksLikeInitiativeStatus(content: string): boolean {
37
- const lines = content.split('\n');
38
- let hasArrow = false;
39
- let hasStatus = false;
40
- let hasIndentedArrow = false;
41
- for (const line of lines) {
42
- const trimmed = line.trim();
43
- if (!trimmed || trimmed.startsWith('//')) continue;
44
- if (trimmed.match(/^chart\s*:/i)) continue;
45
- if (trimmed.match(/^title\s*:/i)) continue;
46
- // Skip new-style first line (bare chart type name)
47
- if (parseFirstLine(trimmed)) continue;
48
- if (trimmed.includes('->')) hasArrow = true;
49
- if (
50
- /\|\s*(done|doing|wip|blocked|paused|waiting|todo|na)\s*$/i.test(trimmed)
51
- )
52
- hasStatus = true;
53
- // Indented arrow is a strong signal — only initiative-status uses this
54
- const isIndented = line.length > 0 && line !== trimmed && /^\s/.test(line);
55
- if (isIndented && (trimmed.startsWith('->') || /^-[^>].*->/.test(trimmed)))
56
- hasIndentedArrow = true;
57
- if (hasArrow && hasStatus) return true;
58
- }
59
- return hasIndentedArrow;
60
- }
61
-
62
- // ============================================================
63
- // Metadata parser — splits comma-delimited segment into status + tags
64
- // ============================================================
65
-
66
- /**
67
- * Parse the metadata segment after a `|` pipe into a status keyword
68
- * and key:value tag pairs. Does NOT use parsePipeMetadata() from
69
- * parsing.ts — that utility drops bare words (no colon), making it
70
- * incompatible with status keyword extraction.
71
- *
72
- * @param segment The raw text after `|` — e.g. `"wip, p: Build, t: Backend"`
73
- * @param aliasMap Maps lowercase aliases to lowercase group names
74
- * @param lineNum Line number for diagnostic reporting
75
- * @param diagnostics Array to push warnings into
76
- */
77
- export function parseNodeMetadata(
78
- segment: string,
79
- aliasMap: Map<string, string>,
80
- lineNum?: number,
81
- diagnostics?: DgmoError[]
82
- ): {
83
- status: InitiativeStatus;
84
- metadata: Record<string, string>;
85
- hadStatusWord: boolean;
86
- } {
87
- const metadata: Record<string, string> = {};
88
- let status: InitiativeStatus = null;
89
- let hadStatusWord = false;
90
-
91
- const items = segment.split(',');
92
- for (const item of items) {
93
- const trimmed = item.trim();
94
- if (!trimmed) continue;
95
-
96
- const colonIdx = trimmed.indexOf(':');
97
- if (colonIdx >= 0) {
98
- // key: value pair
99
- const rawKey = trimmed.slice(0, colonIdx).trim().toLowerCase();
100
- const value = trimmed.slice(colonIdx + 1).trim();
101
-
102
- // Handle explicit `status: keyword` form
103
- if (rawKey === 'status') {
104
- hadStatusWord = true;
105
- const lower = value.toLowerCase();
106
- const canonical = STATUS_ALIASES[lower] ?? lower;
107
- if (VALID_STATUSES.includes(canonical)) {
108
- status = canonical as InitiativeStatus;
109
- } else if (lineNum !== undefined && diagnostics) {
110
- const allKnown = [...VALID_STATUSES, ...Object.keys(STATUS_ALIASES)];
111
- const hint = suggest(lower, allKnown);
112
- const msg = `Unknown status "${value}"${hint ? `. ${hint}` : ''}`;
113
- diagnostics.push(makeDgmoError(lineNum, msg, 'warning'));
114
- }
115
- } else {
116
- // Resolve alias to group name
117
- const resolvedKey = aliasMap.get(rawKey) ?? rawKey;
118
- metadata[resolvedKey] = value;
119
- }
120
- } else {
121
- // Bare word — check if it's a status keyword (or alias)
122
- hadStatusWord = true;
123
- const lower = trimmed.toLowerCase();
124
- const canonical = STATUS_ALIASES[lower] ?? lower;
125
- if (VALID_STATUSES.includes(canonical)) {
126
- status = canonical as InitiativeStatus;
127
- } else if (lineNum !== undefined && diagnostics) {
128
- // Unknown bare word — likely a status typo, emit warning
129
- const allKnown = [...VALID_STATUSES, ...Object.keys(STATUS_ALIASES)];
130
- const hint = suggest(lower, allKnown);
131
- const msg = `Unknown status "${trimmed}"${hint ? `. ${hint}` : ''}`;
132
- diagnostics.push(makeDgmoError(lineNum, msg, 'warning'));
133
- }
134
- }
135
- }
136
-
137
- return { status, metadata, hadStatusWord };
138
- }
139
-
140
- // ============================================================
141
- // Parser
142
- // ============================================================
143
-
144
- // eslint-disable-next-line @typescript-eslint/no-unused-vars
145
- function _parseStatus(
146
- raw: string,
147
- line: number,
148
- diagnostics: DgmoError[]
149
- ): InitiativeStatus {
150
- const trimmed = raw.trim().toLowerCase();
151
- if (!trimmed) return 'na';
152
- const canonical = STATUS_ALIASES[trimmed] ?? trimmed;
153
- if (VALID_STATUSES.includes(canonical)) return canonical as InitiativeStatus;
154
-
155
- // Unknown status — emit warning with suggestion
156
- const allKnown = [...VALID_STATUSES, ...Object.keys(STATUS_ALIASES)];
157
- const hint = suggest(trimmed, allKnown);
158
- const msg = `Unknown status "${raw.trim()}"${hint ? `. ${hint}` : ''}`;
159
- diagnostics.push(makeDgmoError(line, msg, 'warning'));
160
- return null;
161
- }
162
-
163
- /** Measure leading whitespace (tabs = 4 spaces) */
164
- function measureIndent(line: string): number {
165
- let count = 0;
166
- for (const ch of line) {
167
- if (ch === ' ') count++;
168
- else if (ch === '\t') count += 4;
169
- else break;
170
- }
171
- return count;
172
- }
173
-
174
- export function parseInitiativeStatus(content: string): ParsedInitiativeStatus {
175
- const result: ParsedInitiativeStatus = {
176
- type: 'initiative-status',
177
- title: null,
178
- titleLineNumber: null,
179
- nodes: [],
180
- edges: [],
181
- groups: [],
182
- tagGroups: [],
183
- options: {},
184
- initialHiddenTagValues: new Map(),
185
- diagnostics: [],
186
- error: null,
187
- };
188
-
189
- const lines = content.split('\n');
190
- const nodeLabels = new Set<string>();
191
- let currentGroup: ISGroup | null = null;
192
- let lastNodeLabel: string | null = null;
193
-
194
- // Tag block state
195
- let contentStarted = false;
196
- let currentTagGroup: TagGroup | null = null;
197
- const aliasMap = new Map<string, string>(); // lowercase alias → lowercase group name
198
-
199
- const pushWarning = (lineNumber: number, message: string) => {
200
- result.diagnostics.push(makeDgmoError(lineNumber, message, 'warning'));
201
- };
202
-
203
- for (let i = 0; i < lines.length; i++) {
204
- const lineNum = i + 1; // 1-based
205
- const raw = lines[i];
206
- const trimmed = raw.trim();
207
-
208
- // Skip blanks and comments
209
- if (!trimmed || trimmed.startsWith('//')) continue;
210
-
211
- // First line: chart type + optional title (new syntax: `initiative-status My Dashboard`)
212
- const firstLineResult = parseFirstLine(trimmed);
213
- if (firstLineResult && !contentStarted) {
214
- if (firstLineResult.chartType !== 'initiative-status') {
215
- const diag = makeDgmoError(
216
- lineNum,
217
- `Expected chart type "initiative-status", got "${firstLineResult.chartType}"`
218
- );
219
- result.diagnostics.push(diag);
220
- result.error = formatDgmoError(diag);
221
- return result;
222
- }
223
- if (firstLineResult.title) {
224
- result.title = firstLineResult.title;
225
- result.titleLineNumber = lineNum;
226
- }
227
- continue;
228
- }
229
-
230
- // hide directive (colon syntax): `hide phase:Planning, phase:Review`
231
- const hideMatch = trimmed.match(/^hide\s+(.+)/i);
232
- if (hideMatch && !trimmed.match(/^hide\s*\|/)) {
233
- // Parse comma-separated tag:value pairs: `phase:Planning, phase:Review`
234
- const pairs = hideMatch[1].split(',');
235
- for (const pair of pairs) {
236
- const colonIdx = pair.indexOf(':');
237
- if (colonIdx > 0) {
238
- const groupKey = pair.substring(0, colonIdx).trim().toLowerCase();
239
- const value = pair
240
- .substring(colonIdx + 1)
241
- .trim()
242
- .toLowerCase();
243
- if (groupKey && value) {
244
- if (!result.initialHiddenTagValues.has(groupKey)) {
245
- result.initialHiddenTagValues.set(groupKey, new Set());
246
- }
247
- result.initialHiddenTagValues.get(groupKey)!.add(value);
248
- }
249
- }
250
- }
251
- continue;
252
- }
253
-
254
- // Options (space-separated, non-indented): `active-tag Priority`
255
- if (!contentStarted && measureIndent(raw) === 0) {
256
- const optMatch = trimmed.match(OPTION_NOCOLON_RE);
257
- if (optMatch) {
258
- const key = optMatch[1].toLowerCase();
259
- const value = optMatch[2].trim();
260
- // Only recognize known option keys (not node content)
261
- if (key === 'active-tag') {
262
- result.options[key] = value;
263
- continue;
264
- }
265
- }
266
- }
267
-
268
- // Tag group heading — must be checked BEFORE group/node/edge matching
269
- const tagBlockMatch = matchTagBlockHeading(trimmed);
270
- if (tagBlockMatch) {
271
- if (contentStarted) {
272
- result.diagnostics.push(
273
- makeDgmoError(
274
- lineNum,
275
- 'Tag groups must appear before diagram content',
276
- 'error'
277
- )
278
- );
279
- continue;
280
- }
281
- currentTagGroup = {
282
- name: tagBlockMatch.name,
283
- alias: tagBlockMatch.alias,
284
- entries: [],
285
- lineNumber: lineNum,
286
- };
287
- if (tagBlockMatch.alias) {
288
- aliasMap.set(
289
- tagBlockMatch.alias.toLowerCase(),
290
- tagBlockMatch.name.toLowerCase()
291
- );
292
- }
293
- // Handle inline values from single-line tag declaration
294
- if (tagBlockMatch.inlineValues) {
295
- for (const rawVal of tagBlockMatch.inlineValues) {
296
- const { label, color } = extractColor(rawVal);
297
- currentTagGroup.entries.push({
298
- value: label,
299
- color: color ?? '',
300
- lineNumber: lineNum,
301
- });
302
- }
303
- // First entry is the default
304
- if (currentTagGroup.entries.length > 0) {
305
- currentTagGroup.defaultValue = currentTagGroup.entries[0].value;
306
- }
307
- }
308
- result.tagGroups.push(currentTagGroup);
309
- continue;
310
- }
311
-
312
- // Tag group entries (indented Value(color) under tag heading — first value is the default)
313
- if (currentTagGroup && !contentStarted) {
314
- const indent = measureIndent(raw);
315
- if (indent > 0) {
316
- const { label, color } = extractColor(trimmed);
317
- currentTagGroup.entries.push({
318
- value: label,
319
- color: color ?? '',
320
- lineNumber: lineNum,
321
- });
322
- // First entry is the default
323
- if (currentTagGroup.entries.length === 1) {
324
- currentTagGroup.defaultValue = label;
325
- }
326
- continue;
327
- }
328
- // Non-indented line after tag group — close and fall through
329
- currentTagGroup = null; // eslint-disable-line no-useless-assignment
330
- }
331
-
332
- // Group header: [Group Name] or [Group Name] | metadata
333
- const groupMatch = trimmed.match(/^\[(.+?)\]\s*(?:\|\s*(.+))?$/);
334
- if (groupMatch) {
335
- contentStarted = true;
336
- currentTagGroup = null;
337
- // Close previous group
338
- if (currentGroup) {
339
- result.groups.push(currentGroup);
340
- }
341
- const groupMeta: Record<string, string> = {};
342
- if (groupMatch[2]) {
343
- // Parse pipe metadata for group (only key:value pairs, no status)
344
- const items = groupMatch[2].split(',');
345
- for (const item of items) {
346
- const ci = item.indexOf(':');
347
- if (ci >= 0) {
348
- const rawKey = item.slice(0, ci).trim().toLowerCase();
349
- const value = item.slice(ci + 1).trim();
350
- const resolvedKey = aliasMap.get(rawKey) ?? rawKey;
351
- groupMeta[resolvedKey] = value;
352
- }
353
- }
354
- }
355
- currentGroup = {
356
- label: groupMatch[1],
357
- nodeLabels: [],
358
- lineNumber: lineNum,
359
- metadata: Object.keys(groupMeta).length > 0 ? groupMeta : undefined,
360
- };
361
- continue;
362
- }
363
-
364
- // Non-indented line closes the current group
365
- const isIndented = raw.length > 0 && raw !== trimmed && /^\s/.test(raw);
366
- if (!isIndented && currentGroup) {
367
- result.groups.push(currentGroup);
368
- currentGroup = null;
369
- }
370
-
371
- // Edge: contains `->` or labeled form `-label->`
372
- if (trimmed.includes('->')) {
373
- contentStarted = true;
374
- currentTagGroup = null;
375
- let edgeText = trimmed;
376
- // Indented `-> Target` or `-label-> Target` shorthand
377
- if (trimmed.startsWith('->') || /^-[^>].*->/.test(trimmed)) {
378
- if (!lastNodeLabel) {
379
- result.diagnostics.push(
380
- makeDgmoError(
381
- lineNum,
382
- 'Indented edge has no preceding node to use as source',
383
- 'warning'
384
- )
385
- );
386
- continue;
387
- }
388
- edgeText = `${lastNodeLabel} ${trimmed}`;
389
- }
390
- const edge = parseEdgeLine(
391
- edgeText,
392
- lineNum,
393
- aliasMap,
394
- result.diagnostics
395
- );
396
- if (edge) result.edges.push(edge);
397
- continue;
398
- }
399
-
400
- // Node: everything else
401
- contentStarted = true;
402
- currentTagGroup = null;
403
- const node = parseNodeLine(trimmed, lineNum, aliasMap, result.diagnostics);
404
- if (!node) {
405
- result.diagnostics.push(
406
- makeDgmoError(lineNum, `Unexpected line: '${trimmed}'.`, 'warning')
407
- );
408
- continue;
409
- }
410
- lastNodeLabel = node.label;
411
- if (nodeLabels.has(node.label)) {
412
- result.diagnostics.push(
413
- makeDgmoError(lineNum, `Duplicate node "${node.label}"`, 'warning')
414
- );
415
- } else {
416
- nodeLabels.add(node.label);
417
- }
418
- // Cascade group metadata into node (group provides defaults, node overrides)
419
- if (currentGroup && isIndented && currentGroup.metadata) {
420
- for (const [key, val] of Object.entries(currentGroup.metadata)) {
421
- if (!(key in node.metadata)) {
422
- node.metadata[key] = val;
423
- }
424
- }
425
- }
426
- result.nodes.push(node);
427
- // Add to current group if indented
428
- if (currentGroup && isIndented) {
429
- currentGroup.nodeLabels.push(node.label);
430
- }
431
- }
432
-
433
- // Close any trailing group
434
- if (currentGroup) {
435
- result.groups.push(currentGroup);
436
- }
437
-
438
- // Validate edges reference declared nodes
439
- for (const edge of result.edges) {
440
- if (!nodeLabels.has(edge.source)) {
441
- result.diagnostics.push(
442
- makeDgmoError(
443
- edge.lineNumber,
444
- `Edge source "${edge.source}" is not a declared node`,
445
- 'warning'
446
- )
447
- );
448
- // Auto-create an implicit node
449
- if (!result.nodes.some((n) => n.label === edge.source)) {
450
- result.nodes.push({
451
- label: edge.source,
452
- status: 'na',
453
- shape: inferParticipantType(edge.source),
454
- lineNumber: edge.lineNumber,
455
- metadata: {},
456
- });
457
- nodeLabels.add(edge.source);
458
- }
459
- }
460
- if (!nodeLabels.has(edge.target)) {
461
- result.diagnostics.push(
462
- makeDgmoError(
463
- edge.lineNumber,
464
- `Edge target "${edge.target}" is not a declared node`,
465
- 'warning'
466
- )
467
- );
468
- if (!result.nodes.some((n) => n.label === edge.target)) {
469
- result.nodes.push({
470
- label: edge.target,
471
- status: 'na',
472
- shape: inferParticipantType(edge.target),
473
- lineNumber: edge.lineNumber,
474
- metadata: {},
475
- });
476
- nodeLabels.add(edge.target);
477
- }
478
- }
479
- }
480
-
481
- // Post-parse: inject default tag metadata and validate tag values
482
- if (result.tagGroups.length > 0) {
483
- injectDefaultTagMetadata(result.nodes, result.tagGroups);
484
- validateTagValues(result.nodes, result.tagGroups, pushWarning, suggest);
485
- }
486
-
487
- return result;
488
- }
489
-
490
- // ============================================================
491
- // Line parsers
492
- // ============================================================
493
-
494
- function parseNodeLine(
495
- trimmed: string,
496
- lineNum: number,
497
- aliasMap: Map<string, string>,
498
- diagnostics: DgmoError[]
499
- ): ISNode | null {
500
- // Format: <label> | <status>, <key: value>, ...
501
- // or just: <label>
502
- const pipeIdx = trimmed.indexOf('|');
503
- if (pipeIdx >= 0) {
504
- const label = trimmed.slice(0, pipeIdx).trim();
505
- const metaSegment = trimmed.slice(pipeIdx + 1).trim();
506
- if (!label) return null;
507
- const { status, metadata, hadStatusWord } = parseNodeMetadata(
508
- metaSegment,
509
- aliasMap,
510
- lineNum,
511
- diagnostics
512
- );
513
- return {
514
- label,
515
- // Unknown status bare word → keep null; no bare word at all → default 'na'
516
- status: hadStatusWord ? status : (status ?? 'na'),
517
- shape: inferParticipantType(label),
518
- lineNumber: lineNum,
519
- metadata,
520
- };
521
- }
522
- return {
523
- label: trimmed,
524
- status: 'na',
525
- shape: inferParticipantType(trimmed),
526
- lineNumber: lineNum,
527
- metadata: {},
528
- };
529
- }
530
-
531
- function parseEdgeLine(
532
- trimmed: string,
533
- lineNum: number,
534
- aliasMap: Map<string, string>,
535
- diagnostics: DgmoError[]
536
- ): ISEdge | null {
537
- // Format: <source> -> <target>: <label> | <status>, <key: value>, ...
538
- // or: <source> -> <target> | <status>
539
- // or: <source> -> <target>: <label>
540
- // or: <source> -> <target>
541
- // or: <source> -<label>-> <target> [| <status>]
542
-
543
- // Check for labeled arrow form: SOURCE -LABEL-> TARGET [| status]
544
- const labeledMatch = trimmed.match(/^(\S+)\s*-(.+)->\s*(.+)$/);
545
- if (labeledMatch) {
546
- const source = labeledMatch[1];
547
- const label = labeledMatch[2].trim();
548
- let targetRest = labeledMatch[3].trim();
549
-
550
- if (label) {
551
- let status: InitiativeStatus = 'na';
552
- let metadata: Record<string, string> = {};
553
- const pipeIdx = targetRest.indexOf('|');
554
- if (pipeIdx >= 0) {
555
- const metaSegment = targetRest.slice(pipeIdx + 1).trim();
556
- const parsed = parseNodeMetadata(
557
- metaSegment,
558
- aliasMap,
559
- lineNum,
560
- diagnostics
561
- );
562
- status = parsed.hadStatusWord
563
- ? (parsed.status ?? null)
564
- : (parsed.status ?? 'na');
565
- metadata = parsed.metadata;
566
- targetRest = targetRest.slice(0, pipeIdx).trim();
567
- }
568
-
569
- const target = targetRest.trim();
570
- if (!target) {
571
- diagnostics.push(makeDgmoError(lineNum, 'Edge is missing target'));
572
- return null;
573
- }
574
-
575
- return { source, target, label, status, lineNumber: lineNum, metadata };
576
- }
577
- // Empty label — fall through to plain arrow parsing
578
- }
579
-
580
- const arrowIdx = trimmed.indexOf('->');
581
- if (arrowIdx < 0) return null;
582
-
583
- const source = trimmed.slice(0, arrowIdx).trim();
584
- let rest = trimmed.slice(arrowIdx + 2).trim();
585
-
586
- if (!source || !rest) {
587
- diagnostics.push(
588
- makeDgmoError(lineNum, 'Edge is missing source or target')
589
- );
590
- return null;
591
- }
592
-
593
- // Extract metadata from end (after |)
594
- let status: InitiativeStatus = 'na';
595
- let metadata: Record<string, string> = {};
596
- const pipeIdx = rest.indexOf('|');
597
- if (pipeIdx >= 0) {
598
- const metaSegment = rest.slice(pipeIdx + 1).trim();
599
- const parsed = parseNodeMetadata(
600
- metaSegment,
601
- aliasMap,
602
- lineNum,
603
- diagnostics
604
- );
605
- status = parsed.hadStatusWord
606
- ? (parsed.status ?? null)
607
- : (parsed.status ?? 'na');
608
- metadata = parsed.metadata;
609
- rest = rest.slice(0, pipeIdx).trim();
610
- }
611
-
612
- // Extract target and optional label (target: label)
613
- let target: string;
614
- let label: string | undefined;
615
- const colonIdx = rest.indexOf(':');
616
- if (colonIdx >= 0) {
617
- target = rest.slice(0, colonIdx).trim();
618
- label = rest.slice(colonIdx + 1).trim() || undefined;
619
- } else {
620
- target = rest.trim();
621
- }
622
-
623
- if (!target) {
624
- diagnostics.push(makeDgmoError(lineNum, 'Edge is missing target'));
625
- return null;
626
- }
627
-
628
- return { source, target, label, status, lineNumber: lineNum, metadata };
629
- }