proschi 0.4.0 → 0.5.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/README.md CHANGED
@@ -13,6 +13,8 @@ proschi fmt docs/ # format files in place (--check: only repor
13
13
  proschi render checkout.proschi --out diagrams # SVG diagrams (also --format md|html|hld-md|hld-html)
14
14
  proschi test docs/ # run requirements and tests (exit 1 on a failure)
15
15
  proschi analyze shortener.proschi # load, latency, availability and cost per node
16
+ proschi problem check # validate practice problem folders (see docs/PRACTICE.md)
17
+ proschi problem new seat-map # scaffold a practice problem
16
18
  proschi-language-server --stdio # for any editor with an LSP client
17
19
  ```
18
20
 
@@ -33,6 +35,13 @@ server reports failing requirements and tests as warnings and adds load to
33
35
  node hovers. The model and its default numbers:
34
36
  [Simulation and tests](https://github.com/gvart/proschi/blob/main/docs/EDITORS.md#simulation-and-tests).
35
37
 
38
+ `problem check [--format text|github|json] [dir]` validates the practice
39
+ problems, one folder each (front matter, given, starter, reference solution
40
+ and the `wrong/` designs that must fail), with the same rules as the practice
41
+ page's test suite; `problem new <id> [--dir <dir>]` scaffolds one. Inside the
42
+ repository both default to `frontend/src/practice/problems`. The format and
43
+ the rules: [docs/PRACTICE.md](https://github.com/gvart/proschi/blob/main/docs/PRACTICE.md).
44
+
36
45
  ## Rendering
37
46
 
38
47
  ```
