@diagrammo/dgmo 0.2.22 → 0.2.24
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/dist/cli.cjs +119 -113
- package/dist/index.cjs +6311 -2344
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +263 -2
- package/dist/index.d.ts +263 -2
- package/dist/index.js +6269 -2320
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/c4/layout.ts +2137 -0
- package/src/c4/parser.ts +809 -0
- package/src/c4/renderer.ts +1916 -0
- package/src/c4/types.ts +86 -0
- package/src/class/renderer.ts +2 -2
- package/src/cli.ts +54 -10
- package/src/d3.ts +148 -10
- package/src/dgmo-router.ts +13 -0
- package/src/er/renderer.ts +2 -2
- package/src/graph/flowchart-renderer.ts +1 -1
- package/src/index.ts +54 -0
- package/src/initiative-status/layout.ts +217 -0
- package/src/initiative-status/parser.ts +246 -0
- package/src/initiative-status/renderer.ts +837 -0
- package/src/initiative-status/types.ts +43 -0
- package/src/kanban/renderer.ts +20 -2
- package/src/org/layout.ts +34 -16
- package/src/org/renderer.ts +31 -10
- package/src/org/resolver.ts +3 -1
- package/src/render.ts +9 -1
- package/src/sequence/participant-inference.ts +1 -0
- package/src/sequence/renderer.ts +12 -6
package/src/c4/parser.ts
ADDED
|
@@ -0,0 +1,809 @@
|
|
|
1
|
+
// ============================================================
|
|
2
|
+
// C4 Architecture Diagram — Parser
|
|
3
|
+
// ============================================================
|
|
4
|
+
|
|
5
|
+
import { resolveColor } from '../colors';
|
|
6
|
+
import type { PaletteColors } from '../palettes';
|
|
7
|
+
import type { DgmoError } from '../diagnostics';
|
|
8
|
+
import { makeDgmoError, formatDgmoError, suggest } from '../diagnostics';
|
|
9
|
+
import type { OrgTagGroup } from '../org/parser';
|
|
10
|
+
import { inferParticipantType } from '../sequence/participant-inference';
|
|
11
|
+
import type {
|
|
12
|
+
ParsedC4,
|
|
13
|
+
C4Element,
|
|
14
|
+
C4ElementType,
|
|
15
|
+
C4Shape,
|
|
16
|
+
C4ArrowType,
|
|
17
|
+
C4Relationship,
|
|
18
|
+
C4Group,
|
|
19
|
+
C4DeploymentNode,
|
|
20
|
+
} from './types';
|
|
21
|
+
|
|
22
|
+
// ============================================================
|
|
23
|
+
// Regex patterns
|
|
24
|
+
// ============================================================
|
|
25
|
+
|
|
26
|
+
const CHART_TYPE_RE = /^chart\s*:\s*(.+)/i;
|
|
27
|
+
const TITLE_RE = /^title\s*:\s*(.+)/i;
|
|
28
|
+
const OPTION_RE = /^([a-z][a-z0-9-]*)\s*:\s*(.+)$/i;
|
|
29
|
+
const GROUP_HEADING_RE =
|
|
30
|
+
/^##\s+(.+?)(?:\s+alias\s+(\w+))?(?:\s*\(([^)]+)\))?\s*$/;
|
|
31
|
+
const COLOR_SUFFIX_RE = /\(([^)]+)\)\s*$/;
|
|
32
|
+
const CONTAINER_RE = /^\[([^\]]+)\]$/;
|
|
33
|
+
|
|
34
|
+
/** Matches element declarations: `person Name`, `system Name | k: v` */
|
|
35
|
+
const ELEMENT_RE = /^(person|system|container|component)\s+(.+)$/i;
|
|
36
|
+
|
|
37
|
+
/** Matches `is a <shape>` in the element name portion */
|
|
38
|
+
const IS_A_RE = /\s+is\s+a(?:n)?\s+(\w+)\s*$/i;
|
|
39
|
+
|
|
40
|
+
/** Matches relationship arrows: `->`, `~>`, `<->`, `<~>` */
|
|
41
|
+
const RELATIONSHIP_RE = /^(<?-?>|<?~?>)\s+(.+)$/;
|
|
42
|
+
|
|
43
|
+
/** Matches section headers: `containers:`, `components:`, `deployment:` */
|
|
44
|
+
const SECTION_HEADER_RE = /^(containers|components|deployment)\s*:\s*$/i;
|
|
45
|
+
|
|
46
|
+
/** Matches `container X` references inside deployment nodes */
|
|
47
|
+
const CONTAINER_REF_RE = /^container\s+(.+)$/i;
|
|
48
|
+
|
|
49
|
+
/** Matches indented metadata: `key: value` */
|
|
50
|
+
const METADATA_RE = /^([^:]+):\s*(.+)$/;
|
|
51
|
+
|
|
52
|
+
// ============================================================
|
|
53
|
+
// Helpers
|
|
54
|
+
// ============================================================
|
|
55
|
+
|
|
56
|
+
function measureIndent(line: string): number {
|
|
57
|
+
let indent = 0;
|
|
58
|
+
for (const ch of line) {
|
|
59
|
+
if (ch === ' ') indent++;
|
|
60
|
+
else if (ch === '\t') indent += 4;
|
|
61
|
+
else break;
|
|
62
|
+
}
|
|
63
|
+
return indent;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function extractColor(
|
|
67
|
+
label: string,
|
|
68
|
+
palette?: PaletteColors,
|
|
69
|
+
): { label: string; color?: string } {
|
|
70
|
+
const m = label.match(COLOR_SUFFIX_RE);
|
|
71
|
+
if (!m) return { label };
|
|
72
|
+
const colorName = m[1].trim();
|
|
73
|
+
return {
|
|
74
|
+
label: label.substring(0, m.index!).trim(),
|
|
75
|
+
color: resolveColor(colorName, palette),
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const VALID_ELEMENT_TYPES = new Set<string>([
|
|
80
|
+
'person',
|
|
81
|
+
'system',
|
|
82
|
+
'container',
|
|
83
|
+
'component',
|
|
84
|
+
]);
|
|
85
|
+
|
|
86
|
+
const VALID_SHAPES = new Set<string>([
|
|
87
|
+
'default',
|
|
88
|
+
'database',
|
|
89
|
+
'cache',
|
|
90
|
+
'queue',
|
|
91
|
+
'cloud',
|
|
92
|
+
'external',
|
|
93
|
+
]);
|
|
94
|
+
|
|
95
|
+
const ALL_CHART_TYPES = [
|
|
96
|
+
'c4',
|
|
97
|
+
'org',
|
|
98
|
+
'class',
|
|
99
|
+
'flowchart',
|
|
100
|
+
'sequence',
|
|
101
|
+
'er',
|
|
102
|
+
'bar',
|
|
103
|
+
'line',
|
|
104
|
+
'pie',
|
|
105
|
+
'scatter',
|
|
106
|
+
'sankey',
|
|
107
|
+
'venn',
|
|
108
|
+
'timeline',
|
|
109
|
+
'arc',
|
|
110
|
+
'slope',
|
|
111
|
+
'kanban',
|
|
112
|
+
];
|
|
113
|
+
|
|
114
|
+
/** Map from ParticipantType inference → C4Shape */
|
|
115
|
+
function participantTypeToC4Shape(
|
|
116
|
+
pType: string,
|
|
117
|
+
): C4Shape {
|
|
118
|
+
switch (pType) {
|
|
119
|
+
case 'database':
|
|
120
|
+
return 'database';
|
|
121
|
+
case 'cache':
|
|
122
|
+
return 'cache';
|
|
123
|
+
case 'queue':
|
|
124
|
+
return 'queue';
|
|
125
|
+
case 'external':
|
|
126
|
+
return 'external';
|
|
127
|
+
case 'networking':
|
|
128
|
+
return 'cloud';
|
|
129
|
+
default:
|
|
130
|
+
return 'default';
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Infer C4Shape from element name and optional technology value. */
|
|
135
|
+
function inferC4Shape(name: string, tech?: string): C4Shape {
|
|
136
|
+
// Try tech value first (more specific)
|
|
137
|
+
if (tech) {
|
|
138
|
+
const techShape = participantTypeToC4Shape(inferParticipantType(tech));
|
|
139
|
+
if (techShape !== 'default') return techShape;
|
|
140
|
+
}
|
|
141
|
+
// Fall back to name inference
|
|
142
|
+
return participantTypeToC4Shape(inferParticipantType(name));
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function parseArrowType(arrow: string): C4ArrowType | null {
|
|
146
|
+
switch (arrow) {
|
|
147
|
+
case '->':
|
|
148
|
+
return 'sync';
|
|
149
|
+
case '~>':
|
|
150
|
+
return 'async';
|
|
151
|
+
case '<->':
|
|
152
|
+
return 'bidirectional';
|
|
153
|
+
case '<~>':
|
|
154
|
+
return 'bidirectional-async';
|
|
155
|
+
default:
|
|
156
|
+
return null;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** Parse relationship label and optional [technology] annotation. */
|
|
161
|
+
function parseRelationshipBody(
|
|
162
|
+
body: string,
|
|
163
|
+
): { target: string; label?: string; technology?: string } {
|
|
164
|
+
// Format: `Target: label [tech]` or `Target: label` or `Target`
|
|
165
|
+
const colonIdx = body.indexOf(':');
|
|
166
|
+
let target: string;
|
|
167
|
+
let rest: string;
|
|
168
|
+
|
|
169
|
+
if (colonIdx > 0) {
|
|
170
|
+
target = body.substring(0, colonIdx).trim();
|
|
171
|
+
rest = body.substring(colonIdx + 1).trim();
|
|
172
|
+
} else {
|
|
173
|
+
target = body.trim();
|
|
174
|
+
rest = '';
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
if (!rest) return { target };
|
|
178
|
+
|
|
179
|
+
// Extract [technology] from end of rest
|
|
180
|
+
const techMatch = rest.match(/\[([^\]]+)\]\s*$/);
|
|
181
|
+
if (techMatch) {
|
|
182
|
+
const label = rest.substring(0, techMatch.index!).trim() || undefined;
|
|
183
|
+
return { target, label, technology: techMatch[1].trim() };
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
return { target, label: rest };
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** Parse pipe-delimited metadata from segments after the first (name) segment. */
|
|
190
|
+
function parsePipeMetadata(
|
|
191
|
+
segments: string[],
|
|
192
|
+
aliasMap: Map<string, string>,
|
|
193
|
+
): Record<string, string> {
|
|
194
|
+
const metadata: Record<string, string> = {};
|
|
195
|
+
for (let j = 1; j < segments.length; j++) {
|
|
196
|
+
for (const part of segments[j].split(',')) {
|
|
197
|
+
const trimmedPart = part.trim();
|
|
198
|
+
if (!trimmedPart) continue;
|
|
199
|
+
const colonIdx = trimmedPart.indexOf(':');
|
|
200
|
+
if (colonIdx > 0) {
|
|
201
|
+
const rawKey = trimmedPart.substring(0, colonIdx).trim().toLowerCase();
|
|
202
|
+
const key = aliasMap.get(rawKey) ?? rawKey;
|
|
203
|
+
const value = trimmedPart.substring(colonIdx + 1).trim();
|
|
204
|
+
metadata[key] = value;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
return metadata;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// ============================================================
|
|
212
|
+
// Stack entry types
|
|
213
|
+
// ============================================================
|
|
214
|
+
|
|
215
|
+
interface ElementStackEntry {
|
|
216
|
+
kind: 'element';
|
|
217
|
+
element: C4Element;
|
|
218
|
+
indent: number;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
interface GroupStackEntry {
|
|
222
|
+
kind: 'group';
|
|
223
|
+
group: C4Group;
|
|
224
|
+
parentElement: C4Element;
|
|
225
|
+
indent: number;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
interface SectionStackEntry {
|
|
229
|
+
kind: 'section';
|
|
230
|
+
sectionType: 'containers' | 'components';
|
|
231
|
+
parentElement: C4Element;
|
|
232
|
+
indent: number;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
interface DeploymentStackEntry {
|
|
236
|
+
kind: 'deployment';
|
|
237
|
+
node: C4DeploymentNode;
|
|
238
|
+
indent: number;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
type StackEntry =
|
|
242
|
+
| ElementStackEntry
|
|
243
|
+
| GroupStackEntry
|
|
244
|
+
| SectionStackEntry
|
|
245
|
+
| DeploymentStackEntry;
|
|
246
|
+
|
|
247
|
+
// ============================================================
|
|
248
|
+
// Parser
|
|
249
|
+
// ============================================================
|
|
250
|
+
|
|
251
|
+
export function parseC4(
|
|
252
|
+
content: string,
|
|
253
|
+
palette?: PaletteColors,
|
|
254
|
+
): ParsedC4 {
|
|
255
|
+
const result: ParsedC4 = {
|
|
256
|
+
title: null,
|
|
257
|
+
titleLineNumber: null,
|
|
258
|
+
options: {},
|
|
259
|
+
tagGroups: [],
|
|
260
|
+
elements: [],
|
|
261
|
+
relationships: [],
|
|
262
|
+
deployment: [],
|
|
263
|
+
diagnostics: [],
|
|
264
|
+
error: null,
|
|
265
|
+
};
|
|
266
|
+
|
|
267
|
+
const pushError = (line: number, message: string, severity: 'error' | 'warning' = 'error'): void => {
|
|
268
|
+
const diag = makeDgmoError(line, message, severity);
|
|
269
|
+
result.diagnostics.push(diag);
|
|
270
|
+
if (!result.error && severity === 'error') result.error = formatDgmoError(diag);
|
|
271
|
+
};
|
|
272
|
+
|
|
273
|
+
const fail = (line: number, message: string): ParsedC4 => {
|
|
274
|
+
const diag = makeDgmoError(line, message);
|
|
275
|
+
result.diagnostics.push(diag);
|
|
276
|
+
result.error = formatDgmoError(diag);
|
|
277
|
+
return result;
|
|
278
|
+
};
|
|
279
|
+
|
|
280
|
+
if (!content || !content.trim()) {
|
|
281
|
+
return fail(0, 'No content provided');
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
const lines = content.split('\n');
|
|
285
|
+
let contentStarted = false;
|
|
286
|
+
let sawChartType = false;
|
|
287
|
+
let inDeployment = false;
|
|
288
|
+
|
|
289
|
+
// Tag group parsing state
|
|
290
|
+
let currentTagGroup: OrgTagGroup | null = null;
|
|
291
|
+
const aliasMap = new Map<string, string>();
|
|
292
|
+
|
|
293
|
+
// Name uniqueness tracking
|
|
294
|
+
const knownNames = new Map<string, number>(); // name → lineNumber
|
|
295
|
+
|
|
296
|
+
// Indent stack for hierarchy tracking
|
|
297
|
+
const stack: StackEntry[] = [];
|
|
298
|
+
|
|
299
|
+
// Deployment indent stack
|
|
300
|
+
const deployStack: { node: C4DeploymentNode; indent: number }[] = [];
|
|
301
|
+
|
|
302
|
+
for (let i = 0; i < lines.length; i++) {
|
|
303
|
+
const line = lines[i];
|
|
304
|
+
const lineNumber = i + 1;
|
|
305
|
+
const trimmed = line.trim();
|
|
306
|
+
|
|
307
|
+
// Skip empty lines
|
|
308
|
+
if (!trimmed) {
|
|
309
|
+
if (currentTagGroup) currentTagGroup = null;
|
|
310
|
+
continue;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// Skip comments
|
|
314
|
+
if (trimmed.startsWith('//')) continue;
|
|
315
|
+
|
|
316
|
+
// --- Header phase ---
|
|
317
|
+
|
|
318
|
+
// chart: type
|
|
319
|
+
if (!contentStarted) {
|
|
320
|
+
const chartMatch = trimmed.match(CHART_TYPE_RE);
|
|
321
|
+
if (chartMatch) {
|
|
322
|
+
const chartType = chartMatch[1].trim().toLowerCase();
|
|
323
|
+
if (chartType !== 'c4') {
|
|
324
|
+
let msg = `Expected chart type "c4", got "${chartType}"`;
|
|
325
|
+
const hint = suggest(chartType, ALL_CHART_TYPES);
|
|
326
|
+
if (hint) msg += `. ${hint}`;
|
|
327
|
+
return fail(lineNumber, msg);
|
|
328
|
+
}
|
|
329
|
+
sawChartType = true;
|
|
330
|
+
continue;
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
// title: value
|
|
335
|
+
if (!contentStarted) {
|
|
336
|
+
const titleMatch = trimmed.match(TITLE_RE);
|
|
337
|
+
if (titleMatch) {
|
|
338
|
+
result.title = titleMatch[1].trim();
|
|
339
|
+
result.titleLineNumber = lineNumber;
|
|
340
|
+
continue;
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
// Generic header options
|
|
345
|
+
if (!contentStarted && !currentTagGroup && measureIndent(line) === 0) {
|
|
346
|
+
const optMatch = trimmed.match(OPTION_RE);
|
|
347
|
+
if (optMatch && !trimmed.startsWith('##')) {
|
|
348
|
+
const key = optMatch[1].trim().toLowerCase();
|
|
349
|
+
if (key !== 'chart' && key !== 'title') {
|
|
350
|
+
result.options[key] = optMatch[2].trim();
|
|
351
|
+
continue;
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
// ## Tag group heading
|
|
357
|
+
const groupMatch = trimmed.match(GROUP_HEADING_RE);
|
|
358
|
+
if (groupMatch) {
|
|
359
|
+
if (contentStarted) {
|
|
360
|
+
pushError(lineNumber, 'Tag groups (##) must appear before content');
|
|
361
|
+
continue;
|
|
362
|
+
}
|
|
363
|
+
const groupName = groupMatch[1].trim();
|
|
364
|
+
const alias = groupMatch[2] || undefined;
|
|
365
|
+
currentTagGroup = {
|
|
366
|
+
name: groupName,
|
|
367
|
+
alias,
|
|
368
|
+
entries: [],
|
|
369
|
+
lineNumber,
|
|
370
|
+
};
|
|
371
|
+
if (alias) {
|
|
372
|
+
aliasMap.set(alias.toLowerCase(), groupName.toLowerCase());
|
|
373
|
+
}
|
|
374
|
+
result.tagGroups.push(currentTagGroup);
|
|
375
|
+
continue;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
// Tag group entries
|
|
379
|
+
if (currentTagGroup && !contentStarted) {
|
|
380
|
+
const indent = measureIndent(line);
|
|
381
|
+
if (indent > 0) {
|
|
382
|
+
const isDefault = /\bdefault\s*$/.test(trimmed);
|
|
383
|
+
const entryText = isDefault
|
|
384
|
+
? trimmed.replace(/\s+default\s*$/, '').trim()
|
|
385
|
+
: trimmed;
|
|
386
|
+
const { label, color } = extractColor(entryText, palette);
|
|
387
|
+
if (!color) {
|
|
388
|
+
pushError(
|
|
389
|
+
lineNumber,
|
|
390
|
+
`Expected 'Value(color)' in tag group '${currentTagGroup.name}'`,
|
|
391
|
+
);
|
|
392
|
+
continue;
|
|
393
|
+
}
|
|
394
|
+
if (isDefault) {
|
|
395
|
+
currentTagGroup.defaultValue = label;
|
|
396
|
+
}
|
|
397
|
+
currentTagGroup.entries.push({
|
|
398
|
+
value: label,
|
|
399
|
+
color,
|
|
400
|
+
lineNumber,
|
|
401
|
+
});
|
|
402
|
+
continue;
|
|
403
|
+
}
|
|
404
|
+
currentTagGroup = null;
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
// --- Content phase ---
|
|
408
|
+
contentStarted = true;
|
|
409
|
+
currentTagGroup = null;
|
|
410
|
+
|
|
411
|
+
if (!sawChartType) {
|
|
412
|
+
return fail(lineNumber, 'Missing "chart: c4" header');
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
const indent = measureIndent(line);
|
|
416
|
+
|
|
417
|
+
// ── Deployment section ──────────────────────────────────
|
|
418
|
+
if (inDeployment) {
|
|
419
|
+
// Pop deployment stack for decreased indent
|
|
420
|
+
while (deployStack.length > 0) {
|
|
421
|
+
const top = deployStack[deployStack.length - 1];
|
|
422
|
+
if (top.indent < indent) break;
|
|
423
|
+
deployStack.pop();
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
// Check for top-level non-deployment content (section ended)
|
|
427
|
+
if (indent === 0 && ELEMENT_RE.test(trimmed)) {
|
|
428
|
+
inDeployment = false;
|
|
429
|
+
// Fall through to element parsing below
|
|
430
|
+
} else {
|
|
431
|
+
// container X reference?
|
|
432
|
+
const refMatch = trimmed.match(CONTAINER_REF_RE);
|
|
433
|
+
if (refMatch) {
|
|
434
|
+
const refName = refMatch[1].trim();
|
|
435
|
+
if (deployStack.length > 0) {
|
|
436
|
+
deployStack[deployStack.length - 1].node.containerRefs.push(
|
|
437
|
+
refName,
|
|
438
|
+
);
|
|
439
|
+
} else {
|
|
440
|
+
pushError(lineNumber, `"container ${refName}" must be inside a deployment node`);
|
|
441
|
+
}
|
|
442
|
+
continue;
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
// Otherwise it's a deployment node (possibly with pipe metadata)
|
|
446
|
+
const segments = trimmed.split('|').map((s) => s.trim());
|
|
447
|
+
const nodeName = segments[0];
|
|
448
|
+
const metadata = parsePipeMetadata(segments, aliasMap);
|
|
449
|
+
const shape = inferC4Shape(nodeName, metadata.tech ?? metadata.technology);
|
|
450
|
+
|
|
451
|
+
const dNode: C4DeploymentNode = {
|
|
452
|
+
name: nodeName,
|
|
453
|
+
metadata,
|
|
454
|
+
shape,
|
|
455
|
+
children: [],
|
|
456
|
+
containerRefs: [],
|
|
457
|
+
lineNumber,
|
|
458
|
+
};
|
|
459
|
+
|
|
460
|
+
if (deployStack.length > 0) {
|
|
461
|
+
deployStack[deployStack.length - 1].node.children.push(dNode);
|
|
462
|
+
} else {
|
|
463
|
+
result.deployment.push(dNode);
|
|
464
|
+
}
|
|
465
|
+
deployStack.push({ node: dNode, indent });
|
|
466
|
+
continue;
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
// ── Section headers ─────────────────────────────────────
|
|
471
|
+
const sectionMatch = trimmed.match(SECTION_HEADER_RE);
|
|
472
|
+
if (sectionMatch) {
|
|
473
|
+
const sectionType = sectionMatch[1].toLowerCase();
|
|
474
|
+
|
|
475
|
+
if (sectionType === 'deployment') {
|
|
476
|
+
inDeployment = true;
|
|
477
|
+
continue;
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
// containers: / components: must be inside an element
|
|
481
|
+
const parentEntry = findParentElement(indent, stack);
|
|
482
|
+
if (parentEntry) {
|
|
483
|
+
parentEntry.element.sectionHeader =
|
|
484
|
+
sectionType as 'containers' | 'components';
|
|
485
|
+
parentEntry.element.sectionHeaderLineNumber = lineNumber;
|
|
486
|
+
stack.push({
|
|
487
|
+
kind: 'section',
|
|
488
|
+
sectionType: sectionType as 'containers' | 'components',
|
|
489
|
+
parentElement: parentEntry.element,
|
|
490
|
+
indent,
|
|
491
|
+
});
|
|
492
|
+
} else {
|
|
493
|
+
pushError(
|
|
494
|
+
lineNumber,
|
|
495
|
+
`"${sectionType}:" must be inside an element`,
|
|
496
|
+
);
|
|
497
|
+
}
|
|
498
|
+
continue;
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
// ── Pop stack for decreased indent ──────────────────────
|
|
502
|
+
while (stack.length > 0) {
|
|
503
|
+
const top = stack[stack.length - 1];
|
|
504
|
+
if (top.indent < indent) break;
|
|
505
|
+
stack.pop();
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
// ── Group boundaries: [Group Name] ──────────────────────
|
|
509
|
+
const containerMatch = trimmed.match(CONTAINER_RE);
|
|
510
|
+
if (containerMatch) {
|
|
511
|
+
const groupName = containerMatch[1].trim();
|
|
512
|
+
const parentEntry = findParentElement(indent, stack);
|
|
513
|
+
if (parentEntry) {
|
|
514
|
+
const group: C4Group = {
|
|
515
|
+
name: groupName,
|
|
516
|
+
children: [],
|
|
517
|
+
lineNumber,
|
|
518
|
+
};
|
|
519
|
+
parentEntry.element.groups.push(group);
|
|
520
|
+
stack.push({
|
|
521
|
+
kind: 'group',
|
|
522
|
+
group,
|
|
523
|
+
parentElement: parentEntry.element,
|
|
524
|
+
indent,
|
|
525
|
+
});
|
|
526
|
+
} else {
|
|
527
|
+
pushError(lineNumber, `Group [${groupName}] must be inside an element`);
|
|
528
|
+
}
|
|
529
|
+
continue;
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
// ── Relationships ───────────────────────────────────────
|
|
533
|
+
const relMatch = trimmed.match(RELATIONSHIP_RE);
|
|
534
|
+
if (relMatch) {
|
|
535
|
+
const arrowType = parseArrowType(relMatch[1]);
|
|
536
|
+
if (arrowType) {
|
|
537
|
+
const { target, label, technology } = parseRelationshipBody(
|
|
538
|
+
relMatch[2],
|
|
539
|
+
);
|
|
540
|
+
const rel: C4Relationship = {
|
|
541
|
+
target,
|
|
542
|
+
label,
|
|
543
|
+
technology,
|
|
544
|
+
arrowType,
|
|
545
|
+
lineNumber,
|
|
546
|
+
};
|
|
547
|
+
|
|
548
|
+
// Attach to nearest parent element
|
|
549
|
+
const parentEntry = findParentElement(indent, stack);
|
|
550
|
+
if (parentEntry) {
|
|
551
|
+
parentEntry.element.relationships.push(rel);
|
|
552
|
+
} else {
|
|
553
|
+
// Top-level relationship (orphan) — add to result-level relationships
|
|
554
|
+
result.relationships.push(rel);
|
|
555
|
+
}
|
|
556
|
+
continue;
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
// ── Element declarations ────────────────────────────────
|
|
561
|
+
const elementMatch = trimmed.match(ELEMENT_RE);
|
|
562
|
+
if (elementMatch) {
|
|
563
|
+
const elementType = elementMatch[1].toLowerCase() as C4ElementType;
|
|
564
|
+
let nameAndRest = elementMatch[2];
|
|
565
|
+
|
|
566
|
+
// Split on pipe for inline metadata
|
|
567
|
+
const segments = nameAndRest.split('|').map((s) => s.trim());
|
|
568
|
+
let namePart = segments[0];
|
|
569
|
+
|
|
570
|
+
// Check for `is a <shape>` in the name portion
|
|
571
|
+
let explicitShape: C4Shape | null = null;
|
|
572
|
+
const isAMatch = namePart.match(IS_A_RE);
|
|
573
|
+
if (isAMatch) {
|
|
574
|
+
const shapeName = isAMatch[1].toLowerCase();
|
|
575
|
+
if (VALID_SHAPES.has(shapeName)) {
|
|
576
|
+
explicitShape = shapeName as C4Shape;
|
|
577
|
+
} else {
|
|
578
|
+
pushError(
|
|
579
|
+
lineNumber,
|
|
580
|
+
`Unknown shape "${isAMatch[1]}". Valid shapes: ${[...VALID_SHAPES].join(', ')}`,
|
|
581
|
+
);
|
|
582
|
+
}
|
|
583
|
+
namePart = namePart.substring(0, isAMatch.index!).trim();
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
const metadata = parsePipeMetadata(segments, aliasMap);
|
|
587
|
+
|
|
588
|
+
// Determine shape: explicit > inference
|
|
589
|
+
const shape =
|
|
590
|
+
explicitShape ??
|
|
591
|
+
inferC4Shape(namePart, metadata.tech ?? metadata.technology);
|
|
592
|
+
|
|
593
|
+
const element: C4Element = {
|
|
594
|
+
name: namePart,
|
|
595
|
+
type: elementType,
|
|
596
|
+
shape,
|
|
597
|
+
metadata,
|
|
598
|
+
children: [],
|
|
599
|
+
groups: [],
|
|
600
|
+
relationships: [],
|
|
601
|
+
lineNumber,
|
|
602
|
+
};
|
|
603
|
+
|
|
604
|
+
// Check for duplicate name
|
|
605
|
+
const existingLine = knownNames.get(namePart.toLowerCase());
|
|
606
|
+
if (existingLine !== undefined) {
|
|
607
|
+
pushError(
|
|
608
|
+
lineNumber,
|
|
609
|
+
`Duplicate element name "${namePart}" (first defined on line ${existingLine})`,
|
|
610
|
+
);
|
|
611
|
+
} else {
|
|
612
|
+
knownNames.set(namePart.toLowerCase(), lineNumber);
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
// Attach to parent or push to top-level
|
|
616
|
+
attachElement(element, indent, stack, result);
|
|
617
|
+
continue;
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
// ── Indented metadata (key: value) ──────────────────────
|
|
621
|
+
// Only if we have a parent element and line doesn't look like a keyword
|
|
622
|
+
const metadataMatch = trimmed.match(METADATA_RE);
|
|
623
|
+
if (metadataMatch && !ELEMENT_RE.test(trimmed)) {
|
|
624
|
+
const parentEntry = findParentElement(indent, stack);
|
|
625
|
+
if (parentEntry) {
|
|
626
|
+
const rawKey = metadataMatch[1].trim().toLowerCase();
|
|
627
|
+
|
|
628
|
+
// Special case: `import: file.dgmo`
|
|
629
|
+
if (rawKey === 'import') {
|
|
630
|
+
parentEntry.element.importPath = metadataMatch[2].trim();
|
|
631
|
+
continue;
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
const key = aliasMap.get(rawKey) ?? rawKey;
|
|
635
|
+
const value = metadataMatch[2].trim();
|
|
636
|
+
parentEntry.element.metadata[key] = value;
|
|
637
|
+
continue;
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
// ── Unknown line ────────────────────────────────────────
|
|
642
|
+
// Check if it looks like a misspelled element keyword
|
|
643
|
+
const firstWord = trimmed.split(/\s+/)[0].toLowerCase();
|
|
644
|
+
if (firstWord.length > 3) {
|
|
645
|
+
const hint = suggest(firstWord, [...VALID_ELEMENT_TYPES]);
|
|
646
|
+
if (hint) {
|
|
647
|
+
pushError(lineNumber, `Unknown keyword "${firstWord}". ${hint}`);
|
|
648
|
+
continue;
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
// If inside a parent, could be an unkeyed description or misc text — ignore gracefully
|
|
653
|
+
const parent = findParentElement(indent, stack);
|
|
654
|
+
if (!parent) {
|
|
655
|
+
pushError(lineNumber, `Unexpected content: "${trimmed}"`);
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
// ── Post-parse validation ───────────────────────────────
|
|
660
|
+
validateRelationshipTargets(result, knownNames, pushError);
|
|
661
|
+
validateDeploymentRefs(result, knownNames, pushError);
|
|
662
|
+
|
|
663
|
+
return result;
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
// ============================================================
|
|
667
|
+
// Attachment helpers
|
|
668
|
+
// ============================================================
|
|
669
|
+
|
|
670
|
+
/** Find the nearest parent element entry on the stack at shallower indent. */
|
|
671
|
+
function findParentElement(
|
|
672
|
+
indent: number,
|
|
673
|
+
stack: StackEntry[],
|
|
674
|
+
): ElementStackEntry | null {
|
|
675
|
+
for (let i = stack.length - 1; i >= 0; i--) {
|
|
676
|
+
const entry = stack[i];
|
|
677
|
+
if (entry.indent >= indent) continue;
|
|
678
|
+
if (entry.kind === 'element') return entry;
|
|
679
|
+
if (entry.kind === 'group') {
|
|
680
|
+
// Walk further up to find the element that owns this group
|
|
681
|
+
continue;
|
|
682
|
+
}
|
|
683
|
+
if (entry.kind === 'section') {
|
|
684
|
+
// The section's parent element is the attachment target
|
|
685
|
+
return {
|
|
686
|
+
kind: 'element',
|
|
687
|
+
element: entry.parentElement,
|
|
688
|
+
indent: entry.indent,
|
|
689
|
+
};
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
return null;
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
function attachElement(
|
|
696
|
+
element: C4Element,
|
|
697
|
+
indent: number,
|
|
698
|
+
stack: StackEntry[],
|
|
699
|
+
result: ParsedC4,
|
|
700
|
+
): void {
|
|
701
|
+
// Find the immediate context: group, section, or parent element
|
|
702
|
+
let attached = false;
|
|
703
|
+
|
|
704
|
+
for (let i = stack.length - 1; i >= 0; i--) {
|
|
705
|
+
const entry = stack[i];
|
|
706
|
+
if (entry.indent >= indent) continue;
|
|
707
|
+
|
|
708
|
+
if (entry.kind === 'group') {
|
|
709
|
+
// Attach to the group
|
|
710
|
+
entry.group.children.push(element);
|
|
711
|
+
attached = true;
|
|
712
|
+
break;
|
|
713
|
+
}
|
|
714
|
+
if (entry.kind === 'section') {
|
|
715
|
+
// Attach as child of the section's parent element
|
|
716
|
+
entry.parentElement.children.push(element);
|
|
717
|
+
attached = true;
|
|
718
|
+
break;
|
|
719
|
+
}
|
|
720
|
+
if (entry.kind === 'element') {
|
|
721
|
+
entry.element.children.push(element);
|
|
722
|
+
attached = true;
|
|
723
|
+
break;
|
|
724
|
+
}
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
if (!attached) {
|
|
728
|
+
result.elements.push(element);
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
stack.push({ kind: 'element', element, indent });
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
// ============================================================
|
|
735
|
+
// Post-parse validation
|
|
736
|
+
// ============================================================
|
|
737
|
+
|
|
738
|
+
function collectAllNames(result: ParsedC4): Map<string, number> {
|
|
739
|
+
const names = new Map<string, number>();
|
|
740
|
+
function walk(elements: C4Element[]) {
|
|
741
|
+
for (const el of elements) {
|
|
742
|
+
names.set(el.name.toLowerCase(), el.lineNumber);
|
|
743
|
+
walk(el.children);
|
|
744
|
+
for (const g of el.groups) {
|
|
745
|
+
walk(g.children);
|
|
746
|
+
}
|
|
747
|
+
}
|
|
748
|
+
}
|
|
749
|
+
walk(result.elements);
|
|
750
|
+
return names;
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
function validateRelationshipTargets(
|
|
754
|
+
result: ParsedC4,
|
|
755
|
+
knownNames: Map<string, number>,
|
|
756
|
+
pushWarning: (line: number, message: string, severity?: 'error' | 'warning') => void,
|
|
757
|
+
): void {
|
|
758
|
+
function walkRels(elements: C4Element[]) {
|
|
759
|
+
for (const el of elements) {
|
|
760
|
+
for (const rel of el.relationships) {
|
|
761
|
+
if (!knownNames.has(rel.target.toLowerCase())) {
|
|
762
|
+
pushWarning(
|
|
763
|
+
rel.lineNumber,
|
|
764
|
+
`Relationship target "${rel.target}" not found`,
|
|
765
|
+
'warning',
|
|
766
|
+
);
|
|
767
|
+
}
|
|
768
|
+
}
|
|
769
|
+
walkRels(el.children);
|
|
770
|
+
for (const g of el.groups) {
|
|
771
|
+
walkRels(g.children);
|
|
772
|
+
}
|
|
773
|
+
}
|
|
774
|
+
}
|
|
775
|
+
walkRels(result.elements);
|
|
776
|
+
|
|
777
|
+
// Also check top-level relationships
|
|
778
|
+
for (const rel of result.relationships) {
|
|
779
|
+
if (!knownNames.has(rel.target.toLowerCase())) {
|
|
780
|
+
pushWarning(
|
|
781
|
+
rel.lineNumber,
|
|
782
|
+
`Relationship target "${rel.target}" not found`,
|
|
783
|
+
'warning',
|
|
784
|
+
);
|
|
785
|
+
}
|
|
786
|
+
}
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
function validateDeploymentRefs(
|
|
790
|
+
result: ParsedC4,
|
|
791
|
+
knownNames: Map<string, number>,
|
|
792
|
+
pushWarning: (line: number, message: string, severity?: 'error' | 'warning') => void,
|
|
793
|
+
): void {
|
|
794
|
+
function walkDeploy(nodes: C4DeploymentNode[]) {
|
|
795
|
+
for (const node of nodes) {
|
|
796
|
+
for (const ref of node.containerRefs) {
|
|
797
|
+
if (!knownNames.has(ref.toLowerCase())) {
|
|
798
|
+
pushWarning(
|
|
799
|
+
node.lineNumber,
|
|
800
|
+
`Deployment reference "container ${ref}" not found`,
|
|
801
|
+
'warning',
|
|
802
|
+
);
|
|
803
|
+
}
|
|
804
|
+
}
|
|
805
|
+
walkDeploy(node.children);
|
|
806
|
+
}
|
|
807
|
+
}
|
|
808
|
+
walkDeploy(result.deployment);
|
|
809
|
+
}
|