@cruba/crubal 0.0.0-stage → 0.2.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/LICENSE +202 -0
- package/README.md +97 -2
- package/dist/bin/crubal.d.ts +2 -0
- package/dist/bin/crubal.js +103 -0
- package/dist/bin/crubal.js.map +1 -0
- package/dist/src/access.d.ts +126 -0
- package/dist/src/access.js +517 -0
- package/dist/src/access.js.map +1 -0
- package/dist/src/arrival.d.ts +55 -0
- package/dist/src/arrival.js +124 -0
- package/dist/src/arrival.js.map +1 -0
- package/dist/src/browser.d.ts +10 -0
- package/dist/src/browser.js +13 -0
- package/dist/src/browser.js.map +1 -0
- package/dist/src/bundle.d.ts +66 -0
- package/dist/src/bundle.js +753 -0
- package/dist/src/bundle.js.map +1 -0
- package/dist/src/canonical.d.ts +6 -0
- package/dist/src/canonical.js +47 -0
- package/dist/src/canonical.js.map +1 -0
- package/dist/src/conditions.d.ts +28 -0
- package/dist/src/conditions.js +216 -0
- package/dist/src/conditions.js.map +1 -0
- package/dist/src/corpus.d.ts +8 -0
- package/dist/src/corpus.js +1207 -0
- package/dist/src/corpus.js.map +1 -0
- package/dist/src/defs.d.ts +151 -0
- package/dist/src/defs.js +611 -0
- package/dist/src/defs.js.map +1 -0
- package/dist/src/dictionary.d.ts +65 -0
- package/dist/src/dictionary.js +557 -0
- package/dist/src/dictionary.js.map +1 -0
- package/dist/src/exchange.d.ts +236 -0
- package/dist/src/exchange.js +1035 -0
- package/dist/src/exchange.js.map +1 -0
- package/dist/src/forms.d.ts +16 -0
- package/dist/src/forms.js +169 -0
- package/dist/src/forms.js.map +1 -0
- package/dist/src/index.d.ts +27 -0
- package/dist/src/index.js +29 -0
- package/dist/src/index.js.map +1 -0
- package/dist/src/lex.d.ts +21 -0
- package/dist/src/lex.js +142 -0
- package/dist/src/lex.js.map +1 -0
- package/dist/src/lifecycle.d.ts +84 -0
- package/dist/src/lifecycle.js +415 -0
- package/dist/src/lifecycle.js.map +1 -0
- package/dist/src/log.d.ts +78 -0
- package/dist/src/log.js +506 -0
- package/dist/src/log.js.map +1 -0
- package/dist/src/migrate.d.ts +111 -0
- package/dist/src/migrate.js +286 -0
- package/dist/src/migrate.js.map +1 -0
- package/dist/src/model.d.ts +68 -0
- package/dist/src/model.js +188 -0
- package/dist/src/model.js.map +1 -0
- package/dist/src/packages.d.ts +69 -0
- package/dist/src/packages.js +232 -0
- package/dist/src/packages.js.map +1 -0
- package/dist/src/print-text.d.ts +7 -0
- package/dist/src/print-text.js +216 -0
- package/dist/src/print-text.js.map +1 -0
- package/dist/src/publish.d.ts +27 -0
- package/dist/src/publish.js +275 -0
- package/dist/src/publish.js.map +1 -0
- package/dist/src/read-text.d.ts +10 -0
- package/dist/src/read-text.js +385 -0
- package/dist/src/read-text.js.map +1 -0
- package/dist/src/records.d.ts +31 -0
- package/dist/src/records.js +72 -0
- package/dist/src/records.js.map +1 -0
- package/dist/src/review.d.ts +22 -0
- package/dist/src/review.js +198 -0
- package/dist/src/review.js.map +1 -0
- package/dist/src/rules.d.ts +96 -0
- package/dist/src/rules.js +611 -0
- package/dist/src/rules.js.map +1 -0
- package/dist/src/schedule.d.ts +24 -0
- package/dist/src/schedule.js +126 -0
- package/dist/src/schedule.js.map +1 -0
- package/dist/src/std-packages.d.ts +9 -0
- package/dist/src/std-packages.js +268 -0
- package/dist/src/std-packages.js.map +1 -0
- package/dist/src/store.d.ts +164 -0
- package/dist/src/store.js +538 -0
- package/dist/src/store.js.map +1 -0
- package/dist/src/tree.d.ts +35 -0
- package/dist/src/tree.js +159 -0
- package/dist/src/tree.js.map +1 -0
- package/dist/src/units-table.d.ts +1 -0
- package/dist/src/units-table.js +200 -0
- package/dist/src/units-table.js.map +1 -0
- package/dist/src/units.d.ts +42 -0
- package/dist/src/units.js +98 -0
- package/dist/src/units.js.map +1 -0
- package/dist/src/views.d.ts +216 -0
- package/dist/src/views.js +761 -0
- package/dist/src/views.js.map +1 -0
- package/package.json +60 -4
|
@@ -0,0 +1,1207 @@
|
|
|
1
|
+
// corpus.ts: runs the conformance corpus (spec chapter 13). A Reader passes a case when reading the input
|
|
2
|
+
// gives the expected canonical JSON, printing that and reading it back gives the same, and printing the
|
|
3
|
+
// expected JSON reads back to the same. An error case passes when the input is refused at the given line.
|
|
4
|
+
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { canonical } from "./canonical.js";
|
|
7
|
+
import { emptyDictionary, readDefinitions, splitCaseInput } from "./defs.js";
|
|
8
|
+
import { land, matchParts, proposeSplit, rawOf, readContracts, receive, replay, reviewByKey, validate, verifyChain } from "./store.js";
|
|
9
|
+
import { checkRecipe, dryRun, readRecipes, restamp } from "./migrate.js";
|
|
10
|
+
import { checkContracts, Exchange, readRoles, verifyBorder } from "./exchange.js";
|
|
11
|
+
import { checkPackages } from "./packages.js";
|
|
12
|
+
import { checkViews, readViews, Views } from "./views.js";
|
|
13
|
+
import { formOf } from "./forms.js";
|
|
14
|
+
import { Journal, rebuild, redactable, redactableWrite, stateJson } from "./log.js";
|
|
15
|
+
import { decide as decideReview } from "./review.js";
|
|
16
|
+
import { resetUnits } from "./units.js";
|
|
17
|
+
import { arrive, decide } from "./arrival.js";
|
|
18
|
+
import { adopt, apply as applyInstructions, archive, attach, clone, detach, emptyStore, giveBack, issue, merge, split, unarchive } from "./lifecycle.js";
|
|
19
|
+
import { Access, checkPermissions, readAccess } from "./access.js";
|
|
20
|
+
import { readPiece } from "./lex.js";
|
|
21
|
+
import { readUnder } from "./read-text.js";
|
|
22
|
+
import { readTree } from "./tree.js";
|
|
23
|
+
import { MemoryRecords } from "./records.js";
|
|
24
|
+
import { checkRules, Engine, readRules } from "./rules.js";
|
|
25
|
+
import { checkPackage, checkRecord, facetJson, foldRecord, resolve, resolvedJson } from "./dictionary.js";
|
|
26
|
+
import { serialize } from "./canonical.js";
|
|
27
|
+
import { checkWrapper, parseJson } from "./model.js";
|
|
28
|
+
import { printText } from "./print-text.js";
|
|
29
|
+
import { readText } from "./read-text.js";
|
|
30
|
+
import { CrubalError } from "./tree.js";
|
|
31
|
+
const LEVEL_FOLDERS = {
|
|
32
|
+
reader: ["reader", "reader-errors"],
|
|
33
|
+
dictionary: ["reader", "reader-errors", "dictionary", "dictionary-errors"],
|
|
34
|
+
rules: ["reader", "reader-errors", "dictionary", "dictionary-errors", "rules", "rules-errors"],
|
|
35
|
+
store: ["reader", "reader-errors", "dictionary", "dictionary-errors", "rules", "rules-errors", "store", "store-errors"],
|
|
36
|
+
exchange: [
|
|
37
|
+
"reader", "reader-errors", "dictionary", "dictionary-errors", "rules", "rules-errors", "store", "store-errors",
|
|
38
|
+
"exchange", "exchange-errors", "package-errors", "forms",
|
|
39
|
+
],
|
|
40
|
+
views: [
|
|
41
|
+
"reader", "reader-errors", "dictionary", "dictionary-errors", "rules", "rules-errors", "store", "store-errors",
|
|
42
|
+
"exchange", "exchange-errors", "package-errors", "forms", "views", "views-errors",
|
|
43
|
+
],
|
|
44
|
+
};
|
|
45
|
+
/** The shared definitions a folder's cases run under: the reader package, then the folder's own files. */
|
|
46
|
+
function loadDictionary(root, folder) {
|
|
47
|
+
const dict = emptyDictionary();
|
|
48
|
+
const files = [join(root, "reader", "_package.crubal")];
|
|
49
|
+
const own = folder.replace(/-errors$/, "");
|
|
50
|
+
for (const f of ["_facets.crubal", "_classes.crubal"])
|
|
51
|
+
files.push(join(root, own, f));
|
|
52
|
+
// Data cases read under the reader's facets too; a store error case checks recipes against them.
|
|
53
|
+
if (own !== "reader" && (!folder.endsWith("-errors") || folder === "store-errors"))
|
|
54
|
+
files.splice(1, 0, join(root, "reader", "_facets.crubal"));
|
|
55
|
+
for (const p of files)
|
|
56
|
+
if (existsSync(p))
|
|
57
|
+
readDefinitions(readFileSync(p, "utf8"), dict);
|
|
58
|
+
return dict;
|
|
59
|
+
}
|
|
60
|
+
function copy(d) {
|
|
61
|
+
return { facets: new Map(d.facets), classes: new Map(d.classes), kinds: new Map(d.kinds), levels: new Map(d.levels), vary: new Map(d.vary), varyLines: new Map(d.varyLines), trust: [...d.trust], facetTrust: new Map(d.facetTrust), identify: new Map(d.identify), match: new Map(d.match), archive: new Map(d.archive) };
|
|
62
|
+
}
|
|
63
|
+
/** Each expected entry must match an actual one on every field it states; entry sets must be the same. */
|
|
64
|
+
function compareOutcomes(actual, expected) {
|
|
65
|
+
const kind = (o) => ("asked" in o ? "summary" : "value");
|
|
66
|
+
const ident = (o) => `${kind(o)}|${o.record ?? ""}|${o.address ?? ""}`;
|
|
67
|
+
const kinds = new Set(expected.map(kind));
|
|
68
|
+
const pool = actual.filter((a) => kinds.has(kind(a)));
|
|
69
|
+
const want = new Set(expected.map(ident));
|
|
70
|
+
const extra = pool.filter((a) => !want.has(ident(a)));
|
|
71
|
+
if (extra.length)
|
|
72
|
+
return `unexpected: ${extra.map((e) => serialize(e)).join(" ")}`;
|
|
73
|
+
for (const e of expected) {
|
|
74
|
+
const a = pool.find((x) => ident(x) === ident(e));
|
|
75
|
+
if (!a)
|
|
76
|
+
return `missing: ${serialize(e)}`;
|
|
77
|
+
for (const [k, v] of Object.entries(e)) {
|
|
78
|
+
if (serialize(a[k]) !== serialize(v))
|
|
79
|
+
return `${ident(e)}: ${k} is ${serialize((a[k] ?? "∅"))}, expected ${serialize(v)}`;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
84
|
+
function runDictionaryCase(folder, caseDir, name, shared) {
|
|
85
|
+
const input = readFileSync(join(caseDir, "input.crubal"), "utf8");
|
|
86
|
+
const longFile = join(caseDir, "long.crubal");
|
|
87
|
+
try {
|
|
88
|
+
if (folder === "dictionary-errors") {
|
|
89
|
+
const want = Number(readFileSync(join(caseDir, "error.txt"), "utf8").trim().split(/\s/)[0]);
|
|
90
|
+
try {
|
|
91
|
+
checkPackage(readDefinitions(input));
|
|
92
|
+
return { folder, name, pass: false, detail: "published without refusing" };
|
|
93
|
+
}
|
|
94
|
+
catch (e) {
|
|
95
|
+
if (!(e instanceof CrubalError))
|
|
96
|
+
return { folder, name, pass: false, detail: String(e) };
|
|
97
|
+
return e.line === want ? { folder, name, pass: true } : { folder, name, pass: false, detail: `refused at line ${e.line} (${e.message}), expected line ${want}` };
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
if (existsSync(longFile)) {
|
|
101
|
+
const expected = serialize(parseJson(readFileSync(join(caseDir, "expected.json"), "utf8")));
|
|
102
|
+
for (const [label, text] of [["shorthand", input], ["long form", readFileSync(longFile, "utf8")]]) {
|
|
103
|
+
const d = readDefinitions(text);
|
|
104
|
+
checkPackage(d);
|
|
105
|
+
const got = serialize([...d.facets.values()].filter((f) => !f.key.startsWith("crubal:")).map(facetJson));
|
|
106
|
+
if (got !== expected)
|
|
107
|
+
return { folder, name, pass: false, detail: `${label}: got ${got}` };
|
|
108
|
+
}
|
|
109
|
+
return { folder, name, pass: true };
|
|
110
|
+
}
|
|
111
|
+
const { defs, data } = splitCaseInput(input);
|
|
112
|
+
const dict = copy(shared);
|
|
113
|
+
if (defs.trim())
|
|
114
|
+
readDefinitions(defs, dict);
|
|
115
|
+
checkPackage(dict);
|
|
116
|
+
const resolveLine = defs.match(/^resolve: (\S+)/m);
|
|
117
|
+
if (resolveLine) {
|
|
118
|
+
const expected = parseJson(readFileSync(join(caseDir, "expected.json"), "utf8"));
|
|
119
|
+
const got = serialize(resolvedJson(resolve(dict, [resolveLine[1]])));
|
|
120
|
+
return got === serialize(expected.resolved) ? { folder, name, pass: true } : { folder, name, pass: false, detail: `got ${got}` };
|
|
121
|
+
}
|
|
122
|
+
const w = readText(data, dict);
|
|
123
|
+
w.records = w.records.map((r) => foldRecord(r, dict));
|
|
124
|
+
const outcomesFile = join(caseDir, "expected-outcomes.json");
|
|
125
|
+
if (existsSync(outcomesFile)) {
|
|
126
|
+
const actual = w.records.flatMap((r) => {
|
|
127
|
+
const c = checkRecord(r, dict);
|
|
128
|
+
return [...c.outcomes, c.summary];
|
|
129
|
+
});
|
|
130
|
+
const problem = compareOutcomes(actual, JSON.parse(readFileSync(outcomesFile, "utf8")));
|
|
131
|
+
return problem ? { folder, name, pass: false, detail: problem } : { folder, name, pass: true };
|
|
132
|
+
}
|
|
133
|
+
const expected = parseJson(readFileSync(join(caseDir, "expected.json"), "utf8"));
|
|
134
|
+
checkWrapper(expected, dict);
|
|
135
|
+
const want = canonical(expected, dict);
|
|
136
|
+
const got = canonical(w, dict);
|
|
137
|
+
if (got !== want)
|
|
138
|
+
return { folder, name, pass: false, detail: `read and fold: ${firstDiff(got, want)}` };
|
|
139
|
+
const again = canonical(readText(printText(w, dict, "full"), dict), dict);
|
|
140
|
+
if (again !== want)
|
|
141
|
+
return { folder, name, pass: false, detail: `print and read back: ${firstDiff(again, want)}` };
|
|
142
|
+
return { folder, name, pass: true };
|
|
143
|
+
}
|
|
144
|
+
catch (e) {
|
|
145
|
+
return { folder, name, pass: false, detail: e instanceof Error ? e.message : String(e) };
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
function firstDiff(a, b) {
|
|
149
|
+
let i = 0;
|
|
150
|
+
while (i < a.length && a[i] === b[i])
|
|
151
|
+
i++;
|
|
152
|
+
return `at ${i}: got …${a.slice(Math.max(0, i - 40), i + 60)}… expected …${b.slice(Math.max(0, i - 40), i + 60)}…`;
|
|
153
|
+
}
|
|
154
|
+
function runCase(folder, dir, name, dict) {
|
|
155
|
+
const caseDir = join(dir, name);
|
|
156
|
+
const input = readFileSync(join(caseDir, "input.crubal"), "utf8");
|
|
157
|
+
const errorFile = join(caseDir, "error.txt");
|
|
158
|
+
if (existsSync(errorFile)) {
|
|
159
|
+
const want = Number(readFileSync(errorFile, "utf8").trim().split(/\s/)[0]);
|
|
160
|
+
try {
|
|
161
|
+
readText(input, dict);
|
|
162
|
+
return { folder, name, pass: false, detail: "read without refusing" };
|
|
163
|
+
}
|
|
164
|
+
catch (e) {
|
|
165
|
+
if (!(e instanceof CrubalError))
|
|
166
|
+
return { folder, name, pass: false, detail: String(e) };
|
|
167
|
+
return e.line === want
|
|
168
|
+
? { folder, name, pass: true }
|
|
169
|
+
: { folder, name, pass: false, detail: `refused at line ${e.line} (${e.message}), expected line ${want}` };
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
try {
|
|
173
|
+
const expected = parseJson(readFileSync(join(caseDir, "expected.json"), "utf8"));
|
|
174
|
+
checkWrapper(expected, dict);
|
|
175
|
+
const want = canonical(expected, dict);
|
|
176
|
+
const got = readText(input, dict);
|
|
177
|
+
const gotC = canonical(got, dict);
|
|
178
|
+
if (gotC !== want)
|
|
179
|
+
return { folder, name, pass: false, detail: `read: ${firstDiff(gotC, want)}` };
|
|
180
|
+
const again = canonical(readText(printText(got, dict, "full"), dict), dict);
|
|
181
|
+
if (again !== want)
|
|
182
|
+
return { folder, name, pass: false, detail: `print and read back: ${firstDiff(again, want)}` };
|
|
183
|
+
const fromJson = canonical(readText(printText(expected, dict, "full"), dict), dict);
|
|
184
|
+
if (fromJson !== want)
|
|
185
|
+
return { folder, name, pass: false, detail: `JSON to text and back: ${firstDiff(fromJson, want)}` };
|
|
186
|
+
return { folder, name, pass: true };
|
|
187
|
+
}
|
|
188
|
+
catch (e) {
|
|
189
|
+
return { folder, name, pass: false, detail: e instanceof Error ? e.message : String(e) };
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
function compareResults(actual, expected) {
|
|
193
|
+
const ident = (o) => `${o.result}|${o.record}|${o.address ?? ""}`;
|
|
194
|
+
const kinds = new Set(expected.map((e) => e.result));
|
|
195
|
+
const pool = actual.filter((a) => kinds.has(a.result));
|
|
196
|
+
const want = new Set(expected.map(ident));
|
|
197
|
+
const extra = pool.filter((a) => !want.has(ident(a)));
|
|
198
|
+
if (extra.length)
|
|
199
|
+
return `unexpected: ${extra.map((e) => serialize(e)).join(" ")}`;
|
|
200
|
+
for (const e of expected) {
|
|
201
|
+
const a = pool.find((x) => ident(x) === ident(e));
|
|
202
|
+
if (!a)
|
|
203
|
+
return `missing: ${serialize(e)}`;
|
|
204
|
+
for (const [k, v] of Object.entries(e)) {
|
|
205
|
+
if (serialize(a[k]) !== serialize(v))
|
|
206
|
+
return `${ident(e)}: ${k} is ${serialize((a[k] ?? "∅"))}, expected ${serialize(v)}`;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
return null;
|
|
210
|
+
}
|
|
211
|
+
function runRulesCase(folder, caseDir, name, shared, sharedRules) {
|
|
212
|
+
const input = readFileSync(join(caseDir, "input.crubal"), "utf8");
|
|
213
|
+
try {
|
|
214
|
+
if (folder === "rules-errors") {
|
|
215
|
+
const want = Number(readFileSync(join(caseDir, "error.txt"), "utf8").trim().split(/\s/)[0]);
|
|
216
|
+
try {
|
|
217
|
+
const d = copy(shared);
|
|
218
|
+
readDefinitions(input, d);
|
|
219
|
+
checkPackage(d);
|
|
220
|
+
checkRules(readRules(input), d);
|
|
221
|
+
return { folder, name, pass: false, detail: "published without refusing" };
|
|
222
|
+
}
|
|
223
|
+
catch (e) {
|
|
224
|
+
if (!(e instanceof CrubalError))
|
|
225
|
+
return { folder, name, pass: false, detail: String(e) };
|
|
226
|
+
return e.line === want ? { folder, name, pass: true } : { folder, name, pass: false, detail: `refused at line ${e.line} (${e.message}), expected line ${want}` };
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
const { defs, data } = splitCaseInput(input);
|
|
230
|
+
const dict = copy(shared);
|
|
231
|
+
if (defs.trim())
|
|
232
|
+
readDefinitions(defs, dict);
|
|
233
|
+
const rules = [...sharedRules, ...readRules(defs)];
|
|
234
|
+
checkPackage(dict);
|
|
235
|
+
checkRules(rules, dict);
|
|
236
|
+
const engine = new Engine(dict, rules);
|
|
237
|
+
const nowFile = join(caseDir, "now.txt");
|
|
238
|
+
if (existsSync(nowFile))
|
|
239
|
+
engine.now = readFileSync(nowFile, "utf8").trim();
|
|
240
|
+
const altFile = join(caseDir, "alternatives.json");
|
|
241
|
+
const alts = existsSync(altFile) ? JSON.parse(readFileSync(altFile, "utf8")) : {};
|
|
242
|
+
const last = new Map();
|
|
243
|
+
const run = (text) => {
|
|
244
|
+
for (const r of readText(text, dict).records.map((x) => foldRecord(x, dict))) {
|
|
245
|
+
if (alts[r.id])
|
|
246
|
+
r.alternatives = alts[r.id];
|
|
247
|
+
last.set(r.id, engine.write(r));
|
|
248
|
+
}
|
|
249
|
+
};
|
|
250
|
+
run(data);
|
|
251
|
+
const writeFile = join(caseDir, "write.crubal");
|
|
252
|
+
if (existsSync(writeFile))
|
|
253
|
+
run(readFileSync(writeFile, "utf8"));
|
|
254
|
+
const actual = [...last.values()].flat();
|
|
255
|
+
const problem = compareResults(actual, JSON.parse(readFileSync(join(caseDir, "expected-results.json"), "utf8")));
|
|
256
|
+
return problem ? { folder, name, pass: false, detail: problem } : { folder, name, pass: true };
|
|
257
|
+
}
|
|
258
|
+
catch (e) {
|
|
259
|
+
return { folder, name, pass: false, detail: e instanceof Error ? e.message : String(e) };
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
/** Every field an expected object states must be equal in the actual one. */
|
|
263
|
+
function subset(actual, expected, label = "") {
|
|
264
|
+
for (const [k, v] of Object.entries(expected)) {
|
|
265
|
+
if (serialize(actual[k]) !== serialize(v))
|
|
266
|
+
return `${label}${k} is ${serialize((actual[k] ?? "∅"))}, expected ${serialize(v)}`;
|
|
267
|
+
}
|
|
268
|
+
return null;
|
|
269
|
+
}
|
|
270
|
+
function runStoreCase(folder, caseDir, name, shared) {
|
|
271
|
+
resetUnits();
|
|
272
|
+
const file = (f) => join(caseDir, f);
|
|
273
|
+
const json = (f) => JSON.parse(readFileSync(file(f), "utf8"));
|
|
274
|
+
const pass = { folder, name, pass: true };
|
|
275
|
+
const fail = (detail) => ({ folder, name, pass: false, detail });
|
|
276
|
+
try {
|
|
277
|
+
const input = existsSync(file("input.crubal")) ? readFileSync(file("input.crubal"), "utf8") : "";
|
|
278
|
+
if (folder === "store-errors") {
|
|
279
|
+
const want = Number(readFileSync(file("error.txt"), "utf8").trim().split(/\s/)[0]);
|
|
280
|
+
try {
|
|
281
|
+
for (const r of readRecipes(input))
|
|
282
|
+
checkRecipe(r, shared);
|
|
283
|
+
return fail("published without refusing");
|
|
284
|
+
}
|
|
285
|
+
catch (e) {
|
|
286
|
+
if (!(e instanceof CrubalError))
|
|
287
|
+
return fail(String(e));
|
|
288
|
+
return e.line === want ? pass : fail(`refused at line ${e.line} (${e.message}), expected line ${want}`);
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
// Redaction (8.5.2): per entry, whether it may go whole, else which of its writes may, and why not.
|
|
292
|
+
if (existsSync(file("expected-redact.json"))) {
|
|
293
|
+
const entries = json("log.json");
|
|
294
|
+
const got = {};
|
|
295
|
+
for (const e of entries) {
|
|
296
|
+
const whole = redactable(e);
|
|
297
|
+
const writes = Array.isArray(e.writes) ? e.writes.filter((w) => typeof w === "object" && w !== null && !Array.isArray(w)) : [];
|
|
298
|
+
got[String(e.log)] = whole === null ? "redactable" : e.witnessed === true ? whole : writes.map((w) => redactableWrite(e, w) ?? "redactable");
|
|
299
|
+
}
|
|
300
|
+
const want = json("expected-redact.json");
|
|
301
|
+
for (const [k, v] of Object.entries(want))
|
|
302
|
+
if (serialize((got[k] ?? "∅")) !== serialize(v))
|
|
303
|
+
return fail(`${k}: got ${serialize((got[k] ?? "∅"))}`);
|
|
304
|
+
return pass;
|
|
305
|
+
}
|
|
306
|
+
// Rebuild (8.5.3): the log alone gives back the state.
|
|
307
|
+
if (existsSync(file("expected-state.json"))) {
|
|
308
|
+
const got = stateJson(rebuild(json("log.json")));
|
|
309
|
+
const want = json("expected-state.json");
|
|
310
|
+
for (const [k, v] of Object.entries(want)) {
|
|
311
|
+
if (serialize((got[k] ?? "∅")) !== serialize(v))
|
|
312
|
+
return fail(`${k}: ${firstDiff(serialize((got[k] ?? "∅")), serialize(v))}`);
|
|
313
|
+
}
|
|
314
|
+
return pass;
|
|
315
|
+
}
|
|
316
|
+
if (existsSync(file("log.json"))) {
|
|
317
|
+
const keys = existsSync(file("keys.json")) ? json("keys.json") : undefined;
|
|
318
|
+
const got = verifyChain(json("log.json"), keys);
|
|
319
|
+
const want = json("expected-verify.json");
|
|
320
|
+
const p = subset(got, want);
|
|
321
|
+
return p ? fail(`${p} (got ${serialize(got)})`) : pass;
|
|
322
|
+
}
|
|
323
|
+
if (existsSync(file("answer.bin"))) {
|
|
324
|
+
const got = rawOf(readFileSync(file("answer.bin")), "text/plain");
|
|
325
|
+
return serialize(got) === serialize(json("expected-raw.json")) ? pass : fail(`got ${serialize(got)}`);
|
|
326
|
+
}
|
|
327
|
+
const { defs, data } = splitCaseInput(input);
|
|
328
|
+
const dict = copy(shared);
|
|
329
|
+
if (defs.trim())
|
|
330
|
+
readDefinitions(defs, dict);
|
|
331
|
+
const records = readText(data, dict).records.map((r) => foldRecord(r, dict));
|
|
332
|
+
const one = (r) => canonical({ crubal: "2", records: [r] }, dict);
|
|
333
|
+
// Arrival (9.2a): a sequence of payloads through one contract, each finding or making its record.
|
|
334
|
+
if (existsSync(file("answers.json"))) {
|
|
335
|
+
const text = readFileSync(file("contract.crubal"), "utf8");
|
|
336
|
+
readDefinitions(text, dict);
|
|
337
|
+
const [contract] = readContracts(text);
|
|
338
|
+
if (!contract)
|
|
339
|
+
return fail("no contract");
|
|
340
|
+
// Every arrival is an operation on the journal's draft, logged as entries (8.5).
|
|
341
|
+
const journal = new Journal("test.example", records, "2026-10-15T00:00:00Z");
|
|
342
|
+
const store = journal.draft;
|
|
343
|
+
if (existsSync(file("bindings.json")))
|
|
344
|
+
journal.commit({ migration: "backfill" }, (d) => { for (const [k, v] of Object.entries(json("bindings.json")))
|
|
345
|
+
d.bindings.set(k, String(v)); }, "2026-10-15T00:00:01Z");
|
|
346
|
+
const answersFile = json("answers.json");
|
|
347
|
+
const level = isPlainObject(answersFile) ? String(answersFile.level) : "product";
|
|
348
|
+
const answers = (isPlainObject(answersFile) ? answersFile.answers : answersFile);
|
|
349
|
+
let n = 0;
|
|
350
|
+
const mint = () => `${level.charAt(0).toUpperCase()}-NEW${++n}`;
|
|
351
|
+
const owner = records[0]?.owner ?? "test.example";
|
|
352
|
+
const got = [];
|
|
353
|
+
let held = null;
|
|
354
|
+
answers.forEach((a, i) => {
|
|
355
|
+
const at = `2026-10-15T00:01:${String(i).padStart(2, "0")}Z`;
|
|
356
|
+
if (isPlainObject(a) && isPlainObject(a.decide)) {
|
|
357
|
+
if (!held)
|
|
358
|
+
throw new Error("a decision with nothing held");
|
|
359
|
+
const choice = a.decide.new === true ? { new: true } : { record: String(a.decide.record) };
|
|
360
|
+
const h = held;
|
|
361
|
+
journal.commit({ person: "reviewer" }, (d) => {
|
|
362
|
+
got.push(decide(contract, h, choice, d, dict, { mint, owner, level }).arrival);
|
|
363
|
+
}, at);
|
|
364
|
+
held = null;
|
|
365
|
+
return;
|
|
366
|
+
}
|
|
367
|
+
journal.commit({ contract: contract.name, run: `R-${i + 1}` }, (d) => {
|
|
368
|
+
const r = arrive(contract, JSON.stringify(a), level, d, dict, { mint, owner, intakeId: `I-${i + 1}` });
|
|
369
|
+
if (r.arrival.outcome === "held")
|
|
370
|
+
held = { intake: r.intake, ...(r.arrival.ref !== undefined ? { ref: r.arrival.ref } : {}) };
|
|
371
|
+
got.push(r.arrival);
|
|
372
|
+
}, at);
|
|
373
|
+
});
|
|
374
|
+
const want = json("expected-arrival.json");
|
|
375
|
+
if (got.length !== want.length)
|
|
376
|
+
return fail(`${got.length} arrivals, expected ${want.length}: ${serialize(got)}`);
|
|
377
|
+
for (let i = 0; i < want.length; i++) {
|
|
378
|
+
const p = subset(got[i], want[i], `arrival ${i + 1}: `);
|
|
379
|
+
if (p)
|
|
380
|
+
return fail(p);
|
|
381
|
+
}
|
|
382
|
+
if (existsSync(file("expected.json"))) {
|
|
383
|
+
const want2 = json("expected.json");
|
|
384
|
+
for (const w of want2.records) {
|
|
385
|
+
const r = store.records.get(w.id);
|
|
386
|
+
if (!r)
|
|
387
|
+
return fail(`no record ${w.id}`);
|
|
388
|
+
if (one(r) !== one(w))
|
|
389
|
+
return fail(`${w.id}: ${firstDiff(one(r), one(w))}`);
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
return pass;
|
|
393
|
+
}
|
|
394
|
+
// Lifecycle (1.7, 1.12) and instructions (10.7): operations in order, then the records.
|
|
395
|
+
if (existsSync(file("ops.crubal"))) {
|
|
396
|
+
// Every operation runs on the journal's draft and is logged; a change the entries don't describe
|
|
397
|
+
// is refused by the journal itself (8.5).
|
|
398
|
+
const journal = new Journal("test.example", records, "2026-10-15T00:00:00Z");
|
|
399
|
+
const store = journal.draft;
|
|
400
|
+
if (existsSync(file("bindings.json")))
|
|
401
|
+
journal.commit({ migration: "backfill" }, (d) => { for (const [k, v] of Object.entries(json("bindings.json")))
|
|
402
|
+
d.bindings.set(k, String(v)); }, "2026-10-15T00:00:01Z");
|
|
403
|
+
const caseRules = readRules(defs);
|
|
404
|
+
const decisionDefs = { dict, contracts: new Map(readContracts(defs).map((c) => [c.name, c])) };
|
|
405
|
+
let adoptFlag;
|
|
406
|
+
let n = 0;
|
|
407
|
+
for (const op of readTree(readFileSync(file("ops.crubal"), "utf8"))) {
|
|
408
|
+
if (op.kind !== "pair" || !op.key)
|
|
409
|
+
continue;
|
|
410
|
+
const piece = readPiece(op.value, op.line);
|
|
411
|
+
const detail = (k) => piece.detail.find(([n]) => n === k)?.[1].text;
|
|
412
|
+
const [a = "", b] = piece.text.split(" -> ").map((s) => s.trim());
|
|
413
|
+
const key = op.key;
|
|
414
|
+
let problem = null;
|
|
415
|
+
const extra = [];
|
|
416
|
+
journal.commit({ person: "tester" }, () => {
|
|
417
|
+
if (key === "archive")
|
|
418
|
+
archive(store, a, detail("reason") ?? "archived", dict, detail("into"));
|
|
419
|
+
else if (key === "unarchive")
|
|
420
|
+
unarchive(store, a, dict);
|
|
421
|
+
else if (key === "merge" && b)
|
|
422
|
+
merge(store, a, b, dict);
|
|
423
|
+
else if (key === "attach" && b)
|
|
424
|
+
attach(store, a, b, dict, detail("edit") !== "no");
|
|
425
|
+
else if (key === "detach")
|
|
426
|
+
detach(store, a, dict);
|
|
427
|
+
else if (key === "instruct")
|
|
428
|
+
issue(store, piece.text, op.children);
|
|
429
|
+
else if (key === "apply")
|
|
430
|
+
applyInstructions(store, a, dict);
|
|
431
|
+
else if (key === "adopt" && b)
|
|
432
|
+
adoptFlag = adopt(store, a, b, caseRules).flagged ?? adoptFlag;
|
|
433
|
+
else if (key === "give-back" && b)
|
|
434
|
+
giveBack(store, a, b);
|
|
435
|
+
else if (key === "clone" && b)
|
|
436
|
+
clone(store, a, b, detail("owner") ?? "test.example");
|
|
437
|
+
else if (key === "split" && b) {
|
|
438
|
+
const list = (k) => (op.children.find((c) => c.key === k)?.value ?? "").split(",").map((x) => x.trim()).filter(Boolean);
|
|
439
|
+
const vals = {};
|
|
440
|
+
for (const c of op.children.find((c) => c.key === "values")?.children ?? [])
|
|
441
|
+
if (c.key)
|
|
442
|
+
vals[c.key] = dict.facets.get(c.key) ? (readUnder(dict.facets.get(c.key), c.value, dict) ?? c.value) : c.value;
|
|
443
|
+
split(store, a, b, { children: list("children"), values: vals, bindings: list("bindings") });
|
|
444
|
+
}
|
|
445
|
+
else if (key === "suggest") {
|
|
446
|
+
// Test scaffolding: a suggestion on a record, as a gated or lands: suggestion run would leave it.
|
|
447
|
+
const r = store.records.get(a);
|
|
448
|
+
const address = op.children.find((c) => c.key === "address")?.value ?? "";
|
|
449
|
+
const f = dict.facets.get(address);
|
|
450
|
+
const raw = op.children.find((c) => c.key === "value")?.value ?? "";
|
|
451
|
+
const value = f ? (readUnder(f, raw, dict) ?? raw) : raw;
|
|
452
|
+
const item = { value, origin: op.children.find((c) => c.key === "origin")?.value ?? "reported", via: op.children.find((c) => c.key === "via")?.value ?? "L-S" };
|
|
453
|
+
store.records.set(a, { ...r, suggestions: { ...(r.suggestions ?? {}), [address]: [...(r.suggestions?.[address] ?? []), item] } });
|
|
454
|
+
}
|
|
455
|
+
else if (key === "hold") {
|
|
456
|
+
// Test scaffolding: a value held by a rule, with its source kept (6.7).
|
|
457
|
+
const r = store.records.get(a);
|
|
458
|
+
const address = op.children.find((c) => c.key === "address")?.value ?? "";
|
|
459
|
+
const v = r.values[address];
|
|
460
|
+
if (v === undefined) {
|
|
461
|
+
problem = `${a} has nothing at ${address} to hold`;
|
|
462
|
+
return;
|
|
463
|
+
}
|
|
464
|
+
const { [address]: _v, ...values } = r.values;
|
|
465
|
+
const src = (r.sources?.[address] ?? r.sources?.["*"]);
|
|
466
|
+
store.records.set(a, { ...r, values, held: { ...(r.held ?? {}), [address]: { value: v, rule: op.children.find((c) => c.key === "rule")?.value ?? "rule", ...(src ? { source: src } : {}) } } });
|
|
467
|
+
}
|
|
468
|
+
else if (key === "decide") {
|
|
469
|
+
// A review decision (9.5): the item's key on the line, the decision's fields below it.
|
|
470
|
+
const d = { key: piece.text, decision: "" };
|
|
471
|
+
for (const c of op.children) {
|
|
472
|
+
if (!c.key)
|
|
473
|
+
continue;
|
|
474
|
+
if (c.key === "instances" || c.key === "accepts")
|
|
475
|
+
d[c.key] = c.value.split(",").map((x) => x.trim()).filter(Boolean);
|
|
476
|
+
else if (c.key === "value" && typeof d.address === "string" && dict.facets.get(d.address))
|
|
477
|
+
d.value = readUnder(dict.facets.get(d.address), c.value, dict) ?? c.value;
|
|
478
|
+
else
|
|
479
|
+
d[c.key] = c.key === "factor" || c.key === "offset" ? c.value : (dict.facets.get(c.key) ? (readUnder(dict.facets.get(c.key), c.value, dict) ?? c.value) : c.value);
|
|
480
|
+
}
|
|
481
|
+
extra.push(...decideReview(d, store, decisionDefs));
|
|
482
|
+
}
|
|
483
|
+
else if (key === "write") {
|
|
484
|
+
const r = store.records.get(a);
|
|
485
|
+
if (!r) {
|
|
486
|
+
problem = `write to an unknown record ${a}`;
|
|
487
|
+
return;
|
|
488
|
+
}
|
|
489
|
+
const values = { ...r.values };
|
|
490
|
+
for (const c of op.children) {
|
|
491
|
+
const f = c.key ? dict.facets.get(c.key) : undefined;
|
|
492
|
+
if (c.key)
|
|
493
|
+
values[c.key] = f ? (readUnder(f, c.value, dict) ?? c.value) : c.value;
|
|
494
|
+
}
|
|
495
|
+
store.records.set(a, { ...r, values });
|
|
496
|
+
}
|
|
497
|
+
else
|
|
498
|
+
problem = `unknown op ${key}`;
|
|
499
|
+
}, `2026-10-15T00:01:${String(++n).padStart(2, "0")}Z`, extra);
|
|
500
|
+
if (problem)
|
|
501
|
+
return fail(problem);
|
|
502
|
+
}
|
|
503
|
+
if (existsSync(file("expected-flag.json"))) {
|
|
504
|
+
const want = json("expected-flag.json");
|
|
505
|
+
if (want.adopt !== (adoptFlag ?? null))
|
|
506
|
+
return fail(`adopt flagged ${adoptFlag ?? "nothing"}, expected ${serialize(want.adopt)}`);
|
|
507
|
+
}
|
|
508
|
+
if (existsSync(file("expected-entries.json"))) {
|
|
509
|
+
const got = journal.entries.map(({ at: _at, owner: _o, ...e }) => e);
|
|
510
|
+
const wantE = json("expected-entries.json");
|
|
511
|
+
if (serialize(got) !== serialize(wantE))
|
|
512
|
+
return fail(`entries: ${firstDiff(serialize(got), serialize(wantE))}`);
|
|
513
|
+
}
|
|
514
|
+
const want = json("expected.json");
|
|
515
|
+
for (const w of want.records) {
|
|
516
|
+
const r = store.records.get(w.id);
|
|
517
|
+
if (!r)
|
|
518
|
+
return fail(`no record ${w.id}`);
|
|
519
|
+
if (one(r) !== one(w))
|
|
520
|
+
return fail(`${w.id}: ${firstDiff(one(r), one(w))}`);
|
|
521
|
+
}
|
|
522
|
+
if (want.review !== undefined && serialize(store.review) !== serialize(want.review))
|
|
523
|
+
return fail(`review: got ${serialize(store.review)}`);
|
|
524
|
+
if (want.bindings !== undefined && serialize(Object.fromEntries(store.bindings)) !== serialize(want.bindings))
|
|
525
|
+
return fail(`bindings: got ${serialize(Object.fromEntries(store.bindings))}`);
|
|
526
|
+
return pass;
|
|
527
|
+
}
|
|
528
|
+
if (existsSync(file("recipe.crubal"))) {
|
|
529
|
+
const [recipe] = readRecipes(readFileSync(file("recipe.crubal"), "utf8"));
|
|
530
|
+
if (!recipe)
|
|
531
|
+
return fail("no recipe");
|
|
532
|
+
checkRecipe(recipe, dict);
|
|
533
|
+
if (existsSync(file("expected-dry-run.json"))) {
|
|
534
|
+
const got = dryRun(records, recipe, dict);
|
|
535
|
+
return serialize(got) === serialize(json("expected-dry-run.json")) ? pass : fail(`dry run: ${serialize(got)}`);
|
|
536
|
+
}
|
|
537
|
+
const { records: out, review } = restamp(records, recipe, dict);
|
|
538
|
+
const want = json("expected.json");
|
|
539
|
+
const got = { ...JSON.parse(canonical({ crubal: "2", package: recipe.to, records: out }, dict)), review };
|
|
540
|
+
const wantC = { ...JSON.parse(canonical({ crubal: String(want.crubal), package: String(want.package), records: want.records }, dict)), review: want.review };
|
|
541
|
+
return serialize(got) === serialize(wantC) ? pass : fail(`got ${firstDiff(serialize(got), serialize(wantC))}`);
|
|
542
|
+
}
|
|
543
|
+
if (existsSync(file("contract-v2.crubal"))) {
|
|
544
|
+
const [v1] = readContracts(readFileSync(file("contract.crubal"), "utf8"));
|
|
545
|
+
const [v2] = readContracts(readFileSync(file("contract-v2.crubal"), "utf8"));
|
|
546
|
+
if (!v1 || !v2)
|
|
547
|
+
return fail("no contract");
|
|
548
|
+
const body = readFileSync(file("answer.json"), "utf8");
|
|
549
|
+
const first = receive(v1, body, records[0]?.id ?? "", dict);
|
|
550
|
+
const again = replay(first, v2, dict);
|
|
551
|
+
const twice = replay(first, v2, dict);
|
|
552
|
+
const want = json("expected-replay.json");
|
|
553
|
+
const replayed = (again.replays.at(-1)?.outcomes ?? []);
|
|
554
|
+
const check = (got, exp, label) => {
|
|
555
|
+
for (const w of exp) {
|
|
556
|
+
const a = got.find((o) => o.from === w.from);
|
|
557
|
+
if (!a)
|
|
558
|
+
return `${label}: no outcome for ${w.from}`;
|
|
559
|
+
const p = subset(a, w, `${label} ${w.from}: `);
|
|
560
|
+
if (p)
|
|
561
|
+
return p;
|
|
562
|
+
}
|
|
563
|
+
return null;
|
|
564
|
+
};
|
|
565
|
+
const p = check(first.outcomes, want.first, "first") ?? check(replayed, want.replayed, "replayed");
|
|
566
|
+
if (p)
|
|
567
|
+
return fail(p);
|
|
568
|
+
if (again.replays.length !== want.replays)
|
|
569
|
+
return fail(`${again.replays.length} replays`);
|
|
570
|
+
if (serialize(again.replays) !== serialize(twice.replays))
|
|
571
|
+
return fail("replay isn't deterministic");
|
|
572
|
+
if (again.raw.body !== body || again.raw.sha256 !== first.raw.sha256)
|
|
573
|
+
return fail("replay changed the kept bytes");
|
|
574
|
+
return pass;
|
|
575
|
+
}
|
|
576
|
+
if (existsSync(file("expected-review.json"))) {
|
|
577
|
+
const got = reviewByKey(json("intakes.json"));
|
|
578
|
+
const want = json("expected-review.json");
|
|
579
|
+
if (got["by-key"].length !== want["by-key"].length)
|
|
580
|
+
return fail(`by-key: got ${serialize(got["by-key"])}`);
|
|
581
|
+
for (let i = 0; i < want["by-key"].length; i++) {
|
|
582
|
+
const p = subset(got["by-key"][i], want["by-key"][i], `by-key[${i}] `);
|
|
583
|
+
if (p)
|
|
584
|
+
return fail(p);
|
|
585
|
+
}
|
|
586
|
+
return serialize(got.proposals) === serialize(want.proposals) ? pass : fail(`proposals: got ${serialize(got.proposals)}`);
|
|
587
|
+
}
|
|
588
|
+
if (existsSync(file("contract.crubal"))) {
|
|
589
|
+
const text = readFileSync(file("contract.crubal"), "utf8");
|
|
590
|
+
readDefinitions(text, dict);
|
|
591
|
+
const [contract] = readContracts(text);
|
|
592
|
+
if (!contract)
|
|
593
|
+
return fail("no contract");
|
|
594
|
+
const intake = receive(contract, readFileSync(file("answer.json"), "utf8"), records[0]?.id ?? "", dict);
|
|
595
|
+
const want = json("expected-intake.json").outcomes;
|
|
596
|
+
if (intake.outcomes.length !== want.length)
|
|
597
|
+
return fail(`got ${intake.outcomes.length} outcomes: ${serialize(intake.outcomes)}`);
|
|
598
|
+
for (const w of want) {
|
|
599
|
+
const a = intake.outcomes.find((o) => o.from === w.from);
|
|
600
|
+
if (!a)
|
|
601
|
+
return fail(`no outcome for ${w.from}`);
|
|
602
|
+
const p = subset(a, w, `${w.from}: `);
|
|
603
|
+
if (p)
|
|
604
|
+
return fail(p);
|
|
605
|
+
}
|
|
606
|
+
return pass;
|
|
607
|
+
}
|
|
608
|
+
if (existsSync(file("answer.json"))) {
|
|
609
|
+
const answer = json("answer.json");
|
|
610
|
+
const got = matchParts(records[0], answer.parts, dict);
|
|
611
|
+
const want = json("expected.json");
|
|
612
|
+
return serialize(got) === serialize(want) ? pass : fail(`got ${serialize(got)}`);
|
|
613
|
+
}
|
|
614
|
+
if (existsSync(file("observations.json")) && existsSync(file("expected-proposal.json"))) {
|
|
615
|
+
const got = proposeSplit(records, json("observations.json"));
|
|
616
|
+
return serialize(got) === serialize(json("expected-proposal.json")) ? pass : fail(`proposal: ${serialize(got)}`);
|
|
617
|
+
}
|
|
618
|
+
if (existsSync(file("observations.json"))) {
|
|
619
|
+
const want = json("expected-validation.json");
|
|
620
|
+
const rec = records.find((r) => r.id === want.record);
|
|
621
|
+
const got = validate(rec, String(want.address), json("observations.json"));
|
|
622
|
+
const p = subset(got, want);
|
|
623
|
+
return p ? fail(p) : pass;
|
|
624
|
+
}
|
|
625
|
+
const intakes = existsSync(file("intakes.json")) ? json("intakes.json") : [json("intake.json")];
|
|
626
|
+
let rec = records[0];
|
|
627
|
+
for (const i of intakes)
|
|
628
|
+
rec = land(rec, i, { origin: "reported", via: String(i.log) }, dict.trust, dict.facetTrust);
|
|
629
|
+
const want = json("expected.json");
|
|
630
|
+
const got = one(rec);
|
|
631
|
+
const wantC = one(want);
|
|
632
|
+
const alts = serialize((rec.alternatives ?? {})) === serialize((want.alternatives ?? {}));
|
|
633
|
+
return got === wantC && alts ? pass : fail(`got ${firstDiff(got, wantC)} alternatives ${serialize((rec.alternatives ?? {}))}`);
|
|
634
|
+
}
|
|
635
|
+
catch (e) {
|
|
636
|
+
return fail(e instanceof Error ? e.message : String(e));
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
function runExchangeCase(folder, caseDir, name, shared, root) {
|
|
640
|
+
const file = (f) => join(caseDir, f);
|
|
641
|
+
const json = (f) => JSON.parse(readFileSync(file(f), "utf8"));
|
|
642
|
+
const pass = { folder, name, pass: true };
|
|
643
|
+
const fail = (detail) => ({ folder, name, pass: false, detail });
|
|
644
|
+
try {
|
|
645
|
+
const input = existsSync(file("input.crubal")) ? readFileSync(file("input.crubal"), "utf8") : "";
|
|
646
|
+
if (folder === "exchange-errors" || folder === "package-errors") {
|
|
647
|
+
const want = Number(readFileSync(file("error.txt"), "utf8").trim().split(/\s/)[0]);
|
|
648
|
+
try {
|
|
649
|
+
if (folder === "package-errors") {
|
|
650
|
+
checkPackages(input);
|
|
651
|
+
checkPackage(readDefinitions(input));
|
|
652
|
+
}
|
|
653
|
+
else if (/^(edge|permissions|ownership): /m.test(input)) {
|
|
654
|
+
// Access (14.12, 14.13): the declarations, every record's owner, and every edge, in order.
|
|
655
|
+
const { defs, data } = splitCaseInput(input);
|
|
656
|
+
const d = copy(shared);
|
|
657
|
+
readDefinitions(defs, d);
|
|
658
|
+
const { perms, edges } = readAccess(defs);
|
|
659
|
+
checkPermissions(perms, d, new Set(readContracts(defs).map((c) => c.name)));
|
|
660
|
+
const recs = data.trim() ? readText(data, d).records.map((r) => foldRecord(r, d)) : [];
|
|
661
|
+
const access = new Access(d, perms, new MemoryRecords(recs.map((r) => [r.id, r])), [], "test.example");
|
|
662
|
+
// A record's refusal names the line its header was written at.
|
|
663
|
+
const lineOf = (id) => {
|
|
664
|
+
const i = input.split("\n").findIndex((l) => new RegExp(`^\\w+: ${id}(\\s|$)`).test(l));
|
|
665
|
+
return i < 0 ? undefined : i + 1;
|
|
666
|
+
};
|
|
667
|
+
access.checkWithin(lineOf);
|
|
668
|
+
for (const r of recs) {
|
|
669
|
+
access.checkOwnership(r, lineOf(r.id));
|
|
670
|
+
access.checkWears(r, lineOf(r.id));
|
|
671
|
+
}
|
|
672
|
+
for (const e of edges) {
|
|
673
|
+
access.checkEdge(e);
|
|
674
|
+
access.edges.set(e.edge, e);
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
else {
|
|
678
|
+
const d = copy(shared);
|
|
679
|
+
readDefinitions(input, d);
|
|
680
|
+
checkPackage(d);
|
|
681
|
+
checkContracts(readContracts(input), d);
|
|
682
|
+
}
|
|
683
|
+
return fail("published without refusing");
|
|
684
|
+
}
|
|
685
|
+
catch (e) {
|
|
686
|
+
if (!(e instanceof CrubalError))
|
|
687
|
+
return fail(String(e));
|
|
688
|
+
return e.line === want ? pass : fail(`refused at line ${e.line} (${e.message}), expected line ${want}`);
|
|
689
|
+
}
|
|
690
|
+
}
|
|
691
|
+
if (existsSync(file("border.json"))) {
|
|
692
|
+
const got = verifyBorder(json("border.json"), json("keys.json"));
|
|
693
|
+
const p = subset(got, json("expected-border.json"));
|
|
694
|
+
return p ? fail(`${p} (got ${serialize(got)})`) : pass;
|
|
695
|
+
}
|
|
696
|
+
const { defs, data } = splitCaseInput(input);
|
|
697
|
+
const dict = copy(shared);
|
|
698
|
+
if (defs.trim())
|
|
699
|
+
readDefinitions(defs, dict);
|
|
700
|
+
checkPackage(dict);
|
|
701
|
+
// Access (chapter 14): edges over the records, then questions.
|
|
702
|
+
if (existsSync(file("ask.crubal"))) {
|
|
703
|
+
const accessText = readFileSync(file("access.crubal"), "utf8");
|
|
704
|
+
readDefinitions(accessText, dict);
|
|
705
|
+
const { perms, edges } = readAccess(accessText);
|
|
706
|
+
checkPermissions(perms, dict, new Set(readContracts(accessText).map((c) => c.name)));
|
|
707
|
+
const recs = readText(data, dict).records.map((r) => foldRecord(r, dict));
|
|
708
|
+
const now = existsSync(file("now.txt")) ? readFileSync(file("now.txt"), "utf8").trim() : undefined;
|
|
709
|
+
const access = new Access(dict, perms, new MemoryRecords(recs.map((r) => [r.id, r])), edges, "test.example", now);
|
|
710
|
+
access.checkWithin();
|
|
711
|
+
const got = [];
|
|
712
|
+
for (const q of readTree(readFileSync(file("ask.crubal"), "utf8"))) {
|
|
713
|
+
if (q.kind !== "pair" || !q.key)
|
|
714
|
+
continue;
|
|
715
|
+
const words = q.value.trim().split(/\s+/);
|
|
716
|
+
const ask = `${q.key}: ${q.value.trim()}`;
|
|
717
|
+
if (q.key === "may") {
|
|
718
|
+
// may: <caller> <verb> <target> on <record>
|
|
719
|
+
const [caller = "", verb = "", target = "", , record = ""] = words;
|
|
720
|
+
got.push({ ask, result: access.may(caller, verb, target, record) });
|
|
721
|
+
}
|
|
722
|
+
else if (q.key === "mode") {
|
|
723
|
+
const [caller = "", , record = ""] = words;
|
|
724
|
+
got.push({ ask, result: access.effective(caller, record).mode });
|
|
725
|
+
}
|
|
726
|
+
else if (q.key === "stands") {
|
|
727
|
+
// stands: <caller> <partition…> on <record>
|
|
728
|
+
const on = words.indexOf("on");
|
|
729
|
+
got.push({ ask, result: access.standsIn(words[0] ?? "", words[on + 1] ?? "", words.slice(1, on).join(" ")) });
|
|
730
|
+
}
|
|
731
|
+
else if (q.key === "gated") {
|
|
732
|
+
const [caller = "", , record = ""] = words;
|
|
733
|
+
got.push({ ask, result: access.gated(caller, record)?.edge ?? false });
|
|
734
|
+
}
|
|
735
|
+
else if (q.key === "ancestors") {
|
|
736
|
+
got.push({ ask, result: access.ancestors(words[0] ?? "") });
|
|
737
|
+
}
|
|
738
|
+
else if (q.key === "audience") {
|
|
739
|
+
got.push({ ask, result: access.audienceOf(words[0] ?? "") });
|
|
740
|
+
}
|
|
741
|
+
else
|
|
742
|
+
return fail(`unknown ask ${q.key}`);
|
|
743
|
+
}
|
|
744
|
+
const want = json("expected-access.json");
|
|
745
|
+
if (got.length !== want.length)
|
|
746
|
+
return fail(`${got.length} answers, expected ${want.length}`);
|
|
747
|
+
for (let i = 0; i < want.length; i++) {
|
|
748
|
+
const p = subset(got[i], want[i], `${want[i].ask}: `);
|
|
749
|
+
if (p)
|
|
750
|
+
return fail(p);
|
|
751
|
+
}
|
|
752
|
+
return pass;
|
|
753
|
+
}
|
|
754
|
+
const shared_ = readFileSync(join(root, "exchange", "_contracts.crubal"), "utf8");
|
|
755
|
+
const contracts = [...readContracts(shared_), ...readContracts(defs)];
|
|
756
|
+
// A case that defines views lets its stream contracts (12.6.2) carry their projection streams.
|
|
757
|
+
const ownViews = readViews(defs);
|
|
758
|
+
checkContracts(contracts, dict, "", ownViews.views.size ? new Set(ownViews.views.keys()) : undefined);
|
|
759
|
+
const ex = new Exchange(dict, contracts, readRoles(defs), readRules(defs));
|
|
760
|
+
if (ownViews.views.size) {
|
|
761
|
+
checkViews(ownViews, dict);
|
|
762
|
+
ex.views = new Views(dict, ownViews);
|
|
763
|
+
}
|
|
764
|
+
for (const r of readText(data, dict).records.map((x) => foldRecord(x, dict))) {
|
|
765
|
+
ex.put(r);
|
|
766
|
+
ex.views?.put(r);
|
|
767
|
+
}
|
|
768
|
+
// With access.crubal, the border consults access (chapter 14): permission to run, gates, consumer edges.
|
|
769
|
+
if (existsSync(file("access.crubal"))) {
|
|
770
|
+
const accessText = readFileSync(file("access.crubal"), "utf8");
|
|
771
|
+
const { perms, edges } = readAccess(accessText);
|
|
772
|
+
const now = existsSync(file("now.txt")) ? readFileSync(file("now.txt"), "utf8").trim() : undefined;
|
|
773
|
+
ex.access = new Access(dict, perms, ex.records, edges, "test.example", now);
|
|
774
|
+
}
|
|
775
|
+
// Every run is a transaction (8.5): the border works on a journal's draft and commits each run.
|
|
776
|
+
const journal = new Journal("test.example", [], "2026-01-01T00:00:00Z");
|
|
777
|
+
ex.useJournal(journal);
|
|
778
|
+
journal.commit({ migration: "backfill" }, () => { }, "2026-01-01T00:00:00Z");
|
|
779
|
+
// The runs, in order.
|
|
780
|
+
const results = [];
|
|
781
|
+
let current = {};
|
|
782
|
+
const parseCursor = (s) => (s ? Object.fromEntries(s.split(",").map((p) => p.trim().split("=")).map(([v, n]) => [v ?? "", Number(n)])) : undefined);
|
|
783
|
+
const flush = () => {
|
|
784
|
+
// The send split (docs/design/runs.md): prepare, then acted or failed; undo names its cause.
|
|
785
|
+
if (current.prepare && current.on) {
|
|
786
|
+
results.push(ex.prepare(current.prepare, current.on, current.caller));
|
|
787
|
+
current = {};
|
|
788
|
+
return;
|
|
789
|
+
}
|
|
790
|
+
if (current.acted) {
|
|
791
|
+
results.push(ex.acted(current.acted));
|
|
792
|
+
current = {};
|
|
793
|
+
return;
|
|
794
|
+
}
|
|
795
|
+
if (current.failed) {
|
|
796
|
+
results.push(ex.failed(current.failed, current.reason ?? "failed"));
|
|
797
|
+
current = {};
|
|
798
|
+
return;
|
|
799
|
+
}
|
|
800
|
+
if (current.undo) {
|
|
801
|
+
results.push(ex.undo(current.undo, current.caller));
|
|
802
|
+
current = {};
|
|
803
|
+
return;
|
|
804
|
+
}
|
|
805
|
+
if (current.decideRun) {
|
|
806
|
+
results.push(ex.decideRun(current.decideRun, current.decision === "drop" ? "drop" : "retry", current.caller));
|
|
807
|
+
current = {};
|
|
808
|
+
return;
|
|
809
|
+
}
|
|
810
|
+
if (current.tick) {
|
|
811
|
+
results.push(ex.tick(current.tick, current.now ?? "", current.caller));
|
|
812
|
+
current = {};
|
|
813
|
+
return;
|
|
814
|
+
}
|
|
815
|
+
if (current.stream) {
|
|
816
|
+
results.push(ex.streamRun(current.stream, current.caller, parseCursor(current.cursor)));
|
|
817
|
+
current = {};
|
|
818
|
+
return;
|
|
819
|
+
}
|
|
820
|
+
if (current.ack) {
|
|
821
|
+
results.push(ex.ack(current.ack, parseCursor(current.cursor) ?? {}));
|
|
822
|
+
current = {};
|
|
823
|
+
return;
|
|
824
|
+
}
|
|
825
|
+
if (current.arrive) {
|
|
826
|
+
results.push(ex.arriveRun(current.arrive, readFileSync(file(current.answer ?? "answer.json"), "utf8"), current.level ?? "product", current.caller, current.on));
|
|
827
|
+
current = {};
|
|
828
|
+
return;
|
|
829
|
+
}
|
|
830
|
+
if (!current.run || !current.on)
|
|
831
|
+
return;
|
|
832
|
+
const c = contracts.find((x) => x.name === current.run);
|
|
833
|
+
if (current.offer) {
|
|
834
|
+
results.push(ex.take(current.run, current.offer, current.on, json(current.presenter ?? "presenter.json")));
|
|
835
|
+
}
|
|
836
|
+
else if (c?.kind === "receive" || c?.kind === "form" || current.forRun) {
|
|
837
|
+
// A receive lands its payload; a form is a receive whose source is a person (7.2), its answers the
|
|
838
|
+
// payload. A late answer names its run (7.5): the receive closes it and lands with its via.
|
|
839
|
+
results.push(ex.receiveRun(current.run, current.on, readFileSync(file(current.answer ?? "answer.json"), "utf8"), current.caller, current.forRun));
|
|
840
|
+
}
|
|
841
|
+
else {
|
|
842
|
+
results.push(ex.run(current.run, current.on, current.caller));
|
|
843
|
+
}
|
|
844
|
+
current = {};
|
|
845
|
+
};
|
|
846
|
+
for (const n of readTree(readFileSync(file("run.crubal"), "utf8"))) {
|
|
847
|
+
if (n.key === "run") {
|
|
848
|
+
flush();
|
|
849
|
+
current.run = n.value;
|
|
850
|
+
}
|
|
851
|
+
else if (n.key === "arrive") {
|
|
852
|
+
flush();
|
|
853
|
+
current.arrive = n.value;
|
|
854
|
+
}
|
|
855
|
+
else if (n.key === "stream") {
|
|
856
|
+
flush();
|
|
857
|
+
current.stream = n.value;
|
|
858
|
+
}
|
|
859
|
+
else if (n.key === "ack") {
|
|
860
|
+
flush();
|
|
861
|
+
current.ack = n.value;
|
|
862
|
+
}
|
|
863
|
+
else if (n.key === "prepare" || n.key === "acted" || n.key === "failed" || n.key === "undo") {
|
|
864
|
+
flush();
|
|
865
|
+
current[n.key] = n.value;
|
|
866
|
+
}
|
|
867
|
+
else if (n.key === "decide-run") {
|
|
868
|
+
flush();
|
|
869
|
+
current.decideRun = n.value;
|
|
870
|
+
}
|
|
871
|
+
else if (n.key === "decision")
|
|
872
|
+
current.decision = n.value;
|
|
873
|
+
else if (n.key === "reason")
|
|
874
|
+
current.reason = n.value;
|
|
875
|
+
else if (n.key === "for-run")
|
|
876
|
+
current.forRun = n.value;
|
|
877
|
+
else if (n.key === "cursor")
|
|
878
|
+
current.cursor = n.value;
|
|
879
|
+
else if (n.key === "tick") {
|
|
880
|
+
flush();
|
|
881
|
+
current.tick = n.value;
|
|
882
|
+
}
|
|
883
|
+
else if (n.key === "now")
|
|
884
|
+
current.now = n.value;
|
|
885
|
+
else if (n.key === "revise") {
|
|
886
|
+
flush();
|
|
887
|
+
const [view = "", version = ""] = n.value.split(" /");
|
|
888
|
+
ex.views?.revise(view.trim(), version.trim());
|
|
889
|
+
}
|
|
890
|
+
else if (n.key === "on")
|
|
891
|
+
current.on = n.value;
|
|
892
|
+
else if (n.key === "level")
|
|
893
|
+
current.level = n.value;
|
|
894
|
+
else if (n.key === "answer")
|
|
895
|
+
current.answer = n.value;
|
|
896
|
+
else if (n.key === "caller")
|
|
897
|
+
current.caller = n.value;
|
|
898
|
+
else if (n.key === "offer")
|
|
899
|
+
current.offer = n.value;
|
|
900
|
+
else if (n.key === "presenter")
|
|
901
|
+
current.presenter = n.value;
|
|
902
|
+
else if (n.key === "give") {
|
|
903
|
+
// The owner's own offer (7.8), as its host would serve it.
|
|
904
|
+
flush();
|
|
905
|
+
results.push(ex.offer(n.value, "test.example"));
|
|
906
|
+
}
|
|
907
|
+
else if (n.key === "expire") {
|
|
908
|
+
flush();
|
|
909
|
+
results.push(ex.expire(n.value));
|
|
910
|
+
}
|
|
911
|
+
else if (n.key === "write") {
|
|
912
|
+
flush();
|
|
913
|
+
const r = ex.get(n.value);
|
|
914
|
+
if (!r)
|
|
915
|
+
return fail(`write to an unknown record ${n.value}`);
|
|
916
|
+
const values = { ...r.values };
|
|
917
|
+
for (const c of n.children) {
|
|
918
|
+
const f = c.key ? dict.facets.get(c.key) : undefined;
|
|
919
|
+
if (c.key)
|
|
920
|
+
values[c.key] = f ? (readUnder(f, c.value, dict) ?? c.value) : c.value;
|
|
921
|
+
}
|
|
922
|
+
ex.put({ ...r, values });
|
|
923
|
+
ex.views?.put({ ...r, values });
|
|
924
|
+
}
|
|
925
|
+
}
|
|
926
|
+
flush();
|
|
927
|
+
if (existsSync(file("expected.json"))) {
|
|
928
|
+
const id = json("expected.json").id;
|
|
929
|
+
const got = ex.get(id);
|
|
930
|
+
const want = json("expected.json");
|
|
931
|
+
const one = (r) => canonical({ crubal: "2", records: [r] }, dict);
|
|
932
|
+
const extra = (r) => serialize({ a: (r.alternatives ?? {}), s: (r.suggestions ?? {}) });
|
|
933
|
+
if (one(got) !== one(want))
|
|
934
|
+
return fail(`record: ${firstDiff(one(got), one(want))}`);
|
|
935
|
+
if (extra(got) !== extra(want))
|
|
936
|
+
return fail(`alternatives/suggestions: got ${extra(got)}`);
|
|
937
|
+
return pass;
|
|
938
|
+
}
|
|
939
|
+
if (existsSync(file("expected-entries.json"))) {
|
|
940
|
+
const got = journal.entries.map(({ at: _at, owner: _o, ...e }) => e);
|
|
941
|
+
const wantE = json("expected-entries.json");
|
|
942
|
+
if (serialize(got) !== serialize(wantE))
|
|
943
|
+
return fail(`entries: ${firstDiff(serialize(got), serialize(wantE))}`);
|
|
944
|
+
}
|
|
945
|
+
if (existsSync(file("expected-edges.json"))) {
|
|
946
|
+
const got = [...(ex.access?.edges.values() ?? [])].map(({ line: _l, ...e }) => e);
|
|
947
|
+
const want = json("expected-edges.json");
|
|
948
|
+
for (const w of want) {
|
|
949
|
+
const a = got.find((e) => e.edge === w.edge);
|
|
950
|
+
if (!a)
|
|
951
|
+
return fail(`no edge ${w.edge}`);
|
|
952
|
+
const p = subset(a, w, `${w.edge}: `);
|
|
953
|
+
if (p)
|
|
954
|
+
return fail(p);
|
|
955
|
+
}
|
|
956
|
+
}
|
|
957
|
+
const wantRaw = json("expected-run.json");
|
|
958
|
+
const want = (Array.isArray(wantRaw) ? wantRaw : [wantRaw]);
|
|
959
|
+
if (results.length !== want.length)
|
|
960
|
+
return fail(`${results.length} runs, expected ${want.length}`);
|
|
961
|
+
for (let i = 0; i < want.length; i++) {
|
|
962
|
+
const w = { ...want[i] };
|
|
963
|
+
const a = results[i];
|
|
964
|
+
if (Array.isArray(w.outcomes)) {
|
|
965
|
+
for (const wo of w.outcomes) {
|
|
966
|
+
const ao = a.outcomes.find((x) => x.from === wo.from);
|
|
967
|
+
if (!ao)
|
|
968
|
+
return fail(`run ${i + 1}: no outcome for ${wo.from}`);
|
|
969
|
+
const p = subset(ao, wo, `run ${i + 1} ${wo.from}: `);
|
|
970
|
+
if (p)
|
|
971
|
+
return fail(p);
|
|
972
|
+
}
|
|
973
|
+
if (a.outcomes.length !== w.outcomes.length)
|
|
974
|
+
return fail(`run ${i + 1}: ${a.outcomes.length} outcomes`);
|
|
975
|
+
delete w.outcomes;
|
|
976
|
+
}
|
|
977
|
+
if (isPlainObject(w.sources)) {
|
|
978
|
+
const p = subset(a.sources, w.sources, `run ${i + 1} sources `);
|
|
979
|
+
if (p)
|
|
980
|
+
return fail(p);
|
|
981
|
+
delete w.sources;
|
|
982
|
+
}
|
|
983
|
+
// Stopping is the verdict, not a detail: an expectation must say so when a run stopped.
|
|
984
|
+
if (a.stopped !== undefined && w.stopped === undefined)
|
|
985
|
+
return fail(`run ${i + 1}: stopped at ${serialize(a.stopped)}, expected it to run`);
|
|
986
|
+
const p = subset(a, w, `run ${i + 1}: `);
|
|
987
|
+
if (p)
|
|
988
|
+
return fail(p);
|
|
989
|
+
}
|
|
990
|
+
return pass;
|
|
991
|
+
}
|
|
992
|
+
catch (e) {
|
|
993
|
+
return fail(e instanceof Error ? e.message : String(e));
|
|
994
|
+
}
|
|
995
|
+
}
|
|
996
|
+
const RUN_KEYS = new Set(["pick", "context", "audiences", "callers", "page"]);
|
|
997
|
+
function runViewsCase(folder, caseDir, name, shared, root) {
|
|
998
|
+
const file = (f) => join(caseDir, f);
|
|
999
|
+
const json = (f) => JSON.parse(readFileSync(file(f), "utf8"));
|
|
1000
|
+
const pass = { folder, name, pass: true };
|
|
1001
|
+
const fail = (detail) => ({ folder, name, pass: false, detail });
|
|
1002
|
+
try {
|
|
1003
|
+
const input = readFileSync(file("input.crubal"), "utf8");
|
|
1004
|
+
if (folder === "views-errors") {
|
|
1005
|
+
const want = Number(readFileSync(file("error.txt"), "utf8").trim().split(/\s/)[0]);
|
|
1006
|
+
try {
|
|
1007
|
+
const d = copy(shared);
|
|
1008
|
+
readDefinitions(input, d);
|
|
1009
|
+
checkPackage(d);
|
|
1010
|
+
checkViews(readViews(input), d);
|
|
1011
|
+
return fail("published without refusing");
|
|
1012
|
+
}
|
|
1013
|
+
catch (e) {
|
|
1014
|
+
if (!(e instanceof CrubalError))
|
|
1015
|
+
return fail(String(e));
|
|
1016
|
+
return e.line === want ? pass : fail(`refused at line ${e.line} (${e.message}), expected line ${want}`);
|
|
1017
|
+
}
|
|
1018
|
+
}
|
|
1019
|
+
// Run lines (a bodiless "view:", pick, context, audiences, callers, page) are instructions, not definitions.
|
|
1020
|
+
const lines = input.split("\n");
|
|
1021
|
+
const run = { picks: {} };
|
|
1022
|
+
const tree = readTree(input);
|
|
1023
|
+
for (const n of tree) {
|
|
1024
|
+
const isRun = (n.key === "view" && n.children.length === 0) || (n.key !== null && RUN_KEYS.has(n.key));
|
|
1025
|
+
if (!isRun)
|
|
1026
|
+
continue;
|
|
1027
|
+
const blank = (x) => {
|
|
1028
|
+
lines[x.line - 1] = "";
|
|
1029
|
+
for (const c of x.children)
|
|
1030
|
+
blank(c);
|
|
1031
|
+
};
|
|
1032
|
+
blank(n);
|
|
1033
|
+
if (n.key === "view")
|
|
1034
|
+
run.view = n.value;
|
|
1035
|
+
if (n.key === "pick")
|
|
1036
|
+
for (const c of n.children)
|
|
1037
|
+
if (c.key)
|
|
1038
|
+
run.picks[c.key] = c.value.split(",").map((x) => x.trim());
|
|
1039
|
+
if (n.key === "context")
|
|
1040
|
+
run.context = n.value;
|
|
1041
|
+
if (n.key === "audiences")
|
|
1042
|
+
run.audiences = n.value.split(",").map((x) => x.trim());
|
|
1043
|
+
if (n.key === "callers")
|
|
1044
|
+
run.callers = n.value.split(",").map((x) => x.trim());
|
|
1045
|
+
if (n.key === "page")
|
|
1046
|
+
run.page = Number(n.value);
|
|
1047
|
+
}
|
|
1048
|
+
const { defs, data } = splitCaseInput(lines.join("\n"));
|
|
1049
|
+
const dict = copy(shared);
|
|
1050
|
+
const sharedViews = readFileSync(join(root, "views", "_views.crubal"), "utf8");
|
|
1051
|
+
readDefinitions(sharedViews, dict);
|
|
1052
|
+
if (defs.trim())
|
|
1053
|
+
readDefinitions(defs, dict);
|
|
1054
|
+
checkPackage(dict);
|
|
1055
|
+
const set = readViews(sharedViews);
|
|
1056
|
+
const own = readViews(defs);
|
|
1057
|
+
for (const k of ["shapes", "filters", "views", "roles", "shown", "shownFor"])
|
|
1058
|
+
for (const [key, val] of own[k])
|
|
1059
|
+
set[k].set(key, val);
|
|
1060
|
+
checkViews(set, dict);
|
|
1061
|
+
const views = new Views(dict, set);
|
|
1062
|
+
const text = data.trim() ? data : readFileSync(join(root, "views", "_records.crubal"), "utf8");
|
|
1063
|
+
const records = readText(text, dict).records.map((r) => foldRecord(r, dict));
|
|
1064
|
+
for (const r of records)
|
|
1065
|
+
views.put(r);
|
|
1066
|
+
const byId = new MemoryRecords(records.map((r) => [r.id, r]));
|
|
1067
|
+
const viewName = run.view;
|
|
1068
|
+
// Partitioned views (12.6): the case's edges decide which rows a caller is served.
|
|
1069
|
+
let access;
|
|
1070
|
+
if (existsSync(file("access.crubal"))) {
|
|
1071
|
+
const { perms, edges } = readAccess(readFileSync(file("access.crubal"), "utf8"));
|
|
1072
|
+
access = new Access(dict, perms, byId, edges, "test.example");
|
|
1073
|
+
}
|
|
1074
|
+
const opts = { picks: run.picks, ...(run.context ? { context: byId.get(run.context) } : {}), ...(run.page ? { page: run.page } : {}), ...(access ? { access } : {}) };
|
|
1075
|
+
const same = (got, want, label) => (serialize(got) === serialize(want) ? pass : fail(`${label}: got ${serialize(got)}`));
|
|
1076
|
+
if (existsSync(file("expected-stream.json"))) {
|
|
1077
|
+
const first = views.stream(viewName, opts);
|
|
1078
|
+
for (const w of readText(readFileSync(file("write.crubal"), "utf8"), dict).records) {
|
|
1079
|
+
const was = byId.get(w.id);
|
|
1080
|
+
views.put(was ? { ...was, values: { ...was.values, ...w.values } } : foldRecord(w, dict));
|
|
1081
|
+
}
|
|
1082
|
+
const second = views.stream(viewName, opts);
|
|
1083
|
+
return same([first, second], json("expected-stream.json"), "stream");
|
|
1084
|
+
}
|
|
1085
|
+
if (existsSync(file("expected-stale.json"))) {
|
|
1086
|
+
views.project(viewName, opts);
|
|
1087
|
+
const stale = new Set();
|
|
1088
|
+
for (const w of readText(readFileSync(file("write.crubal"), "utf8"), dict).records)
|
|
1089
|
+
for (const id of views.staleAfter(viewName, w))
|
|
1090
|
+
stale.add(id);
|
|
1091
|
+
const want = json("expected-stale.json");
|
|
1092
|
+
const wrong = [...want.stale.filter((id) => !stale.has(id)), ...want["not-stale"].filter((id) => stale.has(id))];
|
|
1093
|
+
return wrong.length ? fail(`stale: got ${[...stale].join(", ")}`) : pass;
|
|
1094
|
+
}
|
|
1095
|
+
if (existsSync(file("expected-by.json"))) {
|
|
1096
|
+
const got = {};
|
|
1097
|
+
for (const a of run.audiences ?? [])
|
|
1098
|
+
got[a] = views.project(viewName, { ...opts, audience: a }).rows;
|
|
1099
|
+
for (const c of run.callers ?? [])
|
|
1100
|
+
got[c] = views.project(viewName, { ...opts, caller: byId.get(c) }).rows;
|
|
1101
|
+
return same(got, json("expected-by.json"), "by audience");
|
|
1102
|
+
}
|
|
1103
|
+
const p = views.project(viewName, opts);
|
|
1104
|
+
if (existsSync(file("expected-counts.json")))
|
|
1105
|
+
return same({ records: p.records, counts: p.counts, ...(p.nested ? { nested: p.nested } : {}), indexable: p.indexable }, json("expected-counts.json"), "counts");
|
|
1106
|
+
if (existsSync(file("expected-jsonld.json")))
|
|
1107
|
+
return same(p.rows, json("expected-jsonld.json"), "jsonld");
|
|
1108
|
+
if (existsSync(file("expected-pages.json")))
|
|
1109
|
+
return same({ pages: p.pages, records: p.records, indexable: p.indexable }, json("expected-pages.json"), "pages");
|
|
1110
|
+
const wantFile = existsSync(file("expected-portable.json")) ? "expected-portable.json" : "expected-projection.json";
|
|
1111
|
+
return same(p.rows, json(wantFile), "projection");
|
|
1112
|
+
}
|
|
1113
|
+
catch (e) {
|
|
1114
|
+
return fail(e instanceof Error ? e.message : String(e));
|
|
1115
|
+
}
|
|
1116
|
+
}
|
|
1117
|
+
const isPlainObject = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
|
|
1118
|
+
/**
|
|
1119
|
+
* Forms as data (7.7, docs/design/index-and-forms.md): `form.crubal` names the contract, the record and
|
|
1120
|
+
* optionally the caller (several `form:` blocks give several forms); `access.crubal` makes editability
|
|
1121
|
+
* follow edges and gates; `arrival.json` is a held arrival whose candidates the form offers;
|
|
1122
|
+
* `expected-form.json` is the form (or the forms) exactly.
|
|
1123
|
+
*/
|
|
1124
|
+
function runFormsCase(folder, caseDir, name, shared) {
|
|
1125
|
+
const file = (f) => join(caseDir, f);
|
|
1126
|
+
const json = (f) => JSON.parse(readFileSync(file(f), "utf8"));
|
|
1127
|
+
const pass = { folder, name, pass: true };
|
|
1128
|
+
const fail = (detail) => ({ folder, name, pass: false, detail });
|
|
1129
|
+
try {
|
|
1130
|
+
const input = readFileSync(file("input.crubal"), "utf8");
|
|
1131
|
+
const { defs, data } = splitCaseInput(input);
|
|
1132
|
+
const dict = copy(shared);
|
|
1133
|
+
if (defs.trim())
|
|
1134
|
+
readDefinitions(defs, dict);
|
|
1135
|
+
const contracts = readContracts(defs);
|
|
1136
|
+
const records = data.trim() ? readText(data, dict).records.map((r) => foldRecord(r, dict)) : [];
|
|
1137
|
+
const byId = new MemoryRecords(records.map((r) => [r.id, r]));
|
|
1138
|
+
let access;
|
|
1139
|
+
if (existsSync(file("access.crubal"))) {
|
|
1140
|
+
const { perms, edges } = readAccess(readFileSync(file("access.crubal"), "utf8"));
|
|
1141
|
+
access = new Access(dict, perms, byId, edges, "test.example");
|
|
1142
|
+
}
|
|
1143
|
+
const arrival = existsSync(file("arrival.json")) ? json("arrival.json") : undefined;
|
|
1144
|
+
// form.crubal reads like run.crubal: `form:` starts one, `record:` and `caller:` belong to it.
|
|
1145
|
+
const asks = [];
|
|
1146
|
+
for (const n of readTree(readFileSync(file("form.crubal"), "utf8"))) {
|
|
1147
|
+
if (n.key === "form")
|
|
1148
|
+
asks.push({ form: n.value.trim() });
|
|
1149
|
+
else if ((n.key === "record" || n.key === "caller") && asks.length)
|
|
1150
|
+
asks[asks.length - 1][n.key] = n.value.trim();
|
|
1151
|
+
}
|
|
1152
|
+
const forms = [];
|
|
1153
|
+
for (const ask of asks) {
|
|
1154
|
+
const c = contracts.find((x) => x.name === ask.form);
|
|
1155
|
+
if (!c)
|
|
1156
|
+
return fail(`no contract ${ask.form}`);
|
|
1157
|
+
const rec = ask.record ? byId.get(ask.record) : undefined;
|
|
1158
|
+
forms.push(formOf(c, rec, dict, { records: byId, ...(ask.caller ? { caller: ask.caller } : {}), ...(access ? { access } : {}), ...(arrival ? { arrival } : {}) }));
|
|
1159
|
+
}
|
|
1160
|
+
const want = json("expected-form.json");
|
|
1161
|
+
const got = Array.isArray(want) ? forms : (forms[0] ?? {});
|
|
1162
|
+
return serialize(got) === serialize(want) ? pass : fail(`form: ${firstDiff(serialize(got), serialize(want))}`);
|
|
1163
|
+
}
|
|
1164
|
+
catch (e) {
|
|
1165
|
+
return fail(e instanceof Error ? e.message : String(e));
|
|
1166
|
+
}
|
|
1167
|
+
}
|
|
1168
|
+
/** Run one level's corpus folders under `root`. */
|
|
1169
|
+
export function runCorpus(root, level) {
|
|
1170
|
+
const folders = LEVEL_FOLDERS[level];
|
|
1171
|
+
if (!folders)
|
|
1172
|
+
throw new Error(`no corpus runner for level ${level} yet`);
|
|
1173
|
+
const results = [];
|
|
1174
|
+
for (const folder of folders) {
|
|
1175
|
+
const dir = join(root, folder);
|
|
1176
|
+
const dict = loadDictionary(root, folder);
|
|
1177
|
+
const rulesFile = join(root, "rules", "_rules.crubal");
|
|
1178
|
+
const sharedRules = folder === "rules" && existsSync(rulesFile) ? readRules(readFileSync(rulesFile, "utf8")) : [];
|
|
1179
|
+
for (const name of readdirSync(dir).sort()) {
|
|
1180
|
+
if (name.startsWith("_"))
|
|
1181
|
+
continue;
|
|
1182
|
+
if (!["input.crubal", "log.json", "border.json"].some((f) => existsSync(join(dir, name, f))))
|
|
1183
|
+
continue;
|
|
1184
|
+
if (folder === "store" && existsSync(join(dir, name, "expected-redact.json"))) {
|
|
1185
|
+
results.push(runStoreCase(folder, join(dir, name), name, dict));
|
|
1186
|
+
continue;
|
|
1187
|
+
}
|
|
1188
|
+
const r = folder === "forms"
|
|
1189
|
+
? runFormsCase(folder, join(dir, name), name, dict)
|
|
1190
|
+
: folder.startsWith("views")
|
|
1191
|
+
? runViewsCase(folder, join(dir, name), name, dict, root)
|
|
1192
|
+
: folder.startsWith("exchange") || folder === "package-errors"
|
|
1193
|
+
? runExchangeCase(folder, join(dir, name), name, dict, root)
|
|
1194
|
+
: folder.startsWith("store")
|
|
1195
|
+
? runStoreCase(folder, join(dir, name), name, dict)
|
|
1196
|
+
: folder.startsWith("rules")
|
|
1197
|
+
? runRulesCase(folder, join(dir, name), name, dict, sharedRules)
|
|
1198
|
+
: folder.startsWith("dictionary")
|
|
1199
|
+
? runDictionaryCase(folder, join(dir, name), name, dict)
|
|
1200
|
+
: runCase(folder, dir, name, dict);
|
|
1201
|
+
if (r)
|
|
1202
|
+
results.push(r);
|
|
1203
|
+
}
|
|
1204
|
+
}
|
|
1205
|
+
return results;
|
|
1206
|
+
}
|
|
1207
|
+
//# sourceMappingURL=corpus.js.map
|