@olierjs/fugue-cli 0.1.0 → 0.1.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +54 -2
- package/THIRD_PARTY_NOTICES +52 -1
- 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 +2 -1
- package/skills/fugue-show-me/SKILL.md +44 -0
- package/skills/fugue-show-me/assets/diagram.css +77 -0
- package/skills/fugue-show-me/references/pele.md +68 -0
- package/skills/fugue-show-me/scripts/render-html.mjs +116 -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,122 @@
|
|
|
1
|
+
import { isRecord, stateKey } from "./lib.mjs";
|
|
2
|
+
import { SEP, edgeId } from "./chart-parts.mjs";
|
|
3
|
+
import { badOf } from "./bug-states.mjs";
|
|
4
|
+
|
|
5
|
+
const holder = (o, p) => p.slice(0, -1).reduce((x, k) => (isRecord(x?.[k]) ? x[k] : null), o);
|
|
6
|
+
function omit(vars, paths) {
|
|
7
|
+
if (!paths.length) return vars;
|
|
8
|
+
const out = structuredClone(vars);
|
|
9
|
+
for (const p of paths) {
|
|
10
|
+
const o = holder(out, p);
|
|
11
|
+
if (o) delete o[p.at(-1)];
|
|
12
|
+
}
|
|
13
|
+
return out;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function replaySecond(
|
|
17
|
+
trace,
|
|
18
|
+
{ main, lacks, extra, second, hiddenStep, readerPath, mainActions, secondRules, rawSecondTraces },
|
|
19
|
+
) {
|
|
20
|
+
if (!second) return null;
|
|
21
|
+
const shared = (vars) => stateKey(omit(vars, lacks));
|
|
22
|
+
const sharedOf = (n) => stateKey(omit(n.vars, extra));
|
|
23
|
+
const out = new Map();
|
|
24
|
+
for (const e of [...second.graph.edges, ...second.graph.loops]) {
|
|
25
|
+
if (!out.has(e.from)) out.set(e.from, []);
|
|
26
|
+
out.get(e.from).push(e);
|
|
27
|
+
}
|
|
28
|
+
const actions = new Set([...second.graph.edges, ...second.graph.loops].map((e) => e.action));
|
|
29
|
+
const nodeOf = (id) => second.graph.nodes.get(id);
|
|
30
|
+
const screen = new Map(
|
|
31
|
+
[...second.graph.nodes.values()].map((n) => [n.id, second.customer(n.vars)]),
|
|
32
|
+
);
|
|
33
|
+
const words = (e) =>
|
|
34
|
+
second.step(e.action, nodeOf(e.from).vars, nodeOf(e.to).vars, "customer") || e.action;
|
|
35
|
+
const settle = (ids) => {
|
|
36
|
+
const all = new Set(ids);
|
|
37
|
+
for (const id of all)
|
|
38
|
+
for (const e of out.get(id) ?? []) {
|
|
39
|
+
const sameLabel = screen.get(e.to) === screen.get(id);
|
|
40
|
+
if (
|
|
41
|
+
!mainActions.has(e.action) &&
|
|
42
|
+
hiddenStep(trace.invariant, sameLabel, e.action, nodeOf(id).vars, nodeOf(e.to).vars)
|
|
43
|
+
)
|
|
44
|
+
all.add(e.to);
|
|
45
|
+
}
|
|
46
|
+
return all;
|
|
47
|
+
};
|
|
48
|
+
const seen = readerPath(trace, main, main.customer, "customer");
|
|
49
|
+
const shownAt = new Map(seen.map((p, j) => [p.modelStep, j]));
|
|
50
|
+
let here = settle(
|
|
51
|
+
[...second.graph.nodes.values()]
|
|
52
|
+
.filter((n) => n.init && screen.get(n.id) === seen[0].key)
|
|
53
|
+
.map((n) => n.id),
|
|
54
|
+
);
|
|
55
|
+
if (here.size === 0) return { reason: "starts in another state" };
|
|
56
|
+
const passed = new Set(here);
|
|
57
|
+
let j = 0;
|
|
58
|
+
for (let i = 1; i < trace.steps.length; i++) {
|
|
59
|
+
const { action } = trace.steps[i];
|
|
60
|
+
const hidden = !shownAt.has(i);
|
|
61
|
+
if (!hidden) j = shownAt.get(i);
|
|
62
|
+
const want = seen[j];
|
|
63
|
+
const [before, after] = [trace.steps[i - 1].vars, trace.steps[i].vars];
|
|
64
|
+
const known = actions.has(action);
|
|
65
|
+
if (!known && hidden) continue;
|
|
66
|
+
const moved = shared(before) !== shared(after);
|
|
67
|
+
const steps = (keep) => [...here].flatMap((id) => (out.get(id) ?? []).filter(keep));
|
|
68
|
+
const tries = steps((e) =>
|
|
69
|
+
known ? e.action === action : moved && sharedOf(nodeOf(e.to)) === shared(after),
|
|
70
|
+
);
|
|
71
|
+
if (hidden && tries.length === 0) continue;
|
|
72
|
+
const looksSame = (e) => screen.get(e.to) === want.key && (hidden || words(e) === want.label);
|
|
73
|
+
const matched = known ? tries.filter(looksSame) : tries;
|
|
74
|
+
const same = matched.length || hidden ? matched : steps(looksSame);
|
|
75
|
+
if (same.length) {
|
|
76
|
+
here = settle(same.map((e) => e.to));
|
|
77
|
+
for (const id of here) passed.add(id);
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
const outcomes = new Map();
|
|
81
|
+
for (const e of tries) {
|
|
82
|
+
const id = `${screen.get(e.to)}${SEP}${words(e)}`;
|
|
83
|
+
outcomes.set(id, [...(outcomes.get(id) ?? []), e]);
|
|
84
|
+
}
|
|
85
|
+
const taken = [...outcomes.values()].sort((a, b) => b.length - a.length)[0]?.[0];
|
|
86
|
+
const from = main.key(before);
|
|
87
|
+
const said = hidden ? main.step(action, before, after, "customer") || action : want.label;
|
|
88
|
+
const at = { modelStep: i, said, from, hidden };
|
|
89
|
+
if (!taken)
|
|
90
|
+
return {
|
|
91
|
+
...at,
|
|
92
|
+
key: from,
|
|
93
|
+
label: main.step(action, before, after) || action,
|
|
94
|
+
disabled: true,
|
|
95
|
+
};
|
|
96
|
+
const [b, a] = [nodeOf(taken.from).vars, nodeOf(taken.to).vars];
|
|
97
|
+
return {
|
|
98
|
+
...at,
|
|
99
|
+
key: second.key(a),
|
|
100
|
+
label: second.step(taken.action, b, a) || taken.action,
|
|
101
|
+
own: words(taken),
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
const bad = badOf(rawSecondTraces, trace.invariant);
|
|
105
|
+
const checked = secondRules.has(trace.invariant);
|
|
106
|
+
const ruleHolds = checked && ![...passed].some((id) => bad.has(stateKey(nodeOf(id).vars)));
|
|
107
|
+
return { sameEnd: true, checked, ruleHolds };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export function divergence(s, path, n, { inSecond, modelEdges }) {
|
|
111
|
+
if (s?.key === undefined) return s;
|
|
112
|
+
const step = path.findLastIndex((p) => p.modelStep < s.modelStep) + (s.hidden ? 0 : 1);
|
|
113
|
+
const plain = edgeId(s.from, s.key);
|
|
114
|
+
if (s.disabled) {
|
|
115
|
+
const text = inSecond(`${s.label} cannot happen at step ${step}`);
|
|
116
|
+
const edge = `${plain}${SEP}cannot${SEP}second`;
|
|
117
|
+
return { ...s, step, text, overview: `${text} of bug ${n}`, edge, apart: true };
|
|
118
|
+
}
|
|
119
|
+
const text = inSecond(s.label);
|
|
120
|
+
const drawn = [s.label, text].some((l) => modelEdges.get(plain)?.labels.has(l));
|
|
121
|
+
return { ...s, step, text, overview: text, edge: drawn ? plain : `${plain}${SEP}second` };
|
|
122
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import process from "node:process";
|
|
5
|
+
|
|
6
|
+
const imports = (text, dir) =>
|
|
7
|
+
[...text.matchAll(/\b(?:from|import)\s*\(?\s*["'](\.{1,2}\/[^"']+)["']/g)].map((m) =>
|
|
8
|
+
path.resolve(dir, m[1]),
|
|
9
|
+
);
|
|
10
|
+
|
|
11
|
+
const DEPENDENCIES = {
|
|
12
|
+
".tla": (text, dir) =>
|
|
13
|
+
[
|
|
14
|
+
...text
|
|
15
|
+
.replace(/\(\*[^]*?\*\)/g, " ")
|
|
16
|
+
.replace(/\\\*.*/g, " ")
|
|
17
|
+
.matchAll(/\b(?:EXTENDS|INSTANCE)\s+(\w+(?:\s*,\s*\w+)*)/g),
|
|
18
|
+
].flatMap((m) => m[1].split(/\s*,\s*/).map((name) => path.join(dir, `${name}.tla`))),
|
|
19
|
+
".mjs": imports,
|
|
20
|
+
".js": imports,
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export const copyPath = (root, file, paths = path) => {
|
|
24
|
+
const absolute = paths.resolve(file);
|
|
25
|
+
const pathRoot = paths.parse(absolute).root;
|
|
26
|
+
return paths.join(root, pathRoot.replace(/\W+/g, ""), paths.relative(pathRoot, absolute));
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
const contentOf = (file) => {
|
|
30
|
+
try {
|
|
31
|
+
return fs.readFileSync(file, "base64");
|
|
32
|
+
} catch {
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
export class Inputs {
|
|
38
|
+
#root = fs.mkdtempSync(path.join(os.tmpdir(), "statechart-inputs-"));
|
|
39
|
+
#files = new Set();
|
|
40
|
+
|
|
41
|
+
constructor() {
|
|
42
|
+
process.on("exit", () => fs.rmSync(this.#root, { recursive: true, force: true }));
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
add(files) {
|
|
46
|
+
for (const file of files) if (file) this.#take(path.resolve(file), true);
|
|
47
|
+
return this;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
copyOf(file) {
|
|
51
|
+
return copyPath(this.#root, file);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
changed() {
|
|
55
|
+
return [...this.#files].find((file) => contentOf(file) !== contentOf(this.copyOf(file)));
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
#take(file, listed) {
|
|
59
|
+
if (this.#files.has(file)) return;
|
|
60
|
+
let content;
|
|
61
|
+
try {
|
|
62
|
+
content = fs.readFileSync(file);
|
|
63
|
+
} catch {
|
|
64
|
+
if (listed) this.#files.add(file);
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
this.#files.add(file);
|
|
68
|
+
const copy = this.copyOf(file);
|
|
69
|
+
fs.mkdirSync(path.dirname(copy), { recursive: true });
|
|
70
|
+
fs.writeFileSync(copy, content);
|
|
71
|
+
const found = DEPENDENCIES[path.extname(file)]?.(content.toString("utf8"), path.dirname(file));
|
|
72
|
+
for (const dependency of found ?? []) this.#take(dependency, false);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { BROKEN_MARK, PRIMES_OR_UNCHANGED, tlaVariables } from "./lib.mjs";
|
|
4
|
+
|
|
5
|
+
export const withoutComments = (spec) =>
|
|
6
|
+
fs
|
|
7
|
+
.readFileSync(spec, "utf8")
|
|
8
|
+
.replace(/\(\*[^]*?\*\)/g, " ")
|
|
9
|
+
.replace(/\\\*.*/g, " ");
|
|
10
|
+
|
|
11
|
+
function specVariables(spec, seen) {
|
|
12
|
+
const text = withoutComments(spec);
|
|
13
|
+
const extended = /\bEXTENDS\s+(\w+(?:\s*,\s*\w+)*)/.exec(text)?.[1].split(/\s*,\s*/) ?? [];
|
|
14
|
+
return [
|
|
15
|
+
...extended.flatMap((m) => {
|
|
16
|
+
const file = path.join(path.dirname(spec), `${m}.tla`);
|
|
17
|
+
if (seen.has(m) || !fs.existsSync(file)) return [];
|
|
18
|
+
seen.add(m);
|
|
19
|
+
return specVariables(file, seen);
|
|
20
|
+
}),
|
|
21
|
+
...tlaVariables(text),
|
|
22
|
+
];
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export const RULES_INVARIANT = "StatechartRules";
|
|
26
|
+
|
|
27
|
+
export function writeRulesModule(spec, rules, dir) {
|
|
28
|
+
const name = path.basename(spec, ".tla");
|
|
29
|
+
const file = path.join(dir, `${name}_rules.tla`);
|
|
30
|
+
const fields = specVariables(spec, new Set([name])).map((v) => `${v} |-> ${v}`);
|
|
31
|
+
const broken = rules.map((r) => `(IF ${r} THEN {} ELSE {"${r}"})`).join(" \\cup ");
|
|
32
|
+
fs.writeFileSync(
|
|
33
|
+
file,
|
|
34
|
+
`---- MODULE ${name}_rules ----
|
|
35
|
+
EXTENDS ${name}
|
|
36
|
+
StatechartTLC == INSTANCE TLC
|
|
37
|
+
StatechartBroken == ${broken}
|
|
38
|
+
${RULES_INVARIANT} == StatechartBroken = {} \\/ StatechartTLC!PrintT(<<"${BROKEN_MARK}", [${fields.join(", ")}], StatechartBroken>>)
|
|
39
|
+
====
|
|
40
|
+
`,
|
|
41
|
+
);
|
|
42
|
+
return file;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export const SCAFFOLD = (name, spec) => {
|
|
46
|
+
const text = withoutComments(spec);
|
|
47
|
+
const vars = tlaVariables(text);
|
|
48
|
+
const primedDefinitions = [
|
|
49
|
+
...new Set(
|
|
50
|
+
text
|
|
51
|
+
.split(/\n(?=\S)/)
|
|
52
|
+
.map((d) => /^(\w+)\s*(?:\([^)]*\))?\s*==([^]*)/.exec(d))
|
|
53
|
+
.filter((d) => d && PRIMES_OR_UNCHANGED.test(d[2]))
|
|
54
|
+
.map((d) => d[1]),
|
|
55
|
+
),
|
|
56
|
+
].sort();
|
|
57
|
+
return `// Chart labels for ${name}, for statechart.mjs. Write labels in the words of the person who reads
|
|
58
|
+
// the chart, not in variable or action names.
|
|
59
|
+
// v, before, and after map each variable to its value. Strings, numbers, and booleans keep their kind.
|
|
60
|
+
// Sequences and tuples, including functions over 1..n, read from 1 as in TLA+, so v.seat[1] is the
|
|
61
|
+
// first item. Reading index 0 stops the run. length, array methods, and spread still work. A
|
|
62
|
+
// method's index, such as indexOf or the i in map((x, i) => ...), still counts from 0.
|
|
63
|
+
// Sets become arrays. Records and other functions become objects.
|
|
64
|
+
// Variables: ${vars.join(", ")}.
|
|
65
|
+
|
|
66
|
+
// The actions that the reader takes, by action name, when the chart separates what the reader does
|
|
67
|
+
// from what happens around the reader. facts.settled is true when only these are enabled. With
|
|
68
|
+
// --cover, the overview keeps only the shortest path to each settled state, plus every bug path.
|
|
69
|
+
// Leave it empty when every action counts the same.
|
|
70
|
+
export const readerActions = [];
|
|
71
|
+
|
|
72
|
+
// The label for one model state. facts.enabled is the set of actions the state enables,
|
|
73
|
+
// including steps that change no variable. A label "context: progress", such as
|
|
74
|
+
// "Primary down: write queued", puts the states of one context in one box, and can draw the
|
|
75
|
+
// overview as two parallel regions. Use that form only when the context and the progress change
|
|
76
|
+
// independently. A label without ": " draws each state on its own.
|
|
77
|
+
// Do not claim a result that a pending step can still change, such as "write lost" while a
|
|
78
|
+
// retry is still queued.
|
|
79
|
+
export function state(v, facts) {
|
|
80
|
+
return JSON.stringify(v);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// The names of the two parallel regions, when the overview draws them: contexts, then progress.
|
|
84
|
+
// export const regionNames = ["Primary", "Write"];
|
|
85
|
+
|
|
86
|
+
// The group of one model state, in the reader's words, such as "Order shipped". The chart fills
|
|
87
|
+
// the states of one group with one color, and the legend names the groups. TLC still decides
|
|
88
|
+
// which states break a rule, and draws them with a thick red border on top of the group color.
|
|
89
|
+
// groupColors gives a group a light color. Groups without one take a palette color.
|
|
90
|
+
// export function group(v, facts) {}
|
|
91
|
+
// export const groupColors = { "Order shipped": "#d1f2eb" };
|
|
92
|
+
|
|
93
|
+
// The label for one step. A step that leaves the reader in the same state is not drawn,
|
|
94
|
+
// unless the reader takes it on a bug path. "" draws no label, and a numbered bug step shows the action.
|
|
95
|
+
// action is the action's name without its parameters, so compare before and after to tell which
|
|
96
|
+
// process or item the step changed.
|
|
97
|
+
// STEPS lists every definition that changes a variable. TLC names no step after one that another
|
|
98
|
+
// action uses in its /\\ list, such as Log in Save == Log /\\ saved' = TRUE, so delete its entry.
|
|
99
|
+
const STEPS = {
|
|
100
|
+
${primedDefinitions.map((a) => `\t${a}: ${JSON.stringify(a)},`).join("\n")}
|
|
101
|
+
};
|
|
102
|
+
export function step(action, before, after) {
|
|
103
|
+
return STEPS[action] ?? action;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// When the step can happen, in plain words, such as "after the lease expires". The chart shows
|
|
107
|
+
// "step [words]". "" adds nothing.
|
|
108
|
+
// export function guard(action, before, after) {}
|
|
109
|
+
|
|
110
|
+
// For a timer step, how many seconds the code waits, from the constant in the code. The chart
|
|
111
|
+
// shows "after N s, step". Return nothing for other steps.
|
|
112
|
+
// export function timer(action, before, after) {}
|
|
113
|
+
|
|
114
|
+
// Why a bad state is bad, in the reader's words, such as "a worker crashed after it took the job,
|
|
115
|
+
// and no other worker can take it". No variable, function, or event names.
|
|
116
|
+
// Counterexamples that break one rule with one cause are one bug, so two bugs that end in the
|
|
117
|
+
// same state stay apart only if their causes differ. Return null to group by the end state.
|
|
118
|
+
// export function cause(v, facts, invariant) {}
|
|
119
|
+
|
|
120
|
+
// The developer's words for one model state. With it, each bug is also drawn through these states.
|
|
121
|
+
// export function detail(v, facts) {}
|
|
122
|
+
|
|
123
|
+
// The legend's words for each status, in place of the defaults. Group entries keep their names.
|
|
124
|
+
// export const legend = { works: "Works on main", wrong: "Wrong on main", fixed: "Fixed in the open PR" };
|
|
125
|
+
`;
|
|
126
|
+
};
|