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/cli.cjs CHANGED
@@ -2796,11 +2796,11 @@ var require_validate = __commonJS({
2796
2796
  jsonPointer = $data;
2797
2797
  data = names_1.default.rootData;
2798
2798
  } else {
2799
- const matches = RELATIVE_JSON_POINTER.exec($data);
2800
- if (!matches)
2799
+ const matches2 = RELATIVE_JSON_POINTER.exec($data);
2800
+ if (!matches2)
2801
2801
  throw new Error(`Invalid JSON-pointer: ${$data}`);
2802
- const up = +matches[1];
2803
- jsonPointer = matches[2];
2802
+ const up = +matches2[1];
2803
+ jsonPointer = matches2[2];
2804
2804
  if (jsonPointer === "#") {
2805
2805
  if (up >= dataLevel)
2806
2806
  throw new Error(errorMsg("property/index", up));
@@ -3687,11 +3687,11 @@ var require_schemes = __commonJS({
3687
3687
  urnComponent.error = "URN can not be parsed";
3688
3688
  return urnComponent;
3689
3689
  }
3690
- const matches = urnComponent.path.match(URN_REG);
3691
- if (matches && matches[0] === urnComponent.path) {
3690
+ const matches2 = urnComponent.path.match(URN_REG);
3691
+ if (matches2 && matches2[0] === urnComponent.path) {
3692
3692
  const scheme = options.scheme || urnComponent.scheme || "urn";
3693
- urnComponent.nid = matches[1].toLowerCase();
3694
- urnComponent.nss = matches[2];
3693
+ urnComponent.nid = matches2[1].toLowerCase();
3694
+ urnComponent.nss = matches2[2];
3695
3695
  const urnScheme = `${scheme}:${options.nid || urnComponent.nid}`;
3696
3696
  const schemeHandler = getSchemeHandler(urnScheme);
3697
3697
  urnComponent.path = void 0;
@@ -4005,8 +4005,8 @@ var require_fast_uri = __commonJS({
4005
4005
  var URI_PARSE = /^(?:([^#/:?]+):)?(?:\/\/((?:([^#/?@]*)@)?(\[[^#/?\]]+\]|[^#/:?]*)(?::(\d*))?))?([^#?]*)(?:\?([^#]*))?(?:#((?:.|[\n\r])*))?/u;
4006
4006
  var AUTHORITY_PREFIX = /^(?:[^#/:?]+:)?\/\/([^/?#]*)/;
4007
4007
  var AUTHORITY_INTRODUCER_REGION = /^(?:[^#/:?]+:)?([/\\\t\n\r]*)/;
4008
- function getParseError(parsed, matches) {
4009
- if (matches[2] !== void 0 && parsed.path && parsed.path[0] !== "/") {
4008
+ function getParseError(parsed, matches2) {
4009
+ if (matches2[2] !== void 0 && parsed.path && parsed.path[0] !== "/") {
4010
4010
  return 'URI path must start with "/" when authority is present.';
4011
4011
  }
4012
4012
  if (typeof parsed.port === "number" && (parsed.port < 0 || parsed.port > 65535)) {
@@ -4028,9 +4028,9 @@ var require_fast_uri = __commonJS({
4028
4028
  function isIPLiteral(host) {
4029
4029
  return host[0] === "[" && host[host.length - 1] === "]";
4030
4030
  }
4031
- function hasMalformedComponentPercentEncoding(matches) {
4032
- const host = matches[4];
4033
- return hasMalformedPercentEncoding(matches[3]) || host !== void 0 && !isIPLiteral(host) && hasMalformedPercentEncoding(host) || hasMalformedPercentEncoding(matches[6]) || hasMalformedPercentEncoding(matches[7]) || hasMalformedPercentEncoding(matches[8]);
4031
+ function hasMalformedComponentPercentEncoding(matches2) {
4032
+ const host = matches2[4];
4033
+ return hasMalformedPercentEncoding(matches2[3]) || host !== void 0 && !isIPLiteral(host) && hasMalformedPercentEncoding(host) || hasMalformedPercentEncoding(matches2[6]) || hasMalformedPercentEncoding(matches2[7]) || hasMalformedPercentEncoding(matches2[8]);
4034
4034
  }
4035
4035
  function canonicalizeHost(parsed, options, schemeHandler, isIP) {
4036
4036
  if (!options.unicodeSupport && (!schemeHandler || !schemeHandler.unicodeSupport) && parsed.host && !isIPLiteral(parsed.host) && (options.domainHost || schemeHandler && schemeHandler.domainHost) && isIP === false && nonSimpleDomain(parsed.host)) {
@@ -4087,15 +4087,15 @@ var require_fast_uri = __commonJS({
4087
4087
  }
4088
4088
  }
4089
4089
  }
4090
- const matches = uri.match(URI_PARSE);
4091
- if (matches) {
4092
- parsed.scheme = matches[1];
4093
- parsed.userinfo = matches[3];
4094
- parsed.host = matches[4];
4095
- parsed.port = parseInt(matches[5], 10);
4096
- parsed.path = matches[6] || "";
4097
- parsed.query = matches[7];
4098
- parsed.fragment = matches[8];
4090
+ const matches2 = uri.match(URI_PARSE);
4091
+ if (matches2) {
4092
+ parsed.scheme = matches2[1];
4093
+ parsed.userinfo = matches2[3];
4094
+ parsed.host = matches2[4];
4095
+ parsed.port = parseInt(matches2[5], 10);
4096
+ parsed.path = matches2[6] || "";
4097
+ parsed.query = matches2[7];
4098
+ parsed.fragment = matches2[8];
4099
4099
  if (parsed.scheme !== void 0) {
4100
4100
  const decodedScheme = unescape(parsed.scheme);
4101
4101
  if (VALID_SCHEME.test(decodedScheme)) {
@@ -4105,14 +4105,14 @@ var require_fast_uri = __commonJS({
4105
4105
  malformedScheme = true;
4106
4106
  }
4107
4107
  }
4108
- malformedPercentEncoding = hasMalformedComponentPercentEncoding(matches);
4108
+ malformedPercentEncoding = hasMalformedComponentPercentEncoding(matches2);
4109
4109
  if (malformedPercentEncoding) {
4110
4110
  parsed.error = parsed.error || "URI contains malformed percent-encoding.";
4111
4111
  }
4112
4112
  if (isNaN(parsed.port)) {
4113
- parsed.port = matches[5];
4113
+ parsed.port = matches2[5];
4114
4114
  }
4115
- const parseError = getParseError(parsed, matches);
4115
+ const parseError = getParseError(parsed, matches2);
4116
4116
  if (parseError !== void 0) {
4117
4117
  parsed.error = parsed.error || parseError;
4118
4118
  malformedAuthorityOrPort = true;
@@ -4820,13 +4820,13 @@ var require_core = __commonJS({
4820
4820
  RULES.all[keyword] = rule;
4821
4821
  (_a = definition.implements) === null || _a === void 0 ? void 0 : _a.forEach((kwd) => this.addKeyword(kwd));
4822
4822
  }
4823
- function addBeforeRule(ruleGroup, rule, before) {
4824
- const i = ruleGroup.rules.findIndex((_rule) => _rule.keyword === before);
4823
+ function addBeforeRule(ruleGroup, rule, before2) {
4824
+ const i = ruleGroup.rules.findIndex((_rule) => _rule.keyword === before2);
4825
4825
  if (i >= 0) {
4826
4826
  ruleGroup.rules.splice(i, 0, rule);
4827
4827
  } else {
4828
4828
  ruleGroup.rules.push(rule);
4829
- this.logger.warn(`rule ${before} is not defined`);
4829
+ this.logger.warn(`rule ${before2} is not defined`);
4830
4830
  }
4831
4831
  }
4832
4832
  function keywordMetaschema(def) {
@@ -12353,11 +12353,11 @@ var require_compose_scalar = __commonJS({
12353
12353
  var require_util_empty_scalar_position = __commonJS({
12354
12354
  "node_modules/yaml/dist/compose/util-empty-scalar-position.js"(exports2) {
12355
12355
  "use strict";
12356
- function emptyScalarPosition(offset, before, pos) {
12357
- if (before) {
12358
- pos ?? (pos = before.length);
12356
+ function emptyScalarPosition(offset, before2, pos) {
12357
+ if (before2) {
12358
+ pos ?? (pos = before2.length);
12359
12359
  for (let i = pos - 1; i >= 0; --i) {
12360
- let st = before[i];
12360
+ let st = before2[i];
12361
12361
  switch (st.type) {
12362
12362
  case "space":
12363
12363
  case "comment":
@@ -12365,10 +12365,10 @@ var require_util_empty_scalar_position = __commonJS({
12365
12365
  offset -= st.source.length;
12366
12366
  continue;
12367
12367
  }
12368
- st = before[++i];
12368
+ st = before2[++i];
12369
12369
  while (st?.type === "space") {
12370
12370
  offset += st.source.length;
12371
- st = before[++i];
12371
+ st = before2[++i];
12372
12372
  }
12373
12373
  break;
12374
12374
  }
@@ -12446,10 +12446,10 @@ var require_compose_node = __commonJS({
12446
12446
  node.srcToken = token;
12447
12447
  return node;
12448
12448
  }
12449
- function composeEmptyNode(ctx, offset, before, pos, { spaceBefore, comment, anchor, tag, end }, onError) {
12449
+ function composeEmptyNode(ctx, offset, before2, pos, { spaceBefore, comment, anchor, tag, end }, onError) {
12450
12450
  const token = {
12451
12451
  type: "scalar",
12452
- offset: utilEmptyScalarPosition.emptyScalarPosition(offset, before, pos),
12452
+ offset: utilEmptyScalarPosition.emptyScalarPosition(offset, before2, pos),
12453
12453
  indent: -1,
12454
12454
  source: ""
12455
12455
  };
@@ -13464,9 +13464,9 @@ var require_lexer = __commonJS({
13464
13464
  }
13465
13465
  }
13466
13466
  *parseQuotedScalar() {
13467
- const quote = this.charAt(0);
13468
- let end = this.buffer.indexOf(quote, this.pos + 1);
13469
- if (quote === "'") {
13467
+ const quote2 = this.charAt(0);
13468
+ let end = this.buffer.indexOf(quote2, this.pos + 1);
13469
+ if (quote2 === "'") {
13470
13470
  while (end !== -1 && this.buffer[end + 1] === "'")
13471
13471
  end = this.buffer.indexOf("'", end + 2);
13472
13472
  } else {
@@ -14934,8 +14934,8 @@ var OpenApiSpec = class {
14934
14934
  return requestBody && this.validateContent(requestBody.pointer, body);
14935
14935
  }
14936
14936
  validateResponse(op, key, body) {
14937
- const response = this.resolve(`${op.pointer}/responses/${esc(key)}`);
14938
- return response && this.validateContent(response.pointer, body);
14937
+ const response2 = this.resolve(`${op.pointer}/responses/${esc(key)}`);
14938
+ return response2 && this.validateContent(response2.pointer, body);
14939
14939
  }
14940
14940
  /** Validates against the `application/json` (or `+json`) schema of a request body or response object. */
14941
14941
  validateContent(pointer, body) {
@@ -15090,9 +15090,9 @@ function checkStep(spec, step, warn) {
15090
15090
  }
15091
15091
  if (step.failed) return;
15092
15092
  const label = `${op.method} ${op.path}`;
15093
- const request = jsonBody(step.requestFormat, step.requestBody);
15094
- if (request !== void 0) {
15095
- const errors = spec.validateRequest(op, request.value);
15093
+ const request2 = jsonBody(step.requestFormat, step.requestBody);
15094
+ if (request2 !== void 0) {
15095
+ const errors = spec.validateRequest(op, request2.value);
15096
15096
  if (errors?.length) warn(`Request body does not match the schema of ${label}: ${summarize(errors)}`, step.loc);
15097
15097
  }
15098
15098
  if (step.statusCode === void 0) return;
@@ -15103,9 +15103,9 @@ function checkStep(spec, step, warn) {
15103
15103
  warn(`Status ${step.statusCode} is not documented for ${label} (documented: ${documented.join(", ") || "none"})`, responseLoc);
15104
15104
  return;
15105
15105
  }
15106
- const response = jsonBody(step.responseFormat, step.responseBody);
15107
- if (response !== void 0) {
15108
- const errors = spec.validateResponse(op, key, response.value);
15106
+ const response2 = jsonBody(step.responseFormat, step.responseBody);
15107
+ if (response2 !== void 0) {
15108
+ const errors = spec.validateResponse(op, key, response2.value);
15109
15109
  if (errors?.length) warn(`${step.statusCode} response body does not match the schema of ${label}: ${summarize(errors)}`, responseLoc);
15110
15110
  }
15111
15111
  }
@@ -15180,11 +15180,14 @@ function openApiDiagnostics(file, diagram, overrides = {}) {
15180
15180
  }
15181
15181
 
15182
15182
  // src/render/usage.ts
15183
- var RENDER_USAGE = ` proschi render [--out <dir>] [--format svg|md|html] <file>`;
15183
+ var RENDER_USAGE = ` proschi render [--out <dir>] [--format svg|md|html|hld-md|hld-html] <file>`;
15184
15184
  var RENDER_HELP = `render Writes the diagram as static files into --out (default: the current
15185
15185
  directory). svg (default): architecture.svg and <usecase>--<scenario>.svg
15186
15186
  per scenario; md: <file>.md with Mermaid blocks; html: <file>.html with
15187
- the SVGs. Refuses (exit 1) when the document has errors.`;
15187
+ the SVGs; hld-md / hld-html: <file>.hld.md / <file>.hld.html, the
15188
+ high-level design document (overview, requirements, capacity,
15189
+ components, data model, APIs, scenarios, decisions, risks).
15190
+ Refuses (exit 1) when the document has errors.`;
15188
15191
 
15189
15192
  // src/fmt.ts
15190
15193
  var import_node_fs3 = require("node:fs");
@@ -15333,6 +15336,8 @@ var IDENT_START = /[A-Za-z_]/;
15333
15336
  var IDENT_PART = /[A-Za-z0-9_]/;
15334
15337
  var TEAM_PART = /[A-Za-z0-9_-]/;
15335
15338
  var DIGIT = /[0-9]/;
15339
+ var LETTER = /[A-Za-z]/;
15340
+ var SEPARATE_UNIT = /^[ \t]+(rps|rpm|rpd|ms|s|%|usd\/month)(?![A-Za-z0-9_/])/;
15336
15341
  function tokenizeLine(text, line) {
15337
15342
  const tokens = [];
15338
15343
  const diagnostics = [];
@@ -15386,8 +15391,16 @@ function tokenizeLine(text, line) {
15386
15391
  }
15387
15392
  }
15388
15393
  if (DIGIT.test(ch)) {
15389
- while (i < text.length && DIGIT.test(text[i])) i++;
15390
- tokens.push({ kind: "number", value: text.slice(start, i), col: start + 1, length: i - start });
15394
+ i = readQuantity(text, i);
15395
+ const value = text.slice(start, i).replace(/[ \t]+/g, "");
15396
+ const kind = /^\d+$/.test(value) ? "number" : "quantity";
15397
+ tokens.push({ kind, value, col: start + 1, length: i - start });
15398
+ continue;
15399
+ }
15400
+ if (ch === "<" || ch === ">") {
15401
+ const value = text[i + 1] === "=" ? ch + "=" : ch;
15402
+ tokens.push({ kind: "op", value, col: start + 1, length: value.length });
15403
+ i += value.length;
15391
15404
  continue;
15392
15405
  }
15393
15406
  if (ch === "@") {
@@ -15413,6 +15426,26 @@ function tokenizeLine(text, line) {
15413
15426
  }
15414
15427
  return { tokens, diagnostics };
15415
15428
  }
15429
+ function readQuantity(text, start) {
15430
+ let i = start;
15431
+ while (i < text.length && DIGIT.test(text[i])) i++;
15432
+ if (text[i] === "." && DIGIT.test(text[i + 1] ?? "")) {
15433
+ i++;
15434
+ while (i < text.length && DIGIT.test(text[i])) i++;
15435
+ }
15436
+ const suffixStart = i;
15437
+ if (text[i] === "%") return i + 1;
15438
+ while (i < text.length && LETTER.test(text[i])) i++;
15439
+ if (i > suffixStart && text[i] === "/" && LETTER.test(text[i + 1] ?? "")) {
15440
+ i++;
15441
+ while (i < text.length && LETTER.test(text[i])) i++;
15442
+ }
15443
+ if (/^[kmb]?$/.test(text.slice(suffixStart, i))) {
15444
+ const unit = SEPARATE_UNIT.exec(text.slice(i));
15445
+ if (unit) i += unit[0].length;
15446
+ }
15447
+ return i;
15448
+ }
15416
15449
  function readString(text, start) {
15417
15450
  let value = "";
15418
15451
  let i = start + 1;
@@ -15431,15 +15464,15 @@ function readString(text, start) {
15431
15464
  }
15432
15465
  function stripTrailingComment(text) {
15433
15466
  let depth = 0;
15434
- let quote = null;
15467
+ let quote2 = null;
15435
15468
  for (let i = 0; i < text.length; i++) {
15436
15469
  const ch = text[i];
15437
- if (quote) {
15470
+ if (quote2) {
15438
15471
  if (ch === "\\") i++;
15439
- else if (ch === quote) quote = null;
15472
+ else if (ch === quote2) quote2 = null;
15440
15473
  continue;
15441
15474
  }
15442
- if (ch === '"') quote = ch;
15475
+ if (ch === '"') quote2 = ch;
15443
15476
  else if (ch === "{" || ch === "[") depth++;
15444
15477
  else if (ch === "}" || ch === "]") depth = Math.max(0, depth - 1);
15445
15478
  else if (ch === "#" && depth === 0 && (i === 0 || /\s/.test(text[i - 1]))) return text.slice(0, i);
@@ -15495,11 +15528,92 @@ function endpointGroupKey(endpoint, all) {
15495
15528
  return method ? `${method} ${templatize(path)}` : templatize(path);
15496
15529
  }
15497
15530
 
15531
+ // ../frontend/src/dsl/kinds.ts
15532
+ var KINDS = ["client", "edge", "service", "function", "cache", "database", "search", "analytics", "queue", "storage", "external", "other"];
15533
+ var DATA_STORE_KINDS = ["cache", "database", "search", "analytics", "queue", "storage"];
15534
+ var BY_TECH = {
15535
+ "AWS API Gateway": "edge",
15536
+ "Azure API Management": "edge",
15537
+ Redis: "cache",
15538
+ Elasticsearch: "search",
15539
+ "GCP BigQuery": "analytics",
15540
+ InfluxDB: "analytics",
15541
+ TimescaleDB: "analytics",
15542
+ // A note shape annotates the diagram; it is not a client.
15543
+ Note: "other"
15544
+ };
15545
+ var BY_TYPE = {
15546
+ shape: "client",
15547
+ cdn: "edge",
15548
+ service: "service",
15549
+ compute: "service",
15550
+ container: "service",
15551
+ serverless: "function",
15552
+ cache: "cache",
15553
+ database: "database",
15554
+ queue: "queue",
15555
+ storage: "storage",
15556
+ external: "external"
15557
+ };
15558
+ function kindOf(node) {
15559
+ if (node.kind !== "component") return "other";
15560
+ return BY_TECH[node.techStack] ?? BY_TYPE[node.type] ?? "other";
15561
+ }
15562
+ function isKind(word) {
15563
+ return KINDS.includes(word);
15564
+ }
15565
+ function isDataStore(kind) {
15566
+ return DATA_STORE_KINDS.includes(kind);
15567
+ }
15568
+
15569
+ // ../frontend/src/dsl/quantity.ts
15570
+ function parseQuantity(text) {
15571
+ const m = /^(\d+(?:\.\d+)?)(.*)$/.exec(text.replace(/\s+/g, ""));
15572
+ if (!m) return { error: `'${text}' is not a number` };
15573
+ const number = Number(m[1]);
15574
+ const suffix = m[2];
15575
+ const unit = UNITS.get(suffix);
15576
+ if (suffix === "" || unit) return unit ? { value: round(number * unit.factor), unit: unit.unit } : { value: number };
15577
+ const magnitude = MAGNITUDES.get(suffix[0]);
15578
+ const rest = suffix.slice(1);
15579
+ const after = UNITS.get(rest);
15580
+ if (magnitude && (rest === "" || after)) {
15581
+ return after ? { value: round(number * magnitude * after.factor), unit: after.unit } : { value: round(number * magnitude) };
15582
+ }
15583
+ 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)` };
15584
+ }
15585
+ var round = (n) => Number(n.toPrecision(12));
15586
+ var MAGNITUDES = /* @__PURE__ */ new Map([
15587
+ ["k", 1e3],
15588
+ ["m", 1e6],
15589
+ ["b", 1e9]
15590
+ ]);
15591
+ var UNITS = /* @__PURE__ */ new Map([
15592
+ ["rps", { unit: "rps", factor: 1 }],
15593
+ ["rpm", { unit: "rps", factor: 1 / 60 }],
15594
+ ["rpd", { unit: "rps", factor: 1 / 86400 }],
15595
+ ["ms", { unit: "ms", factor: 1 }],
15596
+ ["s", { unit: "ms", factor: 1e3 }],
15597
+ ["%", { unit: "%", factor: 1 }],
15598
+ ["usd/month", { unit: "usd/month", factor: 1 }]
15599
+ ]);
15600
+
15498
15601
  // ../frontend/src/dsl/parser.ts
15499
15602
  var HTTP_METHODS = ["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"];
15500
15603
  var DEFAULT_TECH = "Rectangle";
15501
15604
  var DEFAULT_GROUP_TECH = "Logical Group";
15502
15605
  var MAX_SCENARIOS = 32;
15606
+ var REPLICAS = /^x(\d+)$/;
15607
+ var PERCENTILES = { p50: 50, p90: 90, p95: 95, p99: 99, p999: 99.9 };
15608
+ var FIELD_FLAGS = ["key", "index", "unique", "optional"];
15609
+ var UNIT_EXAMPLES = {
15610
+ rps: "a rate, e.g. 100k rps, 6k rpm or 1m rpd",
15611
+ ms: "a duration, e.g. 50ms or 1.5s",
15612
+ "%": "a percentage, e.g. 99.9%",
15613
+ "usd/month": "a monthly cost, e.g. 400 usd/month"
15614
+ };
15615
+ 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';
15616
+ var ASSERTION_VERBS = "calls, every scenario calls, never calls, writes, responds, has scenario or handles failure of";
15503
15617
  var techByName = new Map(
15504
15618
  componentCatalog.map((c) => [c.techStack.toLowerCase(), { type: c.type, techStack: c.techStack }])
15505
15619
  );
@@ -15527,6 +15641,16 @@ var Parser = class {
15527
15641
  /** `a|b` for every connection, both directions; filled before use cases are built. */
15528
15642
  connected = /* @__PURE__ */ new Set();
15529
15643
  title;
15644
+ summary;
15645
+ traffic = [];
15646
+ requirements = [];
15647
+ capacity = [];
15648
+ entities = [];
15649
+ decisions = [];
15650
+ tests = [];
15651
+ /** Checks of use case, scenario and node names, run once every file is read and use cases are built. */
15652
+ deferred = [];
15653
+ built = [];
15530
15654
  options;
15531
15655
  constructor(source2, options) {
15532
15656
  this.options = options;
@@ -15541,11 +15665,21 @@ var Parser = class {
15541
15665
  const useCases = this.useCases.map(({ useCase, body }) => this.buildUseCase(useCase, body));
15542
15666
  const endpoints = useCases.flatMap((u) => u.endpoint ? [u.endpoint] : []);
15543
15667
  for (const u of useCases) if (u.endpoint) u.endpointGroup = endpointGroupKey(u.endpoint, endpoints);
15668
+ this.built = useCases;
15669
+ for (const check of this.deferred) check();
15544
15670
  const diagram = {
15545
15671
  title: this.title,
15546
15672
  nodes: [...this.nodes.values()],
15547
15673
  edges: this.edges,
15548
- useCases
15674
+ useCases,
15675
+ // The HLD sections are left out when a document has none, so older documents parse exactly as before.
15676
+ ...this.summary !== void 0 ? { summary: this.summary } : {},
15677
+ ...this.traffic.length ? { traffic: this.traffic } : {},
15678
+ ...this.requirements.length ? { requirements: this.requirements } : {},
15679
+ ...this.capacity.length ? { capacity: this.capacity } : {},
15680
+ ...this.entities.length ? { entities: this.entities } : {},
15681
+ ...this.decisions.length ? { decisions: this.decisions } : {},
15682
+ ...this.tests.length ? { tests: this.tests } : {}
15549
15683
  };
15550
15684
  const order = (d) => this.fileOrder.get(d.file) ?? 0;
15551
15685
  this.diagnostics.sort((a, b) => order(a) - order(b) || a.line - b.line || a.col - b.col);
@@ -15557,7 +15691,7 @@ var Parser = class {
15557
15691
  i = this.parseLine(i);
15558
15692
  }
15559
15693
  for (const frame of this.stack) {
15560
- const what = frame.kind === "group" ? `group '${frame.id}'` : frame.kind === "usecase" ? `usecase '${frame.useCase.name}'` : frame.kind === "alt" ? `alt '${frame.name}'` : "par block";
15694
+ 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);
15561
15695
  this.error(`Missing } to close ${what}`, frame.loc);
15562
15696
  }
15563
15697
  }
@@ -15567,8 +15701,19 @@ var Parser = class {
15567
15701
  const { tokens, diagnostics } = tokenizeLine(this.lines[index], line);
15568
15702
  this.diagnostics.push(...diagnostics.map((d) => ({ ...d, ...this.fileField() })));
15569
15703
  if (tokens.length === 0 || diagnostics.some((d) => d.severity === "error")) return index;
15570
- const [first, second] = tokens;
15704
+ const [first, second, third] = tokens;
15571
15705
  const loc = (t) => this.locOf(t, line);
15706
+ const top = this.top();
15707
+ if (top?.kind === "section") {
15708
+ if (first.kind === "rbrace") {
15709
+ this.stack.pop();
15710
+ if (!top.discard) this.closeSection(top);
15711
+ if (second) this.error("Unexpected input after }", loc(second));
15712
+ } else if (!top.discard) {
15713
+ this.parseSectionLine(top, tokens, line);
15714
+ }
15715
+ return index;
15716
+ }
15572
15717
  if (first.kind === "rbrace") {
15573
15718
  if (this.stack.length === 0) this.error("Unmatched }", loc(first));
15574
15719
  else this.closeFrame();
@@ -15602,6 +15747,26 @@ var Parser = class {
15602
15747
  case "import":
15603
15748
  this.parseImport(tokens, line);
15604
15749
  break;
15750
+ // These words are keywords only in the shape of their statement, so older documents may still use them as ids.
15751
+ case "traffic":
15752
+ case "requirements":
15753
+ case "capacity":
15754
+ if (second?.kind === "lbrace") this.openSection(first.value, tokens, line);
15755
+ else this.parseNode(tokens, line);
15756
+ break;
15757
+ case "entity":
15758
+ if (second?.kind === "ident" && second.value !== "pos" && !REPLICAS.test(second.value)) this.parseEntity(tokens, line);
15759
+ else this.parseNode(tokens, line);
15760
+ break;
15761
+ case "decision":
15762
+ if (second?.kind === "string" && (third?.kind === "lbrace" || third?.kind === "ident" && third.value !== "pos" && !REPLICAS.test(third.value)))
15763
+ this.parseDecision(tokens, line);
15764
+ else this.parseNode(tokens, line);
15765
+ break;
15766
+ case "test":
15767
+ if (second?.kind === "string" && tokens[tokens.length - 1].kind === "lbrace") this.parseTest(tokens, line);
15768
+ else this.parseNode(tokens, line);
15769
+ break;
15605
15770
  default:
15606
15771
  this.parseNode(tokens, line);
15607
15772
  }
@@ -15613,8 +15778,12 @@ var Parser = class {
15613
15778
  this.error('Expected a title, e.g. title "My System"', this.locOf(tokens[0], line));
15614
15779
  return;
15615
15780
  }
15616
- if (this.file === void 0) this.title = value.value;
15617
- this.expectEnd(tokens, 2, line);
15781
+ const summary = tokens[2]?.kind === "string" ? tokens[2] : void 0;
15782
+ if (this.file === void 0) {
15783
+ this.title = value.value;
15784
+ if (summary) this.summary = summary.value;
15785
+ }
15786
+ this.expectEnd(tokens, summary ? 3 : 2, line);
15618
15787
  }
15619
15788
  parseImport(tokens, line) {
15620
15789
  const keyword = tokens[0];
@@ -15823,6 +15992,13 @@ var Parser = class {
15823
15992
  }
15824
15993
  node.position = { x: Number(x.value), y: Number(y.value) };
15825
15994
  i += 3;
15995
+ } else if (t.kind === "ident" && REPLICAS.test(t.value)) {
15996
+ const replicas = Number(REPLICAS.exec(t.value)[1]);
15997
+ if (replicas < 1) {
15998
+ this.error("A node needs at least one replica: x1, x2, \u2026", this.locOf(t, line));
15999
+ return;
16000
+ }
16001
+ node.replicas = replicas;
15826
16002
  } else if (t.kind === "label" || t.kind === "arrow") {
15827
16003
  this.error(`Expected '${idToken.value} -> target' for a connection`, this.locOf(t, line));
15828
16004
  return;
@@ -15878,6 +16054,592 @@ var Parser = class {
15878
16054
  }
15879
16055
  return lastLine;
15880
16056
  }
16057
+ // ---- HLD sections: traffic, requirements, capacity, entity, decision, test (docs/design/hld-and-practice.md §1) ----
16058
+ /**
16059
+ * Starts a section block. Sections are top level only; a misplaced one, or
16060
+ * one whose header is broken, still opens a block so that its lines and its
16061
+ * `}` are not mistaken for nodes and the end of an enclosing block.
16062
+ */
16063
+ openSection(section, tokens, line, fill = {}) {
16064
+ const keyword = tokens[0];
16065
+ const loc = this.locOf(keyword, line);
16066
+ if (this.stack.length > 0) {
16067
+ this.error(`${section} is only allowed at the top level`, loc);
16068
+ this.skipSection(section, tokens, line);
16069
+ return false;
16070
+ }
16071
+ if (section === "traffic" || section === "requirements" || section === "capacity") this.expectEnd(tokens, 2, line);
16072
+ this.stack.push({ kind: "section", section, ...fill, loc });
16073
+ return true;
16074
+ }
16075
+ /** After an error in a section header: skips the block, if the line opens one. */
16076
+ skipSection(section, tokens, line) {
16077
+ if (tokens[tokens.length - 1].kind === "lbrace") this.stack.push({ kind: "section", section, discard: true, loc: this.locOf(tokens[0], line) });
16078
+ }
16079
+ closeSection(frame) {
16080
+ if (frame.test && frame.test.assertions.length === 0) this.warning(`Test '${frame.test.name}' has no assertions`, frame.test.loc);
16081
+ }
16082
+ parseSectionLine(frame, tokens, line) {
16083
+ switch (frame.section) {
16084
+ case "traffic":
16085
+ return this.parseTrafficLine(tokens, line);
16086
+ case "requirements":
16087
+ return this.parseRequirement(tokens, line);
16088
+ case "capacity":
16089
+ return this.parseCapacityLine(tokens, line);
16090
+ case "entity":
16091
+ return this.parseField(frame.entity, tokens, line);
16092
+ case "decision":
16093
+ return this.parseDecisionLine(frame.decision, tokens, line);
16094
+ case "test": {
16095
+ const assertion2 = this.parseAssertion(tokens, line);
16096
+ if (assertion2) frame.test.assertions.push(assertion2);
16097
+ return;
16098
+ }
16099
+ }
16100
+ }
16101
+ /** `"Redirect" 100k rps mix "Cache hit" 90%, "Cache miss" 10%` */
16102
+ parseTrafficLine(tokens, line) {
16103
+ const [name, rate] = tokens;
16104
+ if (name.kind !== "string") {
16105
+ this.error('Expected a use case name in quotes and a rate, e.g. "Redirect" 100k rps', this.locOf(name, line));
16106
+ return;
16107
+ }
16108
+ const rps = this.quantityOf(rate, "rps", name, line);
16109
+ if (rps === void 0) return;
16110
+ const entry = { useCase: name.value, rps, loc: this.lineLoc(tokens, line) };
16111
+ const shares = [];
16112
+ let i = 2;
16113
+ if (tokens[i]) {
16114
+ const mix = tokens[i++];
16115
+ if (!isWord(mix, "mix")) {
16116
+ this.error(`Unexpected ${describe(mix)}; expected mix "Scenario" 90%, "Other" 10%`, this.locOf(mix, line));
16117
+ return;
16118
+ }
16119
+ for (; ; ) {
16120
+ const scenario = tokens[i];
16121
+ if (scenario?.kind !== "string") {
16122
+ this.error('Expected a scenario name in quotes and its share, e.g. "Cache hit" 90%', this.locOf(scenario ?? tokens[i - 1], line));
16123
+ return;
16124
+ }
16125
+ const share = this.quantityOf(tokens[i + 1], "%", scenario, line);
16126
+ if (share === void 0) return;
16127
+ const loc = this.locOf(scenario, line);
16128
+ if (shares.some((s) => s.scenario === scenario.value)) {
16129
+ this.error(`Scenario '${scenario.value}' is in the mix twice`, loc);
16130
+ return;
16131
+ }
16132
+ shares.push({ scenario: scenario.value, share, loc });
16133
+ i += 2;
16134
+ if (!tokens[i]) break;
16135
+ if (tokens[i].kind !== "comma") {
16136
+ this.error(`Expected , between mix shares, not ${describe(tokens[i])}`, this.locOf(tokens[i], line));
16137
+ return;
16138
+ }
16139
+ i++;
16140
+ }
16141
+ const total = shares.reduce((sum, s) => sum + s.share, 0);
16142
+ if (total <= 0) {
16143
+ this.error("Mix shares must add up to more than 0%", this.locOf(mix, line));
16144
+ return;
16145
+ }
16146
+ if (Math.abs(total - 100) > 1e-9) {
16147
+ this.warning(`Mix shares add up to ${Number(total.toFixed(3))}%, not 100%; they are scaled to fit`, this.locOf(mix, line));
16148
+ }
16149
+ entry.mix = shares.map((s) => ({ scenario: s.scenario, share: s.share / total }));
16150
+ }
16151
+ const nameLoc = this.locOf(name, line);
16152
+ const first = this.traffic.find((t) => t.useCase === entry.useCase);
16153
+ if (first) {
16154
+ this.error(`Duplicate traffic for '${entry.useCase}' (first ${this.where(first.loc, nameLoc)})`, nameLoc);
16155
+ return;
16156
+ }
16157
+ this.traffic.push(entry);
16158
+ this.deferred.push(() => {
16159
+ const useCase = this.checkUseCase(entry.useCase, nameLoc);
16160
+ if (useCase) for (const s of shares) this.checkScenario(useCase, s.scenario, s.loc);
16161
+ });
16162
+ }
16163
+ /** One line of `requirements { … }`. */
16164
+ parseRequirement(tokens, line) {
16165
+ const [first] = tokens;
16166
+ const loc = this.lineLoc(tokens, line);
16167
+ const help = 'Expected a requirement: p99 "Use case" < 50ms, availability >= 99.9%, durable "Use case", survive any node failure, cost <= 3000 usd/month';
16168
+ if (first.kind !== "ident") {
16169
+ this.error(help, this.locOf(first, line));
16170
+ return;
16171
+ }
16172
+ const word = first.value;
16173
+ let i = 1;
16174
+ let useCase;
16175
+ const readUseCase = () => {
16176
+ if (tokens[i]?.kind !== "string") return;
16177
+ useCase = { name: tokens[i].value, loc: this.locOf(tokens[i], line) };
16178
+ i++;
16179
+ };
16180
+ const commit = (requirement2) => {
16181
+ this.requirements.push(requirement2);
16182
+ const named = useCase;
16183
+ if (named) this.deferred.push(() => this.checkUseCase(named.name, named.loc));
16184
+ };
16185
+ const withUseCase = () => useCase ? { useCase: useCase.name } : {};
16186
+ if (/^p\d+$/.test(word)) {
16187
+ const percentile = PERCENTILES[word];
16188
+ if (percentile === void 0) {
16189
+ this.error(`Unknown percentile '${word}'; use p50, p90, p95, p99 or p999`, this.locOf(first, line));
16190
+ return;
16191
+ }
16192
+ readUseCase();
16193
+ if (!this.expectOp(tokens, i, ["<", "<="], `${word} "Use case" < 50ms`, line)) return;
16194
+ const maxMs = this.quantityOf(tokens[i + 1], "ms", tokens[i], line);
16195
+ if (maxMs === void 0 || !this.expectEnd(tokens, i + 2, line)) return;
16196
+ commit({ kind: "latency", percentile, ...withUseCase(), maxMs, loc });
16197
+ return;
16198
+ }
16199
+ switch (word) {
16200
+ case "availability": {
16201
+ readUseCase();
16202
+ if (!this.expectOp(tokens, i, [">=", ">"], 'availability "Use case" >= 99.9%', line)) return;
16203
+ const minPercent = this.quantityOf(tokens[i + 1], "%", tokens[i], line);
16204
+ if (minPercent === void 0) return;
16205
+ if (minPercent > 100) {
16206
+ this.error("Availability cannot be more than 100%", this.locOf(tokens[i + 1], line));
16207
+ return;
16208
+ }
16209
+ if (!this.expectEnd(tokens, i + 2, line)) return;
16210
+ commit({ kind: "availability", ...withUseCase(), minPercent, loc });
16211
+ return;
16212
+ }
16213
+ case "durable":
16214
+ readUseCase();
16215
+ if (!useCase) {
16216
+ this.error('Expected a use case name in quotes, e.g. durable "Shorten"', this.locOf(tokens[1] ?? first, line));
16217
+ return;
16218
+ }
16219
+ if (!this.expectEnd(tokens, i, line)) return;
16220
+ commit({ kind: "durable", useCase: useCase.name, loc });
16221
+ return;
16222
+ case "survive": {
16223
+ if (isWord(tokens[1], "any") && isWord(tokens[2], "node") && isWord(tokens[3], "failure")) {
16224
+ if (this.expectEnd(tokens, 4, line)) commit({ kind: "survive", target: "any", loc });
16225
+ return;
16226
+ }
16227
+ if (!isWord(tokens[1], "failure") || !isWord(tokens[2], "of")) {
16228
+ this.error("Expected survive any node failure, or survive failure of <node | [Tech] | any kind>", this.locOf(tokens[1] ?? first, line));
16229
+ return;
16230
+ }
16231
+ const target = this.parseSelector(tokens, 3, line);
16232
+ if (!target || !this.expectEnd(tokens, target.next, line)) return;
16233
+ commit({ kind: "survive", target: target.selector, loc });
16234
+ this.checkSelectorLater(target);
16235
+ return;
16236
+ }
16237
+ case "cost": {
16238
+ if (!this.expectOp(tokens, 1, ["<=", "<"], "cost <= 3000 usd/month", line)) return;
16239
+ const maxUsdPerMonth = this.quantityOf(tokens[2], "usd/month", tokens[1], line);
16240
+ if (maxUsdPerMonth === void 0 || !this.expectEnd(tokens, 3, line)) return;
16241
+ commit({ kind: "cost", maxUsdPerMonth, loc });
16242
+ return;
16243
+ }
16244
+ default:
16245
+ this.error(`Unknown requirement '${word}'. ${help}`, this.locOf(first, line));
16246
+ }
16247
+ }
16248
+ /** `db 20k rps latency 4ms availability 99.95% cost 400 usd/month durable`, parts in any order. */
16249
+ parseCapacityLine(tokens, line) {
16250
+ const [id] = tokens;
16251
+ if (id.kind !== "ident") {
16252
+ this.error("Expected a node id and its overrides, e.g. db 20k rps latency 4ms", this.locOf(id, line));
16253
+ return;
16254
+ }
16255
+ const override = { node: id.value, loc: this.lineLoc(tokens, line) };
16256
+ const seen = /* @__PURE__ */ new Set();
16257
+ for (let i = 1; i < tokens.length; ) {
16258
+ const t = tokens[i];
16259
+ let part;
16260
+ if (t.kind === "quantity" || t.kind === "number") {
16261
+ part = "rate";
16262
+ const rps = this.quantityOf(t, "rps", t, line);
16263
+ if (rps === void 0) return;
16264
+ override.rps = rps;
16265
+ i++;
16266
+ } else if (isWord(t, "latency") || isWord(t, "availability") || isWord(t, "cost")) {
16267
+ part = t.value;
16268
+ const unit = part === "latency" ? "ms" : part === "availability" ? "%" : "usd/month";
16269
+ const value = this.quantityOf(tokens[i + 1], unit, t, line);
16270
+ if (value === void 0) return;
16271
+ if (part === "latency") override.latencyMs = value;
16272
+ else if (part === "availability") override.availability = value;
16273
+ else override.costUsd = value;
16274
+ i += 2;
16275
+ } else if (isWord(t, "durable") || isWord(t, "volatile")) {
16276
+ part = "durable or volatile";
16277
+ override.durable = t.value === "durable";
16278
+ i++;
16279
+ } else {
16280
+ this.error(`Unexpected ${describe(t)}; expected a rate, latency, availability, cost, durable or volatile`, this.locOf(t, line));
16281
+ return;
16282
+ }
16283
+ if (seen.has(part)) {
16284
+ this.error(`${part[0].toUpperCase()}${part.slice(1)} is given twice for '${id.value}'`, this.locOf(t, line));
16285
+ return;
16286
+ }
16287
+ seen.add(part);
16288
+ }
16289
+ const idLoc = this.locOf(id, line);
16290
+ if (seen.size === 0) {
16291
+ this.warning(`Capacity line for '${id.value}' overrides nothing; add a rate, latency, availability, cost, durable or volatile`, idLoc);
16292
+ return;
16293
+ }
16294
+ const first = this.capacity.find((c) => c.node === id.value);
16295
+ if (first) {
16296
+ this.error(`Duplicate capacity for '${id.value}' (first ${this.where(first.loc, idLoc)})`, idLoc);
16297
+ return;
16298
+ }
16299
+ this.capacity.push(override);
16300
+ this.deferred.push(() => {
16301
+ if (!this.nodes.has(id.value)) this.warning(`Unknown node '${id.value}' in capacity`, idLoc);
16302
+ });
16303
+ }
16304
+ /** `entity Url [in db] ["description"] {` */
16305
+ parseEntity(tokens, line) {
16306
+ const name = tokens[1];
16307
+ let i = 2;
16308
+ let store;
16309
+ if (isWord(tokens[i], "in")) {
16310
+ const id = tokens[i + 1];
16311
+ if (id?.kind !== "ident") {
16312
+ 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));
16313
+ this.skipSection("entity", tokens, line);
16314
+ return;
16315
+ }
16316
+ store = { id: id.value, loc: this.locOf(id, line) };
16317
+ i += 2;
16318
+ }
16319
+ const description = tokens[i]?.kind === "string" ? tokens[i++].value : void 0;
16320
+ if (tokens[i]?.kind !== "lbrace") {
16321
+ this.error('Expected { after the entity header, e.g. entity Url in db "Short codes" {', this.locOf(tokens[i] ?? tokens[i - 1], line));
16322
+ this.skipSection("entity", tokens, line);
16323
+ return;
16324
+ }
16325
+ this.expectEnd(tokens, i + 1, line);
16326
+ const loc = this.locOf(name, line);
16327
+ const entity = { name: name.value, ...store ? { store: store.id } : {}, ...description !== void 0 ? { description } : {}, fields: [], loc };
16328
+ const first = this.entities.find((e) => e.name === entity.name);
16329
+ if (first) {
16330
+ this.error(`Duplicate entity '${entity.name}' (first ${this.where(first.loc, loc)})`, loc);
16331
+ this.skipSection("entity", tokens, line);
16332
+ return;
16333
+ }
16334
+ if (!this.openSection("entity", tokens, line, { entity })) return;
16335
+ this.entities.push(entity);
16336
+ if (store) {
16337
+ const { id, loc: storeLoc } = store;
16338
+ this.deferred.push(() => {
16339
+ const node = this.nodes.get(id);
16340
+ if (!node) this.warning(`Unknown node '${id}'`, storeLoc);
16341
+ else if (!isDataStore(kindOf(node)))
16342
+ this.warning(`'${id}' is not a data store (${DATA_STORE_KINDS.join(", ")}); entity '${entity.name}' is placed in a ${kindOf(node)}`, storeLoc);
16343
+ });
16344
+ }
16345
+ }
16346
+ /** `code string key`: a field name, a type and flags. */
16347
+ parseField(entity, tokens, line) {
16348
+ const [name, type] = tokens;
16349
+ if (name.kind !== "ident") {
16350
+ this.error("Expected a field: name type [key] [index] [unique] [optional], e.g. code string key", this.locOf(name, line));
16351
+ return;
16352
+ }
16353
+ if (type?.kind !== "ident") {
16354
+ this.error(`Expected a type after '${name.value}', e.g. ${name.value} string`, this.locOf(type ?? name, line));
16355
+ return;
16356
+ }
16357
+ const flags = [];
16358
+ for (const t of tokens.slice(2)) {
16359
+ if (t.kind !== "ident" || !FIELD_FLAGS.includes(t.value)) {
16360
+ this.error(`Unknown field flag ${describe(t)}; use key, index, unique or optional`, this.locOf(t, line));
16361
+ return;
16362
+ }
16363
+ if (flags.includes(t.value)) {
16364
+ this.error(`Flag '${t.value}' is given twice`, this.locOf(t, line));
16365
+ return;
16366
+ }
16367
+ flags.push(t.value);
16368
+ }
16369
+ if (entity.fields.some((f) => f.name === name.value)) {
16370
+ this.error(`Duplicate field '${name.value}' in entity '${entity.name}'`, this.locOf(name, line));
16371
+ return;
16372
+ }
16373
+ entity.fields.push({ name: name.value, type: type.value, flags });
16374
+ }
16375
+ /** `decision "Title" because "Reason"`, or `decision "Title" {` with because / rejected lines. */
16376
+ parseDecision(tokens, line) {
16377
+ const title = tokens[1];
16378
+ const decision = { title: title.value, rejected: [], loc: this.locOf(title, line) };
16379
+ const third = tokens[2];
16380
+ if (third.kind === "lbrace") {
16381
+ this.expectEnd(tokens, 3, line);
16382
+ if (this.openSection("decision", tokens, line, { decision })) this.decisions.push(decision);
16383
+ return;
16384
+ }
16385
+ if (!isWord(third, "because")) {
16386
+ this.error(`Expected because "reason" or { after the decision title, not ${describe(third)}`, this.locOf(third, line));
16387
+ this.skipSection("decision", tokens, line);
16388
+ return;
16389
+ }
16390
+ if (this.stack.length > 0) {
16391
+ this.error("decision is only allowed at the top level", this.locOf(tokens[0], line));
16392
+ return;
16393
+ }
16394
+ const reason = tokens[3];
16395
+ if (reason?.kind !== "string") {
16396
+ this.error("Expected the reason in quotes after because", this.locOf(reason ?? third, line));
16397
+ return;
16398
+ }
16399
+ if (!this.expectEnd(tokens, 4, line)) return;
16400
+ decision.because = reason.value;
16401
+ this.decisions.push(decision);
16402
+ }
16403
+ parseDecisionLine(decision, tokens, line) {
16404
+ const [word] = tokens;
16405
+ if (isWord(word, "because")) {
16406
+ if (tokens[1]?.kind !== "string") {
16407
+ this.error("Expected the reason in quotes after because", this.locOf(tokens[1] ?? word, line));
16408
+ return;
16409
+ }
16410
+ if (decision.because !== void 0) {
16411
+ this.error('A decision has one because; list the other options with rejected "option" "reason"', this.locOf(word, line));
16412
+ return;
16413
+ }
16414
+ if (this.expectEnd(tokens, 2, line)) decision.because = tokens[1].value;
16415
+ return;
16416
+ }
16417
+ if (isWord(word, "rejected")) {
16418
+ const [, option, reason] = tokens;
16419
+ if (option?.kind !== "string" || reason?.kind !== "string") {
16420
+ const bad = option?.kind !== "string" ? option : reason;
16421
+ this.error('Expected rejected "option" "reason"', this.locOf(bad ?? tokens[tokens.length - 1], line));
16422
+ return;
16423
+ }
16424
+ if (this.expectEnd(tokens, 3, line)) decision.rejected.push({ option: option.value, reason: reason.value });
16425
+ return;
16426
+ }
16427
+ this.error('Expected because "reason" or rejected "option" "reason"', this.locOf(word, line));
16428
+ }
16429
+ /** `test "Name" {` */
16430
+ parseTest(tokens, line) {
16431
+ const name = tokens[1];
16432
+ const loc = this.locOf(name, line);
16433
+ if (tokens[2].kind !== "lbrace") {
16434
+ this.error(`Expected { after the test name, not ${describe(tokens[2])}`, this.locOf(tokens[2], line));
16435
+ this.skipSection("test", tokens, line);
16436
+ return;
16437
+ }
16438
+ this.expectEnd(tokens, 3, line);
16439
+ const first = this.tests.find((t) => t.name === name.value);
16440
+ if (first) {
16441
+ this.error(`Duplicate test '${name.value}' (first ${this.where(first.loc, loc)})`, loc);
16442
+ this.skipSection("test", tokens, line);
16443
+ return;
16444
+ }
16445
+ const test = { name: name.value, assertions: [], loc };
16446
+ if (this.openSection("test", tokens, line, { test })) this.tests.push(test);
16447
+ }
16448
+ /** One assertion line of a test (docs/design/hld-and-practice.md §1.9). */
16449
+ parseAssertion(tokens, line) {
16450
+ const loc = this.lineLoc(tokens, line);
16451
+ const [first] = tokens;
16452
+ const selectors = [];
16453
+ const selector = (i2) => {
16454
+ const s = this.parseSelector(tokens, i2, line);
16455
+ if (s) selectors.push(s);
16456
+ return s;
16457
+ };
16458
+ const done = (assertion3, end) => {
16459
+ if (!this.expectEnd(tokens, end, line)) return void 0;
16460
+ selectors.forEach((s) => this.checkSelectorLater(s));
16461
+ return assertion3;
16462
+ };
16463
+ const expectWords = (i2, words, example) => {
16464
+ for (const [k, w] of words.entries()) {
16465
+ if (!isWord(tokens[i2 + k], w)) {
16466
+ this.error(`Expected '${w}' here, e.g. ${example}`, this.locOf(tokens[i2 + k] ?? tokens[tokens.length - 1], line));
16467
+ return false;
16468
+ }
16469
+ }
16470
+ return true;
16471
+ };
16472
+ if (isWord(first, "no") && isWord(tokens[1], "path")) {
16473
+ const example = "no path from client to any database";
16474
+ if (!expectWords(2, ["from"], example)) return;
16475
+ const from = selector(3);
16476
+ if (!from || !expectWords(from.next, ["to"], example)) return;
16477
+ const to = selector(from.next + 1);
16478
+ if (!to) return;
16479
+ return done({ kind: "noPath", from: from.selector, to: to.selector, loc }, to.next);
16480
+ }
16481
+ if (first.kind !== "string") {
16482
+ if (first.kind !== "ident" && first.kind !== "tech") {
16483
+ this.error(ASSERTION_HELP, this.locOf(first, line));
16484
+ return;
16485
+ }
16486
+ const target = selector(0);
16487
+ if (!target) return;
16488
+ if (!isWord(tokens[target.next], "has")) {
16489
+ this.error(ASSERTION_HELP, this.locOf(tokens[target.next] ?? first, line));
16490
+ return;
16491
+ }
16492
+ const i2 = target.next + 1;
16493
+ const example = "api has replicas >= 2";
16494
+ if (!expectWords(i2, ["replicas"], example) || !this.expectOp(tokens, i2 + 1, [">="], example, line)) return;
16495
+ const n = tokens[i2 + 2];
16496
+ if (n?.kind !== "number" || Number(n.value) < 1) {
16497
+ this.error("Expected a whole number of replicas, at least 1", this.locOf(n ?? tokens[i2 + 1], line));
16498
+ return;
16499
+ }
16500
+ return done({ kind: "replicas", target: target.selector, min: Number(n.value), loc }, i2 + 3);
16501
+ }
16502
+ const useCase = first.value;
16503
+ const useCaseLoc = this.locOf(first, line);
16504
+ let i = 1;
16505
+ let scenario;
16506
+ if (isWord(tokens[i], "scenario")) {
16507
+ const s = tokens[i + 1];
16508
+ if (s?.kind !== "string") {
16509
+ this.error("Expected a scenario name in quotes after scenario", this.locOf(s ?? tokens[i], line));
16510
+ return;
16511
+ }
16512
+ scenario = { name: s.value, loc: this.locOf(s, line) };
16513
+ i += 2;
16514
+ }
16515
+ const withScenario = scenario ? { scenario: scenario.name } : {};
16516
+ const verb = tokens[i];
16517
+ const wholeUseCase = (what) => {
16518
+ if (!scenario) return true;
16519
+ this.error(`${what} is about the whole use case; leave out scenario "\u2026"`, scenario.loc);
16520
+ return false;
16521
+ };
16522
+ let assertion2;
16523
+ if (isWord(verb, "calls")) {
16524
+ const target = selector(i + 1);
16525
+ if (!target) return;
16526
+ if (isWord(tokens[target.next], "before")) {
16527
+ const then = selector(target.next + 1);
16528
+ if (!then) return;
16529
+ assertion2 = done({ kind: "before", useCase, ...withScenario, first: target.selector, then: then.selector, loc }, then.next);
16530
+ } else {
16531
+ assertion2 = done({ kind: "calls", useCase, ...withScenario, target: target.selector, quantifier: "some", loc }, target.next);
16532
+ }
16533
+ } else if (isWord(verb, "every")) {
16534
+ if (!expectWords(i + 1, ["scenario", "calls"], `"${useCase}" every scenario calls any cache`)) return;
16535
+ const target = selector(i + 3);
16536
+ if (!target) return;
16537
+ assertion2 = done({ kind: "calls", useCase, ...withScenario, target: target.selector, quantifier: "every", loc }, target.next);
16538
+ } else if (isWord(verb, "never")) {
16539
+ if (!expectWords(i + 1, ["calls"], `"${useCase}" never calls any database`)) return;
16540
+ const target = selector(i + 2);
16541
+ if (!target) return;
16542
+ assertion2 = done({ kind: "calls", useCase, ...withScenario, target: target.selector, quantifier: "never", loc }, target.next);
16543
+ } else if (isWord(verb, "writes")) {
16544
+ const target = selector(i + 1);
16545
+ if (!target || !expectWords(target.next, ["before", "responding"], `"${useCase}" writes db before responding`)) return;
16546
+ assertion2 = done({ kind: "writesBeforeResponding", useCase, ...withScenario, target: target.selector, loc }, target.next + 2);
16547
+ } else if (isWord(verb, "responds")) {
16548
+ const status = tokens[i + 1];
16549
+ if (!status || status.kind !== "number" && status.kind !== "quantity" || !/^[1-5](\d\d|xx)$/.test(status.value)) {
16550
+ this.error("Expected a status code or class after responds, e.g. 201 or 4xx", this.locOf(status ?? verb, line));
16551
+ return;
16552
+ }
16553
+ assertion2 = done({ kind: "responds", useCase, ...withScenario, status: status.value, loc }, i + 2);
16554
+ } else if (isWord(verb, "has")) {
16555
+ if (!wholeUseCase("has scenario") || !expectWords(i + 1, ["scenario"], `"${useCase}" has scenario "Not found"`)) return;
16556
+ const name = tokens[i + 2];
16557
+ if (name?.kind !== "string") {
16558
+ this.error("Expected a scenario name in quotes after has scenario", this.locOf(name ?? tokens[i + 1], line));
16559
+ return;
16560
+ }
16561
+ assertion2 = done({ kind: "hasScenario", useCase, scenario: name.value, loc }, i + 3);
16562
+ } else if (isWord(verb, "handles")) {
16563
+ if (!wholeUseCase("handles failure") || !expectWords(i + 1, ["failure", "of"], `"${useCase}" handles failure of cache`)) return;
16564
+ const target = selector(i + 3);
16565
+ if (!target) return;
16566
+ assertion2 = done({ kind: "handlesFailure", useCase, target: target.selector, loc }, target.next);
16567
+ } else {
16568
+ this.error(`Expected ${ASSERTION_VERBS} after "${useCase}"`, this.locOf(verb ?? first, line));
16569
+ return;
16570
+ }
16571
+ if (assertion2) {
16572
+ this.deferred.push(() => {
16573
+ const found = this.checkUseCase(useCase, useCaseLoc);
16574
+ if (found && scenario) this.checkScenario(found, scenario.name, scenario.loc);
16575
+ });
16576
+ }
16577
+ return assertion2;
16578
+ }
16579
+ /** A node id, `[Tech]` or `any <kind>` starting at `tokens[i]`. */
16580
+ parseSelector(tokens, i, line) {
16581
+ const t = tokens[i];
16582
+ if (t?.kind === "tech") {
16583
+ const tech = techByName.get(t.value.toLowerCase())?.techStack ?? t.value;
16584
+ return { selector: { tech }, next: i + 1, token: t, loc: this.locOf(t, line) };
16585
+ }
16586
+ if (isWord(t, "any")) {
16587
+ const kind = tokens[i + 1];
16588
+ if (kind?.kind !== "ident" || !isKind(kind.value)) {
16589
+ this.error(`Expected a kind after any: ${KINDS.join(", ")}`, this.locOf(kind ?? t, line));
16590
+ return void 0;
16591
+ }
16592
+ return { selector: { kind: kind.value }, next: i + 2, token: t, loc: this.locOf(t, line) };
16593
+ }
16594
+ if (t?.kind === "ident") return { selector: { node: t.value }, next: i + 1, token: t, loc: this.locOf(t, line) };
16595
+ this.error(`Expected a node id, [Tech] or any <kind>${t ? `, not ${describe(t)}` : ""}`, this.locOf(t ?? tokens[tokens.length - 1], line));
16596
+ return void 0;
16597
+ }
16598
+ /** Warns about a selector naming a tech stack that does not exist, and (once every file is read) a node that does not. */
16599
+ checkSelectorLater({ selector, token, loc }) {
16600
+ if ("tech" in selector && !techByName.has(token.value.toLowerCase())) this.warning(`Unknown tech stack '${token.value}'`, loc);
16601
+ if ("node" in selector) {
16602
+ this.deferred.push(() => {
16603
+ if (!this.nodes.has(selector.node)) this.warning(`Unknown node '${selector.node}'`, loc);
16604
+ });
16605
+ }
16606
+ }
16607
+ checkUseCase(name, loc) {
16608
+ const useCase = this.built.find((u) => u.name === name);
16609
+ if (!useCase) this.warning(`Unknown use case '${name}'`, loc);
16610
+ return useCase;
16611
+ }
16612
+ checkScenario(useCase, name, loc) {
16613
+ if (useCase.scenarios.some((s) => s.name === name)) return;
16614
+ const names = useCase.scenarios.map((s) => `'${s.name}'`).join(", ");
16615
+ this.warning(`Use case '${useCase.name}' has no scenario '${name}'; its scenarios are ${names}`, loc);
16616
+ }
16617
+ /** The value of a quantity token in `unit`, or undefined after reporting what is wrong. `after` locates a missing token. */
16618
+ quantityOf(token, unit, after, line) {
16619
+ const expected = UNIT_EXAMPLES[unit];
16620
+ if (token?.kind !== "quantity" && token?.kind !== "number") {
16621
+ this.error(`Expected ${expected}${token ? `, not ${describe(token)}` : ""}`, this.locOf(token ?? after, line));
16622
+ return void 0;
16623
+ }
16624
+ const loc = this.locOf(token, line);
16625
+ const written = this.lines[line - 1].slice(token.col - 1, token.col - 1 + token.length);
16626
+ const q = parseQuantity(written);
16627
+ if ("error" in q) {
16628
+ this.error(q.error, loc);
16629
+ return void 0;
16630
+ }
16631
+ if (q.unit !== unit) {
16632
+ this.error(q.unit ? `Expected ${expected}, not '${written}'` : `'${written}' needs a unit: expected ${expected}`, loc);
16633
+ return void 0;
16634
+ }
16635
+ return q.value;
16636
+ }
16637
+ expectOp(tokens, i, ops, example, line) {
16638
+ const t = tokens[i];
16639
+ if (t?.kind === "op" && ops.includes(t.value)) return true;
16640
+ this.error(`Expected ${ops[0]} here, e.g. ${example}`, this.locOf(t ?? tokens[tokens.length - 1], line));
16641
+ return false;
16642
+ }
15881
16643
  buildUseCase(useCase, body) {
15882
16644
  let paths = expand(body.items);
15883
16645
  if (paths.length > MAX_SCENARIOS) {
@@ -15920,22 +16682,22 @@ var Parser = class {
15920
16682
  for (const step of raw) {
15921
16683
  this.checkConnection(step);
15922
16684
  if (step.arrow === "-->") {
15923
- const request = [...steps].reverse().find((s) => s.fromServiceId === step.to && s.toServiceId === step.from && !answered.has(s) && !s.failed);
15924
- if (!request) {
16685
+ const request2 = [...steps].reverse().find((s) => s.fromServiceId === step.to && s.toServiceId === step.from && !answered.has(s) && !s.failed);
16686
+ if (!request2) {
15925
16687
  this.warning(`Response has no matching request from '${step.to}' to '${step.from}'`, step.loc);
15926
16688
  continue;
15927
16689
  }
15928
- answered.add(request);
15929
- request.responseLoc = step.loc;
16690
+ answered.add(request2);
16691
+ request2.responseLoc = step.loc;
15930
16692
  const status = step.label.match(/^(\d{3})\b\s*([\s\S]*)$/);
15931
- if (status) request.statusCode = Number(status[1]);
16693
+ if (status) request2.statusCode = Number(status[1]);
15932
16694
  const payload = splitPayload(status ? status[2] : step.label);
15933
16695
  const body = payload.body ?? payload.text;
15934
16696
  if (body) {
15935
- request.responseBody = body;
15936
- request.responseFormat = payload.body ? payload.format : "FREE_TEXT";
16697
+ request2.responseBody = body;
16698
+ request2.responseFormat = payload.body ? payload.format : "FREE_TEXT";
15937
16699
  }
15938
- if (request.executionType === "ASYNC_FIRE_AND_FORGET") request.executionType = "ASYNC_REQUEST_RESPONSE";
16700
+ if (request2.executionType === "ASYNC_FIRE_AND_FORGET") request2.executionType = "ASYNC_REQUEST_RESPONSE";
15939
16701
  continue;
15940
16702
  }
15941
16703
  const label = parseStepLabel(step.label);
@@ -15982,13 +16744,15 @@ var Parser = class {
15982
16744
  addNode(node) {
15983
16745
  const existing = this.nodes.get(node.id);
15984
16746
  if (existing) {
15985
- const first = existing.loc.file;
15986
- 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}`;
15987
- this.error(`Duplicate id '${node.id}' (first declared ${where})`, node.loc);
16747
+ this.error(`Duplicate id '${node.id}' (first declared ${this.where(existing.loc, node.loc)})`, node.loc);
15988
16748
  return;
15989
16749
  }
15990
16750
  this.nodes.set(node.id, node);
15991
16751
  }
16752
+ /** `on line 3`, or `in infra.proschi on line 3` when `first` is in another file than `later`. */
16753
+ where(first, later) {
16754
+ return first.file === later.file ? `on line ${first.line}` : `in ${fileName(first.file ?? this.options.path ?? "the root document")} on line ${first.line}`;
16755
+ }
15992
16756
  resolveTech(token, line) {
15993
16757
  const resolved = techByName.get(token.value.toLowerCase());
15994
16758
  if (resolved) return resolved;
@@ -16026,8 +16790,17 @@ var Parser = class {
16026
16790
  inUseCase() {
16027
16791
  return this.currentUseCase() !== void 0;
16028
16792
  }
16793
+ /** Reports the first token from `from` on; true when there is none. */
16029
16794
  expectEnd(tokens, from, line) {
16030
- if (tokens.length > from) this.error(`Unexpected ${describe(tokens[from])}`, this.locOf(tokens[from], line));
16795
+ if (tokens.length <= from) return true;
16796
+ this.error(`Unexpected ${describe(tokens[from])}`, this.locOf(tokens[from], line));
16797
+ return false;
16798
+ }
16799
+ /** From the first token of a line to the end of its last one. */
16800
+ lineLoc(tokens, line) {
16801
+ const first = tokens[0];
16802
+ const last = tokens[tokens.length - 1];
16803
+ return { line, col: first.col, length: last.col + last.length - first.col, ...this.fileField() };
16031
16804
  }
16032
16805
  locOf(token, line) {
16033
16806
  return token ? { line, col: token.col, length: token.length, ...this.fileField() } : { line, col: 1, length: 1, ...this.fileField() };
@@ -16069,6 +16842,15 @@ function expand(items) {
16069
16842
  }
16070
16843
  return paths;
16071
16844
  }
16845
+ function isWord(token, word) {
16846
+ return token?.kind === "ident" && token.value === word;
16847
+ }
16848
+ function sectionName(frame) {
16849
+ if (frame.entity) return `entity '${frame.entity.name}'`;
16850
+ if (frame.decision) return `decision '${frame.decision.title}'`;
16851
+ if (frame.test) return `test '${frame.test.name}'`;
16852
+ return `${frame.section} block`;
16853
+ }
16072
16854
  function fileName(path) {
16073
16855
  return path.split(/[\\/]/).pop() || path;
16074
16856
  }
@@ -16154,8 +16936,9 @@ function format(source2) {
16154
16936
  };
16155
16937
  tidyBlankLines(entries).forEach((entry) => {
16156
16938
  const last = run2[run2.length - 1];
16157
- if (last && (entry.kind !== last.kind || entry.depth !== last.depth || last.kind === "connection" && last.continuation.length)) flush();
16158
- if (entry.kind === "node" || entry.kind === "connection") run2.push(entry);
16939
+ 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))
16940
+ flush();
16941
+ if (entry.kind === "node" || entry.kind === "connection" || entry.kind === "cells") run2.push(entry);
16159
16942
  else {
16160
16943
  flush();
16161
16944
  out.push(entry.kind === "blank" ? "" : indent(entry.depth) + entry.text);
@@ -16169,6 +16952,7 @@ var KEYWORDS = /* @__PURE__ */ new Set(["title", "import", "group", "usecase", "
16169
16952
  function classify(lines) {
16170
16953
  const entries = [];
16171
16954
  let depth = 0;
16955
+ let section;
16172
16956
  for (let i = 0; i < lines.length; i++) {
16173
16957
  const raw = lines[i];
16174
16958
  const original = raw.trim();
@@ -16184,7 +16968,13 @@ function classify(lines) {
16184
16968
  const comment = commentOf(raw, tokens);
16185
16969
  const base = { depth, original, tokens, comment };
16186
16970
  const [first, second, third, fourth] = tokens;
16187
- if (first.kind === "ident" && second?.kind === "arrow" && third?.kind === "ident" && tokens.length <= 4 && (!fourth || fourth.kind === "label")) {
16971
+ const inSection = section !== void 0 && depth === 1 && first.kind !== "rbrace";
16972
+ const cells = inSection ? sectionCells(section, raw, tokens) : void 0;
16973
+ if (cells) {
16974
+ entries.push({ ...base, kind: "cells", section, cells });
16975
+ continue;
16976
+ }
16977
+ if (!inSection && first.kind === "ident" && second?.kind === "arrow" && third?.kind === "ident" && tokens.length <= 4 && (!fourth || fourth.kind === "label")) {
16188
16978
  const entry = { ...base, kind: "connection", from: first.value, arrow: second.value, to: third.value, label: fourth?.value, continuation: [] };
16189
16979
  if (fourth && bracketDepth(fourth.value) > 0) {
16190
16980
  const delta = depth * INDENT.length - width(leadingSpace(raw));
@@ -16199,11 +16989,12 @@ function classify(lines) {
16199
16989
  entries.push(entry);
16200
16990
  continue;
16201
16991
  }
16202
- if (first.kind === "ident" && !KEYWORDS.has(first.value) && tokens.slice(1).every((t) => ["string", "tech", "team", "ident", "number", "comma"].includes(t.kind))) {
16992
+ const oneLineDecision = first.value === "decision" && second?.kind === "string" && third?.kind === "ident" && third.value === "because";
16993
+ if (!inSection && first.kind === "ident" && !KEYWORDS.has(first.value) && !oneLineDecision && tokens.slice(1).every((t) => ["string", "tech", "team", "ident", "number", "comma"].includes(t.kind))) {
16203
16994
  let k = 1;
16204
16995
  const take = (kind) => tokens[k]?.kind === kind ? source(raw, tokens[k++]) : "";
16205
- const cells = [first.value, take("string"), take("tech"), take("team"), join2(raw, tokens.slice(k))];
16206
- entries.push({ ...base, kind: "node", cells });
16996
+ const cells2 = [first.value, take("string"), take("tech"), take("team"), join2(raw, tokens.slice(k))];
16997
+ entries.push({ ...base, kind: "node", cells: cells2 });
16207
16998
  continue;
16208
16999
  }
16209
17000
  const closes = first.kind === "rbrace";
@@ -16211,10 +17002,44 @@ function classify(lines) {
16211
17002
  const code = join2(raw, tokens);
16212
17003
  const text = withComment(code, comment);
16213
17004
  entries.push({ kind: "line", depth: Math.max(0, depth - (closes ? 1 : 0)), text: sameTokens(text, tokens) ? text : original, opens, closes });
17005
+ if (depth === 0) section = sectionOpenedBy(tokens);
16214
17006
  for (const t of tokens) depth = Math.max(0, depth + (t.kind === "lbrace" ? 1 : t.kind === "rbrace" ? -1 : 0));
17007
+ if (depth === 0) section = void 0;
16215
17008
  }
16216
17009
  return entries;
16217
17010
  }
17011
+ function sectionOpenedBy(tokens) {
17012
+ const [first, second] = tokens;
17013
+ if (first.kind !== "ident" || tokens[tokens.length - 1].kind !== "lbrace") return void 0;
17014
+ switch (first.value) {
17015
+ case "traffic":
17016
+ case "requirements":
17017
+ case "capacity":
17018
+ return second?.kind === "lbrace" ? first.value : void 0;
17019
+ case "entity":
17020
+ return second?.kind === "ident" ? "entity" : void 0;
17021
+ case "decision":
17022
+ case "test":
17023
+ return second?.kind === "string" ? first.value : void 0;
17024
+ default:
17025
+ return void 0;
17026
+ }
17027
+ }
17028
+ function sectionCells(section, raw, tokens) {
17029
+ const [first, second] = tokens;
17030
+ if (tokens.some((t) => t.kind === "lbrace" || t.kind === "rbrace" || t.kind === "arrow" || t.kind === "label")) return void 0;
17031
+ const rest = (from) => tokens.length > from ? [join2(raw, tokens.slice(from))] : [];
17032
+ switch (section) {
17033
+ case "traffic":
17034
+ return first.kind === "string" && (second?.kind === "quantity" || second?.kind === "number") ? [source(raw, first), source(raw, second), ...rest(2)] : void 0;
17035
+ case "capacity":
17036
+ return first.kind === "ident" && tokens.length > 1 ? [first.value, ...rest(1)] : void 0;
17037
+ case "entity":
17038
+ return first.kind === "ident" && second?.kind === "ident" ? [first.value, second.value, ...rest(2)] : void 0;
17039
+ default:
17040
+ return void 0;
17041
+ }
17042
+ }
16218
17043
  function tidyBlankLines(entries) {
16219
17044
  const kept = [];
16220
17045
  for (const entry of entries) {
@@ -16231,7 +17056,7 @@ function tidyBlankLines(entries) {
16231
17056
  return kept;
16232
17057
  }
16233
17058
  function renderRun(run2) {
16234
- const codes = run2[0].kind === "node" ? alignNodes(run2) : alignConnections(run2);
17059
+ const codes = run2[0].kind === "node" ? alignNodes(run2) : run2[0].kind === "cells" ? alignCells(run2) : alignConnections(run2);
16235
17060
  const items = run2;
16236
17061
  const commented = items.filter((e) => e.comment);
16237
17062
  const commentCol = commented.length > 1 ? Math.max(...items.map((e, i) => e.comment ? len(codes[i]) : 0)) + 1 : 0;
@@ -16250,6 +17075,11 @@ function alignNodes(run2) {
16250
17075
  return (line + cells[4]).trimEnd();
16251
17076
  });
16252
17077
  }
17078
+ function alignCells(run2) {
17079
+ const widths = [];
17080
+ for (const { cells } of run2) cells.slice(0, -1).forEach((c, i) => widths[i] = Math.max(widths[i] ?? 0, len(c)));
17081
+ return run2.map(({ cells }) => cells.map((c, i) => i < cells.length - 1 ? pad(c, widths[i] + 1) : c).join(""));
17082
+ }
16253
17083
  function alignConnections(run2) {
16254
17084
  const head = Math.max(...run2.map((e) => len(e.from) + 1 + e.arrow.length));
16255
17085
  const labelled = run2.filter((e) => e.label !== void 0);
@@ -16272,7 +17102,7 @@ function join2(raw, tokens) {
16272
17102
  let out = "";
16273
17103
  for (const [i, t] of tokens.entries()) {
16274
17104
  const text = t.kind === "label" ? ":" + (t.value ? " " + t.value : "") : source(raw, t);
16275
- const tight = t.kind === "comma" || tokens[i - 1]?.kind === "comma";
17105
+ const tight = t.kind === "comma" || tokens[i - 1]?.kind === "comma" && t.kind === "number";
16276
17106
  out += (i === 0 || tight ? "" : " ") + text;
16277
17107
  }
16278
17108
  return out;
@@ -16286,7 +17116,10 @@ function reindent(line, delta) {
16286
17116
  const body = line.slice(lead.length).trimEnd();
16287
17117
  return body ? " ".repeat(Math.max(0, width(lead) + delta)) + body : "";
16288
17118
  }
16289
- var source = (raw, t) => raw.slice(t.col - 1, t.col - 1 + t.length);
17119
+ var source = (raw, t) => {
17120
+ const text = raw.slice(t.col - 1, t.col - 1 + t.length);
17121
+ return t.kind === "quantity" ? text.replace(/[ \t]+/g, " ") : text;
17122
+ };
16290
17123
  var withComment = (code, comment) => comment ? code + " " + comment : code;
16291
17124
  var leadingSpace = (line) => /^[ \t]*/.exec(line)[0];
16292
17125
  var width = (space) => [...space].reduce((n, ch) => n + (ch === " " ? INDENT.length : 1), 0);
@@ -16294,6 +17127,92 @@ var indent = (depth) => INDENT.repeat(depth);
16294
17127
  var len = (s) => [...s].length;
16295
17128
  var pad = (s, to) => s + " ".repeat(Math.max(0, to - len(s)));
16296
17129
 
17130
+ // ../frontend/src/dsl/sequence.ts
17131
+ var PAYLOAD_LIMIT = 40;
17132
+ function shorten(text, max = PAYLOAD_LIMIT) {
17133
+ const flat = text.replace(/\s+/g, " ").trim();
17134
+ return flat.length > max ? `${flat.slice(0, max - 1).trimEnd()}\u2026` : flat;
17135
+ }
17136
+ function requestLabel(step) {
17137
+ const name = !step.httpMethod && step.stepName === `${step.fromServiceId} \u2192 ${step.toServiceId}` ? "" : step.stepName;
17138
+ return [name, step.description, step.requestBody && shorten(step.requestBody)].filter(Boolean).join(" ");
17139
+ }
17140
+ function responseLabel(step) {
17141
+ return [step.statusCode, step.responseBody && shorten(step.responseBody)].filter((p) => p !== void 0 && p !== "").join(" ");
17142
+ }
17143
+ function hasResponse(step) {
17144
+ return !step.failed && (!!step.responseLoc || step.statusCode !== void 0 || !!step.responseBody);
17145
+ }
17146
+ function request(step) {
17147
+ return {
17148
+ kind: "request",
17149
+ from: step.fromServiceId,
17150
+ to: step.toServiceId,
17151
+ number: step.stepOrder + 1,
17152
+ label: requestLabel(step),
17153
+ async: step.executionType !== "SYNC_REQUEST_RESPONSE",
17154
+ failed: !!step.failed,
17155
+ error: !!step.failed,
17156
+ step
17157
+ };
17158
+ }
17159
+ function response(step) {
17160
+ return {
17161
+ kind: "response",
17162
+ from: step.toServiceId,
17163
+ to: step.fromServiceId,
17164
+ number: step.stepOrder + 1,
17165
+ label: responseLabel(step),
17166
+ async: false,
17167
+ failed: false,
17168
+ error: (step.statusCode ?? 0) >= 400,
17169
+ status: step.statusCode,
17170
+ step
17171
+ };
17172
+ }
17173
+ function buildSequence(useCase, scenario, nodes = []) {
17174
+ const items = [];
17175
+ const stack = [];
17176
+ const emit = (message) => items.push({ kind: "message", message });
17177
+ const unwindFor = (from) => {
17178
+ const depth = stack.map((s) => s.toServiceId).lastIndexOf(from);
17179
+ while (stack.length > depth + 1) emit(response(stack.pop()));
17180
+ };
17181
+ const steps = scenario.steps;
17182
+ for (let i = 0; i < steps.length; i++) {
17183
+ const step = steps[i];
17184
+ if (step.parallelGroup === void 0) {
17185
+ unwindFor(step.fromServiceId);
17186
+ emit(request(step));
17187
+ if (hasResponse(step)) stack.push(step);
17188
+ continue;
17189
+ }
17190
+ const group = [];
17191
+ while (i < steps.length && steps[i].parallelGroup === step.parallelGroup) group.push(steps[i++]);
17192
+ i--;
17193
+ unwindFor(step.fromServiceId);
17194
+ items.push({ kind: "par", branches: group.map((s) => hasResponse(s) ? [request(s), response(s)] : [request(s)]) });
17195
+ }
17196
+ while (stack.length) emit(response(stack.pop()));
17197
+ const byId = new Map(nodes.map((n) => [n.id, n]));
17198
+ const participants = [];
17199
+ const seen = /* @__PURE__ */ new Set();
17200
+ const add = (id) => {
17201
+ if (seen.has(id)) return;
17202
+ seen.add(id);
17203
+ const node = byId.get(id);
17204
+ participants.push({ id, name: node?.name ?? id, tech: node && !node.implicit ? node.techStack : void 0 });
17205
+ };
17206
+ for (const step of steps) {
17207
+ add(step.fromServiceId);
17208
+ add(step.toServiceId);
17209
+ }
17210
+ return { useCase, scenario, participants, items, condition: scenario.condition || void 0 };
17211
+ }
17212
+ function sequenceMessages(items) {
17213
+ return items.flatMap((item) => item.kind === "message" ? [item.message] : item.branches.flat());
17214
+ }
17215
+
16297
17216
  // ../frontend/src/dsl/mermaid.ts
16298
17217
  var RESERVED = new Set(
16299
17218
  [
@@ -16343,6 +17262,695 @@ var RESERVED = new Set(
16343
17262
  ].map((w) => w.toLowerCase())
16344
17263
  );
16345
17264
 
17265
+ // ../frontend/src/sim/flow.ts
17266
+ function findUseCase(diagram, name) {
17267
+ const lower = name.toLowerCase();
17268
+ return diagram.useCases.find((u) => u.name === name) ?? diagram.useCases.find((u) => u.name.toLowerCase() === lower) ?? diagram.useCases.find((u) => u.id === name);
17269
+ }
17270
+ function findScenario2(useCase, name) {
17271
+ const norm = (s) => s.replace(/\s*(›|>)\s*/g, " \u203A ").trim().toLowerCase();
17272
+ const wanted = norm(name);
17273
+ return useCase.scenarios.find((s) => s.name === name) ?? useCase.scenarios.find((s) => norm(s.name) === wanted || s.id === name);
17274
+ }
17275
+ function afterEntryResponse(entry, step) {
17276
+ const r = entry.responseLoc;
17277
+ if (!r || step === entry || step.loc.file !== r.file) return false;
17278
+ return step.loc.line > r.line || step.loc.line === r.line && step.loc.col > r.col;
17279
+ }
17280
+ function criticalPath(useCase, scenario) {
17281
+ const entry = scenario.steps[0];
17282
+ if (!entry) return [];
17283
+ const { items } = buildSequence(useCase, scenario);
17284
+ const working = /* @__PURE__ */ new Map([[entry.fromServiceId, 1]]);
17285
+ const opened = /* @__PURE__ */ new Set();
17286
+ let answered = false;
17287
+ const parts = [];
17288
+ const visit = (m, hops) => {
17289
+ if (m.kind === "response") {
17290
+ if (opened.delete(m.step)) working.set(m.step.toServiceId, (working.get(m.step.toServiceId) ?? 1) - 1);
17291
+ if (m.step === entry) answered = true;
17292
+ return;
17293
+ }
17294
+ if (afterEntryResponse(entry, m.step)) answered = true;
17295
+ if (answered || !working.get(m.from)) return;
17296
+ hops.push({ step: m.step, target: m.to, failed: m.failed, async: m.async });
17297
+ if (!m.async && !m.failed) {
17298
+ opened.add(m.step);
17299
+ working.set(m.to, (working.get(m.to) ?? 0) + 1);
17300
+ }
17301
+ };
17302
+ for (const item of items) {
17303
+ const hops = [];
17304
+ if (item.kind === "message") visit(item.message, hops);
17305
+ else for (const branch of item.branches) for (const m of branch) visit(m, hops);
17306
+ if (hops.length) parts.push(hops);
17307
+ }
17308
+ return parts;
17309
+ }
17310
+ function pathNodes(useCase, scenario) {
17311
+ const entry = scenario.steps[0];
17312
+ if (!entry) return [];
17313
+ const ids = [entry.fromServiceId, ...criticalPath(useCase, scenario).flatMap((part) => part.filter((h) => !h.failed).map((h) => h.target))];
17314
+ return [...new Set(ids)];
17315
+ }
17316
+ function requestOrder(useCase, scenario) {
17317
+ const entry = scenario.steps[0];
17318
+ const messages = sequenceMessages(buildSequence(useCase, scenario).items);
17319
+ const requests = [];
17320
+ let beforeResponse = -1;
17321
+ for (const m of messages) {
17322
+ if (m.kind === "request") {
17323
+ if (beforeResponse < 0 && entry && afterEntryResponse(entry, m.step)) beforeResponse = requests.length;
17324
+ requests.push(m);
17325
+ } else if (m.step === entry && beforeResponse < 0) beforeResponse = requests.length;
17326
+ }
17327
+ return { requests, beforeResponse: beforeResponse < 0 ? requests.length : beforeResponse };
17328
+ }
17329
+ function writesBeforeResponse(useCase, scenario, accept) {
17330
+ const { requests, beforeResponse } = requestOrder(useCase, scenario);
17331
+ return requests.slice(0, beforeResponse).some((m) => !m.async && !m.failed && accept(m.to));
17332
+ }
17333
+ function callsOk(scenario, nodeId) {
17334
+ return scenario.steps.some((s) => s.toServiceId === nodeId && !s.failed);
17335
+ }
17336
+ function needs(useCase, nodeId) {
17337
+ return useCase.scenarios.some((s) => s.outcome === "success" && callsOk(s, nodeId));
17338
+ }
17339
+ function fallbacksFor(useCase, nodeId) {
17340
+ return useCase.scenarios.filter(
17341
+ (s) => s.outcome === "success" && s.steps.some((step) => step.toServiceId === nodeId && step.failed) && !callsOk(s, nodeId)
17342
+ );
17343
+ }
17344
+ function selectorText(selector) {
17345
+ if ("node" in selector) return selector.node;
17346
+ if ("tech" in selector) return `[${selector.tech}]`;
17347
+ return `any ${selector.kind}`;
17348
+ }
17349
+ function selectNodes(diagram, selector) {
17350
+ return diagram.nodes.filter((n) => n.kind === "component" && matches(n, selector));
17351
+ }
17352
+ function matches(node, selector) {
17353
+ if (node.kind !== "component") return false;
17354
+ if ("node" in selector) return node.id === selector.node;
17355
+ if ("tech" in selector) return node.techStack.toLowerCase() === selector.tech.toLowerCase();
17356
+ return kindOf(node) === selector.kind;
17357
+ }
17358
+ function techOf(node) {
17359
+ return node && !node.implicit ? node.techStack : void 0;
17360
+ }
17361
+ function nodeLabel(diagram, id) {
17362
+ const tech = techOf(diagram.nodes.find((n) => n.id === id));
17363
+ return tech ? `${id} (${tech})` : id;
17364
+ }
17365
+
17366
+ // ../frontend/src/sim/format.ts
17367
+ var trim = (n, digits) => String(Number(n.toFixed(digits)));
17368
+ function formatRps(rps) {
17369
+ if (!Number.isFinite(rps)) return "\u221E rps";
17370
+ if (rps >= 1e6) return `${trim(rps / 1e6, 1)}m rps`;
17371
+ if (rps >= 1e3) return `${trim(rps / 1e3, 1)}k rps`;
17372
+ return `${trim(rps, rps < 10 ? 2 : 0)} rps`;
17373
+ }
17374
+ function formatMs(ms) {
17375
+ return `${trim(ms, ms < 100 ? 1 : 0)} ms`;
17376
+ }
17377
+ function formatAvailability(fraction) {
17378
+ if (fraction >= 1) return "100%";
17379
+ const down = (1 - fraction) * 100;
17380
+ const digits = Math.min(6, Math.max(1, Math.ceil(-Math.log10(down)) + 1));
17381
+ return `${trim(Math.floor(fraction * 100 * 10 ** digits + 1e-6) / 10 ** digits, digits)}%`;
17382
+ }
17383
+ function formatPercent(fraction) {
17384
+ return `${Math.round(fraction * 100)}%`;
17385
+ }
17386
+ function formatUsd(usd) {
17387
+ return `$${Math.round(usd).toLocaleString("en-US")}/month`;
17388
+ }
17389
+
17390
+ // ../frontend/src/sim/profiles.ts
17391
+ var NEUTRAL = { rps: Infinity, latencyMs: 0, availability: 1, costUsd: 0, durable: false };
17392
+ var KIND_PROFILES = {
17393
+ client: NEUTRAL,
17394
+ edge: { rps: 1e5, latencyMs: 2, availability: 0.9999, costUsd: 50, durable: false },
17395
+ service: { rps: 2e3, latencyMs: 10, availability: 0.995, costUsd: 100, durable: false },
17396
+ function: { rps: 1e4, latencyMs: 25, availability: 0.9995, costUsd: 200, durable: false },
17397
+ cache: { rps: 1e5, latencyMs: 1, availability: 0.999, costUsd: 150, durable: false },
17398
+ database: { rps: 5e3, latencyMs: 5, availability: 0.9995, costUsd: 400, durable: true },
17399
+ search: { rps: 3e3, latencyMs: 15, availability: 0.999, costUsd: 400, durable: true },
17400
+ analytics: { rps: 200, latencyMs: 500, availability: 0.999, costUsd: 300, durable: true },
17401
+ queue: { rps: 5e4, latencyMs: 5, availability: 0.9999, costUsd: 200, durable: true },
17402
+ storage: { rps: 5e3, latencyMs: 30, availability: 0.9999, costUsd: 50, durable: true },
17403
+ external: { rps: 1e3, latencyMs: 200, availability: 0.999, costUsd: 0, durable: false },
17404
+ other: NEUTRAL
17405
+ };
17406
+ var NOSQL = { rps: 2e4, latencyMs: 5, availability: 0.9999, costUsd: 500, durable: true };
17407
+ var TECH_PROFILES = {
17408
+ DynamoDB: NOSQL,
17409
+ "AWS DynamoDB": NOSQL,
17410
+ Cassandra: NOSQL,
17411
+ MongoDB: NOSQL,
17412
+ CouchDB: NOSQL,
17413
+ "Azure Cosmos DB": NOSQL,
17414
+ "GCP Bigtable": NOSQL,
17415
+ "GCP Firestore": NOSQL,
17416
+ "GCP Spanner": NOSQL
17417
+ };
17418
+ function profileOf(node, override) {
17419
+ const kind = kindOf(node);
17420
+ const base = TECH_PROFILES[node.techStack] ?? KIND_PROFILES[kind];
17421
+ return {
17422
+ kind,
17423
+ rps: override?.rps ?? base.rps,
17424
+ latencyMs: override?.latencyMs ?? base.latencyMs,
17425
+ availability: override?.availability !== void 0 ? override.availability / 100 : base.availability,
17426
+ costUsd: override?.costUsd ?? base.costUsd,
17427
+ durable: override?.durable ?? base.durable
17428
+ };
17429
+ }
17430
+
17431
+ // ../frontend/src/sim/analyze.ts
17432
+ var DEFAULT_TIMEOUT_MS = 1e3;
17433
+ var HOT = 0.7;
17434
+ var QUEUE_CAP = 0.95;
17435
+ var PERCENTILE_FACTORS = { 50: 1, 90: 1.6, 95: 2, 99: 3, 99.9: 5 };
17436
+ var PERCENTILE_KEYS = { 50: "p50", 90: "p90", 95: "p95", 99: "p99", 99.9: "p999" };
17437
+ var PERCENTILES2 = Object.keys(PERCENTILE_KEYS).map(Number);
17438
+ function components(diagram) {
17439
+ return diagram.nodes.filter((n) => n.kind === "component");
17440
+ }
17441
+ var replicasOf = (node) => Math.max(1, node.replicas ?? 1);
17442
+ function resolveTraffic(diagram, warnings = []) {
17443
+ const result = /* @__PURE__ */ new Map();
17444
+ for (const u of diagram.useCases) result.set(u.id, { rps: 0, shares: u.scenarios.map((_, i) => i === 0 ? 1 : 0) });
17445
+ const seen = /* @__PURE__ */ new Set();
17446
+ for (const entry of diagram.traffic ?? []) {
17447
+ const useCase = findUseCase(diagram, entry.useCase);
17448
+ if (!useCase) {
17449
+ warnings.push(`Traffic for unknown use case "${entry.useCase}" is ignored`);
17450
+ continue;
17451
+ }
17452
+ if (seen.has(useCase.id)) continue;
17453
+ seen.add(useCase.id);
17454
+ const shares = useCase.scenarios.map(() => 0);
17455
+ for (const { scenario, share } of entry.mix ?? []) {
17456
+ const found = findScenario2(useCase, scenario);
17457
+ if (found) shares[useCase.scenarios.indexOf(found)] += share;
17458
+ else warnings.push(`"${useCase.name}" has no scenario "${scenario}"; its traffic share is ignored`);
17459
+ }
17460
+ const total = shares.reduce((a, b) => a + b, 0);
17461
+ if (total <= 0) shares[0] = 1;
17462
+ else {
17463
+ if (Math.abs(total - 1) > 1e-6) warnings.push(`Traffic mix of "${useCase.name}" adds up to ${formatPercent(total)}; shares are scaled to 100%`);
17464
+ for (let i = 0; i < shares.length; i++) shares[i] /= total;
17465
+ }
17466
+ result.set(useCase.id, { rps: entry.rps, shares: useCase.scenarios.length ? shares : [] });
17467
+ }
17468
+ return result;
17469
+ }
17470
+ function computeLoad(diagram, traffic) {
17471
+ const load = /* @__PURE__ */ new Map();
17472
+ for (const u of diagram.useCases) {
17473
+ const { rps, shares } = traffic.get(u.id) ?? { rps: 0, shares: [] };
17474
+ u.scenarios.forEach((s, i) => {
17475
+ const rate = rps * (shares[i] ?? 0);
17476
+ if (rate > 0) for (const step of s.steps) load.set(step.toServiceId, (load.get(step.toServiceId) ?? 0) + rate);
17477
+ });
17478
+ }
17479
+ return load;
17480
+ }
17481
+ var utilizationOf = (load, capacity) => Number.isFinite(capacity) && capacity > 0 ? load / capacity : 0;
17482
+ var hopLatency = (baseMs, utilization) => baseMs / (1 - Math.min(utilization, QUEUE_CAP));
17483
+ var replicatedAvailability = (a, replicas) => 1 - (1 - a) ** replicas;
17484
+ function useCasePercentile(scenarios, q) {
17485
+ const tail = 1 - q / 100 - 1e-9;
17486
+ const heavy = scenarios.filter((s) => s.share >= tail && s.share > 0);
17487
+ if (heavy.length) return Math.max(...heavy.map((s) => s.value));
17488
+ let covered = 0;
17489
+ for (const s of [...scenarios].sort((a, b) => b.value - a.value)) {
17490
+ covered += s.share;
17491
+ if (covered >= tail) return s.value;
17492
+ }
17493
+ return scenarios.length ? Math.max(...scenarios.map((s) => s.value)) : 0;
17494
+ }
17495
+ function mainScenarioIndex(shares) {
17496
+ let best = 0;
17497
+ shares.forEach((share, i) => {
17498
+ if (share > shares[best]) best = i;
17499
+ });
17500
+ return best;
17501
+ }
17502
+ function analyze(diagram, options = {}) {
17503
+ const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
17504
+ const warnings = [];
17505
+ const nodes = components(diagram);
17506
+ const byId = new Map(nodes.map((n) => [n.id, n]));
17507
+ const overrides = new Map((diagram.capacity ?? []).map((o) => [o.node, o]));
17508
+ for (const o of diagram.capacity ?? []) if (!byId.has(o.node)) warnings.push(`Capacity for unknown node '${o.node}' is ignored`);
17509
+ const profiles = new Map(nodes.map((n) => [n.id, profileOf(n, overrides.get(n.id))]));
17510
+ const traffic = resolveTraffic(diagram, warnings);
17511
+ const load = computeLoad(diagram, traffic);
17512
+ const nodeResults = nodes.map((n) => {
17513
+ const p = profiles.get(n.id);
17514
+ const replicas = replicasOf(n);
17515
+ const loadRps = load.get(n.id) ?? 0;
17516
+ const capacityRps = p.rps * replicas;
17517
+ const utilization = utilizationOf(loadRps, capacityRps);
17518
+ return {
17519
+ id: n.id,
17520
+ kind: p.kind,
17521
+ replicas,
17522
+ loadRps,
17523
+ capacityRps,
17524
+ utilization,
17525
+ saturated: utilization >= 1,
17526
+ latencyMs: hopLatency(p.latencyMs, utilization),
17527
+ availability: replicatedAvailability(p.availability, replicas),
17528
+ costUsd: replicas * p.costUsd,
17529
+ durable: p.durable
17530
+ };
17531
+ });
17532
+ const nodeById = new Map(nodeResults.map((n) => [n.id, n]));
17533
+ const hopMs = (id) => nodeById.get(id)?.latencyMs ?? 0;
17534
+ const availabilityOf = (id) => nodeById.get(id)?.availability ?? 1;
17535
+ for (const n of nodeResults) {
17536
+ const usage = `${formatRps(n.loadRps)} of ${formatRps(n.capacityRps)}, ${formatPercent(n.utilization)}`;
17537
+ if (n.saturated) warnings.push(`'${n.id}' is saturated: ${usage}. Add replicas or take load off it.`);
17538
+ else if (n.utilization > HOT) warnings.push(`'${n.id}' runs hot: ${usage}.`);
17539
+ }
17540
+ const useCases = diagram.useCases.map((u) => {
17541
+ const { rps, shares } = traffic.get(u.id);
17542
+ const scenarios = u.scenarios.map((s, i) => {
17543
+ const meanMs = criticalPath(u, s).reduce(
17544
+ (sum, part) => sum + Math.max(...part.map((hop) => hop.failed ? timeoutMs : hopMs(hop.target))),
17545
+ 0
17546
+ );
17547
+ return { id: s.id, name: s.name, share: shares[i] ?? 0, meanMs, percentiles: percentilesOf(meanMs) };
17548
+ });
17549
+ const percentiles = Object.fromEntries(
17550
+ PERCENTILES2.map((q) => [PERCENTILE_KEYS[q], useCasePercentile(scenarios.map((s) => ({ share: s.share, value: s.percentiles[PERCENTILE_KEYS[q]] })), q)])
17551
+ );
17552
+ if (rps > 0 && u.scenarios.length === 0) warnings.push(`"${u.name}" has traffic but no steps`);
17553
+ return { id: u.id, name: u.name, rps, scenarios, percentiles, availability: useCaseAvailability(u, shares, availabilityOf) };
17554
+ });
17555
+ return {
17556
+ nodes: nodeResults,
17557
+ useCases,
17558
+ totalCostUsd: nodeResults.reduce((sum, n) => sum + n.costUsd, 0),
17559
+ singlePointsOfFailure: singlePointsOfFailure(diagram),
17560
+ warnings
17561
+ };
17562
+ }
17563
+ function percentilesOf(meanMs) {
17564
+ return Object.fromEntries(PERCENTILES2.map((q) => [PERCENTILE_KEYS[q], meanMs * PERCENTILE_FACTORS[q]]));
17565
+ }
17566
+ function useCaseAvailability(useCase, shares, availabilityOf) {
17567
+ if (useCase.scenarios.length === 0) return 1;
17568
+ const main = pathNodes(useCase, useCase.scenarios[mainScenarioIndex(shares)]);
17569
+ const onMain = new Set(main);
17570
+ let result = 1;
17571
+ for (const id of main) {
17572
+ const a = availabilityOf(id);
17573
+ const fallback = Math.max(
17574
+ 0,
17575
+ ...fallbacksFor(useCase, id).map(
17576
+ (s) => pathNodes(useCase, s).filter((other) => other !== id && !onMain.has(other)).reduce((product, other) => product * availabilityOf(other), 1)
17577
+ )
17578
+ );
17579
+ result *= 1 - (1 - a) * (1 - fallback);
17580
+ }
17581
+ return result;
17582
+ }
17583
+ var DEPLOYED = (kind) => kind !== "client" && kind !== "other" && kind !== "external";
17584
+ function singlePointsOfFailure(diagram) {
17585
+ 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);
17586
+ }
17587
+
17588
+ // ../frontend/src/sim/tests.ts
17589
+ var pass = (message) => ({ passed: true, message });
17590
+ var fail = (message, hint) => ({ passed: false, message, hint });
17591
+ function runTests(diagram, analysis = analyze(diagram)) {
17592
+ const run2 = {
17593
+ diagram,
17594
+ analysis,
17595
+ nodes: new Map(analysis.nodes.map((n) => [n.id, n])),
17596
+ useCases: new Map(analysis.useCases.map((u) => [u.id, u])),
17597
+ shares: new Map([...resolveTraffic(diagram)].map(([id, t]) => [id, t.shares]))
17598
+ };
17599
+ const results = (diagram.requirements ?? []).map((r, i) => ({
17600
+ id: `req:${i + 1}`,
17601
+ name: requirementName(r),
17602
+ category: CATEGORY[r.kind],
17603
+ ...requirement(run2, r),
17604
+ loc: r.loc
17605
+ }));
17606
+ const names = /* @__PURE__ */ new Map();
17607
+ for (const test of diagram.tests ?? []) {
17608
+ const n = (names.get(test.name) ?? 0) + 1;
17609
+ names.set(test.name, n);
17610
+ results.push({ id: n === 1 ? `test:${test.name}` : `test:${test.name}#${n}`, name: test.name, category: "flow", ...flowTest(run2, test), loc: test.loc });
17611
+ }
17612
+ return results.map(({ hint, ...r }) => r.passed || !hint ? r : { ...r, hint });
17613
+ }
17614
+ var CATEGORY = {
17615
+ latency: "latency",
17616
+ availability: "availability",
17617
+ durable: "durability",
17618
+ survive: "resilience",
17619
+ cost: "cost"
17620
+ };
17621
+ var percentileName = (q) => PERCENTILE_KEYS[q];
17622
+ function requirementName(r) {
17623
+ switch (r.kind) {
17624
+ case "latency":
17625
+ return `${percentileName(r.percentile)} of ${r.useCase ?? "every use case"} < ${formatMs(r.maxMs)}`;
17626
+ case "availability":
17627
+ return `availability of ${r.useCase ?? "every use case"} \u2265 ${r.minPercent}%`;
17628
+ case "durable":
17629
+ return `${r.useCase} is durable`;
17630
+ case "survive":
17631
+ return r.target === "any" ? "survive any node failure" : `survive failure of ${selectorText(r.target)}`;
17632
+ case "cost":
17633
+ return `cost \u2264 ${formatUsd(r.maxUsdPerMonth)}`;
17634
+ }
17635
+ }
17636
+ function requirement(run2, r) {
17637
+ switch (r.kind) {
17638
+ case "latency":
17639
+ return overUseCases(run2, r.useCase, (u) => latency(run2, u, r.percentile, r.maxMs), `(limit ${formatMs(r.maxMs)})`);
17640
+ case "availability":
17641
+ return overUseCases(run2, r.useCase, (u) => availability(run2, u, r.minPercent), `(limit ${r.minPercent}%)`, true);
17642
+ case "durable":
17643
+ return durable(run2, r.useCase);
17644
+ case "survive":
17645
+ return survive(run2, r.target);
17646
+ case "cost":
17647
+ return cost(run2, r.maxUsdPerMonth);
17648
+ }
17649
+ }
17650
+ function overUseCases(run2, name, check, limit, anyWithoutTraffic = false) {
17651
+ if (name !== void 0) {
17652
+ const u = findUseCase(run2.diagram, name);
17653
+ return u ? check(u) : unknownUseCase(run2, name);
17654
+ }
17655
+ let targets = run2.diagram.useCases.filter((u) => (run2.useCases.get(u.id)?.rps ?? 0) > 0);
17656
+ if (targets.length === 0 && anyWithoutTraffic) targets = run2.diagram.useCases;
17657
+ 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');
17658
+ const checks = targets.map(check);
17659
+ const failed = checks.filter((c) => !c.passed);
17660
+ if (failed.length === 0) {
17661
+ return pass(checks.length === 1 ? checks[0].message : `All ${checks.length} use cases hold; ${checks.map((c) => c.value).join(", ")} ${limit}`);
17662
+ }
17663
+ return fail(failed.length === 1 ? failed[0].message : `${failed.map((c) => c.value ?? c.message).join("; ")} ${limit}`, failed[0].hint);
17664
+ }
17665
+ function unknownUseCase(run2, name) {
17666
+ const known = run2.diagram.useCases.map((u) => `"${u.name}"`);
17667
+ return fail(`No use case named "${name}"`, known.length ? `Use one of ${known.join(", ")}` : 'Add a usecase "\u2026" { \u2026 } block');
17668
+ }
17669
+ function latency(run2, u, q, maxMs) {
17670
+ const result = run2.useCases.get(u.id);
17671
+ const key = percentileName(q);
17672
+ const shares = run2.shares.get(u.id) ?? [];
17673
+ const touched = new Set(u.scenarios.flatMap((s, i) => (shares[i] ?? 0) > 0 ? s.steps.map((step) => step.toServiceId) : []));
17674
+ const saturated = [...touched].map((id) => run2.nodes.get(id)).filter((n) => !!n?.saturated);
17675
+ if (saturated.length) {
17676
+ const n = saturated[0];
17677
+ const usage = `${formatRps(n.loadRps)} of ${formatRps(n.capacityRps)}, ${formatPercent(n.utilization)}`;
17678
+ const value2 = `${key} of ${u.name}: ${n.id} is saturated (${usage})`;
17679
+ return { ...fail(`${value2} (limit ${formatMs(maxMs)})`, saturationHint(run2, n)), value: value2 };
17680
+ }
17681
+ const ms = result.percentiles[key];
17682
+ const value = `${key} of ${u.name} is ${formatMs(ms)}${result.rps > 0 ? "" : " with no traffic"}`;
17683
+ const message = `${value} (limit ${formatMs(maxMs)})`;
17684
+ return ms < maxMs ? { ...pass(message), value } : { ...fail(message, latencyHint(run2, u, result, key, ms)), value };
17685
+ }
17686
+ function saturationHint(run2, n) {
17687
+ const perReplica = n.capacityRps / n.replicas;
17688
+ const replicas = Math.ceil(n.loadRps / (perReplica * HOT));
17689
+ return `Add replicas to ${nodeLabel(run2.diagram, n.id)}: x${replicas} keeps it under ${formatPercent(HOT)}, or take load off it (a cache in front, or async work)`;
17690
+ }
17691
+ function latencyHint(run2, u, result, key, ms) {
17692
+ const index = result.scenarios.findIndex((s) => s.percentiles[key] === ms);
17693
+ const scenario = u.scenarios[Math.max(0, index)];
17694
+ const lead = u.scenarios.length > 1 ? `The "${scenario.name}" path (${formatPercent(result.scenarios[Math.max(0, index)].share)} of traffic) sets ${key}. ` : "";
17695
+ const hops = criticalPath(u, scenario).flat();
17696
+ const cost2 = (h) => h.failed ? DEFAULT_TIMEOUT_MS : run2.nodes.get(h.target)?.latencyMs ?? 0;
17697
+ const slowest = hops.reduce((best, h) => !best || cost2(h) > cost2(best) ? h : best, void 0);
17698
+ if (!slowest) return `${lead}Shorten the synchronous path of the request.`;
17699
+ const label = nodeLabel(run2.diagram, slowest.target);
17700
+ 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.`;
17701
+ const node = run2.nodes.get(slowest.target);
17702
+ if (node && node.utilization > HOT) return `${lead}${label} runs at ${formatPercent(node.utilization)} and queues requests; add replicas to it.`;
17703
+ 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 ->>.`;
17704
+ }
17705
+ function availability(run2, u, minPercent) {
17706
+ const result = run2.useCases.get(u.id);
17707
+ const value = `availability of ${u.name} is ${formatAvailability(result.availability)}`;
17708
+ const message = `${value} (limit ${minPercent}%)`;
17709
+ if (result.availability * 100 >= minPercent - 1e-9) return { ...pass(message), value };
17710
+ const shares = run2.shares.get(u.id) ?? [];
17711
+ const main = u.scenarios.length ? pathNodes(u, u.scenarios[mainScenarioIndex(shares)]) : [];
17712
+ const weakest = main.filter((id) => fallbacksFor(u, id).length === 0).map((id) => run2.nodes.get(id)).filter((n) => !!n && n.availability < 1).sort((a, b) => a.availability - b.availability)[0];
17713
+ const hint = weakest ? `The weakest link is ${nodeLabel(run2.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";
17714
+ return { ...fail(message, hint), value };
17715
+ }
17716
+ function writeGap(u, s, accept) {
17717
+ const { requests, beforeResponse } = requestOrder(u, s);
17718
+ const writes = requests.map((m, i) => ({ m, i })).filter(({ m }) => !m.failed && accept(m.to));
17719
+ if (writes.some(({ m, i }) => m.async && i < beforeResponse)) return "async";
17720
+ if (writes.some(({ i }) => i >= beforeResponse)) return "after";
17721
+ return "none";
17722
+ }
17723
+ var GAP_TEXT = {
17724
+ async: "writes only asynchronously (->>)",
17725
+ after: "writes only after responding",
17726
+ none: "never writes"
17727
+ };
17728
+ function writesCheck(u, scenarios, accept, what) {
17729
+ const success = scenarios.filter((s) => s.outcome === "success");
17730
+ 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");
17731
+ const gaps = success.map((s) => ({ s, ok: writesBeforeResponse(u, s, accept) })).filter((x) => !x.ok);
17732
+ if (gaps.length === 0) {
17733
+ const label = success.length === 1 ? `"${u.name}"` : `Every success scenario of "${u.name}"`;
17734
+ return pass(`${label} writes ${what} before responding`);
17735
+ }
17736
+ const details = gaps.map(({ s }) => ({ s, gap: writeGap(u, s, accept) }));
17737
+ const where = (s) => u.scenarios.length > 1 ? `"${s.name}"` : `"${u.name}"`;
17738
+ const message = details.map(({ s, gap: gap2 }) => `${where(s)} ${GAP_TEXT[gap2]} ${what} before responding`).join("; ");
17739
+ const gap = details[0].gap;
17740
+ 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`;
17741
+ return fail(message, hint);
17742
+ }
17743
+ function durable(run2, name) {
17744
+ const u = findUseCase(run2.diagram, name);
17745
+ if (!u) return unknownUseCase(run2, name);
17746
+ return writesCheck(u, u.scenarios, (id) => !!run2.nodes.get(id)?.durable, "to a durable store");
17747
+ }
17748
+ function survive(run2, target) {
17749
+ const all = components(run2.diagram);
17750
+ const selected = target === "any" ? all.filter((n) => DEPLOYED(run2.nodes.get(n.id).kind)) : selectNodes(run2.diagram, target);
17751
+ const what = target === "any" ? "any one node" : selectorText(target);
17752
+ if (selected.length === 0) {
17753
+ return target === "any" ? pass("No nodes to lose") : fail(`No node matches ${what}`, "Use a node id, [Tech] or any <kind>");
17754
+ }
17755
+ const failures = [];
17756
+ for (const node of selected) {
17757
+ const n = run2.nodes.get(node.id);
17758
+ const label = nodeLabel(run2.diagram, node.id);
17759
+ const replicas = replicasOf(node);
17760
+ if (replicas >= 2) {
17761
+ const left = n.capacityRps / replicas * (replicas - 1);
17762
+ const rho = n.loadRps / left;
17763
+ if (rho >= 1) {
17764
+ failures.push(
17765
+ fail(
17766
+ `Losing one of ${replicas} ${node.id} replicas leaves ${formatRps(left)} for ${formatRps(n.loadRps)} (${formatPercent(rho)})`,
17767
+ `Add a replica to ${label} (x${replicas + 1}) so the rest carry the load`
17768
+ )
17769
+ );
17770
+ }
17771
+ continue;
17772
+ }
17773
+ for (const u of run2.diagram.useCases) {
17774
+ if (needs(u, node.id) && fallbacksFor(u, node.id).length === 0) {
17775
+ failures.push(
17776
+ fail(
17777
+ `Losing ${label} breaks "${u.name}"`,
17778
+ `Add a replica (x2 on ${node.id}) or a fallback scenario in "${u.name}" that calls ${node.id} with -x and still completes`
17779
+ )
17780
+ );
17781
+ }
17782
+ }
17783
+ }
17784
+ if (failures.length === 0) {
17785
+ const count = selected.length === 1 ? selected[0].id : `any of ${selected.length} nodes`;
17786
+ return pass(`Every use case keeps working after losing ${count}`);
17787
+ }
17788
+ const more = failures.length > 1 ? ` (and ${failures.length - 1} more)` : "";
17789
+ return fail(`${failures[0].message}${more}`, failures[0].hint);
17790
+ }
17791
+ function cost(run2, max) {
17792
+ const total = run2.analysis.totalCostUsd;
17793
+ const message = `Total cost is ${formatUsd(total)} (limit ${formatUsd(max)})`;
17794
+ if (total <= max) return pass(message);
17795
+ const top = [...run2.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)` : ""}`);
17796
+ return fail(message, `Biggest items: ${top.join(", ")}. Drop replicas on nodes with low utilisation or pick cheaper techs.`);
17797
+ }
17798
+ function flowTest(run2, test) {
17799
+ const assertions = test.assertions.map((a) => ({ ...assertion(run2, a), loc: a.loc }));
17800
+ const failed = assertions.filter((a) => !a.passed);
17801
+ if (assertions.length === 0) return { passed: true, message: "No assertions to check", assertions };
17802
+ if (failed.length === 0) {
17803
+ return { passed: true, message: assertions.length === 1 ? assertions[0].message : `All ${assertions.length} assertions hold`, assertions };
17804
+ }
17805
+ return { passed: false, message: failed.map((a) => a.message).join("; "), hint: failed[0].hint, assertions };
17806
+ }
17807
+ var quote = (s) => `"${s}"`;
17808
+ function scope(run2, useCase, scenario) {
17809
+ const u = findUseCase(run2.diagram, useCase);
17810
+ if (!u) return unknownUseCase(run2, useCase);
17811
+ if (scenario === void 0) return { u, scenarios: u.scenarios, label: quote(u.name) };
17812
+ const s = findScenario2(u, scenario);
17813
+ if (!s) return missingScenario(u, scenario);
17814
+ return { u, scenarios: [s], label: `${quote(u.name)} scenario ${quote(s.name)}` };
17815
+ }
17816
+ function missingScenario(u, scenario) {
17817
+ const names = u.scenarios.map((s) => quote(s.name)).join(", ");
17818
+ return fail(`${quote(u.name)} has no scenario ${quote(scenario)}${names ? `; it has ${names}` : ""}`, `Add alt "${scenario}" { \u2026 } to "${u.name}"`);
17819
+ }
17820
+ var isCheck = (x) => "passed" in x;
17821
+ function assertion(run2, a) {
17822
+ switch (a.kind) {
17823
+ case "calls":
17824
+ return calls(run2, a);
17825
+ case "before":
17826
+ return before(run2, a);
17827
+ case "writesBeforeResponding": {
17828
+ const sc = scope(run2, a.useCase, a.scenario);
17829
+ if (isCheck(sc)) return sc;
17830
+ const ids = new Set(selectNodes(run2.diagram, a.target).map((n) => n.id));
17831
+ if (ids.size === 0) return noMatch(a.target);
17832
+ return writesCheck(sc.u, sc.scenarios, (id) => ids.has(id), selectorText(a.target));
17833
+ }
17834
+ case "responds":
17835
+ return responds(run2, a);
17836
+ case "hasScenario": {
17837
+ const u = findUseCase(run2.diagram, a.useCase);
17838
+ if (!u) return unknownUseCase(run2, a.useCase);
17839
+ const s = findScenario2(u, a.scenario);
17840
+ return s ? pass(`${quote(u.name)} has scenario ${quote(s.name)}`) : missingScenario(u, a.scenario);
17841
+ }
17842
+ case "handlesFailure": {
17843
+ const u = findUseCase(run2.diagram, a.useCase);
17844
+ if (!u) return unknownUseCase(run2, a.useCase);
17845
+ const ids = new Set(selectNodes(run2.diagram, a.target).map((n) => n.id));
17846
+ if (ids.size === 0) return noMatch(a.target);
17847
+ const handled = u.scenarios.find((s) => s.outcome === "success" && s.steps.some((step) => step.failed && ids.has(step.toServiceId)));
17848
+ const what = selectorText(a.target);
17849
+ return handled ? pass(`${quote(u.name)} handles a failed call to ${what} in ${quote(handled.name)}`) : fail(
17850
+ `No success scenario of ${quote(u.name)} has a failed call to ${what}`,
17851
+ `Add alt "${what} down" { \u2026 -x ${[...ids][0]} \u2026 } that still answers the entry request with a 2xx`
17852
+ );
17853
+ }
17854
+ case "noPath":
17855
+ return noPath(run2, a.from, a.to);
17856
+ case "replicas": {
17857
+ const nodes = selectNodes(run2.diagram, a.target);
17858
+ if (nodes.length === 0) return noMatch(a.target);
17859
+ const short = nodes.filter((n) => replicasOf(n) < a.min);
17860
+ const has = (n) => `${n.id} has ${replicasOf(n)} replica${replicasOf(n) === 1 ? "" : "s"}`;
17861
+ if (short.length === 0) return pass(`${nodes.map(has).join(", ")} (minimum ${a.min})`);
17862
+ return fail(`${short.map(has).join(", ")} (minimum ${a.min})`, `Declare ${short[0].id} with x${a.min}`);
17863
+ }
17864
+ }
17865
+ }
17866
+ function noMatch(selector) {
17867
+ return fail(`No node matches ${selectorText(selector)}`, "Use a node id, [Tech] or any <kind> (any cache, any database, \u2026)");
17868
+ }
17869
+ function calls(run2, a) {
17870
+ const sc = scope(run2, a.useCase, a.scenario);
17871
+ if (isCheck(sc)) return sc;
17872
+ const what = selectorText(a.target);
17873
+ const ids = new Set(selectNodes(run2.diagram, a.target).map((n) => n.id));
17874
+ const called = (s) => s.steps.filter((step) => ids.has(step.toServiceId)).map((step) => step.toServiceId);
17875
+ const calling = sc.scenarios.filter((s) => called(s).length > 0);
17876
+ const names = (list) => list.map((s) => quote(s.name)).join(", ");
17877
+ const multi = sc.scenarios.length > 1;
17878
+ if (a.quantifier === "never") {
17879
+ if (calling.length === 0) return pass(`${sc.label} never calls ${what}`);
17880
+ const first = calling[0];
17881
+ return fail(
17882
+ `${multi ? quote(first.name) : sc.label} calls ${nodeLabel(run2.diagram, called(first)[0])}`,
17883
+ `Take the call to ${called(first)[0]} out of ${quote(first.name)}, e.g. serve it from a cache`
17884
+ );
17885
+ }
17886
+ if (ids.size === 0) return noMatch(a.target);
17887
+ if (a.quantifier === "some") {
17888
+ if (calling.length) return pass(`${sc.label} calls ${what}${multi ? ` in ${names(calling)}` : ""}`);
17889
+ return fail(`${sc.label} never calls ${what}`, `Add a step to ${what}`);
17890
+ }
17891
+ const missing = sc.scenarios.filter((s) => !calling.includes(s));
17892
+ if (missing.length === 0 && sc.scenarios.length) return pass(`Every scenario of ${sc.label} calls ${what}`);
17893
+ return fail(`${sc.label} does not call ${what} in ${names(missing) || "any scenario"}`, `Add a step to ${what} in ${names(missing) || "a scenario"}`);
17894
+ }
17895
+ function before(run2, a) {
17896
+ const sc = scope(run2, a.useCase, a.scenario);
17897
+ if (isCheck(sc)) return sc;
17898
+ const first = new Set(selectNodes(run2.diagram, a.first).map((n) => n.id));
17899
+ const then = new Set(selectNodes(run2.diagram, a.then).map((n) => n.id));
17900
+ const [x, y] = [selectorText(a.first), selectorText(a.then)];
17901
+ if (first.size === 0) return noMatch(a.first);
17902
+ if (then.size === 0) return noMatch(a.then);
17903
+ let checked = 0;
17904
+ for (const s of sc.scenarios) {
17905
+ const order = requestOrder(sc.u, s).requests.map((m) => m.to);
17906
+ const iy = order.findIndex((id) => then.has(id));
17907
+ if (iy < 0) continue;
17908
+ checked++;
17909
+ const ix = order.findIndex((id) => first.has(id));
17910
+ if (ix < 0 || ix > iy) {
17911
+ const where = sc.scenarios.length > 1 || a.scenario ? `In ${quote(s.name)}` : `In ${quote(sc.u.name)}`;
17912
+ return fail(
17913
+ ix < 0 ? `${where}, ${order[iy]} is called but ${x} never is` : `${where}, ${order[iy]} is called before ${order[ix]}`,
17914
+ `Call ${x} before the first call to ${y}`
17915
+ );
17916
+ }
17917
+ }
17918
+ if (checked === 0) return fail(`${sc.label} never calls ${y}`, `Add a step to ${y} after the call to ${x}`);
17919
+ return pass(`${sc.label} calls ${x} before ${y}${checked > 1 ? ` in all ${checked} scenarios that call ${y}` : ""}`);
17920
+ }
17921
+ function statusMatches(status, wanted) {
17922
+ if (status === void 0) return false;
17923
+ const cls = /^([1-5])xx$/i.exec(wanted);
17924
+ return cls ? Math.floor(status / 100) === Number(cls[1]) : String(status) === wanted;
17925
+ }
17926
+ function responds(run2, a) {
17927
+ const sc = scope(run2, a.useCase, a.scenario);
17928
+ if (isCheck(sc)) return sc;
17929
+ const hit = sc.scenarios.find((s) => statusMatches(s.steps[0]?.statusCode, a.status));
17930
+ if (hit) return pass(`${sc.label} responds ${hit.steps[0].statusCode}${sc.scenarios.length > 1 ? ` in ${quote(hit.name)}` : ""}`);
17931
+ const seen = [...new Set(sc.scenarios.map((s) => s.steps[0]?.failed ? "a failed call" : s.steps[0]?.statusCode ?? "no status"))];
17932
+ 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}`);
17933
+ }
17934
+ function noPath(run2, from, to) {
17935
+ const sources = new Set(selectNodes(run2.diagram, from).map((n) => n.id));
17936
+ const targets = new Set(selectNodes(run2.diagram, to).map((n) => n.id));
17937
+ const [x, y] = [selectorText(from), selectorText(to)];
17938
+ const line = (loc) => `${loc.file ? `${loc.file}:` : "line "}${loc.line}`;
17939
+ const hint = `Route it through a node that guards ${y} (a gateway or a service) instead of going there directly`;
17940
+ const edge = run2.diagram.edges.find((e) => sources.has(e.source) && targets.has(e.target));
17941
+ if (edge) return fail(`The connection ${edge.source} -> ${edge.target} (${line(edge.loc)}) goes directly from ${x} to ${y}`, hint);
17942
+ for (const u of run2.diagram.useCases) {
17943
+ for (const s of u.scenarios) {
17944
+ const step = s.steps.find((st) => sources.has(st.fromServiceId) && targets.has(st.toServiceId));
17945
+ if (step) {
17946
+ const where = u.scenarios.length > 1 ? `${quote(u.name)} scenario ${quote(s.name)}` : quote(u.name);
17947
+ return fail(`The step ${step.fromServiceId} -> ${step.toServiceId} in ${where} (${line(step.loc)}) goes directly from ${x} to ${y}`, hint);
17948
+ }
17949
+ }
17950
+ }
17951
+ return pass(`No connection or step goes directly from ${x} to ${y}`);
17952
+ }
17953
+
16346
17954
  // src/fmt.ts
16347
17955
  var FMT_USAGE = ` proschi fmt [--check] <file|dir>...`;
16348
17956
  var FMT_HELP = `fmt Rewrites files in the canonical layout and lists the ones it changed.
@@ -16387,6 +17995,161 @@ function displayPath2(file) {
16387
17995
  // src/imports.ts
16388
17996
  var import_node_fs4 = require("node:fs");
16389
17997
  var import_node_path5 = require("node:path");
17998
+
17999
+ // src/simulation.ts
18000
+ var SIM_USAGE = ` proschi test [--format text|github|json] <file|dir>...
18001
+ proschi analyze [--format text|json] <file>`;
18002
+ var SIM_HELP = `test Runs the requirements and test blocks of each file (imports are
18003
+ followed) against the simulation. Exits with 1 if any fails or a file
18004
+ has errors.
18005
+ analyze Prints the capacity table: load, utilisation, latency, availability
18006
+ and cost per node; latency percentiles per use case and scenario.`;
18007
+ function testFiles(files, load = parseFile) {
18008
+ return files.map((file) => {
18009
+ const { diagram, diagnostics } = load(file);
18010
+ return { file: displayPath3(file), errors: diagnostics.filter((d) => d.severity === "error"), results: runTests(diagram) };
18011
+ });
18012
+ }
18013
+ var escape2 = (s) => s.replace(/%/g, "%25").replace(/\r?\n/g, "%0A");
18014
+ function formatTestReports(reports, style) {
18015
+ if (style === "json") return JSON.stringify(reports, null, 2);
18016
+ const lines = [];
18017
+ for (const { file, errors, results } of reports) {
18018
+ const where = (loc) => ({ file: loc?.file ?? file, line: loc?.line ?? 1, col: loc?.col ?? 1 });
18019
+ if (style === "github") {
18020
+ for (const e of errors) lines.push(`::error file=${e.file ?? file},line=${e.line},col=${e.col},title=proschi::${escape2(e.message)}`);
18021
+ for (const r of results.filter((r2) => !r2.passed)) {
18022
+ const at = where(r.loc);
18023
+ lines.push(`::error file=${at.file},line=${at.line},col=${at.col},title=proschi test: ${escape2(r.name)}::${escape2(r.hint ? `${r.message}. ${r.hint}` : r.message)}`);
18024
+ }
18025
+ continue;
18026
+ }
18027
+ lines.push(file);
18028
+ for (const e of errors) lines.push(` error ${e.file ? `${e.file}:` : ""}${e.line}:${e.col}: ${e.message}`);
18029
+ if (results.length === 0) lines.push(" no requirements or tests");
18030
+ for (const r of results) {
18031
+ const at = r.loc ? ` (${r.loc.file ? `${r.loc.file}:` : "line "}${r.loc.line})` : "";
18032
+ lines.push(` ${r.passed ? "\u2713" : "\u2717"} ${r.name}${r.passed ? "" : at}: ${r.message}`);
18033
+ if (!r.passed) {
18034
+ for (const a of r.assertions ?? []) if (!a.passed && r.assertions.length > 1) lines.push(` \u2717 line ${a.loc.line}: ${a.message}`);
18035
+ if (r.hint) lines.push(` \u2192 ${r.hint}`);
18036
+ }
18037
+ }
18038
+ }
18039
+ if (style === "text") {
18040
+ const all = reports.flatMap((r) => r.results);
18041
+ const failed = all.filter((r) => !r.passed).length;
18042
+ const errors = reports.reduce((n, r) => n + r.errors.length, 0);
18043
+ const files = reports.length === 1 ? "1 file" : `${reports.length} files`;
18044
+ lines.push(`${files}: ${all.length - failed} passed, ${failed} failed${errors ? `, ${errors} error(s)` : ""}`);
18045
+ }
18046
+ return lines.join("\n");
18047
+ }
18048
+ function parseArgs(args, styles, err) {
18049
+ let style = "text";
18050
+ const paths = [];
18051
+ for (let i = 0; i < args.length; i++) {
18052
+ const arg = args[i];
18053
+ if (arg === "--format") {
18054
+ const value = args[++i];
18055
+ if (!styles.includes(value)) {
18056
+ err(`Unknown format '${value ?? ""}'; use ${styles.join(", ")}`);
18057
+ return void 0;
18058
+ }
18059
+ style = value;
18060
+ } else if (arg.startsWith("-")) {
18061
+ err(`Unknown option ${arg}
18062
+
18063
+ Usage:
18064
+ ${SIM_USAGE}`);
18065
+ return void 0;
18066
+ } else paths.push(arg);
18067
+ }
18068
+ if (paths.length === 0) {
18069
+ err(`Usage:
18070
+ ${SIM_USAGE}`);
18071
+ return void 0;
18072
+ }
18073
+ return { style, paths };
18074
+ }
18075
+ function runTest(args, collectFiles2, out, err, load = parseFile) {
18076
+ const parsed = parseArgs(args, ["text", "github", "json"], err);
18077
+ if (!parsed) return 2;
18078
+ let reports;
18079
+ try {
18080
+ reports = testFiles(collectFiles2(parsed.paths), load);
18081
+ } catch (e) {
18082
+ err(String(e.message));
18083
+ return 2;
18084
+ }
18085
+ const text = formatTestReports(reports, parsed.style);
18086
+ if (text) out(text);
18087
+ return reports.some((r) => r.errors.length > 0 || r.results.some((t) => !t.passed)) ? 1 : 0;
18088
+ }
18089
+ function runAnalyze(args, out, err, load = parseFile) {
18090
+ const parsed = parseArgs(args, ["text", "json"], err);
18091
+ if (!parsed) return 2;
18092
+ if (parsed.paths.length !== 1) {
18093
+ err(`Usage:
18094
+ ${SIM_USAGE}`);
18095
+ return 2;
18096
+ }
18097
+ const [file] = parsed.paths;
18098
+ let result;
18099
+ try {
18100
+ result = load(file);
18101
+ } catch (e) {
18102
+ err(String(e.message));
18103
+ return 2;
18104
+ }
18105
+ const analysis = analyze(result.diagram);
18106
+ if (parsed.style === "json") out(JSON.stringify({ file: displayPath3(file), analysis }, null, 2));
18107
+ else out(capacityReport(result.diagram, analysis, displayPath3(file)));
18108
+ return result.diagnostics.some((d) => d.severity === "error") ? 1 : 0;
18109
+ }
18110
+ function table(rows, left = 1) {
18111
+ const widths = rows[0].map((_, c) => Math.max(...rows.map((r) => r[c].length)));
18112
+ return rows.map((r) => r.map((cell, c) => c < left ? cell.padEnd(widths[c]) : cell.padStart(widths[c])).join(" ").trimEnd());
18113
+ }
18114
+ function capacityReport(diagram, analysis, file) {
18115
+ const lines = [diagram.title ? `${diagram.title} (${file})` : file, ""];
18116
+ if ((diagram.traffic ?? []).length === 0) lines.push("No traffic { \u2026 } block: loads are zero and latencies are at idle.", "");
18117
+ const nodes = analysis.nodes.filter((n) => n.kind !== "client" && n.kind !== "other");
18118
+ lines.push(
18119
+ ...table([
18120
+ ["Node", "Kind", "Replicas", "Load", "Capacity", "Util", "Latency", "Availability", "Cost/month"],
18121
+ ...nodes.map((n) => [
18122
+ n.id,
18123
+ n.kind,
18124
+ String(n.replicas),
18125
+ formatRps(n.loadRps),
18126
+ formatRps(n.capacityRps),
18127
+ `${formatPercent(n.utilization)}${n.saturated ? " SATURATED" : n.utilization > HOT ? " hot" : ""}`,
18128
+ formatMs(n.latencyMs),
18129
+ formatAvailability(n.availability),
18130
+ formatUsd(n.costUsd).replace("/month", "")
18131
+ ])
18132
+ ], 2),
18133
+ "",
18134
+ `Total cost: ${formatUsd(analysis.totalCostUsd)}`,
18135
+ ""
18136
+ );
18137
+ if (analysis.useCases.length) {
18138
+ const rows = [["Use case", "Traffic", "p50", "p95", "p99", "p99.9", "Availability"]];
18139
+ for (const u of analysis.useCases) {
18140
+ rows.push([u.name, u.rps > 0 ? formatRps(u.rps) : "-", ...["p50", "p95", "p99", "p999"].map((p) => formatMs(u.percentiles[p])), formatAvailability(u.availability)]);
18141
+ if (u.scenarios.length > 1) {
18142
+ for (const s of u.scenarios) rows.push([` ${s.name}`, formatPercent(s.share), formatMs(s.percentiles.p50), formatMs(s.percentiles.p95), formatMs(s.percentiles.p99), formatMs(s.percentiles.p999), ""]);
18143
+ }
18144
+ }
18145
+ lines.push(...table(rows), "");
18146
+ }
18147
+ lines.push(`Single points of failure: ${analysis.singlePointsOfFailure.join(", ") || "none"}`);
18148
+ if (analysis.warnings.length) lines.push("", "Warnings:", ...analysis.warnings.map((w) => ` ${w}`));
18149
+ return lines.join("\n");
18150
+ }
18151
+
18152
+ // src/imports.ts
16390
18153
  function displayPath3(file, cwd = process.cwd()) {
16391
18154
  const abs = (0, import_node_path5.resolve)(cwd, file);
16392
18155
  const rel = (0, import_node_path5.relative)(cwd, abs);
@@ -16422,12 +18185,13 @@ function checkFiles(files, extra) {
16422
18185
  }
16423
18186
 
16424
18187
  // src/cli.ts
16425
- var VERSION = true ? "0.2.0" : "dev";
18188
+ var VERSION = true ? "0.3.0" : "dev";
16426
18189
  var USAGE = `Usage:
16427
18190
  proschi check [--strict] [--format text|github|json] [--openapi <node>=<spec>]... <file|dir>...
16428
18191
  proschi parse <file>
16429
18192
  ${RENDER_USAGE}
16430
18193
  ${FMT_USAGE}
18194
+ ${SIM_USAGE}
16431
18195
  proschi --version
16432
18196
 
16433
18197
  check Reports errors and warnings. Directories are searched for *.proschi files.
@@ -16439,7 +18203,8 @@ check Reports errors and warnings. Directories are searched for *.proschi file
16439
18203
  parse Prints {"diagram", "diagnostics"} as JSON; the shape is described by
16440
18204
  schema/proschi-diagram.schema.json.
16441
18205
  ${RENDER_HELP}
16442
- ${FMT_HELP}`;
18206
+ ${FMT_HELP}
18207
+ ${SIM_HELP}`;
16443
18208
  function collectFiles(paths) {
16444
18209
  const out = [];
16445
18210
  const walk = (dir) => {
@@ -16497,6 +18262,8 @@ function run(argv, out = console.log, err = console.error) {
16497
18262
  }
16498
18263
  if (command === "render") return import("./render.cjs").then((m) => m.renderCommand(rest, out, err, USAGE));
16499
18264
  if (command === "fmt") return runFmt(rest, collectFiles, out, err);
18265
+ if (command === "test") return runTest(rest, collectFiles, out, err);
18266
+ if (command === "analyze") return runAnalyze(rest, out, err);
16500
18267
  if (command === "check") {
16501
18268
  let strict = false;
16502
18269
  let style = "text";