@diagrammo/dgmo 0.7.3 → 0.8.1

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 (62) hide show
  1. package/AGENTS.md +15 -20
  2. package/README.md +56 -58
  3. package/dist/cli.cjs +188 -181
  4. package/dist/index.cjs +3522 -1072
  5. package/dist/index.cjs.map +1 -1
  6. package/dist/index.d.cts +196 -43
  7. package/dist/index.d.ts +196 -43
  8. package/dist/index.js +3509 -1072
  9. package/dist/index.js.map +1 -1
  10. package/docs/language-reference.md +629 -289
  11. package/package.json +1 -1
  12. package/src/c4/layout.ts +6 -9
  13. package/src/c4/parser.ts +189 -83
  14. package/src/c4/renderer.ts +8 -9
  15. package/src/chart.ts +296 -83
  16. package/src/class/parser.ts +54 -37
  17. package/src/class/renderer.ts +8 -8
  18. package/src/cli.ts +8 -8
  19. package/src/colors.ts +4 -1
  20. package/src/completion.ts +757 -10
  21. package/src/d3.ts +324 -78
  22. package/src/dgmo-router.ts +63 -8
  23. package/src/echarts.ts +735 -241
  24. package/src/er/parser.ts +94 -76
  25. package/src/er/renderer.ts +6 -5
  26. package/src/gantt/parser.ts +144 -69
  27. package/src/gantt/renderer.ts +50 -14
  28. package/src/gantt/types.ts +3 -3
  29. package/src/graph/flowchart-parser.ts +97 -37
  30. package/src/graph/flowchart-renderer.ts +4 -3
  31. package/src/graph/state-parser.ts +50 -31
  32. package/src/graph/state-renderer.ts +4 -3
  33. package/src/index.ts +14 -5
  34. package/src/infra/compute.ts +1 -0
  35. package/src/infra/layout.ts +3 -0
  36. package/src/infra/parser.ts +291 -92
  37. package/src/infra/renderer.ts +172 -30
  38. package/src/infra/types.ts +5 -0
  39. package/src/initiative-status/layout.ts +1 -1
  40. package/src/initiative-status/parser.ts +121 -47
  41. package/src/initiative-status/renderer.ts +42 -23
  42. package/src/initiative-status/types.ts +10 -2
  43. package/src/kanban/parser.ts +60 -37
  44. package/src/kanban/renderer.ts +2 -2
  45. package/src/kanban/types.ts +1 -0
  46. package/src/org/layout.ts +9 -9
  47. package/src/org/parser.ts +39 -40
  48. package/src/org/renderer.ts +5 -6
  49. package/src/org/resolver.ts +26 -19
  50. package/src/render.ts +1 -1
  51. package/src/sequence/parser.ts +304 -95
  52. package/src/sequence/renderer.ts +9 -9
  53. package/src/sitemap/layout.ts +3 -4
  54. package/src/sitemap/parser.ts +57 -49
  55. package/src/sitemap/renderer.ts +6 -7
  56. package/src/utils/arrows.ts +25 -6
  57. package/src/utils/duration.ts +43 -7
  58. package/src/utils/legend-constants.ts +26 -0
  59. package/src/utils/legend-svg.ts +167 -0
  60. package/src/utils/parsing.ts +247 -7
  61. package/src/utils/tag-groups.ts +160 -15
  62. package/src/utils/title-constants.ts +9 -0
@@ -2,12 +2,13 @@
2
2
  // Infra Chart Parser
3
3
  // ============================================================
4
4
  //
5
- // Parses `chart: infra` syntax into a structured InfraModel.
5
+ // Parses `infra [Title]` syntax into a structured InfraModel.
6
6
  // Handles: chart metadata, component blocks with indented properties
7
- // and connections, [Group] containers, tag groups, pipe metadata.
7
+ // and connections, [Group] / # Group containers, tag groups, pipe metadata.
8
8
 
9
9
  import { makeDgmoError, formatDgmoError, suggest } from '../diagnostics';
