@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,802 @@
1
+ #!/usr/bin/env node
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+ import process from "node:process";
5
+ import { pathToFileURL } from "node:url";
6
+ import {
7
+ FULL_LABELS,
8
+ GROUP_PALETTE,
9
+ LEGEND_ID,
10
+ PLAIN_FILL,
11
+ escapeMermaidQuotes,
12
+ groupClassDef,
13
+ groupColorProblem,
14
+ isRecord,
15
+ joinAnd,
16
+ joinLabels,
17
+ parseDot,
18
+ parseTraces,
19
+ stateKey,
20
+ tlcOutputWarning,
21
+ wrap,
22
+ STATUS_CLASSES,
23
+ } from "./lib.mjs";
24
+ import { args } from "./tlc-to-chart-args.mjs";
25
+ import { ruleBook } from "./rule-book.mjs";
26
+ import { SEP, addGuard, edgeId, frontMatter, guarded } from "./chart-parts.mjs";
27
+ import {
28
+ badStates,
29
+ byOrder,
30
+ diverges,
31
+ firstBad,
32
+ labelStatus,
33
+ readBroken,
34
+ worst,
35
+ } from "./bug-states.mjs";
36
+ import { chartEdges } from "./chart-edges.mjs";
37
+ import { regionsChart } from "./regions-chart.mjs";
38
+ import { mapView } from "./map-view.mjs";
39
+ import { stepsIntoBugs, waysLine } from "./ways-in.mjs";
40
+ import { divergence, replaySecond } from "./second-replay.mjs";
41
+ import { readCheck } from "./read-check.mjs";
42
+
43
+ const role = args.fixedDot ? "fix" : args.againstDot ? "against" : null;
44
+ const L = args.label ?? (role === "fix" ? "the fix" : "the spec");
45
+ const within = role === "fix" ? "with" : "in";
46
+ const cap = (s) => s.charAt(0).toUpperCase() + s.slice(1);
47
+
48
+ const mapping = await import(pathToFileURL(path.resolve(args.map)).href);
49
+ if (typeof mapping.state !== "function")
50
+ throw new Error("the mapping module must export state(vars)");
51
+ if (args.view !== "customer" && typeof mapping.detail !== "function")
52
+ throw new Error(`--view ${args.view} needs detail(vars) in the mapping module`);
53
+
54
+ const rules = new Map();
55
+ const ruleReads = new Map();
56
+ for (const spec of args.specs)
57
+ for (const [name, { sentence, reads }] of ruleBook(spec)) {
58
+ if (sentence && !rules.has(name)) rules.set(name, sentence);
59
+ ruleReads.set(name, new Set([...(ruleReads.get(name) ?? []), ...reads]));
60
+ }
61
+
62
+ const readerActions = new Set(mapping.readerActions ?? []);
63
+ const factsFrom = (enabled) => ({
64
+ enabled,
65
+ settled: [...enabled].every((a) => readerActions.has(a)),
66
+ });
67
+ function keyOf(vars, facts) {
68
+ if (args.view === "developer") return String(mapping.detail(vars, facts));
69
+ const outer = mapping.state(vars, facts);
70
+ if (typeof outer !== "string")
71
+ throw new Error(`state() must return a string; got ${JSON.stringify(outer)}`);
72
+ if (args.view !== "layered") return outer;
73
+ return [outer, String(mapping.detail(vars, facts))]
74
+ .map((part) => part.trim())
75
+ .filter(Boolean)
76
+ .join(SEP);
77
+ }
78
+ function stepLabel(action, before, after, view = args.view) {
79
+ const said = typeof mapping.step === "function" ? mapping.step(action, before, after) : action;
80
+ const named =
81
+ view === "customer" || said === action ? said : said ? `${said} [${action}]` : action;
82
+ const seconds = typeof mapping.timer === "function" ? mapping.timer(action, before, after) : null;
83
+ if (seconds === undefined || seconds === null || seconds === "" || !named) return named;
84
+ if (typeof seconds !== "number" || !Number.isFinite(seconds) || seconds <= 0)
85
+ throw new Error(
86
+ `timer() must return a number of seconds above 0, or nothing; got ${JSON.stringify(seconds)} for ${action}`,
87
+ );
88
+ return `after ${seconds} s, ${named}`;
89
+ }
90
+ function stepGuard(action, before, after) {
91
+ if (typeof mapping.guard !== "function") return "";
92
+ return String(mapping.guard(action, before, after) ?? "")
93
+ .replace(/[[\]]/g, "")
94
+ .trim();
95
+ }
96
+
97
+ const definedLabel = (label, what) => {
98
+ if (typeof label !== "string" || !/\bundefined\b/.test(label)) return label;
99
+ const does = what === "group" ? `puts a state of ${L} in group` : `labels a ${what} of ${L}`;
100
+ throw new Error(
101
+ `the mapping ${does} "${label.replaceAll(SEP, " ")}", so it reads a field ${L} does not have there. Label states and steps only from what both models have.`,
102
+ );
103
+ };
104
+
105
+ function modelOf(graph, guarded = (vars) => vars, checked = (label) => label) {
106
+ const byId = new Map([...graph.nodes.keys()].map((id) => [id, new Set()]));
107
+ for (const e of [...graph.edges, ...graph.loops]) byId.get(e.from).add(e.action);
108
+ const stateKeys = new Map([...graph.nodes.values()].map((n) => [n.id, stateKey(n.vars)]));
109
+ const index = new Map([...stateKeys].map(([id, k]) => [k, byId.get(id)]));
110
+ const facts = (vars) => factsFrom(index.get(stateKey(vars)) ?? new Set());
111
+ const forMap = (vars) => guarded(mapView(vars));
112
+ return {
113
+ graph,
114
+ stateKeys,
115
+ facts,
116
+ key: (vars) => checked(keyOf(forMap(vars), facts(vars)), "state"),
117
+ customer: (vars) => checked(String(mapping.state(forMap(vars), facts(vars))), "state"),
118
+ step: (action, before, after, view) =>
119
+ checked(stepLabel(action, forMap(before), forMap(after), view), "step"),
120
+ guard: (action, before, after) =>
121
+ checked(stepGuard(action, forMap(before), forMap(after)), "step"),
122
+ cause: (vars, invariant) => {
123
+ const why =
124
+ typeof mapping.cause === "function"
125
+ ? mapping.cause(forMap(vars), facts(vars), invariant)
126
+ : null;
127
+ return why === undefined || why === null ? null : checked(String(why), "bug");
128
+ },
129
+ group: (vars) => checked(mapping.group(forMap(vars), facts(vars)), "group"),
130
+ };
131
+ }
132
+
133
+ function quotient(m) {
134
+ const { graph } = m;
135
+ const keys = new Map();
136
+ for (const n of graph.nodes.values()) keys.set(n.id, m.key(n.vars));
137
+ const nodes = new Set(keys.values());
138
+ const initial = new Set(
139
+ [...graph.nodes.values()].filter((n) => n.init).map((n) => keys.get(n.id)),
140
+ );
141
+ const edges = new Map();
142
+ for (const e of [...graph.edges, ...graph.loops]) {
143
+ const from = keys.get(e.from);
144
+ const to = keys.get(e.to);
145
+ if (from === to && !args.loops) continue;
146
+ const id = edgeId(from, to);
147
+ if (!edges.has(id)) edges.set(id, { from, to, counts: new Map(), guards: new Map() });
148
+ const [before, after] = [graph.nodes.get(e.from).vars, graph.nodes.get(e.to).vars];
149
+ const label = m.step(e.action, before, after);
150
+ if (!label) continue;
151
+ edges.get(id).counts.set(label, (edges.get(id).counts.get(label) ?? 0) + 1);
152
+ addGuard(edges.get(id).guards, label, m.guard(e.action, before, after));
153
+ }
154
+ for (const e of edges.values())
155
+ e.labels = new Set([...e.counts].sort((a, b) => b[1] - a[1]).map(([l]) => l));
156
+ const settled = new Set(
157
+ [...graph.nodes.values()].filter((n) => m.facts(n.vars).settled).map((n) => keys.get(n.id)),
158
+ );
159
+ return { nodes, initial, edges, keys, settled };
160
+ }
161
+
162
+ const graph = parseDot(fs.readFileSync(args.dot, "utf8"));
163
+ const main = modelOf(graph);
164
+ const now = quotient(main);
165
+
166
+ const secondDot = args.fixedDot ?? args.againstDot;
167
+ const secondGraph = secondDot ? parseDot(fs.readFileSync(secondDot, "utf8")) : null;
168
+ const rawTraces = readTraces(args.traces);
169
+ const secondTraceFiles = role === "fix" ? args.fixedTraces : args.againstTraces;
170
+ const secondRules = new Set(role === "fix" ? args.fixedRules : args.againstRules);
171
+ const rawSecondTraces = readTraces(secondTraceFiles);
172
+ const traces = firstBad(rawTraces);
173
+ const secondTraces = firstBad(rawSecondTraces);
174
+ const { lacks, extra, guard } = secondGraph
175
+ ? readCheck({ graph, rawTraces, secondGraph, rawSecondTraces, L })
176
+ : {};
177
+ const second = secondGraph && modelOf(secondGraph, guard, definedLabel);
178
+ const secondQ = second && quotient(second);
179
+
180
+ const hiddenStep = (rule, sameLabel, action, before, after) =>
181
+ sameLabel &&
182
+ !readerActions.has(action) &&
183
+ ![...(ruleReads.get(rule) ?? [])].some(
184
+ (v) => v in before && stateKey(before[v]) !== stateKey(after[v]),
185
+ );
186
+
187
+ function readerPath(trace, m, key, view) {
188
+ const last = trace.steps.length - 1;
189
+ const out = [{ key: key(trace.steps[0].vars), label: null, modelStep: 0 }];
190
+ for (let i = 1; i < trace.steps.length; i++) {
191
+ const { action } = trace.steps[i];
192
+ const k = key(trace.steps[i].vars);
193
+ const [before, after] = [trace.steps[i - 1].vars, trace.steps[i].vars];
194
+ const sameLabel = k === out[out.length - 1].key;
195
+ if (i < last && hiddenStep(trace.invariant, sameLabel, action, before, after)) continue;
196
+ const label = m.step(action, before, after, view) || action;
197
+ const guard = m.guard(action, before, after);
198
+ out.push({ key: k, label, guard, action, modelStep: i });
199
+ }
200
+ return out;
201
+ }
202
+
203
+ const mainActions = new Set([...graph.edges, ...graph.loops].map((e) => e.action));
204
+
205
+ function readTraces(files) {
206
+ return files.flatMap((f) => {
207
+ const text = fs.readFileSync(f, "utf8");
208
+ const warning = tlcOutputWarning(text, f);
209
+ if (warning) console.error(`${f}: ${warning}`);
210
+ return parseTraces(text);
211
+ });
212
+ }
213
+ function group(cuts, m) {
214
+ const byId = new Map();
215
+ for (const trace of cuts) {
216
+ const rp = readerPath(trace, m, m.customer);
217
+ const last = trace.steps[trace.steps.length - 1].vars;
218
+ const cause = m.cause(last, trace.invariant);
219
+ const id = `${trace.invariant}${SEP}${cause ?? rp[rp.length - 1].key}`;
220
+ const prev = byId.get(id);
221
+ const ends = new Set([...(prev?.ends ?? []), stateKey(last)]);
222
+ if (
223
+ !prev ||
224
+ rp.length < prev.steps ||
225
+ (rp.length === prev.steps && trace.steps.length < prev.trace.steps.length)
226
+ ) {
227
+ byId.set(id, { id, cause, steps: rp.length, trace, invariant: trace.invariant, ends });
228
+ } else prev.ends = ends;
229
+ }
230
+ return byId;
231
+ }
232
+ const inSecond = (l) => `${within} ${L}, ${l}`;
233
+ const modelEdges = new Map(now.edges);
234
+ if (secondQ)
235
+ for (const [id, e] of secondQ.edges)
236
+ if (!modelEdges.has(id))
237
+ modelEdges.set(id, {
238
+ ...e,
239
+ labels: new Set([...e.labels].map(inSecond)),
240
+ guards: new Map([...e.guards].map(([l, words]) => [inSecond(l), words])),
241
+ secondOnly: true,
242
+ });
243
+ const withPath = (c, m) => {
244
+ const path = readerPath(c.trace, m, m.key);
245
+ return { ...c, path, end: path[path.length - 1].key };
246
+ };
247
+ const classesById = group(traces, main);
248
+ const mainClasses = [...classesById.values()].sort(byOrder).map((c, i) => {
249
+ const bug = withPath(c, main);
250
+ return {
251
+ ...bug,
252
+ second: divergence(
253
+ replaySecond(c.trace, {
254
+ main,
255
+ lacks,
256
+ extra,
257
+ second,
258
+ hiddenStep,
259
+ readerPath,
260
+ mainActions,
261
+ secondRules,
262
+ rawSecondTraces,
263
+ }),
264
+ bug.path,
265
+ i + 1,
266
+ { inSecond, modelEdges },
267
+ ),
268
+ };
269
+ });
270
+ const introduced = second
271
+ ? [...group(secondTraces, second).values()]
272
+ .filter((c) => !classesById.has(c.id))
273
+ .sort(byOrder)
274
+ .map((c) => ({ ...withPath(c, second), second: null, introduced: true }))
275
+ : [];
276
+ const classes = [
277
+ ...stepsIntoBugs(mainClasses, main, rawTraces, args),
278
+ ...stepsIntoBugs(introduced, second, rawSecondTraces, args),
279
+ ];
280
+
281
+ const brokenNow = readBroken(args.broken);
282
+ const statusNow = labelStatus(main, now.keys, badStates(rawTraces, brokenNow));
283
+ const statusSecond = second
284
+ ? labelStatus(
285
+ second,
286
+ secondQ.keys,
287
+ badStates(rawSecondTraces, readBroken(args.fixedBroken ?? args.againstBroken)),
288
+ )
289
+ : () => undefined;
290
+ const statusUnchecked =
291
+ role === "fix"
292
+ ? labelStatus(
293
+ main,
294
+ now.keys,
295
+ badStates(rawTraces, brokenNow, (r) => !secondRules.has(r)),
296
+ )
297
+ : () => undefined;
298
+ const allNodes = new Set([...now.nodes, ...(secondQ?.nodes ?? [])]);
299
+ const initial = new Set([...now.initial, ...(secondQ?.initial ?? [])]);
300
+ const status = (k) => {
301
+ if (role === "against") {
302
+ const s = worst(statusNow(k), statusSecond(k));
303
+ return s === "works" && !now.nodes.has(k) ? "fixed" : s;
304
+ }
305
+ if (role === "fix") {
306
+ const s = worst(statusSecond(k), statusUnchecked(k));
307
+ if (s === "wrong" && !["wrong", "mixed"].includes(statusNow(k))) return "worse";
308
+ return s === "works" && statusNow(k) !== "works" ? "fixed" : s;
309
+ }
310
+ return statusNow(k);
311
+ };
312
+
313
+ const chosen = args.highlight > 0 ? classes[args.highlight - 1] : null;
314
+ if (args.highlight > 0 && classes.length > 0 && !chosen)
315
+ throw new Error(`--highlight ${args.highlight}: only ${classes.length} classes`);
316
+ const onPath = new Set(chosen ? chosen.path.map((p) => p.key) : []);
317
+ if (chosen && diverges(chosen)) onPath.add(chosen.second.key);
318
+
319
+ const coverOff =
320
+ args.cover &&
321
+ ![graph, secondGraph]
322
+ .filter(Boolean)
323
+ .some((g) => [...g.edges, ...g.loops].some((e) => readerActions.has(e.action)))
324
+ ? "no step in TLC's graph is a reader action"
325
+ : null;
326
+ if (coverOff && !args.pathOnly) console.error(`--cover: drew every state, because ${coverOff}`);
327
+ const { edges, pathNumbers, coverEdges } = chartEdges({
328
+ args,
329
+ now,
330
+ secondQ,
331
+ modelEdges,
332
+ classes,
333
+ chosen,
334
+ coverOff,
335
+ });
336
+
337
+ const LEGEND = {
338
+ works: ["legendWorks", mapping.legend?.works ?? (role === "fix" ? "Works today" : "Works")],
339
+ wrong: [
340
+ "legendWrong",
341
+ mapping.legend?.wrong ?? (role === "fix" ? `Still wrong with ${L}` : "Breaks a rule"),
342
+ ],
343
+ fixed: [
344
+ "legendFixed",
345
+ mapping.legend?.fixed ?? (role === "fix" ? `Fixed or new with ${L}` : `Only in ${L}`),
346
+ ],
347
+ mixed: [
348
+ "legendMixed",
349
+ mapping.legend?.mixed ?? `Breaks a rule in some cases${role === "fix" ? ` with ${L}` : ""}`,
350
+ ],
351
+ after: [
352
+ "legendAfter",
353
+ mapping.legend?.after ?? `Reached only after a bug${role === "fix" ? ` with ${L}` : ""}`,
354
+ ],
355
+ ...(role === "fix"
356
+ ? { worse: ["legendWorse", mapping.legend?.worse ?? `Breaks a rule only with ${L}`] }
357
+ : {}),
358
+ };
359
+
360
+ const grouping = typeof mapping.group === "function";
361
+ const groupsOf = new Map();
362
+ const groupOf = (k) => (groupsOf.get(k)?.size === 1 ? [...groupsOf.get(k)][0] : "");
363
+ const groupNames = [];
364
+ const fills = new Map();
365
+ if (grouping) {
366
+ const add = (k, vars, m) => {
367
+ const g = m.group(vars);
368
+ if (g !== undefined && g !== null && typeof g !== "string")
369
+ throw new Error(`group() must return a group name or nothing; got ${JSON.stringify(g)}`);
370
+ if (!groupsOf.has(k)) groupsOf.set(k, new Set());
371
+ groupsOf.get(k).add((g ?? "").replace(/\s+/g, " ").trim());
372
+ };
373
+ for (const n of graph.nodes.values()) add(now.keys.get(n.id), n.vars, main);
374
+ if (second)
375
+ for (const n of second.graph.nodes.values()) add(secondQ.keys.get(n.id), n.vars, second);
376
+
377
+ const given = mapping.groupColors ?? {};
378
+ if (!isRecord(given))
379
+ throw new Error("groupColors must be an object from a group name to a color such as #d6eaf8");
380
+ const statusNames = new Set(
381
+ [
382
+ "Works",
383
+ "Works today",
384
+ "Breaks a rule",
385
+ "Still wrong",
386
+ "Fixed",
387
+ ...Object.values(LEGEND).map(([, t]) => t),
388
+ ].map((t) => t.toLowerCase()),
389
+ );
390
+ const found = new Set([...groupsOf.values()].flatMap((gs) => [...gs]).filter(Boolean));
391
+ for (const g of found)
392
+ if (statusNames.has(g.toLowerCase()))
393
+ throw new Error(
394
+ `group "${g}" has the name of a status; TLC decides the status, so name the group for what the reader sees`,
395
+ );
396
+ groupNames.push(
397
+ ...Object.keys(given).filter((g) => found.has(g)),
398
+ ...[...found].filter((g) => !Object.hasOwn(given, g)).sort(),
399
+ );
400
+ for (const [g, color] of Object.entries(given)) {
401
+ if (color === undefined || color === null || color === "") continue;
402
+ const why = groupColorProblem(color);
403
+ if (why) throw new Error(`groupColors["${g}"] ${JSON.stringify(color)} ${why}`);
404
+ if (found.has(g)) fills.set(g, color.toLowerCase());
405
+ }
406
+ const free = GROUP_PALETTE.filter((c) => ![...fills.values()].includes(c));
407
+ for (const g of groupNames) {
408
+ if (fills.has(g)) continue;
409
+ if (!free.length)
410
+ throw new Error(
411
+ `the mapping has ${groupNames.length} groups, more than the ${GROUP_PALETTE.length} colors of the palette; give the others a color in groupColors`,
412
+ );
413
+ fills.set(g, free.shift());
414
+ }
415
+ }
416
+ const mixedGroups = [...groupsOf].filter(([, gs]) => gs.size > 1).map(([k]) => k);
417
+
418
+ const BORDER = {
419
+ works: "",
420
+ wrong: "Wrong",
421
+ fixed: "Fixed",
422
+ mixed: "Mixed",
423
+ after: "After",
424
+ worse: "Worse",
425
+ };
426
+ const classFor = (g, s, path) =>
427
+ grouping
428
+ ? `${g ? `group${groupNames.indexOf(g)}` : "plain"}${BORDER[s] || (path ? "Path" : "")}`
429
+ : `${s}${path ? "Path" : ""}`;
430
+ const drawnState = (g, s, path = false) => ({ group: g, status: s, cls: classFor(g, s, path) });
431
+ const groupCounts = (drawn) =>
432
+ groupNames
433
+ .map((g) => ({
434
+ name: g,
435
+ color: fills.get(g),
436
+ states: drawn.filter((d) => d.group === g).length,
437
+ }))
438
+ .filter((g) => g.states > 0);
439
+
440
+ function head(drawn) {
441
+ const out = [...frontMatter(args.title), "stateDiagram-v2"];
442
+ if (role) out.push(` %% second model (${role}): ${L}`);
443
+ if (args.fullLabels) out.push(` ${FULL_LABELS}`);
444
+ const used = Object.keys(LEGEND).filter((s) => drawn.some((d) => d.status === s));
445
+ const legend = grouping
446
+ ? [
447
+ ...groupCounts(drawn).map(({ name }) => {
448
+ const i = groupNames.indexOf(name);
449
+ return [`legendGroup${i}`, name, `group${i}`];
450
+ }),
451
+ ...used.filter((s) => BORDER[s]).map((s) => [...LEGEND[s], `plain${BORDER[s]}`]),
452
+ ]
453
+ : used.map((s) => [...LEGEND[s], s]);
454
+ if (grouping) {
455
+ const styleClasses = new Set([...drawn.map((d) => d.cls), ...legend.map(([, , c]) => c)]);
456
+ const bases = [["plain", PLAIN_FILL], ...groupNames.map((g, i) => [`group${i}`, fills.get(g)])];
457
+ for (const [base, fill] of bases)
458
+ for (const border of [...Object.values(BORDER), "Path"])
459
+ if (styleClasses.has(base + border)) out.push(` ${groupClassDef(base, fill, border)}`);
460
+ } else for (const c of STATUS_CLASSES) out.push(` ${c}`);
461
+ if (legend.length) {
462
+ out.push(` state "Legend" as ${LEGEND_ID} {`);
463
+ for (const [id, text] of legend) out.push(` state "${escapeMermaidQuotes(text)}" as ${id}`);
464
+ out.push(" }");
465
+ for (const [id, , c] of legend) out.push(` class ${id} ${c}`);
466
+ }
467
+ return out;
468
+ }
469
+
470
+ const regions = args.regions
471
+ ? regionsChart({
472
+ states: allNodes,
473
+ starts: [...initial],
474
+ edges,
475
+ loops: Boolean(args.loops),
476
+ classes,
477
+ proofPath: args.proof,
478
+ allPath: args.regionsAll,
479
+ mapping,
480
+ status,
481
+ LEGEND,
482
+ groupOf,
483
+ drawnState,
484
+ groupCounts,
485
+ head,
486
+ })
487
+ : null;
488
+ if (regions?.why) console.error(`regions: not drawn, because ${regions.why}`);
489
+
490
+ const flatStates =
491
+ args.pathOnly && chosen
492
+ ? onPath
493
+ : coverEdges
494
+ ? new Set([
495
+ ...initial,
496
+ ...[...coverEdges].flatMap((id) => [edges.get(id).from, edges.get(id).to]),
497
+ ])
498
+ : allNodes;
499
+ const sorted = [...flatStates].sort();
500
+ const drawn = sorted.map((k) => drawnState(groupOf(k), status(k), onPath.has(k)));
501
+
502
+ const boxes = new Map();
503
+ const boxOf = new Map();
504
+ const inBox = (k, box) => {
505
+ if (!boxes.has(box)) boxes.set(box, []);
506
+ boxes.get(box).push(k);
507
+ };
508
+ if (args.view === "layered" && !args.flat) {
509
+ for (const k of sorted) if (k.includes(SEP)) inBox(k, k.slice(0, k.indexOf(SEP)));
510
+ } else if (args.view === "customer" && !args.flat) {
511
+ for (const k of sorted) {
512
+ const at = k.indexOf(": ");
513
+ if (at > 0 && at + 2 < k.length) inBox(k, k.slice(0, at));
514
+ }
515
+ for (const [box, members] of boxes) if (members.length < 2) boxes.delete(box);
516
+ }
517
+ const boxIds = new Map([...boxes.keys()].map((box, i) => [box, `g${i}`]));
518
+ for (const [box, members] of boxes) for (const k of members) boxOf.set(k, box);
519
+ const declared = [...[...boxes.values()].flat(), ...sorted.filter((k) => !boxOf.has(k))];
520
+ const stateIds = new Map(declared.map((k, i) => [k, `s${i}`]));
521
+ const idOf = (k) => stateIds.get(k);
522
+ const show = (k) => (args.view === "layered" ? k.replace(SEP, ": ") : k);
523
+ const caption = (k) => (boxOf.has(k) ? show(k).slice(boxOf.get(k).length + 2) : show(k));
524
+
525
+ function numbered(steps) {
526
+ const runs = [];
527
+ for (const { n, shown } of steps) {
528
+ const last = runs.at(-1);
529
+ if (last?.shown === shown && last.n.at(-1) === n - 1) last.n.push(n);
530
+ else runs.push({ shown, n: [n] });
531
+ }
532
+ return runs.map(({ shown, n }) => `${n.join(", ")}. ${shown}`);
533
+ }
534
+ const drawnLabels = new Set();
535
+ const arrows = new Map();
536
+ for (const [id, e] of edges) {
537
+ if (!flatStates.has(e.from) || !flatStates.has(e.to)) continue;
538
+ if (args.pathOnly && chosen && !pathNumbers.has(id) && id !== chosen.second?.edge) continue;
539
+ if (coverEdges && !coverEdges.has(id)) continue;
540
+ const pair = e.apart ? id : edgeId(e.from, e.to);
541
+ if (!arrows.has(pair))
542
+ arrows.set(pair, { from: e.from, to: e.to, groups: [[], [], []], plain: true, labels: [] });
543
+ const a = arrows.get(pair);
544
+ const [numbers, current, other] = a.groups;
545
+ const all = [...e.labels];
546
+ const shown = all.map((l) => guarded(e.guards, l));
547
+ const said = args.fullLabels ? shown.join(" or ") : joinLabels(shown);
548
+ const p = pathNumbers.get(id);
549
+ const chosenStep = chosen?.second && id === chosen.second.edge;
550
+ if (p) numbers.push(...numbered(p));
551
+ else if (!e.secondOnly) current.push(said);
552
+ else other.push(args.pathOnly && chosenStep ? chosen.second.text : said);
553
+ if (chosenStep && !e.secondOnly) other.push(chosen.second.text);
554
+ if (p || chosenStep || id.endsWith(`${SEP}second`)) a.plain = false;
555
+ a.labels.push(...all);
556
+ for (const l of p ? p.map((s) => s.label) : all) drawnLabels.add(l);
557
+ }
558
+ const transitions = [...arrows.values()].map(({ from, to, groups, plain, labels }) => ({
559
+ from,
560
+ to,
561
+ // ";" ends a Mermaid statement, even inside a transition label.
562
+ text: groups
563
+ .map((g) =>
564
+ g
565
+ .filter(Boolean)
566
+ .map((l) => wrap(l.replaceAll(";", ","), 30))
567
+ .join("<br/>"),
568
+ )
569
+ .filter(Boolean)
570
+ .join("<br/>#160;<br/>"),
571
+ plain,
572
+ labels: labels.sort(),
573
+ }));
574
+ const fromBox = [];
575
+ const folded = new Set();
576
+ const plainNames = new Set(sorted.filter((k) => !boxOf.has(k)).map(caption));
577
+ for (const [box, members] of boxes) {
578
+ if (plainNames.has(box)) continue;
579
+ const mine = new Set(members);
580
+ const byStep = new Map();
581
+ for (const l of transitions) {
582
+ if (!l.plain || !mine.has(l.from) || mine.has(l.to)) continue;
583
+ const step = `${l.to}${SEP}${l.text}${SEP}${l.labels.join(SEP)}`;
584
+ if (!byStep.has(step)) byStep.set(step, []);
585
+ byStep.get(step).push(l);
586
+ }
587
+ for (const same of byStep.values()) {
588
+ if (new Set(same.map((l) => l.from)).size !== members.length) continue;
589
+ for (const l of same) folded.add(l);
590
+ fromBox.push({ ...same[0], box });
591
+ }
592
+ }
593
+
594
+ const out = head(drawn);
595
+ for (const [box, members] of boxes) {
596
+ out.push(` state "${escapeMermaidQuotes(wrap(box))}" as ${boxIds.get(box)} {`);
597
+ for (const k of members)
598
+ out.push(` state "${escapeMermaidQuotes(wrap(caption(k)))}" as ${idOf(k)}`);
599
+ out.push(" }");
600
+ }
601
+ for (const k of sorted)
602
+ if (!boxOf.has(k)) out.push(` state "${escapeMermaidQuotes(wrap(caption(k)))}" as ${idOf(k)}`);
603
+ for (const k of sorted)
604
+ if (initial.has(k) && (!args.pathOnly || k === chosen?.path[0].key))
605
+ out.push(` [*] --> ${idOf(k)}`);
606
+ const arrow = (from, l) => ` ${from} --> ${idOf(l.to)}${l.text ? ` : ${l.text}` : ""}`;
607
+ for (const l of fromBox) out.push(arrow(boxIds.get(l.box), l));
608
+ for (const l of transitions) if (!folded.has(l)) out.push(arrow(idOf(l.from), l));
609
+ sorted.forEach((k, i) => out.push(` class ${idOf(k)} ${drawn[i].cls}`));
610
+ const mermaid = regions?.mermaid ?? out.join("\n") + "\n";
611
+ if (args.out) fs.writeFileSync(args.out, mermaid);
612
+ else process.stdout.write(mermaid);
613
+
614
+ const ruleOf = (c) => rules.get(c.invariant) ?? `\`${c.invariant}\``;
615
+ function secondLine(c) {
616
+ const W = cap(within);
617
+ if (c.introduced) return `Only ${L} has this bug. The steps above happen ${within} ${L}.`;
618
+ const s = c.second;
619
+ if (!s) return null;
620
+ if (s.sameEnd)
621
+ return !s.checked
622
+ ? `${W} ${L} you see the same states and steps, but ${L} does not check this rule.`
623
+ : s.ruleHolds
624
+ ? `${W} ${L} you see the same states and steps, yet the rule holds, so the state labels do not show what ${L} changes.`
625
+ : `${W} ${L} you see the same states and steps, so ${L} does not change this path.`;
626
+ if (s.reason) return `${cap(L)} cannot take these steps: it ${s.reason}.`;
627
+ const outcome = s.disabled
628
+ ? "cannot happen, so you stay at"
629
+ : s.key === s.from
630
+ ? "keeps you at"
631
+ : "takes you to";
632
+ const as = s.own && s.own !== s.said ? ` as "${s.own}"` : "";
633
+ if (s.hidden) {
634
+ const when = s.step ? `after step ${s.step}` : "before your first step";
635
+ return `${W} ${L}, ${when}, "${s.said}" happens${as} and ${outcome}: ${show(s.key)}.`;
636
+ }
637
+ const happens = as ? `happens${as} and ` : "";
638
+ return `${W} ${L}, step ${s.step} (${s.said}) ${happens}${outcome}: ${show(s.key)}.`;
639
+ }
640
+ const listedIn = (c) =>
641
+ Object.keys(args.listed.breaks).filter((cfg) => args.listed.breaks[cfg] === c.invariant);
642
+ function listedLine(c) {
643
+ const { spec, breaks } = args.listed;
644
+ const cfgs = listedIn(c);
645
+ if (cfgs.length)
646
+ return `Listed: ${joinAnd(cfgs)}, ${cfgs.length === 1 ? "a config" : "configs"} of ${spec}, expect${cfgs.length === 1 ? "s" : ""} TLC to break this rule.`;
647
+ const others = [...new Set(Object.values(breaks))].sort().map((r) => `\`${r}\``);
648
+ return `Not listed: no config of ${spec} expects TLC to break this rule. ${others.length ? `Its configs expect TLC to break ${joinAnd(others)}.` : "None of its configs expects TLC to break a rule."}`;
649
+ }
650
+ if (args.story) {
651
+ const lines = [];
652
+ const header = role === "fix" ? `New with ${L}: ` : `Only in ${L}: `;
653
+ classes.forEach((c, i) => {
654
+ lines.push(`## ${i + 1}. ${c.introduced ? header : ""}${show(c.end)}`, "");
655
+ lines.push(`Rule broken: ${ruleOf(c)}`);
656
+ if (c.cause) lines.push(`Why: ${c.cause.replace(/\.$/, "")}.`);
657
+ if (args.listed) lines.push(listedLine(c));
658
+ const ways = waysLine(c);
659
+ lines.push(...(ways ? [ways] : []), "");
660
+ lines.push(`0. Start: ${show(c.path[0].key)}`);
661
+ const m = c.introduced ? second : main;
662
+ c.path.slice(1).forEach((p, j) => {
663
+ const from = c.path[j].modelStep + 1;
664
+ const hidden = c.trace.steps
665
+ .slice(from, p.modelStep)
666
+ .map(
667
+ (s, h) =>
668
+ m.step(s.action, c.trace.steps[from + h - 1].vars, s.vars, "customer") || s.action,
669
+ );
670
+ if (hidden.length)
671
+ lines.push(
672
+ ` (${hidden.length} hidden step${hidden.length === 1 ? "" : "s"}: ${[...new Set(hidden)].join(", ")})`,
673
+ );
674
+ lines.push(`${j + 1}. ${cap(p.label)}. Now: ${show(p.key)}`);
675
+ });
676
+ lines.push("");
677
+ const said = secondLine(c);
678
+ if (said) lines.push(said, "");
679
+ });
680
+ fs.writeFileSync(args.story, lines.join("\n"));
681
+ }
682
+ const CODE_LIKE = /\b[a-z]{2,}[A-Z][a-z]|\b\w+_\w+\b|\b[A-Za-z_]\w*\(|\b[A-Z][a-z]+[A-Z][a-z]/;
683
+ const codeLike = [
684
+ ...new Set([
685
+ ...(args.view === "customer" ? [...sorted, ...drawnLabels] : []),
686
+ ...classes.map((c) => c.cause).filter(Boolean),
687
+ ]),
688
+ ].filter((t) => CODE_LIKE.test(t));
689
+ const secondOf = (c) =>
690
+ c.introduced
691
+ ? role === "fix"
692
+ ? `new with ${L}`
693
+ : `only in ${L}`
694
+ : c.second
695
+ ? c.second.sameEnd
696
+ ? !c.second.checked
697
+ ? "same states, rule not checked"
698
+ : c.second.ruleHolds
699
+ ? "same states, but the rule holds"
700
+ : "unchanged"
701
+ : (c.second.reason ?? show(c.second.key))
702
+ : null;
703
+ function coverSummary() {
704
+ if (coverOff || regions?.mermaid)
705
+ return { drawn: false, why: coverOff ?? "the overview has parallel regions" };
706
+ const pairs = new Set();
707
+ const missed = new Map();
708
+ for (const [id, e] of edges) {
709
+ const pair = e.apart ? id : edgeId(e.from, e.to);
710
+ pairs.add(pair);
711
+ if (coverEdges.has(id)) continue;
712
+ if (!missed.has(pair)) missed.set(pair, { from: e.from, to: e.to, labels: [] });
713
+ missed.get(pair).labels.push(...[...e.labels].map((l) => guarded(e.guards, l)));
714
+ }
715
+ return {
716
+ drawn: true,
717
+ states: allNodes.size,
718
+ transitions: pairs.size,
719
+ leftOutStates: [...allNodes]
720
+ .filter((k) => !flatStates.has(k))
721
+ .map(show)
722
+ .sort(),
723
+ leftOutTransitions: [...missed.values()]
724
+ .map((m) => `${show(m.from)} --> ${show(m.to)}: ${joinLabels(m.labels)}`)
725
+ .sort(),
726
+ };
727
+ }
728
+ const summary = {
729
+ view: args.view,
730
+ ...(role
731
+ ? {
732
+ second: {
733
+ role,
734
+ label: L,
735
+ lacks: lacks.map((p) => p.join(".")),
736
+ ...(args.listed ? { listed: args.listed } : {}),
737
+ },
738
+ }
739
+ : {}),
740
+ modelStates: graph.nodes.size,
741
+ modelTransitions: graph.edges.length,
742
+ chartStates: regions?.mermaid ? regions.states[0] + regions.states[1] : flatStates.size,
743
+ chartTransitions: regions?.mermaid ? regions.transitions : transitions.length,
744
+ chartStatus: regions?.mermaid
745
+ ? regions.status
746
+ : Object.fromEntries(
747
+ Object.keys(LEGEND).map((s) => [
748
+ s,
749
+ [...(args.pathOnly && chosen ? onPath : allNodes)].filter((k) => status(k) === s).length,
750
+ ]),
751
+ ),
752
+ mixedStatus: [...allNodes]
753
+ .filter((k) => status(k) === "mixed")
754
+ .map(show)
755
+ .sort(),
756
+ ...(args.cover ? { cover: coverSummary() } : {}),
757
+ ...(grouping
758
+ ? {
759
+ chartGroups: regions?.mermaid ? regions.groups : groupCounts(drawn),
760
+ mixedGroups: mixedGroups.map(show).sort(),
761
+ }
762
+ : {}),
763
+ ...(regions
764
+ ? {
765
+ regions: regions.mermaid
766
+ ? {
767
+ drawn: true,
768
+ states: regions.states,
769
+ transitions: regions.transitions,
770
+ all: regions.all,
771
+ flat: regions.flat,
772
+ }
773
+ : { drawn: false, why: regions.why },
774
+ }
775
+ : {}),
776
+ classes: classes.map((c, i) => ({
777
+ n: i + 1,
778
+ end: show(c.end),
779
+ invariant: c.invariant,
780
+ rule: rules.get(c.invariant) ?? null,
781
+ cause: c.cause,
782
+ readerSteps: c.path.length - 1,
783
+ modelSteps: c.trace.steps.length,
784
+ counterexamples: c.ends.size,
785
+ lastSteps: c.lastSteps,
786
+ startsHere: c.startsHere,
787
+ [role ?? "fix"]: secondOf(c),
788
+ ...(args.listed ? { listed: listedIn(c) } : {}),
789
+ })),
790
+ codeLike,
791
+ };
792
+ if (args.summary) fs.writeFileSync(args.summary, JSON.stringify(summary, null, 2) + "\n");
793
+ const extraBugs = introduced.length
794
+ ? `, ${introduced.length} ${role === "fix" ? `new with ${L}` : `only in ${L}`}`
795
+ : "";
796
+ const drawnAs = regions?.mermaid
797
+ ? ` of ${regions.all} in 2 regions (proved against ${regions.flat.states} states, ${regions.flat.transitions} transitions)`
798
+ : "";
799
+ console.error(
800
+ `chart: ${summary.chartStates} states, ${summary.chartTransitions} transitions${drawnAs} from ${summary.modelStates} model states; ` +
801
+ `${mainClasses.length} bug(s)${extraBugs}`,
802
+ );