@diagrammo/dgmo 0.8.3 → 0.8.4

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 (112) hide show
  1. package/.claude/commands/dgmo-diagram-this.md +60 -0
  2. package/.claude/commands/dgmo-document-project.md +128 -0
  3. package/.claude/commands/dgmo.md +185 -50
  4. package/.cursorrules +32 -37
  5. package/.github/copilot-instructions.md +35 -44
  6. package/.windsurfrules +32 -37
  7. package/README.md +4 -4
  8. package/dist/cli.cjs +153 -153
  9. package/dist/editor.cjs +336 -0
  10. package/dist/editor.cjs.map +1 -0
  11. package/dist/editor.d.cts +27 -0
  12. package/dist/editor.d.ts +27 -0
  13. package/dist/editor.js +305 -0
  14. package/dist/editor.js.map +1 -0
  15. package/dist/index.cjs +3336 -1055
  16. package/dist/index.cjs.map +1 -1
  17. package/dist/index.js +3336 -1055
  18. package/dist/index.js.map +1 -1
  19. package/docs/language-reference.md +30 -29
  20. package/gallery/fixtures/arc.dgmo +18 -0
  21. package/gallery/fixtures/area.dgmo +19 -0
  22. package/gallery/fixtures/bar-stacked.dgmo +10 -0
  23. package/gallery/fixtures/bar.dgmo +10 -0
  24. package/gallery/fixtures/c4-full.dgmo +52 -0
  25. package/gallery/fixtures/c4.dgmo +17 -0
  26. package/gallery/fixtures/chord.dgmo +12 -0
  27. package/gallery/fixtures/class-basic.dgmo +14 -0
  28. package/gallery/fixtures/class-full.dgmo +43 -0
  29. package/gallery/fixtures/doughnut.dgmo +8 -0
  30. package/gallery/fixtures/flowchart-basic.dgmo +3 -0
  31. package/gallery/fixtures/flowchart-colors.dgmo +5 -0
  32. package/gallery/fixtures/flowchart-complex.dgmo +17 -0
  33. package/gallery/fixtures/flowchart-decision.dgmo +5 -0
  34. package/gallery/fixtures/flowchart-full.dgmo +13 -0
  35. package/gallery/fixtures/flowchart-groups.dgmo +10 -0
  36. package/gallery/fixtures/flowchart-loop.dgmo +7 -0
  37. package/gallery/fixtures/flowchart-nested.dgmo +7 -0
  38. package/gallery/fixtures/flowchart-shapes.dgmo +5 -0
  39. package/gallery/fixtures/function.dgmo +8 -0
  40. package/gallery/fixtures/funnel.dgmo +7 -0
  41. package/gallery/fixtures/gantt-full.dgmo +49 -0
  42. package/gallery/fixtures/gantt.dgmo +42 -0
  43. package/gallery/fixtures/heatmap.dgmo +8 -0
  44. package/gallery/fixtures/infra-full.dgmo +78 -0
  45. package/gallery/fixtures/infra-overload.dgmo +25 -0
  46. package/gallery/fixtures/infra.dgmo +47 -0
  47. package/gallery/fixtures/initiative-status-full.dgmo +46 -0
  48. package/gallery/fixtures/initiative-status-phases.dgmo +29 -0
  49. package/gallery/fixtures/initiative-status.dgmo +9 -0
  50. package/gallery/fixtures/line.dgmo +19 -0
  51. package/gallery/fixtures/multi-line.dgmo +11 -0
  52. package/gallery/fixtures/org-basic.dgmo +16 -0
  53. package/gallery/fixtures/org-full.dgmo +69 -0
  54. package/gallery/fixtures/org-teams.dgmo +25 -0
  55. package/gallery/fixtures/pie.dgmo +9 -0
  56. package/gallery/fixtures/polar-area.dgmo +8 -0
  57. package/gallery/fixtures/quadrant.dgmo +18 -0
  58. package/gallery/fixtures/radar.dgmo +8 -0
  59. package/gallery/fixtures/sankey.dgmo +31 -0
  60. package/gallery/fixtures/scatter.dgmo +21 -0
  61. package/gallery/fixtures/sequence-tags-protocols.dgmo +45 -0
  62. package/gallery/fixtures/sequence-tags.dgmo +41 -0
  63. package/gallery/fixtures/sequence.dgmo +35 -0
  64. package/gallery/fixtures/sitemap-basic.dgmo +12 -0
  65. package/gallery/fixtures/sitemap-full.dgmo +156 -0
  66. package/gallery/fixtures/slope.dgmo +8 -0
  67. package/gallery/fixtures/spr-eras.dgmo +62 -0
  68. package/gallery/fixtures/state.dgmo +30 -0
  69. package/gallery/fixtures/timeline-intraday.dgmo +14 -0
  70. package/gallery/fixtures/timeline.dgmo +32 -0
  71. package/gallery/fixtures/venn.dgmo +10 -0
  72. package/gallery/fixtures/wordcloud.dgmo +24 -0
  73. package/package.json +51 -2
  74. package/src/c4/layout.ts +372 -90
  75. package/src/c4/parser.ts +100 -55
  76. package/src/chart.ts +91 -28
  77. package/src/class/parser.ts +41 -12
  78. package/src/cli.ts +168 -61
  79. package/src/completion.ts +378 -183
  80. package/src/d3.ts +887 -288
  81. package/src/dgmo-mermaid.ts +16 -13
  82. package/src/dgmo-router.ts +69 -23
  83. package/src/echarts.ts +646 -153
  84. package/src/editor/dgmo.grammar +69 -0
  85. package/src/editor/dgmo.grammar.d.ts +2 -0
  86. package/src/editor/dgmo.grammar.js +18 -0
  87. package/src/editor/dgmo.grammar.terms.d.ts +5 -0
  88. package/src/editor/dgmo.grammar.terms.js +35 -0
  89. package/src/editor/highlight.ts +36 -0
  90. package/src/editor/index.ts +28 -0
  91. package/src/editor/keywords.ts +220 -0
  92. package/src/editor/tokens.ts +30 -0
  93. package/src/er/parser.ts +48 -14
  94. package/src/er/renderer.ts +112 -53
  95. package/src/gantt/calculator.ts +91 -29
  96. package/src/gantt/parser.ts +197 -71
  97. package/src/gantt/renderer.ts +1120 -350
  98. package/src/graph/flowchart-parser.ts +46 -25
  99. package/src/graph/state-parser.ts +47 -17
  100. package/src/infra/parser.ts +157 -53
  101. package/src/infra/renderer.ts +723 -271
  102. package/src/initiative-status/parser.ts +138 -44
  103. package/src/kanban/parser.ts +25 -14
  104. package/src/org/layout.ts +111 -44
  105. package/src/org/parser.ts +69 -22
  106. package/src/palettes/index.ts +3 -2
  107. package/src/sequence/parser.ts +193 -61
  108. package/src/sitemap/parser.ts +65 -29
  109. package/src/utils/arrows.ts +2 -22
  110. package/src/utils/duration.ts +39 -21
  111. package/src/utils/legend-constants.ts +0 -2
  112. package/src/utils/parsing.ts +75 -31
