@diagrammo/dgmo 0.5.2 → 0.5.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.
package/src/index.ts CHANGED
@@ -218,6 +218,9 @@ export type {
218
218
 
219
219
  export { renderInitiativeStatus, renderInitiativeStatusForExport } from './initiative-status/renderer';
220
220
 
221
+ export { collapseInitiativeStatus } from './initiative-status/collapse';
222
+ export type { CollapseResult } from './initiative-status/collapse';
223
+
221
224
  export { parseSitemap, looksLikeSitemap } from './sitemap/parser';
222
225
 
223
226
  export type {
@@ -295,29 +295,35 @@ function collapseGroups(parsed: ParsedInfra, collapsedIds: Set<string>, defaultL
295
295
  const children = groupChildren.get(group.id) ?? [];
296
296
  if (children.length === 0) continue;
297
297
 
298
- // Aggregate properties: sum latencies, bottleneck capacity, compose behaviors
299
- let totalLatency = 0;
298
+ // Aggregate properties: bottleneck capacity, compose behaviors
299
+ // Latency is computed separately below via critical-path analysis.
300
300
  let minEffectiveCapacity = Infinity;
301
301
  let hasMaxRps = false;
302
302
  let composedUptime = 1;
303
303
  const behaviorProps: InfraProperty[] = [];
304
304
  const perChildCapacities: number[] = [];
305
305
 
306
+ // Collect per-child latency values for critical-path computation
307
+ const childIdSet = new Set(children.map((c) => c.id));
308
+ const childLatencies = new Map<string, number>();
309
+
306
310
  for (const child of children) {
307
311
  // Latency: use explicit value, or diagram default (matching BFS behavior)
308
312
  const latencyProp = child.properties.find((p) => p.key === 'latency-ms');
309
313
  const childIsServerless = child.properties.some((p) => p.key === 'concurrency');
314
+ let childLat: number;
310
315
  if (childIsServerless) {
311
316
  // Serverless nodes use duration-ms as latency contribution
312
317
  const durationProp = child.properties.find((p) => p.key === 'duration-ms');
313
- totalLatency += durationProp
318
+ childLat = durationProp
314
319
  ? (typeof durationProp.value === 'number' ? durationProp.value : parseFloat(String(durationProp.value)) || 100)
315
320
  : 100;
316
321
  } else if (latencyProp) {
317
- totalLatency += (typeof latencyProp.value === 'number' ? latencyProp.value : parseFloat(String(latencyProp.value)) || 0);
322
+ childLat = typeof latencyProp.value === 'number' ? latencyProp.value : parseFloat(String(latencyProp.value)) || 0;
318
323
  } else {
319
- totalLatency += defaultLatencyMs;
324
+ childLat = defaultLatencyMs;
320
325
  }
326
+ childLatencies.set(child.id, childLat);
321
327
 
322
328
  const maxRps = child.properties.find((p) => p.key === 'max-rps');
323
329
  if (maxRps) {
@@ -349,6 +355,73 @@ function collapseGroups(parsed: ParsedInfra, collapsedIds: Set<string>, defaultL
349
355
  }
350
356
  }
351
357
 
358
+ // ── Critical-path latency through the group ──────────────────────────────
359
+ // totalLatency = max path length from external-inbound entry nodes to
360
+ // external-outbound exit nodes, traversing only internal edges.
361
+ // This prevents side-dependency latencies (nodes only reached via internal
362
+ // edges with no external exit) from inflating the virtual node's latency.
363
+ const entryIds = new Set<string>();
364
+ const exitIds = new Set<string>();
365
+ for (const edge of inboundEdges) {
366
+ if (childIdSet.has(edge.targetId)) entryIds.add(edge.targetId);
367
+ }
368
+ for (const edge of outboundEdges) {
369
+ if (childIdSet.has(edge.sourceId)) exitIds.add(edge.sourceId);
370
+ }
371
+ for (const edge of crossGroupEdges) {
372
+ if (childIdSet.has(edge.sourceId)) exitIds.add(edge.sourceId);
373
+ if (childIdSet.has(edge.targetId)) entryIds.add(edge.targetId);
374
+ }
375
+
376
+ // Build forward adjacency for internal edges scoped to this group
377
+ const fwdAdj = new Map<string, string[]>();
378
+ for (const edge of internalEdges) {
379
+ if (!childIdSet.has(edge.sourceId) || !childIdSet.has(edge.targetId)) continue;
380
+ const list = fwdAdj.get(edge.sourceId) ?? [];
381
+ list.push(edge.targetId);
382
+ fwdAdj.set(edge.sourceId, list);
383
+ }
384
+
385
+ // DAG longest-path from entry nodes (topological DFS + relaxation)
386
+ const topoOrder: string[] = [];
387
+ const tsVisited = new Set<string>();
388
+ const dfsTopoSort = (id: string) => {
389
+ if (tsVisited.has(id)) return;
390
+ tsVisited.add(id);
391
+ for (const next of fwdAdj.get(id) ?? []) dfsTopoSort(next);
392
+ topoOrder.unshift(id);
393
+ };
394
+ for (const child of children) dfsTopoSort(child.id);
395
+
396
+ const dist = new Map<string, number>();
397
+ for (const child of children) {
398
+ dist.set(child.id, entryIds.has(child.id) ? (childLatencies.get(child.id) ?? 0) : -Infinity);
399
+ }
400
+ for (const nodeId of topoOrder) {
401
+ const curDist = dist.get(nodeId) ?? -Infinity;
402
+ if (curDist === -Infinity) continue;
403
+ for (const nextId of fwdAdj.get(nodeId) ?? []) {
404
+ const newDist = curDist + (childLatencies.get(nextId) ?? 0);
405
+ if (newDist > (dist.get(nextId) ?? -Infinity)) dist.set(nextId, newDist);
406
+ }
407
+ }
408
+
409
+ let totalLatency = 0;
410
+ if (exitIds.size > 0) {
411
+ for (const id of exitIds) {
412
+ const d = dist.get(id);
413
+ if (d !== undefined && d > -Infinity && d > totalLatency) totalLatency = d;
414
+ }
415
+ } else if (entryIds.size > 0) {
416
+ // No explicit exits — use max reachable distance from entry nodes
417
+ for (const [, d] of dist) {
418
+ if (d > 0 && d > totalLatency) totalLatency = d;
419
+ }
420
+ } else {
421
+ // No external connections — fall back to summing all children
422
+ for (const [, lat] of childLatencies) totalLatency += lat;
423
+ }
424
+
352
425
  // Build virtual node properties
353
426
  const props: InfraProperty[] = [];
354
427
  if (totalLatency > 0) props.push({ key: 'latency-ms', value: totalLatency, lineNumber: group.lineNumber });
@@ -692,8 +765,10 @@ export function computeInfra(
692
765
 
693
766
  for (const { edge, split } of resolved) {
694
767
  const edgeRps = outboundRps * (split / 100);
768
+ const fanout = (edge.fanout != null && edge.fanout >= 1) ? edge.fanout : 1;
769
+ const fanoutedRps = edgeRps * fanout;
695
770
  const edgeKey = `${edge.sourceId}->${edge.targetId}`;
696
- computedEdgeRps.set(edgeKey, edgeRps);
771
+ computedEdgeRps.set(edgeKey, fanoutedRps);
697
772
 
698
773
  // Resolve target — could be a group or a node
699
774
  let targetIds: string[];
@@ -705,7 +780,7 @@ export function computeInfra(
705
780
  }
706
781
 
707
782
  for (const targetId of targetIds) {
708
- const perTarget = edgeRps / targetIds.length;
783
+ const perTarget = fanoutedRps / targetIds.length;
709
784
  const existing = computedRps.get(targetId) ?? 0;
710
785
  computedRps.set(targetId, existing + perTarget);
711
786
 
@@ -837,6 +912,7 @@ export function computeInfra(
837
912
  const paths: LeafPath[] = [];
838
913
 
839
914
  for (const { edge, split } of resolved) {
915
+ const fanout = (edge.fanout != null && edge.fanout >= 1) ? edge.fanout : 1;
840
916
  const groupChildren = groupChildMap.get(edge.targetId);
841
917
  const targetIds = (groupChildren && groupChildren.length > 0)
842
918
  ? groupChildren : [edge.targetId];
@@ -850,21 +926,21 @@ export function computeInfra(
850
926
  latency: nodeLatency + cp.latency,
851
927
  uptime: nodeUptimeFrac * cp.uptime,
852
928
  availability: nodeAvail * cp.availability,
853
- weight: cp.weight * (split / 100) / targetIds.length * 0.95,
929
+ weight: cp.weight * (split / 100) / targetIds.length * fanout * 0.95,
854
930
  });
855
931
  // Cold path (5% of requests)
856
932
  paths.push({
857
933
  latency: coldLatency + cp.latency,
858
934
  uptime: nodeUptimeFrac * cp.uptime,
859
935
  availability: nodeAvail * cp.availability,
860
- weight: cp.weight * (split / 100) / targetIds.length * 0.05,
936
+ weight: cp.weight * (split / 100) / targetIds.length * fanout * 0.05,
861
937
  });
862
938
  } else {
863
939
  paths.push({
864
940
  latency: nodeLatency + cp.latency,
865
941
  uptime: nodeUptimeFrac * cp.uptime,
866
942
  availability: nodeAvail * cp.availability,
867
- weight: cp.weight * (split / 100) / targetIds.length,
943
+ weight: cp.weight * (split / 100) / targetIds.length * fanout,
868
944
  });
869
945
  }
870
946
  }
@@ -1063,6 +1139,7 @@ export function computeInfra(
1063
1139
  queueMetrics,
1064
1140
  properties: node.properties,
1065
1141
  tags: node.tags,
1142
+ description: node.description,
1066
1143
  lineNumber: node.lineNumber,
1067
1144
  };
1068
1145
  });
@@ -1093,6 +1170,7 @@ export function computeInfra(
1093
1170
  label: edge.label,
1094
1171
  computedRps: rps,
1095
1172
  split: resolvedSplit,
1173
+ fanout: edge.fanout,
1096
1174
  lineNumber: edge.lineNumber,
1097
1175
  };
1098
1176
  });
@@ -41,6 +41,7 @@ export interface InfraLayoutNode {
41
41
  properties: ComputedInfraNode['properties'];
42
42
  queueMetrics?: ComputedInfraNode['queueMetrics'];
43
43
  tags: Record<string, string>;
44
+ description?: string;
44
45
  lineNumber: number;
45
46
  }
46
47
 
@@ -50,6 +51,7 @@ export interface InfraLayoutEdge {
50
51
  label: string;
51
52
  computedRps: number;
52
53
  split: number;
54
+ fanout: number | null;
53
55
  points: { x: number; y: number }[];
54
56
  lineNumber: number;
55
57
  }
@@ -109,8 +111,8 @@ const DISPLAY_KEYS = new Set([
109
111
  /** Display names for width estimation. */
110
112
  const DISPLAY_NAMES: Record<string, string> = {
111
113
  'cache-hit': 'cache hit', 'firewall-block': 'fw block',
112
- 'ratelimit-rps': 'rate limit', 'latency-ms': 'latency', 'uptime': 'uptime',
113
- 'instances': 'instances', 'max-rps': 'capacity',
114
+ 'ratelimit-rps': 'rate limit RPS', 'latency-ms': 'latency', 'uptime': 'uptime',
115
+ 'instances': 'instances', 'max-rps': 'max RPS',
114
116
  'cb-error-threshold': 'CB error', 'cb-latency-threshold-ms': 'CB latency',
115
117
  'concurrency': 'concurrency', 'duration-ms': 'duration', 'cold-start-ms': 'cold start',
116
118
  'buffer': 'buffer', 'drain-rate': 'drain', 'retention-hours': 'retention', 'partitions': 'partitions',
@@ -133,13 +135,13 @@ function countDisplayProps(node: ComputedInfraNode, expanded: boolean, options?:
133
135
  return count;
134
136
  }
135
137
 
136
- /** Count computed rows shown below declared props. When expanded, shows p50/p90/p99; otherwise just p99. */
138
+ /** Count computed rows shown below declared props. When expanded, shows p50/p90/p99; otherwise just p90. */
137
139
  function countComputedRows(node: ComputedInfraNode, expanded: boolean): number {
138
140
  let count = 0;
139
141
  // Serverless instances row
140
142
  if (node.computedConcurrentInvocations > 0) count += 1;
141
143
  const p = node.computedLatencyPercentiles;
142
- if (p.p50 > 0 || p.p90 > 0 || p.p99 > 0) count += expanded ? 3 : 1; // all percentiles or just p99
144
+ if (p.p50 > 0 || p.p90 > 0 || p.p99 > 0) count += expanded ? 3 : 1; // all percentiles or just p90
143
145
  if (node.computedUptime < 1) {
144
146
  const declaredUptime = node.properties.find((p) => p.key === 'uptime');
145
147
  const declaredVal = declaredUptime ? Number(declaredUptime.value) / 100 : 1;
@@ -196,7 +198,7 @@ function computeNodeWidth(node: ComputedInfraNode, expanded: boolean, options?:
196
198
  if (expanded) {
197
199
  allKeys.push('p50', 'p90', 'p99');
198
200
  } else {
199
- allKeys.push('p99');
201
+ allKeys.push('p90');
200
202
  }
201
203
  }
202
204
  if (node.computedUptime < 1) {
@@ -253,16 +255,30 @@ function computeNodeWidth(node: ComputedInfraNode, expanded: boolean, options?:
253
255
  maxRowWidth = Math.max(maxRowWidth, (maxKeyLen + 2 + valLen) * META_CHAR_WIDTH);
254
256
  }
255
257
  }
256
- // Computed row widths (e.g., "p99: 120ms")
258
+ // Computed row widths (e.g., "p90: 520ms" or "p90: 520ms / 500ms" when SLO configured)
257
259
  if (computedRows > 0) {
258
260
  const perc = node.computedLatencyPercentiles;
259
- const msValues = expanded ? [perc.p50, perc.p90, perc.p99] : [perc.p99];
261
+ const msValues = expanded ? [perc.p50, perc.p90, perc.p99] : [perc.p90];
260
262
  for (const ms of msValues) {
261
263
  if (ms > 0) {
262
264
  const valLen = formatMs(ms).length;
263
265
  maxRowWidth = Math.max(maxRowWidth, (maxKeyLen + 2 + valLen) * META_CHAR_WIDTH);
264
266
  }
265
267
  }
268
+ // p90 may show "<current> / <threshold>" when non-green. Always reserve combined width
269
+ // so node width doesn't reflow when SLO state transitions from green to warning/overloaded.
270
+ if (perc.p90 > 0) {
271
+ const rawThreshold =
272
+ node.properties.find((p) => p.key === 'slo-p90-latency-ms')?.value ??
273
+ options?.['slo-p90-latency-ms'];
274
+ const threshold = rawThreshold != null ? parseFloat(String(rawThreshold)) : NaN;
275
+ if (!isNaN(threshold) && threshold > 0) {
276
+ // formatMs here must produce the same string as formatMsShort in renderer.ts — both are identical.
277
+ // If either changes, the reserved width and the rendered text will diverge.
278
+ const combinedVal = `${formatMs(perc.p90)} / ${formatMs(threshold)}`;
279
+ maxRowWidth = Math.max(maxRowWidth, (maxKeyLen + 2 + combinedVal.length) * META_CHAR_WIDTH);
280
+ }
281
+ }
266
282
  if (node.computedUptime < 1) {
267
283
  const valLen = formatUptime(node.computedUptime).length;
268
284
  maxRowWidth = Math.max(maxRowWidth, (maxKeyLen + 2 + valLen) * META_CHAR_WIDTH);
@@ -277,16 +293,21 @@ function computeNodeWidth(node: ComputedInfraNode, expanded: boolean, options?:
277
293
  }
278
294
  }
279
295
 
280
- return Math.max(MIN_NODE_WIDTH, labelWidth, maxRowWidth + 20);
296
+ const DESC_MAX_CHARS = 120;
297
+ const descText = (expanded && node.description && !node.isEdge) ? node.description : '';
298
+ const descTruncated = descText.length > DESC_MAX_CHARS ? descText.slice(0, DESC_MAX_CHARS - 1) + '…' : descText;
299
+ const descWidth = descTruncated.length > 0 ? descTruncated.length * META_CHAR_WIDTH + PADDING_X : 0;
300
+ return Math.max(MIN_NODE_WIDTH, labelWidth, maxRowWidth + 20, descWidth);
281
301
  }
282
302
 
283
303
  function computeNodeHeight(node: ComputedInfraNode, expanded: boolean, options?: Record<string, string>): number {
284
304
  const propCount = countDisplayProps(node, expanded, options);
285
305
  const computedCount = countComputedRows(node, expanded);
286
306
  const hasRps = node.computedRps > 0;
287
- if (propCount === 0 && computedCount === 0 && !hasRps) return NODE_HEADER_HEIGHT + NODE_PAD_BOTTOM;
307
+ const descH = expanded && node.description && !node.isEdge ? META_LINE_HEIGHT : 0;
308
+ if (propCount === 0 && computedCount === 0 && !hasRps) return NODE_HEADER_HEIGHT + descH + NODE_PAD_BOTTOM;
288
309
 
289
- let h = NODE_HEADER_HEIGHT + NODE_SEPARATOR_GAP;
310
+ let h = NODE_HEADER_HEIGHT + descH + NODE_SEPARATOR_GAP;
290
311
  // Computed section: RPS + computed rows
291
312
  const computedSectionCount = (hasRps ? 1 : 0) + computedCount;
292
313
  h += computedSectionCount * META_LINE_HEIGHT;
@@ -331,11 +352,69 @@ function formatUptime(fraction: number): string {
331
352
  return `${pct.toFixed(1)}%`;
332
353
  }
333
354
 
355
+ // ============================================================
356
+ // Group separation pass
357
+ // ============================================================
358
+
359
+ const GROUP_GAP = 24; // min clear gap between group boxes — matches GROUP_HEADER_HEIGHT
360
+
361
+ export function separateGroups(
362
+ groups: InfraLayoutGroup[],
363
+ nodes: InfraLayoutNode[],
364
+ isLR: boolean,
365
+ maxIterations = 20,
366
+ ): void {
367
+ // Symmetric 2D rectangle intersection — no sorting needed, handles all
368
+ // relative positions correctly, stable after mid-pass shifts.
369
+ // Endpoint edge routing is not affected: renderer.ts recomputes border
370
+ // connection points from node x/y at render time via nodeBorderPoint().
371
+ for (let iter = 0; iter < maxIterations; iter++) {
372
+ let anyOverlap = false;
373
+ for (let i = 0; i < groups.length; i++) {
374
+ for (let j = i + 1; j < groups.length; j++) {
375
+ const ga = groups[i];
376
+ const gb = groups[j];
377
+
378
+ // Symmetric primary-axis overlap (Y for LR, X for TB)
379
+ const primaryOverlap = isLR
380
+ ? Math.min(ga.y + ga.height, gb.y + gb.height) - Math.max(ga.y, gb.y)
381
+ : Math.min(ga.x + ga.width, gb.x + gb.width) - Math.max(ga.x, gb.x);
382
+ if (primaryOverlap <= 0) continue;
383
+
384
+ // Symmetric cross-axis overlap — boxes must intersect in 2D
385
+ const crossOverlap = isLR
386
+ ? Math.min(ga.x + ga.width, gb.x + gb.width) - Math.max(ga.x, gb.x)
387
+ : Math.min(ga.y + ga.height, gb.y + gb.height) - Math.max(ga.y, gb.y);
388
+ if (crossOverlap <= 0) continue;
389
+
390
+ anyOverlap = true;
391
+ const shift = primaryOverlap + GROUP_GAP;
392
+
393
+ // Shift the group with the larger primary-axis center (deterministic)
394
+ const aCenter = isLR ? ga.y + ga.height / 2 : ga.x + ga.width / 2;
395
+ const bCenter = isLR ? gb.y + gb.height / 2 : gb.x + gb.width / 2;
396
+ const groupToShift = aCenter <= bCenter ? gb : ga;
397
+
398
+ if (isLR) groupToShift.y += shift;
399
+ else groupToShift.x += shift;
400
+
401
+ for (const node of nodes) {
402
+ if (node.groupId === groupToShift.id) {
403
+ if (isLR) node.y += shift;
404
+ else node.x += shift;
405
+ }
406
+ }
407
+ }
408
+ }
409
+ if (!anyOverlap) break;
410
+ }
411
+ }
412
+
334
413
  // ============================================================
335
414
  // Layout engine
336
415
  // ============================================================
337
416
 
338
- export function layoutInfra(computed: ComputedInfraModel, selectedNodeId?: string | null, collapsedNodes?: Set<string> | null): InfraLayoutResult {
417
+ export function layoutInfra(computed: ComputedInfraModel, expandedNodeIds?: Set<string> | null, collapsedNodes?: Set<string> | null): InfraLayoutResult {
339
418
  if (computed.nodes.length === 0) {
340
419
  return { nodes: [], edges: [], groups: [], options: {}, width: 0, height: 0 };
341
420
  }
@@ -364,7 +443,7 @@ export function layoutInfra(computed: ComputedInfraModel, selectedNodeId?: strin
364
443
  const heightMap = new Map<string, number>();
365
444
  for (const node of computed.nodes) {
366
445
  const isNodeCollapsed = collapsedNodes?.has(node.id) ?? false;
367
- const expanded = !isNodeCollapsed && node.id === selectedNodeId;
446
+ const expanded = !isNodeCollapsed && (expandedNodeIds?.has(node.id) ?? false);
368
447
  const width = computeNodeWidth(node, expanded, computed.options);
369
448
  const height = isNodeCollapsed
370
449
  ? NODE_HEADER_HEIGHT + NODE_PAD_BOTTOM
@@ -437,6 +516,7 @@ export function layoutInfra(computed: ComputedInfraModel, selectedNodeId?: strin
437
516
  queueMetrics: node.queueMetrics,
438
517
  properties: node.properties,
439
518
  tags: node.tags,
519
+ description: node.description,
440
520
  lineNumber: node.lineNumber,
441
521
  };
442
522
  });
@@ -454,6 +534,7 @@ export function layoutInfra(computed: ComputedInfraModel, selectedNodeId?: strin
454
534
  label: edge.label,
455
535
  computedRps: edge.computedRps,
456
536
  split: edge.split,
537
+ fanout: edge.fanout,
457
538
  points: edgeData?.points ?? [],
458
539
  lineNumber: edge.lineNumber,
459
540
  });
@@ -465,6 +546,7 @@ export function layoutInfra(computed: ComputedInfraModel, selectedNodeId?: strin
465
546
  label: edge.label,
466
547
  computedRps: edge.computedRps,
467
548
  split: edge.split,
549
+ fanout: edge.fanout,
468
550
  points: edgeData?.points ?? [],
469
551
  lineNumber: edge.lineNumber,
470
552
  });
@@ -509,6 +591,9 @@ export function layoutInfra(computed: ComputedInfraModel, selectedNodeId?: strin
509
591
  };
510
592
  });
511
593
 
594
+ // Separate overlapping groups (post-layout pass)
595
+ separateGroups(layoutGroups, layoutNodes, isLR);
596
+
512
597
  // Compute total dimensions
513
598
  let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
514
599
  for (const node of layoutNodes) {
@@ -23,13 +23,13 @@ import { INFRA_BEHAVIOR_KEYS, EDGE_ONLY_KEYS } from './types';
23
23
  // Regex patterns
24
24
  // ============================================================
25
25
 
26
- // Connection: -label-> Target or -> Target (with optional | split: N%)
26
+ // Connection: -label-> Target or -> Target (with optional | split: N% and optional x5 fanout)
27
27
  const CONNECTION_RE =
28
- /^-(?:([^-].*?))?->\s+(.+?)(?:(?:\s*\|\s*|\s+)split\s*:?\s*(\d+)%)?\s*$/;
28
+ /^-(?:([^-].*?))?->\s+(.+?)(?:(?:\s*\|\s*|\s+)split\s*:?\s*(\d+)%)?\s*(?:x(\d+))?\s*$/;
29
29
 
30
30
  // Simple connection shorthand: -> Target (no label, no dash prefix needed for edge)
31
31
  const SIMPLE_CONNECTION_RE =
32
- /^->\s+(.+?)(?:(?:\s*\|\s*|\s+)split\s*:?\s*(\d+)%)?\s*$/;
32
+ /^->\s+(.+?)(?:(?:\s*\|\s*|\s+)split\s*:?\s*(\d+)%)?\s*(?:x(\d+))?\s*$/;
33
33
 
34
34
  // Group declaration: [Group Name]
35
35
  const GROUP_RE = /^\[([^\]]+)\]$/;
@@ -41,7 +41,8 @@ const TAG_GROUP_RE = /^tag\s*:\s*(\w[\w\s]*?)(?:\s+alias\s+(\w+))?\s*$/;
41
41
  const TAG_VALUE_RE = /^(\w[\w\s]*?)(?:\(([^)]+)\))?(\s+default)?\s*$/;
42
42
 
43
43
  // Component line: ComponentName or ComponentName | t: Backend | env: Prod
44
- const COMPONENT_RE = /^([a-zA-Z_][\w]*)(.*)$/;
44
+ // Allows hyphens in names (e.g. api-gateway, my-service-v2) — but not at the start.
45
+ const COMPONENT_RE = /^([a-zA-Z_][\w-]*)(.*)$/;
45
46
 
46
47
  // Pipe metadata: | key: value or | k1: v1, k2: v2 (comma-separated)
47
48
  const PIPE_META_RE = /[|,]\s*(\w+)\s*:\s*([^|,]+)/g;
@@ -231,6 +232,24 @@ export function parseInfra(content: string): ParsedInfra {
231
232
  continue;
232
233
  }
233
234
 
235
+ // slo-availability: <percentage e.g. 99.9%>
236
+ if (/^slo-availability\s*:/i.test(trimmed)) {
237
+ result.options['slo-availability'] = trimmed.replace(/^slo-availability\s*:\s*/i, '').trim();
238
+ continue;
239
+ }
240
+
241
+ // slo-p90-latency-ms: <number>
242
+ if (/^slo-p90-latency-ms\s*:/i.test(trimmed)) {
243
+ result.options['slo-p90-latency-ms'] = trimmed.replace(/^slo-p90-latency-ms\s*:\s*/i, '').trim();
244
+ continue;
245
+ }
246
+
247
+ // slo-warning-margin: <percentage e.g. 5%>
248
+ if (/^slo-warning-margin\s*:/i.test(trimmed)) {
249
+ result.options['slo-warning-margin'] = trimmed.replace(/^slo-warning-margin\s*:\s*/i, '').trim();
250
+ continue;
251
+ }
252
+
234
253
  // scenario: Name
235
254
  if (/^scenario\s*:/i.test(trimmed)) {
236
255
  finishCurrentNode();
@@ -397,12 +416,19 @@ export function parseInfra(content: string): ParsedInfra {
397
416
  if (simpleConn) {
398
417
  const targetName = simpleConn[1].trim();
399
418
  const splitStr = simpleConn[2];
419
+ const fanoutStr = simpleConn[3];
400
420
  const split = splitStr ? parseFloat(splitStr) : null;
421
+ const fanoutRaw = fanoutStr ? parseInt(fanoutStr, 10) : null;
422
+ if (fanoutRaw !== null && fanoutRaw < 1) {
423
+ warn(lineNumber, `Fan-out multiplier must be at least 1 (got x${fanoutRaw}). Ignoring.`);
424
+ }
425
+ const fanout = fanoutRaw !== null && fanoutRaw >= 1 ? fanoutRaw : null;
401
426
  result.edges.push({
402
427
  sourceId: currentNode.id,
403
428
  targetId: nodeId(targetName),
404
429
  label: '',
405
430
  split,
431
+ fanout,
406
432
  lineNumber,
407
433
  });
408
434
  continue;
@@ -414,7 +440,13 @@ export function parseInfra(content: string): ParsedInfra {
414
440
  const label = connMatch[1]?.trim() || '';
415
441
  const targetName = connMatch[2].trim();
416
442
  const splitStr = connMatch[3];
443
+ const fanoutStr = connMatch[4];
417
444
  const split = splitStr ? parseFloat(splitStr) : null;
445
+ const fanoutRaw = fanoutStr ? parseInt(fanoutStr, 10) : null;
446
+ if (fanoutRaw !== null && fanoutRaw < 1) {
447
+ warn(lineNumber, `Fan-out multiplier must be at least 1 (got x${fanoutRaw}). Ignoring.`);
448
+ }
449
+ const fanout = fanoutRaw !== null && fanoutRaw >= 1 ? fanoutRaw : null;
418
450
 
419
451
  // Target might be a group ref like [API Pods]
420
452
  let targetId: string;
@@ -430,17 +462,28 @@ export function parseInfra(content: string): ParsedInfra {
430
462
  targetId,
431
463
  label,
432
464
  split,
465
+ fanout,
433
466
  lineNumber,
434
467
  });
435
468
  continue;
436
469
  }
437
470
 
471
+ // Empty description: (no value) — silently skip rather than emitting "Unexpected line"
472
+ if (/^description\s*:\s*$/i.test(trimmed)) continue;
473
+
438
474
  // Property: key: value
439
475
  const propMatch = trimmed.match(PROPERTY_RE);
440
476
  if (propMatch) {
441
477
  const key = propMatch[1].toLowerCase();
442
478
  const rawVal = propMatch[2].trim();
443
479
 
480
+ // description is display metadata, not a behavior key; silently ignored on edge nodes.
481
+ // Single-line only — no length enforcement, but keep it short for legibility.
482
+ if (key === 'description' && currentNode) {
483
+ if (!currentNode.isEdge) currentNode.description = rawVal;
484
+ continue;
485
+ }
486
+
444
487
  // Validate property key
445
488
  if (!INFRA_BEHAVIOR_KEYS.has(key) && !EDGE_ONLY_KEYS.has(key)) {
446
489
  const allKeys = [...INFRA_BEHAVIOR_KEYS, ...EDGE_ONLY_KEYS];