@diagrammo/dgmo 0.2.21 → 0.2.23

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.
@@ -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
+ }