@@ -9,13 +9,7 @@ import {
9
9
  OPTION_NOCOLON_RE,
10
10
  ALL_CHART_TYPES,
11
11
  } from '../utils/parsing';
12
- import type {
13
- ParsedGraph,
14
- GraphNode,
15
- GraphEdge,
16
- GraphShape,
17
- GraphDirection,
18
- } from './types';
12
+ import type { ParsedGraph, GraphNode, GraphEdge, GraphShape } from './types';
19
13
 
20
14
  // ============================================================
21
15
  // Helpers
@@ -36,10 +30,7 @@ interface NodeRef {
36
30
  * Try to parse a node reference from a text fragment.
37
31
  * Order matters: subroutine & document before process.
38
32
  */
39
- function parseNodeRef(
40
- text: string,
41
- palette?: PaletteColors
42
- ): NodeRef | null {
33
+ function parseNodeRef(text: string, palette?: PaletteColors): NodeRef | null {
43
34
  const t = text.trim();
44
35
  if (!t) return null;
45
36
 
@@ -47,7 +38,12 @@ function parseNodeRef(
47
38
  let m = t.match(/^\[\[([^\]]+)\]\]$/);
48
39
  if (m) {
49
40
  const { label, color } = extractColor(m[1].trim(), palette);
50
- return { id: nodeId('subroutine', label), label, shape: 'subroutine', color };
41
+ return {
42
+ id: nodeId('subroutine', label),
43
+ label,
44
+ shape: 'subroutine',
45
+ color,
46
+ };
51
47
  }
52
48
 
53
49
  // Document: [Label~]
@@ -99,7 +95,12 @@ function splitArrows(line: string): string[] {
99
95
  const segments: string[] = [];
100
96
  let lastIndex = 0;
101
97
  // Simpler approach: find all `->` positions, then determine if there's a label prefix
102
- const arrowPositions: { start: number; end: number; label?: string; color?: string }[] = [];
98
+ const arrowPositions: {
99
+ start: number;
100
+ end: number;
101
+ label?: string;
102
+ color?: string;
103
+ }[] = [];
103
104
 
104
105
  // Find all -> occurrences
105
106
  let searchFrom = 0;
@@ -124,10 +125,14 @@ function splitArrows(line: string): string[] {
124
125
  scanBack--;
125
126
  }
126
127
  // Check if this `-` could be the start of the arrow
127
- if (line[scanBack] === '-' && (scanBack === 0 || /\s/.test(line[scanBack - 1]))) {
128
+ if (
129
+ line[scanBack] === '-' &&
130
+ (scanBack === 0 || /\s/.test(line[scanBack - 1]))
131
+ ) {
128
132
  // Content between opening `-` and `->` (strip trailing `-` that is part of `->`)
129
133
  let arrowContent = line.substring(scanBack + 1, idx);
130
- if (arrowContent.endsWith('-')) arrowContent = arrowContent.slice(0, -1);
134
+ if (arrowContent.endsWith('-'))
135
+ arrowContent = arrowContent.slice(0, -1);
131
136
  // Parse label and color from arrow content
132
137
  const colorMatch = arrowContent.match(/\(([^)]+)\)\s*$/);
133
138
  if (colorMatch) {
@@ -159,7 +164,8 @@ function splitArrows(line: string): string[] {
159
164
  }
160
165
  // Arrow marker
161
166
  let arrowToken = '->';
162
- if (arrow.label && arrow.color) arrowToken = `-${arrow.label}(${arrow.color})->`;
167
+ if (arrow.label && arrow.color)
168
+ arrowToken = `-${arrow.label}(${arrow.color})->`;
163
169
  else if (arrow.label) arrowToken = `-${arrow.label}->`;
164
170
  else if (arrow.color) arrowToken = `-(${arrow.color})->`;
165
171
  segments.push(arrowToken);
@@ -190,7 +196,9 @@ function parseArrowToken(token: string, palette?: PaletteColors): ArrowInfo {
190
196
  const m = token.match(/^-(.+?)(?:\(([^)]+)\))?->$/);
191
197
  if (m) {
192
198
  const label = m[1]?.trim() || undefined;
193
- let color = m[2] ? resolveColor(m[2].trim(), palette) ?? undefined : undefined;
199
+ let color = m[2]
200
+ ? (resolveColor(m[2].trim(), palette) ?? undefined)
201
+ : undefined;
194
202
  if (label && !color) {
195
203
  color = inferArrowColor(label);
196
204
  }
@@ -214,7 +222,7 @@ export function parseFlowchart(
214
222
  const lines = content.split('\n');
215
223
  const result: ParsedGraph = {
216
224
  type: 'flowchart',
217
- direction: 'LR',
225
+ direction: 'TB',
218
226
  nodes: [],
219
227
  edges: [],
220
228
  options: {},
@@ -406,9 +414,9 @@ export function parseFlowchart(
406
414
 
407
415
  // Options (space-separated, before content)
408
416
  if (!contentStarted) {
409
- // Bare boolean: direction-tb
410
- if (/^direction-tb$/i.test(trimmed)) {
411
- result.direction = 'TB';
417
+ // Bare boolean: direction-lr
418
+ if (/^direction-lr$/i.test(trimmed)) {
419
+ result.direction = 'LR';
412
420
  continue;
413
421
  }
414
422
 
@@ -435,7 +443,10 @@ export function parseFlowchart(
435
443
 
436
444
  // Validation: no nodes found
437
445
  if (result.nodes.length === 0 && !result.error) {
438
- const diag = makeDgmoError(1, 'No nodes found. Add flowchart content with shape syntax like [Process] or (Start).');
446
+ const diag = makeDgmoError(
447
+ 1,
448
+ 'No nodes found. Add flowchart content with shape syntax like [Process] or (Start).'
449
+ );
439
450
  result.diagnostics.push(diag);
440
451
  result.error = formatDgmoError(diag);
441
452
  }
@@ -449,7 +460,13 @@ export function parseFlowchart(
449
460
  }
450
461
  for (const node of result.nodes) {
451
462
  if (!connectedIds.has(node.id)) {
452
- result.diagnostics.push(makeDgmoError(node.lineNumber, `Node "${node.label}" is not connected to any other node`, 'warning'));
463
+ result.diagnostics.push(
464
+ makeDgmoError(
465
+ node.lineNumber,
466
+ `Node "${node.label}" is not connected to any other node`,
467
+ 'warning'
468
+ )
469
+ );
453
470
  }
454
471
  }
455
472
  }
@@ -485,7 +502,7 @@ export function looksLikeFlowchart(content: string): boolean {
485
502
  // Look for patterns like `[X] ->` or `-> [X]` or `(X) ->` etc.
486
503
  const shapeNearArrow =
487
504
  /[\])][ \t]*-.*->/.test(content) || // shape ] or ) followed by arrow
488
- /->[ \t]*[\[(<\/]/.test(content); // arrow followed by shape opener
505
+ /->[ \t]*[[(</]/.test(content); // arrow followed by shape opener
489
506
 
490
507
  return shapeNearArrow;
491
508
  }
@@ -509,7 +526,11 @@ export function extractSymbols(docText: string): DiagramSymbols {
509
526
  for (const rawLine of docText.split('\n')) {
510
527
  const line = rawLine.trim();
511
528
  // Skip old-style colon metadata and new-style space-separated options
512
- if (inMetadata && (/^[a-z-]+\s*:/i.test(line) || /^[a-z-]+\s+\S/i.test(line))) continue;
529
+ if (
530
+ inMetadata &&
531
+ (/^[a-z-]+\s*:/i.test(line) || /^[a-z-]+\s+\S/i.test(line))
532
+ )
533
+ continue;
513
534
  inMetadata = false;
514
535
  if (line.length === 0 || /^\s/.test(rawLine)) continue;
515
536
  const m = NODE_ID_RE.exec(line);
@@ -8,12 +8,7 @@ import {
8
8
  OPTION_NOCOLON_RE,
9
9
  ALL_CHART_TYPES,
10
10
  } from '../utils/parsing';
11
- import type {
12
- ParsedGraph,
13
- GraphNode,
14
- GraphGroup,
15
- GraphDirection,
16
- } from './types';
11
+ import type { ParsedGraph, GraphNode, GraphGroup } from './types';
17
12
 
18
13
  // ============================================================
19
14
  // Constants
@@ -36,7 +31,12 @@ const GROUP_BRACKET_RE = /^\[([^\]]+)\](?:\(([^)]+)\))?\s*$/;
36
31
  */
37
32
  function splitArrows(line: string): string[] {
38
33
  const segments: string[] = [];
39
- const arrowPositions: { start: number; end: number; label?: string; color?: string }[] = [];
34
+ const arrowPositions: {
35
+ start: number;
36
+ end: number;
37
+ label?: string;
38
+ color?: string;
39
+ }[] = [];
40
40
 
41
41
  let searchFrom = 0;
42
42
  while (searchFrom < line.length) {
@@ -52,9 +52,13 @@ function splitArrows(line: string): string[] {
52
52
  while (scanBack > 0 && line[scanBack] !== '-') {
53
53
  scanBack--;
54
54
  }
55
- if (line[scanBack] === '-' && (scanBack === 0 || /\s/.test(line[scanBack - 1]))) {
55
+ if (
56
+ line[scanBack] === '-' &&
57
+ (scanBack === 0 || /\s/.test(line[scanBack - 1]))
58
+ ) {
56
59
  let arrowContent = line.substring(scanBack + 1, idx);
57
- if (arrowContent.endsWith('-')) arrowContent = arrowContent.slice(0, -1);
60
+ if (arrowContent.endsWith('-'))
61
+ arrowContent = arrowContent.slice(0, -1);
58
62
  const colorMatch = arrowContent.match(/\(([^)]+)\)\s*$/);
59
63
  if (colorMatch) {
60
64
  color = colorMatch[1].trim();
@@ -81,7 +85,8 @@ function splitArrows(line: string): string[] {
81
85
  if (beforeText || i === 0) segments.push(beforeText);
82
86
 
83
87
  let arrowToken = '->';
84
- if (arrow.label && arrow.color) arrowToken = `-${arrow.label}(${arrow.color})->`;
88
+ if (arrow.label && arrow.color)
89
+ arrowToken = `-${arrow.label}(${arrow.color})->`;
85
90
  else if (arrow.label) arrowToken = `-${arrow.label}->`;
86
91
  else if (arrow.color) arrowToken = `-(${arrow.color})->`;
87
92
  segments.push(arrowToken);
@@ -101,11 +106,14 @@ interface ArrowInfo {
101
106
  function parseArrowToken(token: string, palette?: PaletteColors): ArrowInfo {
102
107
  if (token === '->') return {};
103
108
  const colorOnly = token.match(/^-\(([^)]+)\)->$/);
104
- if (colorOnly) return { color: resolveColor(colorOnly[1].trim(), palette) ?? undefined };
109
+ if (colorOnly)
110
+ return { color: resolveColor(colorOnly[1].trim(), palette) ?? undefined };
105
111
  const m = token.match(/^-(.+?)(?:\(([^)]+)\))?->$/);
106
112
  if (m) {
107
113
  const label = m[1]?.trim() || undefined;
108
- const color = m[2] ? resolveColor(m[2].trim(), palette) ?? undefined : undefined;
114
+ const color = m[2]
115
+ ? (resolveColor(m[2].trim(), palette) ?? undefined)
116
+ : undefined;
109
117
  return { label, color };
110
118
  }
111
119
  return {};
@@ -122,13 +130,20 @@ interface NodeRef {
122
130
  color?: string;
123
131
  }
124
132
 
125
- function parseStateNodeRef(text: string, palette?: PaletteColors): NodeRef | null {
133
+ function parseStateNodeRef(
134
+ text: string,
135
+ palette?: PaletteColors
136
+ ): NodeRef | null {
126
137
  const t = text.trim();
127
138
  if (!t) return null;
128
139
 
129
140
  // Pseudostate: [*]
130
141
  if (t === '[*]') {
131
- return { id: PSEUDOSTATE_ID, label: PSEUDOSTATE_LABEL, shape: 'pseudostate' };
142
+ return {
143
+ id: PSEUDOSTATE_ID,
144
+ label: PSEUDOSTATE_LABEL,
145
+ shape: 'pseudostate',
146
+ };
132
147
  }
133
148
 
134
149
  // State: bare text with optional (color) suffix
@@ -350,7 +365,13 @@ export function parseState(
350
365
  // Use explicit source if available, else implicit from indent
351
366
  const sourceId = lastNodeId ?? implicitSourceId;
352
367
  if (sourceId) {
353
- addEdge(sourceId, node.id, lineNumber, pendingArrow.label, pendingArrow.color);
368
+ addEdge(
369
+ sourceId,
370
+ node.id,
371
+ lineNumber,
372
+ pendingArrow.label,
373
+ pendingArrow.color
374
+ );
354
375
  }
355
376
  pendingArrow = null;
356
377
  }
@@ -367,7 +388,10 @@ export function parseState(
367
388
 
368
389
  // Validation: no nodes found
369
390
  if (result.nodes.length === 0 && !result.error) {
370
- const diag = makeDgmoError(1, 'No states found. Add state transitions like: Idle -> Active');
391
+ const diag = makeDgmoError(
392
+ 1,
393
+ 'No states found. Add state transitions like: Idle -> Active'
394
+ );
371
395
  result.diagnostics.push(diag);
372
396
  result.error = formatDgmoError(diag);
373
397
  }
@@ -381,7 +405,13 @@ export function parseState(
381
405
  }
382
406
  for (const node of result.nodes) {
383
407
  if (!connectedIds.has(node.id)) {
384
- result.diagnostics.push(makeDgmoError(node.lineNumber, `State "${node.label}" is not connected to any other state`, 'warning'));
408
+ result.diagnostics.push(
409
+ makeDgmoError(
410
+ node.lineNumber,
411
+ `State "${node.label}" is not connected to any other state`,
412
+ 'warning'
413
+ )
414
+ );
385
415
  }
386
416
  }
387
417
  }
@@ -7,7 +7,11 @@
7
7
  // and connections, [Group] containers, tag groups, pipe metadata.
8
8
 
9
9
  import { makeDgmoError, formatDgmoError, suggest } from '../diagnostics';
10
- import { measureIndent, parseFirstLine, OPTION_NOCOLON_RE } from '../utils/parsing';
10
+ import {
11
+ measureIndent,
12
+ parseFirstLine,
13
+ OPTION_NOCOLON_RE,
14
+ } from '../utils/parsing';
11
15
  import { matchTagBlockHeading } from '../utils/tag-groups';
12
16
  import type {
13
17
  ParsedInfra,
@@ -21,21 +25,17 @@ import { INFRA_BEHAVIOR_KEYS, EDGE_ONLY_KEYS } from './types';
21
25
  // Regex patterns
22
26
  // ============================================================
23
27
 
24
- // Connection: -label-> Target or -> Target (with optional | split: N% or pipe metadata)
25
- const CONNECTION_RE =
26
- /^-(?:([^-].*?))?->\s*(.+?)(?:(?:\s*\|\s*|\s+)split\s*:?\s*(\d+)%)?\s*$/;
28
+ // Connection: -label-> Target or -> Target (pipe metadata handled by extractPipeMetadata)
29
+ const CONNECTION_RE = /^-(?:([^-].*?))?->\s*(.+?)\s*$/;
27
30
 
28
31
  // Simple connection shorthand: -> Target (no label, no dash prefix needed for edge)
29
- const SIMPLE_CONNECTION_RE =
30
- /^->\s*(.+?)(?:(?:\s*\|\s*|\s+)split\s*:?\s*(\d+)%)?\s*$/;
32
+ const SIMPLE_CONNECTION_RE = /^->\s*(.+?)\s*$/;
31
33
 
32
- // Async connection: ~label~> Target or ~> Target (with optional | split: N% or pipe metadata)
33
- const ASYNC_CONNECTION_RE =
34
- /^~(?:([^~].*?))?~>\s*(.+?)(?:(?:\s*\|\s*|\s+)split\s*:?\s*(\d+)%)?\s*$/;
34
+ // Async connection: ~label~> Target or ~> Target
35
+ const ASYNC_CONNECTION_RE = /^~(?:([^~].*?))?~>\s*(.+?)\s*$/;
35
36
 
36
37
  // Async simple connection shorthand: ~> Target
37
- const ASYNC_SIMPLE_CONNECTION_RE =
38
- /^~>\s*(.+?)(?:(?:\s*\|\s*|\s+)split\s*:?\s*(\d+)%)?\s*$/;
38
+ const ASYNC_SIMPLE_CONNECTION_RE = /^~>\s*(.+?)\s*$/;
39
39
 
40
40
  // Deprecated xN fanout suffix (e.g. "x5" at end of line)
41
41
  const DEPRECATED_FANOUT_RE = /\bx(\d+)\s*$/;
@@ -68,8 +68,12 @@ const EDGE_NODE_NAMES = new Set(['edge', 'internet']);
68
68
 
69
69
  // Known top-level option keys (space-separated, no colon)
70
70
  const TOP_LEVEL_OPTIONS = new Set([
71
- 'slo-availability', 'slo-p90-latency-ms', 'slo-warning-margin',
72
- 'default-latency-ms', 'default-uptime', 'default-rps',
71
+ 'slo-availability',
72
+ 'slo-p90-latency-ms',
73
+ 'slo-warning-margin',
74
+ 'default-latency-ms',
75
+ 'default-uptime',
76
+ 'default-rps',
73
77
  ]);
74
78
 
75
79
  // ============================================================
@@ -94,9 +98,10 @@ function parsePropertyValue(raw: string): string | number {
94
98
  return raw.trim();
95
99
  }
96
100
 
97
- function extractPipeMetadata(
98
- rest: string,
99
- ): { tags: Record<string, string>; clean: string } {
101
+ function extractPipeMetadata(rest: string): {
102
+ tags: Record<string, string>;
103
+ clean: string;
104
+ } {
100
105
  const tags: Record<string, string> = {};
101
106
  let clean = rest;
102
107
  let match: RegExpExecArray | null;
@@ -108,6 +113,30 @@ function extractPipeMetadata(
108
113
  return { tags, clean: clean.trim() };
109
114
  }
110
115
 
116
+ // Detect unparsed pipe metadata left in a target name after extractPipeMetadata.
117
+ // Common case: `split 100%` without a colon isn't picked up by PIPE_META_RE.
118
+ const UNPARSED_SPLIT_RE = /\bsplit\s+(\d+)%/;
119
+
120
+ function warnUnparsedPipeMeta(
121
+ targetName: string,
122
+ lineNumber: number,
123
+ warnFn: (line: number, message: string) => void
124
+ ): void {
125
+ if (!targetName.includes('|')) return;
126
+ const splitMatch = targetName.match(UNPARSED_SPLIT_RE);
127
+ if (splitMatch) {
128
+ warnFn(
129
+ lineNumber,
130
+ `'split ${splitMatch[1]}%' needs a colon — use 'split: ${splitMatch[1]}%'`
131
+ );
132
+ } else {
133
+ warnFn(
134
+ lineNumber,
135
+ `Unparsed pipe metadata in target — pipe values use 'key: value' syntax`
136
+ );
137
+ }
138
+ }
139
+
111
140
  // ============================================================
112
141
  // Parser
113
142
  // ============================================================
@@ -150,20 +179,26 @@ export function parseInfra(content: string): ParsedInfra {
150
179
  if (currentNode && !nodeMap.has(currentNode.id)) {
151
180
  // Validate mutual exclusion: concurrency vs instances/max-rps
152
181
  const keys = new Set(currentNode.properties.map((p) => p.key));
153
- if (keys.has('concurrency') && (keys.has('instances') || keys.has('max-rps'))) {
154
- const conflicting = [keys.has('instances') ? 'instances' : '', keys.has('max-rps') ? 'max-rps' : '']
182
+ if (
183
+ keys.has('concurrency') &&
184
+ (keys.has('instances') || keys.has('max-rps'))
185
+ ) {
186
+ const conflicting = [
187
+ keys.has('instances') ? 'instances' : '',
188
+ keys.has('max-rps') ? 'max-rps' : '',
189
+ ]
155
190
  .filter(Boolean)
156
191
  .join(', ');
157
192
  warn(
158
193
  currentNode.lineNumber,
159
- `'concurrency' (serverless) is mutually exclusive with ${conflicting}. Serverless nodes scale via concurrency, not instances.`,
194
+ `'concurrency' (serverless) is mutually exclusive with ${conflicting}. Serverless nodes scale via concurrency, not instances.`
160
195
  );
161
196
  }
162
197
  // Validate mutual exclusion: buffer (queue) vs max-rps (service)
163
198
  if (keys.has('buffer') && keys.has('max-rps')) {
164
199
  warn(
165
200
  currentNode.lineNumber,
166
- `'buffer' (queue) and 'max-rps' (service) represent different capacity models. A queue buffers messages; a service processes them.`,
201
+ `'buffer' (queue) and 'max-rps' (service) represent different capacity models. A queue buffers messages; a service processes them.`
167
202
  );
168
203
  }
169
204
  nodeMap.set(currentNode.id, currentNode);
@@ -202,7 +237,10 @@ export function parseInfra(content: string): ParsedInfra {
202
237
  const firstLineResult = parseFirstLine(trimmed);
203
238
  if (firstLineResult) {
204
239
  if (firstLineResult.chartType !== 'infra') {
205
- setError(lineNumber, `Expected chart type 'infra', got '${firstLineResult.chartType}'`);
240
+ setError(
241
+ lineNumber,
242
+ `Expected chart type 'infra', got '${firstLineResult.chartType}'`
243
+ );
206
244
  }
207
245
  if (firstLineResult.title) {
208
246
  result.title = firstLineResult.title;
@@ -255,11 +293,16 @@ export function parseInfra(content: string): ParsedInfra {
255
293
  finishCurrentTagGroup();
256
294
  const gLabel = groupMatch[1].trim();
257
295
  const gId = groupId(gLabel);
258
- const groupMeta = groupMatch[2] ? extractPipeMetadata('|' + groupMatch[2]).tags : undefined;
296
+ const groupMeta = groupMatch[2]
297
+ ? extractPipeMetadata('|' + groupMatch[2]).tags
298
+ : undefined;
259
299
  currentGroup = {
260
300
  id: gId,
261
301
  label: gLabel,
262
- metadata: groupMeta && Object.keys(groupMeta).length > 0 ? groupMeta : undefined,
302
+ metadata:
303
+ groupMeta && Object.keys(groupMeta).length > 0
304
+ ? groupMeta
305
+ : undefined,
263
306
  lineNumber,
264
307
  };
265
308
  result.groups.push(currentGroup);
@@ -310,6 +353,11 @@ export function parseInfra(content: string): ParsedInfra {
310
353
  }
311
354
  continue;
312
355
  }
356
+ warn(
357
+ lineNumber,
358
+ `Invalid tag value '${trimmed}' in tag group '${currentTagGroup.name}'.`
359
+ );
360
+ continue;
313
361
  }
314
362
 
315
363
  // Inside a [Group] but no current node — group properties or component declaration
@@ -333,6 +381,8 @@ export function parseInfra(content: string): ParsedInfra {
333
381
  currentGroup.collapsed = val.toLowerCase() === 'true';
334
382
  continue;
335
383
  }
384
+ // Fall through to component matching — could be a component name
385
+ // that happens to match PROPERTY_RE (e.g., "MyService v2")
336
386
  }
337
387
 
338
388
  const compMatch = trimmed.match(COMPONENT_RE);
@@ -365,9 +415,17 @@ export function parseInfra(content: string): ParsedInfra {
365
415
  if (currentNode && indent > baseIndent) {
366
416
  // Detect deprecated xN fanout syntax
367
417
  const deprecatedFanout = trimmed.match(DEPRECATED_FANOUT_RE);
368
- if (deprecatedFanout && (trimmed.startsWith('->') || trimmed.startsWith('-') || trimmed.startsWith('~'))) {
418
+ if (
419
+ deprecatedFanout &&
420
+ (trimmed.startsWith('->') ||
421
+ trimmed.startsWith('-') ||
422
+ trimmed.startsWith('~'))
423
+ ) {
369
424
  const n = deprecatedFanout[1];
370
- setError(lineNumber, `'x${n}' fanout syntax is no longer supported — use '| fanout: ${n}' instead`);
425
+ setError(
426
+ lineNumber,
427
+ `'x${n}' fanout syntax is no longer supported — use '| fanout: ${n}' instead`
428
+ );
371
429
  continue;
372
430
  }
373
431
 
@@ -375,14 +433,20 @@ export function parseInfra(content: string): ParsedInfra {
375
433
  const asyncSimpleConn = trimmed.match(ASYNC_SIMPLE_CONNECTION_RE);
376
434
  if (asyncSimpleConn) {
377
435
  const targetRaw = asyncSimpleConn[1].trim();
378
- const splitStr = asyncSimpleConn[2];
379
436
  const pipeMeta = extractPipeMetadata(targetRaw);
380
437
  const targetName = pipeMeta.clean || targetRaw;
381
- const split = splitStr ? parseFloat(splitStr)
382
- : pipeMeta.tags.split ? parseFloat(pipeMeta.tags.split) : null;
383
- const fanoutRaw = pipeMeta.tags.fanout ? parseInt(pipeMeta.tags.fanout, 10) : null;
438
+ warnUnparsedPipeMeta(targetName, lineNumber, warn);
439
+ const split = pipeMeta.tags.split
440
+ ? parseFloat(pipeMeta.tags.split)
441
+ : null;
442
+ const fanoutRaw = pipeMeta.tags.fanout
443
+ ? parseInt(pipeMeta.tags.fanout, 10)
444
+ : null;
384
445
  if (fanoutRaw !== null && fanoutRaw < 1) {
385
- warn(lineNumber, `Fan-out multiplier must be at least 1 (got fanout: ${fanoutRaw}). Ignoring.`);
446
+ warn(
447
+ lineNumber,
448
+ `Fan-out multiplier must be at least 1 (got fanout: ${fanoutRaw}). Ignoring.`
449
+ );
386
450
  }
387
451
  const fanout = fanoutRaw !== null && fanoutRaw >= 1 ? fanoutRaw : null;
388
452
  result.edges.push({
@@ -402,14 +466,20 @@ export function parseInfra(content: string): ParsedInfra {
402
466
  if (asyncConnMatch) {
403
467
  const label = asyncConnMatch[1]?.trim() || '';
404
468
  const targetRaw = asyncConnMatch[2].trim();
405
- const splitStr = asyncConnMatch[3];
406
469
  const pipeMeta = extractPipeMetadata(targetRaw);
407
470
  const targetName = pipeMeta.clean || targetRaw;
408
- const split = splitStr ? parseFloat(splitStr)
409
- : pipeMeta.tags.split ? parseFloat(pipeMeta.tags.split) : null;
410
- const fanoutRaw = pipeMeta.tags.fanout ? parseInt(pipeMeta.tags.fanout, 10) : null;
471
+ warnUnparsedPipeMeta(targetName, lineNumber, warn);
472
+ const split = pipeMeta.tags.split
473
+ ? parseFloat(pipeMeta.tags.split)
474
+ : null;
475
+ const fanoutRaw = pipeMeta.tags.fanout
476
+ ? parseInt(pipeMeta.tags.fanout, 10)
477
+ : null;
411
478
  if (fanoutRaw !== null && fanoutRaw < 1) {
412
- warn(lineNumber, `Fan-out multiplier must be at least 1 (got fanout: ${fanoutRaw}). Ignoring.`);
479
+ warn(
480
+ lineNumber,
481
+ `Fan-out multiplier must be at least 1 (got fanout: ${fanoutRaw}). Ignoring.`
482
+ );
413
483
  }
414
484
  const fanout = fanoutRaw !== null && fanoutRaw >= 1 ? fanoutRaw : null;
415
485
 
@@ -437,15 +507,20 @@ export function parseInfra(content: string): ParsedInfra {
437
507
  const simpleConn = trimmed.match(SIMPLE_CONNECTION_RE);
438
508
  if (simpleConn) {
439
509
  const targetRaw = simpleConn[1].trim();
440
- const splitStr = simpleConn[2];
441
- // Parse pipe metadata for fanout/split (and clean target name)
442
510
  const pipeMeta = extractPipeMetadata(targetRaw);
443
511
  const targetName = pipeMeta.clean || targetRaw;
444
- const split = splitStr ? parseFloat(splitStr)
445
- : pipeMeta.tags.split ? parseFloat(pipeMeta.tags.split) : null;
446
- const fanoutRaw = pipeMeta.tags.fanout ? parseInt(pipeMeta.tags.fanout, 10) : null;
512
+ warnUnparsedPipeMeta(targetName, lineNumber, warn);
513
+ const split = pipeMeta.tags.split
514
+ ? parseFloat(pipeMeta.tags.split)
515
+ : null;
516
+ const fanoutRaw = pipeMeta.tags.fanout
517
+ ? parseInt(pipeMeta.tags.fanout, 10)
518
+ : null;
447
519
  if (fanoutRaw !== null && fanoutRaw < 1) {
448
- warn(lineNumber, `Fan-out multiplier must be at least 1 (got fanout: ${fanoutRaw}). Ignoring.`);
520
+ warn(
521
+ lineNumber,
522
+ `Fan-out multiplier must be at least 1 (got fanout: ${fanoutRaw}). Ignoring.`
523
+ );
449
524
  }
450
525
  const fanout = fanoutRaw !== null && fanoutRaw >= 1 ? fanoutRaw : null;
451
526
  result.edges.push({
@@ -465,15 +540,20 @@ export function parseInfra(content: string): ParsedInfra {
465
540
  if (connMatch) {
466
541
  const label = connMatch[1]?.trim() || '';
467
542
  const targetRaw = connMatch[2].trim();
468
- const splitStr = connMatch[3];
469
- // Parse pipe metadata for fanout/split (and clean target name)
470
543
  const pipeMeta = extractPipeMetadata(targetRaw);
471
544
  const targetName = pipeMeta.clean || targetRaw;
472
- const split = splitStr ? parseFloat(splitStr)
473
- : pipeMeta.tags.split ? parseFloat(pipeMeta.tags.split) : null;
474
- const fanoutRaw = pipeMeta.tags.fanout ? parseInt(pipeMeta.tags.fanout, 10) : null;
545
+ warnUnparsedPipeMeta(targetName, lineNumber, warn);
546
+ const split = pipeMeta.tags.split
547
+ ? parseFloat(pipeMeta.tags.split)
548
+ : null;
549
+ const fanoutRaw = pipeMeta.tags.fanout
550
+ ? parseInt(pipeMeta.tags.fanout, 10)
551
+ : null;
475
552
  if (fanoutRaw !== null && fanoutRaw < 1) {
476
- warn(lineNumber, `Fan-out multiplier must be at least 1 (got fanout: ${fanoutRaw}). Ignoring.`);
553
+ warn(
554
+ lineNumber,
555
+ `Fan-out multiplier must be at least 1 (got fanout: ${fanoutRaw}). Ignoring.`
556
+ );
477
557
  }
478
558
  const fanout = fanoutRaw !== null && fanoutRaw >= 1 ? fanoutRaw : null;
479
559
 
@@ -525,7 +605,10 @@ export function parseInfra(content: string): ParsedInfra {
525
605
 
526
606
  // Validate edge-only keys
527
607
  if (EDGE_ONLY_KEYS.has(key) && !currentNode.isEdge) {
528
- warn(lineNumber, `Property '${key}' is only valid on the entry point (Edge/Internet).`);
608
+ warn(
609
+ lineNumber,
610
+ `Property '${key}' is only valid on the entry point (Edge/Internet).`
611
+ );
529
612
  }
530
613
 
531
614
  const value = parsePropertyValue(rawVal);
@@ -534,7 +617,10 @@ export function parseInfra(content: string): ParsedInfra {
534
617
  }
535
618
 
536
619
  // Unknown indented line
537
- warn(lineNumber, `Unexpected line inside component '${currentNode.label}'.`);
620
+ warn(
621
+ lineNumber,
622
+ `Unexpected line inside component '${currentNode.label}'.`
623
+ );
538
624
  continue;
539
625
  }
540
626
 
@@ -592,6 +678,9 @@ export function parseInfra(content: string): ParsedInfra {
592
678
  continue;
593
679
  }
594
680
  }
681
+
682
+ // Catch-all: nothing matched this line
683
+ warn(lineNumber, `Unexpected line: '${trimmed}'.`);
595
684
  }
596
685
 
597
686
  // Flush last open blocks
@@ -661,7 +750,8 @@ export function extractSymbols(docText: string): DiagramSymbols {
661
750
  // Recognize new-style bare options (`key value`) and old-style (`key: value`)
662
751
  const firstLine = parseFirstLine(line);
663
752
  if (firstLine) continue; // chart type line
664
- if (/^(?:direction-tb|animate|no-animate|slo-|default-)/i.test(line)) continue;
753
+ if (/^(?:direction-tb|animate|no-animate|slo-|default-)/i.test(line))
754
+ continue;
665
755
  if (/^[a-z-]+\s*:/i.test(line)) continue; // legacy colon options
666
756
  inMetadata = false;
667
757
  } else {
@@ -671,8 +761,14 @@ export function extractSymbols(docText: string): DiagramSymbols {
671
761
 
672
762
  if (!indented) {
673
763
  // Root-level: tag group declaration, group header, or component
674
- if (/^tag\s/i.test(line)) { inTagGroup = true; continue; }
675
- if (/^tag\s*:/i.test(line)) { inTagGroup = true; continue; } // legacy
764
+ if (/^tag\s/i.test(line)) {
765
+ inTagGroup = true;
766
+ continue;
767
+ }
768
+ if (/^tag\s*:/i.test(line)) {
769
+ inTagGroup = true;
770
+ continue;
771
+ } // legacy
676
772
  inTagGroup = false;
677
773
  if (/^\[/.test(line)) continue; // [Group] header
678
774
  const m = COMPONENT_RE.exec(line);
@@ -687,7 +783,15 @@ export function extractSymbols(docText: string): DiagramSymbols {
687
783
  if (/^\w[\w-]*\s*:/.test(line)) continue; // property (key: value) legacy
688
784
  // New-style property: first token is a known behavior/property key
689
785
  const firstToken = line.split(/\s/)[0].toLowerCase();
690
- if ((INFRA_BEHAVIOR_KEYS.has(firstToken) || EDGE_ONLY_KEYS.has(firstToken) || firstToken === 'description' || firstToken === 'instances' || firstToken === 'collapsed') && /\s/.test(line)) continue;
786
+ if (
787
+ (INFRA_BEHAVIOR_KEYS.has(firstToken) ||
788
+ EDGE_ONLY_KEYS.has(firstToken) ||
789
+ firstToken === 'description' ||
790
+ firstToken === 'instances' ||
791
+ firstToken === 'collapsed') &&
792
+ /\s/.test(line)
793
+ )
794
+ continue;
691
795
  const m = COMPONENT_RE.exec(line);
692
796
  if (m && !entities.includes(m[1]!)) entities.push(m[1]!);
693
797
  }