@diagrammo/dgmo 0.4.2 → 0.4.3
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.
- package/.claude/skills/dgmo-chart/SKILL.md +28 -0
- package/.claude/skills/dgmo-generate/SKILL.md +1 -0
- package/.claude/skills/dgmo-sequence/SKILL.md +24 -1
- package/.cursorrules +27 -2
- package/.github/copilot-instructions.md +36 -3
- package/.windsurfrules +27 -2
- package/README.md +12 -3
- package/dist/cli.cjs +197 -154
- package/dist/index.cjs +8371 -3200
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +502 -58
- package/dist/index.d.ts +502 -58
- package/dist/index.js +8594 -3444
- package/dist/index.js.map +1 -1
- package/docs/ai-integration.md +1 -1
- package/docs/language-reference.md +336 -17
- package/docs/migration-sequence-color-to-tags.md +98 -0
- package/package.json +1 -1
- package/src/c4/renderer.ts +1 -20
- package/src/class/renderer.ts +1 -11
- package/src/cli.ts +40 -0
- package/src/d3.ts +92 -2
- package/src/dgmo-router.ts +11 -0
- package/src/echarts.ts +74 -8
- package/src/er/parser.ts +29 -3
- package/src/er/renderer.ts +1 -15
- package/src/graph/flowchart-parser.ts +7 -30
- package/src/graph/flowchart-renderer.ts +62 -69
- package/src/graph/layout.ts +5 -0
- package/src/graph/state-parser.ts +388 -0
- package/src/graph/state-renderer.ts +496 -0
- package/src/graph/types.ts +4 -2
- package/src/index.ts +42 -1
- package/src/infra/compute.ts +1113 -0
- package/src/infra/layout.ts +575 -0
- package/src/infra/parser.ts +559 -0
- package/src/infra/renderer.ts +1509 -0
- package/src/infra/roles.ts +60 -0
- package/src/infra/serialize.ts +67 -0
- package/src/infra/types.ts +221 -0
- package/src/infra/validation.ts +192 -0
- package/src/initiative-status/layout.ts +56 -61
- package/src/initiative-status/renderer.ts +13 -13
- package/src/kanban/renderer.ts +1 -24
- package/src/org/layout.ts +28 -37
- package/src/org/parser.ts +16 -1
- package/src/org/renderer.ts +159 -121
- package/src/org/resolver.ts +90 -23
- package/src/palettes/color-utils.ts +30 -0
- package/src/render.ts +2 -0
- package/src/sequence/parser.ts +202 -42
- package/src/sequence/renderer.ts +576 -113
- package/src/sequence/tag-resolution.ts +163 -0
- package/src/sitemap/collapse.ts +187 -0
- package/src/sitemap/layout.ts +738 -0
- package/src/sitemap/parser.ts +489 -0
- package/src/sitemap/renderer.ts +774 -0
- package/src/sitemap/types.ts +42 -0
- 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
|
|
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
|
+
}
|