Skills › Visual Explainer › visual-explainer › machine.mjs
machine.mjs
428 lines · plugins/visual-explainer/kit/machine.mjs at 9d5f880
1// A state machine read from code: the <script type="text/plain" model> child of a <ve-flow> (references/machines.md).
2// The model runs in a child process, so the render stays synchronous, a reducer edited since the last render is read fresh
3// (a long-lived host would keep the first import of it), and a step that never returns is cut off by the timeout.
4import { spawnSync } from "node:child_process";
5import { readFileSync, realpathSync } from "node:fs";
6import { fileURLToPath, pathToFileURL } from "node:url";
7import { checkAt, fileReader } from "./files.mjs";
8import { esc, rawText } from "./html.mjs";
10const IMPORT = /^[ \t]*import\s+([\s\S]+?)\s+from\s*(["'])([^"'\n]+)\2[ \t]*;?[ \t]*(?:\/\/.*)?$/gm;
11// the model runs as a data: URL; name it for the reader instead of printing the encoded source
12const plain = (msg) => msg.replace(/"?data:text\/javascript,[^"\s]*"?/g, "the model script");
13const isObj = (v) => v !== null && typeof v === "object" && !Array.isArray(v);
14// states are compared as JSON, so they hold only what JSON keeps
15const isData = (v) => v === null || ["string", "boolean"].includes(typeof v) || Number.isFinite(v) || (Array.isArray(v) ? v.every(isData) : isObj(v) && Object.getPrototypeOf(v) === Object.prototype && Object.values(v).every(isData));
16// the same state written with its keys in another order is the same state
17const stable = (v) => (Array.isArray(v) ? `[${v.map(stable).join(",")}]` : isObj(v) ? `{${Object.keys(v).sort().map((k) => `${JSON.stringify(k)}:${stable(v[k])}`).join(",")}}` : JSON.stringify(v));
18const isId = (v) => typeof v === "string" && v !== "";
19/** What a state() value draws: flat (an id), regions (region → id), or null. */
20const formOf = (id) => (isId(id) ? "flat" : isObj(id) && Object.keys(id).length && Object.values(id).every(isId) ? "regions" : null);
21const message = (e) => String(e?.message ?? e).split("\n")[0];
22// Node 22.18+ strips TypeScript types by default; 22.6–22.17 need the flag
23const [major, minor] = process.versions.node.split(".").map(Number);
24const STRIP = !process.features.typescript && (major > 22 || (major === 22 && minor >= 6)) ? ["--experimental-strip-types"] : [];
26/** Splits a model script into its imports (paths as written, from the repo root) and the rest, which keeps its line count. */
27function parseModel(text) {
28 const imports = [...text.matchAll(IMPORT)].map((m) => ({ clause: m[1], path: m[3] }));
29 return { imports, body: text.replace(IMPORT, (m) => m.replace(/[^\n]/g, "")) };
30}
32/** Loads, checks, and walks a model: { errors } when it is not a model or the walk fails, else { errors: [], events, rules, form, walk, proof }.
33 * events: [{ name, at? }] (at marks a hand-written step); rules: names; form: flat | regions;
34 * walk: see walk(); proof: { kind: "code" | "model", files } for the caption ("walked from src/player.ts" or "walked from a model of mixer.ts").
35 * One timeout covers loading and walking. */
36export function loadModel({ text, root, timeoutMs = 10_000, cap = 2000 }) {
37 const realRoot = realpathSync(root);
38 const read = fileReader(realRoot);
39 const { imports, body } = parseModel(text);
40 const errors = [];
41 const lines = imports.map(({ clause, path }) => {
42 const r = read(path);
43 if (r.error) errors.push(`import "${path}": ${r.error}`);
44 return `import ${clause} from ${JSON.stringify(r.abs && pathToFileURL(r.abs).href)};`;
45 });
46 if (errors.length) return { errors };
48 const input = `${lines.join(" ")} let init, events, rules, state;\n${body}\nexport default { init, events, rules, state };`;
49 // SIGKILL, because a model's own SIGTERM handler would outlive the timeout
50 const run = spawnSync(process.execPath, [...STRIP, fileURLToPath(import.meta.url), String(cap)], { cwd: realRoot, input, timeout: timeoutMs, killSignal: "SIGKILL", encoding: "utf8", maxBuffer: 64 << 20 });
51 if (run.error?.code === "ETIMEDOUT") return { errors: [`the model did not finish in ${timeoutMs / 1000} s; a step that never returns, or an import that waits on I/O`] };
52 if (run.error) return { errors: [`the model could not run: ${run.error.message}`] };
53 // Node exits 13 when a top-level await never settles; its warning points at the kit's own line, not the model's
54 if (run.status === 13) return { errors: ["the model waits on an await that never settles"] };
55 if (!run.stdout) return { errors: [`the model crashed (exit ${run.status ?? run.signal}): ${plain(run.stderr.split("\n").map((l) => l.trim()).filter((l) => l && !/^\^+$/.test(l)).at(-1) ?? "no message")}`] };
56 let out;
57 try { out = JSON.parse(run.stdout); } catch { return { errors: ["the model wrote to stdout itself; log with console.error"] }; }
58 // name files by their path from the root, as the page does
59 out.errors = out.errors.map((e) => e.replaceAll(`${pathToFileURL(realRoot).href}/`, "").replaceAll(`${realRoot}/`, ""));
61 const hand = out.events.filter((e) => e.at);
62 if (!imports.length) for (const e of out.events.filter((e) => !e.at)) out.errors.push(`event "${e.name}" is a function, but the model imports no code; import the step from the repo, or write { when, then, at } citing the code it copies`);
63 for (const e of hand) {
64 const r = /:\d+$/.test(e.at) ? checkAt(e.at, read) : { error: `at="${e.at}"; write at:"path:line"` };
65 if (r.error) out.errors.push(`event "${e.name}": ${r.error}`);
66 }
67 if (out.errors.length) return { errors: out.errors };
68 const proof = hand.length ? { kind: "model", files: [...new Set(hand.map((e) => e.at.split(":")[0]))] } : { kind: "code", files: imports.map((i) => i.path) };
69 return { ...out, proof };
70}
72function loadError(e) {
73 const msg = plain(message(e));
74 if (e?.code === "ERR_MODULE_NOT_FOUND") return `an import cannot be found: ${msg}. Node runs .ts files by stripping their types, so every relative import needs the file's real extension (.ts, not .js), path aliases do not resolve, and packages must be installed under the root; fix the import, or write a hand model (when, then, at)`;
75 // Node puts the file and line of unstrippable syntax on the first line of the stack
76 if (e?.code === "ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX") return `${/:\d+$/.test(e.stack?.split("\n")[0]) ? e.stack.split("\n")[0] : "the code"} uses TypeScript that Node cannot strip (an enum, a namespace, or parameter properties): ${msg}; write a hand model (when, then, at)`;
77 if (e?.code === "ERR_UNKNOWN_FILE_EXTENSION") return `Node ${process.version} cannot import ${msg.match(/"([^"]+)"/)?.[1] ?? "a .ts file"}; use Node 22.6 or later, or write a hand model (when, then, at)`;
78 const declared = msg.match(/^Identifier '(init|events|rules|state)' has already been declared/)?.[1];
79 if (declared) return `the model script declares ${declared}; write ${declared} = …, not const or let`;
80 if (e instanceof SyntaxError) return `the model script does not parse: ${msg}`;
81 return `the model threw while loading: ${msg}`;
82}
84/** Walks a checked model breadth-first from init: every event at every state, at most cap distinct states (compared as
85 * stable JSON). It returns { error } or, by drawn id (state(): an id, or the JSON of a regions value):
86 * states: drawn ids in the order reached · depths: { id: fewest events from init }
87 * edges: [{ from, to, events, depth }] between different drawn ids, events merged; depth: fewest events to first take it
88 * breaks: { rule: drawn ids where holds is false } · paths: { rule: the shortest [{ event, to }] from init to a break }
89 * first: the rule that breaks in the fewest steps, or null · mayBreak: ids from which a break is reachable, not breaking
90 * deadEnds: ids of states no event leaves · neverFired: events that change no reachable state
91 * counts: { states, full, edges, broken, rules, steps } for the Page line (steps: to the first break, or null) */
92function walk({ init, events, rules = {}, state }, { cap = 2000 } = {}) {
93 const NONE = Symbol("none");
94 const steps = Object.entries(events).map(([name, ev]) => [name, typeof ev === "function" ? ev : (s) => (ev.when && !ev.when(s) ? NONE : ev.then(s))]);
95 const form = formOf(state(init));
96 const nodes = new Map(); // full key → { s, id, from, event, next: keys }
97 const add = (s, from, event, depth) => {
98 const v = state(s);
99 if (formOf(v) !== form) throw new Error(`state() returns ${JSON.stringify(v)} at ${stable(s)}, but a ${form} value at init; return one form for every state`);
100 nodes.set(stable(s), { s, id: typeof v === "string" ? v : stable(v), from, event, depth, next: new Set() });
101 };
102 const edges = new Map(), fired = new Set();
103 try {
104 add(init, null, null, 0);
105 for (const [key, node] of nodes) { // a Map iterates entries added during the loop, so this is the BFS queue
106 for (const [name, step] of steps) {
107 let next;
108 // a step that changes its input and returns it would rewrite a state already walked; states are plain data
109 try { next = step(structuredClone(node.s)); } catch (e) { return { error: `event "${name}" throws at ${node.id}: ${message(e)}` }; }
110 if (next === NONE) continue;
111 if (!isData(next)) return { error: `event "${name}" at ${node.id} returns ${String(next)}, which is not plain data` };
112 const k = stable(next);
113 if (k === key) continue;
114 fired.add(name);
115 node.next.add(k);
116 if (!nodes.has(k)) {
117 if (nodes.size >= cap) return { error: `the walk has more than ${cap} states; model less (fewer events, or fewer fields in the state)` };
118 add(next, key, name, node.depth + 1);
119 }
120 const to = nodes.get(k).id, pair = `${node.id}\n${to}`;
121 if (to === node.id) continue;
122 if (!edges.has(pair)) edges.set(pair, { from: node.id, to, events: [], depth: node.depth + 1 });
123 if (!edges.get(pair).events.includes(name)) edges.get(pair).events.push(name);
124 }
125 }
126 } catch (e) {
127 return { error: message(e) };
128 }
130 const uniq = (xs) => [...new Set(xs)];
131 // keyed by rule names and state ids, so no prototype: a state named constructor is not Object's
132 const breaks = Object.create(null), paths = Object.create(null), broken = new Set();
133 for (const [rule, { holds }] of Object.entries(rules)) {
134 const bad = [];
135 for (const [key, n] of nodes) {
136 let ok;
137 try { ok = holds(n.s); } catch (e) { return { error: `rule "${rule}" throws at ${n.id}: ${message(e)}` }; }
138 if (!ok) bad.push(key);
139 }
140 breaks[rule] = uniq(bad.map((k) => nodes.get(k).id));
141 bad.forEach((k) => broken.add(k));
142 if (!bad.length) continue;
143 // nodes are in BFS order, so the first breaking state is the nearest, and its parents give the shortest path
144 const path = [];
145 for (let n = nodes.get(bad[0]); n.from !== null; n = nodes.get(n.from)) path.unshift({ event: n.event, to: n.id });
146 paths[rule] = path;
147 }
148 // reverse reachability: out from every breaking state along the steps backwards
149 const prev = new Map([...nodes.keys()].map((k) => [k, []]));
150 for (const [k, n] of nodes) for (const t of n.next) prev.get(t).push(k);
151 const reach = new Set(broken), queue = [...broken];
152 for (const k of queue) for (const p of prev.get(k)) if (!reach.has(p)) { reach.add(p); queue.push(p); }
153 const breakIds = new Set([...broken].map((k) => nodes.get(k).id));
154 const first = Object.keys(paths).reduce((a, r) => (a === null || paths[r].length < paths[a].length ? r : a), null);
155 const states = uniq([...nodes.values()].map((n) => n.id));
156 const depths = Object.create(null);
157 for (const n of nodes.values()) depths[n.id] ??= n.depth; // BFS order, so the first state drawn as an id is its nearest
158 return {
159 states,
160 depths,
161 edges: [...edges.values()],
162 breaks,
163 paths,
164 first,
165 mayBreak: uniq([...nodes].filter(([k]) => reach.has(k)).map(([, n]) => n.id)).filter((id) => !breakIds.has(id)),
166 deadEnds: uniq([...nodes.values()].filter((n) => !n.next.size).map((n) => n.id)),
167 neverFired: steps.map(([name]) => name).filter((name) => !fired.has(name)),
168 counts: { states: states.length, full: nodes.size, edges: edges.size, broken: Object.keys(paths).length, rules: Object.keys(rules).length, steps: first === null ? null : paths[first].length },
169 };
170}
172export const plural = (n, one) => `${n} ${n === 1 ? one : `${one}s`}`;
173// this many states the walk reached but nobody drew is a chart to draw with auto, not a list of nodes to add
174const FLOOD = 6;
175const flood = (w, k, at) => `${at}: the walk has ${w.states.length} states and ${w.edges.length} arrows, and ${k} are not drawn; set auto, or model less`;
177/** The walk as drawn: each drawn id's depth, the arrows, the marks, and the shortest path to the first broken rule.
178 * drawn is null for auto, which draws every state the walk reached. */
179function flatView(w, drawn, { at, where }) {
180 if (drawn) {
181 const undrawn = w.states.filter((id) => !drawn.has(id));
182 const errors = [
183 ...(undrawn.length >= FLOOD ? [flood(w, undrawn.length, at)] : undrawn.map((id) => `${at}: the walk reached ${id}, which is not drawn; add <ve-node id="${id}">`)),
184 ...[...drawn].filter(([id]) => !w.states.includes(id)).map(([id, k]) => `${where(k)}: ${id} is drawn, but no path from init reaches it`),
185 ];
186 if (errors.length) return { errors };
187 }
188 const path = w.first !== null ? w.paths[w.first] : [];
189 const along = [w.states[0], ...path.map((p) => p.to)];
190 return {
191 depths: new Map(w.states.map((id) => [id, w.depths[id]])), edges: w.edges, path,
192 breaking: new Set(Object.values(w.breaks).flat()), may: new Set(w.mayBreak),
193 onPath: new Set(along), pathEdges: new Set(path.map((p, i) => `${along[i]}\n${p.to}`)),
194 };
195}
197/** The same, per region: a state is an object of part → value, a value is the id of a node in its part's <ve-region>, and
198 * each step draws one arrow in every part it changes. A step that every node of a region takes to the same node lifts to
199 * one arrow from the region (its from is the region id). Marks go to the parts of each marked state. regions is null for
200 * auto, which takes them from the walk. */
201function regionView(w, drawn, regions, { at, where }) {
202 const states = new Map(w.states.map((id) => [id, JSON.parse(id)]));
203 const keys = Object.keys(states.get(w.states[0]));
204 const errors = [];
205 if (regions) {
206 for (const [r, { k }] of regions) if (!keys.includes(r)) errors.push(`${where(k)}: region "${r}" is not a part of state() (${keys.join(", ")})`);
207 for (const r of keys) if (!regions.has(r)) errors.push(`${at}: state() has a part "${r}" with no <ve-region id="${r}">`);
208 const inRegion = new Set([...regions.values()].flatMap((g) => g.ids));
209 for (const [id, k] of drawn) if (!inRegion.has(id)) errors.push(`${where(k)}: ${id} is in no <ve-region>; state() returns parts, so every node belongs to one`);
210 if (errors.length) return { errors };
211 }
212 const depths = new Map(), part = new Map();
213 for (const [id, s] of states) {
214 if (Object.keys(s).join() !== keys.join()) return { errors: [`${at}: state() returns the parts ${Object.keys(s).join(", ")} at ${id}, but ${keys.join(", ")} at init; return the same parts for every state`] };
215 for (const r of keys) {
216 if (part.has(s[r]) && part.get(s[r]) !== r) return { errors: [`${at}: "${s[r]}" is a value of both "${part.get(s[r])}" and "${r}"; a value names its node, so give each part its own`] };
217 part.set(s[r], r);
218 if (!depths.has(s[r])) depths.set(s[r], w.depths[id]); // the walk's order is by depth, so the first is the nearest
219 }
220 }
221 if (regions) {
222 const undrawn = [...part].filter(([v, r]) => !regions.get(r).ids.includes(v));
223 if (undrawn.length >= FLOOD) errors.push(flood(w, undrawn.length, at));
224 else for (const [v, r] of undrawn) errors.push(`${at}: the walk reached ${r} · ${v}, which is not drawn in region "${r}"; add <ve-node id="${v}"> inside it`);
225 for (const [r, g] of regions) for (const id of g.ids) if (part.get(id) !== r) errors.push(`${where(drawn.get(id))}: ${id} is drawn in region "${r}", but no path from init reaches it there`);
226 if (errors.length) return { errors };
227 } else regions = new Map(keys.map((r) => [r, { ids: [...part].filter(([, p]) => p === r).map(([v]) => v) }]));
229 const arrows = new Map();
230 const add = (from, to, events, depth) => {
231 // the walk's arrows come in the order it reached them, so the first depth an arrow gets is its least
232 const key = `${from}\n${to}`;
233 if (!arrows.has(key)) arrows.set(key, { from, to, events: [], depth });
234 const e = arrows.get(key);
235 for (const ev of events) if (!e.events.includes(ev)) e.events.push(ev);
236 };
237 for (const e of w.edges) for (const r of keys) if (states.get(e.from)[r] !== states.get(e.to)[r]) add(states.get(e.from)[r], states.get(e.to)[r], e.events, e.depth);
238 const path = w.first !== null ? w.paths[w.first] : [];
239 const onPath = [w.states[0], ...path.map((p) => p.to)].map((id) => states.get(id));
240 // each step of the path, per part it changes; a lifted arrow is on the path only when the path takes its event
241 const moves = onPath.slice(1).flatMap((s, i) => keys.filter((r) => s[r] !== onPath[i][r]).map((r) => ({ r, from: onPath[i][r], to: s[r], event: path[i].event })));
242 const pathEdges = new Set();
243 const lifted = [];
244 for (const [r, g] of regions) {
245 const own = [...arrows.values()].filter((e) => part.get(e.from) === r);
246 for (const ev of new Set(own.flatMap((e) => e.events))) {
247 const taking = own.filter((e) => e.events.includes(ev)), to = taking[0].to, from = new Set(taking.map((e) => e.from));
248 if (taking.length < 2 || taking.some((e) => e.to !== to) || g.ids.some((id) => id !== to && !from.has(id))) continue;
249 for (const e of taking) e.events = e.events.filter((x) => x !== ev);
250 const prior = lifted.find((e) => e.from === r && e.to === to);
251 if (prior) { prior.events.push(ev); prior.depth = Math.min(prior.depth, ...taking.map((e) => e.depth)); }
252 else lifted.push({ from: r, to, events: [ev], depth: Math.min(...taking.map((e) => e.depth)), lifted: true });
253 if (moves.some((p) => p.r === r && p.to === to && p.event === ev)) pathEdges.add(`${r}\n${to}`);
254 }
255 }
256 const partsOf = (ids) => ids.flatMap((id) => keys.map((r) => states.get(id)[r]));
257 // a plain arrow is on the path only if it still carries the event the path took (a lifted event left it above)
258 for (const p of moves) if (arrows.get(`${p.from}\n${p.to}`)?.events.includes(p.event)) pathEdges.add(`${p.from}\n${p.to}`);
259 const breaking = new Set(partsOf(Object.values(w.breaks).flat()));
260 return {
261 depths, path, breaking, pathEdges, regions,
262 edges: [...[...arrows.values()].filter((e) => e.events.length), ...lifted],
263 may: new Set(partsOf(w.mayBreak).filter((v) => !breaking.has(v))),
264 onPath: new Set(onPath.flatMap((s) => keys.map((r) => s[r]))),
265 };
266}
268/** The model loaded and walked, or { errors } that each start with the script's line (the MCP opt-in first). */
269function walked(script, { root, models, where }) {
270 const at = where(script);
271 if (!models) return { errors: [`${at}: models run only when the MCP server starts with VISUAL_EXPLAINER_RUN_MODELS=1`] };
272 const m = loadModel({ text: rawText(script), root });
273 return m.errors.length ? { errors: m.errors.map((e) => `${at}: ${e}`) } : m;
274}
276/** The machine's Page line and the "walked from" caption under its drawing. */
277function summary(m) {
278 const w = m.walk, c = w.counts;
279 const line = [
280 `machine from ${m.proof.kind === "model" ? "a model of " : ""}${m.proof.files.join(", ")}`,
281 plural(c.states, "state"),
282 c.rules && (c.broken ? `${c.broken} of ${plural(c.rules, "rule")} ${c.broken === 1 ? "breaks" : "break"} ${c.steps ? `in ${plural(c.steps, "step")}` : "at the start"}` : `${plural(c.rules, "rule")} ${c.rules === 1 ? "holds" : "hold"}`),
283 w.deadEnds.length && plural(w.deadEnds.length, "dead end"),
284 w.neverFired.length && `${w.neverFired.map((e) => `"${e}"`).join(", ")} never ${w.neverFired.length === 1 ? "fires" : "fire"}`,
285 ].filter(Boolean).join(" · ");
286 const proof = `<p class="ve-label">walked from ${m.proof.kind === "model" ? "a model of " : ""}${m.proof.files.map((f) => `<code>${esc(f)}</code>`).join(", ")}</p>`;
287 return { line, proof };
288}
290/** A <ve-flow auto>: the whole walk as Mermaid for Pele to draw, { errors } or { mermaid, bugEdges, line, proof }. */
291export function drawAuto(script, ctx) {
292 const m = walked(script, ctx);
293 if (m.errors.length) return { errors: m.errors };
294 const t = toMermaid(m.walk, m.form, ctx.where(script));
295 return t.errors ? t : { ...t, ...summary(m) };
296}
298/** A walk as a Mermaid state diagram: { mermaid, bugEdges } or { errors }. A flat state() draws one state per id, and each
299 * part of an object draws its own composite, with lifted arrows from the composite (Pele draws a concurrent `--` region as
300 * an empty box, so parts are separate). Marks are classes on the states.
301 * Pele numbers a diagram's transitions in source order, [*] first; bugEdges are the numbers of those on the bug path. */
302export function toMermaid(w, form, at = "") {
303 const v = form === "regions" ? regionView(w, null, null, { at }) : flatView(w, null, { at });
304 if (v.errors) return v;
305 const alias = new Map(), name = (key) => alias.get(key) ?? alias.set(key, `s${alias.size}`).get(key);
306 // Pele reads these as character references; a raw " ends the label
307 const text = (s) => s.replace(/\s+/g, " ").replace(/[;&"<>]/g, (c) => ({ ";": "#59;", "&": "#amp;", '"': "#quot;", "<": "#lt;", ">": "#gt;" })[c]);
308 const lines = ["stateDiagram-v2"], bugEdges = [];
309 let n = 0;
310 const go = (indent, from, to, events, lit) => { lines.push(`${indent}${from} --> ${to}${events ? ` : ${text(events.join(" · "))}` : ""}`); if (lit) bugEdges.push(n); n++; };
311 const lit = (e) => v.pathEdges.has(`${e.from}\n${e.to}`);
312 if (form === "regions") {
313 const init = JSON.parse(w.states[0]);
314 for (const [r, g] of v.regions) {
315 lines.push(` state "${text(r)}" as ${name(`region\n${r}`)} {`);
316 go(" ", "[*]", name(init[r]));
317 for (const id of g.ids) lines.push(` state "${text(id)}" as ${name(id)}`);
318 for (const e of v.edges.filter((e) => (e.lifted ? e.from === r : g.ids.includes(e.from)))) go(" ", name(e.lifted ? `region\n${r}` : e.from), name(e.to), e.events, lit(e));
319 lines.push(" }");
320 }
321 } else {
322 for (const id of v.depths.keys()) lines.push(` state "${text(id)}" as ${name(id)}`);
323 go(" ", "[*]", name(w.states[0]));
324 for (const e of v.edges) go(" ", name(e.from), name(e.to), e.events, lit(e));
325 }
326 const dead = new Set(form === "regions" ? [] : w.deadEnds), path = w.first !== null ? v.onPath : new Set();
327 for (const id of v.depths.keys()) for (const [cls, on] of [["breaks", v.breaking], ["may-break", v.may], ["bug-path", path], ["dead-end", dead]]) if (on.has(id)) lines.push(` class ${name(id)} ${cls}`);
328 return { mermaid: lines.join("\n"), bugEdges };
329}
331/** What a <ve-flow> draws from its <script model>, given its drawn nodes (id → element): { errors } or
332 * edges: <ve-edge>-like parts for flow.mjs's arrow loop · nodes: id → attributes to add (data-s and the mark flags)
333 * steps: one caption per replay step · proof: the "walked from" line under the drawing · line: the machine's Page line.
334 * The replay steps through the walk's depths (step k: what k − 1 events reach), then, when a rule breaks, one step that
335 * lights the shortest path to it. */
336export function drawModel(script, drawn, { root, models, where, regions }) {
337 const at = where(script);
338 const m = walked(script, { root, models, where });
339 if (m.errors.length) return { errors: m.errors };
340 const w = m.walk;
341 if (m.form === "flat" && regions.size) return { errors: [`${where([...regions.values()][0].k)}: a <ve-region> draws one part of state(), so state() must return an object of region → id`] };
342 const v = m.form === "flat" ? flatView(w, drawn, { at, where }) : regionView(w, drawn, regions, { at, where });
343 if (v.errors) return v;
345 const { depths, edges: arrows, breaking, may, onPath, pathEdges, path } = v;
346 const depthSteps = Math.max(...depths.values(), ...arrows.map((e) => e.depth)) + 1;
347 // a rule that already fails at init has an empty path: the last step marks init alone
348 const last = w.first !== null ? ` ${depthSteps + 1}` : "";
349 const nodes = new Map([...depths].map(([id, d]) => [id, {
350 "data-s": `${d + 1}${onPath.has(id) ? last : ""}`,
351 ...(breaking.has(id) && { breaks: "" }), ...(may.has(id) && { "may-break": "" }), ...(last && onPath.has(id) && { "bug-path": "" }),
352 }]));
353 const edges = arrows.map((e) => {
354 const lit = last && pathEdges.has(`${e.from}\n${e.to}`);
355 return { name: "script", pos: script.pos, children: [], attrs: { from: e.from, to: e.to, label: e.events.join(" · "), "data-s": `${e.depth + 1}${lit ? last : ""}`, ...(lit && { "bug-path": "" }) } };
356 });
357 const steps = [];
358 for (let k = 1, before = ""; k <= depthSteps; k++) {
359 const s = [...depths.values()].filter((d) => d < k).length, a = arrows.filter((e) => e.depth < k).length;
360 const text = `${plural(s, "state")}${a ? ` · ${plural(a, "arrow")}` : ""}`;
361 steps.push(text === before ? `${text} · nothing new` : text);
362 before = text;
363 }
364 if (last) steps.push(path.length ? `breaks “${w.first}” in ${plural(path.length, "step")}: ${path.map((p) => p.event).join(" → ")}` : `breaks “${w.first}” at the start`);
365 return { edges, nodes, steps, ...summary(m) };
366}
368/** The child: evaluates the model module from stdin and writes its checked shape as JSON, the only thing on stdout. */
369async function child() {
370 console.log = console.info = console.debug = console.error;
371 // code that writes to stdout itself (a logger) goes to stderr; the result is the only thing on the real stdout
372 const write = process.stdout.write.bind(process.stdout);
373 process.stdout.write = process.stderr.write.bind(process.stderr);
374 const out = { errors: [], events: [], rules: [], form: null };
375 const done = () => write(JSON.stringify(out), () => process.exit(0));
376 const err = (e) => out.errors.push(e);
377 let m;
378 try {
379 m = (await import(`data:text/javascript,${encodeURIComponent(readFileSync(0, "utf8"))}`)).default;
380 } catch (e) {
381 err(loadError(e));
382 return done();
383 }
384 const { init, events, rules = {}, state } = m;
386 if (init === undefined) err("the model sets no init; write init = the first state");
387 else if (!isData(init)) err("init is not plain data (objects, arrays, strings, numbers, booleans, null); the walk compares states as JSON");
389 if (!isObj(events) || !Object.keys(events).length) err('events is not an object of named events; write events = { "you press play": s => … }');
390 else for (const [name, ev] of Object.entries(events)) {
391 if (typeof ev === "function") out.events.push({ name });
392 else if (isObj(ev) && typeof ev.then === "function" && (ev.when === undefined || typeof ev.when === "function")) {
393 if (typeof ev.at === "string") out.events.push({ name, at: ev.at });
394 else err(`event "${name}" has no at:"file:line"; a hand-written step cites the code it copies`);
395 } else err(`event "${name}" is neither a function nor { when, then, at }`);
396 }
398 // a rule that holds on the state it must reject would pass on every machine, so it proves nothing
399 if (!isObj(rules)) err('rules is not an object of named rules; write rules = { "never loud while stopped": { holds, fails } }');
400 else for (const [name, r] of Object.entries(rules)) {
401 if (!isObj(r) || typeof r.holds !== "function") { err(`rule "${name}" has no holds: s => true while the rule holds`); continue; }
402 if (!("fails" in r) || !isData(r.fails)) { err(`rule "${name}" has no fails: the state, as plain data, that it must reject`); continue; }
403 let held;
404 try { held = r.holds(r.fails); } catch (e) { err(`rule "${name}" throws on its fails state: ${message(e)}`); continue; }
405 if (held) err(`rule "${name}" cannot fail; it is not a rule`);
406 else out.rules.push(name);
407 }
409 if (typeof state !== "function") err("the model sets no state; write state = s => the ve-node id that draws s");
410 else if (isData(init)) {
411 try {
412 const id = state(init);
413 out.form = formOf(id);
414 if (!out.form) err(`state(init) returned ${JSON.stringify(id)}; return a ve-node id or an object of region → id`);
415 } catch (e) {
416 err(`state(init) throws: ${message(e)}`);
417 }
418 }
419 if (!out.errors.length) {
420 const w = walk(m, { cap: +process.argv[2] });
421 if (w.error) err(w.error);
422 else out.walk = w;
423 }
424 done();
425}
427const isMain = (() => { try { return import.meta.url === pathToFileURL(realpathSync(process.argv[1])).href; } catch { return false; } })();
428if (process.argv[1] && isMain) await child();