@diagrammo/dgmo 0.4.2 → 0.4.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 (60) hide show
  1. package/.claude/skills/dgmo-chart/SKILL.md +28 -0
  2. package/.claude/skills/dgmo-generate/SKILL.md +1 -0
  3. package/.claude/skills/dgmo-sequence/SKILL.md +24 -1
  4. package/.cursorrules +27 -2
  5. package/.github/copilot-instructions.md +36 -3
  6. package/.windsurfrules +27 -2
  7. package/README.md +12 -3
  8. package/dist/cli.cjs +197 -154
  9. package/dist/index.cjs +8647 -3447
  10. package/dist/index.cjs.map +1 -1
  11. package/dist/index.d.cts +503 -58
  12. package/dist/index.d.ts +503 -58
  13. package/dist/index.js +8379 -3200
  14. package/dist/index.js.map +1 -1
  15. package/docs/ai-integration.md +1 -1
  16. package/docs/language-reference.md +336 -17
  17. package/docs/migration-sequence-color-to-tags.md +98 -0
  18. package/package.json +1 -1
  19. package/src/c4/renderer.ts +1 -20
  20. package/src/class/renderer.ts +1 -11
  21. package/src/cli.ts +40 -0
  22. package/src/d3.ts +92 -2
  23. package/src/dgmo-router.ts +11 -0
  24. package/src/echarts.ts +74 -8
  25. package/src/er/parser.ts +29 -3
  26. package/src/er/renderer.ts +1 -15
  27. package/src/graph/flowchart-parser.ts +7 -30
  28. package/src/graph/flowchart-renderer.ts +62 -69
  29. package/src/graph/layout.ts +5 -0
  30. package/src/graph/state-parser.ts +388 -0
  31. package/src/graph/state-renderer.ts +496 -0
  32. package/src/graph/types.ts +4 -2
  33. package/src/index.ts +42 -1
  34. package/src/infra/compute.ts +1113 -0
  35. package/src/infra/layout.ts +578 -0
  36. package/src/infra/parser.ts +559 -0
  37. package/src/infra/renderer.ts +1553 -0
  38. package/src/infra/roles.ts +60 -0
  39. package/src/infra/serialize.ts +67 -0
  40. package/src/infra/types.ts +221 -0
  41. package/src/infra/validation.ts +192 -0
  42. package/src/initiative-status/layout.ts +56 -61
  43. package/src/initiative-status/renderer.ts +13 -13
  44. package/src/kanban/renderer.ts +1 -24
  45. package/src/org/layout.ts +28 -37
  46. package/src/org/parser.ts +16 -1
  47. package/src/org/renderer.ts +159 -121
  48. package/src/org/resolver.ts +90 -23
  49. package/src/palettes/color-utils.ts +30 -0
  50. package/src/render.ts +2 -0
  51. package/src/sequence/parser.ts +202 -42
  52. package/src/sequence/renderer.ts +576 -113
  53. package/src/sequence/tag-resolution.ts +163 -0
  54. package/src/sharing.ts +8 -0
  55. package/src/sitemap/collapse.ts +187 -0
  56. package/src/sitemap/layout.ts +738 -0
  57. package/src/sitemap/parser.ts +489 -0
  58. package/src/sitemap/renderer.ts +774 -0
  59. package/src/sitemap/types.ts +42 -0
  60. package/src/utils/tag-groups.ts +119 -0
