@olierjs/fugue-cli 0.1.0 → 0.1.2

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.
Files changed (40) hide show
  1. package/README.md +52 -2
  2. package/THIRD_PARTY_NOTICES +52 -1
  3. package/dist/darwin-arm64/fugue-cli +0 -0
  4. package/dist/darwin-x64/fugue-cli +0 -0
  5. package/dist/linux-arm64/fugue-cli +0 -0
  6. package/dist/linux-x64/fugue-cli +0 -0
  7. package/dist/win32-arm64/fugue-cli.exe +0 -0
  8. package/dist/win32-x64/fugue-cli.exe +0 -0
  9. package/package.json +2 -1
  10. package/skills/fugue-show-me/SKILL.md +16 -0
  11. package/skills/show-me/SKILL.md +128 -0
  12. package/skills/show-me/agents/openai.yaml +2 -0
  13. package/skills/statechart/SKILL.md +36 -0
  14. package/skills/statechart/scripts/bug-states.mjs +81 -0
  15. package/skills/statechart/scripts/chart-edges.mjs +68 -0
  16. package/skills/statechart/scripts/chart-parts.mjs +21 -0
  17. package/skills/statechart/scripts/chart-to-tla.mjs +205 -0
  18. package/skills/statechart/scripts/compare-charts.mjs +74 -0
  19. package/skills/statechart/scripts/lib.mjs +970 -0
  20. package/skills/statechart/scripts/map-view.mjs +92 -0
  21. package/skills/statechart/scripts/property-loop.mjs +60 -0
  22. package/skills/statechart/scripts/read-check.mjs +79 -0
  23. package/skills/statechart/scripts/regions-chart.mjs +307 -0
  24. package/skills/statechart/scripts/render.mjs +531 -0
  25. package/skills/statechart/scripts/rule-book.mjs +84 -0
  26. package/skills/statechart/scripts/second-replay.mjs +122 -0
  27. package/skills/statechart/scripts/snapshot.mjs +74 -0
  28. package/skills/statechart/scripts/spec-text.mjs +126 -0
  29. package/skills/statechart/scripts/statechart-help.mjs +350 -0
  30. package/skills/statechart/scripts/statechart.mjs +903 -0
  31. package/skills/statechart/scripts/test/band.mmd +15 -0
  32. package/skills/statechart/scripts/test/regions.mmd +62 -0
  33. package/skills/statechart/scripts/test/statechart.test.mjs +6854 -0
  34. package/skills/statechart/scripts/test/sync.mmd +19 -0
  35. package/skills/statechart/scripts/test/upload.mmd +16 -0
  36. package/skills/statechart/scripts/tlc-to-chart-args.mjs +52 -0
  37. package/skills/statechart/scripts/tlc-to-chart.mjs +802 -0
  38. package/skills/statechart/scripts/tlc-tools.mjs +69 -0
  39. package/skills/statechart/scripts/ways-in.mjs +69 -0
  40. package/upstream-skills.json +18 -0
