@olierjs/fugue-cli 0.0.0-stage → 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.
- package/LICENSE +201 -0
- package/README.md +99 -2
- package/THIRD_PARTY_NOTICES +467 -0
- package/bin/fugue-cli.mjs +23 -0
- package/dist/darwin-arm64/fugue-cli +0 -0
- package/dist/darwin-x64/fugue-cli +0 -0
- package/dist/linux-arm64/fugue-cli +0 -0
- package/dist/linux-x64/fugue-cli +0 -0
- package/dist/win32-arm64/fugue-cli.exe +0 -0
- package/dist/win32-x64/fugue-cli.exe +0 -0
- package/package.json +27 -4
- package/scripts/postinstall.mjs +13 -0
- package/scripts/skills.mjs +104 -0
- package/skills/fugue-show-me/SKILL.md +16 -0
- package/skills/fugue-stack/SKILL.md +10 -0
- package/skills/plan-with-remote/SKILL.md +60 -0
- package/skills/plan-with-remote/references/configuration.md +43 -0
- package/skills/plan-with-remote/scripts/client.mjs +61 -0
- package/skills/plan-with-remote/scripts/config.mjs +53 -0
- package/skills/plan-with-remote/scripts/planning.mjs +279 -0
- package/skills/plan-with-remote/scripts/protocol.mjs +147 -0
- package/skills/plan-with-remote/scripts/service.mjs +146 -0
- package/skills/plan-with-remote/scripts/storage.mjs +74 -0
- package/skills/show-me/SKILL.md +128 -0
- package/skills/show-me/agents/openai.yaml +2 -0
- package/skills/statechart/SKILL.md +36 -0
- package/skills/statechart/scripts/bug-states.mjs +81 -0
- package/skills/statechart/scripts/chart-edges.mjs +68 -0
- package/skills/statechart/scripts/chart-parts.mjs +21 -0
- package/skills/statechart/scripts/chart-to-tla.mjs +205 -0
- package/skills/statechart/scripts/compare-charts.mjs +74 -0
- package/skills/statechart/scripts/lib.mjs +970 -0
- package/skills/statechart/scripts/map-view.mjs +92 -0
- package/skills/statechart/scripts/property-loop.mjs +60 -0
- package/skills/statechart/scripts/read-check.mjs +79 -0
- package/skills/statechart/scripts/regions-chart.mjs +307 -0
- package/skills/statechart/scripts/render.mjs +531 -0
- package/skills/statechart/scripts/rule-book.mjs +84 -0
- package/skills/statechart/scripts/second-replay.mjs +122 -0
- package/skills/statechart/scripts/snapshot.mjs +74 -0
- package/skills/statechart/scripts/spec-text.mjs +126 -0
- package/skills/statechart/scripts/statechart-help.mjs +350 -0
- package/skills/statechart/scripts/statechart.mjs +903 -0
- package/skills/statechart/scripts/test/band.mmd +15 -0
- package/skills/statechart/scripts/test/regions.mmd +62 -0
- package/skills/statechart/scripts/test/statechart.test.mjs +6854 -0
- package/skills/statechart/scripts/test/sync.mmd +19 -0
- package/skills/statechart/scripts/test/upload.mmd +16 -0
- package/skills/statechart/scripts/tlc-to-chart-args.mjs +52 -0
- package/skills/statechart/scripts/tlc-to-chart.mjs +802 -0
- package/skills/statechart/scripts/tlc-tools.mjs +69 -0
- package/skills/statechart/scripts/ways-in.mjs +69 -0
- package/upstream-skills.json +18 -0
|
@@ -0,0 +1,903 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { constants } from "node:buffer";
|
|
3
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
4
|
+
import fs from "node:fs";
|
|
5
|
+
import os from "node:os";
|
|
6
|
+
import path from "node:path";
|
|
7
|
+
import process from "node:process";
|
|
8
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
9
|
+
import { HELP, more, USAGE, usage } from "./statechart-help.mjs";
|
|
10
|
+
import {
|
|
11
|
+
BROKEN_PROPERTY,
|
|
12
|
+
checkedOnlyInInitialStates,
|
|
13
|
+
joinAnd,
|
|
14
|
+
parseDot,
|
|
15
|
+
tlcOutputWarning,
|
|
16
|
+
uncitedConditions,
|
|
17
|
+
} from "./lib.mjs";
|
|
18
|
+
import { loopSentence, readerStateNamer, repeatsIn } from "./property-loop.mjs";
|
|
19
|
+
import { loadRenderer, renderPng, THEMES } from "./render.mjs";
|
|
20
|
+
import { ruleBook } from "./rule-book.mjs";
|
|
21
|
+
import { Inputs } from "./snapshot.mjs";
|
|
22
|
+
import { RULES_INVARIANT, SCAFFOLD, withoutComments, writeRulesModule } from "./spec-text.mjs";
|
|
23
|
+
import { cannotRunTlc, findTlc } from "./tlc-tools.mjs";
|
|
24
|
+
|
|
25
|
+
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
26
|
+
const { MAX_STRING_LENGTH } = constants;
|
|
27
|
+
|
|
28
|
+
class CannotFinish extends Error {}
|
|
29
|
+
class TlcStopped extends CannotFinish {}
|
|
30
|
+
class NotPublished extends CannotFinish {}
|
|
31
|
+
|
|
32
|
+
const COMMANDS = {
|
|
33
|
+
"from-spec": {
|
|
34
|
+
values: [
|
|
35
|
+
"--map",
|
|
36
|
+
"--config",
|
|
37
|
+
"--fixed",
|
|
38
|
+
"--fixed-config",
|
|
39
|
+
"--against",
|
|
40
|
+
"--against-config",
|
|
41
|
+
"--label",
|
|
42
|
+
"--expect-broken",
|
|
43
|
+
"--title",
|
|
44
|
+
"--out",
|
|
45
|
+
"--theme",
|
|
46
|
+
],
|
|
47
|
+
switches: ["--cover", "--png"],
|
|
48
|
+
renders: (flags) => flags.png,
|
|
49
|
+
run: fromSpec,
|
|
50
|
+
},
|
|
51
|
+
"from-chart": {
|
|
52
|
+
values: ["--module", "--expect-broken", "--title", "--out", "--theme"],
|
|
53
|
+
switches: ["--png"],
|
|
54
|
+
renders: (flags) => flags.png,
|
|
55
|
+
run: fromChart,
|
|
56
|
+
},
|
|
57
|
+
render: { values: ["--theme", "--out"], switches: [], renders: () => true, run: renderFile },
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
function parseArgs([command, ...rest]) {
|
|
61
|
+
if (command === "-h" || command === "--help") return { help: HELP[""] };
|
|
62
|
+
if (!Object.hasOwn(COMMANDS, command))
|
|
63
|
+
throw new CannotFinish(`${usage(Object.values(USAGE))}\n${more()}`);
|
|
64
|
+
const { values, switches, renders } = COMMANDS[command];
|
|
65
|
+
const flags = { "expect-broken": new Set() };
|
|
66
|
+
let input;
|
|
67
|
+
for (let i = 0; i < rest.length; i++) {
|
|
68
|
+
const a = rest[i];
|
|
69
|
+
if (a === "-h" || a === "--help") return { help: HELP[command] };
|
|
70
|
+
if (switches.includes(a)) flags[a.slice(2)] = true;
|
|
71
|
+
else if (values.includes(a)) {
|
|
72
|
+
if (i + 1 === rest.length) throw new CannotFinish(`${a} needs a value. ${more(command)}`);
|
|
73
|
+
const name = a.slice(2);
|
|
74
|
+
if (flags[name] instanceof Set) for (const v of rest[++i].split(",")) flags[name].add(v);
|
|
75
|
+
else flags[name] = rest[++i];
|
|
76
|
+
} else if (!a.startsWith("-") && input === undefined) input = a;
|
|
77
|
+
else throw new CannotFinish(`unknown argument ${a}. ${more(command)}`);
|
|
78
|
+
}
|
|
79
|
+
if (input === undefined) throw new CannotFinish(`${usage([USAGE[command]])}\n${more(command)}`);
|
|
80
|
+
const themes = Object.keys(THEMES);
|
|
81
|
+
if (flags.theme !== undefined && !themes.includes(flags.theme))
|
|
82
|
+
throw new CannotFinish(
|
|
83
|
+
`--theme takes ${themes.slice(0, -1).join(", ")}, or ${themes.at(-1)}. ${more(command)}`,
|
|
84
|
+
);
|
|
85
|
+
if (flags.theme !== undefined && !renders(flags))
|
|
86
|
+
throw new CannotFinish(`--theme needs --png. ${more(command)}`);
|
|
87
|
+
return { command, input, flags };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
async function rendererFor(command, flags) {
|
|
91
|
+
if (!COMMANDS[command].renders(flags)) return null;
|
|
92
|
+
try {
|
|
93
|
+
return { lib: await loadRenderer(), theme: THEMES[flags.theme ?? "light"] };
|
|
94
|
+
} catch (e) {
|
|
95
|
+
throw new CannotFinish(e.message);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const rel = (file) => {
|
|
100
|
+
const r = path.relative(process.cwd(), file) || ".";
|
|
101
|
+
return r === ".." || r.startsWith(`..${path.sep}`) ? path.resolve(file) : r;
|
|
102
|
+
};
|
|
103
|
+
const cfgOf = (spec) => spec.replace(/\.tla$/, ".cfg");
|
|
104
|
+
|
|
105
|
+
function findTools() {
|
|
106
|
+
try {
|
|
107
|
+
return findTlc();
|
|
108
|
+
} catch (e) {
|
|
109
|
+
throw new CannotFinish(e.message);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function script(name, scriptArgs, about, { okExits = [0] } = {}) {
|
|
114
|
+
const r = spawnSync(process.execPath, [path.join(here, name), ...scriptArgs], {
|
|
115
|
+
encoding: "utf8",
|
|
116
|
+
});
|
|
117
|
+
if (r.error) throw new CannotFinish(`cannot run ${name}: ${r.error.message}`);
|
|
118
|
+
if (okExits.includes(r.status)) return r;
|
|
119
|
+
const thrown = r.stderr.match(/^Error: (.*)$/m)?.[1];
|
|
120
|
+
throw new CannotFinish(thrown ? `${rel(about)}: ${thrown}` : r.stderr.trim());
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const KEYWORD =
|
|
124
|
+
/^(SPECIFICATION|INIT|NEXT|CONSTANTS?|INVARIANTS?|PROPERTY|PROPERTIES|SYMMETRY|VIEW|CONSTRAINTS?|ACTION_CONSTRAINTS?|CHECK_DEADLOCK|POSTCONDITION|ALIAS)$/;
|
|
125
|
+
|
|
126
|
+
function splitRules(cfg) {
|
|
127
|
+
const words = cfg
|
|
128
|
+
.replace(/\(\*[^]*?\*\)/g, " ")
|
|
129
|
+
.replace(/\\\*.*/g, " ")
|
|
130
|
+
.split(/\s+/)
|
|
131
|
+
.filter(Boolean);
|
|
132
|
+
const kept = [];
|
|
133
|
+
const rules = [];
|
|
134
|
+
const properties = [];
|
|
135
|
+
let section = "";
|
|
136
|
+
for (const w of words) {
|
|
137
|
+
if (KEYWORD.test(w)) section = w;
|
|
138
|
+
const list = /^INVARIANTS?$/.test(section)
|
|
139
|
+
? rules
|
|
140
|
+
: /^PROPERT(Y|IES)$/.test(section)
|
|
141
|
+
? properties
|
|
142
|
+
: null;
|
|
143
|
+
if (!list) kept.push(KEYWORD.test(w) && kept.length ? `\n${w}` : w);
|
|
144
|
+
else if (w !== section && !list.includes(w)) list.push(w);
|
|
145
|
+
}
|
|
146
|
+
// TLC takes only a state predicate as an INVARIANT, and checks a state predicate given as a
|
|
147
|
+
// PROPERTY only in the initial states, which the INVARIANT run checks too.
|
|
148
|
+
return {
|
|
149
|
+
cfgWithoutRules: `${kept.join(" ")}\n`,
|
|
150
|
+
rules,
|
|
151
|
+
properties: properties.filter((p) => !rules.includes(p)),
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function runJava(file, args, options, dot) {
|
|
156
|
+
return new Promise((resolve) => {
|
|
157
|
+
const r = { stdout: "", stderr: "", stopped: false, overflowed: false };
|
|
158
|
+
const child = spawn(file, args, options);
|
|
159
|
+
for (const stream of ["stdout", "stderr"])
|
|
160
|
+
child[stream].setEncoding("utf8").on("data", (text) => {
|
|
161
|
+
if (r.overflowed) return;
|
|
162
|
+
if (r[stream].length + text.length <= MAX_STRING_LENGTH) r[stream] += text;
|
|
163
|
+
else {
|
|
164
|
+
r.overflowed = true;
|
|
165
|
+
child.kill("SIGKILL");
|
|
166
|
+
}
|
|
167
|
+
});
|
|
168
|
+
child.on("error", (e) => (r.error = e));
|
|
169
|
+
const watch =
|
|
170
|
+
dot &&
|
|
171
|
+
setInterval(() => {
|
|
172
|
+
if ((fs.statSync(dot, { throwIfNoEntry: false })?.size ?? 0) <= MAX_STRING_LENGTH) return;
|
|
173
|
+
r.stopped = true;
|
|
174
|
+
child.kill("SIGKILL");
|
|
175
|
+
}, 500);
|
|
176
|
+
child.on("close", (status, signal) => {
|
|
177
|
+
clearInterval(watch);
|
|
178
|
+
resolve({ ...r, status, signal });
|
|
179
|
+
});
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
async function tlc(tools, spec, cfg, base, { dump, module, workers = "1" } = {}) {
|
|
184
|
+
fs.writeFileSync(`${base}.cfg`, cfg);
|
|
185
|
+
const metadir = fs.mkdtempSync(path.join(os.tmpdir(), "tlc-"));
|
|
186
|
+
// TLC writes the standard modules, such as Naturals.tla, to java.io.tmpdir under fixed names,
|
|
187
|
+
// so runs that share that folder can read a module while another run rewrites it.
|
|
188
|
+
const javaTmp = path.join(metadir, "tmp");
|
|
189
|
+
fs.mkdirSync(javaTmp);
|
|
190
|
+
// -deadlock: a system at rest has nothing left to do, which TLC would report as a deadlock.
|
|
191
|
+
const r = await runJava(
|
|
192
|
+
tools.java,
|
|
193
|
+
[
|
|
194
|
+
"-XX:+UseParallelGC",
|
|
195
|
+
`-Djava.io.tmpdir=${javaTmp}`,
|
|
196
|
+
...(module ? [`-DTLA-Library=${path.dirname(path.resolve(spec))}`] : []),
|
|
197
|
+
"-cp",
|
|
198
|
+
tools.jar,
|
|
199
|
+
"tlc2.TLC",
|
|
200
|
+
"-workers",
|
|
201
|
+
workers,
|
|
202
|
+
"-metadir",
|
|
203
|
+
metadir,
|
|
204
|
+
"-deadlock",
|
|
205
|
+
"-continue",
|
|
206
|
+
...(dump ? ["-dump", "dot,actionlabels", path.resolve(dump)] : []),
|
|
207
|
+
"-config",
|
|
208
|
+
path.resolve(`${base}.cfg`),
|
|
209
|
+
module ? path.resolve(module) : path.basename(spec),
|
|
210
|
+
],
|
|
211
|
+
{ cwd: path.dirname(path.resolve(spec)) },
|
|
212
|
+
dump,
|
|
213
|
+
);
|
|
214
|
+
fs.rmSync(metadir, { recursive: true, force: true });
|
|
215
|
+
if (r.overflowed) {
|
|
216
|
+
fs.writeFileSync(`${base}.out`, r.stdout);
|
|
217
|
+
throw new TlcStopped(
|
|
218
|
+
`${base}.out: TLC printed more than ${MAX_STRING_LENGTH.toLocaleString("en-US")} characters, the most that the command can read, so the command stopped TLC. Smaller constants make TLC print less.`,
|
|
219
|
+
);
|
|
220
|
+
}
|
|
221
|
+
if (r.stopped) {
|
|
222
|
+
fs.writeFileSync(`${base}.out`, r.stdout);
|
|
223
|
+
return { stdout: r.stdout, stopped: true };
|
|
224
|
+
}
|
|
225
|
+
if (r.error || !/^TLC2 Version/m.test(r.stdout)) throw new CannotFinish(cannotRunTlc(tools, r));
|
|
226
|
+
fs.writeFileSync(`${base}.out`, r.stdout);
|
|
227
|
+
try {
|
|
228
|
+
return { stdout: r.stdout, warning: tlcOutputWarning(r.stdout, `${base}.out`) };
|
|
229
|
+
} catch (e) {
|
|
230
|
+
throw new TlcStopped(e.message);
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function warnUnnamedSteps({ spec, copy }, cfg, dot) {
|
|
235
|
+
const text = withoutComments(copy);
|
|
236
|
+
const specName = /^SPECIFICATION\s+(\w+)/m.exec(cfg)?.[1];
|
|
237
|
+
const specBody = specName && new RegExp(`^${specName}\\s*==(.*(\\n[ \\t]+.*)*)`, "m").exec(text);
|
|
238
|
+
const next =
|
|
239
|
+
/^NEXT\s+(\w+)/m.exec(cfg)?.[1] ?? /\[\]\s*\[\s*(\w+)\s*\]_/.exec(specBody?.[1] ?? text)?.[1];
|
|
240
|
+
const graph = parseDot(fs.readFileSync(dot, "utf8"));
|
|
241
|
+
const unnamed = [...graph.edges, ...graph.loops].filter((e) => e.action === next).length;
|
|
242
|
+
if (unnamed > 0)
|
|
243
|
+
console.error(
|
|
244
|
+
`${rel(spec)}: ${unnamed} ${unnamed === 1 ? "step has" : "steps have"} the name ${next}, the whole next-state action. TLC goes down from ${next} through each \\/, \\E, LET, and definition, and names each step after the last definition on that path. It stops at any other expression, such as /\\, IF, or CASE. If the reader must tell these steps apart, join a definition for each kind of step with \\/ in ${next}, as in ${next} == Pay \\/ Charge, and move each conjunct of ${next} into those definitions.`,
|
|
245
|
+
);
|
|
246
|
+
return next;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function readModel(spec, cfgFile, flag, inputs) {
|
|
250
|
+
const [copy, cfgCopy] = [spec, cfgFile].map((f) => inputs.copyOf(f));
|
|
251
|
+
if (!fs.existsSync(copy)) throw new CannotFinish(`${rel(spec)} does not exist`);
|
|
252
|
+
if (!fs.existsSync(cfgCopy)) {
|
|
253
|
+
const beside = fs
|
|
254
|
+
.readdirSync(path.dirname(spec))
|
|
255
|
+
.filter((f) => f.endsWith(".cfg"))
|
|
256
|
+
.sort();
|
|
257
|
+
throw new CannotFinish(
|
|
258
|
+
`${rel(cfgFile)} does not exist. Pass ${flag} with the config to check ${path.basename(spec)} with. ${
|
|
259
|
+
beside.length
|
|
260
|
+
? `The configs beside it: ${beside.join(", ")}.`
|
|
261
|
+
: "A config has SPECIFICATION and one INVARIANT line per rule."
|
|
262
|
+
}`,
|
|
263
|
+
);
|
|
264
|
+
}
|
|
265
|
+
return { spec, copy, cfgFile, ...splitRules(fs.readFileSync(cfgCopy, "utf8")) };
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function readInput(spec, cfgFile, expected, inputs) {
|
|
269
|
+
const model = readModel(spec, cfgFile, "--config", inputs);
|
|
270
|
+
const names = [...model.rules, ...model.properties];
|
|
271
|
+
const unknown = [...expected].filter((r) => !names.includes(r));
|
|
272
|
+
if (unknown.length)
|
|
273
|
+
throw new CannotFinish(
|
|
274
|
+
`--expect-broken ${unknown.join(", ")}: not a rule in ${rel(model.cfgFile)}, whose rules are ${names.join(", ") || "none"}.`,
|
|
275
|
+
);
|
|
276
|
+
return model;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
async function check(tools, model, { work, out, role }, expected) {
|
|
280
|
+
const { spec, copy, cfgFile, cfgWithoutRules, rules, properties } = model;
|
|
281
|
+
const started = Date.now();
|
|
282
|
+
let runs = 0;
|
|
283
|
+
const run = (cfg, file, options) => {
|
|
284
|
+
runs++;
|
|
285
|
+
return tlc(tools, copy, cfg, file, options);
|
|
286
|
+
};
|
|
287
|
+
const name = path.basename(spec, ".tla");
|
|
288
|
+
const tlcDir = path.join(work, "tlc", ...(role ? [role] : []));
|
|
289
|
+
fs.mkdirSync(tlcDir, { recursive: true });
|
|
290
|
+
console.log(`TLC on ${rel(spec)} with ${rel(cfgFile)}:`);
|
|
291
|
+
if (rules.length === 0)
|
|
292
|
+
console.log(
|
|
293
|
+
` ${path.basename(cfgFile)} has no INVARIANT, so ${properties.length ? "the chart marks no state broken" : "no rule is checked"}.`,
|
|
294
|
+
);
|
|
295
|
+
const said = (rule, broken) =>
|
|
296
|
+
` ${rule} ${broken ? (expected.has(rule) ? "is broken (expected)" : "is broken") : "holds"}.`;
|
|
297
|
+
const shown = published(work, out);
|
|
298
|
+
const traces = [];
|
|
299
|
+
for (const rule of rules) {
|
|
300
|
+
const file = path.join(tlcDir, `${name}-${rule}`);
|
|
301
|
+
const cfg = `${cfgWithoutRules}INVARIANT ${rule}\nCONSTRAINT ${rule}\n`;
|
|
302
|
+
let result = await run(cfg, file, { workers: "auto" });
|
|
303
|
+
// Several workers can reach a bad state by a longer path first. One worker searches breadth
|
|
304
|
+
// first in a fixed order, so the chart's bug paths are the shortest, and the same every run.
|
|
305
|
+
if (/^Error: Invariant /m.test(result.stdout)) result = await run(cfg, file);
|
|
306
|
+
const broken = /^Error: Invariant /m.test(result.stdout);
|
|
307
|
+
console.log(said(rule, broken));
|
|
308
|
+
if (result.warning) console.error(`${shown(`${file}.out`)}: ${result.warning}`);
|
|
309
|
+
traces.push({ file: `${file}.out`, rule, broken });
|
|
310
|
+
}
|
|
311
|
+
const temporal = [];
|
|
312
|
+
for (const property of properties) {
|
|
313
|
+
const file = path.join(tlcDir, `${name}-${property}`);
|
|
314
|
+
const { stdout } = await run(`${cfgWithoutRules}PROPERTY ${property}\n`, file, {
|
|
315
|
+
workers: "auto",
|
|
316
|
+
});
|
|
317
|
+
const broken = BROKEN_PROPERTY.test(stdout);
|
|
318
|
+
const notes = [
|
|
319
|
+
broken &&
|
|
320
|
+
`The chart draws only INVARIANT rules, so read TLC's counterexample in ${shown(`${file}.out`)}.`,
|
|
321
|
+
checkedOnlyInInitialStates(spec, property) &&
|
|
322
|
+
`TLC checked ${property} only in the initial states, because it has no [], <>, or ~>. List it as an INVARIANT to check every state.`,
|
|
323
|
+
];
|
|
324
|
+
console.log([said(property, broken), ...notes.filter(Boolean)].join(" "));
|
|
325
|
+
temporal.push({ file: `${file}.out`, rule: property, broken });
|
|
326
|
+
}
|
|
327
|
+
const dot = path.join(tlcDir, `${name}.dot`);
|
|
328
|
+
const module = rules.length ? writeRulesModule(copy, rules, tlcDir) : undefined;
|
|
329
|
+
const dumpCfg = module ? `${cfgWithoutRules}INVARIANT ${RULES_INVARIANT}\n` : cfgWithoutRules;
|
|
330
|
+
const { stopped } = await run(dumpCfg, path.join(tlcDir, name), {
|
|
331
|
+
dump: dot,
|
|
332
|
+
module,
|
|
333
|
+
workers: "1",
|
|
334
|
+
});
|
|
335
|
+
const dotBytes = fs.statSync(dot).size;
|
|
336
|
+
const next = dotBytes <= MAX_STRING_LENGTH && warnUnnamedSteps(model, cfgWithoutRules, dot);
|
|
337
|
+
const seconds = ((Date.now() - started) / 1000).toFixed(1);
|
|
338
|
+
console.log(` TLC ran ${runs} ${runs === 1 ? "time" : "times"} in ${seconds} s.`);
|
|
339
|
+
const broken = [...traces, ...temporal].some((t) => t.broken);
|
|
340
|
+
return {
|
|
341
|
+
spec,
|
|
342
|
+
copy,
|
|
343
|
+
dot,
|
|
344
|
+
dotBytes,
|
|
345
|
+
stopped,
|
|
346
|
+
next,
|
|
347
|
+
dumpOut: path.join(tlcDir, `${name}.out`),
|
|
348
|
+
traces,
|
|
349
|
+
properties: temporal,
|
|
350
|
+
broken,
|
|
351
|
+
};
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
function rulesAsExpected(now, other, expected) {
|
|
355
|
+
const brokenIn = (m) =>
|
|
356
|
+
[...(m?.traces ?? []), ...(m?.properties ?? [])].filter((t) => t.broken).map((t) => t.rule);
|
|
357
|
+
if (expected.size === 0) return !now.broken && !other?.broken;
|
|
358
|
+
const unexpected = [...new Set([...brokenIn(now), ...brokenIn(other)])].filter(
|
|
359
|
+
(r) => !expected.has(r),
|
|
360
|
+
);
|
|
361
|
+
const holds = [...expected].filter((r) => !brokenIn(now).includes(r));
|
|
362
|
+
if (unexpected.length) console.log(`\nBroken but not expected: ${unexpected.join(", ")}.`);
|
|
363
|
+
if (holds.length) console.log(`\nExpected to break but holds: ${holds.join(", ")}.`);
|
|
364
|
+
if (!unexpected.length && !holds.length)
|
|
365
|
+
console.log(`\nBroken as expected: ${[...expected].join(", ")}.`);
|
|
366
|
+
return !unexpected.length && !holds.length;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
async function draw({ now, other, uncited, map, work, out, title, comparing, cover }) {
|
|
370
|
+
const huge = [now, other].find((m) => m?.dotBytes > MAX_STRING_LENGTH);
|
|
371
|
+
const limit = MAX_STRING_LENGTH.toLocaleString("en-US");
|
|
372
|
+
if (huge)
|
|
373
|
+
throw new NotPublished(
|
|
374
|
+
`${rel(huge.spec)}: TLC's state graph ${huge.stopped ? `passed ${limit} bytes, the most that the command can read, so the command stopped TLC and draws no chart` : `has ${huge.dotBytes.toLocaleString("en-US")} bytes, more than the ${limit} that the command can read, so it draws no chart`}. The rule results above stand. Smaller constants make a smaller graph.`,
|
|
375
|
+
);
|
|
376
|
+
const shown = published(work, out);
|
|
377
|
+
const mapping = await import(pathToFileURL(path.resolve(map)).href);
|
|
378
|
+
const from = ["--dot", now.dot, "--broken", now.dumpOut, "--map", map, "--spec", now.copy];
|
|
379
|
+
for (const m of [now, other]) if (m?.next) from.push("--next", m.next);
|
|
380
|
+
from.push(...now.traces.flatMap((t) => ["--traces", t.file]));
|
|
381
|
+
if (other) {
|
|
382
|
+
from.push(`--${other.role}-dot`, other.dot, `--${other.role}-broken`, other.dumpOut);
|
|
383
|
+
from.push("--spec", other.copy);
|
|
384
|
+
from.push(...other.traces.flatMap((t) => [`--${other.role}-traces`, t.file]));
|
|
385
|
+
if (other.traces.length)
|
|
386
|
+
from.push(`--${other.role}-rules`, other.traces.map((t) => t.rule).join(","));
|
|
387
|
+
if (other.label !== undefined) from.push("--label", other.label);
|
|
388
|
+
if (other.listed) from.push("--listed", JSON.stringify(other.listed));
|
|
389
|
+
}
|
|
390
|
+
if (comparing) from.push("--full-labels", "--flat", "--loops");
|
|
391
|
+
const titled = (suffix) => (title ? ["--title", `${title}${suffix}`] : []);
|
|
392
|
+
|
|
393
|
+
const files = {
|
|
394
|
+
overview: path.join(work, "chart.mmd"),
|
|
395
|
+
stories: path.join(work, "stories.md"),
|
|
396
|
+
summary: path.join(work, "summary.json"),
|
|
397
|
+
};
|
|
398
|
+
const proof = path.join(work, "tlc", "regions-proof.mmd");
|
|
399
|
+
const everyArrow = path.join(work, "tlc", "regions-all.mmd");
|
|
400
|
+
script(
|
|
401
|
+
"tlc-to-chart.mjs",
|
|
402
|
+
[
|
|
403
|
+
...from,
|
|
404
|
+
...(comparing ? [] : ["--regions", "--proof", proof, "--regions-all", everyArrow]),
|
|
405
|
+
...(cover ? ["--cover"] : []),
|
|
406
|
+
...titled(""),
|
|
407
|
+
"--highlight",
|
|
408
|
+
"0",
|
|
409
|
+
"--out",
|
|
410
|
+
files.overview,
|
|
411
|
+
"--story",
|
|
412
|
+
files.stories,
|
|
413
|
+
"--summary",
|
|
414
|
+
files.summary,
|
|
415
|
+
],
|
|
416
|
+
map,
|
|
417
|
+
);
|
|
418
|
+
const summary = JSON.parse(fs.readFileSync(files.summary, "utf8"));
|
|
419
|
+
const L = summary.second?.label;
|
|
420
|
+
const broken = [now, other].filter(Boolean).flatMap((m) => {
|
|
421
|
+
const book = ruleBook(m.copy);
|
|
422
|
+
const where = m === now ? "" : ` ${other.role === "fixed" ? "with" : "in"} ${L}`;
|
|
423
|
+
const nameOf = readerStateNamer(mapping, m.dot);
|
|
424
|
+
return m.properties
|
|
425
|
+
.filter((p) => p.broken)
|
|
426
|
+
.map((p) => ({
|
|
427
|
+
where,
|
|
428
|
+
property: {
|
|
429
|
+
spec: rel(m.spec),
|
|
430
|
+
property: p.rule,
|
|
431
|
+
rule: book.get(p.rule)?.sentence ?? null,
|
|
432
|
+
repeats: repeatsIn(fs.readFileSync(p.file, "utf8"), nameOf),
|
|
433
|
+
counterexample: path.relative(work, p.file),
|
|
434
|
+
},
|
|
435
|
+
}));
|
|
436
|
+
});
|
|
437
|
+
const brokenProperties = broken.map((b) => b.property);
|
|
438
|
+
fs.writeFileSync(
|
|
439
|
+
files.summary,
|
|
440
|
+
`${JSON.stringify({ ...summary, ...(uncited && { uncited }), brokenProperties }, null, 2)}\n`,
|
|
441
|
+
);
|
|
442
|
+
if (brokenProperties.length) {
|
|
443
|
+
const lines = broken.map(
|
|
444
|
+
({ where, property: b }) =>
|
|
445
|
+
`Rule broken${where}: ${[
|
|
446
|
+
(b.rule ?? `\`${b.property}\``).replace(/\.?$/, "."),
|
|
447
|
+
loopSentence(b.repeats),
|
|
448
|
+
`TLC's counterexample is in ${b.counterexample}.`,
|
|
449
|
+
]
|
|
450
|
+
.filter(Boolean)
|
|
451
|
+
.join(" ")}`,
|
|
452
|
+
);
|
|
453
|
+
const bugs = fs.readFileSync(files.stories, "utf8");
|
|
454
|
+
fs.writeFileSync(
|
|
455
|
+
files.stories,
|
|
456
|
+
`${bugs}${bugs && "\n"}## Broken rules that the chart does not draw\n\n${lines.join("\n")}\n`,
|
|
457
|
+
);
|
|
458
|
+
}
|
|
459
|
+
const charts = [files.overview];
|
|
460
|
+
for (const c of summary.classes) {
|
|
461
|
+
const views = typeof mapping.detail === "function" ? ["", "-developer", "-layered"] : [""];
|
|
462
|
+
for (const view of views) {
|
|
463
|
+
const file = path.join(work, `bug-${c.n}${view}.mmd`);
|
|
464
|
+
const shape = [...(view ? ["--view", view.slice(1)] : []), "--path-only"];
|
|
465
|
+
script(
|
|
466
|
+
"tlc-to-chart.mjs",
|
|
467
|
+
[...from, ...shape, ...titled(`, bug ${c.n}`), "--highlight", String(c.n), "--out", file],
|
|
468
|
+
map,
|
|
469
|
+
);
|
|
470
|
+
charts.push(file);
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
const { works, wrong, fixed: fixedCount, mixed, after, worse } = summary.chartStatus;
|
|
475
|
+
console.log(
|
|
476
|
+
`\nChart: ${summary.chartStates} states and ${summary.chartTransitions} transitions from ${summary.modelStates} model states.`,
|
|
477
|
+
);
|
|
478
|
+
const r = summary.regions;
|
|
479
|
+
if (r?.drawn)
|
|
480
|
+
console.log(
|
|
481
|
+
`It has 2 parallel regions, of ${r.states[0]} and ${r.states[1]} states. Proof: their ${r.all} arrows together make exactly the ${r.flat.states} states and ${r.flat.transitions} labeled steps of ${shown(proof)}, and the ${r.transitions} drawn arrows make only steps among them. ${shown(everyArrow)} has all ${r.all} arrows.`,
|
|
482
|
+
);
|
|
483
|
+
else if (r) console.log(`It has no parallel regions, because ${r.why}.`);
|
|
484
|
+
const c = summary.cover;
|
|
485
|
+
if (c?.drawn === false) console.log(`--cover draws every state, because ${c.why}.`);
|
|
486
|
+
else if (c) {
|
|
487
|
+
const list = (head, xs) => [
|
|
488
|
+
...(xs.length ? [head] : []),
|
|
489
|
+
...xs.slice(0, 10).map((x) => ` ${x}`),
|
|
490
|
+
...(xs.length > 10 ? [` and ${xs.length - 10} more in summary.json`] : []),
|
|
491
|
+
];
|
|
492
|
+
const left = [
|
|
493
|
+
...list("States left out:", c.leftOutStates),
|
|
494
|
+
...list("Transitions left out:", c.leftOutTransitions),
|
|
495
|
+
];
|
|
496
|
+
console.log(
|
|
497
|
+
left.length
|
|
498
|
+
? `--cover leaves out ${c.leftOutStates.length} of ${c.states} states and ${c.leftOutTransitions.length} of ${c.transitions} transitions. The counts of states that work or break a rule include the states it leaves out.\n${left.join("\n")}`
|
|
499
|
+
: "--cover leaves out no state and no transition.",
|
|
500
|
+
);
|
|
501
|
+
}
|
|
502
|
+
const verb = (n) => (n === 1 ? "breaks" : "break");
|
|
503
|
+
const breaks = `${wrong} ${verb(wrong)} a rule`;
|
|
504
|
+
const withFix = other?.role === "fixed" ? ` with ${L}` : "";
|
|
505
|
+
const partly =
|
|
506
|
+
(mixed ? `, ${mixed} ${verb(mixed)} a rule in some cases${withFix}` : "") +
|
|
507
|
+
(after ? `, ${after} reached only after a bug${withFix}` : "") +
|
|
508
|
+
(worse ? `, ${worse} ${verb(worse)} a rule only${withFix}` : "");
|
|
509
|
+
console.log(
|
|
510
|
+
other?.role === "fixed"
|
|
511
|
+
? `${works} work today, ${wrong} still wrong with ${L}, ${fixedCount} fixed or new with ${L}${partly}.\n`
|
|
512
|
+
: other?.role === "against"
|
|
513
|
+
? `${works} work, ${breaks}, ${fixedCount} only in ${L}${partly}.\n`
|
|
514
|
+
: `${works} work, ${breaks}${partly}.\n`,
|
|
515
|
+
);
|
|
516
|
+
if (summary.mixedStatus.length)
|
|
517
|
+
console.log(
|
|
518
|
+
`${summary.mixedStatus.length === 1 ? "This state breaks" : "These states break"} a rule in some cases and not in others:\n${summary.mixedStatus.map((t) => ` ${t}`).join("\n")}\n`,
|
|
519
|
+
);
|
|
520
|
+
if (summary.codeLike.length)
|
|
521
|
+
console.log(
|
|
522
|
+
`These labels or causes read like code. Write them in the reader's words in ${rel(map)}:\n${summary.codeLike.map((t) => ` ${t}`).join("\n")}\n`,
|
|
523
|
+
);
|
|
524
|
+
const stories = fs.readFileSync(files.stories, "utf8").trimEnd();
|
|
525
|
+
console.log(
|
|
526
|
+
summary.classes.length
|
|
527
|
+
? stories
|
|
528
|
+
: ["No state breaks an INVARIANT rule.", stories].filter(Boolean).join("\n\n"),
|
|
529
|
+
);
|
|
530
|
+
return { files, charts };
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
const pngOf = (chart) => chart.replace(/(\.mmd)?$/, ".png");
|
|
534
|
+
|
|
535
|
+
function render(charts, { lib, theme }, shown) {
|
|
536
|
+
const pngs = [];
|
|
537
|
+
for (const chart of charts) {
|
|
538
|
+
try {
|
|
539
|
+
fs.writeFileSync(pngOf(chart), renderPng(lib, fs.readFileSync(chart, "utf8"), { theme }));
|
|
540
|
+
pngs.push(pngOf(chart));
|
|
541
|
+
} catch (e) {
|
|
542
|
+
console.error(`could not render ${shown(chart)}: ${e.message}`);
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
return pngs;
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
function finish({ work, out, inputs }, { files, charts }, ok, renderer) {
|
|
549
|
+
unchanged(inputs, out);
|
|
550
|
+
const pngs = renderer ? render(charts, renderer, published(work, out)) : [];
|
|
551
|
+
publish(work, out, inputs);
|
|
552
|
+
const written = [...charts, ...pngs, files.stories, files.summary]
|
|
553
|
+
.map((f) => path.basename(f))
|
|
554
|
+
.join(", ");
|
|
555
|
+
console.log(`\nWrote ${written} in ${rel(out)}.`);
|
|
556
|
+
if (renderer && pngs.length < charts.length) return 2;
|
|
557
|
+
return ok ? 0 : 1;
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
function renderFile(input, flags, { lib, theme }) {
|
|
561
|
+
const out = flags.out ?? pngOf(input);
|
|
562
|
+
const step = (failed, f) => {
|
|
563
|
+
try {
|
|
564
|
+
return f();
|
|
565
|
+
} catch (e) {
|
|
566
|
+
throw new CannotFinish(`${failed}: ${e.message}`);
|
|
567
|
+
}
|
|
568
|
+
};
|
|
569
|
+
const src = step(`cannot read ${rel(input)}`, () => fs.readFileSync(input, "utf8"));
|
|
570
|
+
const png = step(`could not render ${rel(input)}`, () => renderPng(lib, src, { theme }));
|
|
571
|
+
step(`cannot write ${rel(out)}`, () => {
|
|
572
|
+
fs.mkdirSync(path.dirname(path.resolve(out)), { recursive: true });
|
|
573
|
+
fs.writeFileSync(out, png);
|
|
574
|
+
});
|
|
575
|
+
console.log(`Wrote ${rel(out)}.`);
|
|
576
|
+
return 0;
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
const published = (work, out) => (file) => rel(path.join(out, path.relative(work, file)));
|
|
580
|
+
|
|
581
|
+
const OWN =
|
|
582
|
+
/^((chart|bug-\d+(-developer|-layered)?)\.(mmd|png)|stories\.md|summary\.json|tlc|\.DS_Store)$/;
|
|
583
|
+
|
|
584
|
+
function foreign(dir) {
|
|
585
|
+
try {
|
|
586
|
+
return fs
|
|
587
|
+
.readdirSync(dir)
|
|
588
|
+
.filter((f) => !OWN.test(f))
|
|
589
|
+
.sort();
|
|
590
|
+
} catch (e) {
|
|
591
|
+
if (e.code === "ENOENT") return [];
|
|
592
|
+
throw new CannotFinish(`cannot use ${rel(dir)} as the output folder: ${e.message}`);
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
function refuseForeign(out, files = foreign(out)) {
|
|
597
|
+
const [first, ...others] = files;
|
|
598
|
+
if (first)
|
|
599
|
+
throw new CannotFinish(
|
|
600
|
+
`${rel(out)} has ${others.length ? `${others.length + 1} files that this command did not write, such as ${first}` : `a file that this command did not write: ${first}`}. A run puts its files in place of the whole output folder, so pass --out with a new folder.`,
|
|
601
|
+
);
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
function outDir(flagged, input, name) {
|
|
605
|
+
const out = flagged ?? path.join(path.dirname(input), `${name}-statechart`);
|
|
606
|
+
refuseForeign(out);
|
|
607
|
+
return out;
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
async function inWorkFolder(body) {
|
|
611
|
+
const work = fs.mkdtempSync(path.join(os.tmpdir(), "statechart-"));
|
|
612
|
+
fs.mkdirSync(path.join(work, "tlc"));
|
|
613
|
+
let keep = false;
|
|
614
|
+
try {
|
|
615
|
+
return await body(work);
|
|
616
|
+
} catch (e) {
|
|
617
|
+
keep = fs.readdirSync(path.join(work, "tlc")).some((f) => f.endsWith(".out"));
|
|
618
|
+
if (keep && !(e instanceof TlcStopped))
|
|
619
|
+
e.message += ` The files that this run wrote are in ${rel(work)}.`;
|
|
620
|
+
throw e;
|
|
621
|
+
} finally {
|
|
622
|
+
if (!keep) fs.rmSync(work, { recursive: true, force: true });
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
const moved = (from, to, ...missed) => {
|
|
627
|
+
try {
|
|
628
|
+
fs.renameSync(from, to);
|
|
629
|
+
return true;
|
|
630
|
+
} catch (e) {
|
|
631
|
+
if (missed.includes(e.code)) return false;
|
|
632
|
+
if (!["ENOSYS", "EXDEV", "EISDIR"].includes(e.code)) throw e;
|
|
633
|
+
}
|
|
634
|
+
// Some filesystems, such as FUSE mounts, cannot rename a folder that has files. They fail with
|
|
635
|
+
// ENOSYS, or with EISDIR when the target exists. They can rename an empty folder.
|
|
636
|
+
if (!fs.existsSync(from) && missed.includes("ENOENT")) return false;
|
|
637
|
+
const claimed = claimedFolder(to, missed);
|
|
638
|
+
if (claimed === null) return false;
|
|
639
|
+
try {
|
|
640
|
+
copyExclusive(from, to);
|
|
641
|
+
if (!sameFolder(to, claimed))
|
|
642
|
+
throw new Error("another run replaced the folder during the copy");
|
|
643
|
+
} catch (e) {
|
|
644
|
+
if (sameFolder(to, claimed)) fs.rmSync(to, { recursive: true, force: true });
|
|
645
|
+
throw e;
|
|
646
|
+
} finally {
|
|
647
|
+
fs.closeSync(claimed);
|
|
648
|
+
}
|
|
649
|
+
fs.rmSync(from, { recursive: true, force: true });
|
|
650
|
+
return true;
|
|
651
|
+
};
|
|
652
|
+
|
|
653
|
+
function claimedFolder(to, missed) {
|
|
654
|
+
// Making a folder and opening it take two calls. Another run can replace the folder at `to`
|
|
655
|
+
// between them. So the run opens its folder before it moves the folder to `to`.
|
|
656
|
+
// A filesystem can reuse a removed folder's inode number. Holding this folder open keeps its
|
|
657
|
+
// number, so a folder that another run makes in its place cannot match it.
|
|
658
|
+
const mine = fs.mkdtempSync(path.join(path.dirname(to), `.${path.basename(to)}-`));
|
|
659
|
+
const claimed = fs.openSync(mine, "r");
|
|
660
|
+
try {
|
|
661
|
+
fs.renameSync(mine, to);
|
|
662
|
+
return claimed;
|
|
663
|
+
} catch (e) {
|
|
664
|
+
fs.closeSync(claimed);
|
|
665
|
+
fs.rmdirSync(mine);
|
|
666
|
+
if (missed.includes(e.code)) return null;
|
|
667
|
+
throw e;
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
function sameFolder(at, claimed) {
|
|
672
|
+
const now = fs.statSync(at, { throwIfNoEntry: false });
|
|
673
|
+
const made = fs.fstatSync(claimed);
|
|
674
|
+
return now?.dev === made.dev && now?.ino === made.ino;
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
function copyExclusive(from, to) {
|
|
678
|
+
for (const entry of fs.readdirSync(from, { withFileTypes: true })) {
|
|
679
|
+
const source = path.join(from, entry.name);
|
|
680
|
+
const target = path.join(to, entry.name);
|
|
681
|
+
if (entry.isDirectory()) {
|
|
682
|
+
fs.mkdirSync(target);
|
|
683
|
+
copyExclusive(source, target);
|
|
684
|
+
} else fs.copyFileSync(source, target, fs.constants.COPYFILE_EXCL);
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
function unchanged(inputs, out) {
|
|
689
|
+
const changed = inputs.changed();
|
|
690
|
+
if (changed)
|
|
691
|
+
throw new NotPublished(
|
|
692
|
+
`${rel(changed)} changed while this run read it, so the run left ${rel(out)} as it was. Run the command again.`,
|
|
693
|
+
);
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
function publish(work, out, inputs) {
|
|
697
|
+
const parent = path.dirname(path.resolve(out));
|
|
698
|
+
fs.mkdirSync(parent, { recursive: true });
|
|
699
|
+
const next = fs.mkdtempSync(path.join(parent, `.${path.basename(out)}-`));
|
|
700
|
+
const old = `${next}-old`;
|
|
701
|
+
let aside = false;
|
|
702
|
+
try {
|
|
703
|
+
fs.cpSync(work, next, { recursive: true });
|
|
704
|
+
unchanged(inputs, out);
|
|
705
|
+
refuseForeign(out);
|
|
706
|
+
while (!moved(next, out, "ENOTEMPTY", "EEXIST")) {
|
|
707
|
+
if (aside) {
|
|
708
|
+
refuseForeign(out);
|
|
709
|
+
fs.rmSync(old, { recursive: true, force: true });
|
|
710
|
+
aside = false;
|
|
711
|
+
}
|
|
712
|
+
aside = moved(out, old, "ENOENT");
|
|
713
|
+
if (aside) refuseForeign(out, foreign(old));
|
|
714
|
+
}
|
|
715
|
+
} catch (e) {
|
|
716
|
+
const back = !aside || putBack(old, out);
|
|
717
|
+
fs.rmSync(next, { recursive: true, force: true });
|
|
718
|
+
const why = e instanceof CannotFinish ? e.message : `cannot replace ${rel(out)}: ${e.message}.`;
|
|
719
|
+
if (!back)
|
|
720
|
+
throw new NotPublished(
|
|
721
|
+
`${why} This run moved the previous ${rel(out)} to ${rel(old)} and could not put it back.`,
|
|
722
|
+
);
|
|
723
|
+
throw new NotPublished(
|
|
724
|
+
e instanceof CannotFinish ? why : `${why} The run left ${rel(out)} as it was.`,
|
|
725
|
+
);
|
|
726
|
+
}
|
|
727
|
+
if (aside) fs.rmSync(old, { recursive: true, force: true });
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
function putBack(old, out) {
|
|
731
|
+
try {
|
|
732
|
+
return moved(old, out, "ENOTEMPTY", "EEXIST");
|
|
733
|
+
} catch {
|
|
734
|
+
return false;
|
|
735
|
+
}
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
function secondCfg(cfg, flags) {
|
|
739
|
+
if (flags["fixed-config"] && !flags.fixed)
|
|
740
|
+
throw new CannotFinish(`--fixed-config needs --fixed. ${more("from-spec")}`);
|
|
741
|
+
if (flags["against-config"] && !flags.against)
|
|
742
|
+
throw new CannotFinish(`--against-config needs --against. ${more("from-spec")}`);
|
|
743
|
+
if (flags.fixed)
|
|
744
|
+
return flags["fixed-config"] ?? (fs.existsSync(cfgOf(flags.fixed)) ? cfgOf(flags.fixed) : cfg);
|
|
745
|
+
return flags.against && (flags["against-config"] ?? cfgOf(flags.against));
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
function secondModel(input, cfg, { fixed, against, label }, inputs) {
|
|
749
|
+
if (fixed && against)
|
|
750
|
+
throw new CannotFinish(`use --fixed or --against, not both. ${more("from-spec")}`);
|
|
751
|
+
if (!fixed && !against) {
|
|
752
|
+
if (label === undefined) return null;
|
|
753
|
+
throw new CannotFinish(
|
|
754
|
+
`--label names the --fixed or --against model; it needs one of them. ${more("from-spec")}`,
|
|
755
|
+
);
|
|
756
|
+
}
|
|
757
|
+
const [role, spec] = fixed ? ["fixed", fixed] : ["against", against];
|
|
758
|
+
if (path.resolve(spec) === path.resolve(input))
|
|
759
|
+
throw new CannotFinish(`--${role} needs a file other than the spec, ${rel(input)}.`);
|
|
760
|
+
return { role, label, model: readModel(spec, cfg, `--${role}-config`, inputs) };
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
function warnUnchecked(now, other) {
|
|
764
|
+
const unchecked = now.rules.filter((r) => !other.rules.includes(r));
|
|
765
|
+
if (unchecked.length)
|
|
766
|
+
console.error(
|
|
767
|
+
`${rel(other.cfgFile)} does not check ${unchecked.join(", ")}, so the stories cannot say whether ${rel(other.spec)} keeps ${unchecked.length === 1 ? "it" : "them"}.`,
|
|
768
|
+
);
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
function listedBreaks(spec) {
|
|
772
|
+
const dir = path.dirname(spec);
|
|
773
|
+
const files = fs.readdirSync(dir).sort();
|
|
774
|
+
const modules = files.filter((f) => f.endsWith(".tla") && !f.includes("_TTrace_"));
|
|
775
|
+
const only = modules.length === 1 ? path.basename(modules[0], ".tla") : undefined;
|
|
776
|
+
const breaks = {};
|
|
777
|
+
let headed = false;
|
|
778
|
+
for (const f of files.filter((f) => f.endsWith(".cfg"))) {
|
|
779
|
+
const cfg = fs.readFileSync(path.join(dir, f), "utf8");
|
|
780
|
+
const module = /^\\\* module:[ \t]*(\w+)[ \t]*$/m.exec(cfg)?.[1] ?? only;
|
|
781
|
+
const expect = /^\\\* expect:[ \t]*(\w+)[ \t]*$/m.exec(cfg)?.[1];
|
|
782
|
+
if (module !== path.basename(spec, ".tla") || !expect) continue;
|
|
783
|
+
headed = true;
|
|
784
|
+
if (!/^(pass|deadlock)$|^error/.test(expect)) breaks[f] = expect;
|
|
785
|
+
}
|
|
786
|
+
return headed ? { spec: path.basename(spec), breaks } : null;
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
function warnUnlisted(now, other) {
|
|
790
|
+
if (other.listed || !now.traces.some((t) => t.broken)) return;
|
|
791
|
+
const module = path.basename(other.spec, ".tla");
|
|
792
|
+
console.error(
|
|
793
|
+
`No config beside ${rel(other.spec)} has an "\\* expect:" line for ${module}, so the stories cannot say whether ${module} already lists each bug. When ${path.basename(other.spec)} is a copy, pass the spec in its own folder.`,
|
|
794
|
+
);
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
function warnUncited(models) {
|
|
798
|
+
return Object.fromEntries(
|
|
799
|
+
models.map(({ spec, copy, traces, properties }) => {
|
|
800
|
+
const found = uncitedConditions(fs.readFileSync(copy, "utf8"));
|
|
801
|
+
const held = [...traces, ...properties].filter((t) => !t.broken).map((t) => t.rule);
|
|
802
|
+
const short = (s) => (s.length > 90 ? `${s.slice(0, 87)}...` : s);
|
|
803
|
+
if (found.length && held.length)
|
|
804
|
+
console.error(
|
|
805
|
+
`${rel(spec)}: ${joinAnd(held)} ${held.length === 1 ? "holds" : "hold"}, but a rule can also hold because the model waits for a condition that the code does not check. No comment gives the file and line of the code that checks ${found.length === 1 ? "this condition" : "these conditions"}:\n${found.map((c) => ` ${rel(spec)}:${c.line} ${c.action}: ${short(c.condition)}`).join("\n")}\nPut that file and line in a comment on each condition, as in \\* pay.ts:120, or remove the condition when no code checks it.`,
|
|
806
|
+
);
|
|
807
|
+
return [rel(spec), found];
|
|
808
|
+
}),
|
|
809
|
+
);
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
async function fromSpec(input, { "expect-broken": expected, title, cover, ...flags }, renderer) {
|
|
813
|
+
const name = path.basename(input, ".tla");
|
|
814
|
+
const map = flags.map ?? path.join(path.dirname(input), `${name}.map.mjs`);
|
|
815
|
+
const cfg = flags.config ?? cfgOf(input);
|
|
816
|
+
const otherCfg = secondCfg(cfg, flags);
|
|
817
|
+
const inputs = new Inputs().add([input, cfg, map, flags.fixed ?? flags.against, otherCfg]);
|
|
818
|
+
const second = secondModel(input, otherCfg, flags, inputs);
|
|
819
|
+
const listed = second?.role === "against" ? listedBreaks(second.model.spec) : undefined;
|
|
820
|
+
const model = readInput(input, cfg, expected, inputs);
|
|
821
|
+
const out = outDir(flags.out, input, name);
|
|
822
|
+
if (!fs.existsSync(map)) {
|
|
823
|
+
fs.writeFileSync(map, SCAFFOLD(name, input));
|
|
824
|
+
throw new CannotFinish(`Wrote ${rel(map)}. Fill in its labels, then run this command again.`);
|
|
825
|
+
}
|
|
826
|
+
const tools = findTools();
|
|
827
|
+
return inWorkFolder(async (work) => {
|
|
828
|
+
const now = await check(tools, model, { work, out }, expected);
|
|
829
|
+
const other = second && {
|
|
830
|
+
role: second.role,
|
|
831
|
+
label: second.label,
|
|
832
|
+
listed,
|
|
833
|
+
...(await check(tools, second.model, { work, out, role: second.role }, expected)),
|
|
834
|
+
};
|
|
835
|
+
let uncited;
|
|
836
|
+
if (second) {
|
|
837
|
+
warnUnchecked(model, second.model);
|
|
838
|
+
if (second.role === "against") warnUnlisted(now, other);
|
|
839
|
+
uncited = warnUncited(second.role === "fixed" ? [now, other] : [now]);
|
|
840
|
+
}
|
|
841
|
+
const drawn = await draw({ now, other, uncited, map, work, out, title, cover });
|
|
842
|
+
return finish({ work, out, inputs }, drawn, rulesAsExpected(now, other, expected), renderer);
|
|
843
|
+
});
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
async function fromChart(input, { "expect-broken": expected, ...flags }, renderer) {
|
|
847
|
+
const name =
|
|
848
|
+
flags.module ??
|
|
849
|
+
path
|
|
850
|
+
.basename(input, path.extname(input))
|
|
851
|
+
.replace(/(^|[^A-Za-z0-9]+)([A-Za-z0-9])/g, (_, __, c) => c.toUpperCase());
|
|
852
|
+
if (!/^[A-Za-z][A-Za-z0-9_]*$/.test(name))
|
|
853
|
+
throw new CannotFinish(`cannot make a TLA+ module name from ${input}; add --module NAME`);
|
|
854
|
+
const spec = path.join(path.dirname(input), `${name}.tla`);
|
|
855
|
+
const map = spec.replace(/\.tla$/, ".map.mjs");
|
|
856
|
+
const inputs = new Inputs().add([input]);
|
|
857
|
+
if (fs.existsSync(spec)) {
|
|
858
|
+
console.log(`Kept ${rel(spec)}. Delete it to write the spec again from ${rel(input)}.`);
|
|
859
|
+
} else {
|
|
860
|
+
script("chart-to-tla.mjs", [input, "--module", name], input);
|
|
861
|
+
console.log(`Wrote ${name}.tla, ${name}.cfg, and ${name}.map.mjs from ${rel(input)}.`);
|
|
862
|
+
}
|
|
863
|
+
inputs.add([spec, cfgOf(spec), map]);
|
|
864
|
+
const tools = findTools();
|
|
865
|
+
const model = readInput(spec, cfgOf(spec), expected, inputs);
|
|
866
|
+
const out = outDir(flags.out, input, name);
|
|
867
|
+
return inWorkFolder(async (work) => {
|
|
868
|
+
const now = await check(tools, model, { work, out }, expected);
|
|
869
|
+
const drawn = await draw({ now, map, work, out, title: flags.title, comparing: true });
|
|
870
|
+
const compared = script(
|
|
871
|
+
"compare-charts.mjs",
|
|
872
|
+
[inputs.copyOf(input), drawn.files.overview],
|
|
873
|
+
input,
|
|
874
|
+
{
|
|
875
|
+
okExits: [0, 1],
|
|
876
|
+
},
|
|
877
|
+
);
|
|
878
|
+
if (compared.status === 0) console.log(`\nThe spec draws ${rel(input)} back unchanged.`);
|
|
879
|
+
else
|
|
880
|
+
console.log(
|
|
881
|
+
`\nThe spec does not draw ${rel(input)}:\n${compared.stdout.trimEnd().replace(/^/gm, " ")}`,
|
|
882
|
+
);
|
|
883
|
+
return finish(
|
|
884
|
+
{ work, out, inputs },
|
|
885
|
+
drawn,
|
|
886
|
+
rulesAsExpected(now, null, expected) && compared.status === 0,
|
|
887
|
+
renderer,
|
|
888
|
+
);
|
|
889
|
+
});
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
try {
|
|
893
|
+
const { help, command, input, flags } = parseArgs(process.argv.slice(2));
|
|
894
|
+
if (help) {
|
|
895
|
+
console.log(help);
|
|
896
|
+
process.exit(0);
|
|
897
|
+
}
|
|
898
|
+
process.exit(await COMMANDS[command].run(input, flags, await rendererFor(command, flags)));
|
|
899
|
+
} catch (e) {
|
|
900
|
+
if (!(e instanceof CannotFinish)) throw e;
|
|
901
|
+
console.error(e.message);
|
|
902
|
+
process.exit(2);
|
|
903
|
+
}
|