10
- import { measureIndent } from '../utils/parsing';
10
+ import { measureIndent, normalizeDirection, parseFirstLine, GROUP_HASH_RE, OPTION_NOCOLON_RE } from '../utils/parsing';
11
+ import { matchTagBlockHeading } from '../utils/tag-groups';
11
12
  import type {
12
13
  ParsedInfra,
13
14
  InfraNode,
@@ -20,22 +21,37 @@ import { INFRA_BEHAVIOR_KEYS, EDGE_ONLY_KEYS } from './types';
20
21
  // Regex patterns
21
22
  // ============================================================
22
23
 
23
- // Connection: -label-> Target or -> Target (with optional | split: N% and optional x5 fanout)
24
+ // Connection: -label-> Target or -> Target (with optional | split: N% or pipe metadata)
24
25
  const CONNECTION_RE =
25
- /^-(?:([^-].*?))?->\s+(.+?)(?:(?:\s*\|\s*|\s+)split\s*:?\s*(\d+)%)?\s*(?:x(\d+))?\s*$/;
26
+ /^-(?:([^-].*?))?->\s*(.+?)(?:(?:\s*\|\s*|\s+)split\s*:?\s*(\d+)%)?\s*$/;
26
27
 
27
28
  // Simple connection shorthand: -> Target (no label, no dash prefix needed for edge)
28
29
  const SIMPLE_CONNECTION_RE =
29
- /^->\s+(.+?)(?:(?:\s*\|\s*|\s+)split\s*:?\s*(\d+)%)?\s*(?:x(\d+))?\s*$/;
30
+ /^->\s*(.+?)(?:(?:\s*\|\s*|\s+)split\s*:?\s*(\d+)%)?\s*$/;
30
31
 
31
- // Group declaration: [Group Name]
32
- const GROUP_RE = /^\[([^\]]+)\]$/;
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*$/;
33
35
 
34
- // Tag group declaration: tag: Name alias x
35
- const TAG_GROUP_RE = /^tag\s*:\s*(\w[\w\s]*?)(?:\s+alias\s+(\w+))?\s*$/;
36
+ // Async simple connection shorthand: ~> Target
37
+ const ASYNC_SIMPLE_CONNECTION_RE =
38
+ /^~>\s*(.+?)(?:(?:\s*\|\s*|\s+)split\s*:?\s*(\d+)%)?\s*$/;
36
39
 
37
- // Tag value: Name or Name(color) or Name(color) default
38
- const TAG_VALUE_RE = /^(\w[\w\s]*?)(?:\(([^)]+)\))?(\s+default)?\s*$/;
40
+ // Deprecated xN fanout suffix (e.g. "x5" at end of line)
41
+ const DEPRECATED_FANOUT_RE = /\bx(\d+)\s*$/;
42
+
43
+ // "is a" type declaration: NodeName is a <type>
44
+ const IS_A_RE = /^(.+?)\s+is\s+an?\s+(database|cache|queue|service|gateway|storage|function|network)\s*$/i;
45
+
46
+ // Valid node types for "is a" declarations
47
+ const VALID_NODE_TYPES = new Set(['database', 'cache', 'queue', 'service', 'gateway', 'storage', 'function', 'network']);
48
+
49
+ // Group declaration: [Group Name] with optional pipe metadata
50
+ const GROUP_RE = /^\[([^\]]+)\]\s*(?:\|\s*(.+))?$/;
51
+
52
+ // Tag value: Name or Name(color)
53
+ // Note: `default` keyword removed — first value is the default.
54
+ const TAG_VALUE_RE = /^(\w[\w\s]*?)(?:\(([^)]+)\))?\s*$/;
39
55
 
40
56
  // Component line: ComponentName or ComponentName | t: Backend | env: Prod
41
57
  // Allows hyphens in names (e.g. api-gateway, my-service-v2) — but not at the start.
@@ -44,8 +60,8 @@ const COMPONENT_RE = /^([a-zA-Z_][\w-]*)(.*)$/;
44
60
  // Pipe metadata: | key: value or | k1: v1, k2: v2 (comma-separated)
45
61
  const PIPE_META_RE = /[|,]\s*(\w+)\s*:\s*([^|,]+)/g;
46
62
 
47
- // Property: key: value
48
- const PROPERTY_RE = /^([\w-]+)\s*:\s*(.+)$/;
63
+ // Property: key value (space-separated, no colon)
64
+ const PROPERTY_RE = /^([\w-]+)\s+(.+)$/;
49
65
 
50
66
  // Percentage value: 80% or 99.99%
