proschi 0.2.0 → 0.3.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.
package/dist/server.cjs CHANGED
@@ -1218,15 +1218,15 @@ var require_sharedArrayCancellation = __commonJS({
1218
1218
  constructor() {
1219
1219
  this.buffers = /* @__PURE__ */ new Map();
1220
1220
  }
1221
- enableCancellation(request) {
1222
- if (request.id === null) {
1221
+ enableCancellation(request2) {
1222
+ if (request2.id === null) {
1223
1223
  return;
1224
1224
  }
1225
1225
  const buffer = new SharedArrayBuffer(4);
1226
1226
  const data = new Int32Array(buffer, 0, 1);
1227
1227
  data[0] = CancellationState.Continue;
1228
- this.buffers.set(request.id, buffer);
1229
- request.$cancellationData = buffer;
1228
+ this.buffers.set(request2.id, buffer);
1229
+ request2.$cancellationData = buffer;
1230
1230
  }
1231
1231
  async sendCancellation(_conn, id) {
1232
1232
  const buffer = this.buffers.get(id);
@@ -1268,8 +1268,8 @@ var require_sharedArrayCancellation = __commonJS({
1268
1268
  };
1269
1269
  var SharedArrayReceiverStrategy = class {
1270
1270
  kind = "request";
1271
- createCancellationTokenSource(request) {
1272
- const buffer = request.$cancellationData;
1271
+ createCancellationTokenSource(request2) {
1272
+ const buffer = request2.$cancellationData;
1273
1273
  if (buffer === void 0) {
1274
1274
  return new cancellation_1.CancellationTokenSource();
1275
1275
  }
@@ -2326,13 +2326,13 @@ var require_connection = __commonJS({
2326
2326
  const toCancel = messageQueue.get(key);
2327
2327
  if (messages_1.Message.isRequest(toCancel)) {
2328
2328
  const strategy = options?.connectionStrategy;
2329
- const response = strategy && strategy.cancelUndispatched ? strategy.cancelUndispatched(toCancel, cancelUndispatched) : cancelUndispatched(toCancel);
2330
- if (response && (response.error !== void 0 || response.result !== void 0)) {
2329
+ const response2 = strategy && strategy.cancelUndispatched ? strategy.cancelUndispatched(toCancel, cancelUndispatched) : cancelUndispatched(toCancel);
2330
+ if (response2 && (response2.error !== void 0 || response2.result !== void 0)) {
2331
2331
  messageQueue.delete(key);
2332
2332
  requestTokens.delete(cancelId);
2333
- response.id = toCancel.id;
2334
- traceSendingResponse(response, message.method, Date.now());
2335
- messageWriter.write(response).catch(() => logger.error(`Sending response for canceled message failed.`));
2333
+ response2.id = toCancel.id;
2334
+ traceSendingResponse(response2, message.method, Date.now());
2335
+ messageWriter.write(response2).catch(() => logger.error(`Sending response for canceled message failed.`));
2336
2336
  return;
2337
2337
  }
2338
2338
  }
@@ -9849,8 +9849,8 @@ var require_main3 = __commonJS({
9849
9849
  return args.map((arg) => typeof arg === "string" ? arg : (0, node_util_1.inspect)(arg)).join(" ");
9850
9850
  }
9851
9851
  const counters = /* @__PURE__ */ new Map();
9852
- console.assert = function assert(assertion, ...args) {
9853
- if (assertion) {
9852
+ console.assert = function assert(assertion2, ...args) {
9853
+ if (assertion2) {
9854
9854
  return;
9855
9855
  }
9856
9856
  if (args.length === 0) {
@@ -12666,11 +12666,11 @@ var require_validate = __commonJS({
12666
12666
  jsonPointer = $data;
12667
12667
  data = names_1.default.rootData;
12668
12668
  } else {
12669
- const matches = RELATIVE_JSON_POINTER.exec($data);
12670
- if (!matches)
12669
+ const matches2 = RELATIVE_JSON_POINTER.exec($data);
12670
+ if (!matches2)
12671
12671
  throw new Error(`Invalid JSON-pointer: ${$data}`);
12672
- const up = +matches[1];
12673
- jsonPointer = matches[2];
12672
+ const up = +matches2[1];
12673
+ jsonPointer = matches2[2];
12674
12674
  if (jsonPointer === "#") {
12675
12675
  if (up >= dataLevel)
12676
12676
  throw new Error(errorMsg("property/index", up));
@@ -13557,11 +13557,11 @@ var require_schemes = __commonJS({
13557
13557
  urnComponent.error = "URN can not be parsed";
13558
13558
  return urnComponent;
13559
13559
  }
13560
- const matches = urnComponent.path.match(URN_REG);
13561
- if (matches && matches[0] === urnComponent.path) {
13560
+ const matches2 = urnComponent.path.match(URN_REG);
13561
+ if (matches2 && matches2[0] === urnComponent.path) {
13562
13562
  const scheme = options.scheme || urnComponent.scheme || "urn";
13563
- urnComponent.nid = matches[1].toLowerCase();
13564
- urnComponent.nss = matches[2];
13563
+ urnComponent.nid = matches2[1].toLowerCase();
13564
+ urnComponent.nss = matches2[2];
13565
13565
  const urnScheme = `${scheme}:${options.nid || urnComponent.nid}`;
13566
13566
  const schemeHandler = getSchemeHandler(urnScheme);
13567
13567
  urnComponent.path = void 0;
@@ -13875,8 +13875,8 @@ var require_fast_uri = __commonJS({
13875
13875
  var URI_PARSE = /^(?:([^#/:?]+):)?(?:\/\/((?:([^#/?@]*)@)?(\[[^#/?\]]+\]|[^#/:?]*)(?::(\d*))?))?([^#?]*)(?:\?([^#]*))?(?:#((?:.|[\n\r])*))?/u;
13876
13876
  var AUTHORITY_PREFIX = /^(?:[^#/:?]+:)?\/\/([^/?#]*)/;
13877
13877
  var AUTHORITY_INTRODUCER_REGION = /^(?:[^#/:?]+:)?([/\\\t\n\r]*)/;
13878
- function getParseError(parsed, matches) {
13879
- if (matches[2] !== void 0 && parsed.path && parsed.path[0] !== "/") {
13878
+ function getParseError(parsed, matches2) {
13879
+ if (matches2[2] !== void 0 && parsed.path && parsed.path[0] !== "/") {
13880
13880
  return 'URI path must start with "/" when authority is present.';
13881
13881
  }
13882
13882
  if (typeof parsed.port === "number" && (parsed.port < 0 || parsed.port > 65535)) {
@@ -13898,9 +13898,9 @@ var require_fast_uri = __commonJS({
13898
13898
  function isIPLiteral(host) {
13899
13899
  return host[0] === "[" && host[host.length - 1] === "]";
13900
13900
  }
13901
- function hasMalformedComponentPercentEncoding(matches) {
13902
- const host = matches[4];
13903
- return hasMalformedPercentEncoding(matches[3]) || host !== void 0 && !isIPLiteral(host) && hasMalformedPercentEncoding(host) || hasMalformedPercentEncoding(matches[6]) || hasMalformedPercentEncoding(matches[7]) || hasMalformedPercentEncoding(matches[8]);
13901
+ function hasMalformedComponentPercentEncoding(matches2) {
13902
+ const host = matches2[4];
13903
+ return hasMalformedPercentEncoding(matches2[3]) || host !== void 0 && !isIPLiteral(host) && hasMalformedPercentEncoding(host) || hasMalformedPercentEncoding(matches2[6]) || hasMalformedPercentEncoding(matches2[7]) || hasMalformedPercentEncoding(matches2[8]);
13904
13904
  }
13905
13905
  function canonicalizeHost(parsed, options, schemeHandler, isIP) {
13906
13906
  if (!options.unicodeSupport && (!schemeHandler || !schemeHandler.unicodeSupport) && parsed.host && !isIPLiteral(parsed.host) && (options.domainHost || schemeHandler && schemeHandler.domainHost) && isIP === false && nonSimpleDomain(parsed.host)) {
@@ -13957,15 +13957,15 @@ var require_fast_uri = __commonJS({
13957
13957
  }
13958
13958
  }
13959
13959
  }
13960
- const matches = uri.match(URI_PARSE);
13961
- if (matches) {
13962
- parsed.scheme = matches[1];
13963
- parsed.userinfo = matches[3];
13964
- parsed.host = matches[4];
13965
- parsed.port = parseInt(matches[5], 10);
13966
- parsed.path = matches[6] || "";
13967
- parsed.query = matches[7];
13968
- parsed.fragment = matches[8];
13960
+ const matches2 = uri.match(URI_PARSE);
13961
+ if (matches2) {
13962
+ parsed.scheme = matches2[1];
13963
+ parsed.userinfo = matches2[3];
13964
+ parsed.host = matches2[4];
13965
+ parsed.port = parseInt(matches2[5], 10);
13966
+ parsed.path = matches2[6] || "";
13967
+ parsed.query = matches2[7];
13968
+ parsed.fragment = matches2[8];
13969
13969
  if (parsed.scheme !== void 0) {
13970
13970
  const decodedScheme = unescape(parsed.scheme);
13971
13971
  if (VALID_SCHEME.test(decodedScheme)) {
@@ -13975,14 +13975,14 @@ var require_fast_uri = __commonJS({
13975
13975
  malformedScheme = true;
13976
13976
  }
13977
13977
  }
13978
- malformedPercentEncoding = hasMalformedComponentPercentEncoding(matches);
13978
+ malformedPercentEncoding = hasMalformedComponentPercentEncoding(matches2);
13979
13979
  if (malformedPercentEncoding) {
13980
13980
  parsed.error = parsed.error || "URI contains malformed percent-encoding.";
13981
13981
  }
13982
13982
  if (isNaN(parsed.port)) {
13983
- parsed.port = matches[5];
13983
+ parsed.port = matches2[5];
13984
13984
  }
13985
- const parseError = getParseError(parsed, matches);
13985
+ const parseError = getParseError(parsed, matches2);
13986
13986
  if (parseError !== void 0) {
13987
13987
  parsed.error = parsed.error || parseError;
13988
13988
  malformedAuthorityOrPort = true;
@@ -14690,13 +14690,13 @@ var require_core = __commonJS({
14690
14690
  RULES.all[keyword] = rule;
14691
14691
  (_a = definition.implements) === null || _a === void 0 ? void 0 : _a.forEach((kwd) => this.addKeyword(kwd));
14692
14692
  }
14693
- function addBeforeRule(ruleGroup, rule, before) {
14694
- const i = ruleGroup.rules.findIndex((_rule) => _rule.keyword === before);
14693
+ function addBeforeRule(ruleGroup, rule, before2) {
14694
+ const i = ruleGroup.rules.findIndex((_rule) => _rule.keyword === before2);
14695
14695
  if (i >= 0) {
14696
14696
  ruleGroup.rules.splice(i, 0, rule);
14697
14697
  } else {
14698
14698
  ruleGroup.rules.push(rule);
14699
- this.logger.warn(`rule ${before} is not defined`);
14699
+ this.logger.warn(`rule ${before2} is not defined`);
14700
14700
  }
14701
14701
  }
14702
14702
  function keywordMetaschema(def) {
@@ -22223,11 +22223,11 @@ var require_compose_scalar = __commonJS({
22223
22223
  var require_util_empty_scalar_position = __commonJS({
22224
22224
  "node_modules/yaml/dist/compose/util-empty-scalar-position.js"(exports2) {
22225
22225
  "use strict";
22226
- function emptyScalarPosition(offset, before, pos) {
22227
- if (before) {
22228
- pos ?? (pos = before.length);
22226
+ function emptyScalarPosition(offset, before2, pos) {
22227
+ if (before2) {
22228
+ pos ?? (pos = before2.length);
22229
22229
  for (let i = pos - 1; i >= 0; --i) {
22230
- let st = before[i];
22230
+ let st = before2[i];
22231
22231
  switch (st.type) {
22232
22232
  case "space":
22233
22233
  case "comment":
@@ -22235,10 +22235,10 @@ var require_util_empty_scalar_position = __commonJS({
22235
22235
  offset -= st.source.length;
22236
22236
  continue;
22237
22237
  }
22238
- st = before[++i];
22238
+ st = before2[++i];
22239
22239
  while (st?.type === "space") {
22240
22240
  offset += st.source.length;
22241
- st = before[++i];
22241
+ st = before2[++i];
22242
22242
  }
22243
22243
  break;
22244
22244
  }
@@ -22316,10 +22316,10 @@ var require_compose_node = __commonJS({
22316
22316
  node.srcToken = token;
22317
22317
  return node;
22318
22318
  }
22319
- function composeEmptyNode(ctx, offset, before, pos, { spaceBefore, comment, anchor, tag, end }, onError) {
22319
+ function composeEmptyNode(ctx, offset, before2, pos, { spaceBefore, comment, anchor, tag, end }, onError) {
22320
22320
  const token = {
22321
22321
  type: "scalar",
22322
- offset: utilEmptyScalarPosition.emptyScalarPosition(offset, before, pos),
22322
+ offset: utilEmptyScalarPosition.emptyScalarPosition(offset, before2, pos),
22323
22323
  indent: -1,
22324
22324
  source: ""
22325
22325
  };
@@ -23334,9 +23334,9 @@ var require_lexer = __commonJS({
23334
23334
  }
23335
23335
  }
23336
23336
  *parseQuotedScalar() {
23337
- const quote = this.charAt(0);
23338
- let end = this.buffer.indexOf(quote, this.pos + 1);
23339
- if (quote === "'") {
23337
+ const quote2 = this.charAt(0);
23338
+ let end = this.buffer.indexOf(quote2, this.pos + 1);
23339
+ if (quote2 === "'") {
23340
23340
  while (end !== -1 && this.buffer[end + 1] === "'")
23341
23341
  end = this.buffer.indexOf("'", end + 2);
23342
23342
  } else {
@@ -25048,8 +25048,8 @@ var OpenApiSpec = class {
25048
25048
  return requestBody && this.validateContent(requestBody.pointer, body);
25049
25049
  }
25050
25050
  validateResponse(op, key, body) {
25051
- const response = this.resolve(`${op.pointer}/responses/${esc(key)}`);
25052
- return response && this.validateContent(response.pointer, body);
25051
+ const response2 = this.resolve(`${op.pointer}/responses/${esc(key)}`);
25052
+ return response2 && this.validateContent(response2.pointer, body);
25053
25053
  }
25054
25054
  /** Validates against the `application/json` (or `+json`) schema of a request body or response object. */
25055
25055
  validateContent(pointer, body) {
@@ -25204,9 +25204,9 @@ function checkStep(spec, step, warn) {
25204
25204
  }
25205
25205
  if (step.failed) return;
25206
25206
  const label = `${op.method} ${op.path}`;
25207
- const request = jsonBody(step.requestFormat, step.requestBody);
25208
- if (request !== void 0) {
25209
- const errors = spec.validateRequest(op, request.value);
25207
+ const request2 = jsonBody(step.requestFormat, step.requestBody);
25208
+ if (request2 !== void 0) {
25209
+ const errors = spec.validateRequest(op, request2.value);
25210
25210
  if (errors?.length) warn(`Request body does not match the schema of ${label}: ${summarize(errors)}`, step.loc);
25211
25211
  }
25212
25212
  if (step.statusCode === void 0) return;
@@ -25217,9 +25217,9 @@ function checkStep(spec, step, warn) {
25217
25217
  warn(`Status ${step.statusCode} is not documented for ${label} (documented: ${documented.join(", ") || "none"})`, responseLoc);
25218
25218
  return;
25219
25219
  }
25220
- const response = jsonBody(step.responseFormat, step.responseBody);
25221
- if (response !== void 0) {
25222
- const errors = spec.validateResponse(op, key, response.value);
25220
+ const response2 = jsonBody(step.responseFormat, step.responseBody);
25221
+ if (response2 !== void 0) {
25222
+ const errors = spec.validateResponse(op, key, response2.value);
25223
25223
  if (errors?.length) warn(`${step.statusCode} response body does not match the schema of ${label}: ${summarize(errors)}`, responseLoc);
25224
25224
  }
25225
25225
  }
@@ -25434,6 +25434,8 @@ var IDENT_START = /[A-Za-z_]/;
25434
25434
  var IDENT_PART = /[A-Za-z0-9_]/;
25435
25435
  var TEAM_PART = /[A-Za-z0-9_-]/;
25436
25436
  var DIGIT = /[0-9]/;
25437
+ var LETTER = /[A-Za-z]/;
25438
+ var SEPARATE_UNIT = /^[ \t]+(rps|rpm|rpd|ms|s|%|usd\/month)(?![A-Za-z0-9_/])/;
25437
25439
  function tokenizeLine(text, line) {
25438
25440
  const tokens = [];
25439
25441
  const diagnostics = [];
@@ -25487,8 +25489,16 @@ function tokenizeLine(text, line) {
25487
25489
  }
25488
25490
  }
25489
25491
  if (DIGIT.test(ch)) {
25490
- while (i < text.length && DIGIT.test(text[i])) i++;
25491
- tokens.push({ kind: "number", value: text.slice(start, i), col: start + 1, length: i - start });
25492
+ i = readQuantity(text, i);
25493
+ const value = text.slice(start, i).replace(/[ \t]+/g, "");
25494
+ const kind = /^\d+$/.test(value) ? "number" : "quantity";
25495
+ tokens.push({ kind, value, col: start + 1, length: i - start });
25496
+ continue;
25497
+ }
25498
+ if (ch === "<" || ch === ">") {
25499
+ const value = text[i + 1] === "=" ? ch + "=" : ch;
25500
+ tokens.push({ kind: "op", value, col: start + 1, length: value.length });
25501
+ i += value.length;
25492
25502
  continue;
25493
25503
  }
25494
25504
  if (ch === "@") {
@@ -25514,6 +25524,26 @@ function tokenizeLine(text, line) {
25514
25524
  }
25515
25525
  return { tokens, diagnostics };
25516
25526
  }
25527
+ function readQuantity(text, start) {
25528
+ let i = start;
25529
+ while (i < text.length && DIGIT.test(text[i])) i++;
25530
+ if (text[i] === "." && DIGIT.test(text[i + 1] ?? "")) {
25531
+ i++;
25532
+ while (i < text.length && DIGIT.test(text[i])) i++;
25533
+ }
25534
+ const suffixStart = i;
25535
+ if (text[i] === "%") return i + 1;
25536
+ while (i < text.length && LETTER.test(text[i])) i++;
25537
+ if (i > suffixStart && text[i] === "/" && LETTER.test(text[i + 1] ?? "")) {
25538
+ i++;
25539
+ while (i < text.length && LETTER.test(text[i])) i++;
25540
+ }
25541
+ if (/^[kmb]?$/.test(text.slice(suffixStart, i))) {
25542
+ const unit = SEPARATE_UNIT.exec(text.slice(i));
25543
+ if (unit) i += unit[0].length;
25544
+ }
25545
+ return i;
25546
+ }
25517
25547
  function readString(text, start) {
25518
25548
  let value = "";
25519
25549
  let i = start + 1;
@@ -25532,15 +25562,15 @@ function readString(text, start) {
25532
25562
  }
25533
25563
  function stripTrailingComment(text) {
25534
25564
  let depth = 0;
25535
- let quote = null;
25565
+ let quote2 = null;
25536
25566
  for (let i = 0; i < text.length; i++) {
25537
25567
  const ch = text[i];
25538
- if (quote) {
25568
+ if (quote2) {
25539
25569
  if (ch === "\\") i++;
25540
- else if (ch === quote) quote = null;
25570
+ else if (ch === quote2) quote2 = null;
25541
25571
  continue;
25542
25572
  }
25543
- if (ch === '"') quote = ch;
25573
+ if (ch === '"') quote2 = ch;
25544
25574
  else if (ch === "{" || ch === "[") depth++;
25545
25575
  else if (ch === "}" || ch === "]") depth = Math.max(0, depth - 1);
25546
25576
  else if (ch === "#" && depth === 0 && (i === 0 || /\s/.test(text[i - 1]))) return text.slice(0, i);
@@ -25596,11 +25626,92 @@ function endpointGroupKey(endpoint, all) {
25596
25626
  return method ? `${method} ${templatize(path)}` : templatize(path);
25597
25627
  }
25598
25628
 
25629
+ // ../frontend/src/dsl/kinds.ts
25630
+ var KINDS = ["client", "edge", "service", "function", "cache", "database", "search", "analytics", "queue", "storage", "external", "other"];
25631
+ var DATA_STORE_KINDS = ["cache", "database", "search", "analytics", "queue", "storage"];
25632
+ var BY_TECH = {
25633
+ "AWS API Gateway": "edge",
25634
+ "Azure API Management": "edge",
25635
+ Redis: "cache",
25636
+ Elasticsearch: "search",
25637
+ "GCP BigQuery": "analytics",
25638
+ InfluxDB: "analytics",
25639
+ TimescaleDB: "analytics",
25640
+ // A note shape annotates the diagram; it is not a client.
25641
+ Note: "other"
25642
+ };
25643
+ var BY_TYPE = {
25644
+ shape: "client",
25645
+ cdn: "edge",
25646
+ service: "service",
25647
+ compute: "service",
25648
+ container: "service",
25649
+ serverless: "function",
25650
+ cache: "cache",
25651
+ database: "database",
25652
+ queue: "queue",
25653
+ storage: "storage",
25654
+ external: "external"
25655
+ };
25656
+ function kindOf(node) {
25657
+ if (node.kind !== "component") return "other";
25658
+ return BY_TECH[node.techStack] ?? BY_TYPE[node.type] ?? "other";
25659
+ }
25660
+ function isKind(word) {
25661
+ return KINDS.includes(word);
25662
+ }
25663
+ function isDataStore(kind) {
25664
+ return DATA_STORE_KINDS.includes(kind);
25665
+ }
25666
+
25667
+ // ../frontend/src/dsl/quantity.ts
25668
+ function parseQuantity(text) {
25669
+ const m = /^(\d+(?:\.\d+)?)(.*)$/.exec(text.replace(/\s+/g, ""));
25670
+ if (!m) return { error: `'${text}' is not a number` };
25671
+ const number = Number(m[1]);
25672
+ const suffix = m[2];
25673
+ const unit = UNITS.get(suffix);
25674
+ if (suffix === "" || unit) return unit ? { value: round(number * unit.factor), unit: unit.unit } : { value: number };
25675
+ const magnitude = MAGNITUDES.get(suffix[0]);
25676
+ const rest = suffix.slice(1);
25677
+ const after = UNITS.get(rest);
25678
+ if (magnitude && (rest === "" || after)) {
25679
+ return after ? { value: round(number * magnitude * after.factor), unit: after.unit } : { value: round(number * magnitude) };
25680
+ }
25681
+ return { error: `Unknown unit '${suffix}' in '${text}'; use rps, rpm, rpd, ms, s, % or usd/month (with k, m or b for thousands, millions, billions)` };
25682
+ }
25683
+ var round = (n) => Number(n.toPrecision(12));
25684
+ var MAGNITUDES = /* @__PURE__ */ new Map([
25685
+ ["k", 1e3],
25686
+ ["m", 1e6],
25687
+ ["b", 1e9]
25688
+ ]);
25689
+ var UNITS = /* @__PURE__ */ new Map([
25690
+ ["rps", { unit: "rps", factor: 1 }],
25691
+ ["rpm", { unit: "rps", factor: 1 / 60 }],
25692
+ ["rpd", { unit: "rps", factor: 1 / 86400 }],
25693
+ ["ms", { unit: "ms", factor: 1 }],
25694
+ ["s", { unit: "ms", factor: 1e3 }],
25695
+ ["%", { unit: "%", factor: 1 }],
25696
+ ["usd/month", { unit: "usd/month", factor: 1 }]
25697
+ ]);
25698
+
25599
25699
  // ../frontend/src/dsl/parser.ts
25600
25700
  var HTTP_METHODS = ["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"];
25601
25701
  var DEFAULT_TECH = "Rectangle";
25602
25702
  var DEFAULT_GROUP_TECH = "Logical Group";
25603
25703
  var MAX_SCENARIOS = 32;
25704
+ var REPLICAS = /^x(\d+)$/;
25705
+ var PERCENTILES = { p50: 50, p90: 90, p95: 95, p99: 99, p999: 99.9 };
25706
+ var FIELD_FLAGS = ["key", "index", "unique", "optional"];
25707
+ var UNIT_EXAMPLES = {
25708
+ rps: "a rate, e.g. 100k rps, 6k rpm or 1m rpd",
25709
+ ms: "a duration, e.g. 50ms or 1.5s",
25710
+ "%": "a percentage, e.g. 99.9%",
25711
+ "usd/month": "a monthly cost, e.g. 400 usd/month"
25712
+ };
25713
+ var ASSERTION_HELP = 'Expected an assertion: "Use case" calls <node>, "Use case" writes <node> before responding, no path from <node> to <node>, <node> has replicas >= 2, \u2026';
25714
+ var ASSERTION_VERBS = "calls, every scenario calls, never calls, writes, responds, has scenario or handles failure of";
25604
25715
  var techByName = new Map(
25605
25716
  componentCatalog.map((c) => [c.techStack.toLowerCase(), { type: c.type, techStack: c.techStack }])
25606
25717
  );
@@ -25628,6 +25739,16 @@ var Parser = class {
25628
25739
  /** `a|b` for every connection, both directions; filled before use cases are built. */
25629
25740
  connected = /* @__PURE__ */ new Set();
25630
25741
  title;
25742
+ summary;
25743
+ traffic = [];
25744
+ requirements = [];
25745
+ capacity = [];
25746
+ entities = [];
25747
+ decisions = [];
25748
+ tests = [];
25749
+ /** Checks of use case, scenario and node names, run once every file is read and use cases are built. */
25750
+ deferred = [];
25751
+ built = [];
25631
25752
  options;
25632
25753
  constructor(source2, options) {
25633
25754
  this.options = options;
@@ -25642,11 +25763,21 @@ var Parser = class {
25642
25763
  const useCases = this.useCases.map(({ useCase, body }) => this.buildUseCase(useCase, body));
25643
25764
  const endpoints = useCases.flatMap((u) => u.endpoint ? [u.endpoint] : []);
25644
25765
  for (const u of useCases) if (u.endpoint) u.endpointGroup = endpointGroupKey(u.endpoint, endpoints);
25766
+ this.built = useCases;
25767
+ for (const check of this.deferred) check();
25645
25768
  const diagram = {
25646
25769
  title: this.title,
25647
25770
  nodes: [...this.nodes.values()],
25648
25771
  edges: this.edges,
25649
- useCases
25772
+ useCases,
25773
+ // The HLD sections are left out when a document has none, so older documents parse exactly as before.
25774
+ ...this.summary !== void 0 ? { summary: this.summary } : {},
25775
+ ...this.traffic.length ? { traffic: this.traffic } : {},
25776
+ ...this.requirements.length ? { requirements: this.requirements } : {},
25777
+ ...this.capacity.length ? { capacity: this.capacity } : {},
25778
+ ...this.entities.length ? { entities: this.entities } : {},
25779
+ ...this.decisions.length ? { decisions: this.decisions } : {},
25780
+ ...this.tests.length ? { tests: this.tests } : {}
25650
25781
  };
25651
25782
  const order = (d) => this.fileOrder.get(d.file) ?? 0;
25652
25783
  this.diagnostics.sort((a, b) => order(a) - order(b) || a.line - b.line || a.col - b.col);
@@ -25658,7 +25789,7 @@ var Parser = class {
25658
25789
  i = this.parseLine(i);
25659
25790
  }
25660
25791
  for (const frame of this.stack) {
25661
- const what = frame.kind === "group" ? `group '${frame.id}'` : frame.kind === "usecase" ? `usecase '${frame.useCase.name}'` : frame.kind === "alt" ? `alt '${frame.name}'` : "par block";
25792
+ const what = frame.kind === "group" ? `group '${frame.id}'` : frame.kind === "usecase" ? `usecase '${frame.useCase.name}'` : frame.kind === "alt" ? `alt '${frame.name}'` : frame.kind === "par" ? "par block" : sectionName(frame);
25662
25793
  this.error(`Missing } to close ${what}`, frame.loc);
25663
25794
  }
25664
25795
  }
@@ -25668,8 +25799,19 @@ var Parser = class {
25668
25799
  const { tokens, diagnostics } = tokenizeLine(this.lines[index], line);
25669
25800
  this.diagnostics.push(...diagnostics.map((d) => ({ ...d, ...this.fileField() })));
25670
25801
  if (tokens.length === 0 || diagnostics.some((d) => d.severity === "error")) return index;
25671
- const [first, second] = tokens;
25802
+ const [first, second, third] = tokens;
25672
25803
  const loc = (t) => this.locOf(t, line);
25804
+ const top = this.top();
25805
+ if (top?.kind === "section") {
25806
+ if (first.kind === "rbrace") {
25807
+ this.stack.pop();
25808
+ if (!top.discard) this.closeSection(top);
25809
+ if (second) this.error("Unexpected input after }", loc(second));
25810
+ } else if (!top.discard) {
25811
+ this.parseSectionLine(top, tokens, line);
25812
+ }
25813
+ return index;
25814
+ }
25673
25815
  if (first.kind === "rbrace") {
25674
25816
  if (this.stack.length === 0) this.error("Unmatched }", loc(first));
25675
25817
  else this.closeFrame();
@@ -25703,6 +25845,26 @@ var Parser = class {
25703
25845
  case "import":
25704
25846
  this.parseImport(tokens, line);
25705
25847
  break;
25848
+ // These words are keywords only in the shape of their statement, so older documents may still use them as ids.
25849
+ case "traffic":
25850
+ case "requirements":
25851
+ case "capacity":
25852
+ if (second?.kind === "lbrace") this.openSection(first.value, tokens, line);
25853
+ else this.parseNode(tokens, line);
25854
+ break;
25855
+ case "entity":
25856
+ if (second?.kind === "ident" && second.value !== "pos" && !REPLICAS.test(second.value)) this.parseEntity(tokens, line);
25857
+ else this.parseNode(tokens, line);
25858
+ break;
25859
+ case "decision":
25860
+ if (second?.kind === "string" && (third?.kind === "lbrace" || third?.kind === "ident" && third.value !== "pos" && !REPLICAS.test(third.value)))
25861
+ this.parseDecision(tokens, line);
25862
+ else this.parseNode(tokens, line);
25863
+ break;
25864
+ case "test":
25865
+ if (second?.kind === "string" && tokens[tokens.length - 1].kind === "lbrace") this.parseTest(tokens, line);
25866
+ else this.parseNode(tokens, line);
25867
+ break;
25706
25868
  default:
25707
25869
  this.parseNode(tokens, line);
25708
25870
  }
@@ -25714,8 +25876,12 @@ var Parser = class {
25714
25876
  this.error('Expected a title, e.g. title "My System"', this.locOf(tokens[0], line));
25715
25877
  return;
25716
25878
  }
25717
- if (this.file === void 0) this.title = value.value;
25718
- this.expectEnd(tokens, 2, line);
25879
+ const summary = tokens[2]?.kind === "string" ? tokens[2] : void 0;
25880
+ if (this.file === void 0) {
25881
+ this.title = value.value;
25882
+ if (summary) this.summary = summary.value;
25883
+ }
25884
+ this.expectEnd(tokens, summary ? 3 : 2, line);
25719
25885
  }
25720
25886
  parseImport(tokens, line) {
25721
25887
  const keyword = tokens[0];
@@ -25924,6 +26090,13 @@ var Parser = class {
25924
26090
  }
25925
26091
  node.position = { x: Number(x.value), y: Number(y.value) };
25926
26092
  i += 3;
26093
+ } else if (t.kind === "ident" && REPLICAS.test(t.value)) {
26094
+ const replicas = Number(REPLICAS.exec(t.value)[1]);
26095
+ if (replicas < 1) {
26096
+ this.error("A node needs at least one replica: x1, x2, \u2026", this.locOf(t, line));
26097
+ return;
26098
+ }
26099
+ node.replicas = replicas;
25927
26100
  } else if (t.kind === "label" || t.kind === "arrow") {
25928
26101
  this.error(`Expected '${idToken.value} -> target' for a connection`, this.locOf(t, line));
25929
26102
  return;
@@ -25979,6 +26152,592 @@ var Parser = class {
25979
26152
  }
25980
26153
  return lastLine;
25981
26154
  }
26155
+ // ---- HLD sections: traffic, requirements, capacity, entity, decision, test (docs/design/hld-and-practice.md §1) ----
26156
+ /**
26157
+ * Starts a section block. Sections are top level only; a misplaced one, or
26158
+ * one whose header is broken, still opens a block so that its lines and its
26159
+ * `}` are not mistaken for nodes and the end of an enclosing block.
26160
+ */
26161
+ openSection(section, tokens, line, fill = {}) {
26162
+ const keyword = tokens[0];
26163
+ const loc = this.locOf(keyword, line);
26164
+ if (this.stack.length > 0) {
26165
+ this.error(`${section} is only allowed at the top level`, loc);
26166
+ this.skipSection(section, tokens, line);
26167
+ return false;
26168
+ }
26169
+ if (section === "traffic" || section === "requirements" || section === "capacity") this.expectEnd(tokens, 2, line);
26170
+ this.stack.push({ kind: "section", section, ...fill, loc });
26171
+ return true;
26172
+ }
26173
+ /** After an error in a section header: skips the block, if the line opens one. */
26174
+ skipSection(section, tokens, line) {
26175
+ if (tokens[tokens.length - 1].kind === "lbrace") this.stack.push({ kind: "section", section, discard: true, loc: this.locOf(tokens[0], line) });
26176
+ }
26177
+ closeSection(frame) {
26178
+ if (frame.test && frame.test.assertions.length === 0) this.warning(`Test '${frame.test.name}' has no assertions`, frame.test.loc);
26179
+ }
26180
+ parseSectionLine(frame, tokens, line) {
26181
+ switch (frame.section) {
26182
+ case "traffic":
26183
+ return this.parseTrafficLine(tokens, line);
26184
+ case "requirements":
26185
+ return this.parseRequirement(tokens, line);
26186
+ case "capacity":
26187
+ return this.parseCapacityLine(tokens, line);
26188
+ case "entity":
26189
+ return this.parseField(frame.entity, tokens, line);
26190
+ case "decision":
26191
+ return this.parseDecisionLine(frame.decision, tokens, line);
26192
+ case "test": {
26193
+ const assertion2 = this.parseAssertion(tokens, line);
26194
+ if (assertion2) frame.test.assertions.push(assertion2);
26195
+ return;
26196
+ }
26197
+ }
26198
+ }
26199
+ /** `"Redirect" 100k rps mix "Cache hit" 90%, "Cache miss" 10%` */
26200
+ parseTrafficLine(tokens, line) {
26201
+ const [name, rate] = tokens;
26202
+ if (name.kind !== "string") {
26203
+ this.error('Expected a use case name in quotes and a rate, e.g. "Redirect" 100k rps', this.locOf(name, line));
26204
+ return;
26205
+ }
26206
+ const rps = this.quantityOf(rate, "rps", name, line);
26207
+ if (rps === void 0) return;
26208
+ const entry = { useCase: name.value, rps, loc: this.lineLoc(tokens, line) };
26209
+ const shares = [];
26210
+ let i = 2;
26211
+ if (tokens[i]) {
26212
+ const mix = tokens[i++];
26213
+ if (!isWord(mix, "mix")) {
26214
+ this.error(`Unexpected ${describe(mix)}; expected mix "Scenario" 90%, "Other" 10%`, this.locOf(mix, line));
26215
+ return;
26216
+ }
26217
+ for (; ; ) {
26218
+ const scenario = tokens[i];
26219
+ if (scenario?.kind !== "string") {
26220
+ this.error('Expected a scenario name in quotes and its share, e.g. "Cache hit" 90%', this.locOf(scenario ?? tokens[i - 1], line));
26221
+ return;
26222
+ }
26223
+ const share = this.quantityOf(tokens[i + 1], "%", scenario, line);
26224
+ if (share === void 0) return;
26225
+ const loc = this.locOf(scenario, line);
26226
+ if (shares.some((s) => s.scenario === scenario.value)) {
26227
+ this.error(`Scenario '${scenario.value}' is in the mix twice`, loc);
26228
+ return;
26229
+ }
26230
+ shares.push({ scenario: scenario.value, share, loc });
26231
+ i += 2;
26232
+ if (!tokens[i]) break;
26233
+ if (tokens[i].kind !== "comma") {
26234
+ this.error(`Expected , between mix shares, not ${describe(tokens[i])}`, this.locOf(tokens[i], line));
26235
+ return;
26236
+ }
26237
+ i++;
26238
+ }
26239
+ const total = shares.reduce((sum, s) => sum + s.share, 0);
26240
+ if (total <= 0) {
26241
+ this.error("Mix shares must add up to more than 0%", this.locOf(mix, line));
26242
+ return;
26243
+ }
26244
+ if (Math.abs(total - 100) > 1e-9) {
26245
+ this.warning(`Mix shares add up to ${Number(total.toFixed(3))}%, not 100%; they are scaled to fit`, this.locOf(mix, line));
26246
+ }
26247
+ entry.mix = shares.map((s) => ({ scenario: s.scenario, share: s.share / total }));
26248
+ }
26249
+ const nameLoc = this.locOf(name, line);
26250
+ const first = this.traffic.find((t) => t.useCase === entry.useCase);
26251
+ if (first) {
26252
+ this.error(`Duplicate traffic for '${entry.useCase}' (first ${this.where(first.loc, nameLoc)})`, nameLoc);
26253
+ return;
26254
+ }
26255
+ this.traffic.push(entry);
26256
+ this.deferred.push(() => {
26257
+ const useCase = this.checkUseCase(entry.useCase, nameLoc);
26258
+ if (useCase) for (const s of shares) this.checkScenario(useCase, s.scenario, s.loc);
26259
+ });
26260
+ }
26261
+ /** One line of `requirements { … }`. */
26262
+ parseRequirement(tokens, line) {
26263
+ const [first] = tokens;
26264
+ const loc = this.lineLoc(tokens, line);
26265
+ const help = 'Expected a requirement: p99 "Use case" < 50ms, availability >= 99.9%, durable "Use case", survive any node failure, cost <= 3000 usd/month';
26266
+ if (first.kind !== "ident") {
26267
+ this.error(help, this.locOf(first, line));
26268
+ return;
26269
+ }
26270
+ const word = first.value;
26271
+ let i = 1;
26272
+ let useCase;
26273
+ const readUseCase = () => {
26274
+ if (tokens[i]?.kind !== "string") return;
26275
+ useCase = { name: tokens[i].value, loc: this.locOf(tokens[i], line) };
26276
+ i++;
26277
+ };
26278
+ const commit = (requirement2) => {
26279
+ this.requirements.push(requirement2);
26280
+ const named = useCase;
26281
+ if (named) this.deferred.push(() => this.checkUseCase(named.name, named.loc));
26282
+ };
26283
+ const withUseCase = () => useCase ? { useCase: useCase.name } : {};
26284
+ if (/^p\d+$/.test(word)) {
26285
+ const percentile = PERCENTILES[word];
26286
+ if (percentile === void 0) {
26287
+ this.error(`Unknown percentile '${word}'; use p50, p90, p95, p99 or p999`, this.locOf(first, line));
26288
+ return;
26289
+ }
26290
+ readUseCase();
26291
+ if (!this.expectOp(tokens, i, ["<", "<="], `${word} "Use case" < 50ms`, line)) return;
26292
+ const maxMs = this.quantityOf(tokens[i + 1], "ms", tokens[i], line);
26293
+ if (maxMs === void 0 || !this.expectEnd(tokens, i + 2, line)) return;
26294
+ commit({ kind: "latency", percentile, ...withUseCase(), maxMs, loc });
26295
+ return;
26296
+ }
26297
+ switch (word) {
26298
+ case "availability": {
26299
+ readUseCase();
26300
+ if (!this.expectOp(tokens, i, [">=", ">"], 'availability "Use case" >= 99.9%', line)) return;
26301
+ const minPercent = this.quantityOf(tokens[i + 1], "%", tokens[i], line);
26302
+ if (minPercent === void 0) return;
26303
+ if (minPercent > 100) {
26304
+ this.error("Availability cannot be more than 100%", this.locOf(tokens[i + 1], line));
26305
+ return;
26306
+ }
26307
+ if (!this.expectEnd(tokens, i + 2, line)) return;
26308
+ commit({ kind: "availability", ...withUseCase(), minPercent, loc });
26309
+ return;
26310
+ }
26311
+ case "durable":
26312
+ readUseCase();
26313
+ if (!useCase) {
26314
+ this.error('Expected a use case name in quotes, e.g. durable "Shorten"', this.locOf(tokens[1] ?? first, line));
26315
+ return;
26316
+ }
26317
+ if (!this.expectEnd(tokens, i, line)) return;
26318
+ commit({ kind: "durable", useCase: useCase.name, loc });
26319
+ return;
26320
+ case "survive": {
26321
+ if (isWord(tokens[1], "any") && isWord(tokens[2], "node") && isWord(tokens[3], "failure")) {
26322
+ if (this.expectEnd(tokens, 4, line)) commit({ kind: "survive", target: "any", loc });
26323
+ return;
26324
+ }
26325
+ if (!isWord(tokens[1], "failure") || !isWord(tokens[2], "of")) {
26326
+ this.error("Expected survive any node failure, or survive failure of <node | [Tech] | any kind>", this.locOf(tokens[1] ?? first, line));
26327
+ return;
26328
+ }
26329
+ const target = this.parseSelector(tokens, 3, line);
26330
+ if (!target || !this.expectEnd(tokens, target.next, line)) return;
26331
+ commit({ kind: "survive", target: target.selector, loc });
26332
+ this.checkSelectorLater(target);
26333
+ return;
26334
+ }
26335
+ case "cost": {
26336
+ if (!this.expectOp(tokens, 1, ["<=", "<"], "cost <= 3000 usd/month", line)) return;
26337
+ const maxUsdPerMonth = this.quantityOf(tokens[2], "usd/month", tokens[1], line);
26338
+ if (maxUsdPerMonth === void 0 || !this.expectEnd(tokens, 3, line)) return;
26339
+ commit({ kind: "cost", maxUsdPerMonth, loc });
26340
+ return;
26341
+ }
26342
+ default:
26343
+ this.error(`Unknown requirement '${word}'. ${help}`, this.locOf(first, line));
26344
+ }
26345
+ }
26346
+ /** `db 20k rps latency 4ms availability 99.95% cost 400 usd/month durable`, parts in any order. */
26347
+ parseCapacityLine(tokens, line) {
26348
+ const [id] = tokens;
26349
+ if (id.kind !== "ident") {
26350
+ this.error("Expected a node id and its overrides, e.g. db 20k rps latency 4ms", this.locOf(id, line));
26351
+ return;
26352
+ }
26353
+ const override = { node: id.value, loc: this.lineLoc(tokens, line) };
26354
+ const seen = /* @__PURE__ */ new Set();
26355
+ for (let i = 1; i < tokens.length; ) {
26356
+ const t = tokens[i];
26357
+ let part;
26358
+ if (t.kind === "quantity" || t.kind === "number") {
26359
+ part = "rate";
26360
+ const rps = this.quantityOf(t, "rps", t, line);
26361
+ if (rps === void 0) return;
26362
+ override.rps = rps;
26363
+ i++;
26364
+ } else if (isWord(t, "latency") || isWord(t, "availability") || isWord(t, "cost")) {
26365
+ part = t.value;
26366
+ const unit = part === "latency" ? "ms" : part === "availability" ? "%" : "usd/month";
26367
+ const value = this.quantityOf(tokens[i + 1], unit, t, line);
26368
+ if (value === void 0) return;
26369
+ if (part === "latency") override.latencyMs = value;
26370
+ else if (part === "availability") override.availability = value;
26371
+ else override.costUsd = value;
26372
+ i += 2;
26373
+ } else if (isWord(t, "durable") || isWord(t, "volatile")) {
26374
+ part = "durable or volatile";
26375
+ override.durable = t.value === "durable";
26376
+ i++;
26377
+ } else {
26378
+ this.error(`Unexpected ${describe(t)}; expected a rate, latency, availability, cost, durable or volatile`, this.locOf(t, line));
26379
+ return;
26380
+ }
26381
+ if (seen.has(part)) {
26382
+ this.error(`${part[0].toUpperCase()}${part.slice(1)} is given twice for '${id.value}'`, this.locOf(t, line));
26383
+ return;
26384
+ }
26385
+ seen.add(part);
26386
+ }
26387
+ const idLoc = this.locOf(id, line);
26388
+ if (seen.size === 0) {
26389
+ this.warning(`Capacity line for '${id.value}' overrides nothing; add a rate, latency, availability, cost, durable or volatile`, idLoc);
26390
+ return;
26391
+ }
26392
+ const first = this.capacity.find((c) => c.node === id.value);
26393
+ if (first) {
26394
+ this.error(`Duplicate capacity for '${id.value}' (first ${this.where(first.loc, idLoc)})`, idLoc);
26395
+ return;
26396
+ }
26397
+ this.capacity.push(override);
26398
+ this.deferred.push(() => {
26399
+ if (!this.nodes.has(id.value)) this.warning(`Unknown node '${id.value}' in capacity`, idLoc);
26400
+ });
26401
+ }
26402
+ /** `entity Url [in db] ["description"] {` */
26403
+ parseEntity(tokens, line) {
26404
+ const name = tokens[1];
26405
+ let i = 2;
26406
+ let store;
26407
+ if (isWord(tokens[i], "in")) {
26408
+ const id = tokens[i + 1];
26409
+ if (id?.kind !== "ident") {
26410
+ this.error("Expected the id of the node that stores the entity after in, e.g. entity Url in db {", this.locOf(id ?? tokens[i], line));
26411
+ this.skipSection("entity", tokens, line);
26412
+ return;
26413
+ }
26414
+ store = { id: id.value, loc: this.locOf(id, line) };
26415
+ i += 2;
26416
+ }
26417
+ const description = tokens[i]?.kind === "string" ? tokens[i++].value : void 0;
26418
+ if (tokens[i]?.kind !== "lbrace") {
26419
+ this.error('Expected { after the entity header, e.g. entity Url in db "Short codes" {', this.locOf(tokens[i] ?? tokens[i - 1], line));
26420
+ this.skipSection("entity", tokens, line);
26421
+ return;
26422
+ }
26423
+ this.expectEnd(tokens, i + 1, line);
26424
+ const loc = this.locOf(name, line);
26425
+ const entity = { name: name.value, ...store ? { store: store.id } : {}, ...description !== void 0 ? { description } : {}, fields: [], loc };
26426
+ const first = this.entities.find((e) => e.name === entity.name);
26427
+ if (first) {
26428
+ this.error(`Duplicate entity '${entity.name}' (first ${this.where(first.loc, loc)})`, loc);
26429
+ this.skipSection("entity", tokens, line);
26430
+ return;
26431
+ }
26432
+ if (!this.openSection("entity", tokens, line, { entity })) return;
26433
+ this.entities.push(entity);
26434
+ if (store) {
26435
+ const { id, loc: storeLoc } = store;
26436
+ this.deferred.push(() => {
26437
+ const node = this.nodes.get(id);
26438
+ if (!node) this.warning(`Unknown node '${id}'`, storeLoc);
26439
+ else if (!isDataStore(kindOf(node)))
26440
+ this.warning(`'${id}' is not a data store (${DATA_STORE_KINDS.join(", ")}); entity '${entity.name}' is placed in a ${kindOf(node)}`, storeLoc);
26441
+ });
26442
+ }
26443
+ }
26444
+ /** `code string key`: a field name, a type and flags. */
26445
+ parseField(entity, tokens, line) {
26446
+ const [name, type] = tokens;
26447
+ if (name.kind !== "ident") {
26448
+ this.error("Expected a field: name type [key] [index] [unique] [optional], e.g. code string key", this.locOf(name, line));
26449
+ return;
26450
+ }
26451
+ if (type?.kind !== "ident") {
26452
+ this.error(`Expected a type after '${name.value}', e.g. ${name.value} string`, this.locOf(type ?? name, line));
26453
+ return;
26454
+ }
26455
+ const flags = [];
26456
+ for (const t of tokens.slice(2)) {
26457
+ if (t.kind !== "ident" || !FIELD_FLAGS.includes(t.value)) {
26458
+ this.error(`Unknown field flag ${describe(t)}; use key, index, unique or optional`, this.locOf(t, line));
26459
+ return;
26460
+ }
26461
+ if (flags.includes(t.value)) {
26462
+ this.error(`Flag '${t.value}' is given twice`, this.locOf(t, line));
26463
+ return;
26464
+ }
26465
+ flags.push(t.value);
26466
+ }
26467
+ if (entity.fields.some((f) => f.name === name.value)) {
26468
+ this.error(`Duplicate field '${name.value}' in entity '${entity.name}'`, this.locOf(name, line));
26469
+ return;
26470
+ }
26471
+ entity.fields.push({ name: name.value, type: type.value, flags });
26472
+ }
26473
+ /** `decision "Title" because "Reason"`, or `decision "Title" {` with because / rejected lines. */
26474
+ parseDecision(tokens, line) {
26475
+ const title = tokens[1];
26476
+ const decision = { title: title.value, rejected: [], loc: this.locOf(title, line) };
26477
+ const third = tokens[2];
26478
+ if (third.kind === "lbrace") {
26479
+ this.expectEnd(tokens, 3, line);
26480
+ if (this.openSection("decision", tokens, line, { decision })) this.decisions.push(decision);
26481
+ return;
26482
+ }
26483
+ if (!isWord(third, "because")) {
26484
+ this.error(`Expected because "reason" or { after the decision title, not ${describe(third)}`, this.locOf(third, line));
26485
+ this.skipSection("decision", tokens, line);
26486
+ return;
26487
+ }
26488
+ if (this.stack.length > 0) {
26489
+ this.error("decision is only allowed at the top level", this.locOf(tokens[0], line));
26490
+ return;
26491
+ }
26492
+ const reason = tokens[3];
26493
+ if (reason?.kind !== "string") {
26494
+ this.error("Expected the reason in quotes after because", this.locOf(reason ?? third, line));
26495
+ return;
26496
+ }
26497
+ if (!this.expectEnd(tokens, 4, line)) return;
26498
+ decision.because = reason.value;
26499
+ this.decisions.push(decision);
26500
+ }
26501
+ parseDecisionLine(decision, tokens, line) {
26502
+ const [word] = tokens;
26503
+ if (isWord(word, "because")) {
26504
+ if (tokens[1]?.kind !== "string") {
26505
+ this.error("Expected the reason in quotes after because", this.locOf(tokens[1] ?? word, line));
26506
+ return;
26507
+ }
26508
+ if (decision.because !== void 0) {
26509
+ this.error('A decision has one because; list the other options with rejected "option" "reason"', this.locOf(word, line));
26510
+ return;
26511
+ }
26512
+ if (this.expectEnd(tokens, 2, line)) decision.because = tokens[1].value;
26513
+ return;
26514
+ }
26515
+ if (isWord(word, "rejected")) {
26516
+ const [, option, reason] = tokens;
26517
+ if (option?.kind !== "string" || reason?.kind !== "string") {
26518
+ const bad = option?.kind !== "string" ? option : reason;
26519
+ this.error('Expected rejected "option" "reason"', this.locOf(bad ?? tokens[tokens.length - 1], line));
26520
+ return;
26521
+ }
26522
+ if (this.expectEnd(tokens, 3, line)) decision.rejected.push({ option: option.value, reason: reason.value });
26523
+ return;
26524
+ }
26525
+ this.error('Expected because "reason" or rejected "option" "reason"', this.locOf(word, line));
26526
+ }
26527
+ /** `test "Name" {` */
26528
+ parseTest(tokens, line) {
26529
+ const name = tokens[1];
26530
+ const loc = this.locOf(name, line);
26531
+ if (tokens[2].kind !== "lbrace") {
26532
+ this.error(`Expected { after the test name, not ${describe(tokens[2])}`, this.locOf(tokens[2], line));
26533
+ this.skipSection("test", tokens, line);
26534
+ return;
26535
+ }
26536
+ this.expectEnd(tokens, 3, line);
26537
+ const first = this.tests.find((t) => t.name === name.value);
26538
+ if (first) {
26539
+ this.error(`Duplicate test '${name.value}' (first ${this.where(first.loc, loc)})`, loc);
26540
+ this.skipSection("test", tokens, line);
26541
+ return;
26542
+ }
26543
+ const test = { name: name.value, assertions: [], loc };
26544
+ if (this.openSection("test", tokens, line, { test })) this.tests.push(test);
26545
+ }
26546
+ /** One assertion line of a test (docs/design/hld-and-practice.md §1.9). */
26547
+ parseAssertion(tokens, line) {
26548
+ const loc = this.lineLoc(tokens, line);
26549
+ const [first] = tokens;
26550
+ const selectors = [];
26551
+ const selector = (i2) => {
26552
+ const s = this.parseSelector(tokens, i2, line);
26553
+ if (s) selectors.push(s);
26554
+ return s;
26555
+ };
26556
+ const done = (assertion3, end) => {
26557
+ if (!this.expectEnd(tokens, end, line)) return void 0;
26558
+ selectors.forEach((s) => this.checkSelectorLater(s));
26559
+ return assertion3;
26560
+ };
26561
+ const expectWords = (i2, words, example) => {
26562
+ for (const [k, w] of words.entries()) {
26563
+ if (!isWord(tokens[i2 + k], w)) {
26564
+ this.error(`Expected '${w}' here, e.g. ${example}`, this.locOf(tokens[i2 + k] ?? tokens[tokens.length - 1], line));
26565
+ return false;
26566
+ }
26567
+ }
26568
+ return true;
26569
+ };
26570
+ if (isWord(first, "no") && isWord(tokens[1], "path")) {
26571
+ const example = "no path from client to any database";
26572
+ if (!expectWords(2, ["from"], example)) return;
26573
+ const from = selector(3);
26574
+ if (!from || !expectWords(from.next, ["to"], example)) return;
26575
+ const to = selector(from.next + 1);
26576
+ if (!to) return;
26577
+ return done({ kind: "noPath", from: from.selector, to: to.selector, loc }, to.next);
26578
+ }
26579
+ if (first.kind !== "string") {
26580
+ if (first.kind !== "ident" && first.kind !== "tech") {
26581
+ this.error(ASSERTION_HELP, this.locOf(first, line));
26582
+ return;
26583
+ }
26584
+ const target = selector(0);
26585
+ if (!target) return;
26586
+ if (!isWord(tokens[target.next], "has")) {
26587
+ this.error(ASSERTION_HELP, this.locOf(tokens[target.next] ?? first, line));
26588
+ return;
26589
+ }
26590
+ const i2 = target.next + 1;
26591
+ const example = "api has replicas >= 2";
26592
+ if (!expectWords(i2, ["replicas"], example) || !this.expectOp(tokens, i2 + 1, [">="], example, line)) return;
26593
+ const n = tokens[i2 + 2];
26594
+ if (n?.kind !== "number" || Number(n.value) < 1) {
26595
+ this.error("Expected a whole number of replicas, at least 1", this.locOf(n ?? tokens[i2 + 1], line));
26596
+ return;
26597
+ }
26598
+ return done({ kind: "replicas", target: target.selector, min: Number(n.value), loc }, i2 + 3);
26599
+ }
26600
+ const useCase = first.value;
26601
+ const useCaseLoc = this.locOf(first, line);
26602
+ let i = 1;
26603
+ let scenario;
26604
+ if (isWord(tokens[i], "scenario")) {
26605
+ const s = tokens[i + 1];
26606
+ if (s?.kind !== "string") {
26607
+ this.error("Expected a scenario name in quotes after scenario", this.locOf(s ?? tokens[i], line));
26608
+ return;
26609
+ }
26610
+ scenario = { name: s.value, loc: this.locOf(s, line) };
26611
+ i += 2;
26612
+ }
26613
+ const withScenario = scenario ? { scenario: scenario.name } : {};
26614
+ const verb = tokens[i];
26615
+ const wholeUseCase = (what) => {
26616
+ if (!scenario) return true;
26617
+ this.error(`${what} is about the whole use case; leave out scenario "\u2026"`, scenario.loc);
26618
+ return false;
26619
+ };
26620
+ let assertion2;
26621
+ if (isWord(verb, "calls")) {
26622
+ const target = selector(i + 1);
26623
+ if (!target) return;
26624
+ if (isWord(tokens[target.next], "before")) {
26625
+ const then = selector(target.next + 1);
26626
+ if (!then) return;
26627
+ assertion2 = done({ kind: "before", useCase, ...withScenario, first: target.selector, then: then.selector, loc }, then.next);
26628
+ } else {
26629
+ assertion2 = done({ kind: "calls", useCase, ...withScenario, target: target.selector, quantifier: "some", loc }, target.next);
26630
+ }
26631
+ } else if (isWord(verb, "every")) {
26632
+ if (!expectWords(i + 1, ["scenario", "calls"], `"${useCase}" every scenario calls any cache`)) return;
26633
+ const target = selector(i + 3);
26634
+ if (!target) return;
26635
+ assertion2 = done({ kind: "calls", useCase, ...withScenario, target: target.selector, quantifier: "every", loc }, target.next);
26636
+ } else if (isWord(verb, "never")) {
26637
+ if (!expectWords(i + 1, ["calls"], `"${useCase}" never calls any database`)) return;
26638
+ const target = selector(i + 2);
26639
+ if (!target) return;
26640
+ assertion2 = done({ kind: "calls", useCase, ...withScenario, target: target.selector, quantifier: "never", loc }, target.next);
26641
+ } else if (isWord(verb, "writes")) {
26642
+ const target = selector(i + 1);
26643
+ if (!target || !expectWords(target.next, ["before", "responding"], `"${useCase}" writes db before responding`)) return;
26644
+ assertion2 = done({ kind: "writesBeforeResponding", useCase, ...withScenario, target: target.selector, loc }, target.next + 2);
26645
+ } else if (isWord(verb, "responds")) {
26646
+ const status = tokens[i + 1];
26647
+ if (!status || status.kind !== "number" && status.kind !== "quantity" || !/^[1-5](\d\d|xx)$/.test(status.value)) {
26648
+ this.error("Expected a status code or class after responds, e.g. 201 or 4xx", this.locOf(status ?? verb, line));
26649
+ return;
26650
+ }
26651
+ assertion2 = done({ kind: "responds", useCase, ...withScenario, status: status.value, loc }, i + 2);
26652
+ } else if (isWord(verb, "has")) {
26653
+ if (!wholeUseCase("has scenario") || !expectWords(i + 1, ["scenario"], `"${useCase}" has scenario "Not found"`)) return;
26654
+ const name = tokens[i + 2];
26655
+ if (name?.kind !== "string") {
26656
+ this.error("Expected a scenario name in quotes after has scenario", this.locOf(name ?? tokens[i + 1], line));
26657
+ return;
26658
+ }
26659
+ assertion2 = done({ kind: "hasScenario", useCase, scenario: name.value, loc }, i + 3);
26660
+ } else if (isWord(verb, "handles")) {
26661
+ if (!wholeUseCase("handles failure") || !expectWords(i + 1, ["failure", "of"], `"${useCase}" handles failure of cache`)) return;
26662
+ const target = selector(i + 3);
26663
+ if (!target) return;
26664
+ assertion2 = done({ kind: "handlesFailure", useCase, target: target.selector, loc }, target.next);
26665
+ } else {
26666
+ this.error(`Expected ${ASSERTION_VERBS} after "${useCase}"`, this.locOf(verb ?? first, line));
26667
+ return;
26668
+ }
26669
+ if (assertion2) {
26670
+ this.deferred.push(() => {
26671
+ const found = this.checkUseCase(useCase, useCaseLoc);
26672
+ if (found && scenario) this.checkScenario(found, scenario.name, scenario.loc);
26673
+ });
26674
+ }
26675
+ return assertion2;
26676
+ }
26677
+ /** A node id, `[Tech]` or `any <kind>` starting at `tokens[i]`. */
26678
+ parseSelector(tokens, i, line) {
26679
+ const t = tokens[i];
26680
+ if (t?.kind === "tech") {
26681
+ const tech = techByName.get(t.value.toLowerCase())?.techStack ?? t.value;
26682
+ return { selector: { tech }, next: i + 1, token: t, loc: this.locOf(t, line) };
26683
+ }
26684
+ if (isWord(t, "any")) {
26685
+ const kind = tokens[i + 1];
26686
+ if (kind?.kind !== "ident" || !isKind(kind.value)) {
26687
+ this.error(`Expected a kind after any: ${KINDS.join(", ")}`, this.locOf(kind ?? t, line));
26688
+ return void 0;
26689
+ }
26690
+ return { selector: { kind: kind.value }, next: i + 2, token: t, loc: this.locOf(t, line) };
26691
+ }
26692
+ if (t?.kind === "ident") return { selector: { node: t.value }, next: i + 1, token: t, loc: this.locOf(t, line) };
26693
+ this.error(`Expected a node id, [Tech] or any <kind>${t ? `, not ${describe(t)}` : ""}`, this.locOf(t ?? tokens[tokens.length - 1], line));
26694
+ return void 0;
26695
+ }
26696
+ /** Warns about a selector naming a tech stack that does not exist, and (once every file is read) a node that does not. */
26697
+ checkSelectorLater({ selector, token, loc }) {
26698
+ if ("tech" in selector && !techByName.has(token.value.toLowerCase())) this.warning(`Unknown tech stack '${token.value}'`, loc);
26699
+ if ("node" in selector) {
26700
+ this.deferred.push(() => {
26701
+ if (!this.nodes.has(selector.node)) this.warning(`Unknown node '${selector.node}'`, loc);
26702
+ });
26703
+ }
26704
+ }
26705
+ checkUseCase(name, loc) {
26706
+ const useCase = this.built.find((u) => u.name === name);
26707
+ if (!useCase) this.warning(`Unknown use case '${name}'`, loc);
26708
+ return useCase;
26709
+ }
26710
+ checkScenario(useCase, name, loc) {
26711
+ if (useCase.scenarios.some((s) => s.name === name)) return;
26712
+ const names = useCase.scenarios.map((s) => `'${s.name}'`).join(", ");
26713
+ this.warning(`Use case '${useCase.name}' has no scenario '${name}'; its scenarios are ${names}`, loc);
26714
+ }
26715
+ /** The value of a quantity token in `unit`, or undefined after reporting what is wrong. `after` locates a missing token. */
26716
+ quantityOf(token, unit, after, line) {
26717
+ const expected = UNIT_EXAMPLES[unit];
26718
+ if (token?.kind !== "quantity" && token?.kind !== "number") {
26719
+ this.error(`Expected ${expected}${token ? `, not ${describe(token)}` : ""}`, this.locOf(token ?? after, line));
26720
+ return void 0;
26721
+ }
26722
+ const loc = this.locOf(token, line);
26723
+ const written = this.lines[line - 1].slice(token.col - 1, token.col - 1 + token.length);
26724
+ const q = parseQuantity(written);
26725
+ if ("error" in q) {
26726
+ this.error(q.error, loc);
26727
+ return void 0;
26728
+ }
26729
+ if (q.unit !== unit) {
26730
+ this.error(q.unit ? `Expected ${expected}, not '${written}'` : `'${written}' needs a unit: expected ${expected}`, loc);
26731
+ return void 0;
26732
+ }
26733
+ return q.value;
26734
+ }
26735
+ expectOp(tokens, i, ops, example, line) {
26736
+ const t = tokens[i];
26737
+ if (t?.kind === "op" && ops.includes(t.value)) return true;
26738
+ this.error(`Expected ${ops[0]} here, e.g. ${example}`, this.locOf(t ?? tokens[tokens.length - 1], line));
26739
+ return false;
26740
+ }
25982
26741
  buildUseCase(useCase, body) {
25983
26742
  let paths = expand(body.items);
25984
26743
  if (paths.length > MAX_SCENARIOS) {
@@ -26021,22 +26780,22 @@ var Parser = class {
26021
26780
  for (const step of raw) {
26022
26781
  this.checkConnection(step);
26023
26782
  if (step.arrow === "-->") {
26024
- const request = [...steps].reverse().find((s) => s.fromServiceId === step.to && s.toServiceId === step.from && !answered.has(s) && !s.failed);
26025
- if (!request) {
26783
+ const request2 = [...steps].reverse().find((s) => s.fromServiceId === step.to && s.toServiceId === step.from && !answered.has(s) && !s.failed);
26784
+ if (!request2) {
26026
26785
  this.warning(`Response has no matching request from '${step.to}' to '${step.from}'`, step.loc);
26027
26786
  continue;
26028
26787
  }
26029
- answered.add(request);
26030
- request.responseLoc = step.loc;
26788
+ answered.add(request2);
26789
+ request2.responseLoc = step.loc;
26031
26790
  const status = step.label.match(/^(\d{3})\b\s*([\s\S]*)$/);
26032
- if (status) request.statusCode = Number(status[1]);
26791
+ if (status) request2.statusCode = Number(status[1]);
26033
26792
  const payload = splitPayload(status ? status[2] : step.label);
26034
26793
  const body = payload.body ?? payload.text;
26035
26794
  if (body) {
26036
- request.responseBody = body;
26037
- request.responseFormat = payload.body ? payload.format : "FREE_TEXT";
26795
+ request2.responseBody = body;
26796
+ request2.responseFormat = payload.body ? payload.format : "FREE_TEXT";
26038
26797
  }
26039
- if (request.executionType === "ASYNC_FIRE_AND_FORGET") request.executionType = "ASYNC_REQUEST_RESPONSE";
26798
+ if (request2.executionType === "ASYNC_FIRE_AND_FORGET") request2.executionType = "ASYNC_REQUEST_RESPONSE";
26040
26799
  continue;
26041
26800
  }
26042
26801
  const label = parseStepLabel(step.label);
@@ -26083,13 +26842,15 @@ var Parser = class {
26083
26842
  addNode(node) {
26084
26843
  const existing = this.nodes.get(node.id);
26085
26844
  if (existing) {
26086
- const first = existing.loc.file;
26087
- const where = first === node.loc.file ? `on line ${existing.loc.line}` : `in ${fileName(first ?? this.options.path ?? "the root document")} on line ${existing.loc.line}`;
26088
- this.error(`Duplicate id '${node.id}' (first declared ${where})`, node.loc);
26845
+ this.error(`Duplicate id '${node.id}' (first declared ${this.where(existing.loc, node.loc)})`, node.loc);
26089
26846
  return;
26090
26847
  }
26091
26848
  this.nodes.set(node.id, node);
26092
26849
  }
26850
+ /** `on line 3`, or `in infra.proschi on line 3` when `first` is in another file than `later`. */
26851
+ where(first, later) {
26852
+ return first.file === later.file ? `on line ${first.line}` : `in ${fileName(first.file ?? this.options.path ?? "the root document")} on line ${first.line}`;
26853
+ }
26093
26854
  resolveTech(token, line) {
26094
26855
  const resolved = techByName.get(token.value.toLowerCase());
26095
26856
  if (resolved) return resolved;
@@ -26127,8 +26888,17 @@ var Parser = class {
26127
26888
  inUseCase() {
26128
26889
  return this.currentUseCase() !== void 0;
26129
26890
  }
26891
+ /** Reports the first token from `from` on; true when there is none. */
26130
26892
  expectEnd(tokens, from, line) {
26131
- if (tokens.length > from) this.error(`Unexpected ${describe(tokens[from])}`, this.locOf(tokens[from], line));
26893
+ if (tokens.length <= from) return true;
26894
+ this.error(`Unexpected ${describe(tokens[from])}`, this.locOf(tokens[from], line));
26895
+ return false;
26896
+ }
26897
+ /** From the first token of a line to the end of its last one. */
26898
+ lineLoc(tokens, line) {
26899
+ const first = tokens[0];
26900
+ const last = tokens[tokens.length - 1];
26901
+ return { line, col: first.col, length: last.col + last.length - first.col, ...this.fileField() };
26132
26902
  }
26133
26903
  locOf(token, line) {
26134
26904
  return token ? { line, col: token.col, length: token.length, ...this.fileField() } : { line, col: 1, length: 1, ...this.fileField() };
@@ -26170,6 +26940,15 @@ function expand(items) {
26170
26940
  }
26171
26941
  return paths;
26172
26942
  }
26943
+ function isWord(token, word) {
26944
+ return token?.kind === "ident" && token.value === word;
26945
+ }
26946
+ function sectionName(frame) {
26947
+ if (frame.entity) return `entity '${frame.entity.name}'`;
26948
+ if (frame.decision) return `decision '${frame.decision.title}'`;
26949
+ if (frame.test) return `test '${frame.test.name}'`;
26950
+ return `${frame.section} block`;
26951
+ }
26173
26952
  function fileName(path) {
26174
26953
  return path.split(/[\\/]/).pop() || path;
26175
26954
  }
@@ -26279,8 +27058,9 @@ function format(source2) {
26279
27058
  };
26280
27059
  tidyBlankLines(entries).forEach((entry) => {
26281
27060
  const last = run[run.length - 1];
26282
- if (last && (entry.kind !== last.kind || entry.depth !== last.depth || last.kind === "connection" && last.continuation.length)) flush();
26283
- if (entry.kind === "node" || entry.kind === "connection") run.push(entry);
27061
+ if (last && (entry.kind !== last.kind || entry.depth !== last.depth || last.kind === "connection" && last.continuation.length || last.kind === "cells" && entry.kind === "cells" && entry.section !== last.section))
27062
+ flush();
27063
+ if (entry.kind === "node" || entry.kind === "connection" || entry.kind === "cells") run.push(entry);
26284
27064
  else {
26285
27065
  flush();
26286
27066
  out.push(entry.kind === "blank" ? "" : indent(entry.depth) + entry.text);
@@ -26294,6 +27074,7 @@ var KEYWORDS = /* @__PURE__ */ new Set(["title", "import", "group", "usecase", "
26294
27074
  function classify(lines) {
26295
27075
  const entries = [];
26296
27076
  let depth = 0;
27077
+ let section;
26297
27078
  for (let i = 0; i < lines.length; i++) {
26298
27079
  const raw = lines[i];
26299
27080
  const original = raw.trim();
@@ -26309,7 +27090,13 @@ function classify(lines) {
26309
27090
  const comment = commentOf(raw, tokens);
26310
27091
  const base = { depth, original, tokens, comment };
26311
27092
  const [first, second, third, fourth] = tokens;
26312
- if (first.kind === "ident" && second?.kind === "arrow" && third?.kind === "ident" && tokens.length <= 4 && (!fourth || fourth.kind === "label")) {
27093
+ const inSection = section !== void 0 && depth === 1 && first.kind !== "rbrace";
27094
+ const cells = inSection ? sectionCells(section, raw, tokens) : void 0;
27095
+ if (cells) {
27096
+ entries.push({ ...base, kind: "cells", section, cells });
27097
+ continue;
27098
+ }
27099
+ if (!inSection && first.kind === "ident" && second?.kind === "arrow" && third?.kind === "ident" && tokens.length <= 4 && (!fourth || fourth.kind === "label")) {
26313
27100
  const entry = { ...base, kind: "connection", from: first.value, arrow: second.value, to: third.value, label: fourth?.value, continuation: [] };
26314
27101
  if (fourth && bracketDepth(fourth.value) > 0) {
26315
27102
  const delta = depth * INDENT.length - width(leadingSpace(raw));
@@ -26324,11 +27111,12 @@ function classify(lines) {
26324
27111
  entries.push(entry);
26325
27112
  continue;
26326
27113
  }
26327
- if (first.kind === "ident" && !KEYWORDS.has(first.value) && tokens.slice(1).every((t) => ["string", "tech", "team", "ident", "number", "comma"].includes(t.kind))) {
27114
+ const oneLineDecision = first.value === "decision" && second?.kind === "string" && third?.kind === "ident" && third.value === "because";
27115
+ if (!inSection && first.kind === "ident" && !KEYWORDS.has(first.value) && !oneLineDecision && tokens.slice(1).every((t) => ["string", "tech", "team", "ident", "number", "comma"].includes(t.kind))) {
26328
27116
  let k = 1;
26329
27117
  const take = (kind) => tokens[k]?.kind === kind ? source(raw, tokens[k++]) : "";
26330
- const cells = [first.value, take("string"), take("tech"), take("team"), join2(raw, tokens.slice(k))];
26331
- entries.push({ ...base, kind: "node", cells });
27118
+ const cells2 = [first.value, take("string"), take("tech"), take("team"), join2(raw, tokens.slice(k))];
27119
+ entries.push({ ...base, kind: "node", cells: cells2 });
26332
27120
  continue;
26333
27121
  }
26334
27122
  const closes = first.kind === "rbrace";
@@ -26336,10 +27124,44 @@ function classify(lines) {
26336
27124
  const code = join2(raw, tokens);
26337
27125
  const text = withComment(code, comment);
26338
27126
  entries.push({ kind: "line", depth: Math.max(0, depth - (closes ? 1 : 0)), text: sameTokens(text, tokens) ? text : original, opens, closes });
27127
+ if (depth === 0) section = sectionOpenedBy(tokens);
26339
27128
  for (const t of tokens) depth = Math.max(0, depth + (t.kind === "lbrace" ? 1 : t.kind === "rbrace" ? -1 : 0));
27129
+ if (depth === 0) section = void 0;
26340
27130
  }
26341
27131
  return entries;
26342
27132
  }
27133
+ function sectionOpenedBy(tokens) {
27134
+ const [first, second] = tokens;
27135
+ if (first.kind !== "ident" || tokens[tokens.length - 1].kind !== "lbrace") return void 0;
27136
+ switch (first.value) {
27137
+ case "traffic":
27138
+ case "requirements":
27139
+ case "capacity":
27140
+ return second?.kind === "lbrace" ? first.value : void 0;
27141
+ case "entity":
27142
+ return second?.kind === "ident" ? "entity" : void 0;
27143
+ case "decision":
27144
+ case "test":
27145
+ return second?.kind === "string" ? first.value : void 0;
27146
+ default:
27147
+ return void 0;
27148
+ }
27149
+ }
27150
+ function sectionCells(section, raw, tokens) {
27151
+ const [first, second] = tokens;
27152
+ if (tokens.some((t) => t.kind === "lbrace" || t.kind === "rbrace" || t.kind === "arrow" || t.kind === "label")) return void 0;
27153
+ const rest = (from) => tokens.length > from ? [join2(raw, tokens.slice(from))] : [];
27154
+ switch (section) {
27155
+ case "traffic":
27156
+ return first.kind === "string" && (second?.kind === "quantity" || second?.kind === "number") ? [source(raw, first), source(raw, second), ...rest(2)] : void 0;
27157
+ case "capacity":
27158
+ return first.kind === "ident" && tokens.length > 1 ? [first.value, ...rest(1)] : void 0;
27159
+ case "entity":
27160
+ return first.kind === "ident" && second?.kind === "ident" ? [first.value, second.value, ...rest(2)] : void 0;
27161
+ default:
27162
+ return void 0;
27163
+ }
27164
+ }
26343
27165
  function tidyBlankLines(entries) {
26344
27166
  const kept = [];
26345
27167
  for (const entry of entries) {
@@ -26356,7 +27178,7 @@ function tidyBlankLines(entries) {
26356
27178
  return kept;
26357
27179
  }
26358
27180
  function renderRun(run) {
26359
- const codes = run[0].kind === "node" ? alignNodes(run) : alignConnections(run);
27181
+ const codes = run[0].kind === "node" ? alignNodes(run) : run[0].kind === "cells" ? alignCells(run) : alignConnections(run);
26360
27182
  const items = run;
26361
27183
  const commented = items.filter((e) => e.comment);
26362
27184
  const commentCol = commented.length > 1 ? Math.max(...items.map((e, i) => e.comment ? len(codes[i]) : 0)) + 1 : 0;
@@ -26375,6 +27197,11 @@ function alignNodes(run) {
26375
27197
  return (line + cells[4]).trimEnd();
26376
27198
  });
26377
27199
  }
27200
+ function alignCells(run) {
27201
+ const widths = [];
27202
+ for (const { cells } of run) cells.slice(0, -1).forEach((c, i) => widths[i] = Math.max(widths[i] ?? 0, len(c)));
27203
+ return run.map(({ cells }) => cells.map((c, i) => i < cells.length - 1 ? pad(c, widths[i] + 1) : c).join(""));
27204
+ }
26378
27205
  function alignConnections(run) {
26379
27206
  const head = Math.max(...run.map((e) => len(e.from) + 1 + e.arrow.length));
26380
27207
  const labelled = run.filter((e) => e.label !== void 0);
@@ -26397,7 +27224,7 @@ function join2(raw, tokens) {
26397
27224
  let out = "";
26398
27225
  for (const [i, t] of tokens.entries()) {
26399
27226
  const text = t.kind === "label" ? ":" + (t.value ? " " + t.value : "") : source(raw, t);
26400
- const tight = t.kind === "comma" || tokens[i - 1]?.kind === "comma";
27227
+ const tight = t.kind === "comma" || tokens[i - 1]?.kind === "comma" && t.kind === "number";
26401
27228
  out += (i === 0 || tight ? "" : " ") + text;
26402
27229
  }
26403
27230
  return out;
@@ -26411,7 +27238,10 @@ function reindent(line, delta) {
26411
27238
  const body = line.slice(lead.length).trimEnd();
26412
27239
  return body ? " ".repeat(Math.max(0, width(lead) + delta)) + body : "";
26413
27240
  }
26414
- var source = (raw, t) => raw.slice(t.col - 1, t.col - 1 + t.length);
27241
+ var source = (raw, t) => {
27242
+ const text = raw.slice(t.col - 1, t.col - 1 + t.length);
27243
+ return t.kind === "quantity" ? text.replace(/[ \t]+/g, " ") : text;
27244
+ };
26415
27245
  var withComment = (code, comment) => comment ? code + " " + comment : code;
26416
27246
  var leadingSpace = (line) => /^[ \t]*/.exec(line)[0];
26417
27247
  var width = (space) => [...space].reduce((n, ch) => n + (ch === " " ? INDENT.length : 1), 0);
@@ -26419,6 +27249,92 @@ var indent = (depth) => INDENT.repeat(depth);
26419
27249
  var len = (s) => [...s].length;
26420
27250
  var pad = (s, to) => s + " ".repeat(Math.max(0, to - len(s)));
26421
27251
 
27252
+ // ../frontend/src/dsl/sequence.ts
27253
+ var PAYLOAD_LIMIT = 40;
27254
+ function shorten(text, max = PAYLOAD_LIMIT) {
27255
+ const flat = text.replace(/\s+/g, " ").trim();
27256
+ return flat.length > max ? `${flat.slice(0, max - 1).trimEnd()}\u2026` : flat;
27257
+ }
27258
+ function requestLabel(step) {
27259
+ const name = !step.httpMethod && step.stepName === `${step.fromServiceId} \u2192 ${step.toServiceId}` ? "" : step.stepName;
27260
+ return [name, step.description, step.requestBody && shorten(step.requestBody)].filter(Boolean).join(" ");
27261
+ }
27262
+ function responseLabel(step) {
27263
+ return [step.statusCode, step.responseBody && shorten(step.responseBody)].filter((p) => p !== void 0 && p !== "").join(" ");
27264
+ }
27265
+ function hasResponse(step) {
27266
+ return !step.failed && (!!step.responseLoc || step.statusCode !== void 0 || !!step.responseBody);
27267
+ }
27268
+ function request(step) {
27269
+ return {
27270
+ kind: "request",
27271
+ from: step.fromServiceId,
27272
+ to: step.toServiceId,
27273
+ number: step.stepOrder + 1,
27274
+ label: requestLabel(step),
27275
+ async: step.executionType !== "SYNC_REQUEST_RESPONSE",
27276
+ failed: !!step.failed,
27277
+ error: !!step.failed,
27278
+ step
27279
+ };
27280
+ }
27281
+ function response(step) {
27282
+ return {
27283
+ kind: "response",
27284
+ from: step.toServiceId,
27285
+ to: step.fromServiceId,
27286
+ number: step.stepOrder + 1,
27287
+ label: responseLabel(step),
27288
+ async: false,
27289
+ failed: false,
27290
+ error: (step.statusCode ?? 0) >= 400,
27291
+ status: step.statusCode,
27292
+ step
27293
+ };
27294
+ }
27295
+ function buildSequence(useCase, scenario, nodes = []) {
27296
+ const items = [];
27297
+ const stack = [];
27298
+ const emit = (message) => items.push({ kind: "message", message });
27299
+ const unwindFor = (from) => {
27300
+ const depth = stack.map((s) => s.toServiceId).lastIndexOf(from);
27301
+ while (stack.length > depth + 1) emit(response(stack.pop()));
27302
+ };
27303
+ const steps = scenario.steps;
27304
+ for (let i = 0; i < steps.length; i++) {
27305
+ const step = steps[i];
27306
+ if (step.parallelGroup === void 0) {
27307
+ unwindFor(step.fromServiceId);
27308
+ emit(request(step));
27309
+ if (hasResponse(step)) stack.push(step);
27310
+ continue;
27311
+ }
27312
+ const group = [];
27313
+ while (i < steps.length && steps[i].parallelGroup === step.parallelGroup) group.push(steps[i++]);
27314
+ i--;
27315
+ unwindFor(step.fromServiceId);
27316
+ items.push({ kind: "par", branches: group.map((s) => hasResponse(s) ? [request(s), response(s)] : [request(s)]) });
27317
+ }
27318
+ while (stack.length) emit(response(stack.pop()));
27319
+ const byId = new Map(nodes.map((n) => [n.id, n]));
27320
+ const participants = [];
27321
+ const seen = /* @__PURE__ */ new Set();
27322
+ const add = (id) => {
27323
+ if (seen.has(id)) return;
27324
+ seen.add(id);
27325
+ const node = byId.get(id);
27326
+ participants.push({ id, name: node?.name ?? id, tech: node && !node.implicit ? node.techStack : void 0 });
27327
+ };
27328
+ for (const step of steps) {
27329
+ add(step.fromServiceId);
27330
+ add(step.toServiceId);
27331
+ }
27332
+ return { useCase, scenario, participants, items, condition: scenario.condition || void 0 };
27333
+ }
27334
+ function sequenceMessages(items) {
27335
+ return items.flatMap((item) => item.kind === "message" ? [item.message] : item.branches.flat());
27336
+ }
27337
+
26422
27338
  // ../frontend/src/dsl/mermaid.ts
26423
27339
  var RESERVED = new Set(
26424
27340
  [
@@ -26468,6 +27384,695 @@ var RESERVED = new Set(
26468
27384
  ].map((w) => w.toLowerCase())
26469
27385
  );
26470
27386
 
27387
+ // ../frontend/src/sim/flow.ts
27388
+ function findUseCase(diagram, name) {
27389
+ const lower = name.toLowerCase();
27390
+ return diagram.useCases.find((u) => u.name === name) ?? diagram.useCases.find((u) => u.name.toLowerCase() === lower) ?? diagram.useCases.find((u) => u.id === name);
27391
+ }
27392
+ function findScenario2(useCase, name) {
27393
+ const norm = (s) => s.replace(/\s*(›|>)\s*/g, " \u203A ").trim().toLowerCase();
27394
+ const wanted = norm(name);
27395
+ return useCase.scenarios.find((s) => s.name === name) ?? useCase.scenarios.find((s) => norm(s.name) === wanted || s.id === name);
27396
+ }
27397
+ function afterEntryResponse(entry, step) {
27398
+ const r = entry.responseLoc;
27399
+ if (!r || step === entry || step.loc.file !== r.file) return false;
27400
+ return step.loc.line > r.line || step.loc.line === r.line && step.loc.col > r.col;
27401
+ }
27402
+ function criticalPath(useCase, scenario) {
27403
+ const entry = scenario.steps[0];
27404
+ if (!entry) return [];
27405
+ const { items } = buildSequence(useCase, scenario);
27406
+ const working = /* @__PURE__ */ new Map([[entry.fromServiceId, 1]]);
27407
+ const opened = /* @__PURE__ */ new Set();
27408
+ let answered = false;
27409
+ const parts = [];
27410
+ const visit = (m, hops) => {
27411
+ if (m.kind === "response") {
27412
+ if (opened.delete(m.step)) working.set(m.step.toServiceId, (working.get(m.step.toServiceId) ?? 1) - 1);
27413
+ if (m.step === entry) answered = true;
27414
+ return;
27415
+ }
27416
+ if (afterEntryResponse(entry, m.step)) answered = true;
27417
+ if (answered || !working.get(m.from)) return;
27418
+ hops.push({ step: m.step, target: m.to, failed: m.failed, async: m.async });
27419
+ if (!m.async && !m.failed) {
27420
+ opened.add(m.step);
27421
+ working.set(m.to, (working.get(m.to) ?? 0) + 1);
27422
+ }
27423
+ };
27424
+ for (const item of items) {
27425
+ const hops = [];
27426
+ if (item.kind === "message") visit(item.message, hops);
27427
+ else for (const branch of item.branches) for (const m of branch) visit(m, hops);
27428
+ if (hops.length) parts.push(hops);
27429
+ }
27430
+ return parts;
27431
+ }
27432
+ function pathNodes(useCase, scenario) {
27433
+ const entry = scenario.steps[0];
27434
+ if (!entry) return [];
27435
+ const ids = [entry.fromServiceId, ...criticalPath(useCase, scenario).flatMap((part) => part.filter((h) => !h.failed).map((h) => h.target))];
27436
+ return [...new Set(ids)];
27437
+ }
27438
+ function requestOrder(useCase, scenario) {
27439
+ const entry = scenario.steps[0];
27440
+ const messages = sequenceMessages(buildSequence(useCase, scenario).items);
27441
+ const requests = [];
27442
+ let beforeResponse = -1;
27443
+ for (const m of messages) {
27444
+ if (m.kind === "request") {
27445
+ if (beforeResponse < 0 && entry && afterEntryResponse(entry, m.step)) beforeResponse = requests.length;
27446
+ requests.push(m);
27447
+ } else if (m.step === entry && beforeResponse < 0) beforeResponse = requests.length;
27448
+ }
27449
+ return { requests, beforeResponse: beforeResponse < 0 ? requests.length : beforeResponse };
27450
+ }
27451
+ function writesBeforeResponse(useCase, scenario, accept) {
27452
+ const { requests, beforeResponse } = requestOrder(useCase, scenario);
27453
+ return requests.slice(0, beforeResponse).some((m) => !m.async && !m.failed && accept(m.to));
27454
+ }
27455
+ function callsOk(scenario, nodeId) {
27456
+ return scenario.steps.some((s) => s.toServiceId === nodeId && !s.failed);
27457
+ }
27458
+ function needs(useCase, nodeId) {
27459
+ return useCase.scenarios.some((s) => s.outcome === "success" && callsOk(s, nodeId));
27460
+ }
27461
+ function fallbacksFor(useCase, nodeId) {
27462
+ return useCase.scenarios.filter(
27463
+ (s) => s.outcome === "success" && s.steps.some((step) => step.toServiceId === nodeId && step.failed) && !callsOk(s, nodeId)
27464
+ );
27465
+ }
27466
+ function selectorText(selector) {
27467
+ if ("node" in selector) return selector.node;
27468
+ if ("tech" in selector) return `[${selector.tech}]`;
27469
+ return `any ${selector.kind}`;
27470
+ }
27471
+ function selectNodes(diagram, selector) {
27472
+ return diagram.nodes.filter((n) => n.kind === "component" && matches(n, selector));
27473
+ }
27474
+ function matches(node, selector) {
27475
+ if (node.kind !== "component") return false;
27476
+ if ("node" in selector) return node.id === selector.node;
27477
+ if ("tech" in selector) return node.techStack.toLowerCase() === selector.tech.toLowerCase();
27478
+ return kindOf(node) === selector.kind;
27479
+ }
27480
+ function techOf(node) {
27481
+ return node && !node.implicit ? node.techStack : void 0;
27482
+ }
27483
+ function nodeLabel(diagram, id) {
27484
+ const tech = techOf(diagram.nodes.find((n) => n.id === id));
27485
+ return tech ? `${id} (${tech})` : id;
27486
+ }
27487
+
27488
+ // ../frontend/src/sim/format.ts
27489
+ var trim = (n, digits) => String(Number(n.toFixed(digits)));
27490
+ function formatRps(rps) {
27491
+ if (!Number.isFinite(rps)) return "\u221E rps";
27492
+ if (rps >= 1e6) return `${trim(rps / 1e6, 1)}m rps`;
27493
+ if (rps >= 1e3) return `${trim(rps / 1e3, 1)}k rps`;
27494
+ return `${trim(rps, rps < 10 ? 2 : 0)} rps`;
27495
+ }
27496
+ function formatMs(ms) {
27497
+ return `${trim(ms, ms < 100 ? 1 : 0)} ms`;
27498
+ }
27499
+ function formatAvailability(fraction) {
27500
+ if (fraction >= 1) return "100%";
27501
+ const down = (1 - fraction) * 100;
27502
+ const digits = Math.min(6, Math.max(1, Math.ceil(-Math.log10(down)) + 1));
27503
+ return `${trim(Math.floor(fraction * 100 * 10 ** digits + 1e-6) / 10 ** digits, digits)}%`;
27504
+ }
27505
+ function formatPercent(fraction) {
27506
+ return `${Math.round(fraction * 100)}%`;
27507
+ }
27508
+ function formatUsd(usd) {
27509
+ return `$${Math.round(usd).toLocaleString("en-US")}/month`;
27510
+ }
27511
+
27512
+ // ../frontend/src/sim/profiles.ts
27513
+ var NEUTRAL = { rps: Infinity, latencyMs: 0, availability: 1, costUsd: 0, durable: false };
27514
+ var KIND_PROFILES = {
27515
+ client: NEUTRAL,
27516
+ edge: { rps: 1e5, latencyMs: 2, availability: 0.9999, costUsd: 50, durable: false },
27517
+ service: { rps: 2e3, latencyMs: 10, availability: 0.995, costUsd: 100, durable: false },
27518
+ function: { rps: 1e4, latencyMs: 25, availability: 0.9995, costUsd: 200, durable: false },
27519
+ cache: { rps: 1e5, latencyMs: 1, availability: 0.999, costUsd: 150, durable: false },
27520
+ database: { rps: 5e3, latencyMs: 5, availability: 0.9995, costUsd: 400, durable: true },
27521
+ search: { rps: 3e3, latencyMs: 15, availability: 0.999, costUsd: 400, durable: true },
27522
+ analytics: { rps: 200, latencyMs: 500, availability: 0.999, costUsd: 300, durable: true },
27523
+ queue: { rps: 5e4, latencyMs: 5, availability: 0.9999, costUsd: 200, durable: true },
27524
+ storage: { rps: 5e3, latencyMs: 30, availability: 0.9999, costUsd: 50, durable: true },
27525
+ external: { rps: 1e3, latencyMs: 200, availability: 0.999, costUsd: 0, durable: false },
27526
+ other: NEUTRAL
27527
+ };
27528
+ var NOSQL = { rps: 2e4, latencyMs: 5, availability: 0.9999, costUsd: 500, durable: true };
27529
+ var TECH_PROFILES = {
27530
+ DynamoDB: NOSQL,
27531
+ "AWS DynamoDB": NOSQL,
27532
+ Cassandra: NOSQL,
27533
+ MongoDB: NOSQL,
27534
+ CouchDB: NOSQL,
27535
+ "Azure Cosmos DB": NOSQL,
27536
+ "GCP Bigtable": NOSQL,
27537
+ "GCP Firestore": NOSQL,
27538
+ "GCP Spanner": NOSQL
27539
+ };
27540
+ function profileOf(node, override) {
27541
+ const kind = kindOf(node);
27542
+ const base = TECH_PROFILES[node.techStack] ?? KIND_PROFILES[kind];
27543
+ return {
27544
+ kind,
27545
+ rps: override?.rps ?? base.rps,
27546
+ latencyMs: override?.latencyMs ?? base.latencyMs,
27547
+ availability: override?.availability !== void 0 ? override.availability / 100 : base.availability,
27548
+ costUsd: override?.costUsd ?? base.costUsd,
27549
+ durable: override?.durable ?? base.durable
27550
+ };
27551
+ }
27552
+
27553
+ // ../frontend/src/sim/analyze.ts
27554
+ var DEFAULT_TIMEOUT_MS = 1e3;
27555
+ var HOT = 0.7;
27556
+ var QUEUE_CAP = 0.95;
27557
+ var PERCENTILE_FACTORS = { 50: 1, 90: 1.6, 95: 2, 99: 3, 99.9: 5 };
27558
+ var PERCENTILE_KEYS = { 50: "p50", 90: "p90", 95: "p95", 99: "p99", 99.9: "p999" };
27559
+ var PERCENTILES2 = Object.keys(PERCENTILE_KEYS).map(Number);
27560
+ function components(diagram) {
27561
+ return diagram.nodes.filter((n) => n.kind === "component");
27562
+ }
27563
+ var replicasOf = (node) => Math.max(1, node.replicas ?? 1);
27564
+ function resolveTraffic(diagram, warnings = []) {
27565
+ const result = /* @__PURE__ */ new Map();
27566
+ for (const u of diagram.useCases) result.set(u.id, { rps: 0, shares: u.scenarios.map((_, i) => i === 0 ? 1 : 0) });
27567
+ const seen = /* @__PURE__ */ new Set();
27568
+ for (const entry of diagram.traffic ?? []) {
27569
+ const useCase = findUseCase(diagram, entry.useCase);
27570
+ if (!useCase) {
27571
+ warnings.push(`Traffic for unknown use case "${entry.useCase}" is ignored`);
27572
+ continue;
27573
+ }
27574
+ if (seen.has(useCase.id)) continue;
27575
+ seen.add(useCase.id);
27576
+ const shares = useCase.scenarios.map(() => 0);
27577
+ for (const { scenario, share } of entry.mix ?? []) {
27578
+ const found = findScenario2(useCase, scenario);
27579
+ if (found) shares[useCase.scenarios.indexOf(found)] += share;
27580
+ else warnings.push(`"${useCase.name}" has no scenario "${scenario}"; its traffic share is ignored`);
27581
+ }
27582
+ const total = shares.reduce((a, b) => a + b, 0);
27583
+ if (total <= 0) shares[0] = 1;
27584
+ else {
27585
+ if (Math.abs(total - 1) > 1e-6) warnings.push(`Traffic mix of "${useCase.name}" adds up to ${formatPercent(total)}; shares are scaled to 100%`);
27586
+ for (let i = 0; i < shares.length; i++) shares[i] /= total;
27587
+ }
27588
+ result.set(useCase.id, { rps: entry.rps, shares: useCase.scenarios.length ? shares : [] });
27589
+ }
27590
+ return result;
27591
+ }
27592
+ function computeLoad(diagram, traffic) {
27593
+ const load = /* @__PURE__ */ new Map();
27594
+ for (const u of diagram.useCases) {
27595
+ const { rps, shares } = traffic.get(u.id) ?? { rps: 0, shares: [] };
27596
+ u.scenarios.forEach((s, i) => {
27597
+ const rate = rps * (shares[i] ?? 0);
27598
+ if (rate > 0) for (const step of s.steps) load.set(step.toServiceId, (load.get(step.toServiceId) ?? 0) + rate);
27599
+ });
27600
+ }
27601
+ return load;
27602
+ }
27603
+ var utilizationOf = (load, capacity) => Number.isFinite(capacity) && capacity > 0 ? load / capacity : 0;
27604
+ var hopLatency = (baseMs, utilization) => baseMs / (1 - Math.min(utilization, QUEUE_CAP));
27605
+ var replicatedAvailability = (a, replicas) => 1 - (1 - a) ** replicas;
27606
+ function useCasePercentile(scenarios, q) {
27607
+ const tail = 1 - q / 100 - 1e-9;
27608
+ const heavy = scenarios.filter((s) => s.share >= tail && s.share > 0);
27609
+ if (heavy.length) return Math.max(...heavy.map((s) => s.value));
27610
+ let covered = 0;
27611
+ for (const s of [...scenarios].sort((a, b) => b.value - a.value)) {
27612
+ covered += s.share;
27613
+ if (covered >= tail) return s.value;
27614
+ }
27615
+ return scenarios.length ? Math.max(...scenarios.map((s) => s.value)) : 0;
27616
+ }
27617
+ function mainScenarioIndex(shares) {
27618
+ let best = 0;
27619
+ shares.forEach((share, i) => {
27620
+ if (share > shares[best]) best = i;
27621
+ });
27622
+ return best;
27623
+ }
27624
+ function analyze(diagram, options = {}) {
27625
+ const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
27626
+ const warnings = [];
27627
+ const nodes = components(diagram);
27628
+ const byId = new Map(nodes.map((n) => [n.id, n]));
27629
+ const overrides = new Map((diagram.capacity ?? []).map((o) => [o.node, o]));
27630
+ for (const o of diagram.capacity ?? []) if (!byId.has(o.node)) warnings.push(`Capacity for unknown node '${o.node}' is ignored`);
27631
+ const profiles = new Map(nodes.map((n) => [n.id, profileOf(n, overrides.get(n.id))]));
27632
+ const traffic = resolveTraffic(diagram, warnings);
27633
+ const load = computeLoad(diagram, traffic);
27634
+ const nodeResults = nodes.map((n) => {
27635
+ const p = profiles.get(n.id);
27636
+ const replicas = replicasOf(n);
27637
+ const loadRps = load.get(n.id) ?? 0;
27638
+ const capacityRps = p.rps * replicas;
27639
+ const utilization = utilizationOf(loadRps, capacityRps);
27640
+ return {
27641
+ id: n.id,
27642
+ kind: p.kind,
27643
+ replicas,
27644
+ loadRps,
27645
+ capacityRps,
27646
+ utilization,
27647
+ saturated: utilization >= 1,
27648
+ latencyMs: hopLatency(p.latencyMs, utilization),
27649
+ availability: replicatedAvailability(p.availability, replicas),
27650
+ costUsd: replicas * p.costUsd,
27651
+ durable: p.durable
27652
+ };
27653
+ });
27654
+ const nodeById = new Map(nodeResults.map((n) => [n.id, n]));
27655
+ const hopMs = (id) => nodeById.get(id)?.latencyMs ?? 0;
27656
+ const availabilityOf = (id) => nodeById.get(id)?.availability ?? 1;
27657
+ for (const n of nodeResults) {
27658
+ const usage = `${formatRps(n.loadRps)} of ${formatRps(n.capacityRps)}, ${formatPercent(n.utilization)}`;
27659
+ if (n.saturated) warnings.push(`'${n.id}' is saturated: ${usage}. Add replicas or take load off it.`);
27660
+ else if (n.utilization > HOT) warnings.push(`'${n.id}' runs hot: ${usage}.`);
27661
+ }
27662
+ const useCases = diagram.useCases.map((u) => {
27663
+ const { rps, shares } = traffic.get(u.id);
27664
+ const scenarios = u.scenarios.map((s, i) => {
27665
+ const meanMs = criticalPath(u, s).reduce(
27666
+ (sum, part) => sum + Math.max(...part.map((hop) => hop.failed ? timeoutMs : hopMs(hop.target))),
27667
+ 0
27668
+ );
27669
+ return { id: s.id, name: s.name, share: shares[i] ?? 0, meanMs, percentiles: percentilesOf(meanMs) };
27670
+ });
27671
+ const percentiles = Object.fromEntries(
27672
+ PERCENTILES2.map((q) => [PERCENTILE_KEYS[q], useCasePercentile(scenarios.map((s) => ({ share: s.share, value: s.percentiles[PERCENTILE_KEYS[q]] })), q)])
27673
+ );
27674
+ if (rps > 0 && u.scenarios.length === 0) warnings.push(`"${u.name}" has traffic but no steps`);
27675
+ return { id: u.id, name: u.name, rps, scenarios, percentiles, availability: useCaseAvailability(u, shares, availabilityOf) };
27676
+ });
27677
+ return {
27678
+ nodes: nodeResults,
27679
+ useCases,
27680
+ totalCostUsd: nodeResults.reduce((sum, n) => sum + n.costUsd, 0),
27681
+ singlePointsOfFailure: singlePointsOfFailure(diagram),
27682
+ warnings
27683
+ };
27684
+ }
27685
+ function percentilesOf(meanMs) {
27686
+ return Object.fromEntries(PERCENTILES2.map((q) => [PERCENTILE_KEYS[q], meanMs * PERCENTILE_FACTORS[q]]));
27687
+ }
27688
+ function useCaseAvailability(useCase, shares, availabilityOf) {
27689
+ if (useCase.scenarios.length === 0) return 1;
27690
+ const main = pathNodes(useCase, useCase.scenarios[mainScenarioIndex(shares)]);
27691
+ const onMain = new Set(main);
27692
+ let result = 1;
27693
+ for (const id of main) {
27694
+ const a = availabilityOf(id);
27695
+ const fallback = Math.max(
27696
+ 0,
27697
+ ...fallbacksFor(useCase, id).map(
27698
+ (s) => pathNodes(useCase, s).filter((other) => other !== id && !onMain.has(other)).reduce((product, other) => product * availabilityOf(other), 1)
27699
+ )
27700
+ );
27701
+ result *= 1 - (1 - a) * (1 - fallback);
27702
+ }
27703
+ return result;
27704
+ }
27705
+ var DEPLOYED = (kind) => kind !== "client" && kind !== "other" && kind !== "external";
27706
+ function singlePointsOfFailure(diagram) {
27707
+ return components(diagram).filter((n) => replicasOf(n) === 1 && DEPLOYED(profileOf(n).kind)).filter((n) => diagram.useCases.some((u) => needs(u, n.id) && fallbacksFor(u, n.id).length === 0)).map((n) => n.id);
27708
+ }
27709
+
27710
+ // ../frontend/src/sim/tests.ts
27711
+ var pass = (message) => ({ passed: true, message });
27712
+ var fail = (message, hint) => ({ passed: false, message, hint });
27713
+ function runTests(diagram, analysis = analyze(diagram)) {
27714
+ const run = {
27715
+ diagram,
27716
+ analysis,
27717
+ nodes: new Map(analysis.nodes.map((n) => [n.id, n])),
27718
+ useCases: new Map(analysis.useCases.map((u) => [u.id, u])),
27719
+ shares: new Map([...resolveTraffic(diagram)].map(([id, t]) => [id, t.shares]))
27720
+ };
27721
+ const results = (diagram.requirements ?? []).map((r, i) => ({
27722
+ id: `req:${i + 1}`,
27723
+ name: requirementName(r),
27724
+ category: CATEGORY[r.kind],
27725
+ ...requirement(run, r),
27726
+ loc: r.loc
27727
+ }));
27728
+ const names = /* @__PURE__ */ new Map();
27729
+ for (const test of diagram.tests ?? []) {
27730
+ const n = (names.get(test.name) ?? 0) + 1;
27731
+ names.set(test.name, n);
27732
+ results.push({ id: n === 1 ? `test:${test.name}` : `test:${test.name}#${n}`, name: test.name, category: "flow", ...flowTest(run, test), loc: test.loc });
27733
+ }
27734
+ return results.map(({ hint, ...r }) => r.passed || !hint ? r : { ...r, hint });
27735
+ }
27736
+ var CATEGORY = {
27737
+ latency: "latency",
27738
+ availability: "availability",
27739
+ durable: "durability",
27740
+ survive: "resilience",
27741
+ cost: "cost"
27742
+ };
27743
+ var percentileName = (q) => PERCENTILE_KEYS[q];
27744
+ function requirementName(r) {
27745
+ switch (r.kind) {
27746
+ case "latency":
27747
+ return `${percentileName(r.percentile)} of ${r.useCase ?? "every use case"} < ${formatMs(r.maxMs)}`;
27748
+ case "availability":
27749
+ return `availability of ${r.useCase ?? "every use case"} \u2265 ${r.minPercent}%`;
27750
+ case "durable":
27751
+ return `${r.useCase} is durable`;
27752
+ case "survive":
27753
+ return r.target === "any" ? "survive any node failure" : `survive failure of ${selectorText(r.target)}`;
27754
+ case "cost":
27755
+ return `cost \u2264 ${formatUsd(r.maxUsdPerMonth)}`;
27756
+ }
27757
+ }
27758
+ function requirement(run, r) {
27759
+ switch (r.kind) {
27760
+ case "latency":
27761
+ return overUseCases(run, r.useCase, (u) => latency(run, u, r.percentile, r.maxMs), `(limit ${formatMs(r.maxMs)})`);
27762
+ case "availability":
27763
+ return overUseCases(run, r.useCase, (u) => availability(run, u, r.minPercent), `(limit ${r.minPercent}%)`, true);
27764
+ case "durable":
27765
+ return durable(run, r.useCase);
27766
+ case "survive":
27767
+ return survive(run, r.target);
27768
+ case "cost":
27769
+ return cost(run, r.maxUsdPerMonth);
27770
+ }
27771
+ }
27772
+ function overUseCases(run, name, check, limit, anyWithoutTraffic = false) {
27773
+ if (name !== void 0) {
27774
+ const u = findUseCase(run.diagram, name);
27775
+ return u ? check(u) : unknownUseCase(run, name);
27776
+ }
27777
+ let targets = run.diagram.useCases.filter((u) => (run.useCases.get(u.id)?.rps ?? 0) > 0);
27778
+ if (targets.length === 0 && anyWithoutTraffic) targets = run.diagram.useCases;
27779
+ if (targets.length === 0) return fail("No use case has traffic, so there is nothing to measure", 'Add traffic { "<use case>" 1k rps } for the use cases this applies to');
27780
+ const checks = targets.map(check);
27781
+ const failed = checks.filter((c) => !c.passed);
27782
+ if (failed.length === 0) {
27783
+ return pass(checks.length === 1 ? checks[0].message : `All ${checks.length} use cases hold; ${checks.map((c) => c.value).join(", ")} ${limit}`);
27784
+ }
27785
+ return fail(failed.length === 1 ? failed[0].message : `${failed.map((c) => c.value ?? c.message).join("; ")} ${limit}`, failed[0].hint);
27786
+ }
27787
+ function unknownUseCase(run, name) {
27788
+ const known = run.diagram.useCases.map((u) => `"${u.name}"`);
27789
+ return fail(`No use case named "${name}"`, known.length ? `Use one of ${known.join(", ")}` : 'Add a usecase "\u2026" { \u2026 } block');
27790
+ }
27791
+ function latency(run, u, q, maxMs) {
27792
+ const result = run.useCases.get(u.id);
27793
+ const key = percentileName(q);
27794
+ const shares = run.shares.get(u.id) ?? [];
27795
+ const touched = new Set(u.scenarios.flatMap((s, i) => (shares[i] ?? 0) > 0 ? s.steps.map((step) => step.toServiceId) : []));
27796
+ const saturated = [...touched].map((id) => run.nodes.get(id)).filter((n) => !!n?.saturated);
27797
+ if (saturated.length) {
27798
+ const n = saturated[0];
27799
+ const usage = `${formatRps(n.loadRps)} of ${formatRps(n.capacityRps)}, ${formatPercent(n.utilization)}`;
27800
+ const value2 = `${key} of ${u.name}: ${n.id} is saturated (${usage})`;
27801
+ return { ...fail(`${value2} (limit ${formatMs(maxMs)})`, saturationHint(run, n)), value: value2 };
27802
+ }
27803
+ const ms = result.percentiles[key];
27804
+ const value = `${key} of ${u.name} is ${formatMs(ms)}${result.rps > 0 ? "" : " with no traffic"}`;
27805
+ const message = `${value} (limit ${formatMs(maxMs)})`;
27806
+ return ms < maxMs ? { ...pass(message), value } : { ...fail(message, latencyHint(run, u, result, key, ms)), value };
27807
+ }
27808
+ function saturationHint(run, n) {
27809
+ const perReplica = n.capacityRps / n.replicas;
27810
+ const replicas = Math.ceil(n.loadRps / (perReplica * HOT));
27811
+ return `Add replicas to ${nodeLabel(run.diagram, n.id)}: x${replicas} keeps it under ${formatPercent(HOT)}, or take load off it (a cache in front, or async work)`;
27812
+ }
27813
+ function latencyHint(run, u, result, key, ms) {
27814
+ const index = result.scenarios.findIndex((s) => s.percentiles[key] === ms);
27815
+ const scenario = u.scenarios[Math.max(0, index)];
27816
+ const lead = u.scenarios.length > 1 ? `The "${scenario.name}" path (${formatPercent(result.scenarios[Math.max(0, index)].share)} of traffic) sets ${key}. ` : "";
27817
+ const hops = criticalPath(u, scenario).flat();
27818
+ const cost2 = (h) => h.failed ? DEFAULT_TIMEOUT_MS : run.nodes.get(h.target)?.latencyMs ?? 0;
27819
+ const slowest = hops.reduce((best, h) => !best || cost2(h) > cost2(best) ? h : best, void 0);
27820
+ if (!slowest) return `${lead}Shorten the synchronous path of the request.`;
27821
+ const label = nodeLabel(run.diagram, slowest.target);
27822
+ if (slowest.failed) return `${lead}It waits ${formatMs(DEFAULT_TIMEOUT_MS)} for the failed call to ${label}; give that path a smaller share of traffic or avoid the call.`;
27823
+ const node = run.nodes.get(slowest.target);
27824
+ if (node && node.utilization > HOT) return `${lead}${label} runs at ${formatPercent(node.utilization)} and queues requests; add replicas to it.`;
27825
+ return `${lead}Its slowest hop is ${label} at ${formatMs(cost2(slowest))}: put a cache in front of it, run independent calls in par, or move work off the request path with ->>.`;
27826
+ }
27827
+ function availability(run, u, minPercent) {
27828
+ const result = run.useCases.get(u.id);
27829
+ const value = `availability of ${u.name} is ${formatAvailability(result.availability)}`;
27830
+ const message = `${value} (limit ${minPercent}%)`;
27831
+ if (result.availability * 100 >= minPercent - 1e-9) return { ...pass(message), value };
27832
+ const shares = run.shares.get(u.id) ?? [];
27833
+ const main = u.scenarios.length ? pathNodes(u, u.scenarios[mainScenarioIndex(shares)]) : [];
27834
+ const weakest = main.filter((id) => fallbacksFor(u, id).length === 0).map((id) => run.nodes.get(id)).filter((n) => !!n && n.availability < 1).sort((a, b) => a.availability - b.availability)[0];
27835
+ const hint = weakest ? `The weakest link is ${nodeLabel(run.diagram, weakest.id)} at ${formatAvailability(weakest.availability)}: add a replica (x${weakest.replicas + 1}) or a fallback scenario that calls it with -x and still completes` : "Add replicas to the nodes on the main path, or fallback scenarios that complete without them";
27836
+ return { ...fail(message, hint), value };
27837
+ }
27838
+ function writeGap(u, s, accept) {
27839
+ const { requests, beforeResponse } = requestOrder(u, s);
27840
+ const writes = requests.map((m, i) => ({ m, i })).filter(({ m }) => !m.failed && accept(m.to));
27841
+ if (writes.some(({ m, i }) => m.async && i < beforeResponse)) return "async";
27842
+ if (writes.some(({ i }) => i >= beforeResponse)) return "after";
27843
+ return "none";
27844
+ }
27845
+ var GAP_TEXT = {
27846
+ async: "writes only asynchronously (->>)",
27847
+ after: "writes only after responding",
27848
+ none: "never writes"
27849
+ };
27850
+ function writesCheck(u, scenarios, accept, what) {
27851
+ const success = scenarios.filter((s) => s.outcome === "success");
27852
+ if (success.length === 0) return fail(`"${u.name}" has no success scenario to check`, "Add a scenario whose entry request is answered with a 2xx");
27853
+ const gaps = success.map((s) => ({ s, ok: writesBeforeResponse(u, s, accept) })).filter((x) => !x.ok);
27854
+ if (gaps.length === 0) {
27855
+ const label = success.length === 1 ? `"${u.name}"` : `Every success scenario of "${u.name}"`;
27856
+ return pass(`${label} writes ${what} before responding`);
27857
+ }
27858
+ const details = gaps.map(({ s }) => ({ s, gap: writeGap(u, s, accept) }));
27859
+ const where = (s) => u.scenarios.length > 1 ? `"${s.name}"` : `"${u.name}"`;
27860
+ const message = details.map(({ s, gap: gap2 }) => `${where(s)} ${GAP_TEXT[gap2]} ${what} before responding`).join("; ");
27861
+ const gap = details[0].gap;
27862
+ const hint = gap === "async" ? `Make the write a synchronous request (->) so it is acknowledged before the response` : gap === "after" ? `Move the write before the step that answers the entry request` : `Add a synchronous write (->) to ${what} before the entry request is answered`;
27863
+ return fail(message, hint);
27864
+ }
27865
+ function durable(run, name) {
27866
+ const u = findUseCase(run.diagram, name);
27867
+ if (!u) return unknownUseCase(run, name);
27868
+ return writesCheck(u, u.scenarios, (id) => !!run.nodes.get(id)?.durable, "to a durable store");
27869
+ }
27870
+ function survive(run, target) {
27871
+ const all = components(run.diagram);
27872
+ const selected = target === "any" ? all.filter((n) => DEPLOYED(run.nodes.get(n.id).kind)) : selectNodes(run.diagram, target);
27873
+ const what = target === "any" ? "any one node" : selectorText(target);
27874
+ if (selected.length === 0) {
27875
+ return target === "any" ? pass("No nodes to lose") : fail(`No node matches ${what}`, "Use a node id, [Tech] or any <kind>");
27876
+ }
27877
+ const failures = [];
27878
+ for (const node of selected) {
27879
+ const n = run.nodes.get(node.id);
27880
+ const label = nodeLabel(run.diagram, node.id);
27881
+ const replicas = replicasOf(node);
27882
+ if (replicas >= 2) {
27883
+ const left = n.capacityRps / replicas * (replicas - 1);
27884
+ const rho = n.loadRps / left;
27885
+ if (rho >= 1) {
27886
+ failures.push(
27887
+ fail(
27888
+ `Losing one of ${replicas} ${node.id} replicas leaves ${formatRps(left)} for ${formatRps(n.loadRps)} (${formatPercent(rho)})`,
27889
+ `Add a replica to ${label} (x${replicas + 1}) so the rest carry the load`
27890
+ )
27891
+ );
27892
+ }
27893
+ continue;
27894
+ }
27895
+ for (const u of run.diagram.useCases) {
27896
+ if (needs(u, node.id) && fallbacksFor(u, node.id).length === 0) {
27897
+ failures.push(
27898
+ fail(
27899
+ `Losing ${label} breaks "${u.name}"`,
27900
+ `Add a replica (x2 on ${node.id}) or a fallback scenario in "${u.name}" that calls ${node.id} with -x and still completes`
27901
+ )
27902
+ );
27903
+ }
27904
+ }
27905
+ }
27906
+ if (failures.length === 0) {
27907
+ const count = selected.length === 1 ? selected[0].id : `any of ${selected.length} nodes`;
27908
+ return pass(`Every use case keeps working after losing ${count}`);
27909
+ }
27910
+ const more = failures.length > 1 ? ` (and ${failures.length - 1} more)` : "";
27911
+ return fail(`${failures[0].message}${more}`, failures[0].hint);
27912
+ }
27913
+ function cost(run, max) {
27914
+ const total = run.analysis.totalCostUsd;
27915
+ const message = `Total cost is ${formatUsd(total)} (limit ${formatUsd(max)})`;
27916
+ if (total <= max) return pass(message);
27917
+ const top = [...run.analysis.nodes].filter((n) => n.costUsd > 0).sort((a, b) => b.costUsd - a.costUsd).slice(0, 3).map((n) => `${n.id} ${formatUsd(n.costUsd)}${n.replicas > 1 ? ` (${n.replicas} replicas)` : ""}`);
27918
+ return fail(message, `Biggest items: ${top.join(", ")}. Drop replicas on nodes with low utilisation or pick cheaper techs.`);
27919
+ }
27920
+ function flowTest(run, test) {
27921
+ const assertions = test.assertions.map((a) => ({ ...assertion(run, a), loc: a.loc }));
27922
+ const failed = assertions.filter((a) => !a.passed);
27923
+ if (assertions.length === 0) return { passed: true, message: "No assertions to check", assertions };
27924
+ if (failed.length === 0) {
27925
+ return { passed: true, message: assertions.length === 1 ? assertions[0].message : `All ${assertions.length} assertions hold`, assertions };
27926
+ }
27927
+ return { passed: false, message: failed.map((a) => a.message).join("; "), hint: failed[0].hint, assertions };
27928
+ }
27929
+ var quote = (s) => `"${s}"`;
27930
+ function scope(run, useCase, scenario) {
27931
+ const u = findUseCase(run.diagram, useCase);
27932
+ if (!u) return unknownUseCase(run, useCase);
27933
+ if (scenario === void 0) return { u, scenarios: u.scenarios, label: quote(u.name) };
27934
+ const s = findScenario2(u, scenario);
27935
+ if (!s) return missingScenario(u, scenario);
27936
+ return { u, scenarios: [s], label: `${quote(u.name)} scenario ${quote(s.name)}` };
27937
+ }
27938
+ function missingScenario(u, scenario) {
27939
+ const names = u.scenarios.map((s) => quote(s.name)).join(", ");
27940
+ return fail(`${quote(u.name)} has no scenario ${quote(scenario)}${names ? `; it has ${names}` : ""}`, `Add alt "${scenario}" { \u2026 } to "${u.name}"`);
27941
+ }
27942
+ var isCheck = (x) => "passed" in x;
27943
+ function assertion(run, a) {
27944
+ switch (a.kind) {
27945
+ case "calls":
27946
+ return calls(run, a);
27947
+ case "before":
27948
+ return before(run, a);
27949
+ case "writesBeforeResponding": {
27950
+ const sc = scope(run, a.useCase, a.scenario);
27951
+ if (isCheck(sc)) return sc;
27952
+ const ids = new Set(selectNodes(run.diagram, a.target).map((n) => n.id));
27953
+ if (ids.size === 0) return noMatch(a.target);
27954
+ return writesCheck(sc.u, sc.scenarios, (id) => ids.has(id), selectorText(a.target));
27955
+ }
27956
+ case "responds":
27957
+ return responds(run, a);
27958
+ case "hasScenario": {
27959
+ const u = findUseCase(run.diagram, a.useCase);
27960
+ if (!u) return unknownUseCase(run, a.useCase);
27961
+ const s = findScenario2(u, a.scenario);
27962
+ return s ? pass(`${quote(u.name)} has scenario ${quote(s.name)}`) : missingScenario(u, a.scenario);
27963
+ }
27964
+ case "handlesFailure": {
27965
+ const u = findUseCase(run.diagram, a.useCase);
27966
+ if (!u) return unknownUseCase(run, a.useCase);
27967
+ const ids = new Set(selectNodes(run.diagram, a.target).map((n) => n.id));
27968
+ if (ids.size === 0) return noMatch(a.target);
27969
+ const handled = u.scenarios.find((s) => s.outcome === "success" && s.steps.some((step) => step.failed && ids.has(step.toServiceId)));
27970
+ const what = selectorText(a.target);
27971
+ return handled ? pass(`${quote(u.name)} handles a failed call to ${what} in ${quote(handled.name)}`) : fail(
27972
+ `No success scenario of ${quote(u.name)} has a failed call to ${what}`,
27973
+ `Add alt "${what} down" { \u2026 -x ${[...ids][0]} \u2026 } that still answers the entry request with a 2xx`
27974
+ );
27975
+ }
27976
+ case "noPath":
27977
+ return noPath(run, a.from, a.to);
27978
+ case "replicas": {
27979
+ const nodes = selectNodes(run.diagram, a.target);
27980
+ if (nodes.length === 0) return noMatch(a.target);
27981
+ const short = nodes.filter((n) => replicasOf(n) < a.min);
27982
+ const has = (n) => `${n.id} has ${replicasOf(n)} replica${replicasOf(n) === 1 ? "" : "s"}`;
27983
+ if (short.length === 0) return pass(`${nodes.map(has).join(", ")} (minimum ${a.min})`);
27984
+ return fail(`${short.map(has).join(", ")} (minimum ${a.min})`, `Declare ${short[0].id} with x${a.min}`);
27985
+ }
27986
+ }
27987
+ }
27988
+ function noMatch(selector) {
27989
+ return fail(`No node matches ${selectorText(selector)}`, "Use a node id, [Tech] or any <kind> (any cache, any database, \u2026)");
27990
+ }
27991
+ function calls(run, a) {
27992
+ const sc = scope(run, a.useCase, a.scenario);
27993
+ if (isCheck(sc)) return sc;
27994
+ const what = selectorText(a.target);
27995
+ const ids = new Set(selectNodes(run.diagram, a.target).map((n) => n.id));
27996
+ const called = (s) => s.steps.filter((step) => ids.has(step.toServiceId)).map((step) => step.toServiceId);
27997
+ const calling = sc.scenarios.filter((s) => called(s).length > 0);
27998
+ const names = (list) => list.map((s) => quote(s.name)).join(", ");
27999
+ const multi = sc.scenarios.length > 1;
28000
+ if (a.quantifier === "never") {
28001
+ if (calling.length === 0) return pass(`${sc.label} never calls ${what}`);
28002
+ const first = calling[0];
28003
+ return fail(
28004
+ `${multi ? quote(first.name) : sc.label} calls ${nodeLabel(run.diagram, called(first)[0])}`,
28005
+ `Take the call to ${called(first)[0]} out of ${quote(first.name)}, e.g. serve it from a cache`
28006
+ );
28007
+ }
28008
+ if (ids.size === 0) return noMatch(a.target);
28009
+ if (a.quantifier === "some") {
28010
+ if (calling.length) return pass(`${sc.label} calls ${what}${multi ? ` in ${names(calling)}` : ""}`);
28011
+ return fail(`${sc.label} never calls ${what}`, `Add a step to ${what}`);
28012
+ }
28013
+ const missing = sc.scenarios.filter((s) => !calling.includes(s));
28014
+ if (missing.length === 0 && sc.scenarios.length) return pass(`Every scenario of ${sc.label} calls ${what}`);
28015
+ return fail(`${sc.label} does not call ${what} in ${names(missing) || "any scenario"}`, `Add a step to ${what} in ${names(missing) || "a scenario"}`);
28016
+ }
28017
+ function before(run, a) {
28018
+ const sc = scope(run, a.useCase, a.scenario);
28019
+ if (isCheck(sc)) return sc;
28020
+ const first = new Set(selectNodes(run.diagram, a.first).map((n) => n.id));
28021
+ const then = new Set(selectNodes(run.diagram, a.then).map((n) => n.id));
28022
+ const [x, y] = [selectorText(a.first), selectorText(a.then)];
28023
+ if (first.size === 0) return noMatch(a.first);
28024
+ if (then.size === 0) return noMatch(a.then);
28025
+ let checked = 0;
28026
+ for (const s of sc.scenarios) {
28027
+ const order = requestOrder(sc.u, s).requests.map((m) => m.to);
28028
+ const iy = order.findIndex((id) => then.has(id));
28029
+ if (iy < 0) continue;
28030
+ checked++;
28031
+ const ix = order.findIndex((id) => first.has(id));
28032
+ if (ix < 0 || ix > iy) {
28033
+ const where = sc.scenarios.length > 1 || a.scenario ? `In ${quote(s.name)}` : `In ${quote(sc.u.name)}`;
28034
+ return fail(
28035
+ ix < 0 ? `${where}, ${order[iy]} is called but ${x} never is` : `${where}, ${order[iy]} is called before ${order[ix]}`,
28036
+ `Call ${x} before the first call to ${y}`
28037
+ );
28038
+ }
28039
+ }
28040
+ if (checked === 0) return fail(`${sc.label} never calls ${y}`, `Add a step to ${y} after the call to ${x}`);
28041
+ return pass(`${sc.label} calls ${x} before ${y}${checked > 1 ? ` in all ${checked} scenarios that call ${y}` : ""}`);
28042
+ }
28043
+ function statusMatches(status, wanted) {
28044
+ if (status === void 0) return false;
28045
+ const cls = /^([1-5])xx$/i.exec(wanted);
28046
+ return cls ? Math.floor(status / 100) === Number(cls[1]) : String(status) === wanted;
28047
+ }
28048
+ function responds(run, a) {
28049
+ const sc = scope(run, a.useCase, a.scenario);
28050
+ if (isCheck(sc)) return sc;
28051
+ const hit = sc.scenarios.find((s) => statusMatches(s.steps[0]?.statusCode, a.status));
28052
+ if (hit) return pass(`${sc.label} responds ${hit.steps[0].statusCode}${sc.scenarios.length > 1 ? ` in ${quote(hit.name)}` : ""}`);
28053
+ const seen = [...new Set(sc.scenarios.map((s) => s.steps[0]?.failed ? "a failed call" : s.steps[0]?.statusCode ?? "no status"))];
28054
+ return fail(`${sc.label} never responds ${a.status}; it answers ${seen.join(", ") || "nothing"}`, `Add a scenario (alt) whose entry request is answered with ${a.status}`);
28055
+ }
28056
+ function noPath(run, from, to) {
28057
+ const sources = new Set(selectNodes(run.diagram, from).map((n) => n.id));
28058
+ const targets = new Set(selectNodes(run.diagram, to).map((n) => n.id));
28059
+ const [x, y] = [selectorText(from), selectorText(to)];
28060
+ const line = (loc) => `${loc.file ? `${loc.file}:` : "line "}${loc.line}`;
28061
+ const hint = `Route it through a node that guards ${y} (a gateway or a service) instead of going there directly`;
28062
+ const edge = run.diagram.edges.find((e) => sources.has(e.source) && targets.has(e.target));
28063
+ if (edge) return fail(`The connection ${edge.source} -> ${edge.target} (${line(edge.loc)}) goes directly from ${x} to ${y}`, hint);
28064
+ for (const u of run.diagram.useCases) {
28065
+ for (const s of u.scenarios) {
28066
+ const step = s.steps.find((st) => sources.has(st.fromServiceId) && targets.has(st.toServiceId));
28067
+ if (step) {
28068
+ const where = u.scenarios.length > 1 ? `${quote(u.name)} scenario ${quote(s.name)}` : quote(u.name);
28069
+ return fail(`The step ${step.fromServiceId} -> ${step.toServiceId} in ${where} (${line(step.loc)}) goes directly from ${x} to ${y}`, hint);
28070
+ }
28071
+ }
28072
+ }
28073
+ return pass(`No connection or step goes directly from ${x} to ${y}`);
28074
+ }
28075
+
26471
28076
  // src/proschi.ts
26472
28077
  var KEYWORDS2 = [
26473
28078
  { label: "title", detail: "Document title", snippet: 'title "$1"' },
@@ -26475,15 +28080,91 @@ var KEYWORDS2 = [
26475
28080
  { label: "group", detail: 'group id "Name" [Style] { \u2026 }', snippet: 'group ${1:id} "${2:Name}" {\n $0\n}' },
26476
28081
  { label: "usecase", detail: 'usecase "Name" { steps }', snippet: 'usecase "${1:Name}" {\n $0\n}' },
26477
28082
  { label: "par", detail: "Steps that run in parallel", snippet: "par {\n $0\n}" },
26478
- { label: "alt", detail: "One scenario of a use case", snippet: 'alt "${1:Scenario}" {\n $0\n}' }
28083
+ { label: "alt", detail: "One scenario of a use case", snippet: 'alt "${1:Scenario}" {\n $0\n}' },
28084
+ // High-level design sections (top level).
28085
+ { label: "traffic", detail: 'Requests per use case: "Use case" 100 rps', snippet: 'traffic {\n "${1:Use case}" ${2:100 rps}$0\n}' },
28086
+ { label: "requirements", detail: "Latency, availability, durability, resilience, cost", snippet: "requirements {\n p99 < ${1:200ms}$0\n}" },
28087
+ { label: "capacity", detail: "Per-replica overrides: node 1k rps latency 5ms", snippet: "capacity {\n ${1:node} ${2:1k rps}$0\n}" },
28088
+ { label: "entity", detail: "entity Name in store { fields }", snippet: "entity ${1:Name} in ${2:store} {\n ${3:id} ${4:uuid} key$0\n}" },
28089
+ { label: "decision", detail: "A design decision with its reasons", snippet: 'decision "${1:Title}" {\n because "${2:reason}"$0\n}' },
28090
+ { label: "test", detail: 'Flow assertions: "Use case" calls any cache', snippet: 'test "${1:Name}" {\n $0\n}' }
26479
28091
  ];
26480
28092
 
28093
+ // src/imports.ts
28094
+ var import_node_fs3 = require("node:fs");
28095
+ var import_node_path4 = require("node:path");
28096
+ function fileResolver(read = () => void 0, toPath = (p) => p) {
28097
+ return (importPath, fromPath) => {
28098
+ const abs = (0, import_node_path4.resolve)(fromPath ? (0, import_node_path4.dirname)(fromPath) : process.cwd(), importPath);
28099
+ const text = read(abs) ?? ((0, import_node_fs3.existsSync)(abs) && (0, import_node_fs3.statSync)(abs).isFile() ? (0, import_node_fs3.readFileSync)(abs, "utf8") : void 0);
28100
+ return text === void 0 ? void 0 : { path: toPath(abs), source: text };
28101
+ };
28102
+ }
28103
+ function reachableFrom(result, start) {
28104
+ const reached = /* @__PURE__ */ new Set([start]);
28105
+ const queue = [start];
28106
+ while (queue.length) {
28107
+ const file = queue.shift();
28108
+ for (const i of result.imports ?? []) {
28109
+ if (i.loc.file === file && i.resolved !== void 0 && !reached.has(i.resolved)) {
28110
+ reached.add(i.resolved);
28111
+ queue.push(i.resolved);
28112
+ }
28113
+ }
28114
+ }
28115
+ return reached;
28116
+ }
28117
+ function ownDiagnostics(result) {
28118
+ const own = result.diagnostics.filter((d) => d.file === void 0);
28119
+ for (const i of result.imports ?? []) {
28120
+ if (i.loc.file !== void 0 || i.resolved === void 0) continue;
28121
+ const files = reachableFrom(result, i.resolved);
28122
+ const errors = result.diagnostics.filter((d) => d.severity === "error" && d.file !== void 0 && files.has(d.file)).length;
28123
+ if (errors) own.push({ severity: "error", message: `'${(0, import_node_path4.basename)(i.path)}' has ${errors} error${errors === 1 ? "" : "s"}`, ...i.loc });
28124
+ }
28125
+ return own.sort((a, b) => a.line - b.line || a.col - b.col);
28126
+ }
28127
+ function importLinks(result) {
28128
+ return (result.imports ?? []).filter((i) => i.loc.file === void 0 && i.resolved !== void 0).map((i) => {
28129
+ const range = toRange(i.loc);
28130
+ return {
28131
+ range: { start: { ...range.start, character: range.start.character + 1 }, end: { ...range.end, character: Math.max(range.start.character + 1, range.end.character - 1) } },
28132
+ target: i.resolved
28133
+ };
28134
+ });
28135
+ }
28136
+
28137
+ // src/simulation.ts
28138
+ function testDiagnostics(diagram) {
28139
+ const out = [];
28140
+ for (const r of runTests(diagram)) {
28141
+ if (r.passed || !r.loc || r.loc.file !== void 0) continue;
28142
+ const message = `${r.name}: ${r.message}${r.hint ? `. ${r.hint}` : ""}`;
28143
+ out.push({ ...r.loc, severity: "warning", message });
28144
+ for (const a of r.assertions ?? []) {
28145
+ if (a.passed || a.loc.file !== void 0 || a.loc.line === r.loc.line) continue;
28146
+ out.push({ ...a.loc, severity: "warning", message: `${a.message}${a.hint ? `. ${a.hint}` : ""}` });
28147
+ }
28148
+ }
28149
+ return out;
28150
+ }
28151
+ function nodeSimulation(diagram, nodeId) {
28152
+ if ((diagram.traffic ?? []).length === 0) return void 0;
28153
+ const n = analyze(diagram).nodes.find((x) => x.id === nodeId);
28154
+ if (!n || n.kind === "client" || n.kind === "other") return void 0;
28155
+ const state = n.saturated ? " \u2014 **saturated**" : n.utilization > HOT ? " \u2014 hot" : "";
28156
+ return [
28157
+ `Load ${formatRps(n.loadRps)} of ${formatRps(n.capacityRps)} (${formatPercent(n.utilization)} utilised${state})`,
28158
+ `Latency ${formatMs(n.latencyMs)} per call \xB7 availability ${formatAvailability(n.availability)} \xB7 ${formatUsd(n.costUsd)}${n.replicas > 1 ? ` for ${n.replicas} replicas` : ""}`
28159
+ ].join(" \n");
28160
+ }
28161
+
26481
28162
  // src/analysis.ts
26482
28163
  function toRange(loc) {
26483
28164
  const start = { line: loc.line - 1, character: loc.col - 1 };
26484
28165
  return { start, end: { line: start.line, character: start.character + Math.max(loc.length, 1) } };
26485
28166
  }
26486
- function analyze(text, options) {
28167
+ function analyze2(text, options) {
26487
28168
  const result = parse(text, options);
26488
28169
  return { diagram: result.diagram, diagnostics: result.diagnostics, lines: text.split("\n"), result };
26489
28170
  }
@@ -26496,9 +28177,9 @@ function wordAt(line, character) {
26496
28177
  if (start === end || !/[A-Za-z_]/.test(line[start])) return null;
26497
28178
  return { word: line.slice(start, end), start, end };
26498
28179
  }
26499
- function inLabel(before) {
28180
+ function inLabel(before2) {
26500
28181
  let quoted = false;
26501
- for (const ch of before) {
28182
+ for (const ch of before2) {
26502
28183
  if (ch === '"') quoted = !quoted;
26503
28184
  else if (ch === ":" && !quoted) return true;
26504
28185
  else if (ch === "#" && !quoted) return false;
@@ -26530,9 +28211,9 @@ function labelStart(text) {
26530
28211
  }
26531
28212
  function complete(analysis, pos) {
26532
28213
  const line = analysis.lines[pos.line] ?? "";
26533
- const before = line.slice(0, pos.character);
28214
+ const before2 = line.slice(0, pos.character);
26534
28215
  if (insidePayload(analysis.lines, pos.line)) return [];
26535
- const tech = before.match(/\[([^\]]*)$/);
28216
+ const tech = before2.match(/\[([^\]]*)$/);
26536
28217
  if (tech) {
26537
28218
  const closed = line[pos.character] === "]";
26538
28219
  const range2 = { start: { line: pos.line, character: pos.character - tech[1].length }, end: pos };
@@ -26544,11 +28225,11 @@ function complete(analysis, pos) {
26544
28225
  range: range2
26545
28226
  }));
26546
28227
  }
26547
- if (inLabel(before)) return [];
26548
- const word = before.match(/[A-Za-z_]\w*$/)?.[0] ?? "";
28228
+ if (inLabel(before2)) return [];
28229
+ const word = before2.match(/[A-Za-z_]\w*$/)?.[0] ?? "";
26549
28230
  const range = { start: { line: pos.line, character: pos.character - word.length }, end: pos };
26550
- const atLineStart = /^\s*(\}\s*)?$/.test(before.slice(0, before.length - word.length));
26551
- if (/^\s*(\}\s*)?alt\s+"[^"]*"\s+$/.test(before.slice(0, before.length - word.length))) {
28231
+ const atLineStart = /^\s*(\}\s*)?$/.test(before2.slice(0, before2.length - word.length));
28232
+ if (/^\s*(\}\s*)?alt\s+"[^"]*"\s+$/.test(before2.slice(0, before2.length - word.length))) {
26552
28233
  return [{ label: "when", kind: "keyword", detail: "Condition of this scenario", snippet: 'when "${1:condition}" {\n $0\n}', range }];
26553
28234
  }
26554
28235
  const nodes = analysis.diagram.nodes.map((n) => ({
@@ -26558,7 +28239,7 @@ function complete(analysis, pos) {
26558
28239
  range
26559
28240
  }));
26560
28241
  if (!atLineStart) return nodes;
26561
- const afterBrace = /\}\s*$/.test(before.slice(0, before.length - word.length));
28242
+ const afterBrace = /\}\s*$/.test(before2.slice(0, before2.length - word.length));
26562
28243
  const keywords = KEYWORDS2.filter((k) => !afterBrace || k.label === "alt").map((k) => ({
26563
28244
  label: k.label,
26564
28245
  kind: "keyword",
@@ -26576,8 +28257,8 @@ function nodeAt(analysis, pos) {
26576
28257
  if (insidePayload(analysis.lines, pos.line)) return null;
26577
28258
  const word = wordAt(line, pos.character);
26578
28259
  if (!word) return null;
26579
- const before = line.slice(0, word.start);
26580
- if (inLabel(before) || (before.match(/"/g)?.length ?? 0) % 2 === 1 || /\[[^\]]*$/.test(before) || /@[\w-]*$/.test(before)) return null;
28260
+ const before2 = line.slice(0, word.start);
28261
+ if (inLabel(before2) || (before2.match(/"/g)?.length ?? 0) % 2 === 1 || /\[[^\]]*$/.test(before2) || /@[\w-]*$/.test(before2)) return null;
26581
28262
  const node = analysis.diagram.nodes.find((n) => n.id === word.word);
26582
28263
  if (!node) return null;
26583
28264
  return { node, range: { start: { line: pos.line, character: word.start }, end: { line: pos.line, character: word.end } } };
@@ -26621,6 +28302,8 @@ function hover(analysis, pos) {
26621
28302
  if (n.implicit) lines.push("", "_Not declared; created because it is referenced._");
26622
28303
  else if (n.loc.file) lines.push("", `_Declared in ${n.loc.file.split(/[\\/]/).pop()}_`);
26623
28304
  if (useCases.length) lines.push("", `Used in: ${useCases.map((u) => `\u201C${u.name}\u201D`).join(", ")}`);
28305
+ const load = nodeSimulation(analysis.diagram, n.id);
28306
+ if (load) lines.push("", load);
26624
28307
  return { markdown: lines.join("\n"), range: hit.range };
26625
28308
  }
26626
28309
  function outline(analysis) {
@@ -26646,7 +28329,27 @@ function outline(analysis) {
26646
28329
  children: []
26647
28330
  })) : []
26648
28331
  }));
26649
- return [...top, ...useCases];
28332
+ return [...top, ...useCases, ...sections(analysis)];
28333
+ }
28334
+ function sections(analysis) {
28335
+ const { diagram, lines } = analysis;
28336
+ const own = (items) => (items ?? []).filter((i) => !i.loc.file);
28337
+ const item = (kind, name, loc, detail) => ({ name, detail: detail || void 0, kind, range: toRange(loc), children: [] });
28338
+ const sourceOf = (loc) => (lines[loc.line - 1] ?? "").slice(loc.col - 1, loc.col - 1 + loc.length);
28339
+ const count = (n, what) => `${n} ${what}${n === 1 ? "" : "s"}`;
28340
+ const groups = [
28341
+ ["traffic", own(diagram.traffic).map((t) => item("traffic", t.useCase, t.loc, [`${t.rps} rps`, t.mix ? count(t.mix.length, "scenario") : ""].filter(Boolean).join(" \xB7 ")))],
28342
+ ["requirements", own(diagram.requirements).map((r) => item("requirement", sourceOf(r.loc), r.loc))],
28343
+ ["entities", own(diagram.entities).map((e) => item("entity", e.name, e.loc, [e.store ? `in ${e.store}` : "", count(e.fields.length, "field")].filter(Boolean).join(" \xB7 ")))],
28344
+ ["decisions", own(diagram.decisions).map((d) => item("decision", d.title, d.loc, d.because))],
28345
+ ["tests", own(diagram.tests).map((t) => item("test", t.name, t.loc, count(t.assertions.length, "assertion")))]
28346
+ ];
28347
+ return groups.filter(([, children]) => children.length).map(([name, children]) => ({
28348
+ name,
28349
+ kind: "section",
28350
+ range: { start: children[0].range.start, end: children.reduce((end, c) => c.range.end.line >= end.line ? c.range.end : end, children[0].range.end) },
28351
+ children
28352
+ }));
26650
28353
  }
26651
28354
  var MISSING_CONNECTION = /^No connection between '\w+' and '\w+' in the architecture; add '(\w+) -> (\w+)'$/;
26652
28355
  function quickFix(analysis, message) {
@@ -26658,65 +28361,21 @@ function quickFix(analysis, message) {
26658
28361
  if (updated === text) return null;
26659
28362
  return { title: `Add connection '${from} -> ${to}'`, edit: diffEdit(text, updated) };
26660
28363
  }
26661
- function diffEdit(before, after) {
28364
+ function diffEdit(before2, after) {
26662
28365
  let start = 0;
26663
- while (start < before.length && start < after.length && before[start] === after[start]) start++;
28366
+ while (start < before2.length && start < after.length && before2[start] === after[start]) start++;
26664
28367
  let end = 0;
26665
- while (end < before.length - start && end < after.length - start && before[before.length - 1 - end] === after[after.length - 1 - end]) end++;
26666
- return { range: { start: positionAt(before, start), end: positionAt(before, before.length - end) }, newText: after.slice(start, after.length - end) };
28368
+ while (end < before2.length - start && end < after.length - start && before2[before2.length - 1 - end] === after[after.length - 1 - end]) end++;
28369
+ return { range: { start: positionAt(before2, start), end: positionAt(before2, before2.length - end) }, newText: after.slice(start, after.length - end) };
26667
28370
  }
26668
28371
  function positionAt(text, offset) {
26669
- const before = text.slice(0, offset).split("\n");
26670
- return { line: before.length - 1, character: before[before.length - 1].length };
26671
- }
26672
-
26673
- // src/imports.ts
26674
- var import_node_fs3 = require("node:fs");
26675
- var import_node_path4 = require("node:path");
26676
- function fileResolver(read = () => void 0, toPath = (p) => p) {
26677
- return (importPath, fromPath) => {
26678
- const abs = (0, import_node_path4.resolve)(fromPath ? (0, import_node_path4.dirname)(fromPath) : process.cwd(), importPath);
26679
- const text = read(abs) ?? ((0, import_node_fs3.existsSync)(abs) && (0, import_node_fs3.statSync)(abs).isFile() ? (0, import_node_fs3.readFileSync)(abs, "utf8") : void 0);
26680
- return text === void 0 ? void 0 : { path: toPath(abs), source: text };
26681
- };
26682
- }
26683
- function reachableFrom(result, start) {
26684
- const reached = /* @__PURE__ */ new Set([start]);
26685
- const queue = [start];
26686
- while (queue.length) {
26687
- const file = queue.shift();
26688
- for (const i of result.imports ?? []) {
26689
- if (i.loc.file === file && i.resolved !== void 0 && !reached.has(i.resolved)) {
26690
- reached.add(i.resolved);
26691
- queue.push(i.resolved);
26692
- }
26693
- }
26694
- }
26695
- return reached;
26696
- }
26697
- function ownDiagnostics(result) {
26698
- const own = result.diagnostics.filter((d) => d.file === void 0);
26699
- for (const i of result.imports ?? []) {
26700
- if (i.loc.file !== void 0 || i.resolved === void 0) continue;
26701
- const files = reachableFrom(result, i.resolved);
26702
- const errors = result.diagnostics.filter((d) => d.severity === "error" && d.file !== void 0 && files.has(d.file)).length;
26703
- if (errors) own.push({ severity: "error", message: `'${(0, import_node_path4.basename)(i.path)}' has ${errors} error${errors === 1 ? "" : "s"}`, ...i.loc });
26704
- }
26705
- return own.sort((a, b) => a.line - b.line || a.col - b.col);
26706
- }
26707
- function importLinks(result) {
26708
- return (result.imports ?? []).filter((i) => i.loc.file === void 0 && i.resolved !== void 0).map((i) => {
26709
- const range = toRange(i.loc);
26710
- return {
26711
- range: { start: { ...range.start, character: range.start.character + 1 }, end: { ...range.end, character: Math.max(range.start.character + 1, range.end.character - 1) } },
26712
- target: i.resolved
26713
- };
26714
- });
28372
+ const before2 = text.slice(0, offset).split("\n");
28373
+ return { line: before2.length - 1, character: before2[before2.length - 1].length };
26715
28374
  }
26716
28375
 
26717
28376
  // src/server.ts
26718
28377
  if (process.argv.includes("--version")) {
26719
- console.log(true ? "0.2.0" : "dev");
28378
+ console.log(true ? "0.3.0" : "dev");
26720
28379
  process.exit(0);
26721
28380
  }
26722
28381
  var connection = (0, import_node.createConnection)(import_node.ProposedFeatures.all);
@@ -26731,7 +28390,7 @@ var resolve3 = fileResolver(openText);
26731
28390
  function analysisOf(doc) {
26732
28391
  const cached = cache2.get(doc.uri);
26733
28392
  if (cached?.version === doc.version) return cached.analysis;
26734
- const analysis = analyze(doc.getText(), { path: pathOf(doc.uri), resolve: resolve3 });
28393
+ const analysis = analyze2(doc.getText(), { path: pathOf(doc.uri), resolve: resolve3 });
26735
28394
  const files = new Set((analysis.result.imports ?? []).flatMap((i) => i.resolved ? [i.resolved] : []));
26736
28395
  cache2.set(doc.uri, { version: doc.version, analysis, files });
26737
28396
  return analysis;
@@ -26757,7 +28416,7 @@ connection.onInitialize(() => ({
26757
28416
  documentFormattingProvider: true,
26758
28417
  codeActionProvider: { codeActionKinds: [import_node.CodeActionKind.QuickFix] }
26759
28418
  },
26760
- serverInfo: { name: "proschi-language-server", version: true ? "0.2.0" : "dev" }
28419
+ serverInfo: { name: "proschi-language-server", version: true ? "0.3.0" : "dev" }
26761
28420
  }));
26762
28421
  function validate(document) {
26763
28422
  const analysis = analysisOf(document);
@@ -26773,10 +28432,19 @@ function validate(document) {
26773
28432
  diagnostics: [
26774
28433
  ...ownDiagnostics(analysis.result).map(toLsp("proschi")),
26775
28434
  // Findings on steps in imported files belong to those files.
26776
- ...openApiFindings(document, analysis.diagram).filter((d) => d.file === void 0).map(toLsp("proschi-openapi"))
28435
+ ...openApiFindings(document, analysis.diagram).filter((d) => d.file === void 0).map(toLsp("proschi-openapi")),
28436
+ ...simulationFindings(analysis.diagram).map(toLsp("proschi-test"))
26777
28437
  ]
26778
28438
  });
26779
28439
  }
28440
+ function simulationFindings(diagram) {
28441
+ try {
28442
+ return testDiagnostics(diagram);
28443
+ } catch (e) {
28444
+ connection.console.error(`Simulation failed: ${String(e)}`);
28445
+ return [];
28446
+ }
28447
+ }
26780
28448
  function openApiFindings(document, diagram) {
26781
28449
  if (!document.uri.startsWith("file:")) return [];
26782
28450
  try {
@@ -26847,7 +28515,18 @@ connection.onReferences((params) => {
26847
28515
  if (!doc) return [];
26848
28516
  return references(analysisOf(doc), params.position).map((range) => ({ uri: params.textDocument.uri, range }));
26849
28517
  });
26850
- var SYMBOL_KIND = { node: import_node.SymbolKind.Object, group: import_node.SymbolKind.Namespace, usecase: import_node.SymbolKind.Function, scenario: import_node.SymbolKind.Event };
28518
+ var SYMBOL_KIND = {
28519
+ node: import_node.SymbolKind.Object,
28520
+ group: import_node.SymbolKind.Namespace,
28521
+ usecase: import_node.SymbolKind.Function,
28522
+ scenario: import_node.SymbolKind.Event,
28523
+ section: import_node.SymbolKind.Module,
28524
+ traffic: import_node.SymbolKind.Property,
28525
+ requirement: import_node.SymbolKind.Constant,
28526
+ entity: import_node.SymbolKind.Struct,
28527
+ decision: import_node.SymbolKind.Key,
28528
+ test: import_node.SymbolKind.Method
28529
+ };
26851
28530
  function toSymbol(s) {
26852
28531
  return { name: s.name, detail: s.detail, kind: SYMBOL_KIND[s.kind], range: s.range, selectionRange: s.range, children: s.children.map(toSymbol) };
26853
28532
  }