@diagrammo/dgmo 0.47.0 → 0.48.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (319) hide show
  1. package/dist/auto.js +1 -1
  2. package/dist/auto.mjs +1 -1
  3. package/dist/element.js +1 -1
  4. package/dist/element.mjs +1 -1
  5. package/package.json +19 -70
  6. package/dist/advanced.cjs +0 -71648
  7. package/dist/advanced.d.cts +0 -5719
  8. package/dist/auto.cjs +0 -70648
  9. package/dist/auto.d.cts +0 -54
  10. package/dist/block.cjs +0 -70208
  11. package/dist/block.d.cts +0 -115
  12. package/dist/editor.cjs +0 -526
  13. package/dist/editor.d.cts +0 -26
  14. package/dist/element.cjs +0 -70530
  15. package/dist/element.d.cts +0 -62
  16. package/dist/highlight.cjs +0 -852
  17. package/dist/highlight.d.cts +0 -49
  18. package/dist/index.cjs +0 -70491
  19. package/dist/index.d.cts +0 -160
  20. package/dist/pert.cjs +0 -325
  21. package/dist/pert.d.cts +0 -559
  22. package/dist/themes-1-CuKpeH.d.cts +0 -908
  23. package/src/advanced.ts +0 -873
  24. package/src/arc/parser.ts +0 -15
  25. package/src/arc/renderer.ts +0 -633
  26. package/src/auto/index.ts +0 -621
  27. package/src/auto/shared.ts +0 -370
  28. package/src/auto/styles.ts +0 -73
  29. package/src/block/layout.ts +0 -198
  30. package/src/block/parser.ts +0 -427
  31. package/src/block/renderer.ts +0 -484
  32. package/src/block/types.ts +0 -67
  33. package/src/boxes-and-lines/collapse.ts +0 -96
  34. package/src/boxes-and-lines/focus.ts +0 -257
  35. package/src/boxes-and-lines/label-placement.ts +0 -339
  36. package/src/boxes-and-lines/layout-grouped.ts +0 -912
  37. package/src/boxes-and-lines/layout-layered.ts +0 -722
  38. package/src/boxes-and-lines/layout-search.ts +0 -1678
  39. package/src/boxes-and-lines/layout.ts +0 -398
  40. package/src/boxes-and-lines/parser.ts +0 -1181
  41. package/src/boxes-and-lines/renderer.ts +0 -1465
  42. package/src/boxes-and-lines/types.ts +0 -66
  43. package/src/c4/layout.ts +0 -2441
  44. package/src/c4/parser.ts +0 -1158
  45. package/src/c4/renderer.ts +0 -2008
  46. package/src/c4/types.ts +0 -89
  47. package/src/chart-type-registry.ts +0 -529
  48. package/src/chart-types.ts +0 -225
  49. package/src/chart.ts +0 -685
  50. package/src/charts-d3/bar.ts +0 -338
  51. package/src/charts-d3/chord.ts +0 -140
  52. package/src/charts-d3/function.ts +0 -147
  53. package/src/charts-d3/funnel.ts +0 -104
  54. package/src/charts-d3/heatmap.ts +0 -145
  55. package/src/charts-d3/index.ts +0 -337
  56. package/src/charts-d3/interactions.ts +0 -477
  57. package/src/charts-d3/line.ts +0 -344
  58. package/src/charts-d3/mount.ts +0 -151
  59. package/src/charts-d3/pie.ts +0 -117
  60. package/src/charts-d3/polar.ts +0 -89
  61. package/src/charts-d3/radar.ts +0 -0
  62. package/src/charts-d3/sankey.ts +0 -0
  63. package/src/charts-d3/scatter.ts +0 -185
  64. package/src/charts-d3/shared.ts +0 -208
  65. package/src/class/layout.ts +0 -326
  66. package/src/class/parser.ts +0 -635
  67. package/src/class/renderer.ts +0 -793
  68. package/src/class/types.ts +0 -58
  69. package/src/cli-banner.ts +0 -106
  70. package/src/cli.ts +0 -1080
  71. package/src/colors-diagnostics.ts +0 -35
  72. package/src/colors.ts +0 -379
  73. package/src/completion-types.ts +0 -27
  74. package/src/completion.ts +0 -2668
  75. package/src/cycle/layout.ts +0 -1026
  76. package/src/cycle/parser.ts +0 -387
  77. package/src/cycle/renderer.ts +0 -653
  78. package/src/cycle/types.ts +0 -101
  79. package/src/d3.ts +0 -1434
  80. package/src/data-chart-parser.ts +0 -1007
  81. package/src/dgmo-router.ts +0 -296
  82. package/src/diagnostics-registry.ts +0 -108
  83. package/src/diagnostics.ts +0 -0
  84. package/src/dimensions.ts +0 -20
  85. package/src/directives-registry.ts +0 -396
  86. package/src/editor/dgmo.grammar +0 -76
  87. package/src/editor/dgmo.grammar.d.ts +0 -2
  88. package/src/editor/dgmo.grammar.js +0 -18
  89. package/src/editor/dgmo.grammar.terms.d.ts +0 -5
  90. package/src/editor/dgmo.grammar.terms.js +0 -35
  91. package/src/editor/highlight-api.ts +0 -596
  92. package/src/editor/highlight.ts +0 -36
  93. package/src/editor/index.ts +0 -27
  94. package/src/editor/keywords.ts +0 -128
  95. package/src/editor/tokens.ts +0 -30
  96. package/src/element/index.ts +0 -432
  97. package/src/embed/css.ts +0 -312
  98. package/src/embed/escape.ts +0 -15
  99. package/src/embed/index.ts +0 -280
  100. package/src/er/classify.ts +0 -228
  101. package/src/er/layout.ts +0 -530
  102. package/src/er/parser.ts +0 -750
  103. package/src/er/renderer.ts +0 -725
  104. package/src/er/types.ts +0 -52
  105. package/src/error-card.ts +0 -269
  106. package/src/event-line/diagnostics.ts +0 -117
  107. package/src/event-line/parser.ts +0 -643
  108. package/src/event-line/renderer.ts +0 -1742
  109. package/src/event-line/types.ts +0 -74
  110. package/src/fonts.ts +0 -3
  111. package/src/gantt/calculator.ts +0 -979
  112. package/src/gantt/parser.ts +0 -1641
  113. package/src/gantt/renderer.ts +0 -3787
  114. package/src/gantt/resolver.ts +0 -172
  115. package/src/gantt/types.ts +0 -206
  116. package/src/graph/diagnostics.ts +0 -64
  117. package/src/graph/edge-spline.ts +0 -29
  118. package/src/graph/flowchart-parser.ts +0 -759
  119. package/src/graph/flowchart-renderer.ts +0 -817
  120. package/src/graph/layout.ts +0 -472
  121. package/src/graph/notes.ts +0 -21
  122. package/src/graph/state-collapse.ts +0 -78
  123. package/src/graph/state-parser.ts +0 -574
  124. package/src/graph/state-renderer.ts +0 -653
  125. package/src/graph/types.ts +0 -65
  126. package/src/index.ts +0 -261
  127. package/src/infra/compute.ts +0 -1385
  128. package/src/infra/layout.ts +0 -888
  129. package/src/infra/parser.ts +0 -1234
  130. package/src/infra/renderer.ts +0 -2534
  131. package/src/infra/roles.ts +0 -75
  132. package/src/infra/types.ts +0 -216
  133. package/src/infra/validation.ts +0 -201
  134. package/src/journey-map/layout.ts +0 -490
  135. package/src/journey-map/parser.ts +0 -557
  136. package/src/journey-map/renderer.ts +0 -1580
  137. package/src/journey-map/types.ts +0 -47
  138. package/src/kanban/mutations.ts +0 -175
  139. package/src/kanban/parser.ts +0 -509
  140. package/src/kanban/renderer.ts +0 -1449
  141. package/src/kanban/types.ts +0 -39
  142. package/src/label-layout.ts +0 -368
  143. package/src/map/colorize.ts +0 -54
  144. package/src/map/completion.ts +0 -316
  145. package/src/map/context-labels.ts +0 -609
  146. package/src/map/data/PROVENANCE.json +0 -1
  147. package/src/map/data/README.md +0 -40
  148. package/src/map/data/airport-collisions.json +0 -1
  149. package/src/map/data/airports.json +0 -1
  150. package/src/map/data/gazetteer.json +0 -1
  151. package/src/map/data/lakes.json +0 -1
  152. package/src/map/data/mountain-ranges.json +0 -1
  153. package/src/map/data/na-lakes.json +0 -1
  154. package/src/map/data/na-land.json +0 -1
  155. package/src/map/data/region-names.json +0 -1
  156. package/src/map/data/rivers.json +0 -1
  157. package/src/map/data/types.ts +0 -121
  158. package/src/map/data/us-states.json +0 -1
  159. package/src/map/data/water-bodies.json +0 -1
  160. package/src/map/data/world-coarse.json +0 -1
  161. package/src/map/data/world-detail.json +0 -1
  162. package/src/map/diagnostics.ts +0 -134
  163. package/src/map/dimensions.ts +0 -150
  164. package/src/map/geo-query.ts +0 -295
  165. package/src/map/geo.ts +0 -438
  166. package/src/map/invert.ts +0 -111
  167. package/src/map/layout.ts +0 -5069
  168. package/src/map/legend-band.ts +0 -127
  169. package/src/map/load-data.ts +0 -186
  170. package/src/map/parser.ts +0 -783
  171. package/src/map/renderer.ts +0 -1445
  172. package/src/map/resolved-types.ts +0 -160
  173. package/src/map/resolver.ts +0 -1146
  174. package/src/map/types.ts +0 -194
  175. package/src/mindmap/collapse.ts +0 -67
  176. package/src/mindmap/layout.ts +0 -649
  177. package/src/mindmap/parser.ts +0 -430
  178. package/src/mindmap/renderer.ts +0 -637
  179. package/src/mindmap/text-wrap.ts +0 -218
  180. package/src/mindmap/types.ts +0 -56
  181. package/src/org/collapse.ts +0 -151
  182. package/src/org/layout.ts +0 -1262
  183. package/src/org/parser.ts +0 -519
  184. package/src/org/renderer.ts +0 -866
  185. package/src/org/resolver.ts +0 -636
  186. package/src/palettes/atlas.ts +0 -77
  187. package/src/palettes/blueprint.ts +0 -73
  188. package/src/palettes/catppuccin.ts +0 -84
  189. package/src/palettes/color-utils.ts +0 -443
  190. package/src/palettes/index.ts +0 -67
  191. package/src/palettes/nord.ts +0 -67
  192. package/src/palettes/registry.ts +0 -114
  193. package/src/palettes/slate.ts +0 -73
  194. package/src/palettes/tidewater.ts +0 -73
  195. package/src/palettes/tokyo-night.ts +0 -86
  196. package/src/palettes/types.ts +0 -88
  197. package/src/pert/analyzer.ts +0 -1183
  198. package/src/pert/diagnostics.ts +0 -110
  199. package/src/pert/index.ts +0 -43
  200. package/src/pert/internal.ts +0 -252
  201. package/src/pert/layout.ts +0 -971
  202. package/src/pert/monte-carlo.ts +0 -446
  203. package/src/pert/parser.ts +0 -1606
  204. package/src/pert/renderer.ts +0 -4009
  205. package/src/pert/share-normalize.ts +0 -62
  206. package/src/pert/types.ts +0 -604
  207. package/src/pyramid/parser.ts +0 -185
  208. package/src/pyramid/renderer.ts +0 -768
  209. package/src/pyramid/types.ts +0 -28
  210. package/src/quadrant/parser.ts +0 -18
  211. package/src/quadrant/renderer.ts +0 -638
  212. package/src/raci/diagnostics.ts +0 -213
  213. package/src/raci/index.ts +0 -23
  214. package/src/raci/mutations.ts +0 -321
  215. package/src/raci/parser.ts +0 -699
  216. package/src/raci/renderer.ts +0 -1627
  217. package/src/raci/types.ts +0 -82
  218. package/src/raci/variants.ts +0 -320
  219. package/src/render.ts +0 -268
  220. package/src/ring/parser.ts +0 -212
  221. package/src/ring/renderer.ts +0 -490
  222. package/src/ring/types.ts +0 -27
  223. package/src/sequence/collapse.ts +0 -174
  224. package/src/sequence/parser.ts +0 -1596
  225. package/src/sequence/participant-inference.ts +0 -152
  226. package/src/sequence/renderer.ts +0 -3168
  227. package/src/sequence/tag-resolution.ts +0 -164
  228. package/src/sharing.ts +0 -211
  229. package/src/sitemap/collapse.ts +0 -100
  230. package/src/sitemap/layout.ts +0 -925
  231. package/src/sitemap/parser.ts +0 -755
  232. package/src/sitemap/renderer.ts +0 -828
  233. package/src/sitemap/types.ts +0 -42
  234. package/src/slope/parser.ts +0 -18
  235. package/src/slope/renderer.ts +0 -378
  236. package/src/swimlane/diagnostics.ts +0 -94
  237. package/src/swimlane/layout.ts +0 -597
  238. package/src/swimlane/parser.ts +0 -613
  239. package/src/swimlane/renderer.ts +0 -681
  240. package/src/swimlane/types.ts +0 -151
  241. package/src/tech-radar/interactive.ts +0 -1129
  242. package/src/tech-radar/layout.ts +0 -197
  243. package/src/tech-radar/parser.ts +0 -436
  244. package/src/tech-radar/renderer.ts +0 -1239
  245. package/src/tech-radar/shared.ts +0 -151
  246. package/src/tech-radar/types.ts +0 -86
  247. package/src/themes.ts +0 -22
  248. package/src/timeline/parser.ts +0 -472
  249. package/src/timeline/renderer.ts +0 -3024
  250. package/src/timeline/types.ts +0 -33
  251. package/src/timeline/viz-parser.ts +0 -21
  252. package/src/treemap/diagnostics.ts +0 -62
  253. package/src/treemap/layout-radial.ts +0 -233
  254. package/src/treemap/layout.ts +0 -189
  255. package/src/treemap/parser.ts +0 -463
  256. package/src/treemap/renderer-radial.ts +0 -438
  257. package/src/treemap/renderer.ts +0 -572
  258. package/src/treemap/treemap-shared.ts +0 -309
  259. package/src/treemap/types.ts +0 -60
  260. package/src/utils/arrow-markers.ts +0 -51
  261. package/src/utils/arrows-diagnostics.ts +0 -62
  262. package/src/utils/arrows.ts +0 -227
  263. package/src/utils/brand.ts +0 -61
  264. package/src/utils/card.ts +0 -183
  265. package/src/utils/collapse-engine/tree-edges.ts +0 -115
  266. package/src/utils/collapse-engine/tree.ts +0 -117
  267. package/src/utils/d3-helpers.ts +0 -219
  268. package/src/utils/d3-types.ts +0 -9
  269. package/src/utils/description-helpers.ts +0 -38
  270. package/src/utils/duration.ts +0 -312
  271. package/src/utils/export-container.ts +0 -41
  272. package/src/utils/expr-eval.ts +0 -27
  273. package/src/utils/fit-canvas.ts +0 -64
  274. package/src/utils/hover-styles.ts +0 -673
  275. package/src/utils/inline-markdown.ts +0 -96
  276. package/src/utils/legend-constants.ts +0 -48
  277. package/src/utils/legend-d3.ts +0 -618
  278. package/src/utils/legend-integration.ts +0 -107
  279. package/src/utils/legend-layout.ts +0 -776
  280. package/src/utils/legend-svg.ts +0 -214
  281. package/src/utils/legend-types.ts +0 -272
  282. package/src/utils/name-normalize.ts +0 -163
  283. package/src/utils/note-box/constants.ts +0 -25
  284. package/src/utils/note-box/index.ts +0 -11
  285. package/src/utils/note-box/metrics.ts +0 -90
  286. package/src/utils/note-box/svg.ts +0 -331
  287. package/src/utils/notes/bounds.ts +0 -30
  288. package/src/utils/notes/build.ts +0 -131
  289. package/src/utils/notes/index.ts +0 -18
  290. package/src/utils/notes/model.ts +0 -19
  291. package/src/utils/notes/parse.ts +0 -131
  292. package/src/utils/notes/place.ts +0 -177
  293. package/src/utils/notes/resolve.ts +0 -88
  294. package/src/utils/number-format.ts +0 -36
  295. package/src/utils/parsing.ts +0 -953
  296. package/src/utils/reserved-key-registry.ts +0 -239
  297. package/src/utils/safe-href.ts +0 -38
  298. package/src/utils/scaling.ts +0 -97
  299. package/src/utils/svg-embed.ts +0 -251
  300. package/src/utils/tag-groups.ts +0 -607
  301. package/src/utils/text-measure.ts +0 -122
  302. package/src/utils/time-ticks.ts +0 -253
  303. package/src/utils/title-constants.ts +0 -24
  304. package/src/utils/visual-conventions.ts +0 -61
  305. package/src/utils/wrapped-desc.ts +0 -87
  306. package/src/venn/parser.ts +0 -18
  307. package/src/venn/renderer.ts +0 -1047
  308. package/src/version-control/diagnostics.ts +0 -67
  309. package/src/version-control/parser.ts +0 -544
  310. package/src/version-control/renderer.ts +0 -724
  311. package/src/version-control/types.ts +0 -100
  312. package/src/visualizations/parse.ts +0 -1560
  313. package/src/visualizations/types.ts +0 -262
  314. package/src/wireframe/layout.ts +0 -479
  315. package/src/wireframe/parser.ts +0 -1114
  316. package/src/wireframe/renderer.ts +0 -1318
  317. package/src/wireframe/types.ts +0 -110
  318. package/src/wordcloud/parser.ts +0 -18
  319. package/src/wordcloud/renderer.ts +0 -357