package/dist/cli.cjs CHANGED
@@ -2986,7 +2986,7 @@ var require_compile = __commonJS({
2986
2986
  const schOrFunc = root.refs[ref];
2987
2987
  if (schOrFunc)
2988
2988
  return schOrFunc;
2989
- let _sch = resolve3.call(this, root, ref);
2989
+ let _sch = resolve4.call(this, root, ref);
2990
2990
  if (_sch === void 0) {
2991
2991
  const schema = (_a = root.localRefs) === null || _a === void 0 ? void 0 : _a[ref];
2992
2992
  const { schemaId } = this.opts;
@@ -3013,7 +3013,7 @@ var require_compile = __commonJS({
3013
3013
  function sameSchemaEnv(s1, s2) {
3014
3014
  return s1.schema === s2.schema && s1.root === s2.root && s1.baseId === s2.baseId;
3015
3015
  }
3016
- function resolve3(root, ref) {
3016
+ function resolve4(root, ref) {
3017
3017
  let sch;
3018
3018
  while (typeof (sch = this.refs[ref]) == "string")
3019
3019
  ref = sch;
@@ -3843,7 +3843,7 @@ var require_fast_uri = __commonJS({
3843
3843
  }
3844
3844
  return uri;
3845
3845
  }
3846
- function resolve3(baseURI, relativeURI, options) {
3846
+ function resolve4(baseURI, relativeURI, options) {
3847
3847
  const schemelessOptions = options ? Object.assign({ scheme: "null" }, options) : { scheme: "null" };
3848
3848
  const {
3849
3849
  parsed: baseParsed,
@@ -4212,7 +4212,7 @@ var require_fast_uri = __commonJS({
4212
4212
  var fastUri = {
4213
4213
  SCHEMES,
4214
4214
  normalize,
4215
- resolve: resolve3,
4215
+ resolve: resolve4,
4216
4216
  resolveComponent,
4217
4217
  equal,
4218
4218
  serialize,
@@ -11869,13 +11869,13 @@ var require_resolve_block_scalar = __commonJS({
11869
11869
  "node_modules/yaml/dist/compose/resolve-block-scalar.js"(exports2) {
11870
11870
  "use strict";
11871
11871
  var Scalar = require_Scalar();
11872
- function resolveBlockScalar(ctx, scalar, onError) {
11873
- const start = scalar.offset;
11874
- const header = parseBlockScalarHeader(scalar, ctx.options.strict, onError);
11872
+ function resolveBlockScalar(ctx, scalar2, onError) {
11873
+ const start = scalar2.offset;
11874
+ const header = parseBlockScalarHeader(scalar2, ctx.options.strict, onError);
11875
11875
  if (!header)
11876
11876
  return { value: "", type: null, comment: "", range: [start, start, start] };
11877
11877
  const type = header.mode === ">" ? Scalar.Scalar.BLOCK_FOLDED : Scalar.Scalar.BLOCK_LITERAL;
11878
- const lines = scalar.source ? splitLines(scalar.source) : [];
11878
+ const lines = scalar2.source ? splitLines(scalar2.source) : [];
11879
11879
  let chompStart = lines.length;
11880
11880
  for (let i = lines.length - 1; i >= 0; --i) {
11881
11881
  const content = lines[i][1];
@@ -11887,12 +11887,12 @@ var require_resolve_block_scalar = __commonJS({
11887
11887
  if (chompStart === 0) {
11888
11888
  const value2 = header.chomp === "+" && lines.length > 0 ? "\n".repeat(Math.max(1, lines.length - 1)) : "";
11889
11889
  let end2 = start + header.length;
11890
- if (scalar.source)
11891
- end2 += scalar.source.length;
11890
+ if (scalar2.source)
11891
+ end2 += scalar2.source.length;
11892
11892
  return { value: value2, type, comment: header.comment, range: [start, end2, end2] };
11893
11893
  }
11894
- let trimIndent = scalar.indent + header.indent;
11895
- let offset = scalar.offset + header.length;
11894
+ let trimIndent = scalar2.indent + header.indent;
11895
+ let offset = scalar2.offset + header.length;
11896
11896
  let contentStart = 0;
11897
11897
  for (let i = 0; i < chompStart; ++i) {
11898
11898
  const [indent2, content] = lines[i];
@@ -11970,7 +11970,7 @@ var require_resolve_block_scalar = __commonJS({
11970
11970
  default:
11971
11971
  value += "\n";
11972
11972
  }
11973
- const end = start + header.length + scalar.source.length;
11973
+ const end = start + header.length + scalar2.source.length;
11974
11974
  return { value, type, comment: header.comment, range: [start, end, end] };
11975
11975
  }
11976
11976
  function parseBlockScalarHeader({ offset, props }, strict, onError) {
@@ -12053,8 +12053,8 @@ var require_resolve_flow_scalar = __commonJS({
12053
12053
  "use strict";
12054
12054
  var Scalar = require_Scalar();
12055
12055
  var resolveEnd = require_resolve_end();
12056
- function resolveFlowScalar(scalar, strict, onError) {
12057
- const { offset, type, source: source2, end } = scalar;
12056
+ function resolveFlowScalar(scalar2, strict, onError) {
12057
+ const { offset, type, source: source2, end } = scalar2;
12058
12058
  let _type;
12059
12059
  let value;
12060
12060
  const _onError = (rel, code, msg) => onError(offset + rel, code, msg);
@@ -12073,7 +12073,7 @@ var require_resolve_flow_scalar = __commonJS({
12073
12073
  break;
12074
12074
  /* istanbul ignore next should not happen */
12075
12075
  default:
12076
- onError(scalar, "UNEXPECTED_TOKEN", `Expected a flow scalar value, but found: ${type}`);
12076
+ onError(scalar2, "UNEXPECTED_TOKEN", `Expected a flow scalar value, but found: ${type}`);
12077
12077
  return {
12078
12078
  value: "",
12079
12079
  type: null,
@@ -12288,26 +12288,26 @@ var require_compose_scalar = __commonJS({
12288
12288
  tag = findScalarTagByTest(ctx, value, token, onError);
12289
12289
  else
12290
12290
  tag = ctx.schema[identity.SCALAR];
12291
- let scalar;
12291
+ let scalar2;
12292
12292
  try {
12293
12293
  const res = tag.resolve(value, (msg) => onError(tagToken ?? token, "TAG_RESOLVE_FAILED", msg), ctx.options);
12294
- scalar = identity.isScalar(res) ? res : new Scalar.Scalar(res);
12294
+ scalar2 = identity.isScalar(res) ? res : new Scalar.Scalar(res);
12295
12295
  } catch (error) {
12296
12296
  const msg = error instanceof Error ? error.message : String(error);
12297
12297
  onError(tagToken ?? token, "TAG_RESOLVE_FAILED", msg);
12298
- scalar = new Scalar.Scalar(value);
12298
+ scalar2 = new Scalar.Scalar(value);
12299
12299
  }
12300
- scalar.range = range;
12301
- scalar.source = value;
12300
+ scalar2.range = range;
12301
+ scalar2.source = value;
12302
12302
  if (type)
12303
- scalar.type = type;
12303
+ scalar2.type = type;
12304
12304
  if (tagName)
12305
- scalar.tag = tagName;
12305
+ scalar2.tag = tagName;
12306
12306
  if (tag.format)
12307
- scalar.format = tag.format;
12307
+ scalar2.format = tag.format;
12308
12308
  if (comment)
12309
- scalar.comment = comment;
12310
- return scalar;
12309
+ scalar2.comment = comment;
12310
+ return scalar2;
12311
12311
  }
12312
12312
  function findScalarTagByName(schema, value, tagName, tagToken, onError) {
12313
12313
  if (tagName === "!")
@@ -13464,9 +13464,9 @@ var require_lexer = __commonJS({
13464
13464
  }
13465
13465
  }
13466
13466
  *parseQuotedScalar() {
13467
- const quote2 = this.charAt(0);
13468
- let end = this.buffer.indexOf(quote2, this.pos + 1);
13469
- if (quote2 === "'") {
13467
+ const quote3 = this.charAt(0);
13468
+ let end = this.buffer.indexOf(quote3, this.pos + 1);
13469
+ if (quote3 === "'") {
13470
13470
  while (end !== -1 && this.buffer[end + 1] === "'")
13471
13471
  end = this.buffer.indexOf("'", end + 2);
13472
13472
  } else {
@@ -14135,37 +14135,37 @@ var require_parser = __commonJS({
14135
14135
  };
14136
14136
  }
14137
14137
  }
14138
- *scalar(scalar) {
14138
+ *scalar(scalar2) {
14139
14139
  if (this.type === "map-value-ind") {
14140
14140
  const prev = getPrevProps(this.peek(2));
14141
14141
  const start = getFirstKeyStartProps(prev);
14142
14142
  let sep;
14143
- if (scalar.end) {
14144
- sep = scalar.end;
14143
+ if (scalar2.end) {
14144
+ sep = scalar2.end;
14145
14145
  sep.push(this.sourceToken);
14146
- delete scalar.end;
14146
+ delete scalar2.end;
14147
14147
  } else
14148
14148
  sep = [this.sourceToken];
14149
14149
  const map = {
14150
14150
  type: "block-map",
14151
- offset: scalar.offset,
14152
- indent: scalar.indent,
14153
- items: [{ start, key: scalar, sep }]
14151
+ offset: scalar2.offset,
14152
+ indent: scalar2.indent,
14153
+ items: [{ start, key: scalar2, sep }]
14154
14154
  };
14155
14155
  this.onKeyLine = true;
14156
14156
  this.stack[this.stack.length - 1] = map;
14157
14157
  } else
14158
- yield* this.lineEnd(scalar);
14158
+ yield* this.lineEnd(scalar2);
14159
14159
  }
14160
- *blockScalar(scalar) {
14160
+ *blockScalar(scalar2) {
14161
14161
  switch (this.type) {
14162
14162
  case "space":
14163
14163
  case "comment":
14164
14164
  case "newline":
14165
- scalar.props.push(this.sourceToken);
14165
+ scalar2.props.push(this.sourceToken);
14166
14166
  return;
14167
14167
  case "scalar":
14168
- scalar.source = this.source;
14168
+ scalar2.source = this.source;
14169
14169
  this.atNewLine = true;
14170
14170
  this.indent = 0;
14171
14171
  if (this.onNewLine) {
@@ -14797,8 +14797,8 @@ __export(cli_exports, {
14797
14797
  run: () => run
14798
14798
  });
14799
14799
  module.exports = __toCommonJS(cli_exports);
14800
- var import_node_fs5 = require("node:fs");
14801
- var import_node_path6 = require("node:path");
14800
+ var import_node_fs6 = require("node:fs");
14801
+ var import_node_path7 = require("node:path");
14802
14802
 
14803
14803
  // src/openapi/config.ts
14804
14804
  var import_node_fs2 = require("node:fs");
@@ -15464,15 +15464,15 @@ function readString(text, start) {
15464
15464
  }
15465
15465
  function stripTrailingComment(text) {
15466
15466
  let depth = 0;
15467
- let quote2 = null;
15467
+ let quote3 = null;
15468
15468
  for (let i = 0; i < text.length; i++) {
15469
15469
  const ch = text[i];
15470
- if (quote2) {
15470
+ if (quote3) {
15471
15471
  if (ch === "\\") i++;
15472
- else if (ch === quote2) quote2 = null;
15472
+ else if (ch === quote3) quote3 = null;
15473
15473
  continue;
15474
15474
  }
15475
- if (ch === '"') quote2 = ch;
15475
+ if (ch === '"') quote3 = ch;
15476
15476
  else if (ch === "{" || ch === "[") depth++;
15477
15477
  else if (ch === "}" || ch === "]") depth = Math.max(0, depth - 1);
15478
15478
  else if (ch === "#" && depth === 0 && (i === 0 || /\s/.test(text[i - 1]))) return text.slice(0, i);
@@ -15878,14 +15878,14 @@ var Parser = class {
15878
15878
  const loc = this.locOf(pathToken, line);
15879
15879
  const entry = { path, loc };
15880
15880
  this.imports.push(entry);
15881
- const { resolve: resolve3 } = this.options;
15882
- if (!resolve3) {
15881
+ const { resolve: resolve4 } = this.options;
15882
+ if (!resolve4) {
15883
15883
  this.warning(`Imports are not resolved in this context; '${path}' was not loaded`, loc);
15884
15884
  return;
15885
15885
  }
15886
15886
  let target;
15887
15887
  try {
15888
- target = resolve3(path, this.file ?? this.options.path);
15888
+ target = resolve4(path, this.file ?? this.options.path);
15889
15889
  } catch {
15890
15890
  target = void 0;
15891
15891
  }
@@ -18510,6 +18510,9 @@ function noPath(run2, from, to) {
18510
18510
  return pass(`No connection or step goes directly from ${x} to ${y}`);
18511
18511
  }
18512
18512
 
18513
+ // ../frontend/src/hld/engine.ts
18514
+ var defaultEngine = { available: true, analyze: (d) => analyze(d), runTests: (d, a) => runTests(d, a) };
18515
+
18513
18516
  // src/fmt.ts
18514
18517
  var FMT_USAGE = ` proschi fmt [--check] <file|dir>...`;
18515
18518
  var FMT_HELP = `fmt Rewrites files in the canonical layout and lists the ones it changed.
@@ -18755,14 +18758,700 @@ function checkFiles(files, extra) {
18755
18758
  return [...byFile].map(([file, diagnostics]) => ({ file, diagnostics: diagnostics.sort((x, y) => x.line - y.line || x.col - y.col) }));
18756
18759
  }
18757
18760
 
18761
+ // src/problem.ts
18762
+ var import_node_fs5 = require("node:fs");
18763
+ var import_node_path6 = require("node:path");
18764
+
18765
+ // ../frontend/src/practice/frontMatter.ts
18766
+ var FrontMatterError = class extends Error {
18767
+ /** 1-based line in the file. */
18768
+ line;
18769
+ constructor(message, line) {
18770
+ super(`line ${line}: ${message}`);
18771
+ this.name = "FrontMatterError";
18772
+ this.line = line;
18773
+ }
18774
+ };
18775
+ var KEY = /^([A-Za-z][A-Za-z0-9_-]*):(?:[ \t]+(.*))?$/;
18776
+ var NUMBER = /^-?(?:0|[1-9]\d*)(?:\.\d+)?$/;
18777
+ var AMBIGUOUS = /^(?:true|false|yes|no|on|off|null|~)$/i;
18778
+ var INDICATORS = "-?:,[]{}#&*!|>'\"%@`";
18779
+ function isPlainSafe(s, inFlow = false) {
18780
+ if (s === "" || s !== s.trim() || /[\n\r\t]/.test(s)) return false;
18781
+ if (INDICATORS.includes(s[0]) || s.includes(": ") || s.includes(" #") || s.endsWith(":")) return false;
18782
+ if (NUMBER.test(s) || AMBIGUOUS.test(s)) return false;
18783
+ return !(inFlow && /[,[\]{}]/.test(s));
18784
+ }
18785
+ function scalar(text, line, inFlow) {
18786
+ if (text.startsWith('"')) {
18787
+ if (!/^"(?:[^"\\]|\\.)*"$/.test(text)) throw new FrontMatterError(`Unterminated or malformed double-quoted string: ${text}`, line);
18788
+ try {
18789
+ return JSON.parse(text);
18790
+ } catch {
18791
+ throw new FrontMatterError(`Invalid escape in double-quoted string: ${text}`, line);
18792
+ }
18793
+ }
18794
+ if (text.startsWith("'")) {
18795
+ if (!/^'(?:[^']|'')*'$/.test(text)) throw new FrontMatterError(`Unterminated or malformed single-quoted string: ${text}`, line);
18796
+ return text.slice(1, -1).replace(/''/g, "'");
18797
+ }
18798
+ if (NUMBER.test(text)) return Number(text);
18799
+ if (!isPlainSafe(text, inFlow)) throw new FrontMatterError(`Ambiguous value ${JSON.stringify(text)}; put it in double quotes`, line);
18800
+ return text;
18801
+ }
18802
+ function stringItem(text, line, inFlow) {
18803
+ const value = scalar(text, line, inFlow);
18804
+ if (typeof value !== "string") throw new FrontMatterError(`List items must be strings; quote ${text}`, line);
18805
+ return value;
18806
+ }
18807
+ function flowItems(inner, line) {
18808
+ const items = [];
18809
+ let current = "";
18810
+ let quote3;
18811
+ for (let i = 0; i < inner.length; i++) {
18812
+ const c = inner[i];
18813
+ if (quote3) {
18814
+ current += c;
18815
+ if (quote3 === '"' && c === "\\") current += inner[++i] ?? "";
18816
+ else if (c === quote3) {
18817
+ if (quote3 === "'" && inner[i + 1] === "'") current += inner[++i];
18818
+ else quote3 = void 0;
18819
+ }
18820
+ } else if (c === '"' || c === "'") {
18821
+ quote3 = c;
18822
+ current += c;
18823
+ } else if (c === ",") {
18824
+ items.push(current.trim());
18825
+ current = "";
18826
+ } else current += c;
18827
+ }
18828
+ if (quote3) throw new FrontMatterError("Unterminated string in list", line);
18829
+ if (current.trim() !== "" || items.length > 0) items.push(current.trim());
18830
+ if (items.some((item) => item === "")) throw new FrontMatterError("Empty item in list", line);
18831
+ return items.map((item) => stringItem(item, line, true));
18832
+ }
18833
+ function parseFrontMatter(text) {
18834
+ const lines = text.replace(/\r\n?/g, "\n").split("\n");
18835
+ if (lines[0] !== "---") throw new FrontMatterError('The file must start with a line "---"', 1);
18836
+ const end = lines.indexOf("---", 1);
18837
+ if (end < 0) throw new FrontMatterError('No closing "---" line after the front matter', 1);
18838
+ const data = {};
18839
+ let list;
18840
+ for (let i = 1; i < end; i++) {
18841
+ const raw = lines[i];
18842
+ const line = i + 1;
18843
+ if (raw.trim() === "" || /^\s*#/.test(raw)) continue;
18844
+ if (/\t/.test(raw.match(/^\s*/)[0])) throw new FrontMatterError("Indent with spaces, not tabs", line);
18845
+ const item = raw.match(/^ +- (.*)$/) ?? raw.match(/^ +-()$/);
18846
+ if (item) {
18847
+ if (!list) throw new FrontMatterError('A list item must follow a key with no value, e.g. "hints:"', line);
18848
+ const value2 = item[1].trim();
18849
+ if (value2 === "") throw new FrontMatterError("Empty list item", line);
18850
+ list.items.push(stringItem(value2, line, false));
18851
+ continue;
18852
+ }
18853
+ if (/^\s/.test(raw)) throw new FrontMatterError(`Unexpected indented line: ${raw.trim()}`, line);
18854
+ list = void 0;
18855
+ const m = raw.match(KEY);
18856
+ if (!m) throw new FrontMatterError(`Expected "key: value", got: ${raw}`, line);
18857
+ const [, key, rest] = m;
18858
+ if (Object.prototype.hasOwnProperty.call(data, key)) throw new FrontMatterError(`Duplicate key '${key}'`, line);
18859
+ const value = (rest ?? "").trim();
18860
+ if (value === "") {
18861
+ list = { key, items: [] };
18862
+ data[key] = list.items;
18863
+ } else if (value.startsWith("[")) {
18864
+ if (!value.endsWith("]")) throw new FrontMatterError(`A list must close on the same line: ${value}`, line);
18865
+ data[key] = flowItems(value.slice(1, -1), line);
18866
+ } else {
18867
+ data[key] = scalar(value, line, false);
18868
+ }
18869
+ }
18870
+ for (const [key, value] of Object.entries(data)) {
18871
+ if (Array.isArray(value) && value.length === 0) {
18872
+ const line = lines.findIndex((l, i) => i > 0 && i < end && l.startsWith(`${key}:`)) + 1;
18873
+ throw new FrontMatterError(`'${key}' has no value`, line);
18874
+ }
18875
+ }
18876
+ return { data, body: lines.slice(end + 1).join("\n").trim() };
18877
+ }
18878
+
18879
+ // ../frontend/src/practice/types.ts
18880
+ var DIFFICULTIES = ["easy", "medium", "hard"];
18881
+
18882
+ // ../frontend/src/practice/problemFiles.ts
18883
+ var PROBLEM_MD = "problem.md";
18884
+ var GIVEN = "given.proschi";
18885
+ var STARTER = "starter.proschi";
18886
+ var SOLUTION = "solution.proschi";
18887
+ var WRONG_DIR = "wrong";
18888
+ var REQUIRED = [PROBLEM_MD, GIVEN, STARTER, SOLUTION];
18889
+ var ID = /^[a-z0-9]+(-[a-z0-9]+)*$/;
18890
+ var EXPECT_FAIL = /^#\s*expect-fail:\s*(.*?)\s*$/;
18891
+ var ProblemFolderError = class extends Error {
18892
+ folder;
18893
+ file;
18894
+ line;
18895
+ /** The message without the folder and file. */
18896
+ detail;
18897
+ constructor(folder, message, file, line) {
18898
+ super(`problems/${folder}: ${file ? `${file}${line ? `:${line}` : ""}: ` : ""}${message}`);
18899
+ this.name = "ProblemFolderError";
18900
+ this.detail = message;
18901
+ this.folder = folder;
18902
+ this.file = file;
18903
+ this.line = line;
18904
+ }
18905
+ };
18906
+ function expectFailLines(source2) {
18907
+ const names = [];
18908
+ for (const line of source2.split("\n")) {
18909
+ if (!line.startsWith("#")) break;
18910
+ const m = line.match(EXPECT_FAIL);
18911
+ if (m) names.push(m[1]);
18912
+ }
18913
+ return names;
18914
+ }
18915
+ var FIELDS = ["title", "summary", "difficulty", "tags", "order", "hints"];
18916
+ function readProblemMd(id, text) {
18917
+ const fail2 = (message, line) => {
18918
+ throw new ProblemFolderError(id, message, PROBLEM_MD, line);
18919
+ };
18920
+ let data;
18921
+ let body;
18922
+ try {
18923
+ ({ data, body } = parseFrontMatter(text));
18924
+ } catch (e) {
18925
+ if (e instanceof FrontMatterError) return fail2(e.message.replace(/^line \d+: /, ""), e.line);
18926
+ throw e;
18927
+ }
18928
+ for (const key of Object.keys(data)) if (!FIELDS.includes(key)) fail2(`Unknown front matter field '${key}' (use ${FIELDS.join(", ")})`);
18929
+ const str = (key) => {
18930
+ const v = data[key];
18931
+ if (typeof v !== "string" || v.trim() === "") return fail2(`'${key}' must be a non-empty string`);
18932
+ return v;
18933
+ };
18934
+ const list = (key) => {
18935
+ const v = data[key];
18936
+ if (!Array.isArray(v) || v.length === 0) return fail2(`'${key}' must be a list with at least one item`);
18937
+ return v;
18938
+ };
18939
+ const difficulty = str("difficulty");
18940
+ if (!DIFFICULTIES.includes(difficulty)) fail2(`'difficulty' must be one of ${DIFFICULTIES.join(", ")}`);
18941
+ const order = data.order;
18942
+ if (order !== void 0 && typeof order !== "number") fail2("'order' must be a number");
18943
+ if (body === "") fail2("The statement (the Markdown after the front matter) is empty");
18944
+ return {
18945
+ title: str("title"),
18946
+ summary: str("summary"),
18947
+ difficulty,
18948
+ tags: list("tags"),
18949
+ ...typeof order === "number" ? { order } : {},
18950
+ statement: body,
18951
+ hints: list("hints")
18952
+ };
18953
+ }
18954
+ function problemFromFiles(id, files) {
18955
+ const fail2 = (message) => {
18956
+ throw new ProblemFolderError(id, message);
18957
+ };
18958
+ if (!ID.test(id)) fail2('The folder name is the problem id and must be lowercase words joined by "-", e.g. url-shortener');
18959
+ for (const name of REQUIRED) if (files[name] === void 0) fail2(`Missing ${name}`);
18960
+ for (const name of Object.keys(files)) {
18961
+ const wrong2 = name.startsWith(`${WRONG_DIR}/`) ? name.slice(WRONG_DIR.length + 1) : void 0;
18962
+ if (!REQUIRED.includes(name) && !(wrong2 && /^[a-z0-9][a-z0-9-]*\.proschi$/.test(wrong2))) {
18963
+ fail2(`Unexpected file ${name}: a problem folder holds ${REQUIRED.join(", ")} and ${WRONG_DIR}/<name>.proschi (lowercase, digits and "-")`);
18964
+ }
18965
+ }
18966
+ const meta = readProblemMd(id, files[PROBLEM_MD]);
18967
+ const wrong = Object.keys(files).filter((name) => name.startsWith(`${WRONG_DIR}/`)).sort().map((name) => ({ name: name.slice(WRONG_DIR.length + 1, -".proschi".length), source: files[name], expectFail: expectFailLines(files[name]) }));
18968
+ return {
18969
+ id,
18970
+ ...meta,
18971
+ given: files[GIVEN],
18972
+ starter: files[STARTER],
18973
+ solution: files[SOLUTION],
18974
+ ...wrong.length ? { wrong } : {}
18975
+ };
18976
+ }
18977
+
18978
+ // ../frontend/src/dsl/imports.ts
18979
+ function joinImportPath(fromPath, importPath) {
18980
+ const parts = importPath.startsWith("/") || !fromPath ? [] : fromPath.split("/").slice(0, -1);
18981
+ for (const part of importPath.split("/")) {
18982
+ if (part === "" || part === ".") continue;
18983
+ if (part === ".." && parts.length > 0 && parts[parts.length - 1] !== "..") parts.pop();
18984
+ else parts.push(part);
18985
+ }
18986
+ return parts.join("/");
18987
+ }
18988
+ function mapResolver(files) {
18989
+ const paths = Object.keys(files);
18990
+ return (importPath, fromPath) => {
18991
+ const path = joinImportPath(fromPath, importPath);
18992
+ if (Object.prototype.hasOwnProperty.call(files, path)) return { path, source: files[path] };
18993
+ const name = path.split("/").pop();
18994
+ const sameName = paths.filter((p) => p.split("/").pop() === name);
18995
+ return sameName.length === 1 ? { path: sameName[0], source: files[sameName[0]] } : void 0;
18996
+ };
18997
+ }
18998
+
18999
+ // ../frontend/src/playground/imports.ts
19000
+ function filesResolver(files, rootPath) {
19001
+ const resolve4 = mapResolver(files);
19002
+ return (importPath, fromPath) => {
19003
+ if (joinImportPath(fromPath, importPath) === rootPath) return { path: rootPath, source: "" };
19004
+ return resolve4(importPath, fromPath);
19005
+ };
19006
+ }
19007
+
19008
+ // ../frontend/src/practice/workspace.ts
19009
+ var PROBLEM_FILE = "problem.proschi";
19010
+ var SOLUTION_FILE = "solution.proschi";
19011
+ var CAPACITY_MESSAGE = "capacity is set by the problem; change the design (replicas, shards, caching) instead";
19012
+ function problemResolver(problem) {
19013
+ return filesResolver({ [PROBLEM_FILE]: problem.given }, SOLUTION_FILE);
19014
+ }
19015
+ function enforceProblemCapacity(result) {
19016
+ const capacity = result.diagram.capacity;
19017
+ if (!capacity?.some((c) => c.loc.file !== PROBLEM_FILE)) return result;
19018
+ const kept = [];
19019
+ const errors = [];
19020
+ for (const c of capacity) {
19021
+ if (c.loc.file === PROBLEM_FILE) {
19022
+ kept.push(c);
19023
+ continue;
19024
+ }
19025
+ const { node, loc, shards, ...rest } = c;
19026
+ if (Object.keys(rest).length > 0) {
19027
+ errors.push({ severity: "error", message: `${CAPACITY_MESSAGE} (only \`shards <n>\` may be set in your file)`, ...loc });
19028
+ }
19029
+ if (shards !== void 0) kept.push({ node, shards, loc });
19030
+ }
19031
+ const diagram = { ...result.diagram };
19032
+ if (kept.length) diagram.capacity = kept;
19033
+ else delete diagram.capacity;
19034
+ return { ...result, diagram, diagnostics: [...result.diagnostics, ...errors] };
19035
+ }
19036
+ function parseSolution(problem, source2) {
19037
+ return enforceProblemCapacity(parse(source2, { path: SOLUTION_FILE, resolve: problemResolver(problem) }));
19038
+ }
19039
+ function runTests2(parsed, engine) {
19040
+ if (!engine.available) return { blocked: "no-engine", results: [], passed: 0, solved: false };
19041
+ if (parsed.diagnostics.some((d) => d.severity === "error")) return { blocked: "errors", results: [], passed: 0, solved: false };
19042
+ const analysis = engine.analyze(parsed.diagram);
19043
+ const results = engine.runTests(parsed.diagram, analysis);
19044
+ const passed = results.filter((r) => r.passed).length;
19045
+ return { results, passed, solved: results.length > 0 && passed === results.length };
19046
+ }
19047
+
19048
+ // ../frontend/src/practice/validate.ts
19049
+ var IMPORT_LINE = `import "${PROBLEM_FILE}"`;
19050
+ function trafficUseCases(given) {
19051
+ return [...(given.match(/traffic\s*\{([\s\S]*?)\}/)?.[1] ?? "").matchAll(/^\s*"([^"]+)"/gm)].map((m) => m[1]);
19052
+ }
19053
+ function firstCodeLine(source2) {
19054
+ const lines = source2.split("\n");
19055
+ const i = lines.findIndex((l) => !l.startsWith("#"));
19056
+ return i < 0 ? { text: "", line: lines.length } : { text: lines[i], line: i + 1 };
19057
+ }
19058
+ function validateProblem(problem, engine = defaultEngine) {
19059
+ const violations = [];
19060
+ const add = (file, message, line) => violations.push({ file, message, ...line ? { line } : {} });
19061
+ const fileOf = (d, own) => d.file === PROBLEM_FILE ? GIVEN : own;
19062
+ const report = (own, ds) => {
19063
+ for (const d of ds) add(fileOf(d, own), `${d.severity}: ${d.message}`, d.line);
19064
+ };
19065
+ if (!/^## Functional requirements$/m.test(problem.statement)) add(PROBLEM_MD, 'The statement needs a "## Functional requirements" section');
19066
+ const useCases = trafficUseCases(problem.given);
19067
+ if (useCases.length === 0) add(GIVEN, "The given needs a traffic block naming at least one use case");
19068
+ for (const name of useCases) {
19069
+ if (!problem.statement.includes(`**${name}**`)) add(PROBLEM_MD, `The statement must name the use case **${name}** in bold`);
19070
+ if (!problem.solution.includes(`usecase "${name}"`)) add(SOLUTION, `The solution must define usecase "${name}" from the traffic`);
19071
+ }
19072
+ const importLine = problem.given.split("\n").findIndex((l) => /^\s*import /.test(l));
19073
+ if (importLine >= 0) add(GIVEN, "The given must not import anything", importLine + 1);
19074
+ report(GIVEN, parse(problem.given).diagnostics.filter((d) => d.severity === "error"));
19075
+ if (format(problem.given) !== problem.given) add(GIVEN, "Not in canonical format; run proschi fmt");
19076
+ if (!problem.solution.startsWith(`${IMPORT_LINE}
19077
+ `)) add(SOLUTION, `Must start with ${IMPORT_LINE}`, 1);
19078
+ const solution = parseSolution(problem, problem.solution);
19079
+ report(SOLUTION, solution.diagnostics);
19080
+ if (format(problem.solution) !== problem.solution) add(SOLUTION, "Not in canonical format; run proschi fmt");
19081
+ const solutionRun = runTests2(solution, engine);
19082
+ const testNames = new Set(solutionRun.results.map((r) => r.name));
19083
+ if (solutionRun.blocked === "no-engine") add(SOLUTION, "No simulation to run the tests with");
19084
+ else if (solutionRun.blocked !== "errors") {
19085
+ if (solutionRun.results.length === 0) add(GIVEN, "The problem has no requirements or tests");
19086
+ for (const r of solutionRun.results.filter((r2) => !r2.passed)) add(SOLUTION, `Fails "${r.name}": ${r.message}`, r.loc?.file === void 0 ? r.loc?.line : void 0);
19087
+ }
19088
+ if (!problem.starter.startsWith(`${IMPORT_LINE}
19089
+ `)) add(STARTER, `Must start with ${IMPORT_LINE}`, 1);
19090
+ const starter = parseSolution(problem, problem.starter);
19091
+ report(STARTER, starter.diagnostics.filter((d) => d.severity === "error"));
19092
+ const starterRun = runTests2(starter, engine);
19093
+ const starterFails = starterRun.results.filter((r) => !r.passed).map((r) => r.name);
19094
+ if (!starterRun.blocked && starterFails.length === 0) add(STARTER, "The starter passes every test; it must leave something to solve");
19095
+ const wrong = (problem.wrong ?? []).map((w) => {
19096
+ const file = `${WRONG_DIR}/${w.name}.proschi`;
19097
+ const expectFail = w.expectFail.length ? w.expectFail : expectFailLines(w.source);
19098
+ if (expectFail.length === 0) add(file, 'Start the file with one or more "# expect-fail: <test name>" lines', 1);
19099
+ const code = firstCodeLine(w.source);
19100
+ if (code.text !== IMPORT_LINE) add(file, `After the comment lines the file must start with ${IMPORT_LINE}`, code.line);
19101
+ const parsed = parseSolution(problem, w.source);
19102
+ report(file, parsed.diagnostics.filter((d) => d.severity === "error"));
19103
+ const run2 = runTests2(parsed, engine);
19104
+ const failed = run2.results.filter((r) => !r.passed).map((r) => r.name);
19105
+ const missing = [];
19106
+ if (!run2.blocked) {
19107
+ const lines = w.source.split("\n");
19108
+ for (const name of expectFail) {
19109
+ const line = lines.findIndex((l) => l.startsWith("#") && l.includes(name)) + 1 || void 0;
19110
+ if (!testNames.has(name) && !failed.includes(name)) {
19111
+ add(file, `No test or requirement is named "${name}" (names: ${[...testNames].map((n) => JSON.stringify(n)).join(", ")})`, line);
19112
+ missing.push(name);
19113
+ } else if (!failed.includes(name)) {
19114
+ add(file, `Expected to fail "${name}", but it passes`, line);
19115
+ missing.push(name);
19116
+ }
19117
+ }
19118
+ }
19119
+ return { name: w.name, file, expectFail, failed, missing, alsoFails: failed.filter((n) => !expectFail.includes(n)) };
19120
+ });
19121
+ return { id: problem.id, tests: solutionRun.results.length, starterFails, wrong, violations };
19122
+ }
19123
+
19124
+ // src/problemTemplate.ts
19125
+ function problemTemplate(id) {
19126
+ const title = id.split("-").map((w) => w[0].toUpperCase() + w.slice(1)).join(" ");
19127
+ return {
19128
+ "problem.md": `---
19129
+ title: ${title}
19130
+ summary: TODO One line for the problem list and the landing page.
19131
+ difficulty: easy
19132
+ tags: [caching, todo]
19133
+ hints:
19134
+ - TODO A nudge, e.g. what answers most reads without the database?
19135
+ - TODO Closer, e.g. a miss reads the database, then fills the cache.
19136
+ - TODO Nearly the answer, e.g. two of everything survives any node failure.
19137
+ ---
19138
+
19139
+ TODO A short context: what the system does and for whom.
19140
+
19141
+ ## Functional requirements
19142
+
19143
+ - **Fetch item**: a user asks for an item by id and gets it back. Name its
19144
+ two scenarios \`"Cache hit"\` and \`"Cache miss"\`.
19145
+
19146
+ Use these use case and scenario names exactly: the traffic, requirements and
19147
+ tests in \`problem.proschi\` refer to them.
19148
+
19149
+ ## Scale
19150
+
19151
+ - Fetch item: **1k rps**, 90% of them for items read recently.
19152
+
19153
+ ## Constraints
19154
+
19155
+ - p99 of fetching an item under **100 ms**.
19156
+ - Available **99.9%** of the time.
19157
+ - Losing any single machine must not take the service down.
19158
+ - At most **$2,000 / month**.
19159
+
19160
+ ## What is given
19161
+
19162
+ \`problem.proschi\` declares the \`user\` and holds the traffic,
19163
+ requirements and tests. Your file imports it; add the components,
19164
+ connections and the use case.
19165
+ `,
19166
+ "given.proschi": `# TODO The problem as Proschi: the fixed nodes the solver connects to, the
19167
+ # traffic, the requirements and the tests that encode the key insight.
19168
+ title "${title}" "TODO One line"
19169
+
19170
+ user "User" [Actor]
19171
+
19172
+ traffic {
19173
+ "Fetch item" 1k rps mix "Cache hit" 90%, "Cache miss" 10%
19174
+ }
19175
+
19176
+ requirements {
19177
+ p99 "Fetch item" < 100ms
19178
+ availability "Fetch item" >= 99.9%
19179
+ survive any node failure
19180
+ cost <= 2000 usd/month
19181
+ }
19182
+
19183
+ test "Items are read from the cache first" {
19184
+ "Fetch item" calls any cache before any database
19185
+ "Fetch item" scenario "Cache hit" never calls any database
19186
+ }
19187
+ `,
19188
+ "starter.proschi": `import "problem.proschi"
19189
+
19190
+ # TODO A small, valid start: usually one use case with its first step.
19191
+ api "Item API" [REST API]
19192
+
19193
+ user -> api
19194
+
19195
+ usecase "Fetch item" {
19196
+ user -> api : GET /items/42
19197
+ api --> user : 200 item
19198
+ }
19199
+ `,
19200
+ "solution.proschi": `import "problem.proschi"
19201
+
19202
+ lb "Load Balancer" [AWS Load Balancer] x2
19203
+ api "Item API" [REST API] x2
19204
+ cache "Item Cache" [Redis] x2
19205
+ db "Items DB" [PostgreSQL] x2
19206
+
19207
+ user -> lb
19208
+ lb -> api : HTTPS
19209
+ api -> cache : GET / SET
19210
+ api -> db : SELECT
19211
+
19212
+ decision "Cache items in Redis" because "Most reads are for recent items; the cache answers them in a millisecond"
19213
+
19214
+ usecase "Fetch item" "Return one item" {
19215
+ user -> lb : GET /items/42
19216
+ lb -> api : GET /items/42
19217
+ api -> cache : GET item:42
19218
+
19219
+ alt "Cache hit" when "the item was read recently" {
19220
+ cache --> api : item
19221
+ } alt "Cache miss" when "the item is not cached" {
19222
+ cache --> api : nil
19223
+ api -> db : SELECT item 42
19224
+ db --> api : item
19225
+ api ->> cache : SET item:42
19226
+ }
19227
+ api --> lb : 200 item
19228
+ lb --> user : 200 item
19229
+ }
19230
+ `,
19231
+ "wrong/database-only.proschi": `# expect-fail: Items are read from the cache first
19232
+ # TODO A plausible wrong design (here: no cache) and the tests it must fail.
19233
+ import "problem.proschi"
19234
+
19235
+ lb "Load Balancer" [AWS Load Balancer] x2
19236
+ api "Item API" [REST API] x2
19237
+ db "Items DB" [PostgreSQL] x2
19238
+
19239
+ user -> lb
19240
+ lb -> api : HTTPS
19241
+ api -> db : SELECT
19242
+
19243
+ usecase "Fetch item" {
19244
+ user -> lb : GET /items/42
19245
+ lb -> api : GET /items/42
19246
+ api -> db : SELECT item 42
19247
+ db --> api : item
19248
+ api --> lb : 200 item
19249
+ lb --> user : 200 item
19250
+ }
19251
+ `
19252
+ };
19253
+ }
19254
+
19255
+ // src/problem.ts
19256
+ var PROBLEM_USAGE = ` proschi problem check [--format text|github|json] [dir]
19257
+ proschi problem new <id> [--dir <problems dir>]`;
19258
+ var PROBLEM_HELP = `problem Practice problems, one folder each (docs/PRACTICE.md).
19259
+ check validates every problem folder in dir (default: the repository's
19260
+ frontend/src/practice/problems when run inside it): front matter, the
19261
+ given (no errors, canonical format), the solution (no diagnostics,
19262
+ canonical, passes every test), the starter (fails a test) and each
19263
+ wrong/ design (fails every test its "# expect-fail:" lines name).
19264
+ Exits with 1 on any violation.
19265
+ new scaffolds a folder that already passes check, with TODOs.`;
19266
+ var REPO_PROBLEMS = (0, import_node_path6.join)("frontend", "src", "practice", "problems");
19267
+ function findProblemsDir(cwd = process.cwd()) {
19268
+ for (let dir = (0, import_node_path6.resolve)(cwd); ; dir = (0, import_node_path6.dirname)(dir)) {
19269
+ const candidate = (0, import_node_path6.join)(dir, REPO_PROBLEMS);
19270
+ if ((0, import_node_fs5.existsSync)(candidate) && (0, import_node_fs5.statSync)(candidate).isDirectory()) return candidate;
19271
+ if ((0, import_node_path6.dirname)(dir) === dir) return void 0;
19272
+ }
19273
+ }
19274
+ function readProblemFolder(folder) {
19275
+ const files = {};
19276
+ for (const entry of (0, import_node_fs5.readdirSync)(folder, { withFileTypes: true })) {
19277
+ if (entry.name.startsWith(".")) continue;
19278
+ if (entry.isFile()) files[entry.name] = (0, import_node_fs5.readFileSync)((0, import_node_path6.join)(folder, entry.name), "utf8");
19279
+ else if (entry.isDirectory()) {
19280
+ for (const sub of (0, import_node_fs5.readdirSync)((0, import_node_path6.join)(folder, entry.name), { withFileTypes: true })) {
19281
+ 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") : "";
19282
+ }
19283
+ }
19284
+ }
19285
+ return files;
19286
+ }
19287
+ function checkProblems(dir) {
19288
+ 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) => {
19289
+ const folder = displayPath3((0, import_node_path6.join)(dir, id));
19290
+ try {
19291
+ const problem = problemFromFiles(id, readProblemFolder((0, import_node_path6.join)(dir, id)));
19292
+ return { folder, loaded: true, ...validateProblem(problem) };
19293
+ } catch (e) {
19294
+ if (!(e instanceof ProblemFolderError)) throw e;
19295
+ const violation = { file: e.file ?? ".", message: e.detail, ...e.line ? { line: e.line } : {} };
19296
+ return { folder, loaded: false, id, tests: 0, starterFails: [], wrong: [], violations: [violation] };
19297
+ }
19298
+ });
19299
+ }
19300
+ var quote2 = (names) => names.map((n) => `"${n}"`).join(", ");
19301
+ var escape3 = (s) => s.replace(/%/g, "%25").replace(/\r?\n/g, "%0A");
19302
+ function formatProblemReports(dir, reports, style) {
19303
+ if (style === "json") return JSON.stringify({ dir: displayPath3(dir), problems: reports }, null, 2);
19304
+ const lines = [];
19305
+ const where = (r, v) => v.file === "." ? r.folder : (0, import_node_path6.join)(r.folder, v.file);
19306
+ for (const r of reports) {
19307
+ if (style === "github") {
19308
+ 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)}`);
19309
+ continue;
19310
+ }
19311
+ const ok = r.violations.length === 0;
19312
+ const wrong = r.wrong.length === 1 ? "1 wrong design" : `${r.wrong.length} wrong designs`;
19313
+ lines.push(`${ok ? "\u2713" : "\u2717"} ${r.id}${r.loaded ? `: ${r.tests} tests, the starter fails ${r.starterFails.length}, ${wrong}` : ""}`);
19314
+ for (const v of r.violations) lines.push(` ${where(r, v)}${v.line ? `:${v.line}` : ""}: ${v.message}`);
19315
+ for (const w of r.wrong) {
19316
+ if (w.missing.length) continue;
19317
+ lines.push(` ${WRONG_DIR}/${w.name}: fails ${quote2(w.expectFail)}${w.alsoFails.length ? `; also fails ${quote2(w.alsoFails)}` : ""}`);
19318
+ }
19319
+ }
19320
+ if (style === "text") {
19321
+ const violations = reports.reduce((n, r) => n + r.violations.length, 0);
19322
+ const wrong = reports.reduce((n, r) => n + r.wrong.length, 0);
19323
+ const bad = reports.filter((r) => r.violations.length).length;
19324
+ lines.push(
19325
+ `${reports.length} problem${reports.length === 1 ? "" : "s"}, ${wrong} wrong design${wrong === 1 ? "" : "s"}: ${violations ? `${violations} violation(s) in ${bad} problem${bad === 1 ? "" : "s"}` : "no violations"}`
19326
+ );
19327
+ }
19328
+ return lines.join("\n");
19329
+ }
19330
+ function problemCheck(args, out, err, cwd) {
19331
+ let style = "text";
19332
+ const paths = [];
19333
+ for (let i = 0; i < args.length; i++) {
19334
+ const arg = args[i];
19335
+ if (arg === "--format") {
19336
+ const value = args[++i];
19337
+ if (value !== "text" && value !== "github" && value !== "json") {
19338
+ err(`Unknown format '${value ?? ""}'; use text, github or json`);
19339
+ return 2;
19340
+ }
19341
+ style = value;
19342
+ } else if (arg.startsWith("-")) {
19343
+ err(`Unknown option ${arg}
19344
+
19345
+ Usage:
19346
+ ${PROBLEM_USAGE}`);
19347
+ return 2;
19348
+ } else paths.push(arg);
19349
+ }
19350
+ if (paths.length > 1) {
19351
+ err(`Give one problems directory
19352
+
19353
+ Usage:
19354
+ ${PROBLEM_USAGE}`);
19355
+ return 2;
19356
+ }
19357
+ const dir = paths[0] ? (0, import_node_path6.resolve)(cwd, paths[0]) : findProblemsDir(cwd);
19358
+ if (!dir) {
19359
+ err(`Not inside the Proschi repository; give the problems directory
19360
+
19361
+ Usage:
19362
+ ${PROBLEM_USAGE}`);
19363
+ return 2;
19364
+ }
19365
+ if (!(0, import_node_fs5.existsSync)(dir) || !(0, import_node_fs5.statSync)(dir).isDirectory()) {
19366
+ err(`No such directory: ${paths[0] ?? dir}`);
19367
+ return 2;
19368
+ }
19369
+ const reports = checkProblems(dir);
19370
+ if (reports.length === 0) {
19371
+ err(`No problem folders in ${displayPath3(dir)}`);
19372
+ return 1;
19373
+ }
19374
+ const text = formatProblemReports(dir, reports, style);
19375
+ if (text) out(text);
19376
+ return reports.some((r) => r.violations.length > 0) ? 1 : 0;
19377
+ }
19378
+ function problemNew(args, out, err, cwd) {
19379
+ let dirArg;
19380
+ const ids = [];
19381
+ for (let i = 0; i < args.length; i++) {
19382
+ const arg = args[i];
19383
+ if (arg === "--dir") {
19384
+ dirArg = args[++i];
19385
+ if (!dirArg) {
19386
+ err("Expected a directory after --dir");
19387
+ return 2;
19388
+ }
19389
+ } else if (arg.startsWith("-")) {
19390
+ err(`Unknown option ${arg}
19391
+
19392
+ Usage:
19393
+ ${PROBLEM_USAGE}`);
19394
+ return 2;
19395
+ } else ids.push(arg);
19396
+ }
19397
+ if (ids.length !== 1) {
19398
+ err(`Usage:
19399
+ ${PROBLEM_USAGE}`);
19400
+ return 2;
19401
+ }
19402
+ const id = ids[0];
19403
+ if (!/^[a-z0-9]+(-[a-z0-9]+)*$/.test(id)) {
19404
+ err(`The id '${id}' must be lowercase words joined by "-", e.g. url-shortener (it is the folder name and the URL)`);
19405
+ return 2;
19406
+ }
19407
+ const dir = dirArg ? (0, import_node_path6.resolve)(cwd, dirArg) : findProblemsDir(cwd) ?? (0, import_node_path6.resolve)(cwd, "problems");
19408
+ const folder = (0, import_node_path6.join)(dir, id);
19409
+ if ((0, import_node_fs5.existsSync)(folder)) {
19410
+ err(`${displayPath3(folder)} already exists`);
19411
+ return 1;
19412
+ }
19413
+ const files = problemTemplate(id);
19414
+ for (const [name, text] of Object.entries(files)) {
19415
+ (0, import_node_fs5.mkdirSync)((0, import_node_path6.dirname)((0, import_node_path6.join)(folder, name)), { recursive: true });
19416
+ (0, import_node_fs5.writeFileSync)((0, import_node_path6.join)(folder, name), text);
19417
+ }
19418
+ const shown = displayPath3(folder);
19419
+ out(`Created ${shown}/
19420
+ ${Object.keys(files).join("\n ")}
19421
+
19422
+ It already passes \`proschi problem check\`; now make it your problem:
19423
+ 1. problem.md: title, summary, difficulty, tags, hints, and the statement
19424
+ (name every use case in bold and spell out the scenario names).
19425
+ 2. given.proschi: the fixed nodes, the traffic, the requirements and the
19426
+ tests that encode the key insight. Capacity overrides go here only.
19427
+ 3. solution.proschi: a good answer that passes every test; starter.proschi:
19428
+ a small valid start that fails at least one.
19429
+ 4. Calibrate: set the latency, cost and availability limits between the
19430
+ reference design and brute-force designs, and prove it with wrong/
19431
+ designs that start with "# expect-fail: <test name>".
19432
+ 5. Run: proschi problem check ${displayPath3(dir)}`);
19433
+ return 0;
19434
+ }
19435
+ function runProblem(args, out, err, cwd = process.cwd()) {
19436
+ const [sub, ...rest] = args;
19437
+ if (sub === "check") return problemCheck(rest, out, err, cwd);
19438
+ if (sub === "new") return problemNew(rest, out, err, cwd);
19439
+ err(`${sub ? `Unknown problem command '${sub}'
19440
+
19441
+ ` : ""}Usage:
19442
+ ${PROBLEM_USAGE}`);
19443
+ return 2;
19444
+ }
19445
+
18758
19446
  // src/cli.ts
18759
- var VERSION = true ? "0.4.0" : "dev";
19447
+ var VERSION = true ? "0.5.0" : "dev";
18760
19448
  var USAGE = `Usage:
18761
19449
  proschi check [--strict] [--format text|github|json] [--openapi <node>=<spec>]... <file|dir>...
18762
19450
  proschi parse <file>
18763
19451
  ${RENDER_USAGE}
18764
19452
  ${FMT_USAGE}
18765
19453
  ${SIM_USAGE}
19454
+ ${PROBLEM_USAGE}
18766
19455
  proschi --version
18767
19456
 
18768
19457
  check Reports errors and warnings. Directories are searched for *.proschi files.
@@ -18775,19 +19464,20 @@ parse Prints {"diagram", "diagnostics"} as JSON; the shape is described by
18775
19464
  schema/proschi-diagram.schema.json.
18776
19465
  ${RENDER_HELP}
18777
19466
  ${FMT_HELP}
18778
- ${SIM_HELP}`;
19467
+ ${SIM_HELP}
19468
+ ${PROBLEM_HELP}`;
18779
19469
  function collectFiles(paths) {
18780
19470
  const out = [];
18781
19471
  const walk = (dir) => {
18782
- for (const entry of (0, import_node_fs5.readdirSync)(dir, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
19472
+ for (const entry of (0, import_node_fs6.readdirSync)(dir, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
18783
19473
  if (entry.name.startsWith(".") || entry.name === "node_modules") continue;
18784
- const full = (0, import_node_path6.join)(dir, entry.name);
19474
+ const full = (0, import_node_path7.join)(dir, entry.name);
18785
19475
  if (entry.isDirectory()) walk(full);
18786
19476
  else if (entry.name.endsWith(".proschi")) out.push(full);
18787
19477
  }
18788
19478
  };
18789
19479
  for (const p of paths) {
18790
- if ((0, import_node_fs5.statSync)(p).isDirectory()) walk(p);
19480
+ if ((0, import_node_fs6.statSync)(p).isDirectory()) walk(p);
18791
19481
  else out.push(p);
18792
19482
  }
18793
19483
  return out;
@@ -18835,6 +19525,7 @@ function run(argv, out = console.log, err = console.error) {
18835
19525
  if (command === "fmt") return runFmt(rest, collectFiles, out, err);
18836
19526
  if (command === "test") return runTest(rest, collectFiles, out, err);
18837
19527
  if (command === "analyze") return runAnalyze(rest, out, err);
19528
+ if (command === "problem") return runProblem(rest, out, err);
18838
19529
  if (command === "check") {
18839
19530
  let strict = false;
18840
19531
  let style = "text";
package/dist/server.cjs CHANGED
@@ -28971,7 +28971,7 @@ function positionAt(text, offset) {
28971
28971
 
28972
28972
  // src/server.ts
28973
28973
  if (process.argv.includes("--version")) {
28974
- console.log(true ? "0.4.0" : "dev");
28974
+ console.log(true ? "0.5.0" : "dev");
28975
28975
  process.exit(0);
28976
28976
  }
28977
28977
  var connection = (0, import_node.createConnection)(import_node.ProposedFeatures.all);
@@ -29012,7 +29012,7 @@ connection.onInitialize(() => ({
29012
29012
  documentFormattingProvider: true,
29013
29013
  codeActionProvider: { codeActionKinds: [import_node.CodeActionKind.QuickFix] }
29014
29014
  },
29015
- serverInfo: { name: "proschi-language-server", version: true ? "0.4.0" : "dev" }
29015
+ serverInfo: { name: "proschi-language-server", version: true ? "0.5.0" : "dev" }
29016
29016
  }));
29017
29017
  function validate(document) {
29018
29018
  const analysis = analysisOf(document);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "proschi",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "description": "Command-line checker and language server for Proschi architecture diagrams",
5
5
  "license": "MIT",
6
6
  "repository": {