@@ -0,0 +1,559 @@
1
+ // ============================================================
2
+ // Infra Chart Parser
3
+ // ============================================================
4
+ //
5
+ // Parses `chart: infra` syntax into a structured InfraModel.
6
+ // Handles: chart metadata, component blocks with indented properties
7
+ // and connections, [Group] containers, tag groups, pipe metadata.
8
+
9
+ import { makeDgmoError, formatDgmoError, suggest } from '../diagnostics';
10
+ import { measureIndent } from '../utils/parsing';
11
+ import type {
12
+ ParsedInfra,
13
+ InfraNode,
14
+ InfraEdge,
15
+ InfraGroup,
16
+ InfraTagGroup,
17
+ InfraTagValue,
18
+ InfraProperty,
19
+ } from './types';
20
+ import { INFRA_BEHAVIOR_KEYS, EDGE_ONLY_KEYS } from './types';
21
+
22
+ // ============================================================
23
+ // Regex patterns
24
+ // ============================================================
25
+
26
+ // Connection: -label-> Target or -> Target (with optional | split: N%)
27
+ const CONNECTION_RE =
28
+ /^-(?:([^-].*?))?->\s+(.+?)(?:(?:\s*\|\s*|\s+)split\s*:?\s*(\d+)%)?\s*$/;
29
+
30
+ // Simple connection shorthand: -> Target (no label, no dash prefix needed for edge)
31
+ const SIMPLE_CONNECTION_RE =
32
+ /^->\s+(.+?)(?:(?:\s*\|\s*|\s+)split\s*:?\s*(\d+)%)?\s*$/;
33
+
34
+ // Group declaration: [Group Name]
35
+ const GROUP_RE = /^\[([^\]]+)\]$/;
36
+
37
+ // Tag group declaration: tag: Name alias x
38
+ const TAG_GROUP_RE = /^tag\s*:\s*(\w[\w\s]*?)(?:\s+alias\s+(\w+))?\s*$/;
39
+
40
+ // Tag value: Name or Name(color) or Name(color) default
41
+ const TAG_VALUE_RE = /^(\w[\w\s]*?)(?:\(([^)]+)\))?(\s+default)?\s*$/;
42
+
43
+ // Component line: ComponentName or ComponentName | t: Backend | env: Prod
44
+ const COMPONENT_RE = /^([a-zA-Z_][\w]*)(.*)$/;
45
+
46
+ // Pipe metadata: | key: value or | k1: v1, k2: v2 (comma-separated)
47
+ const PIPE_META_RE = /[|,]\s*(\w+)\s*:\s*([^|,]+)/g;
48
+
49
+ // Property: key: value
50
+ const PROPERTY_RE = /^([\w-]+)\s*:\s*(.+)$/;
51
+
52
+ // Percentage value: 80% or 99.99%
53
+ const PERCENT_RE = /^([\d.]+)%$/;
54
+
55
+ // Range value: N-M (for instances)
56
+ const RANGE_RE = /^(\d+)-(\d+)$/;
57
+
58
+ // Node names that act as the traffic entry point (edge node)
59
+ const EDGE_NODE_NAMES = new Set(['edge', 'internet']);
60
+
61
+ // ============================================================
62
+ // Helpers
63
+ // ============================================================
64
+
65
+ function nodeId(name: string): string {
66
+ return name.trim();
67
+ }
68
+
69
+ function groupId(name: string): string {
70
+ return `[${name.trim()}]`;
71
+ }
72
+
73
+ function parsePropertyValue(raw: string): string | number {
74
+ const pct = raw.match(PERCENT_RE);
75
+ if (pct) return parseFloat(pct[1]);
76
+
77
+ const num = parseFloat(raw);
78
+ if (!isNaN(num) && String(num) === raw.trim()) return num;
79
+
80
+ return raw.trim();
81
+ }
82
+
83
+ function extractPipeMetadata(
84
+ rest: string,
85
+ ): { tags: Record<string, string>; clean: string } {
86
+ const tags: Record<string, string> = {};
87
+ let clean = rest;
88
+ let match: RegExpExecArray | null;
89
+ const re = new RegExp(PIPE_META_RE.source, 'g');
90
+ while ((match = re.exec(rest)) !== null) {
91
+ tags[match[1].trim()] = match[2].trim();
92
+ clean = clean.replace(match[0], '');
93
+ }
94
+ return { tags, clean: clean.trim() };
95
+ }
96
+
97
+ // ============================================================
98
+ // Parser
99
+ // ============================================================
100
+
101
+ export function parseInfra(content: string): ParsedInfra {
102
+ const lines = content.split('\n');
103
+ const result: ParsedInfra = {
104
+ type: 'infra',
105
+ title: null,
106
+ titleLineNumber: null,
107
+ direction: 'LR',
108
+ nodes: [],
109
+ edges: [],
110
+ groups: [],
111
+ tagGroups: [],
112
+ scenarios: [],
113
+ options: {},
114
+ diagnostics: [],
115
+ error: null,
116
+ };
117
+
118
+ const nodeMap = new Map<string, InfraNode>();
119
+ const edgeNodeId = 'edge';
120
+
121
+ const setError = (line: number, message: string) => {
122
+ const diag = makeDgmoError(line, message);
123
+ result.diagnostics.push(diag);
124
+ if (!result.error) result.error = formatDgmoError(diag);
125
+ };
126
+
127
+ const warn = (line: number, message: string) => {
128
+ result.diagnostics.push(makeDgmoError(line, message, 'warning'));
129
+ };
130
+
131
+ // Track parser state
132
+ let currentNode: InfraNode | null = null;
133
+ let currentGroup: InfraGroup | null = null;
134
+ let currentTagGroup: InfraTagGroup | null = null;
135
+ let baseIndent = 0; // indent of the current component line
136
+
137
+ function finishCurrentNode() {
138
+ if (currentNode && !nodeMap.has(currentNode.id)) {
139
+ // Validate mutual exclusion: concurrency vs instances/max-rps
140
+ const keys = new Set(currentNode.properties.map((p) => p.key));
141
+ if (keys.has('concurrency') && (keys.has('instances') || keys.has('max-rps'))) {
142
+ const conflicting = [keys.has('instances') ? 'instances' : '', keys.has('max-rps') ? 'max-rps' : '']
143
+ .filter(Boolean)
144
+ .join(', ');
145
+ warn(
146
+ currentNode.lineNumber,
147
+ `'concurrency' (serverless) is mutually exclusive with ${conflicting}. Serverless nodes scale via concurrency, not instances.`,
148
+ );
149
+ }
150
+ // Validate mutual exclusion: buffer (queue) vs max-rps (service)
151
+ if (keys.has('buffer') && keys.has('max-rps')) {
152
+ warn(
153
+ currentNode.lineNumber,
154
+ `'buffer' (queue) and 'max-rps' (service) represent different capacity models. A queue buffers messages; a service processes them.`,
155
+ );
156
+ }
157
+ nodeMap.set(currentNode.id, currentNode);
158
+ result.nodes.push(currentNode);
159
+ }
160
+ currentNode = null;
161
+ }
162
+
163
+ function finishCurrentTagGroup() {
164
+ if (currentTagGroup) {
165
+ result.tagGroups.push(currentTagGroup);
166
+ }
167
+ currentTagGroup = null;
168
+ }
169
+
170
+ for (let i = 0; i < lines.length; i++) {
171
+ const raw = lines[i];
172
+ const lineNumber = i + 1;
173
+ const trimmed = raw.trim();
174
+ const indent = measureIndent(raw);
175
+
176
+ // Skip empty lines and comments
177
+ if (!trimmed || trimmed.startsWith('//')) continue;
178
+
179
+ // Skip markdown section headers
180
+ if (/^#{2,}\s+/.test(trimmed)) continue;
181
+
182
+ // ---- Top-level metadata (no indent) ----
183
+ if (indent === 0) {
184
+ // Close any open blocks
185
+ if (indent === 0 && currentNode && !trimmed.startsWith('-')) {
186
+ finishCurrentNode();
187
+ }
188
+
189
+ // chart: infra
190
+ if (/^chart\s*:/i.test(trimmed)) {
191
+ const val = trimmed.replace(/^chart\s*:\s*/i, '').trim().toLowerCase();
192
+ if (val !== 'infra') {
193
+ setError(lineNumber, `Expected chart type 'infra', got '${val}'`);
194
+ }
195
+ continue;
196
+ }
197
+
198
+ // title: ...
199
+ if (/^title\s*:/i.test(trimmed)) {
200
+ result.title = trimmed.replace(/^title\s*:\s*/i, '').trim();
201
+ result.titleLineNumber = lineNumber;
202
+ continue;
203
+ }
204
+
205
+ // direction: LR | TB
206
+ if (/^direction\s*:/i.test(trimmed)) {
207
+ const dir = trimmed.replace(/^direction\s*:\s*/i, '').trim().toUpperCase();
208
+ if (dir === 'LR' || dir === 'TB') {
209
+ result.direction = dir;
210
+ } else {
211
+ warn(lineNumber, `Unknown direction '${dir}'. Expected 'LR' or 'TB'.`);
212
+ }
213
+ continue;
214
+ }
215
+
216
+ // animate: on | off
217
+ if (/^animate\s*:/i.test(trimmed)) {
218
+ result.options.animate = trimmed.replace(/^animate\s*:\s*/i, '').trim().toLowerCase();
219
+ continue;
220
+ }
221
+
222
+ // default-latency-ms: <number>
223
+ if (/^default-latency-ms\s*:/i.test(trimmed)) {
224
+ result.options['default-latency-ms'] = trimmed.replace(/^default-latency-ms\s*:\s*/i, '').trim();
225
+ continue;
226
+ }
227
+
228
+ // default-uptime: <number>
229
+ if (/^default-uptime\s*:/i.test(trimmed)) {
230
+ result.options['default-uptime'] = trimmed.replace(/^default-uptime\s*:\s*/i, '').trim();
231
+ continue;
232
+ }
233
+
234
+ // scenario: Name
235
+ if (/^scenario\s*:/i.test(trimmed)) {
236
+ finishCurrentNode();
237
+ finishCurrentTagGroup();
238
+ currentGroup = null;
239
+ const scenarioName = trimmed.replace(/^scenario\s*:\s*/i, '').trim();
240
+ const scenario: import('./types').InfraScenario = {
241
+ name: scenarioName,
242
+ overrides: {},
243
+ lineNumber,
244
+ };
245
+ // Parse indented block for scenario overrides
246
+ let scenarioNodeId: string | null = null;
247
+ let si = i + 1;
248
+ while (si < lines.length) {
249
+ const sLine = lines[si];
250
+ const sTrimmed = sLine.trim();
251
+ if (!sTrimmed || sTrimmed.startsWith('#')) { si++; continue; }
252
+ const sIndent = sLine.length - sLine.trimStart().length;
253
+ if (sIndent === 0) break; // back to top level
254
+
255
+ if (sIndent <= 2) {
256
+ // Node reference (e.g., " edge" or " API")
257
+ scenarioNodeId = nodeId(sTrimmed.replace(/\|.*$/, '').trim());
258
+ if (!scenario.overrides[scenarioNodeId]) {
259
+ scenario.overrides[scenarioNodeId] = {};
260
+ }
261
+ } else if (scenarioNodeId) {
262
+ // Property override (e.g., " rps: 10000")
263
+ const pm = sTrimmed.match(PROPERTY_RE);
264
+ if (pm) {
265
+ const key = pm[1].toLowerCase();
266
+ const val = parsePropertyValue(pm[2].trim());
267
+ scenario.overrides[scenarioNodeId][key] = val;
268
+ }
269
+ }
270
+ si++;
271
+ }
272
+ i = si - 1; // advance past scenario block
273
+ result.scenarios.push(scenario);
274
+ continue;
275
+ }
276
+
277
+ // tag: GroupName alias x
278
+ const tagMatch = trimmed.match(TAG_GROUP_RE);
279
+ if (tagMatch) {
280
+ finishCurrentNode();
281
+ finishCurrentTagGroup();
282
+ currentTagGroup = {
283
+ name: tagMatch[1].trim(),
284
+ alias: tagMatch[2] ?? null,
285
+ values: [],
286
+ lineNumber,
287
+ };
288
+ continue;
289
+ }
290
+
291
+ // [Group Name]
292
+ const groupMatch = trimmed.match(GROUP_RE);
293
+ if (groupMatch) {
294
+ finishCurrentNode();
295
+ finishCurrentTagGroup();
296
+ const gLabel = groupMatch[1].trim();
297
+ const gId = groupId(gLabel);
298
+ currentGroup = { id: gId, label: gLabel, lineNumber };
299
+ result.groups.push(currentGroup);
300
+ continue;
301
+ }
302
+
303
+ // Component at top level (no indent)
304
+ const compMatch = trimmed.match(COMPONENT_RE);
305
+ if (compMatch) {
306
+ finishCurrentNode();
307
+ finishCurrentTagGroup();
308
+
309
+ const name = compMatch[1];
310
+ const rest = compMatch[2] || '';
311
+ const { tags } = extractPipeMetadata(rest);
312
+ const id = nodeId(name);
313
+ const isEdge = EDGE_NODE_NAMES.has(id.toLowerCase());
314
+
315
+ currentNode = {
316
+ id,
317
+ label: name,
318
+ properties: [],
319
+ groupId: null,
320
+ tags,
321
+ isEdge,
322
+ lineNumber,
323
+ };
324
+ currentGroup = null;
325
+ baseIndent = 0;
326
+ continue;
327
+ }
328
+ }
329
+
330
+ // ---- Indented lines ----
331
+
332
+ // Tag value inside tag group
333
+ if (currentTagGroup && indent > 0) {
334
+ const tvMatch = trimmed.match(TAG_VALUE_RE);
335
+ if (tvMatch) {
336
+ const valueName = tvMatch[1].trim();
337
+ currentTagGroup.values.push({
338
+ name: valueName,
339
+ color: tvMatch[2]?.trim(),
340
+ });
341
+ if (tvMatch[3]) {
342
+ currentTagGroup.defaultValue = valueName;
343
+ }
344
+ continue;
345
+ }
346
+ }
347
+
348
+ // Inside a [Group] but no current node — group properties or component declaration
349
+ if (currentGroup && !currentNode && indent > 0) {
350
+ // Group-level properties (instances, collapsed)
351
+ const propMatch = trimmed.match(PROPERTY_RE);
352
+ if (propMatch) {
353
+ const key = propMatch[1].toLowerCase();
354
+ const val = propMatch[2].trim();
355
+ if (key === 'instances') {
356
+ const rangeM = val.match(RANGE_RE);
357
+ if (rangeM) {
358
+ currentGroup.instances = val;
359
+ } else {
360
+ const num = parseInt(val, 10);
361
+ if (!isNaN(num)) currentGroup.instances = num;
362
+ }
363
+ continue;
364
+ }
365
+ if (key === 'collapsed') {
366
+ currentGroup.collapsed = val.toLowerCase() === 'true';
367
+ continue;
368
+ }
369
+ }
370
+
371
+ const compMatch = trimmed.match(COMPONENT_RE);
372
+ if (compMatch) {
373
+ finishCurrentTagGroup();
374
+ const name = compMatch[1];
375
+ const rest = compMatch[2] || '';
376
+ const { tags } = extractPipeMetadata(rest);
377
+ const id = nodeId(name);
378
+
379
+ currentNode = {
380
+ id,
381
+ label: name,
382
+ properties: [],
383
+ groupId: currentGroup.id,
384
+ tags,
385
+ isEdge: false,
386
+ lineNumber,
387
+ };
388
+ baseIndent = indent;
389
+ continue;
390
+ }
391
+ }
392
+
393
+ // Inside a component block — properties and connections
394
+ if (currentNode && indent > baseIndent) {
395
+ // Simple connection: -> Target
396
+ const simpleConn = trimmed.match(SIMPLE_CONNECTION_RE);
397
+ if (simpleConn) {
398
+ const targetName = simpleConn[1].trim();
399
+ const splitStr = simpleConn[2];
400
+ const split = splitStr ? parseFloat(splitStr) : null;
401
+ result.edges.push({
402
+ sourceId: currentNode.id,
403
+ targetId: nodeId(targetName),
404
+ label: '',
405
+ split,
406
+ lineNumber,
407
+ });
408
+ continue;
409
+ }
410
+
411
+ // Labeled connection: -label-> Target | split: N%
412
+ const connMatch = trimmed.match(CONNECTION_RE);
413
+ if (connMatch) {
414
+ const label = connMatch[1]?.trim() || '';
415
+ const targetName = connMatch[2].trim();
416
+ const splitStr = connMatch[3];
417
+ const split = splitStr ? parseFloat(splitStr) : null;
418
+
419
+ // Target might be a group ref like [API Pods]
420
+ let targetId: string;
421
+ const targetGroupMatch = targetName.match(GROUP_RE);
422
+ if (targetGroupMatch) {
423
+ targetId = groupId(targetGroupMatch[1]);
424
+ } else {
425
+ targetId = nodeId(targetName);
426
+ }
427
+
428
+ result.edges.push({
429
+ sourceId: currentNode.id,
430
+ targetId,
431
+ label,
432
+ split,
433
+ lineNumber,
434
+ });
435
+ continue;
436
+ }
437
+
438
+ // Property: key: value
439
+ const propMatch = trimmed.match(PROPERTY_RE);
440
+ if (propMatch) {
441
+ const key = propMatch[1].toLowerCase();
442
+ const rawVal = propMatch[2].trim();
443
+
444
+ // Validate property key
445
+ if (!INFRA_BEHAVIOR_KEYS.has(key) && !EDGE_ONLY_KEYS.has(key)) {
446
+ const allKeys = [...INFRA_BEHAVIOR_KEYS, ...EDGE_ONLY_KEYS];
447
+ let msg = `Unknown property '${key}'.`;
448
+ const hint = suggest(key, allKeys);
449
+ if (hint) msg += ` ${hint}`;
450
+ warn(lineNumber, msg);
451
+ }
452
+
453
+ // Validate edge-only keys
454
+ if (EDGE_ONLY_KEYS.has(key) && !currentNode.isEdge) {
455
+ warn(lineNumber, `Property '${key}' is only valid on the entry point (Edge/Internet).`);
456
+ }
457
+
458
+ const value = parsePropertyValue(rawVal);
459
+ currentNode.properties.push({ key, value, lineNumber });
460
+ continue;
461
+ }
462
+
463
+ // Unknown indented line
464
+ warn(lineNumber, `Unexpected line inside component '${currentNode.label}'.`);
465
+ continue;
466
+ }
467
+
468
+ // Component inside group (same indent as group children)
469
+ if (currentGroup && indent > 0) {
470
+ finishCurrentNode();
471
+ const compMatch = trimmed.match(COMPONENT_RE);
472
+ if (compMatch) {
473
+ const name = compMatch[1];
474
+ const rest = compMatch[2] || '';
475
+ const { tags } = extractPipeMetadata(rest);
476
+ const id = nodeId(name);
477
+
478
+ currentNode = {
479
+ id,
480
+ label: name,
481
+ properties: [],
482
+ groupId: currentGroup.id,
483
+ tags,
484
+ isEdge: false,
485
+ lineNumber,
486
+ };
487
+ baseIndent = indent;
488
+ continue;
489
+ }
490
+ }
491
+
492
+ // If we reach here and indent is 0, try as a top-level component
493
+ if (indent === 0) {
494
+ const compMatch = trimmed.match(COMPONENT_RE);
495
+ if (compMatch) {
496
+ finishCurrentNode();
497
+ finishCurrentTagGroup();
498
+ currentGroup = null;
499
+
500
+ const name = compMatch[1];
501
+ const rest = compMatch[2] || '';
502
+ const { tags } = extractPipeMetadata(rest);
503
+ const id = nodeId(name);
504
+
505
+ currentNode = {
506
+ id,
507
+ label: name,
508
+ properties: [],
509
+ groupId: null,
510
+ tags,
511
+ isEdge: EDGE_NODE_NAMES.has(id.toLowerCase()),
512
+ lineNumber,
513
+ };
514
+ baseIndent = 0;
515
+ continue;
516
+ }
517
+ }
518
+ }
519
+
520
+ // Flush last open blocks
521
+ finishCurrentNode();
522
+ finishCurrentTagGroup();
523
+
524
+ // Ensure referenced targets exist (create stub nodes for forward references)
525
+ for (const edge of result.edges) {
526
+ if (!nodeMap.has(edge.targetId)) {
527
+ // Check if target is a group
528
+ const isGroup = result.groups.some((g) => g.id === edge.targetId);
529
+ if (!isGroup) {
530
+ // Create a stub node for forward-referenced targets
531
+ const stub: InfraNode = {
532
+ id: edge.targetId,
533
+ label: edge.targetId,
534
+ properties: [],
535
+ groupId: null,
536
+ tags: {},
537
+ isEdge: false,
538
+ lineNumber: edge.lineNumber,
539
+ };
540
+ nodeMap.set(stub.id, stub);
541
+ result.nodes.push(stub);
542
+ }
543
+ }
544
+ }
545
+
546
+ // Inject default tag values into nodes that don't have one
547
+ for (const tg of result.tagGroups) {
548
+ if (!tg.defaultValue) continue;
549
+ const key = (tg.alias ?? tg.name).toLowerCase();
550
+ for (const node of result.nodes) {
551
+ if (node.isEdge) continue;
552
+ if (!(key in node.tags)) {
553
+ node.tags[key] = tg.defaultValue;
554
+ }
555
+ }
556
+ }
557
+
558
+ return result;
559
+ }