@@ -1,1234 +0,0 @@
1
- // ============================================================
2
- // Infra Chart Parser
3
- // ============================================================
4
- //
5
- // Parses `infra [Title]` 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 { tagAttrKey } from '../utils/tag-groups';
10
- import {
11
- emit,
12
- formatDgmoError,
13
- makeDgmoError,
14
- NAME_DIAGNOSTIC_CODES,
15
- nameMergedMessage,
16
- suggest,
17
- } from '../diagnostics';
18
- import { GRAPH_DX } from '../graph/diagnostics';
19
- import { tryStripDescriptionKeyword } from '../utils/description-helpers';
20
- import { parseInArrowLabel } from '../utils/arrows';
21
- import {
22
- measureIndent,
23
- parseFirstLine,
24
- OPTION_NOCOLON_RE,
25
- tryParseSharedOption,
26
- warnUnknownMetaKeys,
27
- } from '../utils/parsing';
28
- import { isRecognizedColorName } from '../colors';
29
- import { normalizeName, displayName } from '../utils/name-normalize';
30
- import {
31
- matchTagBlockHeading,
32
- stripDefaultModifier,
33
- validateTagGroupNames,
34
- } from '../utils/tag-groups';
35
- import { INFRA_REGISTRY, withTagAliases } from '../utils/reserved-key-registry';
36
- import type {
37
- ParsedInfra,
38
- InfraNode,
39
- InfraGroup,
40
- InfraTagGroup,
41
- } from './types';
42
- import { INFRA_BEHAVIOR_KEYS, EDGE_ONLY_KEYS } from './types';
43
- import { INFRA_TOP_LEVEL_OPTION_SET } from '../directives-registry';
44
- import type { Writable } from '../utils/brand';
45
-
46
- // ============================================================
47
- // Regex patterns
48
- // ============================================================
49
-
50
- // Connection: -label-> Target or -> Target (pipe metadata handled by extractPipeMetadata)
51
- const CONNECTION_RE = /^-(?:([^-].*?))?->\s*(.+?)\s*$/;
52
-
53
- // Simple connection shorthand: -> Target (no label, no dash prefix needed for edge)
54
- const SIMPLE_CONNECTION_RE = /^->\s*(.+?)\s*$/;
55
-
56
- // Async connection: ~label~> Target or ~> Target
57
- const ASYNC_CONNECTION_RE = /^~(?:([^~].*?))?~>\s*(.+?)\s*$/;
58
-
59
- // Async simple connection shorthand: ~> Target
60
- const ASYNC_SIMPLE_CONNECTION_RE = /^~>\s*(.+?)\s*$/;
61
-
62
- // Deprecated xN fanout suffix (e.g. "x5" at end of line)
63
- const DEPRECATED_FANOUT_RE = /\bx(\d+)\s*$/;
64
-
65
- // Group declaration: [Group Name] with optional `as <alias>` and optional
66
- // metadata (legacy `| ...` or new same-line `key: value` per §1.4).
67
- // Capture order: 1=label, 2=alias, 3=metadata tail.
68
- const GROUP_RE =
69
- /^\[([^\]]+)\]\s*(?:as\s+([A-Za-z][A-Za-z0-9_]{0,11})\s*)?((?:\|.*)|(?:[a-zA-Z_]\w*\s*:.*))?$/;
70
-
71
- // Tag value: `Name` or `Name color` (trailing-token color form). Color is
72
- // extracted via the shared `extractColor` helper at use-site (see
73
- // `dgmo/src/utils/parsing.ts:extractColor`), not via this regex. This regex
74
- // just confirms the line shape is a valid tag value (no reserved sigils).
75
- const TAG_VALUE_RE = /^(\w[\w\s]+?)\s*$/;
76
-
77
- // Component line. Accepts either a quoted name ("name with | : reserved chars")
78
- // or a bare name (multi-word allowed; must start with letter/underscore so digit-
79
- // only or sigil-led lines fall through to other branches). Metadata follows
80
- // after a `|` separator (legacy) or as same-line `key: value` (§1.4).
81
- //
82
- // Named captures: qname (quoted content), uname (bare name), alias (`as <x>`
83
- // postfix), meta (the metadata tail). qname/uname are mutually exclusive.
84
- //
85
- // A component declaration: a quoted display name OR a bare name, an OPTIONAL
86
- // `as <alias>` postfix (alias mirrors peelAlias: letter-led, ≤12 chars), then
87
- // optional same-line metadata. The `as` capture is what makes a quoted name +
88
- // alias parse — `"Web App" as web` — which the bare-name path got for free by
89
- // sweeping the alias into the lazy name group and peeling it later, but the
90
- // quoted path could not reach past the closing quote.
91
- const COMPONENT_RE =
92
- /^(?:"(?<qname>[^"]+)"|(?<uname>[a-zA-Z_][\w -]*?))(?:\s+as\s+(?<alias>[A-Za-z][A-Za-z0-9_]{0,11}))?\s*(?<meta>(?:\|.*)|(?:[a-zA-Z_]\w*\s*:.*))?$/;
93
-
94
- // Legacy pipe metadata: | key: value or | k1: v1, k2: v2 (comma-separated).
95
- // Kept for transitional back-compat extraction; new same-line metadata
96
- // (§1.4) is preferred and extracted via SAMELINE_META_RE below.
97
- const PIPE_META_RE = /[|,]\s*(\w+)\s*:\s*([^|,]+)/g;
98
- // Same-line metadata (post-0.18.0 §1.4): `k: v, k2: v2` after any name
99
- // region — matches a colon-bearing key-value, comma-separated.
100
- const SAMELINE_META_RE = /(?:^|[\s,])(\w+)\s*:\s*([^,]+?)(?=\s*,|\s*$)/g;
101
-
102
- // Property: key: value (colon-separated)
103
- const PROPERTY_RE = /^([\w-]+):\s*(.+)$/;
104
-
105
- // Percentage value: 80% or 99.99%
106
- const PERCENT_RE = /^([\d.]+)%$/;
107
-
108
- // Range value: N-M (for instances)
109
- const RANGE_RE = /^(\d+)-(\d+)$/;
110
-
111
- // Node names that act as the traffic entry point (edge node)
112
- const EDGE_NODE_NAMES = new Set(['edge', 'internet']);
113
-
114
- // Known top-level option keys (space-separated, no colon). Derived from the
115
- // single-source directives registry; the anti-drift guard
116
- // (tests/highlight-coverage.test.ts) cross-checks it against the editor sets.
117
- export const INFRA_TOP_LEVEL_OPTIONS: ReadonlySet<string> =
118
- INFRA_TOP_LEVEL_OPTION_SET;
119
- const TOP_LEVEL_OPTIONS = INFRA_TOP_LEVEL_OPTIONS;
120
-
121
- // ============================================================
122
- // Helpers
123
- // ============================================================
124
-
125
- function nodeId(name: string): string {
126
- return normalizeName(name);
127
- }
128
-
129
- function groupId(name: string): string {
130
- return `[${normalizeName(name)}]`;
131
- }
132
-
133
- function parsePropertyValue(raw: string): string | number {
134
- const pct = raw.match(PERCENT_RE);
135
- // Capture group 1 in-bounds after successful match.
136
- if (pct) return parseFloat(pct[1]!);
137
-
138
- const num = parseFloat(raw);
139
- if (!isNaN(num) && String(num) === raw.trim()) return num;
140
-
141
- return raw.trim();
142
- }
143
-
144
- function extractPipeMetadata(rest: string): {
145
- tags: Record<string, string>;
146
- clean: string;
147
- hadPipe: boolean;
148
- } {
149
- const tags: Record<string, string> = {};
150
- let clean = rest;
151
- let match: RegExpExecArray | null;
152
- const hadPipe = rest.includes('|');
153
- if (hadPipe) {
154
- PIPE_META_RE.lastIndex = 0;
155
- while ((match = PIPE_META_RE.exec(rest)) !== null) {
156
- tags[match[1]!.trim()] = match[2]!.trim();
157
- clean = clean.replace(match[0], '');
158
- }
159
- return { tags, clean: clean.trim(), hadPipe: true };
160
- }
161
- // §1.4 same-line metadata: no pipe, but `key: value, key2: value2`
162
- // may follow the name. Extract from right-to-left so the leftmost
163
- // name region is preserved as `clean`.
164
- SAMELINE_META_RE.lastIndex = 0;
165
- let firstMatchIdx = -1;
166
- while ((match = SAMELINE_META_RE.exec(rest)) !== null) {
167
- tags[match[1]!.trim()] = match[2]!.trim();
168
- if (firstMatchIdx === -1) firstMatchIdx = match.index;
169
- }
170
- if (firstMatchIdx >= 0) {
171
- clean = rest.substring(0, firstMatchIdx).trim();
172
- }
173
- return { tags, clean: clean.trim(), hadPipe: false };
174
- }
175
-
176
- // Detect unparsed pipe metadata left in a target name after extractPipeMetadata.
177
- // Common case: `split 100%` without a colon isn't picked up by PIPE_META_RE.
178
- const UNPARSED_SPLIT_RE = /\bsplit\s+(\d+)%/;
179
-
180
- function warnUnparsedPipeMeta(
181
- targetName: string,
182
- lineNumber: number,
183
- warnFn: (line: number, message: string) => void
184
- ): void {
185
- if (!targetName.includes('|')) return;
186
- const splitMatch = targetName.match(UNPARSED_SPLIT_RE);
187
- if (splitMatch) {
188
- warnFn(
189
- lineNumber,
190
- `'split ${splitMatch[1]}%' needs a colon — use 'split: ${splitMatch[1]}%'`
191
- );
192
- } else {
193
- warnFn(
194
- lineNumber,
195
- `Unparsed pipe metadata in target — pipe values use 'key: value' syntax`
196
- );
197
- }
198
- }
199
-
200
- // ============================================================
201
- // Parser
202
- // ============================================================
203
-
204
- export function parseInfra(content: string): ParsedInfra {
205
- const lines = content.split('\n');
206
- const options: Record<string, string> = {};
207
- const result: Writable<ParsedInfra> = {
208
- type: 'infra',
209
- title: null,
210
- titleLineNumber: null,
211
- direction: 'LR',
212
- nodes: [],
213
- edges: [],
214
- groups: [],
215
- tagGroups: [],
216
- options,
217
- diagnostics: [],
218
- error: null,
219
- };
220
-
221
- const nodeMap = new Map<string, Writable<InfraNode>>();
222
- // Sidecar mutable tag-records, keyed by node id. The same Record object is
223
- // also referenced from `node.tags`; mutating it here updates both views.
224
- const nodeMutableTags = new Map<string, Record<string, string>>();
225
-
226
- // Push a description line onto a node. Manages the underlying mutable
227
- // array and assigns it back so `node.description` stays in sync.
228
- function pushDescription(node: Writable<InfraNode>, text: string): void {
229
- const existing = (node.description as string[] | undefined) ?? [];
230
- existing.push(text);
231
- node.description = existing;
232
- }
233
-
234
- // Tag-alias set grows as `tag Name as <x>` groups are parsed.
235
- const tagAliasSet = new Set<string>();
236
- const EDGE_PIPE_REGISTRY = {
237
- keys: new Set(['split', 'fanout']),
238
- tagAliases: new Set<string>(),
239
- } as const;
240
-
241
- // Per-parse alias literal → canonical node id (TD-18). Per C8.
242
- const nameAliasMap = new Map<string, string>();
243
- function peelAlias(label: string): { label: string; alias?: string } {
244
- const trimmed = label.trim();
245
- const m = trimmed.match(/^(.*?)\s+as\s+([A-Za-z][A-Za-z0-9_]{0,11})\s*$/);
246
- if (!m) return { label: trimmed };
247
- // Capture groups 1 & 2 in-bounds after successful match.
248
- return { label: m[1]!.trim(), ...(m[2] !== undefined && { alias: m[2] }) };
249
- }
250
-
251
- // Infra nodes have no "is a <type>" declaration — capability comes from
252
- // properties (cache-hit, buffer, drain-rate, …). Strip the suffix if a
253
- // user wrote it (likely copying from sequence/c4 syntax) and warn.
254
- const IS_A_SUFFIX = /^(.*?)\s+is\s+an?\s+[A-Za-z][\w-]*\s*$/i;
255
- function peelInfraDecorations(
256
- rawName: string,
257
- lineNumber: number
258
- ): { label: string; alias?: string } {
259
- const peeled = peelAlias(rawName);
260
- const m = peeled.label.match(IS_A_SUFFIX);
261
- if (!m) return peeled;
262
- warn(
263
- lineNumber,
264
- `Infra nodes don't use 'is a <type>' — types are inferred from properties (cache-hit, buffer, drain-rate, …). Drop the 'is a' suffix.`
265
- );
266
- // Capture group 1 in-bounds after successful match.
267
- return {
268
- label: m[1]!.trim(),
269
- ...(peeled.alias !== undefined && { alias: peeled.alias }),
270
- };
271
- }
272
- /**
273
- * Resolve a connection-target token. If the token exactly matches a
274
- * declared alias, return the bound canonical id. Otherwise fall
275
- * through to the normal `nodeId()` hash.
276
- */
277
- function resolveTargetId(rawName: string): string {
278
- const aliasResolved = nameAliasMap.get(rawName.trim());
279
- if (aliasResolved !== undefined) return aliasResolved;
280
- return nodeId(rawName);
281
- }
282
-
283
- // Tracks first-seen display form for each normalized edge-target id.
284
- // Used to give forward-reference stubs a readable label (e.g. 'CDN'
285
- // instead of the lowercased internal 'cdn').
286
- const targetDisplays = new Map<string, string>();
287
- const rememberTargetDisplay = (key: string, display: string): void => {
288
- if (!targetDisplays.has(key)) targetDisplays.set(key, display.trim());
289
- };
290
-
291
- const setError = (line: number, message: string) => {
292
- const diag = makeDgmoError(line, message);
293
- result.diagnostics.push(diag);
294
- if (!result.error) result.error = formatDgmoError(diag);
295
- };
296
-
297
- const warn = (line: number, message: string) => {
298
- result.diagnostics.push(makeDgmoError(line, message, 'warning'));
299
- };
300
-
301
- // Track parser state
302
- let currentNode: Writable<InfraNode> | null = null;
303
- let currentGroup: Writable<InfraGroup> | null = null;
304
- let currentTagGroup: Writable<InfraTagGroup> | null = null;
305
- let baseIndent = 0; // indent of the current component line
306
- // The `infra [Title]` declaration is only legal on the first content line.
307
- // parseFirstLine() must run ONCE, there — otherwise a later bare node line
308
- // whose name is a chart-type keyword (e.g. an edge-block header `timeline`)
309
- // is mis-read as a nested chart declaration and the parse aborts.
310
- let firstLineConsumed = false;
311
-
312
- function finishCurrentNode() {
313
- if (!currentNode) return;
314
- const key = currentNode.id;
315
- const existing = nodeMap.get(key);
316
- if (existing) {
317
- const incomingDisplay = displayName(currentNode.label);
318
- const existingDisplay = displayName(existing.label);
319
- // A bare reference line that is a declared alias of `existing` (e.g. an
320
- // edge-block header `web` for a node `"Web App" as web`) is an intentional
321
- // pointer, not a near-duplicate name — don't flag it as a case/whitespace
322
- // typo. Genuine collisions (no matching alias) still warn.
323
- const isAliasRef = nameAliasMap.get(currentNode.label) === key;
324
- if (incomingDisplay !== existingDisplay && !isAliasRef) {
325
- result.diagnostics.push(
326
- makeDgmoError(
327
- currentNode.lineNumber,
328
- nameMergedMessage({
329
- incomingDisplay,
330
- incomingLine: currentNode.lineNumber,
331
- existingDisplay,
332
- existingLine: existing.lineNumber,
333
- }),
334
- 'warning',
335
- NAME_DIAGNOSTIC_CODES.NAME_MERGED
336
- )
337
- );
338
- }
339
- currentNode = null;
340
- return;
341
- }
342
- // Validate mutual exclusion: concurrency vs instances/max-rps
343
- const keys = new Set(currentNode.properties.map((p) => p.key));
344
- if (
345
- keys.has('concurrency') &&
346
- (keys.has('instances') || keys.has('max-rps'))
347
- ) {
348
- const conflicting = [
349
- keys.has('instances') ? 'instances' : '',
350
- keys.has('max-rps') ? 'max-rps' : '',
351
- ]
352
- .filter(Boolean)
353
- .join(', ');
354
- warn(
355
- currentNode.lineNumber,
356
- `'concurrency' (serverless) is mutually exclusive with ${conflicting}. Serverless nodes scale via concurrency, not instances.`
357
- );
358
- }
359
- // Validate mutual exclusion: buffer (queue) vs max-rps (service)
360
- if (keys.has('buffer') && keys.has('max-rps')) {
361
- warn(
362
- currentNode.lineNumber,
363
- `'buffer' (queue) and 'max-rps' (service) represent different capacity models. A queue buffers messages; a service processes them.`
364
- );
365
- }
366
- nodeMap.set(key, currentNode);
367
- result.nodes.push(currentNode);
368
- currentNode = null;
369
- }
370
-
371
- function finishCurrentTagGroup() {
372
- if (currentTagGroup) {
373
- result.tagGroups.push(currentTagGroup);
374
- }
375
- currentTagGroup = null;
376
- }
377
-
378
- for (let i = 0; i < lines.length; i++) {
379
- // In-bounds by loop guard.
380
- const raw = lines[i]!;
381
- const lineNumber = i + 1;
382
- const trimmed = raw.trim();
383
- const indent = measureIndent(raw);
384
-
385
- // Skip empty lines and comments
386
- if (!trimmed || trimmed.startsWith('//')) continue;
387
-
388
- // Skip markdown section headers
389
- if (/^#{2,}\s+/.test(trimmed)) continue;
390
-
391
- // ---- Top-level metadata (no indent) ----
392
- if (indent === 0) {
393
- // Close any open blocks
394
- if (indent === 0 && currentNode && !trimmed.startsWith('-')) {
395
- finishCurrentNode();
396
- }
397
-
398
- // First line: `infra [Title]` or legacy `chart: infra` — only ever
399
- // attempted on the first content line (see firstLineConsumed). A node or
400
- // alias named like a chart type elsewhere is a normal node, not a redecl.
401
- if (!firstLineConsumed) {
402
- firstLineConsumed = true;
403
- const firstLineResult = parseFirstLine(trimmed);
404
- if (firstLineResult) {
405
- if (firstLineResult.chartType !== 'infra') {
406
- setError(
407
- lineNumber,
408
- `Expected chart type 'infra', got '${firstLineResult.chartType}'`
409
- );
410
- }
411
- if (firstLineResult.title) {
412
- result.title = firstLineResult.title;
413
- result.titleLineNumber = lineNumber;
414
- }
415
- continue;
416
- }
417
- }
418
-
419
- // direction-tb — bare boolean to switch to top-to-bottom (default is LR)
420
- if (/^direction-tb$/i.test(trimmed)) {
421
- result.direction = 'TB';
422
- continue;
423
- }
424
-
425
- if (trimmed === 'animate' || trimmed === 'no-animate') {
426
- warn(
427
- lineNumber,
428
- '"animate" / "no-animate" have been removed — animation is controlled by the rendering context.'
429
- );
430
- continue;
431
- }
432
-
433
- if (tryParseSharedOption(trimmed, options)) {
434
- continue;
435
- }
436
-
437
- // Top-level options: `key value` (space-separated, no colon)
438
- const optMatch = trimmed.match(OPTION_NOCOLON_RE);
439
- // Capture groups 1 & 2 in-bounds after successful match.
440
- if (optMatch && TOP_LEVEL_OPTIONS.has(optMatch[1]!.toLowerCase())) {
441
- options[optMatch[1]!.toLowerCase()] = optMatch[2]!.trim();
442
- continue;
443
- }
444
-
445
- // Tag group: `tag Name as <alias>` (via shared matchTagBlockHeading)
446
- const tagMatch = matchTagBlockHeading(trimmed);
447
- if (tagMatch) {
448
- finishCurrentNode();
449
- finishCurrentTagGroup();
450
- if (tagMatch.alias) tagAliasSet.add(normalizeName(tagMatch.alias));
451
- currentTagGroup = {
452
- name: tagMatch.name,
453
- alias: tagMatch.alias ?? null,
454
- values: [],
455
- lineNumber,
456
- };
457
- continue;
458
- }
459
-
460
- // [Group Name] [as <alias>] [| t: Engineering] — TD-18 native `as` postfix
461
- const groupMatch = trimmed.match(GROUP_RE);
462
- if (groupMatch) {
463
- finishCurrentNode();
464
- finishCurrentTagGroup();
465
- // Capture group 1 in-bounds after successful match.
466
- const gLabel = groupMatch[1]!.trim();
467
- const groupAlias = groupMatch[2];
468
- const gId = groupId(gLabel);
469
- if (groupAlias) nameAliasMap.set(groupAlias, gId);
470
- const groupMeta = groupMatch[3]
471
- ? extractPipeMetadata(groupMatch[3]).tags
472
- : undefined;
473
- if (groupMeta) {
474
- warnUnknownMetaKeys(
475
- groupMeta,
476
- withTagAliases(INFRA_REGISTRY, tagAliasSet),
477
- (msg) => warn(lineNumber, msg)
478
- );
479
- }
480
- const hasMeta = groupMeta && Object.keys(groupMeta).length > 0;
481
- const newGroup: Writable<InfraGroup> = {
482
- id: gId,
483
- label: gLabel,
484
- ...(hasMeta && { metadata: groupMeta }),
485
- lineNumber,
486
- };
487
- currentGroup = newGroup;
488
- result.groups.push(newGroup);
489
- continue;
490
- }
491
-
492
- // Component at top level (no indent) — supports `as` postfix
493
- const compMatch = trimmed.match(COMPONENT_RE);
494
- if (compMatch) {
495
- finishCurrentNode();
496
- finishCurrentTagGroup();
497
-
498
- const { qname, uname, alias: asAlias, meta } = compMatch.groups!;
499
- const rawName = (qname ?? uname ?? '').trim();
500
- const peeled = peelInfraDecorations(rawName, lineNumber);
501
- const name = peeled.label;
502
- // Explicit `as` capture wins; fall back to an alias the peeler split out
503
- // of a bare name (and the quoted-literal `"Foo as bar"` legacy case).
504
- const alias = asAlias ?? peeled.alias;
505
- const rest = meta ?? '';
506
- const { tags } = extractPipeMetadata(rest);
507
- warnUnknownMetaKeys(
508
- tags,
509
- withTagAliases(INFRA_REGISTRY, tagAliasSet),
510
- (msg) => warn(lineNumber, msg)
511
- );
512
- // A bare reference line may be an alias for an already-declared node
513
- // (its label-slug differs from the alias, e.g. "Web App" as web). Resolve
514
- // through the alias map first so an edge-source/reference merges into
515
- // that node instead of spawning a duplicate stub.
516
- const id = nameAliasMap.get(name) ?? nodeId(name);
517
- if (alias) nameAliasMap.set(alias, id);
518
- const isEdge = EDGE_NODE_NAMES.has(id.toLowerCase());
519
-
520
- currentNode = {
521
- id,
522
- label: name,
523
- properties: [],
524
- groupId: null,
525
- tags,
526
- isEdge,
527
- lineNumber,
528
- };
529
- nodeMutableTags.set(id, tags);
530
- currentGroup = null;
531
- baseIndent = 0;
532
- continue;
533
- }
534
- }
535
-
536
- // ---- Indented lines ----
537
-
538
- // Tag value inside tag group — first value is the default unless another is marked `default`
539
- if (currentTagGroup && indent > 0) {
540
- const { text: cleanEntry, isDefault } = stripDefaultModifier(trimmed);
541
- // Trailing-token color (universal rule, §1.5): peel off a lowercase
542
- // recognized color word from the end of the line. Downstream stores
543
- // the raw color NAME (not the palette hex) so the renderer can resolve
544
- // against whichever theme/palette is active at render time.
545
- const lastSpaceIdx = cleanEntry.lastIndexOf(' ');
546
- let valueName = cleanEntry;
547
- let rawColor: string | undefined;
548
- if (lastSpaceIdx > 0) {
549
- const trailing = cleanEntry.substring(lastSpaceIdx + 1);
550
- if (isRecognizedColorName(trailing)) {
551
- rawColor = trailing;
552
- valueName = cleanEntry.substring(0, lastSpaceIdx).trimEnd();
553
- }
554
- }
555
- const tvMatch = valueName.match(TAG_VALUE_RE);
556
- if (tvMatch || /^\w+$/.test(valueName)) {
557
- currentTagGroup.values.push({
558
- name: valueName,
559
- ...(rawColor !== undefined && { color: rawColor }),
560
- });
561
- if (isDefault) {
562
- currentTagGroup.defaultValue = valueName;
563
- } else if (currentTagGroup.values.length === 1) {
564
- currentTagGroup.defaultValue = valueName;
565
- }
566
- continue;
567
- }
568
- warn(
569
- lineNumber,
570
- `Invalid tag value '${trimmed}' in tag group '${currentTagGroup.name}' — a value is a bare word with an optional trailing color (e.g. 'Production red'); no ':' or '|'.`
571
- );
572
- continue;
573
- }
574
-
575
- // Inside a [Group] but no current node — group properties or component declaration
576
- if (currentGroup && !currentNode && indent > 0) {
577
- // Group-level properties (instances, collapsed)
578
- const propMatch = trimmed.match(PROPERTY_RE);
579
- if (propMatch) {
580
- // Capture groups 1 & 2 in-bounds after successful match.
581
- const key = propMatch[1]!.toLowerCase();
582
- const val = propMatch[2]!.trim();
583
- if (key === 'instances') {
584
- const rangeM = val.match(RANGE_RE);
585
- if (rangeM) {
586
- currentGroup.instances = val;
587
- } else {
588
- const num = parseInt(val, 10);
589
- if (!isNaN(num)) currentGroup.instances = num;
590
- }
591
- continue;
592
- }
593
- if (key === 'collapsed') {
594
- currentGroup.collapsed = val.toLowerCase() === 'true';
595
- continue;
596
- }
597
- // Fall through to component matching — could be a component name
598
- // that happens to match PROPERTY_RE (e.g., "MyService: v2")
599
- }
600
-
601
- const compMatch = trimmed.match(COMPONENT_RE);
602
- if (compMatch) {
603
- finishCurrentTagGroup();
604
- const { qname, uname, alias: asAlias, meta } = compMatch.groups!;
605
- const rawName = (qname ?? uname ?? '').trim();
606
- const peeled = peelInfraDecorations(rawName, lineNumber);
607
- const name = peeled.label;
608
- const alias = asAlias ?? peeled.alias;
609
- const rest = meta ?? '';
610
- const { tags: nodeTags } = extractPipeMetadata(rest);
611
- warnUnknownMetaKeys(
612
- nodeTags,
613
- withTagAliases(INFRA_REGISTRY, tagAliasSet),
614
- (msg) => warn(lineNumber, msg)
615
- );
616
- // A bare reference line may be an alias for an already-declared node
617
- // (its label-slug differs from the alias, e.g. "Web App" as web). Resolve
618
- // through the alias map first so an edge-source/reference merges into
619
- // that node instead of spawning a duplicate stub.
620
- const id = nameAliasMap.get(name) ?? nodeId(name);
621
- if (alias) nameAliasMap.set(alias, id);
622
- // Cascade group metadata into node tags; node-level metadata overrides
623
- const tags: Record<string, string> = currentGroup.metadata
624
- ? { ...currentGroup.metadata, ...nodeTags }
625
- : nodeTags;
626
-
627
- currentNode = {
628
- id,
629
- label: name,
630
- properties: [],
631
- groupId: currentGroup.id,
632
- tags,
633
- isEdge: false,
634
- lineNumber,
635
- };
636
- nodeMutableTags.set(id, tags);
637
- baseIndent = indent;
638
- continue;
639
- }
640
- }
641
-
642
- // Inside a component block — properties and connections
643
- if (currentNode && indent > baseIndent) {
644
- // Detect deprecated xN fanout syntax
645
- const deprecatedFanout = trimmed.match(DEPRECATED_FANOUT_RE);
646
- if (
647
- deprecatedFanout &&
648
- (trimmed.startsWith('->') ||
649
- trimmed.startsWith('-') ||
650
- trimmed.startsWith('~'))
651
- ) {
652
- const n = deprecatedFanout[1];
653
- setError(
654
- lineNumber,
655
- `'x${n}' fanout syntax is no longer supported — use 'fanout: ${n}' instead`
656
- );
657
- continue;
658
- }
659
-
660
- // Async simple connection: ~> Target
661
- const asyncSimpleConn = trimmed.match(ASYNC_SIMPLE_CONNECTION_RE);
662
- if (asyncSimpleConn) {
663
- // Capture group 1 in-bounds after successful match.
664
- const targetRaw = asyncSimpleConn[1]!.trim();
665
- const pipeMeta = extractPipeMetadata(targetRaw);
666
- const targetName = pipeMeta.clean || targetRaw;
667
- warnUnparsedPipeMeta(targetName, lineNumber, warn);
668
- warnUnknownMetaKeys(pipeMeta.tags, EDGE_PIPE_REGISTRY, (msg) =>
669
- warn(lineNumber, msg)
670
- );
671
- const split = pipeMeta.tags['split']
672
- ? parseFloat(pipeMeta.tags['split'])
673
- : null;
674
- const fanoutRaw = pipeMeta.tags['fanout']
675
- ? parseInt(pipeMeta.tags['fanout'], 10)
676
- : null;
677
- if (fanoutRaw !== null && fanoutRaw < 1) {
678
- warn(
679
- lineNumber,
680
- `Fan-out multiplier must be at least 1 (got fanout: ${fanoutRaw}). Ignoring.`
681
- );
682
- }
683
- const fanout = fanoutRaw !== null && fanoutRaw >= 1 ? fanoutRaw : null;
684
- const targetIdAsync = resolveTargetId(targetName);
685
- rememberTargetDisplay(targetIdAsync, targetName);
686
- result.edges.push({
687
- sourceId: currentNode.id,
688
- targetId: targetIdAsync,
689
- label: '',
690
- async: true,
691
- split,
692
- fanout,
693
- lineNumber,
694
- });
695
- continue;
696
- }
697
-
698
- // Async labeled connection: ~label~> Target
699
- const asyncConnMatch = trimmed.match(ASYNC_CONNECTION_RE);
700
- if (asyncConnMatch) {
701
- const rawLabel = asyncConnMatch[1] ?? '';
702
- const labelResult = parseInArrowLabel(rawLabel, lineNumber);
703
- result.diagnostics.push(...labelResult.diagnostics);
704
- const label = labelResult.label ?? '';
705
- // Capture group 2 in-bounds after successful match.
706
- const targetRaw = asyncConnMatch[2]!.trim();
707
- const pipeMeta = extractPipeMetadata(targetRaw);
708
- const targetName = pipeMeta.clean || targetRaw;
709
- warnUnparsedPipeMeta(targetName, lineNumber, warn);
710
- warnUnknownMetaKeys(pipeMeta.tags, EDGE_PIPE_REGISTRY, (msg) =>
711
- warn(lineNumber, msg)
712
- );
713
- const split = pipeMeta.tags['split']
714
- ? parseFloat(pipeMeta.tags['split'])
715
- : null;
716
- const fanoutRaw = pipeMeta.tags['fanout']
717
- ? parseInt(pipeMeta.tags['fanout'], 10)
718
- : null;
719
- if (fanoutRaw !== null && fanoutRaw < 1) {
720
- warn(
721
- lineNumber,
722
- `Fan-out multiplier must be at least 1 (got fanout: ${fanoutRaw}). Ignoring.`
723
- );
724
- }
725
- const fanout = fanoutRaw !== null && fanoutRaw >= 1 ? fanoutRaw : null;
726
-
727
- let targetId: string;
728
- const targetGroupMatch = targetName.match(/^\[([^\]]+)\]/);
729
- if (targetGroupMatch) {
730
- // Capture group 1 in-bounds after successful match.
731
- targetId = groupId(targetGroupMatch[1]!);
732
- rememberTargetDisplay(targetId, `[${targetGroupMatch[1]}]`);
733
- } else {
734
- targetId = resolveTargetId(targetName);
735
- rememberTargetDisplay(targetId, targetName);
736
- }
737
-
738
- result.edges.push({
739
- sourceId: currentNode.id,
740
- targetId,
741
- label,
742
- async: true,
743
- split,
744
- fanout,
745
- lineNumber,
746
- });
747
- continue;
748
- }
749
-
750
- // Simple connection: -> Target or -> Target | fanout: 5
751
- const simpleConn = trimmed.match(SIMPLE_CONNECTION_RE);
752
- if (simpleConn) {
753
- // Capture group 1 in-bounds after successful match.
754
- const targetRaw = simpleConn[1]!.trim();
755
- const pipeMeta = extractPipeMetadata(targetRaw);
756
- const targetName = pipeMeta.clean || targetRaw;
757
- warnUnparsedPipeMeta(targetName, lineNumber, warn);
758
- warnUnknownMetaKeys(pipeMeta.tags, EDGE_PIPE_REGISTRY, (msg) =>
759
- warn(lineNumber, msg)
760
- );
761
- const split = pipeMeta.tags['split']
762
- ? parseFloat(pipeMeta.tags['split'])
763
- : null;
764
- const fanoutRaw = pipeMeta.tags['fanout']
765
- ? parseInt(pipeMeta.tags['fanout'], 10)
766
- : null;
767
- if (fanoutRaw !== null && fanoutRaw < 1) {
768
- warn(
769
- lineNumber,
770
- `Fan-out multiplier must be at least 1 (got fanout: ${fanoutRaw}). Ignoring.`
771
- );
772
- }
773
- const fanout = fanoutRaw !== null && fanoutRaw >= 1 ? fanoutRaw : null;
774
- const targetIdSimple = resolveTargetId(targetName);
775
- rememberTargetDisplay(targetIdSimple, targetName);
776
- result.edges.push({
777
- sourceId: currentNode.id,
778
- targetId: targetIdSimple,
779
- label: '',
780
- async: false,
781
- split,
782
- fanout,
783
- lineNumber,
784
- });
785
- continue;
786
- }
787
-
788
- // Labeled connection: -label-> Target | split: N%, fanout: 3
789
- const connMatch = trimmed.match(CONNECTION_RE);
790
- if (connMatch) {
791
- const rawLabel = connMatch[1] ?? '';
792
- const labelResult = parseInArrowLabel(rawLabel, lineNumber);
793
- result.diagnostics.push(...labelResult.diagnostics);
794
- const label = labelResult.label ?? '';
795
- // Capture group 2 in-bounds after successful match.
796
- const targetRaw = connMatch[2]!.trim();
797
- const pipeMeta = extractPipeMetadata(targetRaw);
798
- const targetName = pipeMeta.clean || targetRaw;
799
- warnUnparsedPipeMeta(targetName, lineNumber, warn);
800
- warnUnknownMetaKeys(pipeMeta.tags, EDGE_PIPE_REGISTRY, (msg) =>
801
- warn(lineNumber, msg)
802
- );
803
- const split = pipeMeta.tags['split']
804
- ? parseFloat(pipeMeta.tags['split'])
805
- : null;
806
- const fanoutRaw = pipeMeta.tags['fanout']
807
- ? parseInt(pipeMeta.tags['fanout'], 10)
808
- : null;
809
- if (fanoutRaw !== null && fanoutRaw < 1) {
810
- warn(
811
- lineNumber,
812
- `Fan-out multiplier must be at least 1 (got fanout: ${fanoutRaw}). Ignoring.`
813
- );
814
- }
815
- const fanout = fanoutRaw !== null && fanoutRaw >= 1 ? fanoutRaw : null;
816
-
817
- // Target might be a group ref like [API Pods] or an alias literal.
818
- let targetId: string;
819
- const targetGroupMatch = targetName.match(/^\[([^\]]+)\]/);
820
- if (targetGroupMatch) {
821
- // Capture group 1 in-bounds after successful match.
822
- targetId = groupId(targetGroupMatch[1]!);
823
- rememberTargetDisplay(targetId, `[${targetGroupMatch[1]}]`);
824
- } else {
825
- targetId = resolveTargetId(targetName);
826
- rememberTargetDisplay(targetId, targetName);
827
- }
828
-
829
- result.edges.push({
830
- sourceId: currentNode.id,
831
- targetId,
832
- label,
833
- async: false,
834
- split,
835
- fanout,
836
- lineNumber,
837
- });
838
- continue;
839
- }
840
-
841
- // Empty description (no value) — silently skip rather than emitting "Unexpected line"
842
- if (/^description\s*:?\s*$/i.test(trimmed)) continue;
843
-
844
- // Property: key: value
845
- const propMatch = trimmed.match(PROPERTY_RE);
846
- if (propMatch) {
847
- // Capture groups 1 & 2 in-bounds after successful match.
848
- const key = propMatch[1]!.toLowerCase();
849
- const rawVal = propMatch[2]!.trim();
850
-
851
- // description is display metadata, not a behavior key; silently ignored on edge nodes.
852
- // Single-line only — no length enforcement, but keep it short for legibility.
853
- if (key === 'description' && currentNode) {
854
- if (!currentNode.isEdge) {
855
- pushDescription(currentNode, rawVal);
856
- }
857
- continue;
858
- }
859
-
860
- // Indented tag assignment — `t: Edge` on its own line, the indented twin
861
- // of same-line `Name t: Edge`. A tag alias is neither a behavior property
862
- // nor 'description', so without this it falls through to the unknown-key
863
- // path and is mis-collected as description text, silently dropping the
864
- // node's tier. currentNode.tags is the same object held in
865
- // nodeMutableTags, so a direct assign is enough.
866
- if (currentNode && tagAliasSet.has(normalizeName(key))) {
867
- const mut = nodeMutableTags.get(currentNode.id);
868
- if (mut) mut[tagAttrKey(key)] = rawVal;
869
- continue;
870
- }
871
-
872
- // Unknown keys: decide between property typo warning vs description collection.
873
- // Heuristic: if the key looks like a plausible property (alphanumeric-hyphen, close
874
- // match to a known key, or the value looks numeric/percentage), warn as typo.
875
- // Otherwise treat the whole line as description text.
876
- if (!INFRA_BEHAVIOR_KEYS.has(key) && !EDGE_ONLY_KEYS.has(key)) {
877
- const allKeys = [...INFRA_BEHAVIOR_KEYS, ...EDGE_ONLY_KEYS];
878
- const hint = suggest(key, allKeys);
879
- const valueLooksNumeric = /^[\d.]+%?$/.test(rawVal);
880
- if (hint || valueLooksNumeric) {
881
- // Likely a typo — warn
882
- let msg = `Unknown infra property '${key}'.`;
883
- if (hint) msg += ` ${hint}`;
884
- else
885
- msg +=
886
- ` Properties are colon-keyed 'key: value' from the known schema` +
887
- ` (e.g. latency-ms, max-rps, cache-hit, instances, concurrency).`;
888
- warn(lineNumber, msg);
889
- } else if (!currentNode.isEdge) {
890
- // Likely prose — collect as description
891
- pushDescription(currentNode, trimmed);
892
- continue;
893
- }
894
- continue;
895
- }
896
-
897
- // Validate edge-only keys
898
- if (EDGE_ONLY_KEYS.has(key) && !currentNode.isEdge) {
899
- warn(
900
- lineNumber,
901
- `Property '${key}' belongs on the entry point — declare it indented under an 'internet' or 'edge' node, not on '${currentNode.label}'.`
902
- );
903
- }
904
-
905
- const value = parsePropertyValue(rawVal);
906
- currentNode.properties.push({ key, value, lineNumber });
907
- continue;
908
- }
909
-
910
- // Indented line inside a component: only the `description` keyword
911
- // attaches as a description; a bare prose line is no longer auto-promoted.
912
- const descResult = tryStripDescriptionKeyword(trimmed);
913
- if (descResult.isKeyword && !descResult.needsColon) {
914
- if (currentNode.isEdge) {
915
- // description on edge nodes is silently ignored
916
- continue;
917
- }
918
- pushDescription(currentNode, descResult.text);
919
- continue;
920
- }
921
- warn(
922
- lineNumber,
923
- `Unexpected line inside component '${currentNode.label}'. Expected a property (key: value), connection (-> Target), or a description (description: text).`
924
- );
925
- continue;
926
- }
927
-
928
- // Component inside group (same indent as group children)
929
- if (currentGroup && indent > 0) {
930
- finishCurrentNode();
931
-
932
- const compMatch = trimmed.match(COMPONENT_RE);
933
- if (compMatch) {
934
- const { qname, uname, alias: asAlias, meta } = compMatch.groups!;
935
- const rawName = (qname ?? uname ?? '').trim();
936
- const peeled = peelInfraDecorations(rawName, lineNumber);
937
- const name = peeled.label;
938
- const alias = asAlias ?? peeled.alias;
939
- const rest = meta ?? '';
940
- const { tags: nodeTags } = extractPipeMetadata(rest);
941
- warnUnknownMetaKeys(
942
- nodeTags,
943
- withTagAliases(INFRA_REGISTRY, tagAliasSet),
944
- (msg) => warn(lineNumber, msg)
945
- );
946
- // A bare reference line may be an alias for an already-declared node
947
- // (its label-slug differs from the alias, e.g. "Web App" as web). Resolve
948
- // through the alias map first so an edge-source/reference merges into
949
- // that node instead of spawning a duplicate stub.
950
- const id = nameAliasMap.get(name) ?? nodeId(name);
951
- if (alias) nameAliasMap.set(alias, id);
952
- const tags: Record<string, string> = currentGroup.metadata
953
- ? { ...currentGroup.metadata, ...nodeTags }
954
- : nodeTags;
955
-
956
- currentNode = {
957
- id,
958
- label: name,
959
- properties: [],
960
- groupId: currentGroup.id,
961
- tags,
962
- isEdge: false,
963
- lineNumber,
964
- };
965
- nodeMutableTags.set(id, tags);
966
- baseIndent = indent;
967
- continue;
968
- }
969
- }
970
-
971
- // If we reach here and indent is 0, try as a top-level component
972
- if (indent === 0) {
973
- const compMatch = trimmed.match(COMPONENT_RE);
974
- if (compMatch) {
975
- finishCurrentNode();
976
- finishCurrentTagGroup();
977
- currentGroup = null;
978
-
979
- const { qname, uname, alias: asAlias, meta } = compMatch.groups!;
980
- const rawName = (qname ?? uname ?? '').trim();
981
- const peeled = peelInfraDecorations(rawName, lineNumber);
982
- const name = peeled.label;
983
- const alias = asAlias ?? peeled.alias;
984
- const rest = meta ?? '';
985
- const { tags } = extractPipeMetadata(rest);
986
- warnUnknownMetaKeys(
987
- tags,
988
- withTagAliases(INFRA_REGISTRY, tagAliasSet),
989
- (msg) => warn(lineNumber, msg)
990
- );
991
- // A bare reference line may be an alias for an already-declared node
992
- // (its label-slug differs from the alias, e.g. "Web App" as web). Resolve
993
- // through the alias map first so an edge-source/reference merges into
994
- // that node instead of spawning a duplicate stub.
995
- const id = nameAliasMap.get(name) ?? nodeId(name);
996
- if (alias) nameAliasMap.set(alias, id);
997
-
998
- currentNode = {
999
- id,
1000
- label: name,
1001
- properties: [],
1002
- groupId: null,
1003
- tags,
1004
- isEdge: EDGE_NODE_NAMES.has(id.toLowerCase()),
1005
- lineNumber,
1006
- };
1007
- nodeMutableTags.set(id, tags);
1008
- baseIndent = 0;
1009
- continue;
1010
- }
1011
- }
1012
-
1013
- // Catch-all: nothing matched this line
1014
- warn(
1015
- lineNumber,
1016
- currentGroup
1017
- ? `Unexpected line: '${trimmed}'. Inside a group, indent a component name under the '[Group]'.`
1018
- : `Unexpected line: '${trimmed}'. Expected a node ('Api Gateway'), a group ('[Backend]'), a tag group ('tag Tier as t'), or an option ('default-rps 100'); node properties go indented under a node as 'key: value'.`
1019
- );
1020
- }
1021
-
1022
- // Flush last open blocks
1023
- finishCurrentNode();
1024
- finishCurrentTagGroup();
1025
-
1026
- // Ensure referenced targets exist (create stub nodes for forward references)
1027
- for (const edge of result.edges) {
1028
- if (!nodeMap.has(edge.targetId)) {
1029
- // Check if target is a group
1030
- const isGroup = result.groups.some((g) => g.id === edge.targetId);
1031
- if (!isGroup) {
1032
- // Create a stub node for forward-referenced targets. Display label
1033
- // recovers the user's first-seen casing so the rendered SVG shows
1034
- // 'CDN' instead of the lowercased internal id.
1035
- const stubDisplay = targetDisplays.get(edge.targetId) ?? edge.targetId;
1036
- const stubTags: Record<string, string> = {};
1037
- const stub: Writable<InfraNode> = {
1038
- id: edge.targetId,
1039
- label: stubDisplay,
1040
- properties: [],
1041
- groupId: null,
1042
- tags: stubTags,
1043
- isEdge: false,
1044
- lineNumber: edge.lineNumber,
1045
- };
1046
- // stub.id is edge.targetId, already-normalized via nodeId() at edge construction
1047
- // eslint-disable-next-line name-normalize/required-at-insertion
1048
- nodeMap.set(stub.id, stub);
1049
- result.nodes.push(stub);
1050
- nodeMutableTags.set(stub.id, stubTags);
1051
- }
1052
- }
1053
- }
1054
-
1055
- // Inject default tag values into nodes that don't have one
1056
- for (const tg of result.tagGroups) {
1057
- if (!tg.defaultValue) continue;
1058
- const key = tagAttrKey(tg.alias ?? tg.name);
1059
- for (const node of result.nodes) {
1060
- if (node.isEdge) continue;
1061
- if (!(key in node.tags)) {
1062
- const tags = nodeMutableTags.get(node.id);
1063
- if (tags) tags[key] = tg.defaultValue;
1064
- }
1065
- }
1066
- }
1067
-
1068
- validateTagGroupNames(result.tagGroups, warn, setError);
1069
-
1070
- checkReachability(result);
1071
-
1072
- return result;
1073
- }
1074
-
1075
- /**
1076
- * Reachability lint: an infra diagram traces request traffic flowing inward
1077
- * from an `internet`/`edge` entry, so every node must have a directed path
1078
- * back to an entry. A node nothing routes to carries no traffic — it's dead
1079
- * and doesn't belong. Emits `warning`-severity diagnostics (never blocks
1080
- * rendering). Mirrors the compute model: an edge targeting a `[Group]`
1081
- * delivers to all of that group's children.
1082
- */
1083
- function checkReachability(result: Writable<ParsedInfra>): void {
1084
- if (result.nodes.length === 0) return;
1085
-
1086
- const entries = result.nodes.filter((n) => n.isEdge);
1087
-
1088
- // No entry at all: the rule can't be satisfied. One diagnostic, not N.
1089
- if (entries.length === 0) {
1090
- const line = result.titleLineNumber ?? result.nodes[0]?.lineNumber ?? 1;
1091
- result.diagnostics.push(emit(GRAPH_DX.INFRA_NO_ENTRY, line));
1092
- return;
1093
- }
1094
-
1095
- // groupId -> child node ids (for expanding edges that target a [Group]).
1096
- const groupChildMap = new Map<string, string[]>();
1097
- for (const node of result.nodes) {
1098
- if (node.groupId) {
1099
- const list = groupChildMap.get(node.groupId) ?? [];
1100
- list.push(node.id);
1101
- // node.groupId is already a normalized id from parse time.
1102
- // eslint-disable-next-line name-normalize/required-at-insertion
1103
- groupChildMap.set(node.groupId, list);
1104
- }
1105
- }
1106
-
1107
- // Outbound adjacency: an edge to a group reaches every child of that group.
1108
- const outbound = new Map<string, string[]>();
1109
- for (const edge of result.edges) {
1110
- const targets = groupChildMap.get(edge.targetId) ?? [edge.targetId];
1111
- const list = outbound.get(edge.sourceId) ?? [];
1112
- list.push(...targets);
1113
- outbound.set(edge.sourceId, list);
1114
- }
1115
-
1116
- // BFS from every entry node.
1117
- const reachable = new Set<string>();
1118
- const queue: string[] = [];
1119
- for (const entry of entries) {
1120
- reachable.add(entry.id);
1121
- queue.push(entry.id);
1122
- }
1123
- while (queue.length > 0) {
1124
- const current = queue.shift()!;
1125
- for (const next of outbound.get(current) ?? []) {
1126
- if (!reachable.has(next)) {
1127
- reachable.add(next);
1128
- queue.push(next);
1129
- }
1130
- }
1131
- }
1132
-
1133
- for (const node of result.nodes) {
1134
- if (node.isEdge || reachable.has(node.id)) continue;
1135
- result.diagnostics.push(
1136
- emit(GRAPH_DX.INFRA_UNREACHABLE, node.lineNumber, { label: node.label })
1137
- );
1138
- }
1139
- }
1140
-
1141
- // ============================================================
1142
- // Symbol extraction (for completion API)
1143
- // ============================================================
1144
-
1145
- import type { DiagramSymbols } from '../completion-types';
1146
-
1147
- /**
1148
- * Extract component names (entities) from infra document text.
1149
- * Used by the dgmo completion API for ghost hints and popup completions.
1150
- */
1151
- /** Strip ` as <alias>` and ` is a/an <type>` decorations from a node name
1152
- * so completion suggests the bare identifier the user will reference. */
1153
- function stripNodeDecorations(name: string): string {
1154
- let s = name.trim();
1155
- const aliasMatch = s.match(/^(.*?)\s+as\s+[A-Za-z][A-Za-z0-9_]{0,11}\s*$/);
1156
- // Capture group 1 in-bounds after successful match.
1157
- if (aliasMatch) s = aliasMatch[1]!.trim();
1158
- const isAMatch = s.match(/^(.*?)\s+is\s+an?\s+[A-Za-z][\w-]*\s*$/i);
1159
- // Capture group 1 in-bounds after successful match.
1160
- if (isAMatch) s = isAMatch[1]!.trim();
1161
- return s;
1162
- }
1163
-
1164
- export function extractSymbols(docText: string): DiagramSymbols {
1165
- const entities: string[] = [];
1166
- let inMetadata = true;
1167
- let inTagGroup = false;
1168
- for (const rawLine of docText.split('\n')) {
1169
- const line = rawLine.trim();
1170
- if (line.length === 0) continue;
1171
- const indented = /^\s/.test(rawLine);
1172
-
1173
- // Metadata phase: skip until first non-metadata root-level line.
1174
- // Metadata includes: `infra [Title]`, `chart: type`, `direction X`, `slo-*`, etc.
1175
- if (inMetadata) {
1176
- if (!indented) {
1177
- // Recognize new-style bare options (`key value`) and old-style (`key: value`)
1178
- const firstLine = parseFirstLine(line);
1179
- if (firstLine) continue; // chart type line
1180
- if (/^(?:direction-tb|animate|no-animate|slo-|default-)/i.test(line))
1181
- continue;
1182
- if (/^[a-z-]+\s*:/i.test(line)) continue; // legacy colon options
1183
- inMetadata = false;
1184
- } else {
1185
- continue;
1186
- }
1187
- }
1188
-
1189
- if (!indented) {
1190
- // Root-level: tag group declaration, group header, or component
1191
- if (/^tag\s/i.test(line)) {
1192
- inTagGroup = true;
1193
- continue;
1194
- }
1195
- if (/^tag\s*:/i.test(line)) {
1196
- inTagGroup = true;
1197
- continue;
1198
- } // legacy
1199
- inTagGroup = false;
1200
- if (/^\[/.test(line)) continue; // [Group] header
1201
- const m = COMPONENT_RE.exec(line);
1202
- if (m) {
1203
- const name = stripNodeDecorations((m[1] ?? m[2] ?? '').trim());
1204
- if (name && !entities.includes(name)) entities.push(name);
1205
- }
1206
- } else {
1207
- // Indented: skip tag values, connections, and properties; extract grouped components
1208
- if (inTagGroup) continue;
1209
- if (/^->/.test(line)) continue; // simple connection
1210
- if (/^~>/.test(line)) continue; // async simple connection
1211
- if (/^-[^>]+-?>/.test(line)) continue; // labeled connection
1212
- if (/^~[^~]+~>/.test(line)) continue; // async labeled connection
1213
- if (/^\w[\w-]*\s*:/.test(line)) continue; // property (key: value) legacy
1214
- // New-style property: first token is a known behavior/property key
1215
- // String.split always returns at least one element; line.length > 0 here.
1216
- const firstToken = line.split(/\s/)[0]!.toLowerCase();
1217
- if (
1218
- (INFRA_BEHAVIOR_KEYS.has(firstToken) ||
1219
- EDGE_ONLY_KEYS.has(firstToken) ||
1220
- firstToken === 'description' ||
1221
- firstToken === 'instances' ||
1222
- firstToken === 'collapsed') &&
1223
- /\s/.test(line)
1224
- )
1225
- continue;
1226
- const m = COMPONENT_RE.exec(line);
1227
- if (m) {
1228
- const name = stripNodeDecorations((m[1] ?? m[2] ?? '').trim());
1229
- if (name && !entities.includes(name)) entities.push(name);
1230
- }
1231
- }
1232
- }
1233
- return { kind: 'infra', entities };
1234
- }