@@ -0,0 +1,970 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+
4
+ const tuples = new WeakSet();
5
+ export const isTuple = (v) => tuples.has(v);
6
+
7
+ export function parseValue(text) {
8
+ const src = text.trim();
9
+ let i = 0;
10
+ const ws = () => {
11
+ while (i < src.length && /\s/.test(src[i])) i++;
12
+ };
13
+ const peek = (s) => src.startsWith(s, i);
14
+ const eat = (s) => {
15
+ ws();
16
+ if (!peek(s)) throw new Error(`expected ${s} at ${i} in ${src}`);
17
+ i += s.length;
18
+ };
19
+ const list = (close, item) => {
20
+ const out = [];
21
+ ws();
22
+ if (peek(close)) {
23
+ i += close.length;
24
+ return out;
25
+ }
26
+ for (;;) {
27
+ out.push(item());
28
+ ws();
29
+ if (peek(",")) {
30
+ i++;
31
+ continue;
32
+ }
33
+ eat(close);
34
+ return out;
35
+ }
36
+ };
37
+ const value = () => {
38
+ ws();
39
+ if (peek("<<")) {
40
+ i += 2;
41
+ const items = list(">>", value);
42
+ tuples.add(items);
43
+ return items;
44
+ }
45
+ if (peek("{")) {
46
+ i++;
47
+ return list("}", value);
48
+ }
49
+ if (peek("[")) {
50
+ i++;
51
+ const pairs = list("]", () => {
52
+ ws();
53
+ const m = /^[A-Za-z_][A-Za-z0-9_]*/.exec(src.slice(i));
54
+ if (!m) throw new Error(`bad record field at ${i}`);
55
+ i += m[0].length;
56
+ eat("|->");
57
+ return [m[0], value()];
58
+ });
59
+ return Object.fromEntries(pairs);
60
+ }
61
+ if (peek("(")) {
62
+ i++;
63
+ const fn = {};
64
+ for (;;) {
65
+ const k = value();
66
+ eat(":>");
67
+ fn[typeof k === "string" ? k : JSON.stringify(k)] = value();
68
+ ws();
69
+ if (peek("@@")) {
70
+ i += 2;
71
+ continue;
72
+ }
73
+ eat(")");
74
+ return fn;
75
+ }
76
+ }
77
+ if (peek('"')) {
78
+ i++;
79
+ let s = "";
80
+ while (i < src.length && src[i] !== '"') {
81
+ if (src[i] === "\\") i++;
82
+ s += src[i++];
83
+ }
84
+ i++;
85
+ return s;
86
+ }
87
+ const m = /^-?\d+|^[A-Za-z_][A-Za-z0-9_]*/.exec(src.slice(i));
88
+ if (!m) throw new Error(`bad value at ${i} in ${src}`);
89
+ i += m[0].length;
90
+ if (m[0] === "TRUE") return true;
91
+ if (m[0] === "FALSE") return false;
92
+ return /^-?\d+$/.test(m[0]) ? Number(m[0]) : m[0];
93
+ };
94
+ try {
95
+ const v = value();
96
+ ws();
97
+ return i === src.length ? v : src;
98
+ } catch {
99
+ return src;
100
+ }
101
+ }
102
+
103
+ // One state as TLC prints it: "/\ x = 1" lines, or "x = 1" for one variable.
104
+ export function parseState(text) {
105
+ const lines = text.split("\n").filter((l) => l.trim() !== "");
106
+ const conjuncts = [];
107
+ for (const line of lines) {
108
+ if (/^\s*\/\\ /.test(line) || conjuncts.length === 0)
109
+ conjuncts.push(line.replace(/^\s*\/\\ /, ""));
110
+ else conjuncts[conjuncts.length - 1] += ` ${line.trim()}`;
111
+ }
112
+ const vars = {};
113
+ for (const c of conjuncts) {
114
+ const eq = c.indexOf(" = ");
115
+ if (eq < 0) continue;
116
+ vars[c.slice(0, eq).trim()] = parseValue(c.slice(eq + 3));
117
+ }
118
+ return vars;
119
+ }
120
+
121
+ export const isRecord = (v) => v !== null && typeof v === "object" && !Array.isArray(v);
122
+ const canon = (v) =>
123
+ Array.isArray(v)
124
+ ? v.map(canon)
125
+ : isRecord(v)
126
+ ? Object.fromEntries(
127
+ Object.keys(v)
128
+ .sort()
129
+ .map((k) => [k, canon(v[k])]),
130
+ )
131
+ : v;
132
+ export const stateKey = (vars) => JSON.stringify(canon(vars));
133
+
134
+ export function ruleSentences(tla) {
135
+ const out = new Map();
136
+ const lines = tla.split("\n");
137
+ lines.forEach((line, i) => {
138
+ const name = /^([A-Za-z_]\w*)\s*==/.exec(line)?.[1];
139
+ if (!name) return;
140
+ const said = [];
141
+ for (let j = i - 1; j >= 0 && /^\s*\\\*/.test(lines[j]); j--)
142
+ said.unshift(lines[j].replace(/^\s*\\\*\s?/, "").trim());
143
+ const sentence = said.filter(Boolean).join(" ");
144
+ if (sentence) out.set(name, sentence);
145
+ });
146
+ return out;
147
+ }
148
+
149
+ export const tlaVariables = (code) =>
150
+ [...code.matchAll(/\bVARIABLES?\s+(\w+(?:\s*,\s*\w+)*)/g)].flatMap((m) => m[1].split(/\s*,\s*/));
151
+
152
+ export function splitCommentsKeepingOffsets(text) {
153
+ let code = "";
154
+ let notes = "";
155
+ let block = 0;
156
+ let line = false;
157
+ let string = false;
158
+ const put = (c, n) => {
159
+ code += c;
160
+ notes += n;
161
+ };
162
+ for (let i = 0; i < text.length; i++) {
163
+ const c = text[i];
164
+ const two = text.slice(i, i + 2);
165
+ if (c === "\n") {
166
+ line = string = false;
167
+ put(c, c);
168
+ } else if (block) {
169
+ if (two === "(*" || two === "*)") {
170
+ block += two === "(*" ? 1 : -1;
171
+ put(" ", two);
172
+ i++;
173
+ } else put(" ", c);
174
+ } else if (line) put(" ", c);
175
+ else if (string) {
176
+ if (c === "\\" && text[i + 1] && text[i + 1] !== "\n") {
177
+ put(two, " ");
178
+ i++;
179
+ } else {
180
+ string = c !== '"';
181
+ put(c, " ");
182
+ }
183
+ } else if (two === "\\*" || two === "(*") {
184
+ if (two === "\\*") line = true;
185
+ else block = 1;
186
+ put(" ", two);
187
+ i++;
188
+ } else {
189
+ string = c === '"';
190
+ put(c, " ");
191
+ }
192
+ }
193
+ return { code, notes };
194
+ }
195
+
196
+ const IDENTIFIER = /(?<![\\.\w])[A-Za-z_]\w*(?!\w)(?!\s*\|->)/g;
197
+ const QUALIFIED = /(?<![\\.\w!])[A-Za-z_]\w*(?:![A-Za-z_]\w*)+/g;
198
+ const PRIMED = /(?<![\\.\w])([A-Za-z_]\w*)'/g;
199
+ const UNCHANGED_CLAUSE = /\bUNCHANGED\s*(?:<<[\s\S]*?>>|[A-Za-z_]\w*)/g;
200
+ const CITATION = /[\w./-]+\.[A-Za-z]\w*:\d+|#L\d+/;
201
+
202
+ const DEPTH = { "(": 1, "[": 1, "{": 1, ")": -1, "]": -1, "}": -1 };
203
+ const LOOSER_THAN_AND = /^(\\\/|=>|<=>|~>|-\+->|\\equiv\b)/;
204
+ const RUNS_TO_END = /^(\\[EA]|IF|LET|CASE|CHOOSE)\b/;
205
+ export const PRIMES_OR_UNCHANGED = /[\w)\]>}]'|\bUNCHANGED\b/;
206
+
207
+ function conjuncts(bare, from, to, lineStart) {
208
+ const first = from + bare.slice(from, to).search(/\S/);
209
+ if (first < from) return null;
210
+ if (/^\\E\b/.test(bare.slice(first, to))) {
211
+ for (let i = first, depth = 0; i < to; i++) {
212
+ depth += DEPTH[bare[i]] ?? 0;
213
+ if (depth === 0 && bare[i] === ":") return conjuncts(bare, i + 1, to, lineStart);
214
+ }
215
+ return null;
216
+ }
217
+ if (bare.startsWith("/\\", first)) {
218
+ const column = first - lineStart(first);
219
+ const bullets = [first];
220
+ for (const m of bare.slice(first, to).matchAll(/\n([ \t]*)(?=\S)/g)) {
221
+ if (m[1].length < column) return null;
222
+ const at = first + m.index + m[0].length;
223
+ if (m[1].length === column && bare.startsWith("/\\", at)) bullets.push(at);
224
+ }
225
+ return bullets.flatMap((b, i) => {
226
+ const end = bullets[i + 1] ?? to;
227
+ return conjuncts(bare, b + 2, end, lineStart) ?? [[b + 2, end]];
228
+ });
229
+ }
230
+ const cuts = [];
231
+ let depth = 0;
232
+ for (let i = first; i < to; i++) {
233
+ const two = bare.slice(i, i + 2);
234
+ const ahead = bare.slice(i, i + 7);
235
+ if (two === "<<" || two === ">>") {
236
+ depth += two === "<<" ? 1 : -1;
237
+ i++;
238
+ } else if (DEPTH[bare[i]]) depth += DEPTH[bare[i]];
239
+ else if (depth > 0) continue;
240
+ else if (LOOSER_THAN_AND.test(ahead)) return null;
241
+ else if (!/\w/.test(bare[i - 1] ?? "") && RUNS_TO_END.test(ahead)) break;
242
+ else if (two === "/\\") cuts.push(i++);
243
+ }
244
+ return [first, ...cuts.map((c) => c + 2)].map((a, i) => [a, cuts[i] ?? to]);
245
+ }
246
+
247
+ export const blankStrings = (code) =>
248
+ code.replace(/"(?:[^"\\\n]|\\.)*"/g, (s) => " ".repeat(s.length));
249
+
250
+ export function definitions(text, instancedReads = new Map(), parameters = []) {
251
+ const { code, notes } = splitCommentsKeepingOffsets(text);
252
+ const bare = blankStrings(code);
253
+ const variables = new Set([...parameters, ...tlaVariables(bare)]);
254
+ const defs = new Map();
255
+ const nextItem = /^(?![ \t\n]|\/\\|\\\/)/gm;
256
+ for (const m of bare.matchAll(
257
+ /^(?:LOCAL[ \t]+)?([A-Za-z_]\w*)[ \t]*(?:\([^)]*\)|\[[^\]]*\])?[ \t]*==/gm,
258
+ )) {
259
+ const from = m.index + m[0].length;
260
+ nextItem.lastIndex = from;
261
+ defs.set(m[1], { at: m.index, from, to: nextItem.exec(bare)?.index ?? bare.length });
262
+ }
263
+ const known = (id) => defs.has(id) || instancedReads.has(id);
264
+ const direct = (body) => {
265
+ const plain = body.replace(UNCHANGED_CLAUSE, " ").replace(PRIMED, " ");
266
+ const qualified = (plain.match(QUALIFIED) ?? []).filter(known);
267
+ const ids = plain.replace(QUALIFIED, (q) => (known(q) ? " " : q)).match(IDENTIFIER) ?? [];
268
+ return {
269
+ reads: ids.filter((id) => variables.has(id)),
270
+ writes: [...body.matchAll(PRIMED)].map((m) => m[1]).filter((id) => variables.has(id)),
271
+ acts: PRIMES_OR_UNCHANGED.test(body),
272
+ calls: [...new Set([...ids.filter(known), ...qualified])],
273
+ };
274
+ };
275
+ const own = new Map([
276
+ ...[...instancedReads].map(([name, reads]) => [name, direct(reads.join(" "))]),
277
+ ...[...defs].map(([name, d]) => [name, direct(bare.slice(d.from, d.to))]),
278
+ ]);
279
+ const through = (start) => {
280
+ const out = { reads: new Set(start.reads), writes: new Set(start.writes), acts: start.acts };
281
+ const seen = new Set(start.calls);
282
+ const todo = [...seen];
283
+ while (todo.length) {
284
+ const d = own.get(todo.pop());
285
+ d.reads.forEach((v) => out.reads.add(v));
286
+ d.writes.forEach((v) => out.writes.add(v));
287
+ out.acts ||= d.acts;
288
+ for (const c of d.calls.filter((c) => !seen.has(c))) {
289
+ seen.add(c);
290
+ todo.push(c);
291
+ }
292
+ }
293
+ return out;
294
+ };
295
+ return { code, notes, bare, defs, direct, own, through };
296
+ }
297
+
298
+ export function uncitedConditions(text) {
299
+ const { code, notes, bare, defs, direct, own, through } = definitions(text);
300
+ const starts = [0, ...[...bare.matchAll(/\n/g)].map((m) => m.index + 1)];
301
+ const lineOf = (at) => {
302
+ let lo = 0;
303
+ let hi = starts.length - 1;
304
+ while (lo < hi) {
305
+ const mid = (lo + hi + 1) >> 1;
306
+ if (starts[mid] <= at) lo = mid;
307
+ else hi = mid - 1;
308
+ }
309
+ return lo;
310
+ };
311
+ const lineStart = (at) => starts[lineOf(at)];
312
+ const lines = { bare: bare.split("\n"), notes: notes.split("\n") };
313
+ const cited = (from, to, { above }) => {
314
+ for (let l = from; l <= to; l++) if (CITATION.test(lines.notes[l])) return true;
315
+ for (let l = from - 1; above && !lines.bare[l].trim() && lines.notes[l].trim(); l--)
316
+ if (CITATION.test(lines.notes[l])) return true;
317
+ return false;
318
+ };
319
+
320
+ const found = [];
321
+ for (const [action, d] of defs) {
322
+ if (!own.get(action).acts) continue;
323
+ const changes = through(own.get(action)).writes;
324
+ for (const [a, b] of conjuncts(bare, d.from, d.to, lineStart) ?? []) {
325
+ const part = through(direct(bare.slice(a, b)));
326
+ if (part.acts || [...part.reads].every((v) => changes.has(v))) continue;
327
+ const first = lineOf(a + bare.slice(a, b).search(/\S/));
328
+ const last = lineOf(a + bare.slice(a, b).trimEnd().length - 1);
329
+ if (cited(first, last, { above: first > lineOf(d.at) })) continue;
330
+ found.push({
331
+ line: first + 1,
332
+ action,
333
+ condition: code.slice(a, b).replace(/\s+/g, " ").trim(),
334
+ });
335
+ }
336
+ }
337
+ return found;
338
+ }
339
+
340
+ const TEMPORAL = /\[\]|(?<!<)<>(?!>)|~>|-\+->|\\(?:AA|EE)\b|\b[WS]F_/;
341
+
342
+ export function checkedOnlyInInitialStates(spec, property) {
343
+ const bodies = new Map();
344
+ const modules = new Set();
345
+ const read = (module) => {
346
+ const file = path.join(path.dirname(spec), `${module}.tla`);
347
+ if (modules.has(module) || !fs.existsSync(file)) return;
348
+ modules.add(module);
349
+ const { bare, defs } = definitions(fs.readFileSync(file, "utf8"));
350
+ for (const [name, d] of defs)
351
+ bodies.set(name, [...(bodies.get(name) ?? []), bare.slice(d.from, d.to)]);
352
+ for (const [, names] of bare.matchAll(/\b(?:EXTENDS|INSTANCE)\s+(\w+(?:\s*,\s*\w+)*)/g))
353
+ names.split(/\s*,\s*/).forEach(read);
354
+ };
355
+ read(path.basename(spec, ".tla"));
356
+ const seen = new Set();
357
+ const temporal = (name) => {
358
+ if (seen.has(name)) return false;
359
+ seen.add(name);
360
+ return (bodies.get(name) ?? []).some(
361
+ (body) => TEMPORAL.test(body) || (body.match(IDENTIFIER) ?? []).some(temporal),
362
+ );
363
+ };
364
+ return bodies.has(property) && !temporal(property);
365
+ }
366
+
367
+ function unescapeDot(s) {
368
+ let out = "";
369
+ for (let i = 0; i < s.length; i++) {
370
+ if (s[i] === "\\" && i + 1 < s.length) {
371
+ const n = s[++i];
372
+ out += n === "n" ? "\n" : n;
373
+ } else out += s[i];
374
+ }
375
+ return out;
376
+ }
377
+
378
+ export function parseDot(text) {
379
+ const nodes = new Map();
380
+ const edges = [];
381
+ const loops = [];
382
+ const seen = new Set();
383
+ const LABEL = '"((?:[^"\\\\]|\\\\.)*)"';
384
+ const nodeRe = new RegExp(`^(-?\\d+) \\[label=${LABEL}(.*)\\];?\\s*$`);
385
+ const edgeRe = new RegExp(`^(-?\\d+) -> (-?\\d+)(?: \\[label=${LABEL})?`);
386
+ for (const line of text.split("\n")) {
387
+ let m = edgeRe.exec(line);
388
+ if (m) {
389
+ const e = { from: m[1], to: m[2], action: m[3] ? unescapeDot(m[3]).split("(")[0] : "" };
390
+ const id = `${e.from} ${e.to} ${e.action}`;
391
+ if (seen.has(id)) continue;
392
+ seen.add(id);
393
+ (e.from === e.to ? loops : edges).push(e);
394
+ continue;
395
+ }
396
+ m = nodeRe.exec(line);
397
+ if (m)
398
+ nodes.set(m[1], {
399
+ id: m[1],
400
+ vars: parseState(unescapeDot(m[2])),
401
+ init: /style\s*=\s*filled/.test(m[3]),
402
+ });
403
+ }
404
+ if (nodes.size === 0)
405
+ throw new Error("no states found; dump the graph with TLC -dump dot,actionlabels <file>");
406
+ const missing = [...edges, ...loops].find((e) => !nodes.has(e.from) || !nodes.has(e.to));
407
+ if (missing)
408
+ throw new Error(
409
+ `transition ${missing.from} -> ${missing.to} names a state the file does not define`,
410
+ );
411
+ if (edges.length > 0 && [...edges, ...loops].every((e) => !e.action))
412
+ throw new Error(
413
+ "the graph has no action names; dump it with TLC -dump dot,actionlabels <file>",
414
+ );
415
+ return { nodes, edges, loops };
416
+ }
417
+
418
+ export function parseTraces(text) {
419
+ const traces = [];
420
+ let invariant = null;
421
+ let current = null;
422
+ const lines = text.split("\n");
423
+ const stateAt = (i) => {
424
+ const body = [];
425
+ while (i + 1 < lines.length && lines[i + 1].trim() !== "") body.push(lines[++i]);
426
+ return [parseState(body.join("\n")), i];
427
+ };
428
+ for (let i = 0; i < lines.length; i++) {
429
+ const line = lines[i];
430
+ const inv = /^Error: Invariant (\S+) is violated( by the initial state:)?/.exec(line);
431
+ if (inv) {
432
+ invariant = inv[1];
433
+ if (inv[2]) {
434
+ const [vars, end] = stateAt(i);
435
+ traces.push({ invariant, steps: [{ action: null, vars }] });
436
+ i = end;
437
+ }
438
+ continue;
439
+ }
440
+ if (line.startsWith("Error: ") && !line.startsWith("Error: The behavior up to this point is"))
441
+ invariant = null;
442
+ const head = /^State (\d+): <([^\s>(]+)/.exec(line);
443
+ if (!head) continue;
444
+ const [vars, end] = stateAt(i);
445
+ i = end;
446
+ if (head[1] === "1") {
447
+ current = invariant && { invariant, steps: [] };
448
+ if (current) traces.push(current);
449
+ }
450
+ current?.steps.push({ action: head[2] === "Initial" ? null : head[2], vars });
451
+ }
452
+ return traces;
453
+ }
454
+
455
+ // TLC words a broken PROPERTY by its form: [](state predicate) as an invariant, [][A]_v as an
456
+ // action property, a bare state predicate, which TLC checks only in the initial states, as a
457
+ // property of the initial state, and any other formula as a temporal property.
458
+ export const BROKEN_PROPERTY =
459
+ /^Error: (Invariant .+? is violated|Action property .+? is violated\.|Property .+? is violated by the initial state:|Temporal properties were violated\.)/m;
460
+
461
+ // With -continue, TLC prints "Model checking completed" even after broken invariants, and not
462
+ // when it stopped early.
463
+ export function tlcOutputWarning(text, file) {
464
+ const lines = text.split("\n");
465
+ const broken = [
466
+ ...new Set(
467
+ lines.map((l) => /^Error: Invariant (\S+) is violated/.exec(l)?.[1]).filter(Boolean),
468
+ ),
469
+ ];
470
+ const reported =
471
+ /^Error: (Invariant .+? is violated|Action property .+? is violated\.|Property .+? is violated by the initial state:|The behavior up to this point is|Temporal properties were violated\.|The following behavior constitutes a counter-example:)/;
472
+ const other = lines.find((l) => l.startsWith("Error: ") && !reported.test(l));
473
+ if (other && broken.length) {
474
+ const thrown = /^The exception was a (\S+)\n: (?:([\w$]+(?:\.[\w$]+)+):)?(.*)$/m.exec(text);
475
+ const error = thrown
476
+ ? `${thrown[2] ?? thrown[1]}: ${thrown[3].trim()}`
477
+ : other.slice(7).replace(/\.$/, "");
478
+ const rules = joinAnd(broken);
479
+ return `TLC found that ${rules} ${broken.length === 1 ? "is" : "are"} broken, and then stopped on an error: ${error}. The paths that TLC printed before it stopped are complete, but TLC did not check every state, so the charts can miss states that break ${rules}. A smaller constant can let TLC finish.`;
480
+ }
481
+ if (other)
482
+ throw new Error(
483
+ `${file}: TLC stopped on an error that is not a broken rule: ${other.slice(7)}`,
484
+ );
485
+ // TLC stops at the first broken temporal property, and at an initial state that breaks a
486
+ // PROPERTY, and then does not print that it completed.
487
+ if (
488
+ !/^(Model checking completed\.|Error: (Temporal properties were violated\.|Property .+? is violated by the initial state:))/m.test(
489
+ text,
490
+ )
491
+ )
492
+ throw new Error(
493
+ `${file}: TLC did not finish, so its counterexamples are not all there; read the file`,
494
+ );
495
+ return null;
496
+ }
497
+
498
+ export function wrap(text, width = 26) {
499
+ const words = String(text).split(/\s+/);
500
+ const rows = [];
501
+ let row = "";
502
+ for (const w of words) {
503
+ if (row && (row + " " + w).length > width) {
504
+ rows.push(row);
505
+ row = w;
506
+ } else row = row ? `${row} ${w}` : w;
507
+ }
508
+ if (row) rows.push(row);
509
+ return rows.join("<br/>");
510
+ }
511
+
512
+ export const joinAnd = (items) =>
513
+ items.length < 3 ? items.join(" and ") : `${items.slice(0, -1).join(", ")}, and ${items.at(-1)}`;
514
+
515
+ const OPENS_A_PHRASE = new Set(
516
+ "a an the your my our their its this that to of in on at for with from by and or".split(" "),
517
+ );
518
+ export function joinLabels(labels) {
519
+ if (labels.length < 2) return labels.join(" or ");
520
+ const words = labels.map((l) => String(l).split(" "));
521
+ let n = 0;
522
+ while (words.every((w) => n < w.length - 1 && w[n] === words[0][n])) n++;
523
+ while (n > 0 && OPENS_A_PHRASE.has(words[0][n - 1])) n--;
524
+ if (n < 2) return labels.join(" or ");
525
+ const rest = words.map((w) => w.slice(n).join(" "));
526
+ const list =
527
+ rest.length === 2 ? rest.join(" or ") : `${rest.slice(0, -1).join(", ")}, or ${rest.at(-1)}`;
528
+ return `${words[0].slice(0, n).join(" ")} ${list}`;
529
+ }
530
+
531
+ const unwrap = (s) =>
532
+ s
533
+ .replace(/<br\s*\/?>|#160;/g, " ")
534
+ .replace(/#(quot|34);/g, '"')
535
+ .replace(/\s+/g, " ")
536
+ .trim();
537
+
538
+ export const escapeMermaidQuotes = (s) => String(s).replaceAll('"', "#quot;");
539
+
540
+ export const LEGEND_ID = "legend";
541
+ export const FULL_LABELS = "%% labels in full";
542
+ export const NO_LOOPS = "%% self-loops left out";
543
+ export const BROKEN_MARK = "statechart broken";
544
+
545
+ export function parseMermaid(text) {
546
+ const names = new Map();
547
+ const nevers = [];
548
+ let last = null;
549
+ const newPart = () => ({ children: [], transitions: [] });
550
+ const root = { name: "", parts: [newPart()] };
551
+ const stack = [root];
552
+ const part = () => stack[stack.length - 1].parts.at(-1);
553
+ const display = (id) => names.get(id) ?? id;
554
+ const implicit = new Map();
555
+ const declare = (id) => {
556
+ const prev = implicit.get(id);
557
+ if (prev)
558
+ prev.children.splice(
559
+ prev.children.findIndex((c) => c.name === id),
560
+ 1,
561
+ );
562
+ implicit.set(id, null);
563
+ };
564
+ const mention = (id) => {
565
+ if (id === "[*]" || implicit.has(id)) return;
566
+ part().children.push({ name: id, leaf: true });
567
+ implicit.set(id, part());
568
+ };
569
+ const body = text.replace(/^\s*---[ \t]*\r?\n[^]*?\r?\n---[ \t]*(\r?\n|$)/, "");
570
+ for (const raw of body.split("\n")) {
571
+ const line = raw.trim().replace(/(\[\*\]|[A-Za-z_]\w*):::[\w-]+/g, "$1");
572
+ const when = /^%%\s*when\s+(not\s+)?(.+)$/.exec(line);
573
+ if (when) {
574
+ if (!last) throw new Error(`a %% when line must come right after a transition: ${line}`);
575
+ if (!last.words)
576
+ throw new Error(
577
+ `the guarded step "${last.label}" must end its label with the guard's words in [ ]`,
578
+ );
579
+ last.when = { not: Boolean(when[1]), ids: when[2].split(",").map((s) => s.trim()) };
580
+ last = null;
581
+ continue;
582
+ }
583
+ last = null;
584
+ const never = /^%%\s*never\s+([A-Za-z_][A-Za-z0-9_]*)\s*:\s*(.+?)(?:\s+--\s+(.*))?$/.exec(line);
585
+ if (never) {
586
+ nevers.push({
587
+ name: never[1],
588
+ states: never[2].split("&").map((s) => s.trim()),
589
+ sentence: never[3] ?? "",
590
+ });
591
+ continue;
592
+ }
593
+ if (
594
+ !line ||
595
+ line.startsWith("%%") ||
596
+ /^(stateDiagram|direction|classDef|class |note)/.test(line)
597
+ )
598
+ continue;
599
+ let m = /^state\s+"([^"]*)"\s+as\s+([A-Za-z_]\w*)\s*(\{)?\s*$/.exec(line);
600
+ if (!m) {
601
+ const plain = /^state\s+([A-Za-z_]\w*)\s*(\{)?\s*$/.exec(line);
602
+ if (plain) m = [line, null, plain[1], plain[2]];
603
+ }
604
+ if (m) {
605
+ if (m[1] !== null && !names.has(m[2])) names.set(m[2], unwrap(m[1]));
606
+ declare(m[2]);
607
+ if (m[3]) {
608
+ const node = { name: m[2], parts: [newPart()] };
609
+ part().children.push(node);
610
+ stack.push(node);
611
+ } else part().children.push({ name: m[2], leaf: true });
612
+ continue;
613
+ }
614
+ if (line === "}") {
615
+ if (stack.length === 1) throw new Error("unmatched } in the chart");
616
+ stack.pop();
617
+ continue;
618
+ }
619
+ if (line === "--") {
620
+ stack[stack.length - 1].parts.push(newPart());
621
+ continue;
622
+ }
623
+ m = /^(\[\*\]|[A-Za-z_]\w*)\s*-->\s*(\[\*\]|[A-Za-z_]\w*)(?:\s*:\s*(.*))?$/.exec(line);
624
+ if (m) {
625
+ if (m[2] === "[*]" && m[1] !== "[*]")
626
+ throw new Error(
627
+ `cannot read final state in "${line}"; draw the step to the state the reader sees next`,
628
+ );
629
+ mention(m[1]);
630
+ mention(m[2]);
631
+ const said = m[3] ? unwrap(m[3]) : "";
632
+ const label = said.replace(/\s*\[[^\]]*\]/g, "");
633
+ last = { from: m[1], to: m[2], label, words: label !== said };
634
+ part().transitions.push(last);
635
+ continue;
636
+ }
637
+ m = /^([A-Za-z_]\w*)\s*:\s*(.*)$/.exec(line);
638
+ if (m) {
639
+ if (!names.has(m[1])) names.set(m[1], unwrap(m[2]));
640
+ if (implicit.get(m[1]) === null) continue;
641
+ declare(m[1]);
642
+ part().children.push({ name: m[1], leaf: true });
643
+ continue;
644
+ }
645
+ throw new Error(`cannot read chart line: ${line}`);
646
+ }
647
+ if (stack.length !== 1) throw new Error("the chart has an unclosed {");
648
+ if (root.parts.length > 1)
649
+ throw new Error(
650
+ "the chart has -- outside a composite; to make parallel regions, split one top-level composite with --",
651
+ );
652
+
653
+ function region(p) {
654
+ const out = { states: new Set(), initial: new Set(), transitions: [] };
655
+ const info = new Map();
656
+ const declaredInRegion = new Set();
657
+ (function collect(q) {
658
+ for (const c of q.children) {
659
+ if (c.name === LEGEND_ID) continue;
660
+ declaredInRegion.add(c.name);
661
+ if (!c.leaf) for (const sub of c.parts) collect(sub);
662
+ }
663
+ })(p);
664
+ const transitions = [];
665
+ const lookup = (id) => {
666
+ const found = info.get(id);
667
+ if (!found) throw new Error(`transition names ${id}, which is not in this region`);
668
+ return found;
669
+ };
670
+ function states(q, prefix, depth) {
671
+ const local = q.children.filter((c) => c.name !== LEGEND_ID);
672
+ for (const t of q.transitions) {
673
+ for (const end of [t.from, t.to]) {
674
+ if (end === "[*]" || declaredInRegion.has(end)) continue;
675
+ declaredInRegion.add(end);
676
+ local.push({ name: end, leaf: true });
677
+ }
678
+ }
679
+ const all = [];
680
+ for (const c of local) {
681
+ if (info.has(c.name)) {
682
+ if (local.indexOf(c) !== local.findIndex((x) => x.name === c.name)) continue;
683
+ throw new Error(`state id ${c.name} is declared in two places`);
684
+ }
685
+ const full = `${prefix}${display(c.name)}`;
686
+ if (c.leaf) {
687
+ if (out.states.has(full))
688
+ throw new Error(`two states have the caption "${full}"; give each its own caption`);
689
+ out.states.add(full);
690
+ info.set(c.name, { all: [full], initial: [full], depth });
691
+ all.push(full);
692
+ continue;
693
+ }
694
+ if (c.parts.length > 1)
695
+ throw new Error(
696
+ `${display(c.name)} splits into regions below the top level; make each region a top-level region`,
697
+ );
698
+ const inner = states(c.parts[0], `${full}.`, depth + 1);
699
+ const initial = c.parts[0].transitions
700
+ .filter((t) => t.from === "[*]")
701
+ .flatMap((t) => lookup(t.to).initial);
702
+ if (initial.length === 0)
703
+ throw new Error(
704
+ `${display(c.name)} has no [*] --> start, so a transition into it has nowhere to land`,
705
+ );
706
+ info.set(c.name, { all: inner, initial, depth });
707
+ all.push(...inner);
708
+ }
709
+ for (const t of q.transitions) if (t.from !== "[*]" && t.to !== "[*]") transitions.push(t);
710
+ return all;
711
+ }
712
+ states(p, "", 0);
713
+ for (const t of p.transitions)
714
+ if (t.from === "[*]") for (const s of lookup(t.to).initial) out.initial.add(s);
715
+ transitions.sort((a, b) => lookup(b.from).depth - lookup(a.from).depth);
716
+ const handled = new Set();
717
+ let pending = [];
718
+ let depth = null;
719
+ for (const t of transitions) {
720
+ const from = lookup(t.from);
721
+ if (from.depth !== depth) {
722
+ for (const h of pending) handled.add(h);
723
+ pending = [];
724
+ depth = from.depth;
725
+ }
726
+ for (const f of from.all) {
727
+ const key = `${f}\u0000${t.label}`;
728
+ if (t.label && handled.has(key)) continue;
729
+ for (const to of lookup(t.to).initial)
730
+ out.transitions.push({
731
+ from: f,
732
+ to,
733
+ label: t.label,
734
+ ...(t.when ? { when: t.when } : {}),
735
+ });
736
+ if (t.label) pending.push(key);
737
+ }
738
+ }
739
+ return [out, info];
740
+ }
741
+
742
+ const owners = new Map();
743
+ const regions = {};
744
+ const top = root.parts[0];
745
+ const machine = top.children.filter((c) => c.name !== LEGEND_ID);
746
+ const parallel =
747
+ machine.length === 1 &&
748
+ !machine[0].leaf &&
749
+ machine[0].parts.length > 1 &&
750
+ top.transitions.every((t) => t.from === "[*]");
751
+ if (parallel) {
752
+ for (const p of machine[0].parts) {
753
+ const [only] = p.children;
754
+ const holder =
755
+ p.children.length === 1 &&
756
+ !only.leaf &&
757
+ p.transitions.every((t) => t.from === "[*]" && t.to === only.name)
758
+ ? only
759
+ : null;
760
+ if (holder && holder.parts.length > 1)
761
+ throw new Error(`${display(holder.name)} splits into regions below the top level`);
762
+ const name = holder ? display(holder.name) : `Region${Object.keys(regions).length + 1}`;
763
+ const [r, ids] = region(holder ? holder.parts[0] : p);
764
+ regions[name] = r;
765
+ for (const [id, { all }] of ids) owners.set(id, { name, all });
766
+ }
767
+ } else {
768
+ const [r, ids] = region(top);
769
+ regions.Main = r;
770
+ for (const [id, { all }] of ids) owners.set(id, { name: "Main", all });
771
+ }
772
+ for (const [name, r] of Object.entries(regions))
773
+ for (const t of r.transitions) {
774
+ if (!t.when) continue;
775
+ if (!parallel)
776
+ throw new Error("a %% when line guards a step on other regions, and the chart has none");
777
+ const states = {};
778
+ for (const id of t.when.ids) {
779
+ const at = owners.get(id);
780
+ if (!at)
781
+ throw new Error(`a %% when line names ${id}, which is not a state id in the chart`);
782
+ if (at.name === name)
783
+ throw new Error(
784
+ `a %% when line names ${display(id)}, which is in the step's own region; a guard reads other regions`,
785
+ );
786
+ states[at.name] = [...new Set([...(states[at.name] ?? []), ...at.all])].sort();
787
+ }
788
+ t.guard = { not: t.when.not, states };
789
+ delete t.when;
790
+ }
791
+ return { regions, nevers };
792
+ }
793
+
794
+ function guardHolds(guard, at) {
795
+ if (!guard) return true;
796
+ const hits = Object.entries(guard.states).map(([r, states]) => states.includes(at[r]));
797
+ return guard.not ? !hits.some(Boolean) : hits.every(Boolean);
798
+ }
799
+
800
+ export function productOf(regions, { loops = true } = {}) {
801
+ const names = Object.keys(regions);
802
+ const rs = Object.values(regions);
803
+ const join = (parts) => parts.join(" | ");
804
+ const byLabel = new Map();
805
+ const unlabeled = [];
806
+ rs.forEach((r, ri) => {
807
+ for (const t of r.transitions) {
808
+ if (!t.label) {
809
+ unlabeled.push({ ri, from: t.from, to: t.to, guard: t.guard });
810
+ continue;
811
+ }
812
+ if (!byLabel.has(t.label))
813
+ byLabel.set(
814
+ t.label,
815
+ rs.map(() => new Map()),
816
+ );
817
+ const moves = byLabel.get(t.label)[ri];
818
+ if (!moves.has(t.from)) moves.set(t.from, []);
819
+ moves.get(t.from).push({ to: t.to, guard: t.guard });
820
+ }
821
+ });
822
+ let starts = [[]];
823
+ for (const r of rs) starts = starts.flatMap((p) => [...r.initial].map((s) => [...p, s]));
824
+ const out = { states: new Set(), initial: new Set(starts.map(join)), transitions: [] };
825
+ const seen = new Set(starts.map(join));
826
+ const queue = [...starts];
827
+ const step = (from, to, label) => {
828
+ const [a, b] = [join(from), join(to)];
829
+ if (a === b && !loops) return;
830
+ out.transitions.push({ from: a, to: b, label });
831
+ if (!seen.has(b)) {
832
+ seen.add(b);
833
+ queue.push(to);
834
+ }
835
+ };
836
+ while (queue.length) {
837
+ const cur = queue.shift();
838
+ out.states.add(join(cur));
839
+ const at = Object.fromEntries(names.map((n, ri) => [n, cur[ri]]));
840
+ for (const u of unlabeled) {
841
+ if (cur[u.ri] !== u.from || !guardHolds(u.guard, at)) continue;
842
+ const next = [...cur];
843
+ next[u.ri] = u.to;
844
+ step(cur, next, "");
845
+ }
846
+ for (const [label, perRegion] of byLabel) {
847
+ const open = perRegion.map((moves, ri) =>
848
+ (moves.get(cur[ri]) ?? []).filter((m) => guardHolds(m.guard, at)).map((m) => m.to),
849
+ );
850
+ if (open.every((tos) => tos.length === 0)) continue;
851
+ let nexts = [[]];
852
+ open.forEach((tos, ri) => {
853
+ const options = tos.length ? [...new Set(tos)] : [cur[ri]];
854
+ nexts = nexts.flatMap((p) => options.map((s) => [...p, s]));
855
+ });
856
+ for (const next of nexts) step(cur, next, label);
857
+ }
858
+ }
859
+ return out;
860
+ }
861
+
862
+ export function compareCharts(expectedRegions, actualRegions, { loops = true } = {}) {
863
+ const expected = productOf(expectedRegions, { loops });
864
+ const actual = productOf(actualRegions, { loops });
865
+ const edgeSet = (m) => {
866
+ const labeled = new Set();
867
+ const out = new Set();
868
+ for (const t of m.transitions) {
869
+ if (!t.label) continue;
870
+ labeled.add(`${t.from} --> ${t.to}`);
871
+ for (const l of t.label.split(" or ")) out.add(`${t.from} --> ${t.to} : ${l}`);
872
+ }
873
+ for (const t of m.transitions)
874
+ if (!t.label && !labeled.has(`${t.from} --> ${t.to}`)) out.add(`${t.from} --> ${t.to}`);
875
+ return out;
876
+ };
877
+ const minus = (a, b) => [...a].filter((x) => !b.has(x)).sort();
878
+ const unreached = [];
879
+ const regionEntries = Object.entries(expectedRegions);
880
+ if (regionEntries.length === 1) {
881
+ for (const s of regionEntries[0][1].states) if (!expected.states.has(s)) unreached.push(s);
882
+ } else {
883
+ regionEntries.forEach(([name, r], ri) => {
884
+ const seen = new Set([...expected.states].map((s) => s.split(" | ")[ri]));
885
+ for (const s of r.states) if (!seen.has(s)) unreached.push(`${name}: ${s}`);
886
+ });
887
+ }
888
+ return [
889
+ ["states the agreed chart's own steps never reach", unreached.sort()],
890
+ [
891
+ "states only in EXPECTED (no path from the start reaches them in the spec, or the spec renamed them)",
892
+ minus(expected.states, actual.states),
893
+ ],
894
+ ["states only in ACTUAL", minus(actual.states, expected.states)],
895
+ ["start states only in EXPECTED", minus(expected.initial, actual.initial)],
896
+ ["start states only in ACTUAL", minus(actual.initial, expected.initial)],
897
+ [
898
+ "transitions only in EXPECTED (a guard in the spec blocks them, or the spec renamed the step)",
899
+ minus(edgeSet(expected), edgeSet(actual)),
900
+ ],
901
+ ["transitions only in ACTUAL", minus(edgeSet(actual), edgeSet(expected))],
902
+ ].filter(([, xs]) => xs.length > 0);
903
+ }
904
+
905
+ export const STATUS_CLASSES = [
906
+ "classDef works fill:#f4f6f7,stroke:#566573,color:#1b2631",
907
+ "classDef wrong fill:#fdedec,stroke:#c0392b,color:#7b241c",
908
+ "classDef fixed fill:#e9f7ef,stroke:#1e8449,color:#145a32",
909
+ "classDef mixed fill:#fdebd0,stroke:#d35400,color:#7e5109",
910
+ "classDef after fill:#ffffff,stroke:#808b96,color:#566573",
911
+ "classDef worse fill:#f4ecf7,stroke:#7d3c98,color:#4a235a",
912
+ "classDef worksPath fill:#f4f6f7,stroke:#1f3a93,stroke-width:4px,color:#1b2631",
913
+ "classDef wrongPath fill:#fdedec,stroke:#1f3a93,stroke-width:4px,color:#7b241c",
914
+ "classDef fixedPath fill:#e9f7ef,stroke:#1f3a93,stroke-width:4px,color:#145a32",
915
+ "classDef mixedPath fill:#fdebd0,stroke:#1f3a93,stroke-width:4px,color:#7e5109",
916
+ "classDef afterPath fill:#ffffff,stroke:#1f3a93,stroke-width:4px,color:#566573",
917
+ "classDef worsePath fill:#f4ecf7,stroke:#1f3a93,stroke-width:4px,color:#4a235a",
918
+ ];
919
+
920
+ export const GROUP_PALETTE = [
921
+ "#d6eaf8",
922
+ "#fdebd0",
923
+ "#e8daef",
924
+ "#fcf3cf",
925
+ "#d1f2eb",
926
+ "#ede0d4",
927
+ "#f6e0ef",
928
+ ];
929
+ export const PLAIN_FILL = "#f4f6f7";
930
+
931
+ const rgb = (hex) => {
932
+ const h = hex.length === 4 ? [...hex.slice(1)].map((c) => c + c).join("") : hex.slice(1);
933
+ return [0, 2, 4].map((i) => parseInt(h.slice(i, i + 2), 16));
934
+ };
935
+ function wcagRelativeLuminance(hex) {
936
+ const [r, g, b] = rgb(hex).map((c) => {
937
+ const s = c / 255;
938
+ return s <= 0.03928 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4;
939
+ });
940
+ return 0.2126 * r + 0.7152 * g + 0.0722 * b;
941
+ }
942
+ export function groupColorProblem(color) {
943
+ if (typeof color !== "string" || !/^#([0-9a-f]{3}|[0-9a-f]{6})$/i.test(color))
944
+ return 'is not "#rgb" or "#rrggbb"';
945
+ if (wcagRelativeLuminance(color) < 0.5) return "is too dark for the status borders to show on it";
946
+ return null;
947
+ }
948
+
949
+ const BORDERS = {
950
+ Wrong: ["stroke:#c0392b,stroke-width:4px", "#7b241c"],
951
+ Fixed: ["stroke:#1e8449,stroke-width:4px", "#145a32"],
952
+ Mixed: ["stroke:#d35400,stroke-width:4px", "#7e5109"],
953
+ After: ["stroke:#808b96,stroke-width:4px", "#566573"],
954
+ Worse: ["stroke:#7d3c98,stroke-width:4px", "#4a235a"],
955
+ Path: ["stroke:#1f3a93,stroke-width:4px", "#1b2631"],
956
+ };
957
+ export function groupClassDef(base, fill, border) {
958
+ const darker = `#${rgb(fill)
959
+ .map((c) =>
960
+ Math.round(c / 2)
961
+ .toString(16)
962
+ .padStart(2, "0"),
963
+ )
964
+ .join("")}`;
965
+ const [stroke, color] = BORDERS[border] ?? [
966
+ `stroke:${fill === PLAIN_FILL ? "#566573" : darker}`,
967
+ "#1b2631",
968
+ ];
969
+ return `classDef ${base}${border} fill:${fill},${stroke},color:${color}`;
970
+ }