proschi 0.12.0 → 0.13.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
@@ -11422,10 +11422,10 @@ var require_resolve_block_map = __commonJS({
11422
11422
  let offset = bm.offset;
11423
11423
  let commentEnd = null;
11424
11424
  for (const collItem of bm.items) {
11425
- const { start, key, sep, value } = collItem;
11425
+ const { start, key, sep: sep2, value } = collItem;
11426
11426
  const keyProps = resolveProps.resolveProps(start, {
11427
11427
  indicator: "explicit-key-ind",
11428
- next: key ?? sep?.[0],
11428
+ next: key ?? sep2?.[0],
11429
11429
  offset,
11430
11430
  onError,
11431
11431
  parentIndent: bm.indent,
@@ -11439,7 +11439,7 @@ var require_resolve_block_map = __commonJS({
11439
11439
  else if ("indent" in key && key.indent !== bm.indent)
11440
11440
  onError(offset, "BAD_INDENT", startColMsg);
11441
11441
  }
11442
- if (!keyProps.anchor && !keyProps.tag && !sep) {
11442
+ if (!keyProps.anchor && !keyProps.tag && !sep2) {
11443
11443
  commentEnd = keyProps.end;
11444
11444
  if (keyProps.comment) {
11445
11445
  if (map.comment)
@@ -11463,7 +11463,7 @@ var require_resolve_block_map = __commonJS({
11463
11463
  ctx.atKey = false;
11464
11464
  if (utilMapIncludes.mapIncludes(ctx, map.items, keyNode))
11465
11465
  onError(keyStart, "DUPLICATE_KEY", "Map keys must be unique");
11466
- const valueProps = resolveProps.resolveProps(sep ?? [], {
11466
+ const valueProps = resolveProps.resolveProps(sep2 ?? [], {
11467
11467
  indicator: "map-value-ind",
11468
11468
  next: value,
11469
11469
  offset: keyNode.range[2],
@@ -11479,7 +11479,7 @@ var require_resolve_block_map = __commonJS({
11479
11479
  if (ctx.options.strict && keyProps.start < valueProps.found.offset - 1024)
11480
11480
  onError(keyNode.range, "KEY_OVER_1024_CHARS", "The : indicator must be at most 1024 chars after the start of an implicit block mapping key");
11481
11481
  }
11482
- const valueNode = value ? composeNode(ctx, value, valueProps, onError) : composeEmptyNode(ctx, offset, sep, null, valueProps, onError);
11482
+ const valueNode = value ? composeNode(ctx, value, valueProps, onError) : composeEmptyNode(ctx, offset, sep2, null, valueProps, onError);
11483
11483
  if (ctx.schema.compat)
11484
11484
  utilFlowIndentCheck.flowIndentCheck(bm.indent, value, onError);
11485
11485
  offset = valueNode.range[2];
@@ -11570,7 +11570,7 @@ var require_resolve_end = __commonJS({
11570
11570
  let comment = "";
11571
11571
  if (end) {
11572
11572
  let hasSpace = false;
11573
- let sep = "";
11573
+ let sep2 = "";
11574
11574
  for (const token of end) {
11575
11575
  const { source: source2, type } = token;
11576
11576
  switch (type) {
@@ -11584,13 +11584,13 @@ var require_resolve_end = __commonJS({
11584
11584
  if (!comment)
11585
11585
  comment = cb;
11586
11586
  else
11587
- comment += sep + cb;
11588
- sep = "";
11587
+ comment += sep2 + cb;
11588
+ sep2 = "";
11589
11589
  break;
11590
11590
  }
11591
11591
  case "newline":
11592
11592
  if (comment)
11593
- sep += source2;
11593
+ sep2 += source2;
11594
11594
  hasSpace = true;
11595
11595
  break;
11596
11596
  default:
@@ -11633,18 +11633,18 @@ var require_resolve_flow_collection = __commonJS({
11633
11633
  let offset = fc.offset + fc.start.source.length;
11634
11634
  for (let i = 0; i < fc.items.length; ++i) {
11635
11635
  const collItem = fc.items[i];
11636
- const { start, key, sep, value } = collItem;
11636
+ const { start, key, sep: sep2, value } = collItem;
11637
11637
  const props = resolveProps.resolveProps(start, {
11638
11638
  flow: fcName,
11639
11639
  indicator: "explicit-key-ind",
11640
- next: key ?? sep?.[0],
11640
+ next: key ?? sep2?.[0],
11641
11641
  offset,
11642
11642
  onError,
11643
11643
  parentIndent: fc.indent,
11644
11644
  startOnNewline: false
11645
11645
  });
11646
11646
  if (!props.found) {
11647
- if (!props.anchor && !props.tag && !sep && !value) {
11647
+ if (!props.anchor && !props.tag && !sep2 && !value) {
11648
11648
  if (i === 0 && props.comma)
11649
11649
  onError(props.comma, "UNEXPECTED_TOKEN", `Unexpected , in ${fcName}`);
11650
11650
  else if (i < fc.items.length - 1)
@@ -11698,8 +11698,8 @@ var require_resolve_flow_collection = __commonJS({
11698
11698
  }
11699
11699
  }
11700
11700
  }
11701
- if (!isMap && !sep && !props.found) {
11702
- const valueNode = value ? composeNode(ctx, value, props, onError) : composeEmptyNode(ctx, props.end, sep, null, props, onError);
11701
+ if (!isMap && !sep2 && !props.found) {
11702
+ const valueNode = value ? composeNode(ctx, value, props, onError) : composeEmptyNode(ctx, props.end, sep2, null, props, onError);
11703
11703
  coll.items.push(valueNode);
11704
11704
  offset = valueNode.range[2];
11705
11705
  if (isBlock(value))
@@ -11711,7 +11711,7 @@ var require_resolve_flow_collection = __commonJS({
11711
11711
  if (isBlock(key))
11712
11712
  onError(keyNode.range, "BLOCK_IN_FLOW", blockMsg);
11713
11713
  ctx.atKey = false;
11714
- const valueProps = resolveProps.resolveProps(sep ?? [], {
11714
+ const valueProps = resolveProps.resolveProps(sep2 ?? [], {
11715
11715
  flow: fcName,
11716
11716
  indicator: "map-value-ind",
11717
11717
  next: value,
@@ -11722,8 +11722,8 @@ var require_resolve_flow_collection = __commonJS({
11722
11722
  });
11723
11723
  if (valueProps.found) {
11724
11724
  if (!isMap && !props.found && ctx.options.strict) {
11725
- if (sep)
11726
- for (const st of sep) {
11725
+ if (sep2)
11726
+ for (const st of sep2) {
11727
11727
  if (st === valueProps.found)
11728
11728
  break;
11729
11729
  if (st.type === "newline") {
@@ -11740,7 +11740,7 @@ var require_resolve_flow_collection = __commonJS({
11740
11740
  else
11741
11741
  onError(valueProps.start, "MISSING_CHAR", `Missing , or : between ${fcName} items`);
11742
11742
  }
11743
- const valueNode = value ? composeNode(ctx, value, valueProps, onError) : valueProps.found ? composeEmptyNode(ctx, valueProps.end, sep, null, valueProps, onError) : null;
11743
+ const valueNode = value ? composeNode(ctx, value, valueProps, onError) : valueProps.found ? composeEmptyNode(ctx, valueProps.end, sep2, null, valueProps, onError) : null;
11744
11744
  if (valueNode) {
11745
11745
  if (isBlock(value))
11746
11746
  onError(valueNode.range, "BLOCK_IN_FLOW", blockMsg);
@@ -11920,7 +11920,7 @@ var require_resolve_block_scalar = __commonJS({
11920
11920
  chompStart = i + 1;
11921
11921
  }
11922
11922
  let value = "";
11923
- let sep = "";
11923
+ let sep2 = "";
11924
11924
  let prevMoreIndented = false;
11925
11925
  for (let i = 0; i < contentStart; ++i)
11926
11926
  value += lines[i][0].slice(trimIndent) + "\n";
@@ -11937,24 +11937,24 @@ var require_resolve_block_scalar = __commonJS({
11937
11937
  indent2 = "";
11938
11938
  }
11939
11939
  if (type === Scalar.Scalar.BLOCK_LITERAL) {
11940
- value += sep + indent2.slice(trimIndent) + content;
11941
- sep = "\n";
11940
+ value += sep2 + indent2.slice(trimIndent) + content;
11941
+ sep2 = "\n";
11942
11942
  } else if (indent2.length > trimIndent || content[0] === " ") {
11943
- if (sep === " ")
11944
- sep = "\n";
11945
- else if (!prevMoreIndented && sep === "\n")
11946
- sep = "\n\n";
11947
- value += sep + indent2.slice(trimIndent) + content;
11948
- sep = "\n";
11943
+ if (sep2 === " ")
11944
+ sep2 = "\n";
11945
+ else if (!prevMoreIndented && sep2 === "\n")
11946
+ sep2 = "\n\n";
11947
+ value += sep2 + indent2.slice(trimIndent) + content;
11948
+ sep2 = "\n";
11949
11949
  prevMoreIndented = true;
11950
11950
  } else if (content === "") {
11951
- if (sep === "\n")
11951
+ if (sep2 === "\n")
11952
11952
  value += "\n";
11953
11953
  else
11954
- sep = "\n";
11954
+ sep2 = "\n";
11955
11955
  } else {
11956
- value += sep + content;
11957
- sep = " ";
11956
+ value += sep2 + content;
11957
+ sep2 = " ";
11958
11958
  prevMoreIndented = false;
11959
11959
  }
11960
11960
  }
@@ -12137,25 +12137,25 @@ var require_resolve_flow_scalar = __commonJS({
12137
12137
  trimBoth = /^[ \t]+|[ \t]+$/g;
12138
12138
  }
12139
12139
  let res = match[1].replace(trimEnd, "");
12140
- let sep = " ";
12140
+ let sep2 = " ";
12141
12141
  let pos = line.lastIndex;
12142
12142
  while (match = line.exec(source2)) {
12143
12143
  const lm = match[1].replace(trimBoth, "");
12144
12144
  if (lm === "") {
12145
- if (sep === "\n")
12146
- res += sep;
12145
+ if (sep2 === "\n")
12146
+ res += sep2;
12147
12147
  else
12148
- sep = "\n";
12148
+ sep2 = "\n";
12149
12149
  } else {
12150
- res += sep + lm;
12151
- sep = " ";
12150
+ res += sep2 + lm;
12151
+ sep2 = " ";
12152
12152
  }
12153
12153
  pos = line.lastIndex;
12154
12154
  }
12155
12155
  const last = /[ \t]*(.*)/sy;
12156
12156
  last.lastIndex = pos;
12157
12157
  match = last.exec(source2);
12158
- return res + sep + (match?.[1] ?? "");
12158
+ return res + sep2 + (match?.[1] ?? "");
12159
12159
  }
12160
12160
  function doubleQuotedValue(source2, onError) {
12161
12161
  let res = "";
@@ -12965,14 +12965,14 @@ var require_cst_stringify = __commonJS({
12965
12965
  }
12966
12966
  }
12967
12967
  }
12968
- function stringifyItem({ start, key, sep, value }) {
12968
+ function stringifyItem({ start, key, sep: sep2, value }) {
12969
12969
  let res = "";
12970
12970
  for (const st of start)
12971
12971
  res += st.source;
12972
12972
  if (key)
12973
12973
  res += stringifyToken(key);
12974
- if (sep)
12975
- for (const st of sep)
12974
+ if (sep2)
12975
+ for (const st of sep2)
12976
12976
  res += st.source;
12977
12977
  if (value)
12978
12978
  res += stringifyToken(value);
@@ -14139,18 +14139,18 @@ var require_parser = __commonJS({
14139
14139
  if (this.type === "map-value-ind") {
14140
14140
  const prev = getPrevProps(this.peek(2));
14141
14141
  const start = getFirstKeyStartProps(prev);
14142
- let sep;
14142
+ let sep2;
14143
14143
  if (scalar2.end) {
14144
- sep = scalar2.end;
14145
- sep.push(this.sourceToken);
14144
+ sep2 = scalar2.end;
14145
+ sep2.push(this.sourceToken);
14146
14146
  delete scalar2.end;
14147
14147
  } else
14148
- sep = [this.sourceToken];
14148
+ sep2 = [this.sourceToken];
14149
14149
  const map = {
14150
14150
  type: "block-map",
14151
14151
  offset: scalar2.offset,
14152
14152
  indent: scalar2.indent,
14153
- items: [{ start, key: scalar2, sep }]
14153
+ items: [{ start, key: scalar2, sep: sep2 }]
14154
14154
  };
14155
14155
  this.onKeyLine = true;
14156
14156
  this.stack[this.stack.length - 1] = map;
@@ -14303,15 +14303,15 @@ var require_parser = __commonJS({
14303
14303
  } else if (isFlowToken(it.key) && !includesToken(it.sep, "newline")) {
14304
14304
  const start2 = getFirstKeyStartProps(it.start);
14305
14305
  const key = it.key;
14306
- const sep = it.sep;
14307
- sep.push(this.sourceToken);
14306
+ const sep2 = it.sep;
14307
+ sep2.push(this.sourceToken);
14308
14308
  delete it.key;
14309
14309
  delete it.sep;
14310
14310
  this.stack.push({
14311
14311
  type: "block-map",
14312
14312
  offset: this.offset,
14313
14313
  indent: this.indent,
14314
- items: [{ start: start2, key, sep }]
14314
+ items: [{ start: start2, key, sep: sep2 }]
14315
14315
  });
14316
14316
  } else if (start.length > 0) {
14317
14317
  it.sep = it.sep.concat(start, this.sourceToken);
@@ -14505,13 +14505,13 @@ var require_parser = __commonJS({
14505
14505
  const prev = getPrevProps(parent);
14506
14506
  const start = getFirstKeyStartProps(prev);
14507
14507
  fixFlowSeqItems(fc);
14508
- const sep = fc.end.splice(1, fc.end.length);
14509
- sep.push(this.sourceToken);
14508
+ const sep2 = fc.end.splice(1, fc.end.length);
14509
+ sep2.push(this.sourceToken);
14510
14510
  const map = {
14511
14511
  type: "block-map",
14512
14512
  offset: fc.offset,
14513
14513
  indent: fc.indent,
14514
- items: [{ start, key: fc, sep }]
14514
+ items: [{ start, key: fc, sep: sep2 }]
14515
14515
  };
14516
14516
  this.onKeyLine = true;
14517
14517
  this.stack[this.stack.length - 1] = map;
@@ -15253,8 +15253,8 @@ __export(cli_exports, {
15253
15253
  run: () => run
15254
15254
  });
15255
15255
  module.exports = __toCommonJS(cli_exports);
15256
- var import_node_fs11 = require("node:fs");
15257
- var import_node_path11 = require("node:path");
15256
+ var import_node_fs14 = require("node:fs");
15257
+ var import_node_path13 = require("node:path");
15258
15258
 
15259
15259
  // src/openapi/config.ts
15260
15260
  var import_node_fs2 = require("node:fs");
@@ -16278,6 +16278,8 @@ function editDistance2(a, b) {
16278
16278
  var HTTP_METHODS = ["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"];
16279
16279
  var DEFAULT_TECH = "Rectangle";
16280
16280
  var DEFAULT_GROUP_TECH = "Logical Group";
16281
+ var SYSTEM_TECH = "Service";
16282
+ var STUB_HELP = "a rate, p99, latency or availability, e.g. capacity { 2k rps p99 150ms availability 99.95% }";
16281
16283
  var MAX_SCENARIOS = 32;
16282
16284
  var MAX_ALT_DEPTH = 16;
16283
16285
  var DIRECTIONS = ["down", "right", "up", "left"];
@@ -16321,7 +16323,7 @@ function unknownTechMessage(tech, kind, suggestion) {
16321
16323
  function parse(source2, options = {}) {
16322
16324
  return new Parser(source2, options).run();
16323
16325
  }
16324
- var Parser = class {
16326
+ var Parser = class _Parser {
16325
16327
  // Per-file state, swapped while an imported file is read.
16326
16328
  lines;
16327
16329
  /** Path of the imported file being read; undefined for the root document. */
@@ -16342,7 +16344,12 @@ var Parser = class {
16342
16344
  /** Files being read, outermost first, to find import cycles. */
16343
16345
  chain;
16344
16346
  /** Diagnostics are sorted by file in the order the files were first read, the root first. */
16345
- fileOrder = /* @__PURE__ */ new Map([[void 0, 0]]);
16347
+ fileOrder;
16348
+ /** The file read as the root: undefined for a document, the path for a system's team file (its own scope). */
16349
+ rootFile;
16350
+ teamFiles;
16351
+ systems = [];
16352
+ systemById = /* @__PURE__ */ new Map();
16346
16353
  /** `a|b` for every connection, both directions; filled before use cases are built. */
16347
16354
  connected = /* @__PURE__ */ new Set();
16348
16355
  title;
@@ -16364,14 +16371,18 @@ var Parser = class {
16364
16371
  deferred = [];
16365
16372
  built = [];
16366
16373
  options;
16367
- constructor(source2, options) {
16374
+ constructor(source2, options, scope2) {
16368
16375
  this.options = options;
16369
16376
  this.lines = source2.split("\n");
16370
- this.chain = [options.path];
16377
+ this.chain = scope2 ? scope2.chain : [options.path];
16378
+ this.teamFiles = scope2?.teamFiles ?? /* @__PURE__ */ new Map();
16379
+ if (scope2) this.rootFile = this.file = options.path;
16380
+ this.fileOrder = /* @__PURE__ */ new Map([[this.rootFile, 0]]);
16371
16381
  if (options.path !== void 0) this.included.add(options.path);
16372
16382
  }
16373
16383
  run() {
16374
16384
  this.parseFile();
16385
+ this.resolvePorts();
16375
16386
  this.createImplicitNodes();
16376
16387
  this.resolveTags();
16377
16388
  for (const e of this.edges) this.connected.add(`${e.source}|${e.target}`).add(`${e.target}|${e.source}`);
@@ -16394,7 +16405,8 @@ var Parser = class {
16394
16405
  ...this.decisions.length ? { decisions: this.decisions } : {},
16395
16406
  ...this.tests.length ? { tests: this.tests } : {},
16396
16407
  ...this.legend.length ? { legend: this.legend } : {},
16397
- ...Object.keys(this.layout).length ? { layout: this.layout } : {}
16408
+ ...Object.keys(this.layout).length ? { layout: this.layout } : {},
16409
+ ...this.systems.length ? { systems: this.systems } : {}
16398
16410
  };
16399
16411
  const order = (d) => this.fileOrder.get(d.file) ?? 0;
16400
16412
  this.diagnostics.sort((a, b) => order(a) - order(b) || a.line - b.line || a.col - b.col);
@@ -16411,14 +16423,16 @@ var Parser = class {
16411
16423
  i = this.parseLine(i);
16412
16424
  }
16413
16425
  for (const frame of this.stack) {
16414
- 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);
16426
+ 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" : frame.kind === "system" ? `system '${frame.system.id}'` : frame.kind === "stub" ? `the capacity of system '${frame.system.id}'` : sectionName(frame);
16415
16427
  this.error(`Missing } to close ${what}`, frame.loc);
16428
+ if (frame.kind === "system") this.finishSystem(frame);
16416
16429
  }
16417
16430
  }
16418
16431
  /** Parses the line at `index` and returns the index of the last line it consumed. */
16419
16432
  parseLine(index) {
16420
16433
  const line = index + 1;
16421
- const { tokens, diagnostics } = tokenizeLine(this.lines[index], line);
16434
+ const { tokens: lexed, diagnostics } = tokenizeLine(this.lines[index], line);
16435
+ const tokens = qualify(lexed);
16422
16436
  this.diagnostics.push(...diagnostics.map((d) => ({ ...d, ...this.fileField() })));
16423
16437
  if (tokens.length === 0 || diagnostics.some((d) => d.severity === "error")) return index;
16424
16438
  const [first, second, third] = tokens;
@@ -16439,13 +16453,17 @@ var Parser = class {
16439
16453
  if (first.kind === "rbrace") {
16440
16454
  this.stack.pop();
16441
16455
  if (!top.discard) this.closeSection(top);
16442
- if (!top.discard && this.file === void 0) this.blocks.push({ kind: top.section, start: top.loc.line, end: line });
16456
+ if (!top.discard && this.file === this.rootFile) this.blocks.push({ kind: top.section, start: top.loc.line, end: line });
16443
16457
  if (second) this.error("Unexpected input after }", loc(second));
16444
16458
  } else if (!top.discard) {
16445
16459
  this.parseSectionLine(top, tokens, line);
16446
16460
  }
16447
16461
  return index;
16448
16462
  }
16463
+ if (top?.kind === "system" || top?.kind === "stub") {
16464
+ this.parseSystemLine(top, tokens, line);
16465
+ return index;
16466
+ }
16449
16467
  if (first.kind === "rbrace") {
16450
16468
  if (this.stack.length === 0) this.error("Unmatched }", loc(first));
16451
16469
  else this.closeFrame();
@@ -16467,6 +16485,11 @@ var Parser = class {
16467
16485
  case "group":
16468
16486
  this.parseGroup(tokens, line);
16469
16487
  break;
16488
+ // A keyword only as `system id … {`, so a node may still be called system.
16489
+ case "system":
16490
+ if (second?.kind === "ident" && tokens[tokens.length - 1].kind === "lbrace") this.parseSystem(tokens, line);
16491
+ else this.parseNode(tokens, line);
16492
+ break;
16470
16493
  case "usecase":
16471
16494
  this.parseUseCase(tokens, line);
16472
16495
  break;
@@ -16521,7 +16544,7 @@ var Parser = class {
16521
16544
  return;
16522
16545
  }
16523
16546
  const summary = tokens[2]?.kind === "string" ? tokens[2] : void 0;
16524
- if (this.file === void 0) {
16547
+ if (this.file === this.rootFile) {
16525
16548
  this.title = value.value;
16526
16549
  if (summary) this.summary = summary.value;
16527
16550
  }
@@ -16553,7 +16576,7 @@ var Parser = class {
16553
16576
  this.error("direction is only allowed at the top level or inside a group", this.locOf(keyword, line));
16554
16577
  return;
16555
16578
  }
16556
- if (this.file !== void 0) return;
16579
+ if (this.file !== this.rootFile) return;
16557
16580
  if (this.layoutLocs.direction) {
16558
16581
  this.warning(`direction is already set ${this.where(this.layoutLocs.direction, loc)}; this one is ignored`, this.locOf(keyword, line));
16559
16582
  return;
@@ -16573,7 +16596,7 @@ var Parser = class {
16573
16596
  this.error("layout ordered applies to the whole diagram; write it at the top level", this.locOf(keyword, line));
16574
16597
  return;
16575
16598
  }
16576
- if (this.file !== void 0) return;
16599
+ if (this.file !== this.rootFile) return;
16577
16600
  if (this.layoutLocs.ordered) {
16578
16601
  this.warning(`layout ordered is already set ${this.where(this.layoutLocs.ordered, loc)}`, this.locOf(keyword, line));
16579
16602
  return;
@@ -16628,16 +16651,8 @@ var Parser = class {
16628
16651
  this.warning(`Imports are not resolved in this context; '${path}' was not loaded`, loc);
16629
16652
  return;
16630
16653
  }
16631
- let target;
16632
- try {
16633
- target = resolve8(path, this.file ?? this.options.path);
16634
- } catch {
16635
- target = void 0;
16636
- }
16637
- if (!target) {
16638
- this.error(`Cannot find '${path}'`, loc);
16639
- return;
16640
- }
16654
+ const target = this.resolveFile(path, loc);
16655
+ if (!target) return;
16641
16656
  entry.resolved = target.path;
16642
16657
  const cycleStart = this.chain.indexOf(target.path);
16643
16658
  if (cycleStart !== -1) {
@@ -16657,6 +16672,25 @@ var Parser = class {
16657
16672
  this.chain.pop();
16658
16673
  ({ lines: this.lines, file: this.file, stack: this.stack } = outer);
16659
16674
  }
16675
+ /**
16676
+ * Loads `path` through the resolver, relative to the file being read. A
16677
+ * resolver error named `ImportError` (outside the project root, not synced,
16678
+ * …) is reported with its own message; anything else is "Cannot find".
16679
+ */
16680
+ resolveFile(path, loc) {
16681
+ let target;
16682
+ try {
16683
+ target = this.options.resolve?.(path, this.file ?? this.options.path);
16684
+ } catch (e) {
16685
+ if (e instanceof Error && e.name === "ImportError") {
16686
+ this.error(e.message, loc);
16687
+ return void 0;
16688
+ }
16689
+ target = void 0;
16690
+ }
16691
+ if (!target) this.error(`Cannot find '${path}'`, loc);
16692
+ return target;
16693
+ }
16660
16694
  parseGroup(tokens, line) {
16661
16695
  const keyword = tokens[0];
16662
16696
  const top = this.top();
@@ -16716,6 +16750,273 @@ var Parser = class {
16716
16750
  this.addNode(group);
16717
16751
  this.stack.push({ kind: "group", id: idToken.value, loc });
16718
16752
  }
16753
+ // ---- Systems: a team's design behind a contract (docs/design/team-systems-and-imports.md) ----
16754
+ /** `system id ["Name"] [@team] [pos x,y] [color c] [.tag] [rank N] {` */
16755
+ parseSystem(tokens, line) {
16756
+ const [keyword, idToken] = tokens;
16757
+ const top = this.top();
16758
+ if (top && top.kind !== "group") {
16759
+ this.error("A system is declared at the top level or inside a group", this.locOf(keyword, line));
16760
+ this.skipSection("test", tokens, line);
16761
+ return;
16762
+ }
16763
+ const loc = this.locOf(idToken, line);
16764
+ const node = { id: idToken.value, kind: "system", name: idToken.value, type: "service", techStack: SYSTEM_TECH, parent: this.currentGroup(), loc };
16765
+ const style = {};
16766
+ let named = false;
16767
+ let i = 2;
16768
+ while (i < tokens.length - 1) {
16769
+ const t = tokens[i];
16770
+ const styleUsed = this.readStylePart(tokens, i, line, style, false);
16771
+ const used2 = styleUsed === 0 ? this.readRank(tokens, i, line, node) : styleUsed;
16772
+ if (used2 === void 0) return this.skipSection("test", tokens, line);
16773
+ if (used2 > 0) {
16774
+ i += used2;
16775
+ continue;
16776
+ }
16777
+ if (t.kind === "string" && !named) {
16778
+ node.name = t.value;
16779
+ named = true;
16780
+ } else if (t.kind === "string" && node.description === void 0) {
16781
+ node.description = t.value;
16782
+ } else if (t.kind === "team") {
16783
+ node.ownerTeam = t.value;
16784
+ } else if (isWord(t, "pos")) {
16785
+ const [x, comma, y] = [tokens[i + 1], tokens[i + 2], tokens[i + 3]];
16786
+ if (x?.kind !== "number" || comma?.kind !== "comma" || y?.kind !== "number") {
16787
+ this.error("Expected pos x,y", this.locOf(t, line));
16788
+ return this.skipSection("test", tokens, line);
16789
+ }
16790
+ node.position = { x: Number(x.value), y: Number(y.value) };
16791
+ i += 3;
16792
+ } else {
16793
+ this.error(`Unexpected ${describe(t)} in system header; write system id "Name" @Team {`, this.locOf(t, line));
16794
+ return this.skipSection("test", tokens, line);
16795
+ }
16796
+ i++;
16797
+ }
16798
+ this.applyStyle(node, style);
16799
+ const system = { id: node.id, entries: [], loc };
16800
+ if (!this.nodes.has(node.id)) {
16801
+ this.systems.push(system);
16802
+ this.systemById.set(system.id, system);
16803
+ }
16804
+ this.addNode(node);
16805
+ this.stack.push({ kind: "system", system, useCaseLocs: /* @__PURE__ */ new Map(), loc });
16806
+ }
16807
+ /** A line inside `system … { }` or its multi-line `capacity { }`. */
16808
+ parseSystemLine(frame, tokens, line) {
16809
+ const [first, second] = tokens;
16810
+ const { system } = frame;
16811
+ if (first.kind === "rbrace") {
16812
+ this.stack.pop();
16813
+ if (frame.kind === "system") this.finishSystem(frame);
16814
+ if (second) this.error("Unexpected input after }", this.locOf(second, line));
16815
+ return;
16816
+ }
16817
+ if (frame.kind === "stub") {
16818
+ this.readStub(system.stub, tokens, 0, tokens.length, line);
16819
+ return;
16820
+ }
16821
+ if (isWord(first, "from")) {
16822
+ if (second?.kind !== "string" || !second.value.trim()) {
16823
+ this.error('Expected the team file, e.g. from "teams/orders.proschi"', this.locOf(second ?? first, line));
16824
+ return;
16825
+ }
16826
+ if (system.from !== void 0) {
16827
+ this.error(`System '${system.id}' already has from "${system.from}"`, this.locOf(first, line));
16828
+ return;
16829
+ }
16830
+ this.expectEnd(tokens, 2, line);
16831
+ system.from = second.value;
16832
+ frame.fromLoc = this.locOf(second, line);
16833
+ return;
16834
+ }
16835
+ if (isWord(first, "expose")) return this.parseExpose(frame, tokens, line);
16836
+ if (isWord(first, "capacity") && second?.kind === "lbrace") {
16837
+ const opens = tokens[tokens.length - 1].kind !== "rbrace";
16838
+ if (system.stub) {
16839
+ this.error(`System '${system.id}' already has a capacity`, this.locOf(first, line));
16840
+ if (opens) this.stack.push({ kind: "section", section: "capacity", discard: true, loc: this.locOf(first, line) });
16841
+ return;
16842
+ }
16843
+ const stub = { loc: this.lineLoc(tokens, line) };
16844
+ system.stub = stub;
16845
+ this.readStub(stub, tokens, 2, opens ? tokens.length : tokens.length - 1, line);
16846
+ if (opens) this.stack.push({ kind: "stub", system, loc: this.locOf(first, line) });
16847
+ return;
16848
+ }
16849
+ this.error('Inside a system, write from "file", expose <id> "Name" -> "Use case" or capacity { \u2026 }', this.locOf(first, line));
16850
+ if (tokens[tokens.length - 1].kind === "lbrace") this.stack.push({ kind: "section", section: "test", discard: true, loc: this.locOf(first, line) });
16851
+ }
16852
+ /** `expose id ["Name"] [-> "Use case"]` */
16853
+ parseExpose(frame, tokens, line) {
16854
+ const [keyword, idToken] = tokens;
16855
+ if (idToken?.kind !== "ident") {
16856
+ this.error('Expected an entry point id, e.g. expose api "Orders API" -> "Place order"', this.locOf(idToken ?? keyword, line));
16857
+ return;
16858
+ }
16859
+ const entry = { id: idToken.value, name: idToken.value, loc: this.locOf(idToken, line) };
16860
+ let i = 2;
16861
+ if (tokens[i]?.kind === "string") entry.name = tokens[i++].value;
16862
+ if (tokens[i]?.kind === "arrow") {
16863
+ const useCase = tokens[i + 1];
16864
+ if (tokens[i].value !== "->" || useCase?.kind !== "string" && useCase?.kind !== "ident") {
16865
+ this.error('Expected -> "Use case" after the entry point', this.locOf(useCase ?? tokens[i], line));
16866
+ return;
16867
+ }
16868
+ entry.useCase = useCase.value;
16869
+ frame.useCaseLocs.set(entry, this.locOf(useCase, line));
16870
+ i += 2;
16871
+ }
16872
+ if (!this.expectEnd(tokens, i, line)) return;
16873
+ if (frame.system.entries.some((e) => e.id === entry.id)) {
16874
+ this.error(`System '${frame.system.id}' already exposes '${entry.id}'`, entry.loc);
16875
+ return;
16876
+ }
16877
+ frame.system.entries.push(entry);
16878
+ }
16879
+ /** The parts of a system's `capacity { … }` in `tokens[from, to)`. */
16880
+ readStub(stub, tokens, from, to, line) {
16881
+ for (let i = from; i < to; ) {
16882
+ const t = tokens[i];
16883
+ if (t.kind === "quantity" || t.kind === "number") {
16884
+ const rps = this.quantityOf(t, "rps", t, line);
16885
+ if (rps === void 0) return;
16886
+ stub.rps = rps;
16887
+ i++;
16888
+ } else if (isWord(t, "p99") || isWord(t, "latency") || isWord(t, "availability")) {
16889
+ const value = this.quantityOf(i + 1 < to ? tokens[i + 1] : void 0, t.value === "availability" ? "%" : "ms", t, line);
16890
+ if (value === void 0) return;
16891
+ if (t.value === "p99") stub.p99Ms = value;
16892
+ else if (t.value === "latency") stub.latencyMs = value;
16893
+ else stub.availability = value;
16894
+ i += 2;
16895
+ } else {
16896
+ this.error(`Expected ${STUB_HELP}`, this.locOf(t, line));
16897
+ return;
16898
+ }
16899
+ }
16900
+ }
16901
+ /** At the system's `}`: loads the team file and checks the entry points against its use cases. */
16902
+ finishSystem(frame) {
16903
+ const { system } = frame;
16904
+ if (system.from !== void 0) {
16905
+ const team2 = this.loadTeam(system.from, frame.fromLoc ?? system.loc);
16906
+ if (team2) {
16907
+ system.resolved = team2.path;
16908
+ system.diagram = team2.diagram;
16909
+ }
16910
+ if (system.stub) {
16911
+ this.warning(`System '${system.id}' has a from file, which sets its contract; this capacity is ignored`, system.stub.loc);
16912
+ if (team2) delete system.stub;
16913
+ }
16914
+ } else if (!system.stub) {
16915
+ this.warning(`System '${system.id}' has no from "file" and no capacity { \u2026 }; it is drawn but not simulated`, system.loc);
16916
+ }
16917
+ const team = system.diagram;
16918
+ if (!team) return;
16919
+ const name = fileName(system.resolved ?? "");
16920
+ for (const entry of system.entries) {
16921
+ const wanted = entry.useCase;
16922
+ if (wanted !== void 0) {
16923
+ const u = findUseCaseIn(team, wanted);
16924
+ if (u) entry.useCaseId = u.id;
16925
+ else {
16926
+ const near = team.useCases.find((x) => editDistance2(x.name.toLowerCase(), wanted.toLowerCase()) <= Math.max(1, Math.floor(wanted.length / 4)));
16927
+ const all = team.useCases.map((x) => `"${x.name}"`).join(", ");
16928
+ const help = near ? ` Did you mean "${near.name}"?` : all ? ` Its use cases are ${all}` : "";
16929
+ this.error(`${name} has no use case "${wanted}".${help}`, frame.useCaseLocs.get(entry) ?? entry.loc);
16930
+ }
16931
+ } else if (team.useCases.length === 1) {
16932
+ entry.useCaseId = team.useCases[0].id;
16933
+ } else if (team.useCases.length === 0) {
16934
+ this.error(`${name} has no use case for '${entry.id}' to enter`, entry.loc);
16935
+ } else {
16936
+ this.error(`Say which use case '${entry.id}' enters, e.g. expose ${entry.id} -> "${team.useCases[0].name}"`, entry.loc);
16937
+ }
16938
+ }
16939
+ if (system.entries.length === 0) this.warning(`System '${system.id}' exposes no entry point; add expose <id> -> "Use case" so others can call it`, system.loc);
16940
+ }
16941
+ /** Parses a team file in its own scope (once per root parse), through the import resolver. */
16942
+ loadTeam(path, loc) {
16943
+ if (!this.options.resolve) {
16944
+ this.warning(`Files are not resolved in this context; '${path}' was not loaded`, loc);
16945
+ return void 0;
16946
+ }
16947
+ const target = this.resolveFile(path, loc);
16948
+ if (!target) return void 0;
16949
+ const cycleStart = this.chain.indexOf(target.path);
16950
+ if (cycleStart !== -1) {
16951
+ const names = [...this.chain.slice(cycleStart), target.path].map((p) => fileName(p ?? ""));
16952
+ this.error(`System cycle: ${names.join(" \u2192 ")}`, loc);
16953
+ return void 0;
16954
+ }
16955
+ let result = this.teamFiles.get(target.path);
16956
+ if (!result) {
16957
+ const scope2 = { chain: [...this.chain, target.path], teamFiles: this.teamFiles };
16958
+ result = new _Parser(target.source, { path: target.path, resolve: this.options.resolve }, scope2).run();
16959
+ this.teamFiles.set(target.path, result);
16960
+ for (const d of result.diagnostics) {
16961
+ if (!this.fileOrder.has(d.file)) this.fileOrder.set(d.file, this.fileOrder.size);
16962
+ this.diagnostics.push(d);
16963
+ }
16964
+ }
16965
+ return { path: target.path, diagram: result.diagram };
16966
+ }
16967
+ /**
16968
+ * Resolves `orders.api` references once every file is read: the entry point
16969
+ * `api` of system `orders`. Edges and references then name the system, with
16970
+ * the entry point in `sourcePort` / `targetPort`. On a connection to a
16971
+ * non-system, `b.planned` stays what it always was: `b` with a tag.
16972
+ */
16973
+ resolvePorts() {
16974
+ for (const ref of this.references) {
16975
+ const dot = ref.id.indexOf(".");
16976
+ if (dot === -1) continue;
16977
+ const prefix = ref.id.slice(0, dot);
16978
+ const port = ref.id.slice(dot + 1);
16979
+ const system = this.systemById.get(prefix);
16980
+ if (system) {
16981
+ if (!system.entries.some((e) => e.id === port)) this.error(this.unexposedMessage(system, port), ref.loc);
16982
+ } else if (ref.edgeTarget) {
16983
+ ref.edgeTarget.tag = port;
16984
+ this.tagRefs.push({ tag: port, loc: { ...ref.loc, col: ref.loc.col + dot, length: ref.loc.length - dot }, target: ref.edgeTarget });
16985
+ } else {
16986
+ this.error(`'${prefix}' is not a system, so '${ref.id}' names nothing; write ${prefix}, or declare system ${prefix} { \u2026 }`, ref.loc);
16987
+ }
16988
+ ref.id = prefix;
16989
+ }
16990
+ for (const edge of this.edges) {
16991
+ const [source2, sourcePort] = this.splitRef(edge.source);
16992
+ const [target, targetPort] = this.splitRef(edge.target);
16993
+ edge.source = source2;
16994
+ edge.target = target;
16995
+ if (sourcePort !== void 0) edge.sourcePort = sourcePort;
16996
+ if (targetPort !== void 0) edge.targetPort = targetPort;
16997
+ }
16998
+ }
16999
+ unexposedMessage(system, port) {
17000
+ const entries2 = system.entries.map((e) => `${system.id}.${e.id}`);
17001
+ const known = entries2.length ? `; connect to ${entries2.join(" or ")}` : "";
17002
+ const internal = system.diagram?.nodes.some((n) => n.id === port);
17003
+ return internal ? `'${port}' is inside system '${system.id}' but is not an entry point; only entry points are reachable from outside: add expose ${port} -> "Use case"${known}` : `System '${system.id}' has no entry point '${port}'${known}`;
17004
+ }
17005
+ /** `orders.api` → `['orders', 'api']` when `orders` is a system; else the id up to its dot, without a port. */
17006
+ splitRef(id) {
17007
+ const dot = id.indexOf(".");
17008
+ if (dot === -1) return [id, void 0];
17009
+ const prefix = id.slice(0, dot);
17010
+ return [prefix, this.systemById.has(prefix) ? id.slice(dot + 1) : void 0];
17011
+ }
17012
+ /** A step to a whole system enters its only entry point; with several it must name one. */
17013
+ onlyEntry(id, loc) {
17014
+ const system = this.systemById.get(id);
17015
+ if (!system || system.entries.length === 0) return void 0;
17016
+ if (system.entries.length === 1) return system.entries[0].id;
17017
+ this.errorOnce(`Say which entry point of '${id}' this step enters: ${system.entries.map((e) => `${id}.${e.id}`).join(" or ")}`, loc);
17018
+ return void 0;
17019
+ }
16719
17020
  parseUseCase(tokens, line) {
16720
17021
  const keyword = tokens[0];
16721
17022
  if (this.stack.length > 0) {
@@ -16905,7 +17206,8 @@ var Parser = class {
16905
17206
  const from = fromToken.value;
16906
17207
  const to = toToken.value;
16907
17208
  const arrow = arrowToken.value;
16908
- this.references.push({ id: from, loc: this.locOf(fromToken, line) }, { id: to, loc: this.locOf(toToken, line) });
17209
+ const toRef = { id: to, loc: this.locOf(toToken, line) };
17210
+ this.references.push({ id: from, loc: this.locOf(fromToken, line) }, toRef);
16909
17211
  const top = this.top();
16910
17212
  const inUseCase = top?.kind === "usecase" || top?.kind === "alt" || top?.kind === "par";
16911
17213
  if (tagToken && inUseCase) {
@@ -16940,6 +17242,7 @@ var Parser = class {
16940
17242
  edge.tag = tagToken.value;
16941
17243
  this.tagRefs.push({ tag: tagToken.value, loc: this.locOf(tagToken, line), target: edge });
16942
17244
  }
17245
+ if (!tagToken) toRef.edgeTarget = edge;
16943
17246
  this.edges.push(edge);
16944
17247
  }
16945
17248
  return lastLine;
@@ -17850,7 +18153,9 @@ var Parser = class {
17850
18153
  for (const step of raw) {
17851
18154
  this.checkConnection(step);
17852
18155
  if (step.arrow === "-->") {
17853
- const request2 = [...steps2].reverse().find((s) => s.fromServiceId === step.to && s.toServiceId === step.from && !answered.has(s) && !s.failed);
18156
+ const [respFrom] = this.splitRef(step.from);
18157
+ const [respTo] = this.splitRef(step.to);
18158
+ const request2 = [...steps2].reverse().find((s) => s.fromServiceId === respTo && s.toServiceId === respFrom && !answered.has(s) && !s.failed);
17854
18159
  if (!request2) {
17855
18160
  this.warning(`Response has no matching request from '${step.to}' to '${step.from}'`, step.loc);
17856
18161
  continue;
@@ -17869,14 +18174,17 @@ var Parser = class {
17869
18174
  continue;
17870
18175
  }
17871
18176
  const label = parseStepLabel(step.label);
17872
- const target = this.nodes.get(step.to);
18177
+ const [from, fromPort] = this.splitRef(step.from);
18178
+ const [to, toPort] = this.splitRef(step.to);
18179
+ const port = toPort ?? this.onlyEntry(to, step.loc);
18180
+ const target = this.nodes.get(to);
17873
18181
  const isAsync = step.arrow === "->>";
17874
18182
  steps2.push({
17875
18183
  id: `${prefix}-${steps2.length + 1}`,
17876
18184
  stepOrder: steps2.length,
17877
18185
  stepName: label.name || `${step.from} \u2192 ${step.to}`,
17878
- fromServiceId: step.from,
17879
- toServiceId: step.to,
18186
+ fromServiceId: from,
18187
+ toServiceId: to,
17880
18188
  protocol: inferProtocol(isAsync, label.method, target),
17881
18189
  httpMethod: label.method ?? "",
17882
18190
  endpoint: label.endpoint ?? "",
@@ -17892,7 +18200,9 @@ var Parser = class {
17892
18200
  loc: step.loc,
17893
18201
  ...step.multiplier !== void 0 ? { multiplier: step.multiplier } : {},
17894
18202
  ...step.sizeBytes !== void 0 ? { sizeBytes: step.sizeBytes } : {},
17895
- access: accessOf(label.method, step.label)
18203
+ access: accessOf(label.method, step.label),
18204
+ ...port !== void 0 ? { toPort: port } : {},
18205
+ ...fromPort !== void 0 ? { fromPort } : {}
17896
18206
  });
17897
18207
  }
17898
18208
  return steps2;
@@ -17902,8 +18212,10 @@ var Parser = class {
17902
18212
  * Documents without any connection are use-case-only sketches; they are left alone.
17903
18213
  */
17904
18214
  checkConnection(step) {
17905
- if (this.edges.length === 0 || step.from === step.to || this.connected.has(`${step.from}|${step.to}`)) return;
17906
- const [from, to] = step.arrow === "-->" ? [step.to, step.from] : [step.from, step.to];
18215
+ const [a] = this.splitRef(step.from);
18216
+ const [b] = this.splitRef(step.to);
18217
+ if (this.edges.length === 0 || a === b || this.connected.has(`${a}|${b}`)) return;
18218
+ const [from, to] = step.arrow === "-->" ? [b, a] : [a, b];
17907
18219
  this.warning(`No connection between '${from}' and '${to}' in the architecture; add '${from} -> ${to}'`, step.loc);
17908
18220
  }
17909
18221
  createImplicitNodes() {
@@ -17999,6 +18311,17 @@ var Parser = class {
17999
18311
  error(message, loc) {
18000
18312
  this.diagnostics.push({ severity: "error", message, ...loc });
18001
18313
  }
18314
+ /** An error for a step that every scenario sharing it would report again. */
18315
+ errorOnce(message, loc) {
18316
+ const key = `error
18317
+ ${loc.file ?? ""}
18318
+ ${loc.line}
18319
+ ${loc.col}
18320
+ ${message}`;
18321
+ if (this.warned.has(key)) return;
18322
+ this.warned.add(key);
18323
+ this.error(message, loc);
18324
+ }
18002
18325
  warning(message, loc) {
18003
18326
  const key = `${loc.file ?? ""}
18004
18327
  ${loc.line}
@@ -18034,6 +18357,23 @@ function expand(items) {
18034
18357
  }
18035
18358
  return paths;
18036
18359
  }
18360
+ function qualify(tokens) {
18361
+ let out;
18362
+ for (let i = 0; i < tokens.length; i++) {
18363
+ const [t, next] = [tokens[i], tokens[i + 1]];
18364
+ const joined = t.kind === "ident" && next?.kind === "tag" && next.col === t.col + t.length;
18365
+ if (joined && (tokens[i + 2]?.kind === "arrow" || tokens[i - 1]?.kind === "arrow")) {
18366
+ out ??= tokens.slice(0, i);
18367
+ out.push({ ...t, value: `${t.value}.${next.value}`, length: t.length + next.length });
18368
+ i++;
18369
+ } else out?.push(t);
18370
+ }
18371
+ return out ?? tokens;
18372
+ }
18373
+ function findUseCaseIn(diagram, name) {
18374
+ const lower = name.toLowerCase();
18375
+ return diagram.useCases.find((u) => u.name === name) ?? diagram.useCases.find((u) => u.name.toLowerCase() === lower) ?? diagram.useCases.find((u) => u.id === name);
18376
+ }
18037
18377
  function isWord(token, word) {
18038
18378
  return token?.kind === "ident" && token.value === word;
18039
18379
  }
@@ -18701,13 +19041,17 @@ function openApiDiagnostics(file, diagram, overrides = {}) {
18701
19041
  }
18702
19042
 
18703
19043
  // src/render/usage.ts
18704
- var RENDER_USAGE = ` proschi render [--out <dir>] [--format svg|md|html|hld-md|hld-html] <file>`;
19044
+ var RENDER_USAGE = ` proschi render [--out <dir>] [--format svg|md|html|hld-md|hld-html] [--expand <id>[,<id>\u2026]] [--focus <id>] <file>`;
18705
19045
  var RENDER_HELP = `render Writes the diagram as static files into --out (default: the current
18706
19046
  directory). svg (default): architecture.svg and <usecase>--<scenario>.svg
18707
19047
  per scenario; md: <file>.md with Mermaid blocks; html: <file>.html with
18708
19048
  the SVGs; hld-md / hld-html: <file>.hld.md / <file>.hld.html, the
18709
19049
  high-level design document (overview, requirements, capacity,
18710
19050
  components, data model, APIs, scenarios, decisions, risks).
19051
+ Systems are drawn as one box each, with the team's promise and what
19052
+ they are sent against their headroom; --expand opens them in place
19053
+ (orders.warehouse names one inside another), --focus draws one
19054
+ system's design alone. An id that names no system exits with 2.
18711
19055
  Refuses (exit 1) when the document has errors.`;
18712
19056
 
18713
19057
  // src/fmt.ts
@@ -19090,6 +19434,340 @@ function formatUsd(usd) {
19090
19434
  return `$${Math.round(usd).toLocaleString("en-US")}/month`;
19091
19435
  }
19092
19436
 
19437
+ // ../frontend/src/sim/overlay.ts
19438
+ function flowsOf(diagram, traffic) {
19439
+ const flows = /* @__PURE__ */ new Map();
19440
+ for (const t of traffic) {
19441
+ const u = findUseCase(diagram, t.useCase);
19442
+ if (!u || t.rps <= 0) continue;
19443
+ for (const sc of u.scenarios) {
19444
+ const share = t.mix ? t.mix.find((m) => m.scenario === sc.name)?.share ?? 0 : sc === u.scenarios[0] ? 1 : 0;
19445
+ if (share <= 0) continue;
19446
+ for (const step of sc.steps) {
19447
+ if (step.failed) continue;
19448
+ const key = `${step.fromServiceId}>${step.toServiceId}`;
19449
+ const f = flows.get(key) ?? { from: step.fromServiceId, to: step.toServiceId, rps: 0, async: step.executionType !== "SYNC_REQUEST_RESPONSE" };
19450
+ f.rps += t.rps * share * (step.multiplier ?? 1);
19451
+ flows.set(key, f);
19452
+ }
19453
+ }
19454
+ }
19455
+ return [...flows.values()];
19456
+ }
19457
+ function analysisOverlay(diagram, analysis) {
19458
+ const nodes = {};
19459
+ for (const n of analysis.nodes) {
19460
+ const works = Number.isFinite(n.capacityRps) && n.kind !== "client";
19461
+ nodes[n.id] = {
19462
+ ...works ? { utilization: n.utilization, saturated: n.saturated } : {},
19463
+ ...n.replicas > 1 ? { replicas: n.replicas } : {},
19464
+ ...n.shards > 1 ? { shards: n.shards } : {}
19465
+ };
19466
+ }
19467
+ return { nodes, flows: flowsOf(diagram, diagram.traffic ?? []) };
19468
+ }
19469
+
19470
+ // ../frontend/src/sim/systems.ts
19471
+ var teamKey = (s) => s.resolved ?? s.diagram;
19472
+ var contracts = /* @__PURE__ */ new WeakMap();
19473
+ function contractOf(system, options = {}) {
19474
+ let contract = contracts.get(system);
19475
+ if (!contract) {
19476
+ contract = system.diagram ? designContract(system, system.diagram, options) : stubContract(system);
19477
+ contracts.set(system, contract);
19478
+ }
19479
+ return contract;
19480
+ }
19481
+ function stubContract(system) {
19482
+ const stub = system.stub;
19483
+ if (!stub) return { source: "none", entries: system.entries.map((e) => emptyEntry(e.id, e.name)) };
19484
+ const rps = stub.rps ?? Infinity;
19485
+ return {
19486
+ source: "stub",
19487
+ entries: system.entries.map((e) => ({
19488
+ ...emptyEntry(e.id, e.name),
19489
+ latency: stub.p99Ms !== void 0 ? [{ percentile: 99, maxMs: stub.p99Ms }] : [],
19490
+ ...stub.availability !== void 0 ? { availabilityPercent: stub.availability } : {},
19491
+ saturatesAtRps: rps,
19492
+ hotAtRps: rps * HOT
19493
+ }))
19494
+ };
19495
+ }
19496
+ var emptyEntry = (entry, name) => ({ entry, name, ownRps: 0, latency: [], saturatesAtRps: Infinity, hotAtRps: Infinity });
19497
+ function designContract(system, team, options) {
19498
+ const traffic = resolveTraffic(team);
19499
+ const base = computeLoad(team, traffic);
19500
+ const baseBytes = computeBytesPerSecond(team, traffic);
19501
+ const profiles = profilesOf(team);
19502
+ const nodes = components(team);
19503
+ const entries2 = system.entries.map((e) => {
19504
+ const u = team.useCases.find((x) => x.id === e.useCaseId);
19505
+ if (!u) return emptyEntry(e.id, e.name);
19506
+ const own = traffic.get(u.id);
19507
+ const unit = /* @__PURE__ */ new Map([[u.id, { rps: 1, shares: own.shares }]]);
19508
+ const delta = computeLoad(team, unit);
19509
+ const deltaBytes = computeBytesPerSecond(team, unit);
19510
+ const result = { ...emptyEntry(e.id, e.name), useCase: u.name, ownRps: own.rps };
19511
+ for (const n of nodes) {
19512
+ const d = delta.get(n.id);
19513
+ const db = deltaBytes.get(n.id) ?? 0;
19514
+ if (!d && !db) continue;
19515
+ const p = profiles.get(n.id);
19516
+ const replicas = replicasOf(n);
19517
+ const b = base.get(n.id) ?? { read: 0, write: 0 };
19518
+ const bb = baseBytes.get(n.id) ?? 0;
19519
+ const u0 = usageOf(p, replicas, b.read, b.write, bb);
19520
+ const u1 = usageOf(p, replicas, b.read + (d?.read ?? 0), b.write + (d?.write ?? 0), bb + db);
19521
+ const sat = crossing(u0, u1, 1);
19522
+ const hot = crossing(u0, u1, HOT);
19523
+ if (sat < result.saturatesAtRps) {
19524
+ result.saturatesAtRps = sat;
19525
+ result.bottleneck = n.id;
19526
+ }
19527
+ if (hot < result.hotAtRps) {
19528
+ result.hotAtRps = hot;
19529
+ result.hotNode = n.id;
19530
+ }
19531
+ }
19532
+ for (const r of team.requirements ?? []) {
19533
+ if (r.kind !== "latency" && r.kind !== "availability") continue;
19534
+ if (r.useCase !== void 0 && findUseCase(team, r.useCase) !== u) continue;
19535
+ if (r.kind === "latency") result.latency.push({ percentile: r.percentile, maxMs: r.maxMs, ...r.scenario !== void 0 ? { scenario: r.scenario } : {} });
19536
+ else result.availabilityPercent = Math.max(result.availabilityPercent ?? 0, r.minPercent);
19537
+ }
19538
+ return result;
19539
+ });
19540
+ return { source: "design", entries: entries2, baseline: analyze(team, { timeoutMs: options.timeoutMs }) };
19541
+ }
19542
+ function crossing(u0, u1, theta) {
19543
+ const terms = u0.writeScaling === "shards" ? [
19544
+ [u0.readUtilization, u1.readUtilization],
19545
+ [u0.writeUtilization, u1.writeUtilization]
19546
+ ] : [[u0.requestUtilization, u1.requestUtilization]];
19547
+ terms.push([u0.bandwidthUtilization, u1.bandwidthUtilization]);
19548
+ let best = Infinity;
19549
+ for (const [a, b] of terms) {
19550
+ const slope = b - a;
19551
+ if (slope <= 1e-15) continue;
19552
+ if (a >= theta) return 0;
19553
+ best = Math.min(best, (theta - a) / slope);
19554
+ }
19555
+ return best;
19556
+ }
19557
+ function inboundLoad(diagram, traffic) {
19558
+ const result = /* @__PURE__ */ new Map();
19559
+ forEachStepRate(diagram, traffic, (step, rate) => {
19560
+ if (step.toPort === void 0) return;
19561
+ const ports = result.get(step.toServiceId) ?? /* @__PURE__ */ new Map();
19562
+ ports.set(step.toPort, (ports.get(step.toPort) ?? 0) + rate * multiplierOf(step));
19563
+ result.set(step.toServiceId, ports);
19564
+ });
19565
+ return result;
19566
+ }
19567
+ function hopFrom(p50, p99) {
19568
+ const l50 = tailFactor(0.5);
19569
+ const l99 = tailFactor(0.99);
19570
+ let tailMs = Math.max(0, (p99 - p50) / (l99 - l50));
19571
+ let fixedMs = p50 - tailMs * l50;
19572
+ if (fixedMs < 0) {
19573
+ fixedMs = 0;
19574
+ tailMs = p99 / l99;
19575
+ }
19576
+ return { fixedMs, tailMs, waitProbability: 0 };
19577
+ }
19578
+ function analyzeSystems(diagram, traffic, options = {}) {
19579
+ const inbound = inboundLoad(diagram, traffic);
19580
+ if (!options.sharedSystems) options = { ...options, sharedSystems: sharedLoad(diagram, inbound) };
19581
+ const hops = /* @__PURE__ */ new Map();
19582
+ const availabilities = /* @__PURE__ */ new Map();
19583
+ const key = (id, port) => `${id}
19584
+ ${port}`;
19585
+ const systems = (diagram.systems ?? []).map((system) => {
19586
+ const result = analyzeSystem(system, inbound.get(system.id) ?? /* @__PURE__ */ new Map(), options);
19587
+ for (const e of result.entries) {
19588
+ hops.set(key(system.id, e.entry), hopFrom(e.p50Ms, e.p99Ms));
19589
+ availabilities.set(key(system.id, e.entry), e.availability);
19590
+ }
19591
+ return result;
19592
+ });
19593
+ return {
19594
+ systems,
19595
+ hop: (id, port) => port === void 0 ? void 0 : hops.get(key(id, port)),
19596
+ availability: (id, port) => port === void 0 ? void 0 : availabilities.get(key(id, port))
19597
+ };
19598
+ }
19599
+ function sentByUseCase(system, sent, into) {
19600
+ let total = 0;
19601
+ for (const e of system.entries) {
19602
+ const rps = sent.get(e.id) ?? 0;
19603
+ if (rps <= 0 || e.useCaseId === void 0) continue;
19604
+ into.set(e.useCaseId, (into.get(e.useCaseId) ?? 0) + rps);
19605
+ total += rps;
19606
+ }
19607
+ return total;
19608
+ }
19609
+ function sharedLoad(root, rootInbound) {
19610
+ const byFile = /* @__PURE__ */ new Map();
19611
+ const designs = /* @__PURE__ */ new Map();
19612
+ const order = [];
19613
+ const visit = (d, prefix) => {
19614
+ for (const s of d.systems ?? []) {
19615
+ if (!s.diagram) continue;
19616
+ const key = teamKey(s);
19617
+ if (designs.has(key)) continue;
19618
+ const path = prefix ? `${prefix}.${s.id}` : s.id;
19619
+ designs.set(key, { diagram: s.diagram, path, loc: s.loc });
19620
+ visit(s.diagram, path);
19621
+ order.push(key);
19622
+ }
19623
+ };
19624
+ visit(root, "");
19625
+ const send = (d, prefix, inbound) => {
19626
+ for (const s of d.systems ?? []) {
19627
+ if (!s.diagram) continue;
19628
+ const key = teamKey(s);
19629
+ const load = byFile.get(key) ?? { useCases: /* @__PURE__ */ new Map(), callers: [] };
19630
+ byFile.set(key, load);
19631
+ const rps = sentByUseCase(s, inbound.get(s.id) ?? /* @__PURE__ */ new Map(), load.useCases);
19632
+ if (rps > 0) load.callers.push({ path: prefix ? `${prefix}.${s.id}` : s.id, ...d.title !== void 0 ? { from: d.title } : {}, rps });
19633
+ }
19634
+ };
19635
+ send(root, "", rootInbound);
19636
+ for (const key of order.reverse()) {
19637
+ const { diagram, path, loc } = designs.get(key);
19638
+ if (!diagram.systems?.length) continue;
19639
+ const combined = withExtraTraffic(diagram, byFile.get(key)?.useCases ?? /* @__PURE__ */ new Map(), loc);
19640
+ send(diagram, path, inboundLoad(combined, resolveTraffic(combined)));
19641
+ }
19642
+ return { byFile, analyses: /* @__PURE__ */ new Map() };
19643
+ }
19644
+ function analyzeSystem(system, sent, options) {
19645
+ const contract = contractOf(system, options);
19646
+ const inbound = system.entries.map((e) => ({ entry: e.id, rps: sent.get(e.id) ?? 0 }));
19647
+ const totalInboundRps = inbound.reduce((sum, e) => sum + e.rps, 0);
19648
+ const result = { id: system.id, contract, inbound, totalInboundRps, utilization: 0, saturated: false, hot: false, breaches: [], entries: [], warnings: [] };
19649
+ const shared = system.diagram ? options.sharedSystems?.byFile.get(teamKey(system)) : void 0;
19650
+ const sending = inbound.filter((e) => e.rps > 0).map((e) => {
19651
+ const c = contract.entries.find((x) => x.entry === e.entry);
19652
+ const useCaseId = system.entries.find((x) => x.id === e.entry)?.useCaseId;
19653
+ const gets = useCaseId !== void 0 ? shared?.useCases.get(useCaseId) ?? 0 : 0;
19654
+ const rps = gets > e.rps * (1 + 1e-9) ? `${formatRps(e.rps).replace(/ rps$/, "")} of the ${formatRps(gets)} it gets` : formatRps(e.rps);
19655
+ return `${rps} into ${c?.useCase ? `"${c.useCase}" (${system.id}.${e.entry})` : `${system.id}.${e.entry}`}`;
19656
+ }).join(" and ");
19657
+ const label = `'${system.id}'`;
19658
+ if (contract.source === "stub") {
19659
+ const stub = system.stub;
19660
+ const rps = stub.rps ?? Infinity;
19661
+ result.utilization = Number.isFinite(rps) && rps > 0 ? totalInboundRps / rps : 0;
19662
+ result.saturated = result.utilization >= 1;
19663
+ result.hot = !result.saturated && result.utilization > HOT;
19664
+ const base = stub.latencyMs ?? (stub.p99Ms !== void 0 ? stub.p99Ms / (1 - SERVICE_SPREAD + SERVICE_SPREAD * tailFactor(0.99)) : 0);
19665
+ const hop = hopModel(base, 0);
19666
+ const at = (p) => hop.fixedMs + hop.tailMs * tailFactor(p);
19667
+ const availability2 = stub.availability !== void 0 ? stub.availability / 100 : 1;
19668
+ result.entries = system.entries.map((e) => ({ entry: e.id, p50Ms: at(0.5), p99Ms: at(0.99), availability: availability2 }));
19669
+ if (result.saturated) {
19670
+ result.warnings.push(`${label} cannot take what it is sent: you send ${sending}; it takes at most ${formatRps(rps)}.`);
19671
+ if (stub.p99Ms !== void 0) result.breaches.push({ requirement: `p99 < ${formatMs(stub.p99Ms)}`, message: `p99 is not held past ${formatRps(rps)}` });
19672
+ } else if (result.hot) result.warnings.push(`${label} runs hot under what it is sent: you send ${sending}; it runs hot past ${formatRps(rps * HOT)} and takes at most ${formatRps(rps)}.`);
19673
+ return result;
19674
+ }
19675
+ if (contract.source === "none" || !system.diagram) {
19676
+ result.entries = system.entries.map((e) => ({ entry: e.id, p50Ms: 0, p99Ms: 0, availability: 1 }));
19677
+ return result;
19678
+ }
19679
+ const team = system.diagram;
19680
+ const key = teamKey(system);
19681
+ const sentToTeam = shared?.useCases ?? /* @__PURE__ */ new Map();
19682
+ if (!shared) sentByUseCase(system, sent, sentToTeam);
19683
+ const sharedInboundRps = [...sentToTeam.values()].reduce((sum, rps) => sum + rps, 0);
19684
+ result.callers = shared?.callers ?? (totalInboundRps > 0 ? [{ path: system.id, rps: totalInboundRps }] : []);
19685
+ result.sharedInboundRps = sharedInboundRps;
19686
+ let cached = options.sharedSystems?.analyses.get(key);
19687
+ if (!cached) {
19688
+ const combined = sharedInboundRps > 0 ? withExtraTraffic(team, sentToTeam, system.loc) : team;
19689
+ const nested = { timeoutMs: options.timeoutMs, ...options.sharedSystems ? { sharedSystems: options.sharedSystems } : {} };
19690
+ cached = { combined, analysis: sharedInboundRps > 0 ? analyze(combined, nested) : contract.baseline };
19691
+ options.sharedSystems?.analyses.set(key, cached);
19692
+ }
19693
+ const { analysis } = cached;
19694
+ result.analysis = analysis;
19695
+ result.overlay = analysisOverlay(cached.combined, analysis);
19696
+ const busiest = analysis.nodes.reduce((best, n) => !best || n.utilization > best.utilization ? n : best, void 0);
19697
+ result.utilization = busiest?.utilization ?? 0;
19698
+ result.busiest = busiest?.id;
19699
+ result.saturated = !!busiest?.saturated;
19700
+ result.hot = !result.saturated && result.utilization > HOT;
19701
+ const byId = new Map(analysis.useCases.map((u) => [u.id, u]));
19702
+ result.entries = system.entries.map((e) => {
19703
+ const u = e.useCaseId !== void 0 ? byId.get(e.useCaseId) : void 0;
19704
+ return { entry: e.id, p50Ms: u?.percentiles.p50 ?? 0, p99Ms: u?.percentiles.p99 ?? 0, availability: u?.availability ?? 1 };
19705
+ });
19706
+ if (totalInboundRps === 0) return result;
19707
+ result.breaches = breachesOf(team, sentToTeam, analysis);
19708
+ const first = contract.entries.find((c) => inbound.some((e) => e.entry === c.entry && e.rps > 0));
19709
+ if (result.saturated && busiest) {
19710
+ const onOwn = first && first.saturatesAtRps === 0;
19711
+ const limit = first && Number.isFinite(first.saturatesAtRps) ? `; it takes at most ${formatRps(first.saturatesAtRps)} before its ${first.bottleneck} saturates` : "";
19712
+ result.warnings.push(
19713
+ onOwn ? `${label} is saturated on its own traffic, before what it is sent: its ${busiest.id} is at ${usageText(busiest)}.` : `${label} cannot take what it is sent: you send ${sending}${limit}. Its ${busiest.id} is at ${usageText(busiest)}.`
19714
+ );
19715
+ } else if (result.hot && busiest) {
19716
+ const limit = first && Number.isFinite(first.hotAtRps) ? `; it runs hot past ${formatRps(first.hotAtRps)}` : "";
19717
+ result.warnings.push(`${label} runs hot under what it is sent: you send ${sending}${limit}. Its ${busiest.id} is at ${usageText(busiest)}.`);
19718
+ }
19719
+ for (const b of result.breaches) result.warnings.push(`${label} breaks its SLA under what it is sent: ${b.message}.`);
19720
+ return result;
19721
+ }
19722
+ function withExtraTraffic(team, extra, loc) {
19723
+ const traffic = [...team.traffic ?? []];
19724
+ for (const [id, rps] of extra) {
19725
+ const u = team.useCases.find((x) => x.id === id);
19726
+ if (!u || rps <= 0) continue;
19727
+ const i = traffic.findIndex((t) => findUseCase(team, t.useCase) === u);
19728
+ if (i >= 0) traffic[i] = { ...traffic[i], rps: traffic[i].rps + rps };
19729
+ else traffic.push({ useCase: u.name, rps, loc });
19730
+ }
19731
+ return { ...team, traffic };
19732
+ }
19733
+ function breachesOf(team, sent, analysis) {
19734
+ const entered = /* @__PURE__ */ new Set();
19735
+ for (const [id, rps] of sent) {
19736
+ const u = team.useCases.find((x) => x.id === id);
19737
+ if (u && rps > 0) entered.add(u);
19738
+ }
19739
+ const nodes = new Map(analysis.nodes.map((n) => [n.id, n]));
19740
+ const results = new Map(analysis.useCases.map((u) => [u.id, u]));
19741
+ const breaches = [];
19742
+ for (const r of team.requirements ?? []) {
19743
+ if (r.kind !== "latency" && r.kind !== "availability") continue;
19744
+ const targets2 = r.useCase === void 0 ? [...entered] : [findUseCase(team, r.useCase)].filter((u) => !!u && entered.has(u));
19745
+ for (const u of targets2) {
19746
+ const ua = results.get(u.id);
19747
+ const under = `under ${formatRps(ua.rps)}`;
19748
+ if (r.kind === "availability") {
19749
+ const name2 = `availability of "${u.name}" \u2265 ${r.minPercent}%`;
19750
+ if (ua.availability * 100 < r.minPercent - 1e-9) breaches.push({ requirement: name2, message: `availability of "${u.name}" is ${formatAvailability(ua.availability)} ${under} (limit ${r.minPercent}%)` });
19751
+ continue;
19752
+ }
19753
+ const key = PERCENTILE_KEYS[r.percentile];
19754
+ const scenario = r.scenario !== void 0 ? findScenario2(u, r.scenario) : void 0;
19755
+ if (r.scenario !== void 0 && !scenario) continue;
19756
+ const what = `${key} of "${u.name}"${scenario ? ` scenario "${scenario.name}"` : ""}`;
19757
+ const name = `${what} < ${formatMs(r.maxMs)}`;
19758
+ const steps2 = scenario ? scenario.steps : u.scenarios.flatMap((s) => s.steps);
19759
+ const saturated = steps2.map((s) => nodes.get(s.toServiceId)).reduce((best, n) => n?.saturated && (!best || n.utilization > best.utilization) ? n : best, void 0);
19760
+ if (saturated) {
19761
+ breaches.push({ requirement: name, message: `${what}: ${saturated.id} is saturated (${usageText(saturated)}) ${under} (limit ${formatMs(r.maxMs)})` });
19762
+ continue;
19763
+ }
19764
+ const ms = scenario ? ua.scenarios[u.scenarios.indexOf(scenario)].percentiles[key] : ua.percentiles[key];
19765
+ if (ms >= r.maxMs) breaches.push({ requirement: name, message: `${what} is ${formatMs(ms)} ${under} (limit ${formatMs(r.maxMs)})` });
19766
+ }
19767
+ }
19768
+ return breaches;
19769
+ }
19770
+
19093
19771
  // ../frontend/src/sim/analyze.ts
19094
19772
  var DEFAULT_TIMEOUT_MS = 1e3;
19095
19773
  var HOT = 0.7;
@@ -19247,6 +19925,7 @@ function usageText(n) {
19247
19925
  return `bandwidth ${mb(n.bandwidthLoadMBps)} of ${mb(n.bandwidthCapacityMBps)}, ${formatPercent(n.bandwidthUtilization)}`;
19248
19926
  }
19249
19927
  if (n.writeScaling === "shards" && n.writeLoadRps > 0) {
19928
+ if (n.readLoadRps <= 0) return `writes ${part(n.writeLoadRps, n.writeCapacityRps, n.writeUtilization)}`;
19250
19929
  return `reads ${part(n.readLoadRps, n.readCapacityRps, n.readUtilization)}; writes ${part(n.writeLoadRps, n.writeCapacityRps, n.writeUtilization)}`;
19251
19930
  }
19252
19931
  return part(n.loadRps, n.capacityRps, n.requestUtilization);
@@ -19332,6 +20011,7 @@ function analyze(diagram, options = {}) {
19332
20011
  const load = computeLoad(diagram, traffic);
19333
20012
  const egress = computeEgressGb(diagram, traffic, kindOf2);
19334
20013
  const bytes = computeBytesPerSecond(diagram, traffic);
20014
+ const systems = diagram.systems?.length ? analyzeSystems(diagram, traffic, options) : void 0;
19335
20015
  const hops = /* @__PURE__ */ new Map();
19336
20016
  const nodeResults = nodes.map((n) => {
19337
20017
  const p = profiles.get(n.id);
@@ -19370,12 +20050,13 @@ function analyze(diagram, options = {}) {
19370
20050
  if (n.saturated) warnings.push(`'${n.id}' is saturated: ${usage}. ${writeBound(n) ? "Add shards for writes" : "Add replicas"} or take load off it.`);
19371
20051
  else if (n.utilization > HOT) warnings.push(`'${n.id}' runs hot: ${usage}.`);
19372
20052
  }
20053
+ for (const s of systems?.systems ?? []) warnings.push(...s.warnings);
19373
20054
  const access = accessIn(diagram);
19374
20055
  const pathOf = (hopsOfPath) => ({
19375
20056
  parts: hopsOfPath.map(
19376
20057
  (part) => part.map((h) => {
19377
20058
  if (h.failed) return { fixedMs: timeoutOf(h.target), tailMs: 0 };
19378
- const hop = hops.get(h.target) ?? { fixedMs: 0, tailMs: 0 };
20059
+ const hop = systems?.hop(h.target, h.step.toPort) ?? hops.get(h.target) ?? { fixedMs: 0, tailMs: 0 };
19379
20060
  return { fixedMs: transferMs(h.step, bandwidthOf) + hop.fixedMs, tailMs: hop.tailMs };
19380
20061
  })
19381
20062
  )
@@ -19405,6 +20086,7 @@ function analyze(diagram, options = {}) {
19405
20086
  const writes = mainWrites(u, shares, (step) => access(step) === "write");
19406
20087
  const availabilityOf = (id) => {
19407
20088
  const n = nodeById.get(id);
20089
+ if (!n && systems) return systemAvailability(u, id, systems.availability);
19408
20090
  return !n ? 1 : writes.has(id) ? n.writeAvailability : n.availability;
19409
20091
  };
19410
20092
  return { id: u.id, name: u.name, rps, scenarios, percentiles, tailScenario, availability: useCaseAvailability(u, shares, availabilityOf) };
@@ -19415,9 +20097,15 @@ function analyze(diagram, options = {}) {
19415
20097
  totalCostUsd: nodeResults.reduce((sum, n) => sum + n.costUsd, 0),
19416
20098
  totalEgressUsd: nodeResults.reduce((sum, n) => sum + n.egressUsd, 0),
19417
20099
  singlePointsOfFailure: singlePointsOfFailure(diagram),
19418
- warnings
20100
+ warnings,
20101
+ ...systems ? { systems: systems.systems } : {}
19419
20102
  };
19420
20103
  }
20104
+ function systemAvailability(u, id, availability2) {
20105
+ let result = 1;
20106
+ for (const s of u.scenarios) for (const step of s.steps) if (step.toServiceId === id) result = Math.min(result, availability2(id, step.toPort) ?? 1);
20107
+ return result;
20108
+ }
19421
20109
  function mainWrites(useCase, shares, isWrite) {
19422
20110
  if (useCase.scenarios.length === 0) return /* @__PURE__ */ new Set();
19423
20111
  const path = criticalPath(useCase, useCase.scenarios[mainScenarioIndex(shares)]).flat();
@@ -19467,6 +20155,7 @@ function runTests(diagram, analysis = analyze(diagram), options = {}) {
19467
20155
  const { passed, message, hint } = requirement(run2, r);
19468
20156
  return { id: `req:${i + 1}`, name: requirementName(r), category: CATEGORY[r.kind], passed, message, hint, loc: r.loc };
19469
20157
  });
20158
+ results.push(...systemResults(diagram, analysis));
19470
20159
  const names = /* @__PURE__ */ new Map();
19471
20160
  for (const test of diagram.tests ?? []) {
19472
20161
  const n = (names.get(test.name) ?? 0) + 1;
@@ -19475,6 +20164,26 @@ function runTests(diagram, analysis = analyze(diagram), options = {}) {
19475
20164
  }
19476
20165
  return results.map(({ hint, ...r }) => r.passed || !hint ? r : { ...r, hint });
19477
20166
  }
20167
+ function systemResults(diagram, analysis) {
20168
+ return (analysis.systems ?? []).filter((s) => s.totalInboundRps > 0 && s.contract.source !== "none").map((s) => {
20169
+ const system = diagram.systems?.find((x) => x.id === s.id);
20170
+ const node = diagram.nodes.find((n) => n.id === s.id);
20171
+ const team = node?.ownerTeam ? `the ${node.ownerTeam} team` : `the owners of ${s.id}`;
20172
+ const problem = s.warnings.find((w) => !w.includes(" runs hot "));
20173
+ const sat = s.contract.entries.find((e) => s.inbound.some((i) => i.entry === e.entry && i.rps > 0));
20174
+ const room = sat && Number.isFinite(sat.saturatesAtRps) ? `; it takes at most ${formatRps(sat.saturatesAtRps)}` : "";
20175
+ const bottleneck = s.saturated && s.busiest ? ` (their bottleneck is ${s.busiest})` : "";
20176
+ return {
20177
+ id: `system:${s.id}`,
20178
+ name: `${s.id} takes what it is sent`,
20179
+ category: s.breaches.some((b) => b.requirement.startsWith("availability")) && !s.saturated ? "availability" : "latency",
20180
+ passed: !problem,
20181
+ message: problem ?? `'${s.id}' takes ${formatRps(s.totalInboundRps)}${(s.sharedInboundRps ?? 0) > s.totalInboundRps * (1 + 1e-9) ? ` (of the ${formatRps(s.sharedInboundRps)} it gets)` : ""}${s.contract.source === "stub" ? "" : " on top of its own traffic"} and holds its requirements${room}`,
20182
+ ...problem ? { hint: `Send it less, or ask ${team} to add capacity for it${bottleneck}` } : {},
20183
+ ...system ? { loc: system.loc } : {}
20184
+ };
20185
+ });
20186
+ }
19478
20187
  var CATEGORY = {
19479
20188
  latency: "latency",
19480
20189
  availability: "availability",
@@ -20049,8 +20758,126 @@ function displayPath2(file) {
20049
20758
  }
20050
20759
 
20051
20760
  // src/imports.ts
20052
- var import_node_fs4 = require("node:fs");
20053
- var import_node_path5 = require("node:path");
20761
+ var import_node_fs5 = require("node:fs");
20762
+ var import_node_path6 = require("node:path");
20763
+
20764
+ // ../frontend/src/dsl/imports.ts
20765
+ var ImportError = class extends Error {
20766
+ name = "ImportError";
20767
+ };
20768
+ var MAX_SPEC_LENGTH = 1024;
20769
+ var SEGMENT = /^[A-Za-z0-9_][A-Za-z0-9._ +-]*$/;
20770
+ var HOST = /^[A-Za-z0-9](?:[A-Za-z0-9-]*[A-Za-z0-9])?(?:\.[A-Za-z0-9](?:[A-Za-z0-9-]*[A-Za-z0-9])?)*(?::\d{1,5})?$/;
20771
+ var REPO_SEGMENT = /^[A-Za-z0-9_][A-Za-z0-9._-]*$/;
20772
+ var REF = /^[A-Za-z0-9_][A-Za-z0-9._/-]*$/;
20773
+ var SCHEME = /^([A-Za-z][A-Za-z0-9+.-]+):/;
20774
+ function isRemoteSpec(spec) {
20775
+ return SCHEME.test(spec);
20776
+ }
20777
+ function isAbsolutePath(path) {
20778
+ return /^[\\/]/.test(path) || /^[A-Za-z]:/.test(path);
20779
+ }
20780
+ function checkRef(ref, spec) {
20781
+ if (!REF.test(ref) || ref.length > 200 || ref.includes("..") || ref.includes("//") || ref.endsWith("/") || ref.endsWith(".") || ref.endsWith(".lock") || ref.split("/").some((p) => p.startsWith("."))) {
20782
+ throw new ImportError(`'${spec}' has an invalid ref '${ref}'; use a tag, branch or commit made of letters, digits, '.', '_', '-' and '/'`);
20783
+ }
20784
+ }
20785
+ function repoPathSegments(path, spec) {
20786
+ const parts = path.split("/");
20787
+ if (!path || parts.some((p) => !SEGMENT.test(p) || p === "." || p === "..")) {
20788
+ throw new ImportError(`'${spec}' has an invalid path in the repository; use a relative path like dir/file.proschi, without '..'`);
20789
+ }
20790
+ return parts;
20791
+ }
20792
+ function parseRemoteSpec(spec) {
20793
+ const scheme = SCHEME.exec(spec)?.[1].toLowerCase();
20794
+ if (!scheme) return void 0;
20795
+ if (spec.length > MAX_SPEC_LENGTH) throw new ImportError(`Import source is longer than ${MAX_SPEC_LENGTH} characters`);
20796
+ if (/[\s\0\\]/.test(spec)) throw new ImportError(`'${spec}' contains whitespace or a backslash`);
20797
+ if (scheme === "git") {
20798
+ const body = spec.slice(4);
20799
+ const at = body.lastIndexOf("@");
20800
+ const split2 = body.indexOf("//");
20801
+ if (at === -1 || split2 === -1 || split2 > at) {
20802
+ throw new ImportError(`'${spec}' is not a git source; write git:<host>/<org>/<repo>//<path/in/repo>@<ref>`);
20803
+ }
20804
+ const repo = body.slice(0, split2);
20805
+ const path = body.slice(split2 + 2, at);
20806
+ const ref = body.slice(at + 1);
20807
+ const [host, ...names] = repo.split("/");
20808
+ if (!HOST.test(host) || names.length < 2 || names.some((n) => !REPO_SEGMENT.test(n) || n.endsWith("."))) {
20809
+ throw new ImportError(`'${spec}' names an invalid repository '${repo}'; write <host>/<org>/<repo>`);
20810
+ }
20811
+ repoPathSegments(path, spec);
20812
+ checkRef(ref, spec);
20813
+ return { kind: "git", repo, path, ref };
20814
+ }
20815
+ if (scheme === "http") throw new ImportError(`'${spec}' uses http://; only https:// imports are allowed`);
20816
+ if (scheme !== "https") throw new ImportError(`'${spec}' is not a supported import source; use a relative path, git: or https://`);
20817
+ let url;
20818
+ try {
20819
+ url = new URL(spec);
20820
+ } catch {
20821
+ throw new ImportError(`'${spec}' is not a valid URL`);
20822
+ }
20823
+ if (url.username || url.password) throw new ImportError(`'${spec}' contains credentials; git and https imports use your own credentials, never ones in the diagram`);
20824
+ if (url.hash) throw new ImportError(`'${spec}' has a #fragment; remove it`);
20825
+ if (!HOST.test(url.host)) throw new ImportError(`'${spec}' has an invalid host`);
20826
+ return { kind: "https", url: url.href };
20827
+ }
20828
+ function formatRemoteSpec(spec) {
20829
+ return spec.kind === "git" ? `git:${spec.repo}//${spec.path}@${spec.ref}` : spec.url;
20830
+ }
20831
+ function resolveImportPath(fromPath, importPath) {
20832
+ if (importPath.includes("\0")) throw new ImportError(`'${importPath.replace(/\0/g, "")}' contains a NUL character`);
20833
+ const remote = parseRemoteSpec(importPath);
20834
+ if (remote) return formatRemoteSpec(remote);
20835
+ if (isAbsolutePath(importPath)) throw new ImportError(`'${importPath}' is an absolute path; imports must be relative to the importing file`);
20836
+ const from = fromPath && isRemoteSpec(fromPath) ? parseRemoteSpec(fromPath) : void 0;
20837
+ if (from?.kind === "git") {
20838
+ const parts2 = from.path.split("/").slice(0, -1);
20839
+ for (const part of importPath.split("/")) {
20840
+ if (part === "" || part === ".") continue;
20841
+ if (part !== "..") parts2.push(part);
20842
+ else if (parts2.length > 0) parts2.pop();
20843
+ else throw new ImportError(`'${importPath}' leaves the repository ${from.repo}`);
20844
+ }
20845
+ repoPathSegments(parts2.join("/"), importPath);
20846
+ return formatRemoteSpec({ ...from, path: parts2.join("/") });
20847
+ }
20848
+ if (from?.kind === "https") {
20849
+ const base = new URL(from.url);
20850
+ const url = new URL(importPath, base);
20851
+ if (url.origin !== base.origin) throw new ImportError(`'${importPath}' leaves ${base.origin}`);
20852
+ return formatRemoteSpec(parseRemoteSpec(url.href));
20853
+ }
20854
+ const parts = !fromPath ? [] : fromPath.split("/").slice(0, -1);
20855
+ for (const part of importPath.split("/")) {
20856
+ if (part === "" || part === ".") continue;
20857
+ if (part === ".." && parts.length > 0 && parts[parts.length - 1] !== "..") parts.pop();
20858
+ else parts.push(part);
20859
+ }
20860
+ return parts.join("/");
20861
+ }
20862
+ function joinImportPath(fromPath, importPath) {
20863
+ try {
20864
+ return resolveImportPath(fromPath, importPath);
20865
+ } catch (e) {
20866
+ if (e instanceof ImportError) return void 0;
20867
+ throw e;
20868
+ }
20869
+ }
20870
+ function mapResolver(files) {
20871
+ const paths = Object.keys(files).filter((p) => !isRemoteSpec(p));
20872
+ return (importPath, fromPath) => {
20873
+ const path = resolveImportPath(fromPath, importPath);
20874
+ if (Object.prototype.hasOwnProperty.call(files, path)) return { path, source: files[path] };
20875
+ if (isRemoteSpec(path)) return void 0;
20876
+ const name = path.split("/").pop();
20877
+ const sameName = paths.filter((p) => p.split("/").pop() === name);
20878
+ return sameName.length === 1 ? { path: sameName[0], source: files[sameName[0]] } : void 0;
20879
+ };
20880
+ }
20054
20881
 
20055
20882
  // src/simulation.ts
20056
20883
  var SIM_USAGE = ` proschi test [--format text|github|json] <file|dir>...
@@ -20217,21 +21044,201 @@ function capacityReport(diagram, analysis, file) {
20217
21044
  return lines.join("\n");
20218
21045
  }
20219
21046
 
21047
+ // src/lock.ts
21048
+ var import_node_crypto = require("node:crypto");
21049
+ var import_node_fs4 = require("node:fs");
21050
+ var import_node_path5 = require("node:path");
21051
+
21052
+ // src/lockFormat.ts
21053
+ var LOCK_FILE = "proschi.lock";
21054
+ var CACHE_DIR = ".proschi/cache";
21055
+ var HASH = /^[0-9a-f]{64}$/;
21056
+ var COMMIT = /^[0-9a-f]{40}([0-9a-f]{24})?$/;
21057
+ var emptyLock = () => ({ version: 1, git: {}, https: {} });
21058
+ var gitKey = (repo, ref) => `${repo}@${ref}`;
21059
+ var remoteError = {
21060
+ noLock: (spec) => new ImportError(`'${spec}' is a remote import and there is no ${LOCK_FILE}; run proschi imports sync`),
21061
+ notLocked: (spec) => new ImportError(`'${spec}' is not in ${LOCK_FILE}; run proschi imports sync`),
21062
+ notCached: (spec) => new ImportError(`'${spec}' is not in the import cache (${CACHE_DIR}); run proschi imports sync`),
21063
+ altered: (spec) => new ImportError(`The cached copy of '${spec}' does not match its hash in ${LOCK_FILE}; run proschi imports sync`)
21064
+ };
21065
+ var isRecord = (v) => !!v && typeof v === "object" && !Array.isArray(v);
21066
+ function parseLock(text) {
21067
+ const invalid = (why) => new ImportError(`Invalid ${LOCK_FILE}: ${why}; delete it and run proschi imports sync`);
21068
+ let json2;
21069
+ try {
21070
+ json2 = JSON.parse(text);
21071
+ } catch (e) {
21072
+ throw invalid(e.message);
21073
+ }
21074
+ if (!isRecord(json2) || json2.version !== 1) throw invalid('expected {"version": 1, \u2026}');
21075
+ const lock = emptyLock();
21076
+ const git = json2.git ?? {};
21077
+ const https = json2.https ?? {};
21078
+ if (!isRecord(git) || !isRecord(https)) throw invalid('"git" and "https" must be objects');
21079
+ for (const [key, entry] of Object.entries(git)) {
21080
+ const at = key.lastIndexOf("@");
21081
+ const valid = at > 0 && isRecord(entry) && typeof entry.commit === "string" && COMMIT.test(entry.commit) && isRecord(entry.files) && Object.entries(entry.files).every(([path, hash]) => typeof hash === "string" && HASH.test(hash) && isGitSpec(`git:${key.slice(0, at)}//${path}@${key.slice(at + 1)}`));
21082
+ if (!valid) throw invalid(`bad git entry '${key}'`);
21083
+ lock.git[key] = { commit: entry.commit, files: { ...entry.files } };
21084
+ }
21085
+ for (const [url, hash] of Object.entries(https)) {
21086
+ if (typeof hash !== "string" || !HASH.test(hash) || parseRemoteSpec(url)?.kind !== "https") throw invalid(`bad https entry '${url}'`);
21087
+ lock.https[url] = hash;
21088
+ }
21089
+ return lock;
21090
+ }
21091
+ function isGitSpec(spec) {
21092
+ try {
21093
+ return parseRemoteSpec(spec)?.kind === "git";
21094
+ } catch {
21095
+ return false;
21096
+ }
21097
+ }
21098
+ function lockedHash(lock, spec) {
21099
+ const remote = parseRemoteSpec(spec);
21100
+ if (remote?.kind === "git") return lock.git[gitKey(remote.repo, remote.ref)]?.files[remote.path];
21101
+ if (remote?.kind === "https") return lock.https[remote.url];
21102
+ return void 0;
21103
+ }
21104
+
21105
+ // src/lock.ts
21106
+ var sha256 = (bytes) => (0, import_node_crypto.createHash)("sha256").update(bytes).digest("hex");
21107
+ function readLock(root) {
21108
+ const file = (0, import_node_path5.join)(root, LOCK_FILE);
21109
+ return (0, import_node_fs4.existsSync)(file) ? parseLock((0, import_node_fs4.readFileSync)(file, "utf8")) : void 0;
21110
+ }
21111
+ var sorted = (o, map = (v) => v) => Object.fromEntries(Object.keys(o).sort().map((k) => [k, map(o[k])]));
21112
+ function formatLock(lock) {
21113
+ return JSON.stringify({ version: 1, git: sorted(lock.git, (e) => ({ commit: e.commit, files: sorted(e.files) })), https: sorted(lock.https) }, null, 2) + "\n";
21114
+ }
21115
+ function writeLock(root, lock) {
21116
+ (0, import_node_fs4.writeFileSync)((0, import_node_path5.join)(root, LOCK_FILE), formatLock(lock));
21117
+ }
21118
+ var cacheFile = (root, hash) => (0, import_node_path5.join)(root, CACHE_DIR, hash);
21119
+ function readCached(root, hash, spec) {
21120
+ if (!HASH.test(hash)) return void 0;
21121
+ const file = cacheFile(root, hash);
21122
+ if (!(0, import_node_fs4.existsSync)(file)) return void 0;
21123
+ const bytes = (0, import_node_fs4.readFileSync)(file);
21124
+ if (sha256(bytes) !== hash) throw remoteError.altered(spec);
21125
+ return bytes;
21126
+ }
21127
+ function writeCached(root, bytes) {
21128
+ const hash = sha256(bytes);
21129
+ const dir = (0, import_node_path5.join)(root, CACHE_DIR);
21130
+ (0, import_node_fs4.mkdirSync)(dir, { recursive: true });
21131
+ const tmp = (0, import_node_path5.join)(dir, `${hash}.${process.pid}.tmp`);
21132
+ (0, import_node_fs4.writeFileSync)(tmp, bytes);
21133
+ (0, import_node_fs4.renameSync)(tmp, cacheFile(root, hash));
21134
+ return hash;
21135
+ }
21136
+
20220
21137
  // src/imports.ts
20221
21138
  function displayPath3(file, cwd = process.cwd()) {
20222
- const abs = (0, import_node_path5.resolve)(cwd, file);
20223
- const rel = (0, import_node_path5.relative)(cwd, abs);
20224
- return rel && !rel.startsWith("..") && !(0, import_node_path5.isAbsolute)(rel) ? rel : abs;
20225
- }
20226
- function fileResolver(read = () => void 0, toPath = (p) => p) {
20227
- return (importPath, fromPath) => {
20228
- const abs = (0, import_node_path5.resolve)(fromPath ? (0, import_node_path5.dirname)(fromPath) : process.cwd(), importPath);
20229
- const text = read(abs) ?? ((0, import_node_fs4.existsSync)(abs) && (0, import_node_fs4.statSync)(abs).isFile() ? (0, import_node_fs4.readFileSync)(abs, "utf8") : void 0);
20230
- return text === void 0 ? void 0 : { path: toPath(abs), source: text };
21139
+ const abs = (0, import_node_path6.resolve)(cwd, file);
21140
+ const rel = (0, import_node_path6.relative)(cwd, abs);
21141
+ return rel && !rel.startsWith("..") && !(0, import_node_path6.isAbsolute)(rel) ? rel : abs;
21142
+ }
21143
+ var MAX_IMPORT_BYTES = 1e6;
21144
+ var MAX_IMPORT_FILES = 500;
21145
+ var MAX_IMPORT_DEPTH = 32;
21146
+ function projectRoot(entry) {
21147
+ const dir = (0, import_node_path6.dirname)((0, import_node_path6.resolve)(entry));
21148
+ const config = findConfigFile(dir);
21149
+ if (config) return (0, import_node_fs5.realpathSync)((0, import_node_path6.dirname)(config));
21150
+ for (let current = dir; ; current = (0, import_node_path6.dirname)(current)) {
21151
+ if ((0, import_node_fs5.existsSync)((0, import_node_path6.join)(current, ".git"))) return (0, import_node_fs5.realpathSync)(current);
21152
+ if ((0, import_node_path6.dirname)(current) === current) break;
21153
+ }
21154
+ return (0, import_node_fs5.realpathSync)((0, import_node_fs5.existsSync)(dir) ? dir : process.cwd());
21155
+ }
21156
+ var isInside = (root, path) => {
21157
+ const rel = (0, import_node_path6.relative)(root, path);
21158
+ return rel === "" || rel !== ".." && !rel.startsWith(`..${import_node_path6.sep}`) && !(0, import_node_path6.isAbsolute)(rel);
21159
+ };
21160
+ function realLocation(path) {
21161
+ let existing = path;
21162
+ while (!(0, import_node_fs5.existsSync)(existing) && (0, import_node_path6.dirname)(existing) !== existing) existing = (0, import_node_path6.dirname)(existing);
21163
+ return (0, import_node_path6.join)((0, import_node_fs5.realpathSync)(existing), (0, import_node_path6.relative)(existing, path));
21164
+ }
21165
+ var tooLarge = (what) => new ImportError(`'${what}' is larger than ${MAX_IMPORT_BYTES / 1e6} MB`);
21166
+ function fileResolver({ entry, read = () => void 0, toPath = (p) => p, remote } = {}) {
21167
+ const root = projectRoot(entry ?? (0, import_node_path6.join)(process.cwd(), "stdin.proschi"));
21168
+ const errors = /* @__PURE__ */ new Map();
21169
+ const levels = /* @__PURE__ */ new Map();
21170
+ let lock = null;
21171
+ const fromCache = (spec) => {
21172
+ if (lock === null) lock = readLock(root);
21173
+ if (!lock) throw remoteError.noLock(spec);
21174
+ const hash = lockedHash(lock, spec);
21175
+ if (!hash) throw remoteError.notLocked(spec);
21176
+ const bytes = readCached(root, hash, spec);
21177
+ if (!bytes) throw remoteError.notCached(spec);
21178
+ return bytes.toString("utf8");
20231
21179
  };
21180
+ const loadRemote = remote ?? fromCache;
21181
+ const found = (path, source2, level) => {
21182
+ if (!levels.has(path)) {
21183
+ if (levels.size >= MAX_IMPORT_FILES) throw new ImportError(`More than ${MAX_IMPORT_FILES} imported files; split the diagram`);
21184
+ levels.set(path, level);
21185
+ }
21186
+ return { path, source: source2 };
21187
+ };
21188
+ const resolveOne = (importPath, fromPath) => {
21189
+ const level = (fromPath === void 0 ? 0 : levels.get(fromPath) ?? 0) + 1;
21190
+ if (level > MAX_IMPORT_DEPTH) throw new ImportError(`Imports nest more than ${MAX_IMPORT_DEPTH} files deep`);
21191
+ if (isRemoteSpec(importPath) || fromPath !== void 0 && isRemoteSpec(fromPath)) {
21192
+ const spec = resolveImportPath(fromPath, importPath);
21193
+ const source2 = loadRemote(spec);
21194
+ if (source2 === void 0) return void 0;
21195
+ if (Buffer.byteLength(source2) > MAX_IMPORT_BYTES) throw tooLarge(spec);
21196
+ return found(spec, source2, level);
21197
+ }
21198
+ resolveImportPath(void 0, importPath);
21199
+ const abs = (0, import_node_path6.resolve)(fromPath ? (0, import_node_path6.dirname)((0, import_node_path6.resolve)(fromPath)) : root, importPath);
21200
+ if (!isInside(root, realLocation(abs))) {
21201
+ const link = (0, import_node_fs5.existsSync)(abs) && (0, import_node_fs5.lstatSync)(abs).isSymbolicLink();
21202
+ throw new ImportError(
21203
+ link ? `'${importPath}' is a symlink to a file outside the project root ${displayPath3(root)}` : `'${importPath}' is outside the project root ${displayPath3(root)}; imports must stay inside the folder of proschi.json (else the git root)`
21204
+ );
21205
+ }
21206
+ const text = read(abs);
21207
+ if (text !== void 0) {
21208
+ if (Buffer.byteLength(text) > MAX_IMPORT_BYTES) throw tooLarge(importPath);
21209
+ return found(toPath(abs), text, level);
21210
+ }
21211
+ if (!(0, import_node_fs5.existsSync)(abs)) return void 0;
21212
+ const stat = (0, import_node_fs5.statSync)(abs);
21213
+ if (!stat.isFile()) return void 0;
21214
+ if (stat.size > MAX_IMPORT_BYTES) throw tooLarge(importPath);
21215
+ return found(toPath(abs), (0, import_node_fs5.readFileSync)((0, import_node_fs5.realpathSync)(abs), "utf8"), level);
21216
+ };
21217
+ const resolver = ((importPath, fromPath) => {
21218
+ try {
21219
+ return resolveOne(importPath, fromPath);
21220
+ } catch (e) {
21221
+ if (e instanceof ImportError) errors.set(`${fromPath ?? ""}\0${importPath}`, e.message);
21222
+ throw e;
21223
+ }
21224
+ });
21225
+ resolver.root = root;
21226
+ resolver.errors = errors;
21227
+ resolver.explain = (result) => {
21228
+ for (const i of result.imports ?? []) {
21229
+ if (i.resolved !== void 0) continue;
21230
+ const reason = errors.get(`${i.loc.file ?? entry ?? ""}\0${i.path}`);
21231
+ const d = reason && result.diagnostics.find((d2) => d2.file === i.loc.file && d2.line === i.loc.line && d2.col === i.loc.col && d2.message === `Cannot find '${i.path}'`);
21232
+ if (d) d.message = reason;
21233
+ }
21234
+ return result;
21235
+ };
21236
+ return resolver;
20232
21237
  }
20233
21238
  function parseFile(file) {
20234
- return parse((0, import_node_fs4.readFileSync)(file, "utf8"), { path: displayPath3(file), resolve: fileResolver(void 0, (p) => displayPath3(p)) });
21239
+ const path = displayPath3(file);
21240
+ const resolver = fileResolver({ entry: path, toPath: (p) => displayPath3(p) });
21241
+ return resolver.explain(parse((0, import_node_fs5.readFileSync)(file, "utf8"), { path, resolve: resolver }));
20235
21242
  }
20236
21243
  function checkFiles(files, extra) {
20237
21244
  const byFile = /* @__PURE__ */ new Map();
@@ -20253,8 +21260,8 @@ function checkFiles(files, extra) {
20253
21260
  }
20254
21261
 
20255
21262
  // src/problem.ts
20256
- var import_node_fs5 = require("node:fs");
20257
- var import_node_path6 = require("node:path");
21263
+ var import_node_fs6 = require("node:fs");
21264
+ var import_node_path7 = require("node:path");
20258
21265
 
20259
21266
  // ../frontend/src/practice/frontMatter.ts
20260
21267
  var FrontMatterError = class extends Error {
@@ -20370,27 +21377,6 @@ function parseFrontMatter(text) {
20370
21377
  return { data, body: lines.slice(end + 1).join("\n").trim() };
20371
21378
  }
20372
21379
 
20373
- // ../frontend/src/dsl/imports.ts
20374
- function joinImportPath(fromPath, importPath) {
20375
- const parts = importPath.startsWith("/") || !fromPath ? [] : fromPath.split("/").slice(0, -1);
20376
- for (const part of importPath.split("/")) {
20377
- if (part === "" || part === ".") continue;
20378
- if (part === ".." && parts.length > 0 && parts[parts.length - 1] !== "..") parts.pop();
20379
- else parts.push(part);
20380
- }
20381
- return parts.join("/");
20382
- }
20383
- function mapResolver(files) {
20384
- const paths = Object.keys(files);
20385
- return (importPath, fromPath) => {
20386
- const path = joinImportPath(fromPath, importPath);
20387
- if (Object.prototype.hasOwnProperty.call(files, path)) return { path, source: files[path] };
20388
- const name = path.split("/").pop();
20389
- const sameName = paths.filter((p) => p.split("/").pop() === name);
20390
- return sameName.length === 1 ? { path: sameName[0], source: files[sameName[0]] } : void 0;
20391
- };
20392
- }
20393
-
20394
21380
  // ../frontend/src/playground/imports.ts
20395
21381
  function filesResolver(files, rootPath) {
20396
21382
  const resolve8 = mapResolver(files);
@@ -21276,7 +22262,7 @@ function readTopics(text, file = "tags.json") {
21276
22262
  return { id: topic.id, title: topic.title, summary: topic.summary };
21277
22263
  });
21278
22264
  }
21279
- function readLock(text) {
22265
+ function readLock2(text) {
21280
22266
  const ids = [];
21281
22267
  const lines = /* @__PURE__ */ new Map();
21282
22268
  text.split("\n").forEach((raw, i) => {
@@ -21291,7 +22277,7 @@ var LOCK_HEADER = `# Every card id ever published, sorted. Review history is sto
21291
22277
  # id is never deleted or reused: retire a card with \`status: retired\` instead.
21292
22278
  # \`proschi cards lock\` adds new cards' ids (docs/CARDS.md).
21293
22279
  `;
21294
- function writeLock(existing, ids) {
22280
+ function writeLock2(existing, ids) {
21295
22281
  return LOCK_HEADER + [.../* @__PURE__ */ new Set([...existing, ...ids])].sort().join("\n") + "\n";
21296
22282
  }
21297
22283
  function compareCards(a, b) {
@@ -21729,43 +22715,43 @@ var PROBLEM_HELP = `problem Practice problems, one folder each (docs/PRACTICE.md
21729
22715
  passes).
21730
22716
  Exits with 1 on any violation.
21731
22717
  new scaffolds a folder that already passes check, with TODOs.`;
21732
- var REPO_PROBLEMS = (0, import_node_path6.join)("frontend", "src", "practice", "problems");
22718
+ var REPO_PROBLEMS = (0, import_node_path7.join)("frontend", "src", "practice", "problems");
21733
22719
  function findProblemsDir(cwd = process.cwd()) {
21734
- for (let dir = (0, import_node_path6.resolve)(cwd); ; dir = (0, import_node_path6.dirname)(dir)) {
21735
- const candidate = (0, import_node_path6.join)(dir, REPO_PROBLEMS);
21736
- if ((0, import_node_fs5.existsSync)(candidate) && (0, import_node_fs5.statSync)(candidate).isDirectory()) return candidate;
21737
- if ((0, import_node_path6.dirname)(dir) === dir) return void 0;
22720
+ for (let dir = (0, import_node_path7.resolve)(cwd); ; dir = (0, import_node_path7.dirname)(dir)) {
22721
+ const candidate = (0, import_node_path7.join)(dir, REPO_PROBLEMS);
22722
+ if ((0, import_node_fs6.existsSync)(candidate) && (0, import_node_fs6.statSync)(candidate).isDirectory()) return candidate;
22723
+ if ((0, import_node_path7.dirname)(dir) === dir) return void 0;
21738
22724
  }
21739
22725
  }
21740
22726
  function readProblemFolder(folder) {
21741
22727
  const files = {};
21742
- for (const entry of (0, import_node_fs5.readdirSync)(folder, { withFileTypes: true })) {
22728
+ for (const entry of (0, import_node_fs6.readdirSync)(folder, { withFileTypes: true })) {
21743
22729
  if (entry.name.startsWith(".")) continue;
21744
- if (entry.isFile()) files[entry.name] = (0, import_node_fs5.readFileSync)((0, import_node_path6.join)(folder, entry.name), "utf8");
22730
+ if (entry.isFile()) files[entry.name] = (0, import_node_fs6.readFileSync)((0, import_node_path7.join)(folder, entry.name), "utf8");
21745
22731
  else if (entry.isDirectory()) {
21746
- for (const sub of (0, import_node_fs5.readdirSync)((0, import_node_path6.join)(folder, entry.name), { withFileTypes: true })) {
21747
- if (!sub.name.startsWith(".")) files[`${entry.name}/${sub.name}`] = sub.isFile() ? (0, import_node_fs5.readFileSync)((0, import_node_path6.join)(folder, entry.name, sub.name), "utf8") : "";
22732
+ for (const sub of (0, import_node_fs6.readdirSync)((0, import_node_path7.join)(folder, entry.name), { withFileTypes: true })) {
22733
+ if (!sub.name.startsWith(".")) files[`${entry.name}/${sub.name}`] = sub.isFile() ? (0, import_node_fs6.readFileSync)((0, import_node_path7.join)(folder, entry.name, sub.name), "utf8") : "";
21748
22734
  }
21749
22735
  }
21750
22736
  }
21751
22737
  return files;
21752
22738
  }
21753
22739
  function siblingCardIds(problemsDir) {
21754
- const cards = (0, import_node_path6.join)(problemsDir, "..", "cards");
21755
- if (!(0, import_node_fs5.existsSync)((0, import_node_path6.join)(cards, "tags.json"))) return void 0;
22740
+ const cards = (0, import_node_path7.join)(problemsDir, "..", "cards");
22741
+ if (!(0, import_node_fs6.existsSync)((0, import_node_path7.join)(cards, "tags.json"))) return void 0;
21756
22742
  const ids = /* @__PURE__ */ new Set();
21757
- for (const topic of (0, import_node_fs5.readdirSync)(cards, { withFileTypes: true })) {
22743
+ for (const topic of (0, import_node_fs6.readdirSync)(cards, { withFileTypes: true })) {
21758
22744
  if (!topic.isDirectory()) continue;
21759
- for (const file of (0, import_node_fs5.readdirSync)((0, import_node_path6.join)(cards, topic.name))) if (file.endsWith(".md")) ids.add(file.slice(0, -".md".length));
22745
+ for (const file of (0, import_node_fs6.readdirSync)((0, import_node_path7.join)(cards, topic.name))) if (file.endsWith(".md")) ids.add(file.slice(0, -".md".length));
21760
22746
  }
21761
22747
  return ids;
21762
22748
  }
21763
22749
  function checkProblems(dir) {
21764
22750
  const cardIds = siblingCardIds(dir);
21765
- return (0, import_node_fs5.readdirSync)(dir, { withFileTypes: true }).filter((e) => e.isDirectory() && !e.name.startsWith(".") && e.name !== "node_modules").map((e) => e.name).sort().map((id) => {
21766
- const folder = displayPath3((0, import_node_path6.join)(dir, id));
22751
+ return (0, import_node_fs6.readdirSync)(dir, { withFileTypes: true }).filter((e) => e.isDirectory() && !e.name.startsWith(".") && e.name !== "node_modules").map((e) => e.name).sort().map((id) => {
22752
+ const folder = displayPath3((0, import_node_path7.join)(dir, id));
21767
22753
  try {
21768
- const problem = problemFromFiles(id, readProblemFolder((0, import_node_path6.join)(dir, id)));
22754
+ const problem = problemFromFiles(id, readProblemFolder((0, import_node_path7.join)(dir, id)));
21769
22755
  return { folder, loaded: true, ...validateProblem(problem, void 0, { cardIds }) };
21770
22756
  } catch (e) {
21771
22757
  if (!(e instanceof ProblemFolderError)) throw e;
@@ -21779,7 +22765,7 @@ var escape3 = (s) => s.replace(/%/g, "%25").replace(/\r?\n/g, "%0A");
21779
22765
  function formatProblemReports(dir, reports, style) {
21780
22766
  if (style === "json") return JSON.stringify({ dir: displayPath3(dir), problems: reports }, null, 2);
21781
22767
  const lines = [];
21782
- const where = (r, v) => v.file === "." ? r.folder : (0, import_node_path6.join)(r.folder, v.file);
22768
+ const where = (r, v) => v.file === "." ? r.folder : (0, import_node_path7.join)(r.folder, v.file);
21783
22769
  for (const r of reports) {
21784
22770
  if (style === "github") {
21785
22771
  for (const v of r.violations) lines.push(`::error file=${where(r, v)},line=${v.line ?? 1},title=proschi problem ${r.id}::${escape3(v.message)}`);
@@ -21833,7 +22819,7 @@ Usage:
21833
22819
  ${PROBLEM_USAGE}`);
21834
22820
  return 2;
21835
22821
  }
21836
- const dir = paths[0] ? (0, import_node_path6.resolve)(cwd, paths[0]) : findProblemsDir(cwd);
22822
+ const dir = paths[0] ? (0, import_node_path7.resolve)(cwd, paths[0]) : findProblemsDir(cwd);
21837
22823
  if (!dir) {
21838
22824
  err(`Not inside the Proschi repository; give the problems directory
21839
22825
 
@@ -21841,7 +22827,7 @@ Usage:
21841
22827
  ${PROBLEM_USAGE}`);
21842
22828
  return 2;
21843
22829
  }
21844
- if (!(0, import_node_fs5.existsSync)(dir) || !(0, import_node_fs5.statSync)(dir).isDirectory()) {
22830
+ if (!(0, import_node_fs6.existsSync)(dir) || !(0, import_node_fs6.statSync)(dir).isDirectory()) {
21845
22831
  err(`No such directory: ${paths[0] ?? dir}`);
21846
22832
  return 2;
21847
22833
  }
@@ -21883,16 +22869,16 @@ ${PROBLEM_USAGE}`);
21883
22869
  err(`The id '${id}' must be lowercase words joined by "-", e.g. url-shortener (it is the folder name and the URL)`);
21884
22870
  return 2;
21885
22871
  }
21886
- const dir = dirArg ? (0, import_node_path6.resolve)(cwd, dirArg) : findProblemsDir(cwd) ?? (0, import_node_path6.resolve)(cwd, "problems");
21887
- const folder = (0, import_node_path6.join)(dir, id);
21888
- if ((0, import_node_fs5.existsSync)(folder)) {
22872
+ const dir = dirArg ? (0, import_node_path7.resolve)(cwd, dirArg) : findProblemsDir(cwd) ?? (0, import_node_path7.resolve)(cwd, "problems");
22873
+ const folder = (0, import_node_path7.join)(dir, id);
22874
+ if ((0, import_node_fs6.existsSync)(folder)) {
21889
22875
  err(`${displayPath3(folder)} already exists`);
21890
22876
  return 1;
21891
22877
  }
21892
22878
  const files = problemTemplate(id);
21893
22879
  for (const [name, text] of Object.entries(files)) {
21894
- (0, import_node_fs5.mkdirSync)((0, import_node_path6.dirname)((0, import_node_path6.join)(folder, name)), { recursive: true });
21895
- (0, import_node_fs5.writeFileSync)((0, import_node_path6.join)(folder, name), text);
22880
+ (0, import_node_fs6.mkdirSync)((0, import_node_path7.dirname)((0, import_node_path7.join)(folder, name)), { recursive: true });
22881
+ (0, import_node_fs6.writeFileSync)((0, import_node_path7.join)(folder, name), text);
21896
22882
  }
21897
22883
  const shown = displayPath3(folder);
21898
22884
  out(`Created ${shown}/
@@ -21924,12 +22910,12 @@ ${PROBLEM_USAGE}`);
21924
22910
  }
21925
22911
 
21926
22912
  // src/cards.ts
21927
- var import_node_fs6 = require("node:fs");
21928
- var import_node_path7 = require("node:path");
22913
+ var import_node_fs7 = require("node:fs");
22914
+ var import_node_path8 = require("node:path");
21929
22915
 
21930
22916
  // ../frontend/src/learn/cardCheck.ts
21931
22917
  var TAGS_FILE = "tags.json";
21932
- var LOCK_FILE = "ids.lock";
22918
+ var LOCK_FILE2 = "ids.lock";
21933
22919
  var DUPLICATE_THRESHOLD = 0.4;
21934
22920
  var STOP = new Set(
21935
22921
  "a an the and or of to in on for with is are was be it its this that what which when why how does do you your from by as at than then into can would should".split(" ")
@@ -21980,9 +22966,9 @@ function checkCardFiles(files, options = {}) {
21980
22966
  const topicIds = new Set(topics.map((t) => t.id));
21981
22967
  const cards = [];
21982
22968
  for (const file of Object.keys(files).sort()) {
21983
- if (file === TAGS_FILE || file === LOCK_FILE) continue;
22969
+ if (file === TAGS_FILE || file === LOCK_FILE2) continue;
21984
22970
  if (!/^[^/]+\/[^/]+\.md$/.test(file)) {
21985
- add(file, `Unexpected file: the cards folder holds ${TAGS_FILE}, ${LOCK_FILE} and <topic>/<id>.md`);
22971
+ add(file, `Unexpected file: the cards folder holds ${TAGS_FILE}, ${LOCK_FILE2} and <topic>/<id>.md`);
21986
22972
  continue;
21987
22973
  }
21988
22974
  try {
@@ -22018,16 +23004,16 @@ function checkCardFiles(files, options = {}) {
22018
23004
  for (const card of cards) {
22019
23005
  for (const other of card.distinctFrom) if (!byId.has(other)) add(fileOf(card), `'distinct-from' names "${other}", which is not a card`);
22020
23006
  }
22021
- if (files[LOCK_FILE] === void 0) add(LOCK_FILE, "Missing: run `proschi cards lock` to create it");
23007
+ if (files[LOCK_FILE2] === void 0) add(LOCK_FILE2, "Missing: run `proschi cards lock` to create it");
22022
23008
  else {
22023
- const { ids, lines } = readLock(files[LOCK_FILE]);
22024
- const sorted = [...new Set(ids)].sort();
22025
- if (ids.join("\n") !== sorted.join("\n")) add(LOCK_FILE, "Not sorted, or an id appears twice; run `proschi cards lock`");
23009
+ const { ids, lines } = readLock2(files[LOCK_FILE2]);
23010
+ const sorted2 = [...new Set(ids)].sort();
23011
+ if (ids.join("\n") !== sorted2.join("\n")) add(LOCK_FILE2, "Not sorted, or an id appears twice; run `proschi cards lock`");
22026
23012
  for (const card of cards) {
22027
- if (!lines.has(card.id)) add(fileOf(card), `New card: add its id to ${LOCK_FILE} with \`proschi cards lock\``);
23013
+ if (!lines.has(card.id)) add(fileOf(card), `New card: add its id to ${LOCK_FILE2} with \`proschi cards lock\``);
22028
23014
  }
22029
23015
  for (const id of lines.keys()) {
22030
- if (!byId.has(id)) add(LOCK_FILE, `"${id}" has no card file: never delete a card, set \`status: retired\` in its front matter instead`, lines.get(id));
23016
+ if (!byId.has(id)) add(LOCK_FILE2, `"${id}" has no card file: never delete a card, set \`status: retired\` in its front matter instead`, lines.get(id));
22031
23017
  }
22032
23018
  }
22033
23019
  for (const { a, b, score } of overlaps(cards, options.threshold)) {
@@ -22052,34 +23038,34 @@ var CARDS_HELP = `cards Practice cards, one Markdown file each (docs/CARDS.md)
22052
23038
  phone, and no two cards that ask nearly the same thing.
22053
23039
  Exits with 1 on any violation.
22054
23040
  lock adds the ids of new cards to ids.lock.`;
22055
- var REPO_CARDS = (0, import_node_path7.join)("frontend", "src", "practice", "cards");
23041
+ var REPO_CARDS = (0, import_node_path8.join)("frontend", "src", "practice", "cards");
22056
23042
  function findCardsDir(cwd = process.cwd()) {
22057
- for (let dir = (0, import_node_path7.resolve)(cwd); ; dir = (0, import_node_path7.dirname)(dir)) {
22058
- const candidate = (0, import_node_path7.join)(dir, REPO_CARDS);
22059
- if ((0, import_node_fs6.existsSync)(candidate) && (0, import_node_fs6.statSync)(candidate).isDirectory()) return candidate;
22060
- if ((0, import_node_path7.dirname)(dir) === dir) return void 0;
23043
+ for (let dir = (0, import_node_path8.resolve)(cwd); ; dir = (0, import_node_path8.dirname)(dir)) {
23044
+ const candidate = (0, import_node_path8.join)(dir, REPO_CARDS);
23045
+ if ((0, import_node_fs7.existsSync)(candidate) && (0, import_node_fs7.statSync)(candidate).isDirectory()) return candidate;
23046
+ if ((0, import_node_path8.dirname)(dir) === dir) return void 0;
22061
23047
  }
22062
23048
  }
22063
23049
  function readCardsFolder(dir) {
22064
23050
  const files = {};
22065
- for (const entry of (0, import_node_fs6.readdirSync)(dir, { withFileTypes: true })) {
23051
+ for (const entry of (0, import_node_fs7.readdirSync)(dir, { withFileTypes: true })) {
22066
23052
  if (entry.name.startsWith(".")) continue;
22067
- if (entry.isFile()) files[entry.name] = (0, import_node_fs6.readFileSync)((0, import_node_path7.join)(dir, entry.name), "utf8");
23053
+ if (entry.isFile()) files[entry.name] = (0, import_node_fs7.readFileSync)((0, import_node_path8.join)(dir, entry.name), "utf8");
22068
23054
  else if (entry.isDirectory()) {
22069
- for (const sub of (0, import_node_fs6.readdirSync)((0, import_node_path7.join)(dir, entry.name), { withFileTypes: true })) {
22070
- if (!sub.name.startsWith(".")) files[`${entry.name}/${sub.name}`] = sub.isFile() ? (0, import_node_fs6.readFileSync)((0, import_node_path7.join)(dir, entry.name, sub.name), "utf8") : "";
23055
+ for (const sub of (0, import_node_fs7.readdirSync)((0, import_node_path8.join)(dir, entry.name), { withFileTypes: true })) {
23056
+ if (!sub.name.startsWith(".")) files[`${entry.name}/${sub.name}`] = sub.isFile() ? (0, import_node_fs7.readFileSync)((0, import_node_path8.join)(dir, entry.name, sub.name), "utf8") : "";
22071
23057
  }
22072
23058
  }
22073
23059
  }
22074
23060
  return files;
22075
23061
  }
22076
23062
  function problemIdsIn(problemsDir) {
22077
- return (0, import_node_fs6.readdirSync)(problemsDir, { withFileTypes: true }).filter((e) => e.isDirectory() && !e.name.startsWith(".")).map((e) => e.name);
23063
+ return (0, import_node_fs7.readdirSync)(problemsDir, { withFileTypes: true }).filter((e) => e.isDirectory() && !e.name.startsWith(".")).map((e) => e.name);
22078
23064
  }
22079
23065
  var escape4 = (s) => s.replace(/%/g, "%25").replace(/\r?\n/g, "%0A");
22080
23066
  function formatCardCheck(dir, check, style) {
22081
23067
  const shown = displayPath3(dir);
22082
- const where = (v) => (0, import_node_path7.join)(shown, v.file);
23068
+ const where = (v) => (0, import_node_path8.join)(shown, v.file);
22083
23069
  if (style === "json") {
22084
23070
  return JSON.stringify({ dir: shown, cards: check.cards.length, topics: check.topics.length, violations: check.violations }, null, 2);
22085
23071
  }
@@ -22134,7 +23120,7 @@ ${CARDS_USAGE}`);
22134
23120
  return parsed;
22135
23121
  }
22136
23122
  function resolveDir(dirArg, cwd, err) {
22137
- const dir = dirArg ? (0, import_node_path7.resolve)(cwd, dirArg) : findCardsDir(cwd);
23123
+ const dir = dirArg ? (0, import_node_path8.resolve)(cwd, dirArg) : findCardsDir(cwd);
22138
23124
  if (!dir) {
22139
23125
  err(`Not inside the Proschi repository; give the cards directory
22140
23126
 
@@ -22142,7 +23128,7 @@ Usage:
22142
23128
  ${CARDS_USAGE}`);
22143
23129
  return void 0;
22144
23130
  }
22145
- if (!(0, import_node_fs6.existsSync)(dir) || !(0, import_node_fs6.statSync)(dir).isDirectory()) {
23131
+ if (!(0, import_node_fs7.existsSync)(dir) || !(0, import_node_fs7.statSync)(dir).isDirectory()) {
22146
23132
  err(`No such directory: ${dirArg ?? dir}`);
22147
23133
  return void 0;
22148
23134
  }
@@ -22153,9 +23139,9 @@ function cardsCheck(args, out, err, cwd) {
22153
23139
  if (!parsed) return 2;
22154
23140
  const dir = resolveDir(parsed.dir, cwd, err);
22155
23141
  if (!dir) return 2;
22156
- const besideCards = (0, import_node_path7.join)(dir, "..", "problems");
22157
- const problemsDir = parsed.problems ? (0, import_node_path7.resolve)(cwd, parsed.problems) : (0, import_node_fs6.existsSync)(besideCards) ? besideCards : findProblemsDir(cwd);
22158
- if (parsed.problems && !((0, import_node_fs6.existsSync)(problemsDir) && (0, import_node_fs6.statSync)(problemsDir).isDirectory())) {
23142
+ const besideCards = (0, import_node_path8.join)(dir, "..", "problems");
23143
+ const problemsDir = parsed.problems ? (0, import_node_path8.resolve)(cwd, parsed.problems) : (0, import_node_fs7.existsSync)(besideCards) ? besideCards : findProblemsDir(cwd);
23144
+ if (parsed.problems && !((0, import_node_fs7.existsSync)(problemsDir) && (0, import_node_fs7.statSync)(problemsDir).isDirectory())) {
22159
23145
  err(`No such directory: ${parsed.problems}`);
22160
23146
  return 2;
22161
23147
  }
@@ -22180,11 +23166,11 @@ function cardsLock(args, out, err, cwd) {
22180
23166
  ids.push(file.slice(file.indexOf("/") + 1, -".md".length));
22181
23167
  }
22182
23168
  }
22183
- const lockPath = (0, import_node_path7.join)(dir, LOCK_FILE);
22184
- const existing = (0, import_node_fs6.existsSync)(lockPath) ? readLock((0, import_node_fs6.readFileSync)(lockPath, "utf8")).ids : [];
23169
+ const lockPath = (0, import_node_path8.join)(dir, LOCK_FILE2);
23170
+ const existing = (0, import_node_fs7.existsSync)(lockPath) ? readLock2((0, import_node_fs7.readFileSync)(lockPath, "utf8")).ids : [];
22185
23171
  const added = [...new Set(ids)].filter((id) => !existing.includes(id)).sort();
22186
- const text = writeLock(existing, ids);
22187
- if (!(0, import_node_fs6.existsSync)(lockPath) || (0, import_node_fs6.readFileSync)(lockPath, "utf8") !== text) (0, import_node_fs6.writeFileSync)(lockPath, text);
23172
+ const text = writeLock2(existing, ids);
23173
+ if (!(0, import_node_fs7.existsSync)(lockPath) || (0, import_node_fs7.readFileSync)(lockPath, "utf8") !== text) (0, import_node_fs7.writeFileSync)(lockPath, text);
22188
23174
  out(added.length ? `Added ${added.length} id${added.length === 1 ? "" : "s"} to ${displayPath3(lockPath)}:
22189
23175
  ${added.join("\n ")}` : `${displayPath3(lockPath)} already lists every card`);
22190
23176
  return 0;
@@ -22201,8 +23187,8 @@ ${CARDS_USAGE}`);
22201
23187
  }
22202
23188
 
22203
23189
  // src/achievements.ts
22204
- var import_node_fs7 = require("node:fs");
22205
- var import_node_path8 = require("node:path");
23190
+ var import_node_fs8 = require("node:fs");
23191
+ var import_node_path9 = require("node:path");
22206
23192
 
22207
23193
  // ../frontend/src/learn/fsrs.ts
22208
23194
  var FACTOR = 19 / 81;
@@ -22282,7 +23268,7 @@ function checkAchievements(raw, context) {
22282
23268
  if (violations.length === before2) achievements.push(a);
22283
23269
  });
22284
23270
  if (context.lock !== void 0) {
22285
- const { ids: locked, lines } = readLock(context.lock);
23271
+ const { ids: locked, lines } = readLock2(context.lock);
22286
23272
  if (locked.join("\n") !== [...new Set(locked)].sort().join("\n")) violations.push({ inLock: true, message: `${ACHIEVEMENTS_LOCK_FILE} is not sorted, or an id appears twice; run \`proschi achievements lock\`` });
22287
23273
  raw.forEach((item, index) => {
22288
23274
  const id = item && typeof item === "object" ? item.id : void 0;
@@ -22395,21 +23381,21 @@ var ACHIEVEMENTS_HELP = `achievements
22395
23381
  the file, which lists no deleted achievement (retire one with
22396
23382
  "retired": true instead). Exits with 1 on any violation. lock adds
22397
23383
  the ids of new achievements to achievements.lock.`;
22398
- var REPO_ACHIEVEMENTS = (0, import_node_path8.join)("frontend", "src", "practice", "achievements.json");
23384
+ var REPO_ACHIEVEMENTS = (0, import_node_path9.join)("frontend", "src", "practice", "achievements.json");
22399
23385
  function findAchievementsFile(cwd = process.cwd()) {
22400
- for (let dir = (0, import_node_path8.resolve)(cwd); ; dir = (0, import_node_path8.dirname)(dir)) {
22401
- const candidate = (0, import_node_path8.join)(dir, REPO_ACHIEVEMENTS);
22402
- if ((0, import_node_fs7.existsSync)(candidate) && (0, import_node_fs7.statSync)(candidate).isFile()) return candidate;
22403
- if ((0, import_node_path8.dirname)(dir) === dir) return void 0;
23386
+ for (let dir = (0, import_node_path9.resolve)(cwd); ; dir = (0, import_node_path9.dirname)(dir)) {
23387
+ const candidate = (0, import_node_path9.join)(dir, REPO_ACHIEVEMENTS);
23388
+ if ((0, import_node_fs8.existsSync)(candidate) && (0, import_node_fs8.statSync)(candidate).isFile()) return candidate;
23389
+ if ((0, import_node_path9.dirname)(dir) === dir) return void 0;
22404
23390
  }
22405
23391
  }
22406
23392
  function problemsIn(dir) {
22407
23393
  const out = [];
22408
- for (const entry of (0, import_node_fs7.readdirSync)(dir, { withFileTypes: true })) {
22409
- const file = (0, import_node_path8.join)(dir, entry.name, PROBLEM_MD);
22410
- if (!entry.isDirectory() || entry.name.startsWith(".") || !(0, import_node_fs7.existsSync)(file)) continue;
23394
+ for (const entry of (0, import_node_fs8.readdirSync)(dir, { withFileTypes: true })) {
23395
+ const file = (0, import_node_path9.join)(dir, entry.name, PROBLEM_MD);
23396
+ if (!entry.isDirectory() || entry.name.startsWith(".") || !(0, import_node_fs8.existsSync)(file)) continue;
22411
23397
  try {
22412
- const { difficulty, tags } = readProblemMd(entry.name, (0, import_node_fs7.readFileSync)(file, "utf8"));
23398
+ const { difficulty, tags } = readProblemMd(entry.name, (0, import_node_fs8.readFileSync)(file, "utf8"));
22413
23399
  out.push({ id: entry.name, difficulty, tags });
22414
23400
  } catch {
22415
23401
  }
@@ -22417,7 +23403,7 @@ function problemsIn(dir) {
22417
23403
  return out;
22418
23404
  }
22419
23405
  function lineOf2(text, lock, v) {
22420
- if (v.inLock) return v.id !== void 0 && lock !== void 0 ? readLock(lock).lines.get(v.id) : void 0;
23406
+ if (v.inLock) return v.id !== void 0 && lock !== void 0 ? readLock2(lock).lines.get(v.id) : void 0;
22421
23407
  if (v.id === void 0) return void 0;
22422
23408
  const needle = `"id": ${JSON.stringify(v.id)}`;
22423
23409
  const at = v.message.startsWith("Duplicate id") ? text.lastIndexOf(needle) : text.indexOf(needle);
@@ -22461,11 +23447,11 @@ ${ACHIEVEMENTS_USAGE}`);
22461
23447
  parsed.file = paths[0];
22462
23448
  return parsed;
22463
23449
  }
22464
- var isDir = (p) => (0, import_node_fs7.existsSync)(p) && (0, import_node_fs7.statSync)(p).isDirectory();
23450
+ var isDir = (p) => (0, import_node_fs8.existsSync)(p) && (0, import_node_fs8.statSync)(p).isDirectory();
22465
23451
  function achievementsCheck(args, out, err, cwd) {
22466
23452
  const parsed = parseArgs3(args, err);
22467
23453
  if (!parsed) return 2;
22468
- const file = parsed.file ? (0, import_node_path8.resolve)(cwd, parsed.file) : findAchievementsFile(cwd);
23454
+ const file = parsed.file ? (0, import_node_path9.resolve)(cwd, parsed.file) : findAchievementsFile(cwd);
22469
23455
  if (!file) {
22470
23456
  err(`Not inside the Proschi repository; give the achievements file
22471
23457
 
@@ -22473,26 +23459,26 @@ Usage:
22473
23459
  ${ACHIEVEMENTS_USAGE}`);
22474
23460
  return 2;
22475
23461
  }
22476
- if (!(0, import_node_fs7.existsSync)(file) || !(0, import_node_fs7.statSync)(file).isFile()) {
23462
+ if (!(0, import_node_fs8.existsSync)(file) || !(0, import_node_fs8.statSync)(file).isFile()) {
22477
23463
  err(`No such file: ${parsed.file ?? file}`);
22478
23464
  return 2;
22479
23465
  }
22480
- const problemsDir = parsed.problems ? (0, import_node_path8.resolve)(cwd, parsed.problems) : (0, import_node_path8.join)((0, import_node_path8.dirname)(file), "problems");
22481
- const cardsDir = parsed.cards ? (0, import_node_path8.resolve)(cwd, parsed.cards) : (0, import_node_path8.join)((0, import_node_path8.dirname)(file), "cards");
23466
+ const problemsDir = parsed.problems ? (0, import_node_path9.resolve)(cwd, parsed.problems) : (0, import_node_path9.join)((0, import_node_path9.dirname)(file), "problems");
23467
+ const cardsDir = parsed.cards ? (0, import_node_path9.resolve)(cwd, parsed.cards) : (0, import_node_path9.join)((0, import_node_path9.dirname)(file), "cards");
22482
23468
  for (const [flag, dir] of [["--problems", problemsDir], ["--cards", cardsDir]]) {
22483
23469
  if (!isDir(dir)) {
22484
23470
  err(`No such directory: ${dir}${parsed[flag === "--problems" ? "problems" : "cards"] ? "" : ` (give ${flag} <dir>)`}`);
22485
23471
  return 2;
22486
23472
  }
22487
23473
  }
22488
- const text = (0, import_node_fs7.readFileSync)(file, "utf8");
22489
- const lockPath = (0, import_node_path8.join)((0, import_node_path8.dirname)(file), ACHIEVEMENTS_LOCK_FILE);
22490
- const lock = (0, import_node_fs7.existsSync)(lockPath) ? (0, import_node_fs7.readFileSync)(lockPath, "utf8") : void 0;
23474
+ const text = (0, import_node_fs8.readFileSync)(file, "utf8");
23475
+ const lockPath = (0, import_node_path9.join)((0, import_node_path9.dirname)(file), ACHIEVEMENTS_LOCK_FILE);
23476
+ const lock = (0, import_node_fs8.existsSync)(lockPath) ? (0, import_node_fs8.readFileSync)(lockPath, "utf8") : void 0;
22491
23477
  let topics;
22492
23478
  try {
22493
- topics = readTopics((0, import_node_fs7.readFileSync)((0, import_node_path8.join)(cardsDir, "tags.json"), "utf8")).map((t) => t.id);
23479
+ topics = readTopics((0, import_node_fs8.readFileSync)((0, import_node_path9.join)(cardsDir, "tags.json"), "utf8")).map((t) => t.id);
22494
23480
  } catch (e) {
22495
- err(e instanceof CardFileError ? `${(0, import_node_path8.join)(displayPath3(cardsDir), "tags.json")}: ${e.detail}` : String(e));
23481
+ err(e instanceof CardFileError ? `${(0, import_node_path9.join)(displayPath3(cardsDir), "tags.json")}: ${e.detail}` : String(e));
22496
23482
  return 2;
22497
23483
  }
22498
23484
  let raw;
@@ -22535,7 +23521,7 @@ Usage:
22535
23521
  ${ACHIEVEMENTS_USAGE}`);
22536
23522
  return 2;
22537
23523
  }
22538
- const file = args[0] ? (0, import_node_path8.resolve)(cwd, args[0]) : findAchievementsFile(cwd);
23524
+ const file = args[0] ? (0, import_node_path9.resolve)(cwd, args[0]) : findAchievementsFile(cwd);
22539
23525
  if (!file) {
22540
23526
  err(`Not inside the Proschi repository; give the achievements file
22541
23527
 
@@ -22543,13 +23529,13 @@ Usage:
22543
23529
  ${ACHIEVEMENTS_USAGE}`);
22544
23530
  return 2;
22545
23531
  }
22546
- if (!(0, import_node_fs7.existsSync)(file) || !(0, import_node_fs7.statSync)(file).isFile()) {
23532
+ if (!(0, import_node_fs8.existsSync)(file) || !(0, import_node_fs8.statSync)(file).isFile()) {
22547
23533
  err(`No such file: ${args[0] ?? file}`);
22548
23534
  return 2;
22549
23535
  }
22550
23536
  let raw;
22551
23537
  try {
22552
- raw = JSON.parse((0, import_node_fs7.readFileSync)(file, "utf8"));
23538
+ raw = JSON.parse((0, import_node_fs8.readFileSync)(file, "utf8"));
22553
23539
  } catch (e) {
22554
23540
  err(`${displayPath3(file)}: not valid JSON: ${e.message}`);
22555
23541
  return 1;
@@ -22559,12 +23545,12 @@ ${ACHIEVEMENTS_USAGE}`);
22559
23545
  return 1;
22560
23546
  }
22561
23547
  const ids = raw.flatMap((a) => a && typeof a === "object" && typeof a.id === "string" ? [a.id] : []);
22562
- const lockPath = (0, import_node_path8.join)((0, import_node_path8.dirname)(file), ACHIEVEMENTS_LOCK_FILE);
22563
- const before2 = (0, import_node_fs7.existsSync)(lockPath) ? (0, import_node_fs7.readFileSync)(lockPath, "utf8") : void 0;
22564
- const existing = before2 !== void 0 ? readLock(before2).ids : [];
23548
+ const lockPath = (0, import_node_path9.join)((0, import_node_path9.dirname)(file), ACHIEVEMENTS_LOCK_FILE);
23549
+ const before2 = (0, import_node_fs8.existsSync)(lockPath) ? (0, import_node_fs8.readFileSync)(lockPath, "utf8") : void 0;
23550
+ const existing = before2 !== void 0 ? readLock2(before2).ids : [];
22565
23551
  const added = [...new Set(ids)].filter((id) => !existing.includes(id)).sort();
22566
23552
  const text = writeAchievementsLock(existing, ids);
22567
- if (before2 !== text) (0, import_node_fs7.writeFileSync)(lockPath, text);
23553
+ if (before2 !== text) (0, import_node_fs8.writeFileSync)(lockPath, text);
22568
23554
  out(added.length ? `Added ${added.length} id${added.length === 1 ? "" : "s"} to ${displayPath3(lockPath)}:
22569
23555
  ${added.join("\n ")}` : `${displayPath3(lockPath)} already lists every achievement`);
22570
23556
  return 0;
@@ -22581,8 +23567,8 @@ ${ACHIEVEMENTS_USAGE}`);
22581
23567
  }
22582
23568
 
22583
23569
  // src/game.ts
22584
- var import_node_fs8 = require("node:fs");
22585
- var import_node_path9 = require("node:path");
23570
+ var import_node_fs9 = require("node:fs");
23571
+ var import_node_path10 = require("node:path");
22586
23572
 
22587
23573
  // ../frontend/src/game/engine/compile.ts
22588
23574
  var BACKFILL_PREFIX = "backfill_";
@@ -24341,27 +25327,6 @@ function ownerOf(t) {
24341
25327
  return void 0;
24342
25328
  }
24343
25329
 
24344
- // ../frontend/src/sim/overlay.ts
24345
- function flowsOf(diagram, traffic) {
24346
- const flows = /* @__PURE__ */ new Map();
24347
- for (const t of traffic) {
24348
- const u = findUseCase(diagram, t.useCase);
24349
- if (!u || t.rps <= 0) continue;
24350
- for (const sc of u.scenarios) {
24351
- const share = t.mix ? t.mix.find((m) => m.scenario === sc.name)?.share ?? 0 : sc === u.scenarios[0] ? 1 : 0;
24352
- if (share <= 0) continue;
24353
- for (const step of sc.steps) {
24354
- if (step.failed) continue;
24355
- const key = `${step.fromServiceId}>${step.toServiceId}`;
24356
- const f = flows.get(key) ?? { from: step.fromServiceId, to: step.toServiceId, rps: 0, async: step.executionType !== "SYNC_REQUEST_RESPONSE" };
24357
- f.rps += t.rps * share * (step.multiplier ?? 1);
24358
- flows.set(key, f);
24359
- }
24360
- }
24361
- }
24362
- return [...flows.values()];
24363
- }
24364
-
24365
25330
  // ../frontend/src/game/engine/tick.ts
24366
25331
  var LOC = { line: 0, col: 0, length: 0 };
24367
25332
  var PROFILE_CACHE = /* @__PURE__ */ new Map();
@@ -25250,7 +26215,7 @@ function structuredCloneAction(a) {
25250
26215
  }
25251
26216
 
25252
26217
  // ../frontend/src/game/engine/check.ts
25253
- var LOCK_FILE2 = "ids.lock";
26218
+ var LOCK_FILE3 = "ids.lock";
25254
26219
  function applyPlay(board, play) {
25255
26220
  const b = cloneBoard(board);
25256
26221
  for (const id of play.remove ?? []) {
@@ -25346,13 +26311,13 @@ function checkGame(files, ctx) {
25346
26311
  const violations = errors.map((e) => ({ file: e.file, message: e.message.replace(/^[^:]*: /, ""), ...e.line ? { line: e.line } : {} }));
25347
26312
  const v = (file, message) => violations.push({ file, message });
25348
26313
  const runs = [];
25349
- const lockText = files[LOCK_FILE2];
26314
+ const lockText = files[LOCK_FILE3];
25350
26315
  const ids = publishedIds(content);
25351
- if (lockText === void 0) v(LOCK_FILE2, "Missing: run `proschi game lock`");
26316
+ if (lockText === void 0) v(LOCK_FILE3, "Missing: run `proschi game lock`");
25352
26317
  else {
25353
26318
  const locked = new Set(lockText.split(/\r?\n/).map((l) => l.trim()).filter((l) => l && !l.startsWith("#")));
25354
- for (const id of ids) if (!locked.has(id)) v(LOCK_FILE2, `'${id}' is not in ids.lock: run \`proschi game lock\``);
25355
- for (const id of locked) if (!ids.includes(id)) v(LOCK_FILE2, `'${id}' was published and is gone: ids are never deleted (progress and replays refer to them)`);
26319
+ for (const id of ids) if (!locked.has(id)) v(LOCK_FILE3, `'${id}' is not in ids.lock: run \`proschi game lock\``);
26320
+ for (const id of locked) if (!ids.includes(id)) v(LOCK_FILE3, `'${id}' was published and is gone: ids are never deleted (progress and replays refer to them)`);
25356
26321
  }
25357
26322
  const learn = (file, list) => {
25358
26323
  for (const id of list) if (!ctx.cards.has(id)) v(file, `Review card '${id}' does not exist`);
@@ -25679,22 +26644,22 @@ var GAME_HELP = `game Scale or Fail, the system design game (docs/GAME.md).
25679
26644
  sim plays a scenario's reference run (or --run) and prints every
25680
26645
  wave: traffic, cost, revenue, score, Trust and what broke. --basic
25681
26646
  plays it under the basic rules of a first run.`;
25682
- var REPO_GAME = (0, import_node_path9.join)("frontend", "src", "game", "content");
26647
+ var REPO_GAME = (0, import_node_path10.join)("frontend", "src", "game", "content");
25683
26648
  function findGameDir(cwd = process.cwd()) {
25684
- for (let dir = (0, import_node_path9.resolve)(cwd); ; dir = (0, import_node_path9.dirname)(dir)) {
25685
- const candidate = (0, import_node_path9.join)(dir, REPO_GAME);
25686
- if ((0, import_node_fs8.existsSync)(candidate) && (0, import_node_fs8.statSync)(candidate).isDirectory()) return candidate;
25687
- if ((0, import_node_path9.dirname)(dir) === dir) return void 0;
26649
+ for (let dir = (0, import_node_path10.resolve)(cwd); ; dir = (0, import_node_path10.dirname)(dir)) {
26650
+ const candidate = (0, import_node_path10.join)(dir, REPO_GAME);
26651
+ if ((0, import_node_fs9.existsSync)(candidate) && (0, import_node_fs9.statSync)(candidate).isDirectory()) return candidate;
26652
+ if ((0, import_node_path10.dirname)(dir) === dir) return void 0;
25688
26653
  }
25689
26654
  }
25690
26655
  function readGameFolder(dir) {
25691
26656
  const files = {};
25692
26657
  const walk = (d) => {
25693
- for (const entry of (0, import_node_fs8.readdirSync)(d, { withFileTypes: true })) {
26658
+ for (const entry of (0, import_node_fs9.readdirSync)(d, { withFileTypes: true })) {
25694
26659
  if (entry.name.startsWith(".")) continue;
25695
- const full = (0, import_node_path9.join)(d, entry.name);
26660
+ const full = (0, import_node_path10.join)(d, entry.name);
25696
26661
  if (entry.isDirectory()) walk(full);
25697
- else files[(0, import_node_path9.relative)(dir, full).split("\\").join("/")] = (0, import_node_fs8.readFileSync)(full, "utf8");
26662
+ else files[(0, import_node_path10.relative)(dir, full).split("\\").join("/")] = (0, import_node_fs9.readFileSync)(full, "utf8");
25698
26663
  }
25699
26664
  };
25700
26665
  walk(dir);
@@ -25706,22 +26671,22 @@ function cardContext(dir, cwd) {
25706
26671
  const cards = /* @__PURE__ */ new Set();
25707
26672
  const topics = /* @__PURE__ */ new Set();
25708
26673
  if (cardsDir) {
25709
- for (const topic of (0, import_node_fs8.readdirSync)(cardsDir, { withFileTypes: true })) {
26674
+ for (const topic of (0, import_node_fs9.readdirSync)(cardsDir, { withFileTypes: true })) {
25710
26675
  if (!topic.isDirectory()) continue;
25711
- for (const f of (0, import_node_fs8.readdirSync)((0, import_node_path9.join)(cardsDir, topic.name))) if (f.endsWith(".md")) cards.add(f.slice(0, -3));
26676
+ for (const f of (0, import_node_fs9.readdirSync)((0, import_node_path10.join)(cardsDir, topic.name))) if (f.endsWith(".md")) cards.add(f.slice(0, -3));
25712
26677
  }
25713
- const tags = (0, import_node_path9.join)(cardsDir, "tags.json");
25714
- if ((0, import_node_fs8.existsSync)(tags)) for (const t of readTopics((0, import_node_fs8.readFileSync)(tags, "utf8"))) topics.add(t.id);
26678
+ const tags = (0, import_node_path10.join)(cardsDir, "tags.json");
26679
+ if ((0, import_node_fs9.existsSync)(tags)) for (const t of readTopics((0, import_node_fs9.readFileSync)(tags, "utf8"))) topics.add(t.id);
25715
26680
  }
25716
- const problems = new Set(problemsDir ? (0, import_node_fs8.readdirSync)(problemsDir, { withFileTypes: true }).filter((e) => e.isDirectory()).map((e) => e.name) : []);
26681
+ const problems = new Set(problemsDir ? (0, import_node_fs9.readdirSync)(problemsDir, { withFileTypes: true }).filter((e) => e.isDirectory()).map((e) => e.name) : []);
25717
26682
  return { cards, topics, problems };
25718
26683
  }
25719
26684
  var escape6 = (s) => s.replace(/%/g, "%25").replace(/\r?\n/g, "%0A");
25720
26685
  function formatGameCheck(dir, check, style) {
25721
26686
  const shown = displayPath3(dir);
25722
26687
  if (style === "json") return JSON.stringify({ dir: shown, scenarios: check.content.scenarios.length, runs: check.runs, violations: check.violations }, null, 2);
25723
- if (style === "github") return check.violations.map((v) => `::error file=${(0, import_node_path9.join)(shown, v.file)},line=${v.line ?? 1},title=proschi game::${escape6(v.message)}`).join("\n");
25724
- const lines = check.violations.map((v) => `${(0, import_node_path9.join)(shown, v.file)}${v.line ? `:${v.line}` : ""}: ${v.message}`);
26688
+ if (style === "github") return check.violations.map((v) => `::error file=${(0, import_node_path10.join)(shown, v.file)},line=${v.line ?? 1},title=proschi game::${escape6(v.message)}`).join("\n");
26689
+ const lines = check.violations.map((v) => `${(0, import_node_path10.join)(shown, v.file)}${v.line ? `:${v.line}` : ""}: ${v.message}`);
25725
26690
  for (const r of check.runs) lines.push(` ${r.scenario}/${r.name}: ${r.outcome ?? "running"} after ${r.waves} wave(s), score ${r.score}, ${Math.round(r.ms)} ms`);
25726
26691
  const c = check.content;
25727
26692
  lines.push(
@@ -25771,7 +26736,7 @@ ${GAME_USAGE}`);
25771
26736
  return a;
25772
26737
  }
25773
26738
  function resolveDir2(dirArg, cwd, err) {
25774
- const dir = dirArg ? (0, import_node_path9.resolve)(cwd, dirArg) : findGameDir(cwd);
26739
+ const dir = dirArg ? (0, import_node_path10.resolve)(cwd, dirArg) : findGameDir(cwd);
25775
26740
  if (!dir) {
25776
26741
  err(`Not inside the Proschi repository; give the game content directory
25777
26742
 
@@ -25779,7 +26744,7 @@ Usage:
25779
26744
  ${GAME_USAGE}`);
25780
26745
  return void 0;
25781
26746
  }
25782
- if (!(0, import_node_fs8.existsSync)(dir) || !(0, import_node_fs8.statSync)(dir).isDirectory()) {
26747
+ if (!(0, import_node_fs9.existsSync)(dir) || !(0, import_node_fs9.statSync)(dir).isDirectory()) {
25783
26748
  err(`No such directory: ${dirArg ?? dir}`);
25784
26749
  return void 0;
25785
26750
  }
@@ -25820,13 +26785,13 @@ function gameLock(args, out, err, cwd) {
25820
26785
  if (!a) return 2;
25821
26786
  const dir = resolveDir2(a.positional[0], cwd, err);
25822
26787
  if (!dir) return 2;
25823
- const lockPath = (0, import_node_path9.join)(dir, LOCK_FILE2);
25824
- const existing = (0, import_node_fs8.existsSync)(lockPath) ? (0, import_node_fs8.readFileSync)(lockPath, "utf8").split(/\r?\n/).map((l) => l.trim()).filter((l) => l && !l.startsWith("#")) : [];
26788
+ const lockPath = (0, import_node_path10.join)(dir, LOCK_FILE3);
26789
+ const existing = (0, import_node_fs9.existsSync)(lockPath) ? (0, import_node_fs9.readFileSync)(lockPath, "utf8").split(/\r?\n/).map((l) => l.trim()).filter((l) => l && !l.startsWith("#")) : [];
25825
26790
  const ids = contentIds(readGameFolder(dir));
25826
26791
  const added = ids.filter((id) => !existing.includes(id)).sort();
25827
26792
  const all = [.../* @__PURE__ */ new Set([...existing, ...ids])].sort();
25828
26793
  const text = LOCK_HEADER2 + all.join("\n") + "\n";
25829
- if (!(0, import_node_fs8.existsSync)(lockPath) || (0, import_node_fs8.readFileSync)(lockPath, "utf8") !== text) (0, import_node_fs8.writeFileSync)(lockPath, text);
26794
+ if (!(0, import_node_fs9.existsSync)(lockPath) || (0, import_node_fs9.readFileSync)(lockPath, "utf8") !== text) (0, import_node_fs9.writeFileSync)(lockPath, text);
25830
26795
  out(added.length ? `Added ${added.length} id${added.length === 1 ? "" : "s"} to ${displayPath3(lockPath)}:
25831
26796
  ${added.join("\n ")}` : `${displayPath3(lockPath)} already lists every id`);
25832
26797
  return 0;
@@ -25854,8 +26819,8 @@ ${GAME_USAGE}`);
25854
26819
  err(`No scenario '${scenario}'; there are ${content.scenarios.map((s2) => s2.id).join(", ")}`);
25855
26820
  return 2;
25856
26821
  }
25857
- const runPath = a.run ? (0, import_node_path9.resolve)(cwd, a.run) : void 0;
25858
- const text = runPath ? (0, import_node_fs8.existsSync)(runPath) ? (0, import_node_fs8.readFileSync)(runPath, "utf8") : void 0 : files[`scenarios/${scenario}/reference.json`];
26822
+ const runPath = a.run ? (0, import_node_path10.resolve)(cwd, a.run) : void 0;
26823
+ const text = runPath ? (0, import_node_fs9.existsSync)(runPath) ? (0, import_node_fs9.readFileSync)(runPath, "utf8") : void 0 : files[`scenarios/${scenario}/reference.json`];
25859
26824
  if (text === void 0) {
25860
26825
  err(runPath ? `No such file: ${a.run}` : `Scenario '${scenario}' has no reference.json; give one with --run`);
25861
26826
  return 2;
@@ -25907,8 +26872,8 @@ ${GAME_USAGE}`);
25907
26872
  }
25908
26873
 
25909
26874
  // src/share.ts
25910
- var import_node_fs9 = require("node:fs");
25911
- var import_node_path10 = require("node:path");
26875
+ var import_node_fs10 = require("node:fs");
26876
+ var import_node_path11 = require("node:path");
25912
26877
 
25913
26878
  // ../frontend/src/playground/share.ts
25914
26879
  var import_lz_string = __toESM(require_lz_string(), 1);
@@ -25934,24 +26899,31 @@ var SHARE_HELP = `share-link
25934
26899
  Prints a link that opens the file (with the files it imports) in the
25935
26900
  web editor; nothing is uploaded, the diagram is in the link. --base
25936
26901
  sets the editor's address (default ${DEFAULT_BASE}).`;
25937
- function shareLink(source2, name, dir, base = DEFAULT_BASE) {
26902
+ function shareLink(file, base = DEFAULT_BASE) {
26903
+ const entry = (0, import_node_path11.resolve)(file);
26904
+ const name = (0, import_node_path11.basename)(entry);
26905
+ const source2 = (0, import_node_fs10.readFileSync)(entry, "utf8");
26906
+ const disk = fileResolver({ entry });
26907
+ const where = /* @__PURE__ */ new Map([[name, entry]]);
25938
26908
  const read = {};
25939
26909
  const result = parse(source2, {
25940
26910
  path: name,
25941
- resolve: (importPath, fromPath) => {
25942
- const key = joinImportPath(fromPath, importPath);
26911
+ resolve: (importPath, fromKey) => {
26912
+ const found = disk(importPath, fromKey === void 0 ? entry : where.get(fromKey));
26913
+ const key = resolveImportPath(fromKey, importPath);
25943
26914
  if (key === name) return { path: key, source: "" };
25944
- const abs = (0, import_node_path10.resolve)(dir, key);
25945
- if (!(0, import_node_fs9.existsSync)(abs) || !(0, import_node_fs9.statSync)(abs).isFile()) return void 0;
25946
- read[key] ??= (0, import_node_fs9.readFileSync)(abs, "utf8");
26915
+ if (!found) return void 0;
26916
+ where.set(key, found.path);
26917
+ read[key] ??= found.source;
25947
26918
  return { path: key, source: read[key] };
25948
26919
  }
25949
26920
  });
26921
+ if (disk.errors.size) return { errors: [...new Set(disk.errors.values())] };
25950
26922
  const imports = {};
25951
26923
  for (const { resolved } of result.imports ?? []) {
25952
26924
  if (resolved !== void 0 && resolved !== name && Object.prototype.hasOwnProperty.call(read, resolved)) imports[resolved] = read[resolved];
25953
26925
  }
25954
- return base.replace(/#.*$/, "") + encodeShareHash(source2, void 0, imports);
26926
+ return { url: base.replace(/#.*$/, "") + encodeShareHash(source2, void 0, imports) };
25955
26927
  }
25956
26928
  function runShareLink(args, out, err) {
25957
26929
  let base = DEFAULT_BASE;
@@ -25978,20 +26950,23 @@ ${SHARE_USAGE}`);
25978
26950
  ${SHARE_USAGE}`);
25979
26951
  return 2;
25980
26952
  }
25981
- const [file] = paths;
25982
- let source2;
26953
+ let link;
25983
26954
  try {
25984
- source2 = (0, import_node_fs9.readFileSync)(file, "utf8");
26955
+ link = shareLink(paths[0], base);
25985
26956
  } catch (e) {
25986
26957
  err(String(e.message));
25987
26958
  return 2;
25988
26959
  }
25989
- out(shareLink(source2, (0, import_node_path10.basename)(file), (0, import_node_path10.dirname)((0, import_node_path10.resolve)(file)), base));
26960
+ if ("errors" in link) {
26961
+ err(link.errors.map((e) => `error: ${e}`).join("\n"));
26962
+ return 1;
26963
+ }
26964
+ out(link.url);
25990
26965
  return 0;
25991
26966
  }
25992
26967
 
25993
26968
  // src/importCommand.ts
25994
- var import_node_fs10 = require("node:fs");
26969
+ var import_node_fs11 = require("node:fs");
25995
26970
  var import_yaml2 = __toESM(require_dist(), 1);
25996
26971
 
25997
26972
  // ../frontend/src/dsl/importMermaid.ts
@@ -26591,20 +27566,289 @@ ${IMPORT_USAGE}`);
26591
27566
  }
26592
27567
  let text;
26593
27568
  try {
26594
- text = (0, import_node_fs10.readFileSync)(file, "utf8");
27569
+ text = (0, import_node_fs11.readFileSync)(file, "utf8");
26595
27570
  } catch (e) {
26596
27571
  err(`Cannot read ${file}: ${e.message}`);
26597
27572
  return 2;
26598
27573
  }
26599
27574
  const result = format3 === "mermaid" ? fromMermaid(text) : openApiFromText(text, (t) => (0, import_yaml2.parse)(t));
26600
27575
  for (const w of result.warnings) err(`${file}:${w.line ? `${w.line}:` : ""} warning: ${w.message}`);
26601
- if (output) (0, import_node_fs10.writeFileSync)(output, result.source);
27576
+ if (output) (0, import_node_fs11.writeFileSync)(output, result.source);
26602
27577
  else out(result.source.trimEnd());
26603
27578
  return 0;
26604
27579
  }
26605
27580
 
27581
+ // src/sync.ts
27582
+ var import_node_fs13 = require("node:fs");
27583
+
27584
+ // src/fetchers.ts
27585
+ var import_node_child_process = require("node:child_process");
27586
+ var import_node_fs12 = require("node:fs");
27587
+ var import_node_os = require("node:os");
27588
+ var import_node_path12 = require("node:path");
27589
+ var GIT_TIMEOUT_MS = 12e4;
27590
+ var HTTPS_TIMEOUT_MS = 3e4;
27591
+ var MAX_REDIRECTS = 5;
27592
+ var defaultGitUrl = (repo) => `https://${repo}.git`;
27593
+ function hardening(noHooks) {
27594
+ return [
27595
+ "-c",
27596
+ `core.hooksPath=${noHooks}`,
27597
+ "-c",
27598
+ "core.fsmonitor=false",
27599
+ "-c",
27600
+ "protocol.ext.allow=never",
27601
+ "-c",
27602
+ "fetch.recurseSubmodules=false",
27603
+ "-c",
27604
+ "submodule.recurse=false",
27605
+ "-c",
27606
+ "transfer.fsckObjects=true"
27607
+ ];
27608
+ }
27609
+ function gitEnv() {
27610
+ const env = { ...process.env };
27611
+ for (const name of ["GIT_DIR", "GIT_WORK_TREE", "GIT_INDEX_FILE", "GIT_OBJECT_DIRECTORY", "GIT_ALTERNATE_OBJECT_DIRECTORIES", "GIT_COMMON_DIR", "GIT_NAMESPACE"]) delete env[name];
27612
+ return env;
27613
+ }
27614
+ var GitScratch = class {
27615
+ dir = (0, import_node_fs12.mkdtempSync)((0, import_node_path12.join)((0, import_node_os.tmpdir)(), "proschi-git-"));
27616
+ repo = (0, import_node_path12.join)(this.dir, "repo.git");
27617
+ noHooks = (0, import_node_path12.join)(this.dir, "no-hooks");
27618
+ constructor() {
27619
+ (0, import_node_fs12.mkdirSync)(this.noHooks);
27620
+ this.git(["init", "--bare", "--quiet", this.repo], false);
27621
+ }
27622
+ git(args, inRepo = true, encoding = "utf8") {
27623
+ try {
27624
+ return (0, import_node_child_process.execFileSync)("git", [...hardening(this.noHooks), ...inRepo ? ["-C", this.repo] : [], ...args], {
27625
+ env: gitEnv(),
27626
+ timeout: GIT_TIMEOUT_MS,
27627
+ maxBuffer: MAX_IMPORT_BYTES + 1024 * 1024,
27628
+ stdio: ["ignore", "pipe", "pipe"],
27629
+ encoding: encoding === "utf8" ? "utf8" : "buffer"
27630
+ });
27631
+ } catch (e) {
27632
+ const stderr = String(e.stderr ?? "").trim();
27633
+ throw new ImportError(`git ${args[0]} failed${stderr ? `: ${stderr.split("\n").pop()}` : ""}`);
27634
+ }
27635
+ }
27636
+ /** Fetches one ref (or commit) shallowly and returns the commit it points at. */
27637
+ fetch(url, ref) {
27638
+ this.git(["fetch", "--quiet", "--depth=1", "--no-tags", "--no-recurse-submodules", "--", url, ref]);
27639
+ return String(this.git(["rev-parse", "--verify", "FETCH_HEAD^{commit}"])).trim();
27640
+ }
27641
+ /** A regular file at `path` in `commit`: never a symlink or submodule, at most MAX_IMPORT_BYTES. */
27642
+ read(commit, path, spec) {
27643
+ const entry = String(this.git(["ls-tree", "-z", commit, "--", path]));
27644
+ const match = /^(\d{6}) (\w+) ([0-9a-f]{40,64})\t/.exec(entry);
27645
+ if (!match) throw new ImportError(`'${spec}': no such file in the repository at ${commit.slice(0, 12)}`);
27646
+ const [, mode, type, oid] = match;
27647
+ if (type !== "blob" || mode !== "100644" && mode !== "100755") throw new ImportError(`'${spec}' is not a regular file (a symlink, folder or submodule)`);
27648
+ const size = Number(String(this.git(["cat-file", "-s", oid])).trim());
27649
+ if (size > MAX_IMPORT_BYTES) throw new ImportError(`'${spec}' is larger than ${MAX_IMPORT_BYTES / 1e6} MB`);
27650
+ return this.git(["cat-file", "blob", oid], true, "buffer");
27651
+ }
27652
+ dispose() {
27653
+ (0, import_node_fs12.rmSync)(this.dir, { recursive: true, force: true });
27654
+ }
27655
+ };
27656
+ async function fetchHttps(url, fetchImpl = fetch) {
27657
+ let current = new URL(url);
27658
+ for (let redirects = 0; ; redirects++) {
27659
+ if (current.protocol !== "https:") throw new ImportError(`'${url}' redirects to ${current.protocol}//; only https:// is allowed`);
27660
+ if (current.username || current.password) throw new ImportError(`'${url}' redirects to a URL with credentials`);
27661
+ let response2;
27662
+ try {
27663
+ response2 = await fetchImpl(current.href, { redirect: "manual", signal: AbortSignal.timeout(HTTPS_TIMEOUT_MS) });
27664
+ } catch (e) {
27665
+ throw new ImportError(`Cannot download '${url}': ${e.message}`);
27666
+ }
27667
+ if (response2.status >= 300 && response2.status < 400) {
27668
+ const location = response2.headers.get("location");
27669
+ if (!location || redirects >= MAX_REDIRECTS) throw new ImportError(`'${url}' redirects too often or without a location`);
27670
+ current = new URL(location, current);
27671
+ continue;
27672
+ }
27673
+ if (!response2.ok) throw new ImportError(`Cannot download '${url}': HTTP ${response2.status}`);
27674
+ if (Number(response2.headers.get("content-length") ?? 0) > MAX_IMPORT_BYTES) throw new ImportError(`'${url}' is larger than ${MAX_IMPORT_BYTES / 1e6} MB`);
27675
+ const chunks = [];
27676
+ let size = 0;
27677
+ const reader = response2.body?.getReader();
27678
+ for (; ; ) {
27679
+ const chunk = reader ? await reader.read() : { done: true, value: void 0 };
27680
+ if (chunk.done) break;
27681
+ size += chunk.value.byteLength;
27682
+ if (size > MAX_IMPORT_BYTES) {
27683
+ await reader?.cancel();
27684
+ throw new ImportError(`'${url}' is larger than ${MAX_IMPORT_BYTES / 1e6} MB`);
27685
+ }
27686
+ chunks.push(chunk.value);
27687
+ }
27688
+ return Buffer.concat(chunks);
27689
+ }
27690
+ }
27691
+
27692
+ // src/sync.ts
27693
+ var IMPORTS_USAGE = ` proschi imports sync [--frozen] [<file|dir>...]`;
27694
+ var IMPORTS_HELP = `imports sync
27695
+ Downloads the git: and https:// imports of the files (default: the
27696
+ current folder) into .proschi/cache and pins them in proschi.lock.
27697
+ No other command uses the network. --frozen fails instead of
27698
+ changing proschi.lock (for CI).`;
27699
+ var staleLock = (what) => new ImportError(`${LOCK_FILE} is out of date: ${what}; run proschi imports sync and commit ${LOCK_FILE}`);
27700
+ async function syncImports(entries2, { frozen = false, fetch: fetch2, gitUrl = defaultGitUrl } = {}) {
27701
+ const roots = [...new Set(entries2.map((e) => projectRoot(e)))];
27702
+ if (roots.length !== 1) {
27703
+ const errors2 = roots.length ? [`The files belong to different project roots (${roots.map((r) => displayPath3(r)).join(", ")}); sync each separately`] : ["No .proschi files to sync"];
27704
+ return { root: roots[0] ?? "", lock: emptyLock(), errors: errors2, written: false };
27705
+ }
27706
+ const [root] = roots;
27707
+ const lock = emptyLock();
27708
+ let old;
27709
+ try {
27710
+ old = readLock(root);
27711
+ } catch (e) {
27712
+ return { root, lock, errors: [e.message], written: false };
27713
+ }
27714
+ if (frozen && !old) return { root, lock, errors: [`${LOCK_FILE} is missing; run proschi imports sync and commit it`], written: false };
27715
+ const texts = /* @__PURE__ */ new Map();
27716
+ const failed = /* @__PURE__ */ new Map();
27717
+ const scratches = /* @__PURE__ */ new Map();
27718
+ const commits = /* @__PURE__ */ new Map();
27719
+ const obtain = async (spec) => {
27720
+ const remote = parseRemoteSpec(spec);
27721
+ const cached = (hash) => {
27722
+ try {
27723
+ return hash ? readCached(root, hash, spec) : void 0;
27724
+ } catch {
27725
+ return void 0;
27726
+ }
27727
+ };
27728
+ const changed = (pin) => new ImportError(`'${spec}' does not match its sha256 in ${LOCK_FILE} (${pin}); its content changed. If that is expected, delete its entry from ${LOCK_FILE} and sync again`);
27729
+ if (remote.kind === "https") {
27730
+ const pinned = old?.https[remote.url];
27731
+ if (frozen && !pinned) throw staleLock(`'${spec}' is not locked`);
27732
+ let bytes2 = cached(pinned);
27733
+ if (!bytes2) {
27734
+ bytes2 = await fetchHttps(remote.url, fetch2);
27735
+ if (pinned && sha256(bytes2) !== pinned) throw changed(`sha256 ${pinned.slice(0, 12)}`);
27736
+ writeCached(root, bytes2);
27737
+ }
27738
+ lock.https[remote.url] = sha256(bytes2);
27739
+ return bytes2;
27740
+ }
27741
+ const key = gitKey(remote.repo, remote.ref);
27742
+ const pinnedEntry = old?.git[key];
27743
+ const pinnedHash = pinnedEntry?.files[remote.path];
27744
+ if (frozen && !pinnedHash) throw staleLock(`'${spec}' is not locked`);
27745
+ let bytes = cached(pinnedHash);
27746
+ let commit = pinnedEntry?.commit;
27747
+ if (!bytes) {
27748
+ let scratch = scratches.get(remote.repo);
27749
+ if (!scratch) scratches.set(remote.repo, scratch = new GitScratch());
27750
+ if (!commits.has(key)) {
27751
+ const got = scratch.fetch(gitUrl(remote.repo), commit ?? remote.ref);
27752
+ if (commit && got !== commit) throw new ImportError(`'${spec}': fetching the locked commit ${commit} gave ${got}`);
27753
+ if (/^[0-9a-f]{40}$/.test(remote.ref) && got !== remote.ref) throw new ImportError(`'${spec}': the ref is a commit but resolved to ${got}`);
27754
+ commits.set(key, got);
27755
+ }
27756
+ commit = commits.get(key);
27757
+ bytes = scratch.read(commit, remote.path, spec);
27758
+ if (pinnedHash && sha256(bytes) !== pinnedHash) throw changed(`commit ${commit.slice(0, 12)}`);
27759
+ writeCached(root, bytes);
27760
+ }
27761
+ lock.git[key] ??= { commit, files: {} };
27762
+ lock.git[key].files[remote.path] = sha256(bytes);
27763
+ return bytes;
27764
+ };
27765
+ let errors = [];
27766
+ try {
27767
+ for (let round2 = 0; round2 <= MAX_IMPORT_DEPTH + 1; round2++) {
27768
+ const pending = /* @__PURE__ */ new Set();
27769
+ const found = /* @__PURE__ */ new Set();
27770
+ for (const entry of entries2) {
27771
+ const resolver = fileResolver({
27772
+ entry,
27773
+ remote: (spec) => {
27774
+ if (texts.has(spec)) return texts.get(spec);
27775
+ if (!failed.has(spec)) pending.add(spec);
27776
+ return void 0;
27777
+ }
27778
+ });
27779
+ parse((0, import_node_fs13.readFileSync)(entry, "utf8"), { path: entry, resolve: resolver });
27780
+ for (const [where, message] of resolver.errors) {
27781
+ const from = where.split("\0")[0] || entry;
27782
+ found.add(`${isRemoteSpec(from) ? from : displayPath3(from)}: ${message}`);
27783
+ }
27784
+ }
27785
+ if (pending.size === 0) {
27786
+ errors = [...found];
27787
+ break;
27788
+ }
27789
+ for (const spec of pending) {
27790
+ try {
27791
+ texts.set(spec, (await obtain(spec)).toString("utf8"));
27792
+ } catch (e) {
27793
+ if (!(e instanceof ImportError)) throw e;
27794
+ failed.set(spec, e.message);
27795
+ }
27796
+ }
27797
+ }
27798
+ } finally {
27799
+ for (const scratch of scratches.values()) scratch.dispose();
27800
+ }
27801
+ errors.push(...failed.values());
27802
+ if (errors.length) return { root, lock, errors, written: false };
27803
+ const text = formatLock(lock);
27804
+ if (old && text !== formatLock(old)) {
27805
+ if (frozen) return { root, lock, errors: [staleLock("it locks imports that are no longer used").message], written: false };
27806
+ }
27807
+ const write = !frozen && (old ? text !== formatLock(old) : Object.keys(lock.git).length + Object.keys(lock.https).length > 0);
27808
+ if (write) writeLock(root, lock);
27809
+ return { root, lock, errors, written: write };
27810
+ }
27811
+ async function runImports(args, collectFiles2, out, err, options = {}) {
27812
+ const [sub, ...rest] = args;
27813
+ if (sub !== "sync") {
27814
+ err(`Usage:
27815
+ ${IMPORTS_USAGE}`);
27816
+ return 2;
27817
+ }
27818
+ let frozen = false;
27819
+ const paths = [];
27820
+ for (const arg of rest) {
27821
+ if (arg === "--frozen") frozen = true;
27822
+ else if (arg.startsWith("-")) {
27823
+ err(`Unknown option ${arg}
27824
+
27825
+ Usage:
27826
+ ${IMPORTS_USAGE}`);
27827
+ return 2;
27828
+ } else paths.push(arg);
27829
+ }
27830
+ let files;
27831
+ try {
27832
+ files = collectFiles2(paths.length ? paths : ["."]);
27833
+ } catch (e) {
27834
+ err(String(e.message));
27835
+ return 2;
27836
+ }
27837
+ const result = await syncImports(files, { ...options, frozen });
27838
+ if (result.errors.length) {
27839
+ err(result.errors.map((e) => `error: ${e}`).join("\n"));
27840
+ return 1;
27841
+ }
27842
+ const sources = Object.keys(result.lock.git).length + Object.keys(result.lock.https).length;
27843
+ const filesLocked = Object.values(result.lock.git).reduce((n, g) => n + Object.keys(g.files).length, 0) + Object.keys(result.lock.https).length;
27844
+ const lockPath = displayPath3(`${result.root}/${LOCK_FILE}`);
27845
+ if (!sources) out("No git: or https:// imports to sync");
27846
+ else out(`${filesLocked} remote file(s) from ${sources} source(s) ${result.written ? "locked in" : "match"} ${lockPath}`);
27847
+ return 0;
27848
+ }
27849
+
26606
27850
  // src/cli.ts
26607
- var VERSION2 = true ? "0.12.0" : "dev";
27851
+ var VERSION2 = true ? "0.13.0" : "dev";
26608
27852
  var USAGE = `Usage:
26609
27853
  proschi check [--strict] [--format text|github|json] [--openapi <node>=<spec>]... <file|dir>...
26610
27854
  proschi parse <file>
@@ -26617,6 +27861,7 @@ ${ACHIEVEMENTS_USAGE}
26617
27861
  ${GAME_USAGE}
26618
27862
  ${SHARE_USAGE}
26619
27863
  ${IMPORT_USAGE}
27864
+ ${IMPORTS_USAGE}
26620
27865
  proschi --version
26621
27866
 
26622
27867
  check Reports errors and warnings. Directories are searched for *.proschi files.
@@ -26635,19 +27880,20 @@ ${CARDS_HELP}
26635
27880
  ${ACHIEVEMENTS_HELP}
26636
27881
  ${GAME_HELP}
26637
27882
  ${SHARE_HELP}
26638
- ${IMPORT_HELP}`;
27883
+ ${IMPORT_HELP}
27884
+ ${IMPORTS_HELP}`;
26639
27885
  function collectFiles(paths) {
26640
27886
  const out = [];
26641
27887
  const walk = (dir) => {
26642
- for (const entry of (0, import_node_fs11.readdirSync)(dir, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
27888
+ for (const entry of (0, import_node_fs14.readdirSync)(dir, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
26643
27889
  if (entry.name.startsWith(".") || entry.name === "node_modules") continue;
26644
- const full = (0, import_node_path11.join)(dir, entry.name);
27890
+ const full = (0, import_node_path13.join)(dir, entry.name);
26645
27891
  if (entry.isDirectory()) walk(full);
26646
27892
  else if (entry.name.endsWith(".proschi")) out.push(full);
26647
27893
  }
26648
27894
  };
26649
27895
  for (const p of paths) {
26650
- if ((0, import_node_fs11.statSync)(p).isDirectory()) walk(p);
27896
+ if ((0, import_node_fs14.statSync)(p).isDirectory()) walk(p);
26651
27897
  else out.push(p);
26652
27898
  }
26653
27899
  return out;
@@ -26701,6 +27947,7 @@ function run(argv, out = console.log, err = console.error) {
26701
27947
  if (command === "game") return runGame(rest, out, err);
26702
27948
  if (command === "share-link") return runShareLink(rest, out, err);
26703
27949
  if (command === "import") return runImport(rest, out, err);
27950
+ if (command === "imports") return runImports(rest, collectFiles, out, err);
26704
27951
  if (command === "check") {
26705
27952
  let strict = false;
26706
27953
  let style = "text";