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 +9 -0
- package/dist/cli.cjs +745 -54
- package/dist/server.cjs +2 -2
- package/package.json +1 -1
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 =
|
|
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
|
|
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
|
|
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:
|
|
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,
|
|
11873
|
-
const start =
|
|
11874
|
-
const header = parseBlockScalarHeader(
|
|
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 =
|
|
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 (
|
|
11891
|
-
end2 +=
|
|
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 =
|
|
11895
|
-
let offset =
|
|
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 +
|
|
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(
|
|
12057
|
-
const { offset, type, source: source2, end } =
|
|
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(
|
|
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
|
|
12291
|
+
let scalar2;
|
|
12292
12292
|
try {
|
|
12293
12293
|
const res = tag.resolve(value, (msg) => onError(tagToken ?? token, "TAG_RESOLVE_FAILED", msg), ctx.options);
|
|
12294
|
-
|
|
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
|
-
|
|
12298
|
+
scalar2 = new Scalar.Scalar(value);
|
|
12299
12299
|
}
|
|
12300
|
-
|
|
12301
|
-
|
|
12300
|
+
scalar2.range = range;
|
|
12301
|
+
scalar2.source = value;
|
|
12302
12302
|
if (type)
|
|
12303
|
-
|
|
12303
|
+
scalar2.type = type;
|
|
12304
12304
|
if (tagName)
|
|
12305
|
-
|
|
12305
|
+
scalar2.tag = tagName;
|
|
12306
12306
|
if (tag.format)
|
|
12307
|
-
|
|
12307
|
+
scalar2.format = tag.format;
|
|
12308
12308
|
if (comment)
|
|
12309
|
-
|
|
12310
|
-
return
|
|
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
|
|
13468
|
-
let end = this.buffer.indexOf(
|
|
13469
|
-
if (
|
|
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(
|
|
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 (
|
|
14144
|
-
sep =
|
|
14143
|
+
if (scalar2.end) {
|
|
14144
|
+
sep = scalar2.end;
|
|
14145
14145
|
sep.push(this.sourceToken);
|
|
14146
|
-
delete
|
|
14146
|
+
delete scalar2.end;
|
|
14147
14147
|
} else
|
|
14148
14148
|
sep = [this.sourceToken];
|
|
14149
14149
|
const map = {
|
|
14150
14150
|
type: "block-map",
|
|
14151
|
-
offset:
|
|
14152
|
-
indent:
|
|
14153
|
-
items: [{ start, key:
|
|
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(
|
|
14158
|
+
yield* this.lineEnd(scalar2);
|
|
14159
14159
|
}
|
|
14160
|
-
*blockScalar(
|
|
14160
|
+
*blockScalar(scalar2) {
|
|
14161
14161
|
switch (this.type) {
|
|
14162
14162
|
case "space":
|
|
14163
14163
|
case "comment":
|
|
14164
14164
|
case "newline":
|
|
14165
|
-
|
|
14165
|
+
scalar2.props.push(this.sourceToken);
|
|
14166
14166
|
return;
|
|
14167
14167
|
case "scalar":
|
|
14168
|
-
|
|
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
|
|
14801
|
-
var
|
|
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
|
|
15467
|
+
let quote3 = null;
|
|
15468
15468
|
for (let i = 0; i < text.length; i++) {
|
|
15469
15469
|
const ch = text[i];
|
|
15470
|
-
if (
|
|
15470
|
+
if (quote3) {
|
|
15471
15471
|
if (ch === "\\") i++;
|
|
15472
|
-
else if (ch ===
|
|
15472
|
+
else if (ch === quote3) quote3 = null;
|
|
15473
15473
|
continue;
|
|
15474
15474
|
}
|
|
15475
|
-
if (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:
|
|
15882
|
-
if (!
|
|
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 =
|
|
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.
|
|
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,
|
|
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,
|
|
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,
|
|
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.
|
|
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.
|
|
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);
|