51
67
  const PERCENT_RE = /^([\d.]+)%$/;
@@ -56,6 +72,12 @@ const RANGE_RE = /^(\d+)-(\d+)$/;
56
72
  // Node names that act as the traffic entry point (edge node)
57
73
  const EDGE_NODE_NAMES = new Set(['edge', 'internet']);
58
74
 
75
+ // Known top-level option keys (space-separated, no colon)
76
+ const TOP_LEVEL_OPTIONS = new Set([
77
+ 'slo-availability', 'slo-p90-latency-ms', 'slo-warning-margin',
78
+ 'default-latency-ms', 'default-uptime', 'default-rps',
79
+ ]);
80
+
59
81
  // ============================================================
60
82
  // Helpers
61
83
  // ============================================================
@@ -182,72 +204,52 @@ export function parseInfra(content: string): ParsedInfra {
182
204
  finishCurrentNode();
183
205
  }
184
206
 
185
- // chart: infra
186
- if (/^chart\s*:/i.test(trimmed)) {
187
- const val = trimmed.replace(/^chart\s*:\s*/i, '').trim().toLowerCase();
188
- if (val !== 'infra') {
189
- setError(lineNumber, `Expected chart type 'infra', got '${val}'`);
207
+ // First line: `infra [Title]` or legacy `chart: infra`
208
+ const firstLineResult = parseFirstLine(trimmed);
209
+ if (firstLineResult) {
210
+ if (firstLineResult.chartType !== 'infra') {
211
+ setError(lineNumber, `Expected chart type 'infra', got '${firstLineResult.chartType}'`);
212
+ }
213
+ if (firstLineResult.title) {
214
+ result.title = firstLineResult.title;
215
+ result.titleLineNumber = lineNumber;
190
216
  }
191
217
  continue;
192
218
  }
193
219
 
194
- // title: ...
195
- if (/^title\s*:/i.test(trimmed)) {
196
- result.title = trimmed.replace(/^title\s*:\s*/i, '').trim();
197
- result.titleLineNumber = lineNumber;
198
- continue;
199
- }
200
-
201
- // direction: LR | TB
202
- if (/^direction\s*:/i.test(trimmed)) {
203
- const dir = trimmed.replace(/^direction\s*:\s*/i, '').trim().toUpperCase();
204
- if (dir === 'LR' || dir === 'TB') {
220
+ // direction LR | TB (also accepts orientation as alias)
221
+ // Supports both `direction LR` (new) and `direction: LR` (legacy)
222
+ if (/^(?:direction|orientation)\s/i.test(trimmed)) {
223
+ const raw = trimmed.replace(/^(?:direction|orientation)\s+/i, '').trim();
224
+ const dir = normalizeDirection(raw);
225
+ if (dir) {
205
226
  result.direction = dir;
206
227
  } else {
207
- warn(lineNumber, `Unknown direction '${dir}'. Expected 'LR' or 'TB'.`);
228
+ warn(lineNumber, `Unknown direction '${raw}'. Expected 'LR', 'TB', 'horizontal', or 'vertical'.`);
208
229
  }
209
230
  continue;
210
231
  }
211
232
 
212
- // animate: on | off
213
- if (/^animate\s*:/i.test(trimmed)) {
214
- result.options.animate = trimmed.replace(/^animate\s*:\s*/i, '').trim().toLowerCase();
215
- continue;
216
- }
217
-
218
- // default-latency-ms: <number>
219
- if (/^default-latency-ms\s*:/i.test(trimmed)) {
220
- result.options['default-latency-ms'] = trimmed.replace(/^default-latency-ms\s*:\s*/i, '').trim();
221
- continue;
222
- }
223
-
224
- // default-uptime: <number>
225
- if (/^default-uptime\s*:/i.test(trimmed)) {
226
- result.options['default-uptime'] = trimmed.replace(/^default-uptime\s*:\s*/i, '').trim();
227
- continue;
228
- }
229
-
230
- // slo-availability: <percentage e.g. 99.9%>
231
- if (/^slo-availability\s*:/i.test(trimmed)) {
232
- result.options['slo-availability'] = trimmed.replace(/^slo-availability\s*:\s*/i, '').trim();
233
+ // animate (default ON) / no-animate
234
+ if (trimmed === 'animate') {
235
+ result.options.animate = 'on';
233
236
  continue;
234
237
  }
235
-
236
- // slo-p90-latency-ms: <number>
237
- if (/^slo-p90-latency-ms\s*:/i.test(trimmed)) {
238
- result.options['slo-p90-latency-ms'] = trimmed.replace(/^slo-p90-latency-ms\s*:\s*/i, '').trim();
238
+ if (trimmed === 'no-animate') {
239
+ result.options.animate = 'off';
239
240
  continue;
240
241
  }
241
242
 
242
- // slo-warning-margin: <percentage e.g. 5%>
243
- if (/^slo-warning-margin\s*:/i.test(trimmed)) {
244
- result.options['slo-warning-margin'] = trimmed.replace(/^slo-warning-margin\s*:\s*/i, '').trim();
243
+ // Top-level options: `key value` (space-separated, no colon)
244
+ const optMatch = trimmed.match(OPTION_NOCOLON_RE);
245
+ if (optMatch && TOP_LEVEL_OPTIONS.has(optMatch[1].toLowerCase())) {
246
+ result.options[optMatch[1].toLowerCase()] = optMatch[2].trim();
245
247
  continue;
246
248
  }
247
249
 
248
- // scenario: Name — deprecated, emit warning and skip block
250
+ // scenario: Name — no longer supported
249
251
  if (/^scenario\s*:/i.test(trimmed)) {
250
- console.warn('[dgmo warn] scenario syntax is deprecated and will be ignored');
252
+ setError(lineNumber, `'scenario:' syntax is no longer supported`);
251
253
  // Skip indented block
252
254
  let si = i + 1;
253
255
  while (si < lines.length) {
@@ -262,32 +264,81 @@ export function parseInfra(content: string): ParsedInfra {
262
264
  continue;
263
265
  }
264
266
 
265
- // tag: GroupName alias x
266
- const tagMatch = trimmed.match(TAG_GROUP_RE);
267
+ // Tag group: `tag Name [alias]` (via shared matchTagBlockHeading)
268
+ const tagMatch = matchTagBlockHeading(trimmed);
267
269
  if (tagMatch) {
268
270
  finishCurrentNode();
269
271
  finishCurrentTagGroup();
270
272
  currentTagGroup = {
271
- name: tagMatch[1].trim(),
272
- alias: tagMatch[2] ?? null,
273
+ name: tagMatch.name,
274
+ alias: tagMatch.alias ?? null,
273
275
  values: [],
274
276
  lineNumber,
275
277
  };
276
278
  continue;
277
279
  }
278
280
 
279
- // [Group Name]
281
+ // # GroupName (alternate group notation)
282
+ const hashGroupMatch = trimmed.match(GROUP_HASH_RE);
283
+ if (hashGroupMatch) {
284
+ finishCurrentNode();
285
+ finishCurrentTagGroup();
286
+ const gLabel = hashGroupMatch[1].trim();
287
+ const gId = groupId(gLabel);
288
+ currentGroup = {
289
+ id: gId,
290
+ label: gLabel,
291
+ metadata: undefined,
292
+ lineNumber,
293
+ };
294
+ result.groups.push(currentGroup);
295
+ continue;
296
+ }
297
+
298
+ // [Group Name] or [Group Name] | t: Engineering
280
299
  const groupMatch = trimmed.match(GROUP_RE);
281
300
  if (groupMatch) {
282
301
  finishCurrentNode();
283
302
  finishCurrentTagGroup();
284
303
  const gLabel = groupMatch[1].trim();
285
304
  const gId = groupId(gLabel);
286
- currentGroup = { id: gId, label: gLabel, lineNumber };
305
+ const groupMeta = groupMatch[2] ? extractPipeMetadata('|' + groupMatch[2]).tags : undefined;
306
+ currentGroup = {
307
+ id: gId,
308
+ label: gLabel,
309
+ metadata: groupMeta && Object.keys(groupMeta).length > 0 ? groupMeta : undefined,
310
+ lineNumber,
311
+ };
287
312
  result.groups.push(currentGroup);
288
313
  continue;
289
314
  }
290
315
 
316
+ // "is a" type declaration: NodeName is a <type>
317
+ const isaMatch = trimmed.match(IS_A_RE);
318
+ if (isaMatch) {
319
+ finishCurrentNode();
320
+ finishCurrentTagGroup();
321
+
322
+ const name = isaMatch[1].trim();
323
+ const nType = isaMatch[2].toLowerCase();
324
+ const id = nodeId(name);
325
+ const isEdge = EDGE_NODE_NAMES.has(id.toLowerCase());
326
+
327
+ currentNode = {
328
+ id,
329
+ label: name,
330
+ properties: [],
331
+ groupId: null,
332
+ tags: {},
333
+ isEdge,
334
+ nodeType: nType,
335
+ lineNumber,
336
+ };
337
+ currentGroup = null;
338
+ baseIndent = 0;
339
+ continue;
340
+ }
341
+
291
342
  // Component at top level (no indent)
292
343
  const compMatch = trimmed.match(COMPONENT_RE);
293
344
  if (compMatch) {
@@ -317,7 +368,7 @@ export function parseInfra(content: string): ParsedInfra {
317
368
 
318
369
  // ---- Indented lines ----
319
370
 
320
- // Tag value inside tag group
371
+ // Tag value inside tag group — first value is the default
321
372
  if (currentTagGroup && indent > 0) {
322
373
  const tvMatch = trimmed.match(TAG_VALUE_RE);
323
374
  if (tvMatch) {
@@ -326,7 +377,8 @@ export function parseInfra(content: string): ParsedInfra {
326
377
  name: valueName,
327
378
  color: tvMatch[2]?.trim(),
328
379
  });
329
- if (tvMatch[3]) {
380
+ // First value is the default
381
+ if (currentTagGroup.values.length === 1) {
330
382
  currentTagGroup.defaultValue = valueName;
331
383
  }
332
384
  continue;
@@ -356,13 +408,41 @@ export function parseInfra(content: string): ParsedInfra {
356
408
  }
357
409
  }
358
410
 
411
+ // "is a" type declaration inside group
412
+ const isaMatchG = trimmed.match(IS_A_RE);
413
+ if (isaMatchG) {
414
+ finishCurrentTagGroup();
415
+ const name = isaMatchG[1].trim();
416
+ const nType = isaMatchG[2].toLowerCase();
417
+ const id = nodeId(name);
418
+ // Cascade group metadata into node tags (node-level overrides later)
419
+ const tags: Record<string, string> = currentGroup.metadata ? { ...currentGroup.metadata } : {};
420
+
421
+ currentNode = {
422
+ id,
423
+ label: name,
424
+ properties: [],
425
+ groupId: currentGroup.id,
426
+ tags,
427
+ isEdge: false,
428
+ nodeType: nType,
429
+ lineNumber,
430
+ };
431
+ baseIndent = indent;
432
+ continue;
433
+ }
434
+
359
435
  const compMatch = trimmed.match(COMPONENT_RE);
360
436
  if (compMatch) {
361
437
  finishCurrentTagGroup();
362
438
  const name = compMatch[1];
363
439
  const rest = compMatch[2] || '';
364
- const { tags } = extractPipeMetadata(rest);
440
+ const { tags: nodeTags } = extractPipeMetadata(rest);
365
441
  const id = nodeId(name);
442
+ // Cascade group metadata into node tags; node-level metadata overrides
443
+ const tags: Record<string, string> = currentGroup.metadata
444
+ ? { ...currentGroup.metadata, ...nodeTags }
445
+ : nodeTags;
366
446
 
367
447
  currentNode = {
368
448
  id,
@@ -380,22 +460,96 @@ export function parseInfra(content: string): ParsedInfra {
380
460
 
381
461
  // Inside a component block — properties and connections
382
462
  if (currentNode && indent > baseIndent) {
383
- // Simple connection: -> Target
463
+ // Detect deprecated xN fanout syntax
464
+ const deprecatedFanout = trimmed.match(DEPRECATED_FANOUT_RE);
465
+ if (deprecatedFanout && (trimmed.startsWith('->') || trimmed.startsWith('-') || trimmed.startsWith('~'))) {
466
+ const n = deprecatedFanout[1];
467
+ setError(lineNumber, `'x${n}' fanout syntax is no longer supported — use '| fanout: ${n}' instead`);
468
+ continue;
469
+ }
470
+
471
+ // Async simple connection: ~> Target
472
+ const asyncSimpleConn = trimmed.match(ASYNC_SIMPLE_CONNECTION_RE);
473
+ if (asyncSimpleConn) {
474
+ const targetRaw = asyncSimpleConn[1].trim();
475
+ const splitStr = asyncSimpleConn[2];
476
+ const pipeMeta = extractPipeMetadata(targetRaw);
477
+ const targetName = pipeMeta.clean || targetRaw;
478
+ const split = splitStr ? parseFloat(splitStr)
479
+ : pipeMeta.tags.split ? parseFloat(pipeMeta.tags.split) : null;
480
+ const fanoutRaw = pipeMeta.tags.fanout ? parseInt(pipeMeta.tags.fanout, 10) : null;
481
+ if (fanoutRaw !== null && fanoutRaw < 1) {
482
+ warn(lineNumber, `Fan-out multiplier must be at least 1 (got fanout: ${fanoutRaw}). Ignoring.`);
483
+ }
484
+ const fanout = fanoutRaw !== null && fanoutRaw >= 1 ? fanoutRaw : null;
485
+ result.edges.push({
486
+ sourceId: currentNode.id,
487
+ targetId: nodeId(targetName),
488
+ label: '',
489
+ async: true,
490
+ split,
491
+ fanout,
492
+ lineNumber,
493
+ });
494
+ continue;
495
+ }
496
+
497
+ // Async labeled connection: ~label~> Target
498
+ const asyncConnMatch = trimmed.match(ASYNC_CONNECTION_RE);
499
+ if (asyncConnMatch) {
500
+ const label = asyncConnMatch[1]?.trim() || '';
501
+ const targetRaw = asyncConnMatch[2].trim();
502
+ const splitStr = asyncConnMatch[3];
503
+ const pipeMeta = extractPipeMetadata(targetRaw);
504
+ const targetName = pipeMeta.clean || targetRaw;
505
+ const split = splitStr ? parseFloat(splitStr)
506
+ : pipeMeta.tags.split ? parseFloat(pipeMeta.tags.split) : null;
507
+ const fanoutRaw = pipeMeta.tags.fanout ? parseInt(pipeMeta.tags.fanout, 10) : null;
508
+ if (fanoutRaw !== null && fanoutRaw < 1) {
509
+ warn(lineNumber, `Fan-out multiplier must be at least 1 (got fanout: ${fanoutRaw}). Ignoring.`);
510
+ }
511
+ const fanout = fanoutRaw !== null && fanoutRaw >= 1 ? fanoutRaw : null;
512
+
513
+ let targetId: string;
514
+ const targetGroupMatch = targetName.match(/^\[([^\]]+)\]/);
515
+ if (targetGroupMatch) {
516
+ targetId = groupId(targetGroupMatch[1]);
517
+ } else {
518
+ targetId = nodeId(targetName);
519
+ }
520
+
521
+ result.edges.push({
522
+ sourceId: currentNode.id,
523
+ targetId,
524
+ label,
525
+ async: true,
526
+ split,
527
+ fanout,
528
+ lineNumber,
529
+ });
530
+ continue;
531
+ }
532
+
533
+ // Simple connection: -> Target or -> Target | fanout: 5
384
534
  const simpleConn = trimmed.match(SIMPLE_CONNECTION_RE);
385
535
  if (simpleConn) {
386
- const targetName = simpleConn[1].trim();
536
+ const targetRaw = simpleConn[1].trim();
387
537
  const splitStr = simpleConn[2];
388
- const fanoutStr = simpleConn[3];
389
- const split = splitStr ? parseFloat(splitStr) : null;
390
- const fanoutRaw = fanoutStr ? parseInt(fanoutStr, 10) : null;
538
+ // Parse pipe metadata for fanout/split (and clean target name)
539
+ const pipeMeta = extractPipeMetadata(targetRaw);
540
+ const targetName = pipeMeta.clean || targetRaw;
541
+ const split = splitStr ? parseFloat(splitStr)
542
+ : pipeMeta.tags.split ? parseFloat(pipeMeta.tags.split) : null;
543
+ const fanoutRaw = pipeMeta.tags.fanout ? parseInt(pipeMeta.tags.fanout, 10) : null;
391
544
  if (fanoutRaw !== null && fanoutRaw < 1) {
392
- warn(lineNumber, `Fan-out multiplier must be at least 1 (got x${fanoutRaw}). Ignoring.`);
545
+ warn(lineNumber, `Fan-out multiplier must be at least 1 (got fanout: ${fanoutRaw}). Ignoring.`);
393
546
  }
394
547
  const fanout = fanoutRaw !== null && fanoutRaw >= 1 ? fanoutRaw : null;
395
548
  result.edges.push({
396
549
  sourceId: currentNode.id,
397
550
  targetId: nodeId(targetName),
398
551
  label: '',
552
+ async: false,
399
553
  split,
400
554
  fanout,
401
555
  lineNumber,
@@ -403,23 +557,26 @@ export function parseInfra(content: string): ParsedInfra {
403
557
  continue;
404
558
  }
405
559
 
406
- // Labeled connection: -label-> Target | split: N%
560
+ // Labeled connection: -label-> Target | split: N%, fanout: 3
407
561
  const connMatch = trimmed.match(CONNECTION_RE);
408
562
  if (connMatch) {
409
563
  const label = connMatch[1]?.trim() || '';
410
- const targetName = connMatch[2].trim();
564
+ const targetRaw = connMatch[2].trim();
411
565
  const splitStr = connMatch[3];
412
- const fanoutStr = connMatch[4];
413
- const split = splitStr ? parseFloat(splitStr) : null;
414
- const fanoutRaw = fanoutStr ? parseInt(fanoutStr, 10) : null;
566
+ // Parse pipe metadata for fanout/split (and clean target name)
567
+ const pipeMeta = extractPipeMetadata(targetRaw);
568
+ const targetName = pipeMeta.clean || targetRaw;
569
+ const split = splitStr ? parseFloat(splitStr)
570
+ : pipeMeta.tags.split ? parseFloat(pipeMeta.tags.split) : null;
571
+ const fanoutRaw = pipeMeta.tags.fanout ? parseInt(pipeMeta.tags.fanout, 10) : null;
415
572
  if (fanoutRaw !== null && fanoutRaw < 1) {
416
- warn(lineNumber, `Fan-out multiplier must be at least 1 (got x${fanoutRaw}). Ignoring.`);
573
+ warn(lineNumber, `Fan-out multiplier must be at least 1 (got fanout: ${fanoutRaw}). Ignoring.`);
417
574
  }
418
575
  const fanout = fanoutRaw !== null && fanoutRaw >= 1 ? fanoutRaw : null;
419
576
 
420
577
  // Target might be a group ref like [API Pods]
421
578
  let targetId: string;
422
- const targetGroupMatch = targetName.match(GROUP_RE);
579
+ const targetGroupMatch = targetName.match(/^\[([^\]]+)\]/);
423
580
  if (targetGroupMatch) {
424
581
  targetId = groupId(targetGroupMatch[1]);
425
582
  } else {
@@ -430,6 +587,7 @@ export function parseInfra(content: string): ParsedInfra {
430
587
  sourceId: currentNode.id,
431
588
  targetId,
432
589
  label,
590
+ async: false,
433
591
  split,
434
592
  fanout,
435
593
  lineNumber,
@@ -437,8 +595,8 @@ export function parseInfra(content: string): ParsedInfra {
437
595
  continue;
438
596
  }
439
597
 
440
- // Empty description: (no value) — silently skip rather than emitting "Unexpected line"
441
- if (/^description\s*:\s*$/i.test(trimmed)) continue;
598
+ // Empty description (no value) — silently skip rather than emitting "Unexpected line"
599
+ if (/^description\s*:?\s*$/i.test(trimmed)) continue;
442
600
 
443
601
  // Property: key: value
444
602
  const propMatch = trimmed.match(PROPERTY_RE);
@@ -480,12 +638,38 @@ export function parseInfra(content: string): ParsedInfra {
480
638
  // Component inside group (same indent as group children)
481
639
  if (currentGroup && indent > 0) {
482
640
  finishCurrentNode();
641
+
642
+ // "is a" type declaration inside group
643
+ const isaMatchG2 = trimmed.match(IS_A_RE);
644
+ if (isaMatchG2) {
645
+ const name = isaMatchG2[1].trim();
646
+ const nType = isaMatchG2[2].toLowerCase();
647
+ const id = nodeId(name);
648
+ const tags: Record<string, string> = currentGroup.metadata ? { ...currentGroup.metadata } : {};
649
+
650
+ currentNode = {
651
+ id,
652
+ label: name,
653
+ properties: [],
654
+ groupId: currentGroup.id,
655
+ tags,
656
+ isEdge: false,
657
+ nodeType: nType,
658
+ lineNumber,
659
+ };
660
+ baseIndent = indent;
661
+ continue;
662
+ }
663
+
483
664
  const compMatch = trimmed.match(COMPONENT_RE);
484
665
  if (compMatch) {
485
666
  const name = compMatch[1];
486
667
  const rest = compMatch[2] || '';
487
- const { tags } = extractPipeMetadata(rest);
668
+ const { tags: nodeTags } = extractPipeMetadata(rest);
488
669
  const id = nodeId(name);
670
+ const tags: Record<string, string> = currentGroup.metadata
671
+ ? { ...currentGroup.metadata, ...nodeTags }
672
+ : nodeTags;
489
673
 
490
674
  currentNode = {
491
675
  id,
@@ -590,25 +774,40 @@ export function extractSymbols(docText: string): DiagramSymbols {
590
774
  const indented = /^\s/.test(rawLine);
591
775
 
592
776
  // Metadata phase: skip until first non-metadata root-level line.
593
- // All lines (including indented) are skipped while inMetadata = true.
777
+ // Metadata includes: `infra [Title]`, `chart: type`, `direction X`, `slo-*`, etc.
594
778
  if (inMetadata) {
595
- if (!indented && !/^[a-z-]+\s*:/i.test(line)) inMetadata = false;
596
- else continue;
779
+ if (!indented) {
780
+ // Recognize new-style bare options (`key value`) and old-style (`key: value`)
781
+ const firstLine = parseFirstLine(line);
782
+ if (firstLine) continue; // chart type line
783
+ if (/^(?:direction|orientation|animate|no-animate|slo-|default-)/i.test(line)) continue;
784
+ if (/^[a-z-]+\s*:/i.test(line)) continue; // legacy colon options
785
+ inMetadata = false;
786
+ } else {
787
+ continue;
788
+ }
597
789
  }
598
790
 
599
791
  if (!indented) {
600
792
  // Root-level: tag group declaration, group header, or component
601
- if (/^tag\s*:/i.test(line)) { inTagGroup = true; continue; }
793
+ if (/^tag\s/i.test(line)) { inTagGroup = true; continue; }
794
+ if (/^tag\s*:/i.test(line)) { inTagGroup = true; continue; } // legacy
602
795
  inTagGroup = false;
603
- if (/^\[/.test(line)) continue; // group header
796
+ if (/^\[/.test(line)) continue; // [Group] header
797
+ if (/^#\s/.test(line)) continue; // # Group header
604
798
  const m = COMPONENT_RE.exec(line);
605
799
  if (m && !entities.includes(m[1]!)) entities.push(m[1]!);
606
800
  } else {
607
801
  // Indented: skip tag values, connections, and properties; extract grouped components
608
802
  if (inTagGroup) continue;
609
803
  if (/^->/.test(line)) continue; // simple connection
804
+ if (/^~>/.test(line)) continue; // async simple connection
610
805
  if (/^-[^>]+-?>/.test(line)) continue; // labeled connection
611
- if (/^\w[\w-]*\s*:/.test(line)) continue; // property (key: value)
806
+ if (/^~[^~]+~>/.test(line)) continue; // async labeled connection
807
+ if (/^\w[\w-]*\s*:/.test(line)) continue; // property (key: value) legacy
808
+ // New-style property: first token is a known behavior/property key
809
+ const firstToken = line.split(/\s/)[0].toLowerCase();
810
+ if ((INFRA_BEHAVIOR_KEYS.has(firstToken) || EDGE_ONLY_KEYS.has(firstToken) || firstToken === 'description' || firstToken === 'instances' || firstToken === 'collapsed') && /\s/.test(line)) continue;
612
811
  const m = COMPONENT_RE.exec(line);
613
812
  if (m && !entities.includes(m[1]!)) entities.push(m[1]!);
614
813
  }